mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-04-20 07:00:11 +00:00
WIP Building for latest.
Still Quantizing, WOrking on that next also removed prebake
This commit is contained in:
@@ -79,6 +79,8 @@ dependencies {
|
||||
transitive = false
|
||||
}
|
||||
compileOnly(libs.multiverseCore)
|
||||
compileOnly(libs.craftengine.core)
|
||||
compileOnly(libs.craftengine.bukkit)
|
||||
|
||||
// Shaded
|
||||
implementation('de.crazydev22.slimjar.helper:spigot:2.1.9')
|
||||
|
||||
2
core/plugins/Iris/cache/instance
vendored
2
core/plugins/Iris/cache/instance
vendored
@@ -1 +1 @@
|
||||
2117487583
|
||||
466077434
|
||||
@@ -24,6 +24,10 @@ import com.google.gson.JsonParser;
|
||||
import art.arcane.iris.core.IrisSettings;
|
||||
import art.arcane.iris.core.IrisWorlds;
|
||||
import art.arcane.iris.core.ServerConfigurator;
|
||||
import art.arcane.iris.core.lifecycle.WorldLifecycleService;
|
||||
import art.arcane.iris.core.runtime.TransientWorldCleanupSupport;
|
||||
import art.arcane.iris.core.runtime.WorldRuntimeControlService;
|
||||
import art.arcane.iris.core.lifecycle.WorldLifecycleStaging;
|
||||
import art.arcane.iris.core.link.IrisPapiExpansion;
|
||||
import art.arcane.iris.core.link.MultiverseCoreLink;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
@@ -78,7 +82,6 @@ import java.io.*;
|
||||
import java.lang.annotation.Annotation;
|
||||
import java.net.URI;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
@@ -98,9 +101,6 @@ public class Iris extends VolmitPlugin implements Listener {
|
||||
private static File settingsFile;
|
||||
private static final String PENDING_WORLD_DELETE_FILE = "pending-world-deletes.txt";
|
||||
private static final StackWalker DEBUG_STACK_WALKER = StackWalker.getInstance();
|
||||
private static final Map<String, ChunkGenerator> stagedRuntimeGenerators = new ConcurrentHashMap<>();
|
||||
private static final Map<String, BiomeProvider> stagedRuntimeBiomeProviders = new ConcurrentHashMap<>();
|
||||
|
||||
static {
|
||||
try {
|
||||
InstanceState.updateInstanceId();
|
||||
@@ -122,40 +122,30 @@ public class Iris extends VolmitPlugin implements Listener {
|
||||
return sender;
|
||||
}
|
||||
|
||||
public static void stageRuntimeWorldGenerator(@NotNull String worldName, @NotNull ChunkGenerator generator, @Nullable BiomeProvider biomeProvider) {
|
||||
stagedRuntimeGenerators.put(worldName, generator);
|
||||
if (biomeProvider != null) {
|
||||
stagedRuntimeBiomeProviders.put(worldName, biomeProvider);
|
||||
} else {
|
||||
stagedRuntimeBiomeProviders.remove(worldName);
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static ChunkGenerator consumeRuntimeWorldGenerator(@NotNull String worldName) {
|
||||
return stagedRuntimeGenerators.remove(worldName);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static BiomeProvider consumeRuntimeBiomeProvider(@NotNull String worldName) {
|
||||
return stagedRuntimeBiomeProviders.remove(worldName);
|
||||
}
|
||||
|
||||
public static void clearStagedRuntimeWorldGenerator(@NotNull String worldName) {
|
||||
stagedRuntimeGenerators.remove(worldName);
|
||||
stagedRuntimeBiomeProviders.remove(worldName);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public static <T> T service(Class<T> c) {
|
||||
return (T) instance.services.get(c);
|
||||
}
|
||||
|
||||
public static void callEvent(Event e) {
|
||||
Runnable dispatcher = () -> {
|
||||
try {
|
||||
Bukkit.getPluginManager().callEvent(e);
|
||||
} catch (Throwable ex) {
|
||||
reportError("Event dispatch failed for \"" + e.getEventName() + "\".", ex);
|
||||
if (ex instanceof RuntimeException runtimeException) {
|
||||
throw runtimeException;
|
||||
}
|
||||
if (ex instanceof Error error) {
|
||||
throw error;
|
||||
}
|
||||
throw new IllegalStateException(ex);
|
||||
}
|
||||
};
|
||||
if (!e.isAsynchronous()) {
|
||||
J.s(() -> Bukkit.getPluginManager().callEvent(e));
|
||||
J.s(dispatcher);
|
||||
} else {
|
||||
Bukkit.getPluginManager().callEvent(e);
|
||||
dispatcher.run();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -443,8 +433,22 @@ public class Iris extends VolmitPlugin implements Listener {
|
||||
}
|
||||
|
||||
public static void reportError(Throwable e) {
|
||||
if (e == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Bindings.capture(e);
|
||||
if (IrisSettings.get().getGeneral().isDebug()) {
|
||||
boolean debug = false;
|
||||
if (instance != null) {
|
||||
try {
|
||||
IrisSettings currentSettings = IrisSettings.settings != null ? IrisSettings.settings : IrisSettings.get();
|
||||
debug = currentSettings != null && currentSettings.getGeneral().isDebug();
|
||||
} catch (Throwable ignored) {
|
||||
debug = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
String n = e.getClass().getCanonicalName() + "-" + e.getStackTrace()[0].getClassName() + "-" + e.getStackTrace()[0].getLineNumber();
|
||||
|
||||
if (e.getCause() != null) {
|
||||
@@ -467,6 +471,25 @@ public class Iris extends VolmitPlugin implements Listener {
|
||||
}
|
||||
}
|
||||
|
||||
public static void reportError(String context, Throwable e) {
|
||||
Throwable error = e == null ? new IllegalStateException("Unknown Iris failure") : e;
|
||||
String message = context == null || context.isBlank() ? "Unhandled Iris failure." : context;
|
||||
|
||||
try {
|
||||
if (instance != null) {
|
||||
Iris.error(message);
|
||||
} else {
|
||||
System.err.println("[Iris] " + message);
|
||||
}
|
||||
} catch (Throwable inner) {
|
||||
System.err.println("[Iris] " + message);
|
||||
inner.printStackTrace(System.err);
|
||||
}
|
||||
|
||||
reportError(error);
|
||||
error.printStackTrace(System.err);
|
||||
}
|
||||
|
||||
public static void dump() {
|
||||
try {
|
||||
File fi = Iris.instance.getDataFile("dump", "td-" + new java.sql.Date(M.ms()) + ".txt");
|
||||
@@ -533,6 +556,8 @@ public class Iris extends VolmitPlugin implements Listener {
|
||||
addShutdownHook();
|
||||
processPendingStartupWorldDeletes();
|
||||
IrisToolbelt.applyPregenPerformanceProfile();
|
||||
WorldLifecycleService.get();
|
||||
WorldRuntimeControlService.get();
|
||||
|
||||
if (J.isFolia()) {
|
||||
checkForBukkitWorlds(s -> true);
|
||||
@@ -614,11 +639,10 @@ public class Iris extends VolmitPlugin implements Listener {
|
||||
Iris.error("Failed to load world " + s + "!");
|
||||
Iris.error("This server denied Bukkit.createWorld for \"" + s + "\" at the current startup phase.");
|
||||
Iris.error("Ensure Iris is loaded at STARTUP and restart after staging worlds in bukkit.yml.");
|
||||
reportError(e);
|
||||
reportError("Failed to load staged startup world \"" + s + "\".", e);
|
||||
return;
|
||||
}
|
||||
Iris.error("Failed to load world " + s + "!");
|
||||
e.printStackTrace();
|
||||
reportError("Failed to load startup world \"" + s + "\".", e);
|
||||
}
|
||||
});
|
||||
if (!deferredStartupWorlds.isEmpty()) {
|
||||
@@ -626,8 +650,7 @@ public class Iris extends VolmitPlugin implements Listener {
|
||||
Iris.warn("Bukkit.createWorld is intentionally unavailable in this startup phase. Worlds remain staged in bukkit.yml.");
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
e.printStackTrace();
|
||||
reportError(e);
|
||||
reportError("Failed while loading startup Iris worlds.", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -673,6 +696,9 @@ public class Iris extends VolmitPlugin implements Listener {
|
||||
private void processPendingStartupWorldDeletes() {
|
||||
try {
|
||||
LinkedHashMap<String, String> queue = loadPendingWorldDeleteMap();
|
||||
for (String transientStudioWorld : TransientWorldCleanupSupport.collectTransientStudioWorldNames(Bukkit.getWorldContainer())) {
|
||||
queue.putIfAbsent(transientStudioWorld.toLowerCase(Locale.ROOT), transientStudioWorld);
|
||||
}
|
||||
if (queue.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
@@ -690,20 +716,33 @@ public class Iris extends VolmitPlugin implements Listener {
|
||||
continue;
|
||||
}
|
||||
|
||||
File worldFolder = new File(Bukkit.getWorldContainer(), worldName);
|
||||
if (!worldFolder.exists()) {
|
||||
boolean foundAny = false;
|
||||
boolean deletedAll = true;
|
||||
for (String familyWorldName : TransientWorldCleanupSupport.worldFamilyNames(worldName)) {
|
||||
File worldFolder = new File(Bukkit.getWorldContainer(), familyWorldName);
|
||||
if (!worldFolder.exists()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foundAny = true;
|
||||
IO.delete(worldFolder);
|
||||
if (worldFolder.exists()) {
|
||||
deletedAll = false;
|
||||
Iris.warn("Failed to delete queued world folder \"" + familyWorldName + "\". Retrying on next startup.");
|
||||
} else {
|
||||
Iris.info("Deleted queued world folder \"" + familyWorldName + "\".");
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundAny) {
|
||||
Iris.info("Queued world deletion skipped for \"" + worldName + "\" (folder missing).");
|
||||
continue;
|
||||
}
|
||||
|
||||
IO.delete(worldFolder);
|
||||
if (worldFolder.exists()) {
|
||||
Iris.warn("Failed to delete queued world folder \"" + worldName + "\". Retrying on next startup.");
|
||||
if (!deletedAll) {
|
||||
remaining.put(worldName.toLowerCase(Locale.ROOT), worldName);
|
||||
continue;
|
||||
}
|
||||
|
||||
Iris.info("Deleted queued world folder \"" + worldName + "\".");
|
||||
}
|
||||
|
||||
writePendingWorldDeleteMap(remaining);
|
||||
@@ -952,7 +991,7 @@ public class Iris extends VolmitPlugin implements Listener {
|
||||
@Nullable
|
||||
@Override
|
||||
public BiomeProvider getDefaultBiomeProvider(@NotNull String worldName, @Nullable String id) {
|
||||
BiomeProvider stagedBiomeProvider = consumeRuntimeBiomeProvider(worldName);
|
||||
org.bukkit.generator.BiomeProvider stagedBiomeProvider = WorldLifecycleStaging.consumeBiomeProvider(worldName);
|
||||
if (stagedBiomeProvider != null) {
|
||||
Iris.debug("Using staged runtime biome provider for " + worldName);
|
||||
return stagedBiomeProvider;
|
||||
@@ -963,7 +1002,7 @@ public class Iris extends VolmitPlugin implements Listener {
|
||||
|
||||
@Override
|
||||
public ChunkGenerator getDefaultWorldGenerator(String worldName, String id) {
|
||||
ChunkGenerator stagedGenerator = consumeRuntimeWorldGenerator(worldName);
|
||||
ChunkGenerator stagedGenerator = WorldLifecycleStaging.consumeGenerator(worldName);
|
||||
if (stagedGenerator != null) {
|
||||
Iris.debug("Using staged runtime generator for " + worldName);
|
||||
return stagedGenerator;
|
||||
@@ -1029,27 +1068,81 @@ public class Iris extends VolmitPlugin implements Listener {
|
||||
|
||||
private void printPacks() {
|
||||
File packFolder = Iris.service(StudioSVC.class).getWorkspaceFolder();
|
||||
File[] packs = packFolder.listFiles(File::isDirectory);
|
||||
if (packs == null || packs.length == 0)
|
||||
List<SplashPackMetadata> packs = collectSplashPacks(packFolder);
|
||||
if (packs.isEmpty())
|
||||
return;
|
||||
Iris.info("Custom Dimensions: " + packs.length);
|
||||
for (File f : packs)
|
||||
printPack(f);
|
||||
Iris.info("Custom Dimensions: " + packs.size());
|
||||
for (SplashPackMetadata pack : packs) {
|
||||
printPack(pack);
|
||||
}
|
||||
}
|
||||
|
||||
private void printPack(File pack) {
|
||||
String dimName = pack.getName();
|
||||
String version = "???";
|
||||
try (FileReader r = new FileReader(new File(pack, "dimensions/" + dimName + ".json"))) {
|
||||
JsonObject json = JsonParser.parseReader(r).getAsJsonObject();
|
||||
if (json.has("version"))
|
||||
version = json.get("version").getAsString();
|
||||
} catch (IOException | JsonParseException ex) {
|
||||
Iris.verbose("Failed to read dimension version metadata for " + dimName + ": "
|
||||
+ ex.getClass().getSimpleName()
|
||||
+ (ex.getMessage() == null ? "" : " - " + ex.getMessage()));
|
||||
static List<SplashPackMetadata> collectSplashPacks(File packFolder) {
|
||||
if (packFolder == null || !packFolder.isDirectory()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
File[] folders = packFolder.listFiles(File::isDirectory);
|
||||
if (folders == null || folders.length == 0) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
List<SplashPackMetadata> packs = new ArrayList<>();
|
||||
for (File folder : folders) {
|
||||
SplashPackMetadata metadata = readSplashPack(folder);
|
||||
if (metadata != null) {
|
||||
packs.add(metadata);
|
||||
}
|
||||
}
|
||||
|
||||
packs.sort(Comparator.comparing(SplashPackMetadata::name));
|
||||
return packs;
|
||||
}
|
||||
|
||||
static SplashPackMetadata readSplashPack(File pack) {
|
||||
if (pack == null || !pack.isDirectory()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String dimName = pack.getName();
|
||||
File dimensionFile = new File(pack, "dimensions/" + dimName + ".json");
|
||||
if (!dimensionFile.isFile()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try (FileReader r = new FileReader(dimensionFile)) {
|
||||
JsonObject json = JsonParser.parseReader(r).getAsJsonObject();
|
||||
if (!json.has("version")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SplashPackMetadata(dimName, json.get("version").getAsString());
|
||||
} catch (IOException | JsonParseException ex) {
|
||||
reportError("Failed to read splash metadata for dimension pack \"" + dimName + "\".", ex);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void printPack(SplashPackMetadata pack) {
|
||||
Iris.info(" " + pack.name() + " v" + pack.version());
|
||||
}
|
||||
|
||||
static final class SplashPackMetadata {
|
||||
private final String name;
|
||||
private final String version;
|
||||
|
||||
SplashPackMetadata(String name, String version) {
|
||||
this.name = name;
|
||||
this.version = version;
|
||||
}
|
||||
|
||||
String name() {
|
||||
return name;
|
||||
}
|
||||
|
||||
String version() {
|
||||
return version;
|
||||
}
|
||||
Iris.info(" " + dimName + " v" + version);
|
||||
}
|
||||
|
||||
public int getIrisVersion() {
|
||||
|
||||
@@ -38,6 +38,9 @@ public enum IrisRuntimeSchedulerMode {
|
||||
if (containsIgnoreCase(bukkitName, "purpur")
|
||||
|| containsIgnoreCase(bukkitVersion, "purpur")
|
||||
|| containsIgnoreCase(serverClassName, "purpur")
|
||||
|| containsIgnoreCase(bukkitName, "canvas")
|
||||
|| containsIgnoreCase(bukkitVersion, "canvas")
|
||||
|| containsIgnoreCase(serverClassName, "canvas")
|
||||
|| containsIgnoreCase(bukkitName, "paper")
|
||||
|| containsIgnoreCase(bukkitVersion, "paper")
|
||||
|| containsIgnoreCase(serverClassName, "paper")
|
||||
|
||||
@@ -159,7 +159,6 @@ public class IrisSettings {
|
||||
public int chunkLoadTimeoutSeconds = 15;
|
||||
public int timeoutWarnIntervalMs = 500;
|
||||
public int saveIntervalMs = 120_000;
|
||||
public boolean startupNoisemapPrebake = true;
|
||||
public boolean enablePregenPerformanceProfile = true;
|
||||
public int pregenProfileNoiseCacheSize = 4_096;
|
||||
public boolean pregenProfileEnableFastCache = true;
|
||||
|
||||
@@ -24,7 +24,6 @@ import art.arcane.iris.core.loader.ResourceLoader;
|
||||
import art.arcane.iris.core.nms.INMS;
|
||||
import art.arcane.iris.core.nms.datapack.DataVersion;
|
||||
import art.arcane.iris.core.nms.datapack.IDataFixer;
|
||||
import art.arcane.iris.engine.IrisNoisemapPrebakePipeline;
|
||||
import art.arcane.iris.engine.object.*;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.volmlib.util.collection.KMap;
|
||||
@@ -77,10 +76,7 @@ public class ServerConfigurator {
|
||||
}
|
||||
|
||||
deferredInstallPending = false;
|
||||
boolean datapacksMissing = installDataPacks(true);
|
||||
if (!datapacksMissing) {
|
||||
IrisNoisemapPrebakePipeline.scheduleInstalledPacksPrebakeAsync();
|
||||
}
|
||||
installDataPacks(true);
|
||||
}
|
||||
|
||||
public static void configureIfDeferred() {
|
||||
@@ -129,8 +125,15 @@ public class ServerConfigurator {
|
||||
return new KList<File>().qadd(new File(Bukkit.getWorldContainer(), IrisSettings.get().getGeneral().forceMainWorld + "/datapacks"));
|
||||
}
|
||||
KList<File> worlds = new KList<>();
|
||||
Bukkit.getServer().getWorlds().forEach(w -> worlds.add(new File(w.getWorldFolder(), "datapacks")));
|
||||
if (worlds.isEmpty()) worlds.add(new File(Bukkit.getWorldContainer(), ServerProperties.LEVEL_NAME + "/datapacks"));
|
||||
Bukkit.getServer().getWorlds().forEach(w -> {
|
||||
File folder = resolveDatapacksFolder(w.getWorldFolder());
|
||||
if (!worlds.contains(folder)) {
|
||||
worlds.add(folder);
|
||||
}
|
||||
});
|
||||
if (worlds.isEmpty()) {
|
||||
worlds.add(new File(Bukkit.getWorldContainer(), ServerProperties.LEVEL_NAME + "/datapacks"));
|
||||
}
|
||||
return worlds;
|
||||
}
|
||||
|
||||
@@ -180,9 +183,10 @@ public class ServerConfigurator {
|
||||
Iris.verbose("Checking Data Packs...");
|
||||
}
|
||||
DimensionHeight height = new DimensionHeight(fixer);
|
||||
KList<File> folders = getDatapacksFolder();
|
||||
KList<File> baseFolders = getDatapacksFolder();
|
||||
KList<File> folders = collectInstallDatapackFolders(baseFolders, extraWorldDatapackFoldersByPack);
|
||||
if (includeExternal) {
|
||||
installExternalDataPacks(folders, extraWorldDatapackFoldersByPack);
|
||||
installExternalDataPacks(baseFolders, extraWorldDatapackFoldersByPack);
|
||||
}
|
||||
KMap<String, KSet<String>> biomes = new KMap<>();
|
||||
|
||||
@@ -205,6 +209,34 @@ public class ServerConfigurator {
|
||||
return fullInstall && verifyDataPacksPost(IrisSettings.get().getAutoConfiguration().isAutoRestartOnCustomBiomeInstall());
|
||||
}
|
||||
|
||||
static KList<File> collectInstallDatapackFolders(
|
||||
KList<File> baseFolders,
|
||||
KMap<String, KList<File>> extraWorldDatapackFoldersByPack
|
||||
) {
|
||||
KList<File> folders = new KList<>();
|
||||
if (baseFolders != null) {
|
||||
for (File folder : baseFolders) {
|
||||
if (folder != null && !folders.contains(folder)) {
|
||||
folders.add(folder);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (extraWorldDatapackFoldersByPack == null || extraWorldDatapackFoldersByPack.isEmpty()) {
|
||||
return folders;
|
||||
}
|
||||
for (KList<File> extraFolders : extraWorldDatapackFoldersByPack.values()) {
|
||||
if (extraFolders == null || extraFolders.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
for (File folder : extraFolders) {
|
||||
if (folder != null && !folders.contains(folder)) {
|
||||
folders.add(folder);
|
||||
}
|
||||
}
|
||||
}
|
||||
return folders;
|
||||
}
|
||||
|
||||
private static void installExternalDataPacks(
|
||||
KList<File> folders,
|
||||
KMap<String, KList<File>> extraWorldDatapackFoldersByPack
|
||||
@@ -915,7 +947,10 @@ public class ServerConfigurator {
|
||||
if (packName.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
File datapacksFolder = new File(Bukkit.getWorldContainer(), worldName + File.separator + "datapacks");
|
||||
org.bukkit.World world = Bukkit.getWorld(worldName);
|
||||
File datapacksFolder = world == null
|
||||
? new File(Bukkit.getWorldContainer(), worldName + File.separator + "datapacks")
|
||||
: resolveDatapacksFolder(world.getWorldFolder());
|
||||
addWorldDatapackFolder(foldersByPack, packName, datapacksFolder);
|
||||
}
|
||||
|
||||
@@ -929,7 +964,7 @@ public class ServerConfigurator {
|
||||
if (packName.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
File datapacksFolder = new File(world.getWorldFolder(), "datapacks");
|
||||
File datapacksFolder = resolveDatapacksFolder(world.getWorldFolder());
|
||||
addWorldDatapackFolder(foldersByPack, packName, datapacksFolder);
|
||||
}
|
||||
|
||||
@@ -969,6 +1004,31 @@ public class ServerConfigurator {
|
||||
}
|
||||
}
|
||||
|
||||
public static File resolveDatapacksFolder(File worldFolder) {
|
||||
File rootFolder = resolveWorldRootFolder(worldFolder);
|
||||
return new File(rootFolder, "datapacks");
|
||||
}
|
||||
|
||||
static File resolveWorldRootFolder(File worldFolder) {
|
||||
if (worldFolder == null) {
|
||||
return new File(Bukkit.getWorldContainer(), ServerProperties.LEVEL_NAME);
|
||||
}
|
||||
|
||||
File current = worldFolder.getAbsoluteFile();
|
||||
while (current != null) {
|
||||
if ("dimensions".equals(current.getName())) {
|
||||
File parent = current.getParentFile();
|
||||
if (parent != null) {
|
||||
return parent;
|
||||
}
|
||||
break;
|
||||
}
|
||||
current = current.getParentFile();
|
||||
}
|
||||
|
||||
return worldFolder.getAbsoluteFile();
|
||||
}
|
||||
|
||||
private static String sanitizePackName(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
|
||||
@@ -87,6 +87,7 @@ public class CommandDeveloper implements DirectorExecutor {
|
||||
private static final Set<String> ACTIVE_DELETE_CHUNK_WORLDS = ConcurrentHashMap.newKeySet();
|
||||
private CommandTurboPregen turboPregen;
|
||||
private CommandLazyPregen lazyPregen;
|
||||
private CommandSmoke smoke;
|
||||
|
||||
@Director(description = "Get Loaded TectonicPlates Count", origin = DirectorOrigin.BOTH, sync = true)
|
||||
public void EngineStatus() {
|
||||
|
||||
@@ -22,7 +22,7 @@ import art.arcane.iris.Iris;
|
||||
import art.arcane.iris.core.ExternalDataPackPipeline;
|
||||
import art.arcane.iris.core.IrisSettings;
|
||||
import art.arcane.iris.core.IrisWorlds;
|
||||
import art.arcane.iris.core.link.FoliaWorldsLink;
|
||||
import art.arcane.iris.core.lifecycle.WorldLifecycleService;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.nms.INMS;
|
||||
import art.arcane.iris.core.service.StudioSVC;
|
||||
@@ -196,8 +196,7 @@ public class CommandIris implements DirectorExecutor {
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
sender().sendMessage(C.RED + "Exception raised during creation. See the console for more details.");
|
||||
Iris.error("Exception raised during world creation: " + e.getMessage());
|
||||
Iris.reportError(e);
|
||||
Iris.reportError("Exception raised during world creation for \"" + name + "\".", e);
|
||||
worldCreation = false;
|
||||
return;
|
||||
}
|
||||
@@ -742,7 +741,7 @@ public class CommandIris implements DirectorExecutor {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!FoliaWorldsLink.get().unloadWorld(world, false)) {
|
||||
if (!WorldLifecycleService.get().unload(world, false)) {
|
||||
sender().sendMessage(C.RED + "Failed to unload world: " + world.getName());
|
||||
return;
|
||||
}
|
||||
@@ -755,7 +754,7 @@ public class CommandIris implements DirectorExecutor {
|
||||
}
|
||||
} catch (IOException e) {
|
||||
sender().sendMessage(C.RED + "Failed to save bukkit.yml because of " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
Iris.reportError("Failed to remove world \"" + world.getName() + "\" from bukkit.yml.", e);
|
||||
}
|
||||
IrisToolbelt.evacuate(world, "Deleting world");
|
||||
deletingWorld = true;
|
||||
@@ -2144,7 +2143,7 @@ public class CommandIris implements DirectorExecutor {
|
||||
sender().sendMessage(C.GREEN + "Unloading world: " + world.getName());
|
||||
try {
|
||||
IrisToolbelt.evacuate(world);
|
||||
boolean unloaded = FoliaWorldsLink.get().unloadWorld(world, false);
|
||||
boolean unloaded = WorldLifecycleService.get().unload(world, false);
|
||||
if (unloaded) {
|
||||
sender().sendMessage(C.GREEN + "World unloaded successfully.");
|
||||
} else {
|
||||
@@ -2152,7 +2151,7 @@ public class CommandIris implements DirectorExecutor {
|
||||
}
|
||||
} catch (Exception e) {
|
||||
sender().sendMessage(C.RED + "Failed to unload the world: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
Iris.reportError("Failed to unload world \"" + world.getName() + "\".", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package art.arcane.iris.core.commands;
|
||||
|
||||
import art.arcane.iris.Iris;
|
||||
import art.arcane.iris.core.runtime.DatapackReadinessResult;
|
||||
import art.arcane.iris.core.runtime.SmokeDiagnosticsService;
|
||||
import art.arcane.iris.core.runtime.SmokeTestService;
|
||||
import art.arcane.iris.util.common.director.DirectorExecutor;
|
||||
import art.arcane.iris.util.common.format.C;
|
||||
import art.arcane.volmlib.util.director.annotations.Director;
|
||||
import art.arcane.volmlib.util.director.annotations.Param;
|
||||
import art.arcane.volmlib.util.format.Form;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Director(name = "smoke", description = "Run Iris developer smoke diagnostics")
|
||||
public class CommandSmoke implements DirectorExecutor {
|
||||
@Director(description = "Run the full smoke suite", sync = true)
|
||||
public void full(
|
||||
@Param(name = "dimension", aliases = {"pack"}, description = "The dimension/pack key to validate")
|
||||
String dimension,
|
||||
@Param(description = "The seed to use", defaultValue = "1337")
|
||||
long seed,
|
||||
@Param(description = "Optional player validation target or none", defaultValue = "none")
|
||||
String player,
|
||||
@Param(name = "retain-on-failure", aliases = {"retain"}, description = "Retain the temp world after failure", defaultValue = "false")
|
||||
boolean retainOnFailure
|
||||
) {
|
||||
String runId = SmokeTestService.get().startFullSmoke(sender(), dimension, seed, player, retainOnFailure);
|
||||
announceRun(runId, "full");
|
||||
}
|
||||
|
||||
@Director(description = "Run the studio smoke flow", sync = true)
|
||||
public void studio(
|
||||
@Param(name = "dimension", aliases = {"pack"}, description = "The dimension/pack key to validate")
|
||||
String dimension,
|
||||
@Param(description = "The seed to use", defaultValue = "1337")
|
||||
long seed,
|
||||
@Param(description = "Optional player validation target or none", defaultValue = "none")
|
||||
String player,
|
||||
@Param(name = "retain-on-failure", aliases = {"retain"}, description = "Retain the temp world after failure", defaultValue = "false")
|
||||
boolean retainOnFailure
|
||||
) {
|
||||
String runId = SmokeTestService.get().startStudioSmoke(sender(), dimension, seed, player, retainOnFailure);
|
||||
announceRun(runId, "studio");
|
||||
}
|
||||
|
||||
@Director(description = "Run the create/unload smoke flow", sync = true)
|
||||
public void create(
|
||||
@Param(name = "dimension", aliases = {"pack"}, description = "The dimension/pack key to validate")
|
||||
String dimension,
|
||||
@Param(description = "The seed to use", defaultValue = "1337")
|
||||
long seed,
|
||||
@Param(name = "retain-on-failure", aliases = {"retain"}, description = "Retain the temp world after failure", defaultValue = "false")
|
||||
boolean retainOnFailure
|
||||
) {
|
||||
String runId = SmokeTestService.get().startCreateSmoke(sender(), dimension, seed, retainOnFailure);
|
||||
announceRun(runId, "create");
|
||||
}
|
||||
|
||||
@Director(description = "Run the benchmark create/unload smoke flow", sync = true)
|
||||
public void benchmark(
|
||||
@Param(name = "dimension", aliases = {"pack"}, description = "The dimension/pack key to validate")
|
||||
String dimension,
|
||||
@Param(description = "The seed to use", defaultValue = "1337")
|
||||
long seed,
|
||||
@Param(name = "retain-on-failure", aliases = {"retain"}, description = "Retain the temp world after failure", defaultValue = "false")
|
||||
boolean retainOnFailure
|
||||
) {
|
||||
String runId = SmokeTestService.get().startBenchmarkSmoke(sender(), dimension, seed, retainOnFailure);
|
||||
announceRun(runId, "benchmark");
|
||||
}
|
||||
|
||||
@Director(description = "Show live or persisted smoke status", sync = true)
|
||||
public void status(
|
||||
@Param(description = "Use latest or a specific run id", defaultValue = "latest")
|
||||
String run
|
||||
) {
|
||||
SmokeDiagnosticsService.SmokeRunReport report = resolveReport(run);
|
||||
if (report == null) {
|
||||
sender().sendMessage(C.RED + "No smoke report found for \"" + run + "\".");
|
||||
return;
|
||||
}
|
||||
|
||||
sendReport(report);
|
||||
}
|
||||
|
||||
@Director(description = "Inspect a currently loaded smoke/studio world", sync = true)
|
||||
public void inspect(
|
||||
@Param(description = "The loaded world name to inspect")
|
||||
String world
|
||||
) {
|
||||
SmokeTestService.WorldInspection inspection = SmokeTestService.get().inspectWorld(world);
|
||||
if (inspection == null) {
|
||||
sender().sendMessage(C.RED + "World \"" + world + "\" is not currently loaded.");
|
||||
return;
|
||||
}
|
||||
|
||||
sender().sendMessage(C.GREEN + "Smoke inspection for " + C.GOLD + inspection.worldName());
|
||||
sender().sendMessage(C.GRAY + "Lifecycle backend: " + C.WHITE + inspection.lifecycleBackend());
|
||||
sender().sendMessage(C.GRAY + "Runtime backend: " + C.WHITE + inspection.runtimeBackend());
|
||||
sender().sendMessage(C.GRAY + "Studio: " + C.WHITE + inspection.studio() + C.GRAY + " | Maintenance active: " + C.WHITE + inspection.maintenanceActive());
|
||||
sender().sendMessage(C.GRAY + "Engine closed: " + C.WHITE + inspection.engineClosed() + C.GRAY + " | Engine failing: " + C.WHITE + inspection.engineFailing());
|
||||
sender().sendMessage(C.GRAY + "Generation session: " + C.WHITE + inspection.generationSessionId() + C.GRAY + " | Active leases: " + C.WHITE + inspection.activeLeaseCount());
|
||||
sender().sendMessage(C.GRAY + "Datapack folders: " + C.WHITE + joinList(inspection.datapackFolders()));
|
||||
}
|
||||
|
||||
private void announceRun(String runId, String mode) {
|
||||
sender().sendMessage(C.GREEN + "Started " + C.GOLD + mode + C.GREEN + " smoke run " + C.GOLD + runId + C.GREEN + ".");
|
||||
sender().sendMessage(C.GREEN + "Use " + C.GOLD + "/iris developer smoke status run=" + runId + C.GREEN + " to monitor progress.");
|
||||
sender().sendMessage(C.GREEN + "Latest report: " + C.GOLD + latestReportPath());
|
||||
}
|
||||
|
||||
private SmokeDiagnosticsService.SmokeRunReport resolveReport(String run) {
|
||||
if (run == null || run.isBlank() || run.equalsIgnoreCase("latest")) {
|
||||
return SmokeTestService.get().latest();
|
||||
}
|
||||
|
||||
return SmokeTestService.get().get(run);
|
||||
}
|
||||
|
||||
private void sendReport(SmokeDiagnosticsService.SmokeRunReport report) {
|
||||
String elapsed = Form.duration(Math.max(0L, report.getElapsedMs()), 0);
|
||||
sender().sendMessage(C.GREEN + "Smoke run " + C.GOLD + report.getRunId() + C.GREEN + " (" + C.GOLD + report.getMode() + C.GREEN + ")");
|
||||
sender().sendMessage(C.GRAY + "World: " + C.WHITE + fallback(report.getWorldName()) + C.GRAY + " | Outcome: " + C.WHITE + fallback(report.getOutcome()));
|
||||
sender().sendMessage(C.GRAY + "Stage: " + C.WHITE + fallback(report.getStage()) + C.GRAY + " | Elapsed: " + C.WHITE + elapsed);
|
||||
if (report.getStageDetail() != null && !report.getStageDetail().isBlank()) {
|
||||
sender().sendMessage(C.GRAY + "Stage detail: " + C.WHITE + report.getStageDetail());
|
||||
}
|
||||
sender().sendMessage(C.GRAY + "Lifecycle backend: " + C.WHITE + fallback(report.getLifecycleBackend()));
|
||||
sender().sendMessage(C.GRAY + "Runtime backend: " + C.WHITE + fallback(report.getRuntimeBackend()));
|
||||
sender().sendMessage(C.GRAY + "Generation session: " + C.WHITE + report.getGenerationSessionId() + C.GRAY + " | Active leases: " + C.WHITE + report.getGenerationActiveLeases());
|
||||
if (report.getEntryChunkX() != null && report.getEntryChunkZ() != null) {
|
||||
sender().sendMessage(C.GRAY + "Entry chunk: " + C.WHITE + report.getEntryChunkX() + "," + report.getEntryChunkZ());
|
||||
}
|
||||
sender().sendMessage(C.GRAY + "Headless: " + C.WHITE + report.isHeadless() + C.GRAY + " | Player: " + C.WHITE + fallback(report.getPlayerName()));
|
||||
sender().sendMessage(C.GRAY + "Retain on failure: " + C.WHITE + report.isRetainOnFailure() + C.GRAY + " | Cleanup applied: " + C.WHITE + report.isCleanupApplied());
|
||||
sendDatapackReadiness(report.getDatapackReadiness());
|
||||
if (!report.getNotes().isEmpty()) {
|
||||
sender().sendMessage(C.GRAY + "Notes: " + C.WHITE + joinList(report.getNotes()));
|
||||
}
|
||||
if (report.getFailureType() != null && !report.getFailureType().isBlank()) {
|
||||
sender().sendMessage(C.RED + "Failure: " + report.getFailureType() + C.GRAY + " - " + C.WHITE + fallback(report.getFailureMessage()));
|
||||
if (!report.getFailureChain().isEmpty()) {
|
||||
sender().sendMessage(C.RED + "Failure chain: " + C.WHITE + joinList(report.getFailureChain()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void sendDatapackReadiness(DatapackReadinessResult readiness) {
|
||||
if (readiness == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
sender().sendMessage(C.GRAY + "Datapack pack key: " + C.WHITE + fallback(readiness.getRequestedPackKey()));
|
||||
sender().sendMessage(C.GRAY + "Datapack folders: " + C.WHITE + joinList(readiness.getResolvedDatapackFolders()));
|
||||
sender().sendMessage(C.GRAY + "External datapack result: " + C.WHITE + fallback(readiness.getExternalDatapackInstallResult()));
|
||||
sender().sendMessage(C.GRAY + "Verification passed: " + C.WHITE + readiness.isVerificationPassed() + C.GRAY + " | Restart required: " + C.WHITE + readiness.isRestartRequired());
|
||||
if (!readiness.getMissingPaths().isEmpty()) {
|
||||
sender().sendMessage(C.RED + "Missing datapack paths: " + C.WHITE + joinList(readiness.getMissingPaths()));
|
||||
}
|
||||
}
|
||||
|
||||
private String latestReportPath() {
|
||||
if (Iris.instance == null) {
|
||||
return "plugins/Iris/diagnostics/smoke/latest.json";
|
||||
}
|
||||
|
||||
return Iris.instance.getDataFile("diagnostics", "smoke", "latest.json").getAbsolutePath();
|
||||
}
|
||||
|
||||
private String joinList(List<String> values) {
|
||||
if (values == null || values.isEmpty()) {
|
||||
return "none";
|
||||
}
|
||||
|
||||
return String.join(", ", values);
|
||||
}
|
||||
|
||||
private String fallback(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return "none";
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -26,9 +26,7 @@ import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.project.IrisProject;
|
||||
import art.arcane.iris.core.service.StudioSVC;
|
||||
import art.arcane.iris.core.tools.IrisToolbelt;
|
||||
import art.arcane.iris.engine.IrisNoisemapPrebakePipeline;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.SeedManager;
|
||||
import art.arcane.iris.engine.object.*;
|
||||
import art.arcane.iris.engine.platform.ChunkReplacementListener;
|
||||
import art.arcane.iris.engine.platform.ChunkReplacementOptions;
|
||||
@@ -141,8 +139,25 @@ public class CommandStudio implements DirectorExecutor {
|
||||
return;
|
||||
}
|
||||
|
||||
Iris.service(StudioSVC.class).close();
|
||||
sender().sendMessage(C.GREEN + "Project Closed.");
|
||||
sender().sendMessage(C.YELLOW + "Closing studio...");
|
||||
Iris.service(StudioSVC.class).close().whenComplete((result, throwable) -> J.s(() -> {
|
||||
if (throwable != null) {
|
||||
sender().sendMessage(C.RED + "Studio close failed: " + throwable.getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
if (result != null && result.failureCause() != null) {
|
||||
sender().sendMessage(C.RED + "Studio close failed: " + result.failureCause().getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
if (result != null && result.startupCleanupQueued()) {
|
||||
sender().sendMessage(C.YELLOW + "Studio closed. Remaining world-family cleanup was queued for startup fallback.");
|
||||
return;
|
||||
}
|
||||
|
||||
sender().sendMessage(C.GREEN + "Studio closed.");
|
||||
}));
|
||||
}
|
||||
|
||||
@Director(description = "Create a new studio project", aliases = "+", sync = true)
|
||||
@@ -455,17 +470,13 @@ public class CommandStudio implements DirectorExecutor {
|
||||
IrisData data = IrisData.get(pack);
|
||||
PlatformChunkGenerator activeGenerator = resolveProfileGenerator(dimension);
|
||||
Engine activeEngine = activeGenerator == null ? null : activeGenerator.getEngine();
|
||||
long profileSeed = IrisNoisemapPrebakePipeline.dynamicStartupSeed();
|
||||
|
||||
if (activeEngine != null) {
|
||||
profileSeed = activeEngine.getSeedManager().getSeed();
|
||||
IrisToolbelt.applyPregenPerformanceProfile(activeEngine);
|
||||
} else {
|
||||
IrisToolbelt.applyPregenPerformanceProfile();
|
||||
}
|
||||
|
||||
IrisNoisemapPrebakePipeline.prebake(data, new SeedManager(profileSeed), "studio-profile", dimension.getLoadKey());
|
||||
|
||||
KList<String> fileText = new KList<>();
|
||||
|
||||
KMap<NoiseStyle, Double> styleTimings = new KMap<>();
|
||||
@@ -644,30 +655,6 @@ public class CommandStudio implements DirectorExecutor {
|
||||
sender().sendMessage(C.GREEN + "Done! " + report.getPath());
|
||||
}
|
||||
|
||||
@Director(description = "Profiles a dimension with a cache warm-up pass", origin = DirectorOrigin.PLAYER)
|
||||
public void profilecache(
|
||||
@Param(description = "The dimension to profile", contextual = true, defaultValue = "default", customHandler = DimensionHandler.class)
|
||||
IrisDimension dimension
|
||||
) {
|
||||
File pack = dimension.getLoadFile().getParentFile().getParentFile();
|
||||
IrisData data = IrisData.get(pack);
|
||||
PlatformChunkGenerator activeGenerator = resolveProfileGenerator(dimension);
|
||||
Engine activeEngine = activeGenerator == null ? null : activeGenerator.getEngine();
|
||||
long profileSeed = IrisNoisemapPrebakePipeline.dynamicStartupSeed();
|
||||
|
||||
if (activeEngine != null) {
|
||||
profileSeed = activeEngine.getSeedManager().getSeed();
|
||||
IrisToolbelt.applyPregenPerformanceProfile(activeEngine);
|
||||
} else {
|
||||
IrisToolbelt.applyPregenPerformanceProfile();
|
||||
}
|
||||
|
||||
sender().sendMessage(C.YELLOW + "Warming noisemap cache for profile...");
|
||||
IrisNoisemapPrebakePipeline.prebakeForced(data, new SeedManager(profileSeed), "studio-profilecache", dimension.getLoadKey());
|
||||
sender().sendMessage(C.YELLOW + "Running measured profile pass...");
|
||||
profile(dimension);
|
||||
}
|
||||
|
||||
@Director(description = "List pack noise generators as pack/generator", aliases = {"pack-noise", "packnoises"})
|
||||
public void packnoise() {
|
||||
LinkedHashSet<File> packFolders = new LinkedHashSet<>();
|
||||
|
||||
@@ -105,9 +105,37 @@ public class IrisLootEvent extends Event {
|
||||
if (!Bukkit.isPrimaryThread()) {
|
||||
Iris.warn("LootGenerateEvent was not called on the main thread, please report this issue.");
|
||||
Thread.dumpStack();
|
||||
J.sfut(() -> Bukkit.getPluginManager().callEvent(event)).join();
|
||||
} else Bukkit.getPluginManager().callEvent(event);
|
||||
J.sfut(() -> {
|
||||
try {
|
||||
Bukkit.getPluginManager().callEvent(event);
|
||||
} catch (Throwable e) {
|
||||
Iris.reportError("LootGenerateEvent dispatch failed at "
|
||||
+ world.getName() + " [" + x + "," + y + "," + z + "].", e);
|
||||
if (e instanceof RuntimeException runtimeException) {
|
||||
throw runtimeException;
|
||||
}
|
||||
if (e instanceof Error error) {
|
||||
throw error;
|
||||
}
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}).join();
|
||||
} else {
|
||||
try {
|
||||
Bukkit.getPluginManager().callEvent(event);
|
||||
} catch (Throwable e) {
|
||||
Iris.reportError("LootGenerateEvent dispatch failed at "
|
||||
+ world.getName() + " [" + x + "," + y + "," + z + "].", e);
|
||||
if (e instanceof RuntimeException runtimeException) {
|
||||
throw runtimeException;
|
||||
}
|
||||
if (e instanceof Error error) {
|
||||
throw error;
|
||||
}
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
return event.isCancelled();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package art.arcane.iris.core.lifecycle;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.WorldCreator;
|
||||
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
final class BukkitPublicBackend implements WorldLifecycleBackend {
|
||||
private final CapabilitySnapshot capabilities;
|
||||
|
||||
BukkitPublicBackend(CapabilitySnapshot capabilities) {
|
||||
this.capabilities = capabilities;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supports(WorldLifecycleRequest request, CapabilitySnapshot capabilities) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<World> create(WorldLifecycleRequest request) {
|
||||
World existing = Bukkit.getWorld(request.worldName());
|
||||
if (existing != null) {
|
||||
return CompletableFuture.completedFuture(existing);
|
||||
}
|
||||
|
||||
WorldCreator creator = request.toWorldCreator();
|
||||
if (request.generator() != null) {
|
||||
WorldLifecycleStaging.stageGenerator(request.worldName(), request.generator(), request.biomeProvider());
|
||||
WorldLifecycleStaging.stageStemGenerator(request.worldName(), request.generator());
|
||||
}
|
||||
|
||||
try {
|
||||
World world = creator.createWorld();
|
||||
return CompletableFuture.completedFuture(world);
|
||||
} catch (Throwable e) {
|
||||
return CompletableFuture.failedFuture(WorldLifecycleSupport.unwrap(e));
|
||||
} finally {
|
||||
WorldLifecycleStaging.clearAll(request.worldName());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean unload(World world, boolean save) {
|
||||
return WorldLifecycleSupport.unloadWorld(capabilities, world, save);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String backendName() {
|
||||
return "bukkit_public";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String describeSelectionReason() {
|
||||
return "public Bukkit world lifecycle path";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package art.arcane.iris.core.lifecycle;
|
||||
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
final class CapabilityResolution {
|
||||
private CapabilityResolution() {
|
||||
}
|
||||
|
||||
static Method resolveCreateLevelMethod(Class<?> owner) throws NoSuchMethodException {
|
||||
Method current = resolveMethod(owner, "createLevel", method -> {
|
||||
Class<?>[] params = method.getParameterTypes();
|
||||
return params.length == 3
|
||||
&& "LevelStem".equals(params[0].getSimpleName())
|
||||
&& "WorldLoadingInfoAndData".equals(params[1].getSimpleName())
|
||||
&& "WorldDataAndGenSettings".equals(params[2].getSimpleName());
|
||||
});
|
||||
if (current != null) {
|
||||
return current;
|
||||
}
|
||||
|
||||
Method legacy = resolveMethod(owner, "createLevel", method -> {
|
||||
Class<?>[] params = method.getParameterTypes();
|
||||
return params.length == 4
|
||||
&& "LevelStem".equals(params[0].getSimpleName())
|
||||
&& "WorldLoadingInfo".equals(params[1].getSimpleName())
|
||||
&& "LevelStorageAccess".equals(params[2].getSimpleName())
|
||||
&& "PrimaryLevelData".equals(params[3].getSimpleName());
|
||||
});
|
||||
if (legacy != null) {
|
||||
return legacy;
|
||||
}
|
||||
|
||||
throw new NoSuchMethodException(owner.getName() + "#createLevel");
|
||||
}
|
||||
|
||||
static Method resolveLevelStorageAccessMethod(Class<?> owner) throws NoSuchMethodException {
|
||||
Method exactValidate = resolveMethod(owner, "validateAndCreateAccess", method -> {
|
||||
Class<?>[] params = method.getParameterTypes();
|
||||
return params.length == 2
|
||||
&& String.class.equals(params[0])
|
||||
&& "ResourceKey".equals(params[1].getSimpleName())
|
||||
&& "LevelStorageAccess".equals(method.getReturnType().getSimpleName());
|
||||
});
|
||||
if (exactValidate != null) {
|
||||
return exactValidate;
|
||||
}
|
||||
|
||||
Method oneArgValidate = resolveMethod(owner, "validateAndCreateAccess", method -> {
|
||||
Class<?>[] params = method.getParameterTypes();
|
||||
return params.length == 1
|
||||
&& String.class.equals(params[0])
|
||||
&& "LevelStorageAccess".equals(method.getReturnType().getSimpleName());
|
||||
});
|
||||
if (oneArgValidate != null) {
|
||||
return oneArgValidate;
|
||||
}
|
||||
|
||||
Method exactCreate = resolveMethod(owner, "createAccess", method -> {
|
||||
Class<?>[] params = method.getParameterTypes();
|
||||
return params.length == 2
|
||||
&& String.class.equals(params[0])
|
||||
&& "ResourceKey".equals(params[1].getSimpleName())
|
||||
&& "LevelStorageAccess".equals(method.getReturnType().getSimpleName());
|
||||
});
|
||||
if (exactCreate != null) {
|
||||
return exactCreate;
|
||||
}
|
||||
|
||||
Method oneArgCreate = resolveMethod(owner, "createAccess", method -> {
|
||||
Class<?>[] params = method.getParameterTypes();
|
||||
return params.length == 1
|
||||
&& String.class.equals(params[0])
|
||||
&& "LevelStorageAccess".equals(method.getReturnType().getSimpleName());
|
||||
});
|
||||
if (oneArgCreate != null) {
|
||||
return oneArgCreate;
|
||||
}
|
||||
|
||||
throw new NoSuchMethodException(owner.getName() + "#validateAndCreateAccess/createAccess");
|
||||
}
|
||||
|
||||
static Method resolvePaperWorldDataMethod(Class<?> owner) throws NoSuchMethodException {
|
||||
Method current = resolveMethod(owner, "loadWorldData", method -> {
|
||||
Class<?>[] params = method.getParameterTypes();
|
||||
return params.length == 3
|
||||
&& "MinecraftServer".equals(params[0].getSimpleName())
|
||||
&& "ResourceKey".equals(params[1].getSimpleName())
|
||||
&& String.class.equals(params[2])
|
||||
&& "LoadedWorldData".equals(method.getReturnType().getSimpleName());
|
||||
});
|
||||
if (current != null) {
|
||||
return current;
|
||||
}
|
||||
|
||||
Method legacy = resolveMethod(owner, "getLevelData", method -> {
|
||||
Class<?>[] params = method.getParameterTypes();
|
||||
return params.length == 1 && "LevelStorageAccess".equals(params[0].getSimpleName());
|
||||
});
|
||||
if (legacy != null) {
|
||||
return legacy;
|
||||
}
|
||||
|
||||
throw new NoSuchMethodException(owner.getName() + "#loadWorldData/getLevelData");
|
||||
}
|
||||
|
||||
static Constructor<?> resolveWorldLoadingInfoConstructor(Class<?> owner) throws NoSuchMethodException {
|
||||
Constructor<?> current = resolveConstructor(owner, constructor -> {
|
||||
Class<?>[] params = constructor.getParameterTypes();
|
||||
return params.length == 4
|
||||
&& "Environment".equals(params[0].getSimpleName())
|
||||
&& "ResourceKey".equals(params[1].getSimpleName())
|
||||
&& "ResourceKey".equals(params[2].getSimpleName())
|
||||
&& boolean.class.equals(params[3]);
|
||||
});
|
||||
if (current != null) {
|
||||
return current;
|
||||
}
|
||||
|
||||
Constructor<?> legacy = resolveConstructor(owner, constructor -> {
|
||||
Class<?>[] params = constructor.getParameterTypes();
|
||||
return params.length == 5
|
||||
&& int.class.equals(params[0])
|
||||
&& String.class.equals(params[1])
|
||||
&& String.class.equals(params[2])
|
||||
&& "ResourceKey".equals(params[3].getSimpleName())
|
||||
&& boolean.class.equals(params[4]);
|
||||
});
|
||||
if (legacy != null) {
|
||||
return legacy;
|
||||
}
|
||||
|
||||
throw new NoSuchMethodException(owner.getName() + "#<init>");
|
||||
}
|
||||
|
||||
static Constructor<?> resolveWorldLoadingInfoAndDataConstructor(Class<?> owner) throws NoSuchMethodException {
|
||||
Constructor<?> constructor = resolveConstructor(owner, candidate -> {
|
||||
Class<?>[] params = candidate.getParameterTypes();
|
||||
return params.length == 2
|
||||
&& "WorldLoadingInfo".equals(params[0].getSimpleName())
|
||||
&& "LoadedWorldData".equals(params[1].getSimpleName());
|
||||
});
|
||||
if (constructor == null) {
|
||||
throw new NoSuchMethodException(owner.getName() + "#<init>");
|
||||
}
|
||||
return constructor;
|
||||
}
|
||||
|
||||
static Method resolveCreateNewWorldDataMethod(Class<?> owner) throws NoSuchMethodException {
|
||||
Method method = resolveMethod(owner, "createNewWorldData", candidate -> {
|
||||
Class<?>[] params = candidate.getParameterTypes();
|
||||
return params.length == 5
|
||||
&& "DedicatedServerSettings".equals(params[0].getSimpleName())
|
||||
&& "DataLoadContext".equals(params[1].getSimpleName())
|
||||
&& "Registry".equals(params[2].getSimpleName())
|
||||
&& boolean.class.equals(params[3])
|
||||
&& boolean.class.equals(params[4]);
|
||||
});
|
||||
if (method == null) {
|
||||
throw new NoSuchMethodException(owner.getName() + "#createNewWorldData");
|
||||
}
|
||||
return method;
|
||||
}
|
||||
|
||||
static Method resolveServerRegistryAccessMethod(Class<?> owner) throws NoSuchMethodException {
|
||||
Method method = resolveMethod(owner, "registryAccess", candidate -> candidate.getParameterCount() == 0
|
||||
&& !void.class.equals(candidate.getReturnType()));
|
||||
if (method == null) {
|
||||
throw new NoSuchMethodException(owner.getName() + "#registryAccess");
|
||||
}
|
||||
return method;
|
||||
}
|
||||
|
||||
static Method resolveMethod(Class<?> owner, String name, Predicate<Method> predicate) {
|
||||
Method selected = scanMethods(owner.getMethods(), name, predicate);
|
||||
if (selected != null) {
|
||||
return selected;
|
||||
}
|
||||
|
||||
Class<?> current = owner;
|
||||
while (current != null) {
|
||||
selected = scanMethods(current.getDeclaredMethods(), name, predicate);
|
||||
if (selected != null) {
|
||||
selected.setAccessible(true);
|
||||
return selected;
|
||||
}
|
||||
current = current.getSuperclass();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static Field resolveField(Class<?> owner, String name) throws NoSuchFieldException {
|
||||
Class<?> current = owner;
|
||||
while (current != null) {
|
||||
try {
|
||||
Field field = current.getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
return field;
|
||||
} catch (NoSuchFieldException ignored) {
|
||||
current = current.getSuperclass();
|
||||
}
|
||||
}
|
||||
throw new NoSuchFieldException(owner.getName() + "#" + name);
|
||||
}
|
||||
|
||||
private static Method scanMethods(Method[] methods, String name, Predicate<Method> predicate) {
|
||||
for (Method method : methods) {
|
||||
if (!method.getName().equals(name)) {
|
||||
continue;
|
||||
}
|
||||
if (predicate.test(method)) {
|
||||
return method;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Constructor<?> resolveConstructor(Class<?> owner, Predicate<Constructor<?>> predicate) {
|
||||
for (Constructor<?> constructor : owner.getConstructors()) {
|
||||
if (predicate.test(constructor)) {
|
||||
return constructor;
|
||||
}
|
||||
}
|
||||
for (Constructor<?> constructor : owner.getDeclaredConstructors()) {
|
||||
if (predicate.test(constructor)) {
|
||||
constructor.setAccessible(true);
|
||||
return constructor;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
package art.arcane.iris.core.lifecycle;
|
||||
|
||||
import art.arcane.iris.util.common.scheduling.J;
|
||||
import art.arcane.volmlib.util.scheduling.FoliaScheduler;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.plugin.RegisteredServiceProvider;
|
||||
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Collection;
|
||||
import java.util.Locale;
|
||||
|
||||
public final class CapabilitySnapshot {
|
||||
public enum PaperLikeFlavor {
|
||||
CURRENT_INFO_AND_DATA,
|
||||
LEGACY_STORAGE_ACCESS,
|
||||
UNSUPPORTED
|
||||
}
|
||||
|
||||
private final ServerFamily serverFamily;
|
||||
private final boolean regionizedRuntime;
|
||||
private final Object worldsProvider;
|
||||
private final Class<?> worldsLevelStemClass;
|
||||
private final Class<?> worldsGeneratorTypeClass;
|
||||
private final String worldsProviderResolution;
|
||||
private final Object bukkitServer;
|
||||
private final Object minecraftServer;
|
||||
private final Method createLevelMethod;
|
||||
private final PaperLikeFlavor paperLikeFlavor;
|
||||
private final Class<?> paperWorldLoaderClass;
|
||||
private final Method paperWorldDataMethod;
|
||||
private final Constructor<?> worldLoadingInfoConstructor;
|
||||
private final Constructor<?> worldLoadingInfoAndDataConstructor;
|
||||
private final Method createNewWorldDataMethod;
|
||||
private final Method levelStorageAccessMethod;
|
||||
private final Field worldLoaderContextField;
|
||||
private final Method serverRegistryAccessMethod;
|
||||
private final Field settingsField;
|
||||
private final Field optionsField;
|
||||
private final Method isDemoMethod;
|
||||
private final Method unloadWorldAsyncMethod;
|
||||
private final Method chunkAtAsyncMethod;
|
||||
private final Method removeLevelMethod;
|
||||
private final String paperLikeResolution;
|
||||
|
||||
private CapabilitySnapshot(
|
||||
ServerFamily serverFamily,
|
||||
boolean regionizedRuntime,
|
||||
Object worldsProvider,
|
||||
Class<?> worldsLevelStemClass,
|
||||
Class<?> worldsGeneratorTypeClass,
|
||||
String worldsProviderResolution,
|
||||
Object bukkitServer,
|
||||
Object minecraftServer,
|
||||
Method createLevelMethod,
|
||||
PaperLikeFlavor paperLikeFlavor,
|
||||
Class<?> paperWorldLoaderClass,
|
||||
Method paperWorldDataMethod,
|
||||
Constructor<?> worldLoadingInfoConstructor,
|
||||
Constructor<?> worldLoadingInfoAndDataConstructor,
|
||||
Method createNewWorldDataMethod,
|
||||
Method levelStorageAccessMethod,
|
||||
Field worldLoaderContextField,
|
||||
Method serverRegistryAccessMethod,
|
||||
Field settingsField,
|
||||
Field optionsField,
|
||||
Method isDemoMethod,
|
||||
Method unloadWorldAsyncMethod,
|
||||
Method chunkAtAsyncMethod,
|
||||
Method removeLevelMethod,
|
||||
String paperLikeResolution
|
||||
) {
|
||||
this.serverFamily = serverFamily;
|
||||
this.regionizedRuntime = regionizedRuntime;
|
||||
this.worldsProvider = worldsProvider;
|
||||
this.worldsLevelStemClass = worldsLevelStemClass;
|
||||
this.worldsGeneratorTypeClass = worldsGeneratorTypeClass;
|
||||
this.worldsProviderResolution = worldsProviderResolution;
|
||||
this.bukkitServer = bukkitServer;
|
||||
this.minecraftServer = minecraftServer;
|
||||
this.createLevelMethod = createLevelMethod;
|
||||
this.paperLikeFlavor = paperLikeFlavor;
|
||||
this.paperWorldLoaderClass = paperWorldLoaderClass;
|
||||
this.paperWorldDataMethod = paperWorldDataMethod;
|
||||
this.worldLoadingInfoConstructor = worldLoadingInfoConstructor;
|
||||
this.worldLoadingInfoAndDataConstructor = worldLoadingInfoAndDataConstructor;
|
||||
this.createNewWorldDataMethod = createNewWorldDataMethod;
|
||||
this.levelStorageAccessMethod = levelStorageAccessMethod;
|
||||
this.worldLoaderContextField = worldLoaderContextField;
|
||||
this.serverRegistryAccessMethod = serverRegistryAccessMethod;
|
||||
this.settingsField = settingsField;
|
||||
this.optionsField = optionsField;
|
||||
this.isDemoMethod = isDemoMethod;
|
||||
this.unloadWorldAsyncMethod = unloadWorldAsyncMethod;
|
||||
this.chunkAtAsyncMethod = chunkAtAsyncMethod;
|
||||
this.removeLevelMethod = removeLevelMethod;
|
||||
this.paperLikeResolution = paperLikeResolution;
|
||||
}
|
||||
|
||||
public static CapabilitySnapshot probe() {
|
||||
Server server = Bukkit.getServer();
|
||||
Object bukkitServer = server;
|
||||
boolean regionizedRuntime = FoliaScheduler.isRegionizedRuntime(server);
|
||||
ServerFamily serverFamily = detectServerFamily(server, regionizedRuntime);
|
||||
|
||||
Object worldsProvider = null;
|
||||
Class<?> worldsLevelStemClass = null;
|
||||
Class<?> worldsGeneratorTypeClass = null;
|
||||
String worldsProviderResolution = "inactive";
|
||||
try {
|
||||
Object[] worldsProviderData = resolveWorldsProvider();
|
||||
worldsProvider = worldsProviderData[0];
|
||||
worldsLevelStemClass = (Class<?>) worldsProviderData[1];
|
||||
worldsGeneratorTypeClass = (Class<?>) worldsProviderData[2];
|
||||
worldsProviderResolution = (String) worldsProviderData[3];
|
||||
} catch (Throwable e) {
|
||||
worldsProviderResolution = e.getClass().getSimpleName() + ": " + String.valueOf(e.getMessage());
|
||||
}
|
||||
|
||||
Object minecraftServer = null;
|
||||
Method createLevelMethod = null;
|
||||
PaperLikeFlavor paperLikeFlavor = PaperLikeFlavor.UNSUPPORTED;
|
||||
Class<?> paperWorldLoaderClass = null;
|
||||
Method paperWorldDataMethod = null;
|
||||
Constructor<?> worldLoadingInfoConstructor = null;
|
||||
Constructor<?> worldLoadingInfoAndDataConstructor = null;
|
||||
Method createNewWorldDataMethod = null;
|
||||
Method levelStorageAccessMethod = null;
|
||||
Field worldLoaderContextField = null;
|
||||
Method serverRegistryAccessMethod = null;
|
||||
Field settingsField = null;
|
||||
Field optionsField = null;
|
||||
Method isDemoMethod = null;
|
||||
Method removeLevelMethod = null;
|
||||
String paperLikeResolution = "inactive";
|
||||
|
||||
try {
|
||||
if (bukkitServer != null) {
|
||||
Method getServerMethod = CapabilityResolution.resolveMethod(bukkitServer.getClass(), "getServer", method -> method.getParameterCount() == 0);
|
||||
if (getServerMethod != null) {
|
||||
minecraftServer = getServerMethod.invoke(bukkitServer);
|
||||
}
|
||||
}
|
||||
|
||||
if (minecraftServer != null) {
|
||||
Class<?> minecraftServerClass = Class.forName("net.minecraft.server.MinecraftServer");
|
||||
if (!minecraftServerClass.isInstance(minecraftServer)) {
|
||||
throw new IllegalStateException("resolved server is not a MinecraftServer: " + minecraftServer.getClass().getName());
|
||||
}
|
||||
|
||||
createLevelMethod = CapabilityResolution.resolveCreateLevelMethod(minecraftServer.getClass());
|
||||
removeLevelMethod = CapabilityResolution.resolveMethod(minecraftServer.getClass(), "removeLevel", method -> {
|
||||
Class<?>[] params = method.getParameterTypes();
|
||||
return params.length == 1 && "ServerLevel".equals(params[0].getSimpleName());
|
||||
});
|
||||
worldLoaderContextField = CapabilityResolution.resolveField(minecraftServer.getClass(), "worldLoaderContext");
|
||||
serverRegistryAccessMethod = CapabilityResolution.resolveServerRegistryAccessMethod(minecraftServer.getClass());
|
||||
settingsField = CapabilityResolution.resolveField(minecraftServer.getClass(), "settings");
|
||||
optionsField = CapabilityResolution.resolveField(minecraftServer.getClass(), "options");
|
||||
isDemoMethod = CapabilityResolution.resolveMethod(minecraftServer.getClass(), "isDemo", method -> method.getParameterCount() == 0 && boolean.class.equals(method.getReturnType()));
|
||||
|
||||
Class<?> mainClass = Class.forName("net.minecraft.server.Main");
|
||||
createNewWorldDataMethod = CapabilityResolution.resolveCreateNewWorldDataMethod(mainClass);
|
||||
|
||||
Class<?> paperLoaderCandidate = Class.forName("io.papermc.paper.world.PaperWorldLoader");
|
||||
paperWorldLoaderClass = paperLoaderCandidate;
|
||||
paperWorldDataMethod = CapabilityResolution.resolvePaperWorldDataMethod(paperLoaderCandidate);
|
||||
Class<?> worldLoadingInfoClass = Class.forName("io.papermc.paper.world.PaperWorldLoader$WorldLoadingInfo");
|
||||
worldLoadingInfoConstructor = CapabilityResolution.resolveWorldLoadingInfoConstructor(worldLoadingInfoClass);
|
||||
|
||||
if (createLevelMethod.getParameterCount() == 3) {
|
||||
Class<?> worldLoadingInfoAndDataClass = Class.forName("io.papermc.paper.world.PaperWorldLoader$WorldLoadingInfoAndData");
|
||||
worldLoadingInfoAndDataConstructor = CapabilityResolution.resolveWorldLoadingInfoAndDataConstructor(worldLoadingInfoAndDataClass);
|
||||
paperLikeFlavor = PaperLikeFlavor.CURRENT_INFO_AND_DATA;
|
||||
} else {
|
||||
Class<?> levelStorageSourceClass = Class.forName("net.minecraft.world.level.storage.LevelStorageSource");
|
||||
levelStorageAccessMethod = CapabilityResolution.resolveLevelStorageAccessMethod(levelStorageSourceClass);
|
||||
paperLikeFlavor = PaperLikeFlavor.LEGACY_STORAGE_ACCESS;
|
||||
}
|
||||
|
||||
paperLikeResolution = "available(flavor=" + paperLikeFlavor.name().toLowerCase(Locale.ROOT)
|
||||
+ ", createLevel=" + createLevelMethod.toGenericString() + ")";
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
paperLikeResolution = e.getClass().getSimpleName() + ": " + String.valueOf(e.getMessage());
|
||||
createLevelMethod = null;
|
||||
paperLikeFlavor = PaperLikeFlavor.UNSUPPORTED;
|
||||
paperWorldLoaderClass = null;
|
||||
paperWorldDataMethod = null;
|
||||
worldLoadingInfoConstructor = null;
|
||||
worldLoadingInfoAndDataConstructor = null;
|
||||
createNewWorldDataMethod = null;
|
||||
levelStorageAccessMethod = null;
|
||||
worldLoaderContextField = null;
|
||||
serverRegistryAccessMethod = null;
|
||||
settingsField = null;
|
||||
optionsField = null;
|
||||
isDemoMethod = null;
|
||||
removeLevelMethod = null;
|
||||
}
|
||||
|
||||
Method unloadWorldAsyncMethod = null;
|
||||
try {
|
||||
if (bukkitServer != null) {
|
||||
unloadWorldAsyncMethod = CapabilityResolution.resolveMethod(bukkitServer.getClass(), "unloadWorldAsync", method -> {
|
||||
Class<?>[] params = method.getParameterTypes();
|
||||
return params.length == 3
|
||||
&& World.class.equals(params[0])
|
||||
&& boolean.class.equals(params[1])
|
||||
&& "Consumer".equals(params[2].getSimpleName());
|
||||
});
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
unloadWorldAsyncMethod = null;
|
||||
}
|
||||
|
||||
Method chunkAtAsyncMethod = null;
|
||||
try {
|
||||
chunkAtAsyncMethod = CapabilityResolution.resolveMethod(World.class, "getChunkAtAsync", method -> {
|
||||
Class<?>[] params = method.getParameterTypes();
|
||||
return params.length == 3
|
||||
&& int.class.equals(params[0])
|
||||
&& int.class.equals(params[1])
|
||||
&& boolean.class.equals(params[2]);
|
||||
});
|
||||
} catch (Throwable ignored) {
|
||||
chunkAtAsyncMethod = null;
|
||||
}
|
||||
|
||||
return new CapabilitySnapshot(
|
||||
serverFamily,
|
||||
regionizedRuntime,
|
||||
worldsProvider,
|
||||
worldsLevelStemClass,
|
||||
worldsGeneratorTypeClass,
|
||||
worldsProviderResolution,
|
||||
bukkitServer,
|
||||
minecraftServer,
|
||||
createLevelMethod,
|
||||
paperLikeFlavor,
|
||||
paperWorldLoaderClass,
|
||||
paperWorldDataMethod,
|
||||
worldLoadingInfoConstructor,
|
||||
worldLoadingInfoAndDataConstructor,
|
||||
createNewWorldDataMethod,
|
||||
levelStorageAccessMethod,
|
||||
worldLoaderContextField,
|
||||
serverRegistryAccessMethod,
|
||||
settingsField,
|
||||
optionsField,
|
||||
isDemoMethod,
|
||||
unloadWorldAsyncMethod,
|
||||
chunkAtAsyncMethod,
|
||||
removeLevelMethod,
|
||||
paperLikeResolution
|
||||
);
|
||||
}
|
||||
|
||||
public static CapabilitySnapshot forTesting(ServerFamily serverFamily, boolean regionizedRuntime, boolean worldsProviderHealthy, boolean paperLikeRuntimeHealthy) {
|
||||
Object minecraftServer = paperLikeRuntimeHealthy ? new TestingPaperLikeServer("datapack-registry", "server-registry") : null;
|
||||
Method createLevelMethod = null;
|
||||
Field worldLoaderContextField = null;
|
||||
Method serverRegistryAccessMethod = null;
|
||||
try {
|
||||
createLevelMethod = paperLikeRuntimeHealthy
|
||||
? TestingPaperLikeServer.class.getDeclaredMethod("createLevel", Object.class, Object.class, Object.class)
|
||||
: null;
|
||||
worldLoaderContextField = paperLikeRuntimeHealthy
|
||||
? CapabilityResolution.resolveField(TestingPaperLikeServer.class, "worldLoaderContext")
|
||||
: null;
|
||||
serverRegistryAccessMethod = paperLikeRuntimeHealthy
|
||||
? CapabilityResolution.resolveServerRegistryAccessMethod(TestingPaperLikeServer.class)
|
||||
: null;
|
||||
} catch (NoSuchMethodException | NoSuchFieldException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
return new CapabilitySnapshot(
|
||||
serverFamily,
|
||||
regionizedRuntime,
|
||||
worldsProviderHealthy ? new Object() : null,
|
||||
worldsProviderHealthy ? Object.class : null,
|
||||
worldsProviderHealthy ? Object.class : null,
|
||||
worldsProviderHealthy ? "test-provider" : "inactive",
|
||||
null,
|
||||
minecraftServer,
|
||||
createLevelMethod,
|
||||
paperLikeRuntimeHealthy ? PaperLikeFlavor.CURRENT_INFO_AND_DATA : PaperLikeFlavor.UNSUPPORTED,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
worldLoaderContextField,
|
||||
serverRegistryAccessMethod,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
paperLikeRuntimeHealthy ? "available(test)" : "unsupported(test)"
|
||||
);
|
||||
}
|
||||
|
||||
public static CapabilitySnapshot forTestingRuntimeRegistries(ServerFamily serverFamily, boolean regionizedRuntime, Object datapackDimensions, Object serverRegistryAccess) {
|
||||
TestingPaperLikeServer minecraftServer = new TestingPaperLikeServer(datapackDimensions, serverRegistryAccess);
|
||||
Method createLevelMethod;
|
||||
Field worldLoaderContextField;
|
||||
Method registryAccessMethod;
|
||||
try {
|
||||
createLevelMethod = TestingPaperLikeServer.class.getDeclaredMethod("createLevel", Object.class, Object.class, Object.class);
|
||||
worldLoaderContextField = CapabilityResolution.resolveField(TestingPaperLikeServer.class, "worldLoaderContext");
|
||||
registryAccessMethod = CapabilityResolution.resolveServerRegistryAccessMethod(TestingPaperLikeServer.class);
|
||||
} catch (NoSuchMethodException | NoSuchFieldException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
return new CapabilitySnapshot(
|
||||
serverFamily,
|
||||
regionizedRuntime,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"inactive",
|
||||
null,
|
||||
minecraftServer,
|
||||
createLevelMethod,
|
||||
PaperLikeFlavor.CURRENT_INFO_AND_DATA,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
worldLoaderContextField,
|
||||
registryAccessMethod,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"available(test-runtime-registries)"
|
||||
);
|
||||
}
|
||||
|
||||
public ServerFamily serverFamily() {
|
||||
return serverFamily;
|
||||
}
|
||||
|
||||
public boolean regionizedRuntime() {
|
||||
return regionizedRuntime;
|
||||
}
|
||||
|
||||
public Object worldsProvider() {
|
||||
return worldsProvider;
|
||||
}
|
||||
|
||||
public Class<?> worldsLevelStemClass() {
|
||||
return worldsLevelStemClass;
|
||||
}
|
||||
|
||||
public Class<?> worldsGeneratorTypeClass() {
|
||||
return worldsGeneratorTypeClass;
|
||||
}
|
||||
|
||||
public Object bukkitServer() {
|
||||
return bukkitServer;
|
||||
}
|
||||
|
||||
public Object minecraftServer() {
|
||||
return minecraftServer;
|
||||
}
|
||||
|
||||
public Method createLevelMethod() {
|
||||
return createLevelMethod;
|
||||
}
|
||||
|
||||
public PaperLikeFlavor paperLikeFlavor() {
|
||||
return paperLikeFlavor;
|
||||
}
|
||||
|
||||
public Class<?> paperWorldLoaderClass() {
|
||||
return paperWorldLoaderClass;
|
||||
}
|
||||
|
||||
public Method paperWorldDataMethod() {
|
||||
return paperWorldDataMethod;
|
||||
}
|
||||
|
||||
public Constructor<?> worldLoadingInfoConstructor() {
|
||||
return worldLoadingInfoConstructor;
|
||||
}
|
||||
|
||||
public Constructor<?> worldLoadingInfoAndDataConstructor() {
|
||||
return worldLoadingInfoAndDataConstructor;
|
||||
}
|
||||
|
||||
public Method createNewWorldDataMethod() {
|
||||
return createNewWorldDataMethod;
|
||||
}
|
||||
|
||||
public Method levelStorageAccessMethod() {
|
||||
return levelStorageAccessMethod;
|
||||
}
|
||||
|
||||
public Field worldLoaderContextField() {
|
||||
return worldLoaderContextField;
|
||||
}
|
||||
|
||||
public Method serverRegistryAccessMethod() {
|
||||
return serverRegistryAccessMethod;
|
||||
}
|
||||
|
||||
public Field settingsField() {
|
||||
return settingsField;
|
||||
}
|
||||
|
||||
public Field optionsField() {
|
||||
return optionsField;
|
||||
}
|
||||
|
||||
public Method isDemoMethod() {
|
||||
return isDemoMethod;
|
||||
}
|
||||
|
||||
public Method unloadWorldAsyncMethod() {
|
||||
return unloadWorldAsyncMethod;
|
||||
}
|
||||
|
||||
public Method chunkAtAsyncMethod() {
|
||||
return chunkAtAsyncMethod;
|
||||
}
|
||||
|
||||
public Method removeLevelMethod() {
|
||||
return removeLevelMethod;
|
||||
}
|
||||
|
||||
public boolean hasWorldsProvider() {
|
||||
return worldsProvider != null && worldsLevelStemClass != null && worldsGeneratorTypeClass != null;
|
||||
}
|
||||
|
||||
public boolean hasPaperLikeRuntime() {
|
||||
return minecraftServer != null
|
||||
&& createLevelMethod != null
|
||||
&& serverRegistryAccessMethod != null
|
||||
&& paperLikeFlavor != PaperLikeFlavor.UNSUPPORTED;
|
||||
}
|
||||
|
||||
public String worldsProviderResolution() {
|
||||
return worldsProviderResolution;
|
||||
}
|
||||
|
||||
public String paperLikeResolution() {
|
||||
return paperLikeResolution;
|
||||
}
|
||||
|
||||
public String describe() {
|
||||
return "family=" + serverFamily.id()
|
||||
+ ", regionizedRuntime=" + regionizedRuntime
|
||||
+ ", worldsProvider=" + worldsProviderResolution
|
||||
+ ", paperLike=" + paperLikeResolution
|
||||
+ ", serverRegistryAccess=" + (serverRegistryAccessMethod != null)
|
||||
+ ", unloadAsync=" + (unloadWorldAsyncMethod != null)
|
||||
+ ", chunkAsync=" + (chunkAtAsyncMethod != null);
|
||||
}
|
||||
|
||||
private static ServerFamily detectServerFamily(Server server, boolean regionizedRuntime) {
|
||||
String bukkitName = server == null ? "" : server.getName();
|
||||
String bukkitVersion = server == null ? "" : server.getVersion();
|
||||
String serverClassName = server == null ? "" : server.getClass().getName();
|
||||
boolean canvasRuntime = hasCanvasRuntime();
|
||||
|
||||
if (containsIgnoreCase(bukkitName, "folia")
|
||||
|| containsIgnoreCase(bukkitVersion, "folia")
|
||||
|| containsIgnoreCase(serverClassName, "folia")) {
|
||||
return ServerFamily.FOLIA;
|
||||
}
|
||||
|
||||
if (canvasRuntime
|
||||
|| containsIgnoreCase(bukkitName, "canvas")
|
||||
|| containsIgnoreCase(bukkitVersion, "canvas")
|
||||
|| containsIgnoreCase(serverClassName, "canvas")) {
|
||||
return regionizedRuntime ? ServerFamily.CANVAS : ServerFamily.CANVAS;
|
||||
}
|
||||
|
||||
if (containsIgnoreCase(bukkitName, "purpur")
|
||||
|| containsIgnoreCase(bukkitVersion, "purpur")
|
||||
|| containsIgnoreCase(serverClassName, "purpur")) {
|
||||
return ServerFamily.PURPUR;
|
||||
}
|
||||
|
||||
if (containsIgnoreCase(bukkitName, "paper")
|
||||
|| containsIgnoreCase(bukkitVersion, "paper")
|
||||
|| containsIgnoreCase(serverClassName, "paper")
|
||||
|| containsIgnoreCase(bukkitName, "pufferfish")
|
||||
|| containsIgnoreCase(bukkitVersion, "pufferfish")
|
||||
|| containsIgnoreCase(serverClassName, "pufferfish")) {
|
||||
return ServerFamily.PAPER;
|
||||
}
|
||||
|
||||
if (containsIgnoreCase(bukkitName, "spigot")
|
||||
|| containsIgnoreCase(bukkitVersion, "spigot")
|
||||
|| containsIgnoreCase(serverClassName, "spigot")) {
|
||||
return ServerFamily.SPIGOT;
|
||||
}
|
||||
|
||||
if (containsIgnoreCase(bukkitName, "craftbukkit")
|
||||
|| containsIgnoreCase(bukkitVersion, "craftbukkit")
|
||||
|| containsIgnoreCase(serverClassName, "craftbukkit")
|
||||
|| containsIgnoreCase(bukkitName, "bukkit")
|
||||
|| containsIgnoreCase(bukkitVersion, "bukkit")) {
|
||||
return ServerFamily.BUKKIT;
|
||||
}
|
||||
|
||||
if (regionizedRuntime || J.isFolia()) {
|
||||
return ServerFamily.FOLIA;
|
||||
}
|
||||
|
||||
return ServerFamily.UNKNOWN;
|
||||
}
|
||||
|
||||
private static boolean hasCanvasRuntime() {
|
||||
try {
|
||||
Class.forName("io.canvasmc.canvas.region.WorldRegionizer");
|
||||
return true;
|
||||
} catch (Throwable ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean containsIgnoreCase(String value, String needle) {
|
||||
if (value == null || needle == null || needle.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return value.toLowerCase(Locale.ROOT).contains(needle.toLowerCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
private static Object[] resolveWorldsProvider() throws Throwable {
|
||||
try {
|
||||
Class<?> worldsProviderClass = Class.forName("net.thenextlvl.worlds.api.WorldsProvider");
|
||||
Class<?> levelStemClass = Class.forName("net.thenextlvl.worlds.api.generator.LevelStem");
|
||||
Class<?> generatorTypeClass = Class.forName("net.thenextlvl.worlds.api.generator.GeneratorType");
|
||||
Object provider = Bukkit.getServicesManager().load(worldsProviderClass);
|
||||
String resolution = provider == null ? "inactive(service not registered)" : "active(service=" + provider.getClass().getName() + ")";
|
||||
return new Object[]{provider, levelStemClass, generatorTypeClass, resolution};
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
|
||||
Collection<Class<?>> knownServices = Bukkit.getServicesManager().getKnownServices();
|
||||
for (Class<?> serviceClass : knownServices) {
|
||||
if (!"net.thenextlvl.worlds.api.WorldsProvider".equals(serviceClass.getName())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
RegisteredServiceProvider<?> registration = Bukkit.getServicesManager().getRegistration(serviceClass);
|
||||
if (registration == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Object provider = registration.getProvider();
|
||||
ClassLoader loader = serviceClass.getClassLoader();
|
||||
if (loader == null && provider != null) {
|
||||
loader = provider.getClass().getClassLoader();
|
||||
}
|
||||
if (loader == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Class<?> levelStemClass = Class.forName("net.thenextlvl.worlds.api.generator.LevelStem", false, loader);
|
||||
Class<?> generatorTypeClass = Class.forName("net.thenextlvl.worlds.api.generator.GeneratorType", false, loader);
|
||||
return new Object[]{provider, levelStemClass, generatorTypeClass, "active(service-scan=" + provider.getClass().getName() + ")"};
|
||||
}
|
||||
|
||||
return new Object[]{null, null, null, "inactive(service scan found nothing)"};
|
||||
}
|
||||
|
||||
private static final class TestingPaperLikeServer {
|
||||
private final TestingWorldLoaderContext worldLoaderContext;
|
||||
private final Object registryAccess;
|
||||
|
||||
private TestingPaperLikeServer(Object datapackDimensions, Object registryAccess) {
|
||||
this.worldLoaderContext = new TestingWorldLoaderContext(datapackDimensions);
|
||||
this.registryAccess = registryAccess;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
private void createLevel(Object levelStem, Object worldLoadingInfoAndData, Object worldDataAndGenSettings) {
|
||||
}
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
private Object registryAccess() {
|
||||
return registryAccess;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class TestingWorldLoaderContext {
|
||||
private final Object datapackDimensions;
|
||||
|
||||
private TestingWorldLoaderContext(Object datapackDimensions) {
|
||||
this.datapackDimensions = datapackDimensions;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
private Object datapackDimensions() {
|
||||
return datapackDimensions;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package art.arcane.iris.core.lifecycle;
|
||||
|
||||
import art.arcane.iris.Iris;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.World;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
final class PaperLikeRuntimeBackend implements WorldLifecycleBackend {
|
||||
private final CapabilitySnapshot capabilities;
|
||||
|
||||
PaperLikeRuntimeBackend(CapabilitySnapshot capabilities) {
|
||||
this.capabilities = capabilities;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supports(WorldLifecycleRequest request, CapabilitySnapshot capabilities) {
|
||||
return request.studio()
|
||||
&& capabilities.serverFamily().isPaperLike()
|
||||
&& capabilities.hasPaperLikeRuntime();
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<World> create(WorldLifecycleRequest request) {
|
||||
Object legacyStorageAccess = null;
|
||||
try {
|
||||
World existing = Bukkit.getWorld(request.worldName());
|
||||
if (existing != null) {
|
||||
return CompletableFuture.completedFuture(existing);
|
||||
}
|
||||
|
||||
if (request.generator() == null) {
|
||||
return CompletableFuture.failedFuture(new IllegalStateException("Runtime world creation requires a non-null chunk generator."));
|
||||
}
|
||||
|
||||
WorldLifecycleStaging.stageGenerator(request.worldName(), request.generator(), request.biomeProvider());
|
||||
WorldLifecycleSupport.stageRuntimeConfiguration(request.worldName());
|
||||
|
||||
Iris.info("WorldLifecycle runtime LevelStem: world=" + request.worldName()
|
||||
+ ", backend=paper_like_runtime, flavor=" + capabilities.paperLikeFlavor().name().toLowerCase(Locale.ROOT)
|
||||
+ ", registrySource=" + WorldLifecycleSupport.runtimeLevelStemRegistrySource(request));
|
||||
Object levelStem = WorldLifecycleSupport.resolveRuntimeLevelStem(capabilities, request);
|
||||
Object stemKey = WorldLifecycleSupport.createRuntimeLevelStemKey(request.worldName());
|
||||
|
||||
if (capabilities.paperLikeFlavor() == CapabilitySnapshot.PaperLikeFlavor.CURRENT_INFO_AND_DATA) {
|
||||
Object dimensionKey = WorldLifecycleSupport.createDimensionKey(stemKey);
|
||||
Object loadedWorldData = capabilities.paperWorldDataMethod().invoke(null, capabilities.minecraftServer(), dimensionKey, request.worldName());
|
||||
Object worldLoadingInfo = capabilities.worldLoadingInfoConstructor().newInstance(request.environment(), stemKey, dimensionKey, true);
|
||||
Object worldLoadingInfoAndData = capabilities.worldLoadingInfoAndDataConstructor().newInstance(worldLoadingInfo, loadedWorldData);
|
||||
Object worldDataAndGenSettings = WorldLifecycleSupport.createCurrentWorldDataAndSettings(capabilities, request.worldName());
|
||||
capabilities.createLevelMethod().invoke(capabilities.minecraftServer(), levelStem, worldLoadingInfoAndData, worldDataAndGenSettings);
|
||||
} else {
|
||||
legacyStorageAccess = WorldLifecycleSupport.createLegacyStorageAccess(capabilities, request.worldName());
|
||||
Object primaryLevelData = WorldLifecycleSupport.createLegacyPrimaryLevelData(capabilities, legacyStorageAccess, request.worldName());
|
||||
Object worldLoadingInfo = capabilities.worldLoadingInfoConstructor().newInstance(0, request.worldName(), request.environment().name().toLowerCase(Locale.ROOT), stemKey, true);
|
||||
capabilities.createLevelMethod().invoke(capabilities.minecraftServer(), levelStem, worldLoadingInfo, legacyStorageAccess, primaryLevelData);
|
||||
}
|
||||
|
||||
World loadedWorld = Bukkit.getWorld(request.worldName());
|
||||
if (loadedWorld == null) {
|
||||
return CompletableFuture.failedFuture(new IllegalStateException("Paper-like runtime backend did not load world \"" + request.worldName() + "\"."));
|
||||
}
|
||||
|
||||
return CompletableFuture.completedFuture(loadedWorld);
|
||||
} catch (Throwable e) {
|
||||
return CompletableFuture.failedFuture(WorldLifecycleSupport.unwrap(e));
|
||||
} finally {
|
||||
WorldLifecycleStaging.clearGenerator(request.worldName());
|
||||
WorldLifecycleSupport.closeLevelStorageAccess(legacyStorageAccess);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean unload(World world, boolean save) {
|
||||
return WorldLifecycleSupport.unloadWorld(capabilities, world, save);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String backendName() {
|
||||
return "paper_like_runtime";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String describeSelectionReason() {
|
||||
return "server family " + capabilities.serverFamily().id() + " exposes paper-like runtime world lifecycle capabilities";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package art.arcane.iris.core.lifecycle;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
public enum ServerFamily {
|
||||
BUKKIT,
|
||||
SPIGOT,
|
||||
PAPER,
|
||||
PURPUR,
|
||||
FOLIA,
|
||||
CANVAS,
|
||||
UNKNOWN;
|
||||
|
||||
public boolean isPaperLike() {
|
||||
return this == PAPER || this == PURPUR || this == FOLIA || this == CANVAS;
|
||||
}
|
||||
|
||||
public String id() {
|
||||
return name().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package art.arcane.iris.core.lifecycle;
|
||||
|
||||
import org.bukkit.World;
|
||||
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
public interface WorldLifecycleBackend {
|
||||
boolean supports(WorldLifecycleRequest request, CapabilitySnapshot capabilities);
|
||||
|
||||
CompletableFuture<World> create(WorldLifecycleRequest request);
|
||||
|
||||
boolean unload(World world, boolean save);
|
||||
|
||||
String backendName();
|
||||
|
||||
String describeSelectionReason();
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package art.arcane.iris.core.lifecycle;
|
||||
|
||||
public enum WorldLifecycleCaller {
|
||||
STUDIO,
|
||||
CREATE,
|
||||
BENCHMARK
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package art.arcane.iris.core.lifecycle;
|
||||
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.WorldCreator;
|
||||
import org.bukkit.WorldType;
|
||||
import org.bukkit.generator.BiomeProvider;
|
||||
import org.bukkit.generator.ChunkGenerator;
|
||||
|
||||
public record WorldLifecycleRequest(
|
||||
String worldName,
|
||||
World.Environment environment,
|
||||
ChunkGenerator generator,
|
||||
BiomeProvider biomeProvider,
|
||||
WorldType worldType,
|
||||
boolean generateStructures,
|
||||
boolean hardcore,
|
||||
long seed,
|
||||
boolean studio,
|
||||
boolean benchmark,
|
||||
WorldLifecycleCaller callerKind
|
||||
) {
|
||||
public static WorldLifecycleRequest fromCreator(WorldCreator creator, boolean studio, boolean benchmark, WorldLifecycleCaller callerKind) {
|
||||
return new WorldLifecycleRequest(
|
||||
creator.name(),
|
||||
creator.environment(),
|
||||
creator.generator(),
|
||||
creator.biomeProvider(),
|
||||
creator.type(),
|
||||
creator.generateStructures(),
|
||||
creator.hardcore(),
|
||||
creator.seed(),
|
||||
studio,
|
||||
benchmark,
|
||||
callerKind
|
||||
);
|
||||
}
|
||||
|
||||
public WorldCreator toWorldCreator() {
|
||||
WorldCreator creator = new WorldCreator(worldName)
|
||||
.environment(environment)
|
||||
.generateStructures(generateStructures)
|
||||
.hardcore(hardcore)
|
||||
.type(worldType)
|
||||
.seed(seed)
|
||||
.generator(generator);
|
||||
if (biomeProvider != null) {
|
||||
creator.biomeProvider(biomeProvider);
|
||||
}
|
||||
return creator;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package art.arcane.iris.core.lifecycle;
|
||||
|
||||
import art.arcane.iris.Iris;
|
||||
import art.arcane.iris.util.common.scheduling.J;
|
||||
import org.bukkit.World;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
public final class WorldLifecycleService {
|
||||
private static volatile WorldLifecycleService instance;
|
||||
|
||||
private final CapabilitySnapshot capabilities;
|
||||
private final WorldsProviderBackend worldsProviderBackend;
|
||||
private final PaperLikeRuntimeBackend paperLikeRuntimeBackend;
|
||||
private final BukkitPublicBackend bukkitPublicBackend;
|
||||
private final List<WorldLifecycleBackend> backends;
|
||||
private final Map<String, String> worldBackendByName;
|
||||
|
||||
public WorldLifecycleService(CapabilitySnapshot capabilities) {
|
||||
this.capabilities = capabilities;
|
||||
this.worldsProviderBackend = new WorldsProviderBackend(capabilities);
|
||||
this.paperLikeRuntimeBackend = new PaperLikeRuntimeBackend(capabilities);
|
||||
this.bukkitPublicBackend = new BukkitPublicBackend(capabilities);
|
||||
this.backends = List.of(worldsProviderBackend, paperLikeRuntimeBackend, bukkitPublicBackend);
|
||||
this.worldBackendByName = new ConcurrentHashMap<>();
|
||||
}
|
||||
|
||||
public static WorldLifecycleService get() {
|
||||
WorldLifecycleService current = instance;
|
||||
if (current != null) {
|
||||
return current;
|
||||
}
|
||||
|
||||
synchronized (WorldLifecycleService.class) {
|
||||
if (instance != null) {
|
||||
return instance;
|
||||
}
|
||||
|
||||
CapabilitySnapshot capabilities = CapabilitySnapshot.probe();
|
||||
instance = new WorldLifecycleService(capabilities);
|
||||
Iris.info("WorldLifecycle capabilities: %s", capabilities.describe());
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
|
||||
public CapabilitySnapshot capabilities() {
|
||||
return capabilities;
|
||||
}
|
||||
|
||||
public CompletableFuture<World> create(WorldLifecycleRequest request) {
|
||||
WorldLifecycleBackend backend;
|
||||
try {
|
||||
backend = selectCreateBackend(request);
|
||||
} catch (Throwable e) {
|
||||
Iris.reportError("WorldLifecycle create backend selection failed for world=\"" + request.worldName()
|
||||
+ "\", caller=" + request.callerKind().name().toLowerCase() + ".", e);
|
||||
return CompletableFuture.failedFuture(e);
|
||||
}
|
||||
Iris.info("WorldLifecycle create: world=%s, caller=%s, backend=%s, reason=%s",
|
||||
request.worldName(),
|
||||
request.callerKind().name().toLowerCase(),
|
||||
backend.backendName(),
|
||||
backend.describeSelectionReason());
|
||||
return backend.create(request).whenComplete((world, throwable) -> {
|
||||
if (throwable != null) {
|
||||
Throwable cause = WorldLifecycleSupport.unwrap(throwable);
|
||||
Iris.reportError("WorldLifecycle create failed: world=\"" + request.worldName()
|
||||
+ "\", caller=" + request.callerKind().name().toLowerCase()
|
||||
+ ", backend=" + backend.backendName()
|
||||
+ ", family=" + capabilities.serverFamily().id() + ".", cause);
|
||||
return;
|
||||
}
|
||||
if (world != null) {
|
||||
worldBackendByName.put(world.getName(), backend.backendName());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public World createBlocking(WorldLifecycleRequest request) {
|
||||
try {
|
||||
return create(request).join();
|
||||
} catch (CompletionException e) {
|
||||
throw new IllegalStateException(WorldLifecycleSupport.unwrap(e));
|
||||
}
|
||||
}
|
||||
|
||||
public boolean unload(World world, boolean save) {
|
||||
if (!J.isPrimaryThread()) {
|
||||
CompletableFuture<Boolean> future = new CompletableFuture<>();
|
||||
J.s(() -> {
|
||||
try {
|
||||
future.complete(unloadDirect(world, save));
|
||||
} catch (Throwable e) {
|
||||
future.completeExceptionally(e);
|
||||
}
|
||||
});
|
||||
return future.join();
|
||||
}
|
||||
|
||||
return unloadDirect(world, save);
|
||||
}
|
||||
|
||||
private boolean unloadDirect(World world, boolean save) {
|
||||
WorldLifecycleBackend backend = selectUnloadBackend(world.getName());
|
||||
Iris.info("WorldLifecycle unload: world=%s, backend=%s, reason=%s",
|
||||
world.getName(),
|
||||
backend.backendName(),
|
||||
backend.describeSelectionReason());
|
||||
boolean unloaded;
|
||||
try {
|
||||
unloaded = backend.unload(world, save);
|
||||
} catch (Throwable e) {
|
||||
Iris.reportError("WorldLifecycle unload failed: world=\"" + world.getName()
|
||||
+ "\", backend=" + backend.backendName()
|
||||
+ ", family=" + capabilities.serverFamily().id() + ".", e);
|
||||
if (e instanceof RuntimeException runtimeException) {
|
||||
throw runtimeException;
|
||||
}
|
||||
if (e instanceof Error error) {
|
||||
throw error;
|
||||
}
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
if (unloaded) {
|
||||
worldBackendByName.remove(world.getName());
|
||||
}
|
||||
return unloaded;
|
||||
}
|
||||
|
||||
public String backendNameForWorld(String worldName) {
|
||||
return selectUnloadBackend(worldName).backendName();
|
||||
}
|
||||
|
||||
WorldLifecycleBackend selectCreateBackend(WorldLifecycleRequest request) {
|
||||
if (worldsProviderBackend.supports(request, capabilities)) {
|
||||
return worldsProviderBackend;
|
||||
}
|
||||
|
||||
if (request.studio() && capabilities.serverFamily().isPaperLike()) {
|
||||
if (!paperLikeRuntimeBackend.supports(request, capabilities)) {
|
||||
throw new IllegalStateException("World lifecycle backend paper_like_runtime is unavailable for studio create on "
|
||||
+ capabilities.serverFamily().id() + ": " + capabilities.paperLikeResolution());
|
||||
}
|
||||
return paperLikeRuntimeBackend;
|
||||
}
|
||||
|
||||
for (WorldLifecycleBackend backend : backends) {
|
||||
if (backend.supports(request, capabilities)) {
|
||||
return backend;
|
||||
}
|
||||
}
|
||||
|
||||
throw new IllegalStateException("No world lifecycle backend supports request for \"" + request.worldName() + "\".");
|
||||
}
|
||||
|
||||
WorldLifecycleBackend selectUnloadBackend(String worldName) {
|
||||
String backendName = worldBackendByName.get(worldName);
|
||||
if (backendName == null) {
|
||||
return bukkitPublicBackend;
|
||||
}
|
||||
|
||||
for (WorldLifecycleBackend backend : backends) {
|
||||
if (backend.backendName().equals(backendName)) {
|
||||
return backend;
|
||||
}
|
||||
}
|
||||
|
||||
return bukkitPublicBackend;
|
||||
}
|
||||
|
||||
void rememberBackend(String worldName, String backendName) {
|
||||
worldBackendByName.put(worldName, backendName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package art.arcane.iris.core.lifecycle;
|
||||
|
||||
import org.bukkit.generator.BiomeProvider;
|
||||
import org.bukkit.generator.ChunkGenerator;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
public final class WorldLifecycleStaging {
|
||||
private static final Map<String, ChunkGenerator> stagedGenerators = new ConcurrentHashMap<>();
|
||||
private static final Map<String, BiomeProvider> stagedBiomeProviders = new ConcurrentHashMap<>();
|
||||
private static final Map<String, ChunkGenerator> stagedStemGenerators = new ConcurrentHashMap<>();
|
||||
|
||||
private WorldLifecycleStaging() {
|
||||
}
|
||||
|
||||
public static void stageGenerator(@NotNull String worldName, @NotNull ChunkGenerator generator, @Nullable BiomeProvider biomeProvider) {
|
||||
stagedGenerators.put(worldName, generator);
|
||||
if (biomeProvider != null) {
|
||||
stagedBiomeProviders.put(worldName, biomeProvider);
|
||||
} else {
|
||||
stagedBiomeProviders.remove(worldName);
|
||||
}
|
||||
}
|
||||
|
||||
public static void stageStemGenerator(@NotNull String worldName, @NotNull ChunkGenerator generator) {
|
||||
stagedStemGenerators.put(worldName, generator);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static ChunkGenerator consumeGenerator(@NotNull String worldName) {
|
||||
return stagedGenerators.remove(worldName);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static BiomeProvider consumeBiomeProvider(@NotNull String worldName) {
|
||||
return stagedBiomeProviders.remove(worldName);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static ChunkGenerator consumeStemGenerator(@NotNull String worldName) {
|
||||
return stagedStemGenerators.remove(worldName);
|
||||
}
|
||||
|
||||
public static void clearGenerator(@NotNull String worldName) {
|
||||
stagedGenerators.remove(worldName);
|
||||
stagedBiomeProviders.remove(worldName);
|
||||
}
|
||||
|
||||
public static void clearStem(@NotNull String worldName) {
|
||||
stagedStemGenerators.remove(worldName);
|
||||
}
|
||||
|
||||
public static void clearAll(@NotNull String worldName) {
|
||||
clearGenerator(worldName);
|
||||
clearStem(worldName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
package art.arcane.iris.core.lifecycle;
|
||||
|
||||
import art.arcane.iris.Iris;
|
||||
import art.arcane.iris.core.link.Identifier;
|
||||
import art.arcane.iris.core.nms.INMS;
|
||||
import art.arcane.iris.core.nms.INMSBinding;
|
||||
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
|
||||
import art.arcane.iris.util.common.scheduling.J;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.configuration.ConfigurationSection;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.bukkit.generator.ChunkGenerator;
|
||||
|
||||
import java.io.File;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
final class WorldLifecycleSupport {
|
||||
private WorldLifecycleSupport() {
|
||||
}
|
||||
|
||||
static Throwable unwrap(Throwable throwable) {
|
||||
if (throwable instanceof InvocationTargetException invocationTargetException && invocationTargetException.getCause() != null) {
|
||||
return unwrap(invocationTargetException.getCause());
|
||||
}
|
||||
if (throwable instanceof java.util.concurrent.CompletionException completionException && completionException.getCause() != null) {
|
||||
return unwrap(completionException.getCause());
|
||||
}
|
||||
if (throwable instanceof ExecutionException executionException && executionException.getCause() != null) {
|
||||
return unwrap(executionException.getCause());
|
||||
}
|
||||
return throwable;
|
||||
}
|
||||
|
||||
static Object invoke(Method method, Object target, Object... args) throws ReflectiveOperationException {
|
||||
return method.invoke(target, args);
|
||||
}
|
||||
|
||||
static Object invokeNamed(Object target, String methodName, Class<?>[] parameterTypes, Object... args) throws ReflectiveOperationException {
|
||||
Method method = target.getClass().getMethod(methodName, parameterTypes);
|
||||
return method.invoke(target, args);
|
||||
}
|
||||
|
||||
static Object read(Field field, Object target) throws IllegalAccessException {
|
||||
return field.get(target);
|
||||
}
|
||||
|
||||
static void stageRuntimeConfiguration(String worldName) throws ReflectiveOperationException {
|
||||
Object bukkitServer = Bukkit.getServer();
|
||||
if (bukkitServer == null) {
|
||||
throw new IllegalStateException("Bukkit server is unavailable.");
|
||||
}
|
||||
|
||||
Field configurationField = CapabilityResolution.resolveField(bukkitServer.getClass(), "configuration");
|
||||
Object rawConfiguration = configurationField.get(bukkitServer);
|
||||
if (!(rawConfiguration instanceof YamlConfiguration configuration)) {
|
||||
throw new IllegalStateException("CraftServer configuration field is unavailable.");
|
||||
}
|
||||
|
||||
ConfigurationSection worldsSection = configuration.getConfigurationSection("worlds");
|
||||
if (worldsSection == null) {
|
||||
worldsSection = configuration.createSection("worlds");
|
||||
}
|
||||
|
||||
ConfigurationSection worldSection = worldsSection.getConfigurationSection(worldName);
|
||||
if (worldSection == null) {
|
||||
worldSection = worldsSection.createSection(worldName);
|
||||
}
|
||||
|
||||
worldSection.set("generator", "Iris:runtime");
|
||||
}
|
||||
|
||||
static Object getRuntimeDatapackDimensions(CapabilitySnapshot capabilities) throws ReflectiveOperationException {
|
||||
Object worldLoaderContext = read(capabilities.worldLoaderContextField(), capabilities.minecraftServer());
|
||||
Method datapackDimensionsMethod = CapabilityResolution.resolveMethod(worldLoaderContext.getClass(), "datapackDimensions", method -> method.getParameterCount() == 0);
|
||||
if (datapackDimensionsMethod == null) {
|
||||
throw new IllegalStateException("DataLoadContext does not expose datapackDimensions().");
|
||||
}
|
||||
Object datapackDimensions = datapackDimensionsMethod.invoke(worldLoaderContext);
|
||||
if (datapackDimensions == null) {
|
||||
throw new IllegalStateException("DataLoadContext.datapackDimensions() returned null.");
|
||||
}
|
||||
return datapackDimensions;
|
||||
}
|
||||
|
||||
static Object getRuntimeServerRegistryAccess(CapabilitySnapshot capabilities) throws ReflectiveOperationException {
|
||||
Method registryAccessMethod = capabilities.serverRegistryAccessMethod();
|
||||
if (registryAccessMethod == null) {
|
||||
throw new IllegalStateException("MinecraftServer does not expose registryAccess().");
|
||||
}
|
||||
Object registryAccess = registryAccessMethod.invoke(capabilities.minecraftServer());
|
||||
if (registryAccess == null) {
|
||||
throw new IllegalStateException("MinecraftServer.registryAccess() returned null.");
|
||||
}
|
||||
return registryAccess;
|
||||
}
|
||||
|
||||
static Object getRuntimeLevelStemRegistry(CapabilitySnapshot capabilities) throws ReflectiveOperationException {
|
||||
Object datapackDimensions = getRuntimeDatapackDimensions(capabilities);
|
||||
Object levelStemRegistryKey = Class.forName("net.minecraft.core.registries.Registries")
|
||||
.getField("LEVEL_STEM")
|
||||
.get(null);
|
||||
Method lookupMethod = CapabilityResolution.resolveMethod(datapackDimensions.getClass(), "lookupOrThrow", method -> method.getParameterCount() == 1);
|
||||
if (lookupMethod == null) {
|
||||
throw new IllegalStateException("Registry access does not expose lookupOrThrow(...).");
|
||||
}
|
||||
return lookupMethod.invoke(datapackDimensions, levelStemRegistryKey);
|
||||
}
|
||||
|
||||
static Object createRuntimeLevelStemKey(String worldName) throws ReflectiveOperationException {
|
||||
String sanitized = worldName.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9/_-]", "_");
|
||||
String path = "runtime/" + sanitized;
|
||||
Identifier identifier = new Identifier("iris", path);
|
||||
Object rawIdentifier = Class.forName("net.minecraft.resources.Identifier")
|
||||
.getMethod("fromNamespaceAndPath", String.class, String.class)
|
||||
.invoke(null, identifier.namespace(), identifier.key());
|
||||
Object registryKey = Class.forName("net.minecraft.core.registries.Registries")
|
||||
.getField("LEVEL_STEM")
|
||||
.get(null);
|
||||
Method createMethod = Class.forName("net.minecraft.resources.ResourceKey")
|
||||
.getMethod("create", registryKey.getClass(), rawIdentifier.getClass());
|
||||
return createMethod.invoke(null, registryKey, rawIdentifier);
|
||||
}
|
||||
|
||||
static Object createDimensionKey(Object stemKey) throws ReflectiveOperationException {
|
||||
Class<?> resourceKeyClass = Class.forName("net.minecraft.resources.ResourceKey");
|
||||
Method identifierMethod = CapabilityResolution.resolveMethod(resourceKeyClass, "identifier", method -> method.getParameterCount() == 0);
|
||||
Object identifier = identifierMethod.invoke(stemKey);
|
||||
Object dimensionRegistryKey = Class.forName("net.minecraft.core.registries.Registries")
|
||||
.getField("DIMENSION")
|
||||
.get(null);
|
||||
Method createMethod = resourceKeyClass.getMethod("create", dimensionRegistryKey.getClass(), identifier.getClass());
|
||||
return createMethod.invoke(null, dimensionRegistryKey, identifier);
|
||||
}
|
||||
|
||||
static Object resolveRuntimeLevelStem(CapabilitySnapshot capabilities, WorldLifecycleRequest request) throws ReflectiveOperationException {
|
||||
return resolveRuntimeLevelStem(capabilities, request, INMS.get());
|
||||
}
|
||||
|
||||
static Object resolveRuntimeLevelStem(CapabilitySnapshot capabilities, WorldLifecycleRequest request, INMSBinding binding) throws ReflectiveOperationException {
|
||||
ChunkGenerator generator = request.generator();
|
||||
if (generator instanceof PlatformChunkGenerator) {
|
||||
Object registryAccess = getRuntimeServerRegistryAccess(capabilities);
|
||||
try {
|
||||
Object levelStem = binding.createRuntimeLevelStem(registryAccess, generator);
|
||||
if (levelStem == null) {
|
||||
throw new IllegalStateException("Iris NMS binding returned null runtime LevelStem.");
|
||||
}
|
||||
return levelStem;
|
||||
} catch (Throwable e) {
|
||||
throw new IllegalStateException("Failed to create runtime LevelStem from full server registry access for world \"" + request.worldName() + "\".", unwrap(e));
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
Object levelStemRegistry = getRuntimeLevelStemRegistry(capabilities);
|
||||
Object overworldKey = Class.forName("net.minecraft.world.level.dimension.LevelStem")
|
||||
.getField("OVERWORLD")
|
||||
.get(null);
|
||||
Method getValueMethod = CapabilityResolution.resolveMethod(levelStemRegistry.getClass(), "getValue", method -> method.getParameterCount() == 1);
|
||||
if (getValueMethod != null) {
|
||||
Object resolved = getValueMethod.invoke(levelStemRegistry, overworldKey);
|
||||
if (resolved != null) {
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
|
||||
Method getMethod = CapabilityResolution.resolveMethod(levelStemRegistry.getClass(), "get", method -> method.getParameterCount() == 1);
|
||||
if (getMethod == null) {
|
||||
throw new IllegalStateException("Unable to resolve OVERWORLD LevelStem from registry.");
|
||||
}
|
||||
Object raw = getMethod.invoke(levelStemRegistry, overworldKey);
|
||||
return extractRegistryValue(raw);
|
||||
} catch (Throwable e) {
|
||||
throw new IllegalStateException("Failed to resolve fallback OVERWORLD LevelStem from datapack registry access for world \"" + request.worldName() + "\".", unwrap(e));
|
||||
}
|
||||
}
|
||||
|
||||
static String runtimeLevelStemRegistrySource(WorldLifecycleRequest request) {
|
||||
if (request.generator() instanceof PlatformChunkGenerator) {
|
||||
return "full_server_registry";
|
||||
}
|
||||
return "datapack_level_stem_registry";
|
||||
}
|
||||
|
||||
static Object extractRegistryValue(Object raw) throws ReflectiveOperationException {
|
||||
if (raw == null) {
|
||||
return null;
|
||||
}
|
||||
if (raw instanceof Optional<?> optional) {
|
||||
Object nested = optional.orElse(null);
|
||||
if (nested == null) {
|
||||
return null;
|
||||
}
|
||||
return extractRegistryValue(nested);
|
||||
}
|
||||
Method valueMethod = CapabilityResolution.resolveMethod(raw.getClass(), "value", method -> method.getParameterCount() == 0);
|
||||
if (valueMethod != null) {
|
||||
return valueMethod.invoke(raw);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
static void applyWorldDataNameAndModInfo(CapabilitySnapshot capabilities, Object worldDataAndGenSettings, String worldName) throws ReflectiveOperationException {
|
||||
Method dataMethod = CapabilityResolution.resolveMethod(worldDataAndGenSettings.getClass(), "data", method -> method.getParameterCount() == 0);
|
||||
if (dataMethod == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object worldData = dataMethod.invoke(worldDataAndGenSettings);
|
||||
if (worldData == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Method checkNameMethod = CapabilityResolution.resolveMethod(worldData.getClass(), "checkName", method -> {
|
||||
Class<?>[] params = method.getParameterTypes();
|
||||
return params.length == 1 && String.class.equals(params[0]);
|
||||
});
|
||||
if (checkNameMethod != null) {
|
||||
checkNameMethod.invoke(worldData, worldName);
|
||||
}
|
||||
|
||||
Method getModdedStatusMethod = CapabilityResolution.resolveMethod(capabilities.minecraftServer().getClass(), "getModdedStatus", method -> method.getParameterCount() == 0);
|
||||
Method getServerModNameMethod = CapabilityResolution.resolveMethod(capabilities.minecraftServer().getClass(), "getServerModName", method -> method.getParameterCount() == 0);
|
||||
if (getModdedStatusMethod == null || getServerModNameMethod == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object modCheck = getModdedStatusMethod.invoke(capabilities.minecraftServer());
|
||||
Method shouldReportAsModifiedMethod = CapabilityResolution.resolveMethod(modCheck.getClass(), "shouldReportAsModified", method -> method.getParameterCount() == 0);
|
||||
Method setModdedInfoMethod = CapabilityResolution.resolveMethod(worldData.getClass(), "setModdedInfo", method -> {
|
||||
Class<?>[] params = method.getParameterTypes();
|
||||
return params.length == 2 && String.class.equals(params[0]) && boolean.class.equals(params[1]);
|
||||
});
|
||||
if (shouldReportAsModifiedMethod == null || setModdedInfoMethod == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
boolean modified = Boolean.TRUE.equals(shouldReportAsModifiedMethod.invoke(modCheck));
|
||||
String modName = (String) getServerModNameMethod.invoke(capabilities.minecraftServer());
|
||||
setModdedInfoMethod.invoke(worldData, modName, modified);
|
||||
}
|
||||
|
||||
static Object createCurrentWorldDataAndSettings(CapabilitySnapshot capabilities, String worldName) throws ReflectiveOperationException {
|
||||
Object settings = read(capabilities.settingsField(), capabilities.minecraftServer());
|
||||
Object worldLoaderContext = read(capabilities.worldLoaderContextField(), capabilities.minecraftServer());
|
||||
Object levelStemRegistry = getRuntimeLevelStemRegistry(capabilities);
|
||||
boolean demo = Boolean.TRUE.equals(capabilities.isDemoMethod().invoke(capabilities.minecraftServer()));
|
||||
Object options = read(capabilities.optionsField(), capabilities.minecraftServer());
|
||||
Method hasMethod = CapabilityResolution.resolveMethod(options.getClass(), "has", method -> {
|
||||
Class<?>[] params = method.getParameterTypes();
|
||||
return params.length == 1 && String.class.equals(params[0]);
|
||||
});
|
||||
boolean bonusChest = hasMethod != null && Boolean.TRUE.equals(hasMethod.invoke(options, "bonusChest"));
|
||||
Object dataLoadOutput = capabilities.createNewWorldDataMethod().invoke(null, settings, worldLoaderContext, levelStemRegistry, demo, bonusChest);
|
||||
Method cookieMethod = CapabilityResolution.resolveMethod(dataLoadOutput.getClass(), "cookie", method -> method.getParameterCount() == 0);
|
||||
if (cookieMethod == null) {
|
||||
throw new IllegalStateException("WorldLoader.DataLoadOutput does not expose cookie().");
|
||||
}
|
||||
Object worldDataAndGenSettings = cookieMethod.invoke(dataLoadOutput);
|
||||
applyWorldDataNameAndModInfo(capabilities, worldDataAndGenSettings, worldName);
|
||||
return worldDataAndGenSettings;
|
||||
}
|
||||
|
||||
static Object createLegacyPrimaryLevelData(CapabilitySnapshot capabilities, Object levelStorageAccess, String worldName) throws ReflectiveOperationException {
|
||||
Object levelDataResult = capabilities.paperWorldDataMethod().invoke(null, levelStorageAccess);
|
||||
Method fatalErrorMethod = CapabilityResolution.resolveMethod(levelDataResult.getClass(), "fatalError", method -> method.getParameterCount() == 0);
|
||||
Method dataTagMethod = CapabilityResolution.resolveMethod(levelDataResult.getClass(), "dataTag", method -> method.getParameterCount() == 0);
|
||||
if (fatalErrorMethod != null && Boolean.TRUE.equals(fatalErrorMethod.invoke(levelDataResult))) {
|
||||
throw new IllegalStateException("Paper runtime world-data helper reported a fatal error for \"" + worldName + "\".");
|
||||
}
|
||||
if (dataTagMethod != null && dataTagMethod.invoke(levelDataResult) != null) {
|
||||
throw new IllegalStateException("Runtime world \"" + worldName + "\" already contains level data.");
|
||||
}
|
||||
|
||||
Object settings = read(capabilities.settingsField(), capabilities.minecraftServer());
|
||||
Object worldLoaderContext = read(capabilities.worldLoaderContextField(), capabilities.minecraftServer());
|
||||
Object levelStemRegistry = getRuntimeLevelStemRegistry(capabilities);
|
||||
boolean demo = Boolean.TRUE.equals(capabilities.isDemoMethod().invoke(capabilities.minecraftServer()));
|
||||
Object options = read(capabilities.optionsField(), capabilities.minecraftServer());
|
||||
Method hasMethod = CapabilityResolution.resolveMethod(options.getClass(), "has", method -> {
|
||||
Class<?>[] params = method.getParameterTypes();
|
||||
return params.length == 1 && String.class.equals(params[0]);
|
||||
});
|
||||
boolean bonusChest = hasMethod != null && Boolean.TRUE.equals(hasMethod.invoke(options, "bonusChest"));
|
||||
Object dataLoadOutput = capabilities.createNewWorldDataMethod().invoke(null, settings, worldLoaderContext, levelStemRegistry, demo, bonusChest);
|
||||
Method cookieMethod = CapabilityResolution.resolveMethod(dataLoadOutput.getClass(), "cookie", method -> method.getParameterCount() == 0);
|
||||
if (cookieMethod == null) {
|
||||
throw new IllegalStateException("WorldLoader.DataLoadOutput does not expose cookie().");
|
||||
}
|
||||
Object primaryLevelData = cookieMethod.invoke(dataLoadOutput);
|
||||
|
||||
Method checkNameMethod = CapabilityResolution.resolveMethod(primaryLevelData.getClass(), "checkName", method -> {
|
||||
Class<?>[] params = method.getParameterTypes();
|
||||
return params.length == 1 && String.class.equals(params[0]);
|
||||
});
|
||||
if (checkNameMethod != null) {
|
||||
checkNameMethod.invoke(primaryLevelData, worldName);
|
||||
}
|
||||
|
||||
Method getModdedStatusMethod = CapabilityResolution.resolveMethod(capabilities.minecraftServer().getClass(), "getModdedStatus", method -> method.getParameterCount() == 0);
|
||||
Method getServerModNameMethod = CapabilityResolution.resolveMethod(capabilities.minecraftServer().getClass(), "getServerModName", method -> method.getParameterCount() == 0);
|
||||
if (getModdedStatusMethod != null && getServerModNameMethod != null) {
|
||||
Object modCheck = getModdedStatusMethod.invoke(capabilities.minecraftServer());
|
||||
Method shouldReportAsModifiedMethod = CapabilityResolution.resolveMethod(modCheck.getClass(), "shouldReportAsModified", method -> method.getParameterCount() == 0);
|
||||
Method setModdedInfoMethod = CapabilityResolution.resolveMethod(primaryLevelData.getClass(), "setModdedInfo", method -> {
|
||||
Class<?>[] params = method.getParameterTypes();
|
||||
return params.length == 2 && String.class.equals(params[0]) && boolean.class.equals(params[1]);
|
||||
});
|
||||
if (shouldReportAsModifiedMethod != null && setModdedInfoMethod != null) {
|
||||
boolean modified = Boolean.TRUE.equals(shouldReportAsModifiedMethod.invoke(modCheck));
|
||||
String modName = (String) getServerModNameMethod.invoke(capabilities.minecraftServer());
|
||||
setModdedInfoMethod.invoke(primaryLevelData, modName, modified);
|
||||
}
|
||||
}
|
||||
|
||||
return primaryLevelData;
|
||||
}
|
||||
|
||||
static Object createLegacyStorageAccess(CapabilitySnapshot capabilities, String worldName) throws ReflectiveOperationException {
|
||||
Class<?> levelStorageSourceClass = Class.forName("net.minecraft.world.level.storage.LevelStorageSource");
|
||||
Method createDefaultMethod = levelStorageSourceClass.getMethod("createDefault", Path.class);
|
||||
Object levelStorageSource = createDefaultMethod.invoke(null, Bukkit.getWorldContainer().toPath());
|
||||
Method storageAccessMethod = capabilities.levelStorageAccessMethod();
|
||||
if (storageAccessMethod.getParameterCount() == 1) {
|
||||
return storageAccessMethod.invoke(levelStorageSource, worldName);
|
||||
}
|
||||
Object overworldStemKey = Class.forName("net.minecraft.world.level.dimension.LevelStem")
|
||||
.getField("OVERWORLD")
|
||||
.get(null);
|
||||
return storageAccessMethod.invoke(levelStorageSource, worldName, overworldStemKey);
|
||||
}
|
||||
|
||||
static void closeLevelStorageAccess(Object levelStorageAccess) {
|
||||
if (levelStorageAccess == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Method closeMethod = levelStorageAccess.getClass().getMethod("close");
|
||||
closeMethod.invoke(levelStorageAccess);
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
static boolean unloadWorld(CapabilitySnapshot capabilities, World world, boolean save) {
|
||||
if (world == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
CompletableFuture<Boolean> asyncUnload = unloadWorldViaAsyncApi(capabilities, world, save);
|
||||
if (asyncUnload != null) {
|
||||
return resolveAsyncUnload(asyncUnload);
|
||||
}
|
||||
|
||||
try {
|
||||
return Bukkit.unloadWorld(world, save);
|
||||
} catch (UnsupportedOperationException unsupported) {
|
||||
if (capabilities.minecraftServer() == null || capabilities.removeLevelMethod() == null) {
|
||||
throw unsupported;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (save) {
|
||||
world.save();
|
||||
}
|
||||
|
||||
Method getHandleMethod = world.getClass().getMethod("getHandle");
|
||||
Object serverLevel = getHandleMethod.invoke(world);
|
||||
closeServerLevel(world, serverLevel);
|
||||
detachServerLevel(capabilities, serverLevel, world.getName());
|
||||
return Bukkit.getWorld(world.getName()) == null;
|
||||
} catch (Throwable e) {
|
||||
throw new IllegalStateException("Failed to unload world \"" + world.getName() + "\" through the selected world lifecycle backend.", unwrap(e));
|
||||
}
|
||||
}
|
||||
|
||||
private static CompletableFuture<Boolean> unloadWorldViaAsyncApi(CapabilitySnapshot capabilities, World world, boolean save) {
|
||||
if (capabilities.unloadWorldAsyncMethod() == null || capabilities.bukkitServer() == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
CompletableFuture<Boolean> callbackFuture = new CompletableFuture<>();
|
||||
Runnable invokeTask = () -> {
|
||||
Consumer<Boolean> callback = result -> callbackFuture.complete(Boolean.TRUE.equals(result));
|
||||
try {
|
||||
capabilities.unloadWorldAsyncMethod().invoke(capabilities.bukkitServer(), world, save, callback);
|
||||
} catch (Throwable e) {
|
||||
callbackFuture.completeExceptionally(unwrap(e));
|
||||
}
|
||||
};
|
||||
|
||||
if (J.isFolia() && !isGlobalTickThread()) {
|
||||
CompletableFuture<Void> scheduled = J.sfut(invokeTask);
|
||||
if (scheduled == null) {
|
||||
callbackFuture.completeExceptionally(new IllegalStateException("Failed to schedule global unload task."));
|
||||
return callbackFuture;
|
||||
}
|
||||
scheduled.whenComplete((unused, throwable) -> {
|
||||
if (throwable != null) {
|
||||
callbackFuture.completeExceptionally(unwrap(throwable));
|
||||
}
|
||||
});
|
||||
return callbackFuture;
|
||||
}
|
||||
|
||||
invokeTask.run();
|
||||
return callbackFuture;
|
||||
}
|
||||
|
||||
private static boolean resolveAsyncUnload(CompletableFuture<Boolean> asyncUnload) {
|
||||
if (J.isPrimaryThread()) {
|
||||
if (!asyncUnload.isDone()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
return Boolean.TRUE.equals(asyncUnload.join());
|
||||
} catch (Throwable e) {
|
||||
throw new IllegalStateException("Failed to consume async world unload result.", unwrap(e));
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return Boolean.TRUE.equals(asyncUnload.get(120, TimeUnit.SECONDS));
|
||||
} catch (Throwable e) {
|
||||
throw new IllegalStateException("Failed while waiting for async world unload result.", unwrap(e));
|
||||
}
|
||||
}
|
||||
|
||||
private static void closeServerLevel(World world, Object serverLevel) throws Throwable {
|
||||
Method closeMethod = CapabilityResolution.resolveMethod(serverLevel.getClass(), "close", method -> method.getParameterCount() == 0);
|
||||
if (closeMethod == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!J.isFolia()) {
|
||||
closeMethod.invoke(serverLevel);
|
||||
return;
|
||||
}
|
||||
|
||||
Location spawn = world.getSpawnLocation();
|
||||
int chunkX = spawn == null ? 0 : spawn.getBlockX() >> 4;
|
||||
int chunkZ = spawn == null ? 0 : spawn.getBlockZ() >> 4;
|
||||
CompletableFuture<Void> closeFuture = new CompletableFuture<>();
|
||||
boolean scheduled = J.runRegion(world, chunkX, chunkZ, () -> {
|
||||
try {
|
||||
closeMethod.invoke(serverLevel);
|
||||
closeFuture.complete(null);
|
||||
} catch (Throwable e) {
|
||||
closeFuture.completeExceptionally(unwrap(e));
|
||||
}
|
||||
});
|
||||
if (!scheduled) {
|
||||
throw new IllegalStateException("Failed to schedule region close task for world \"" + world.getName() + "\".");
|
||||
}
|
||||
closeFuture.get(90, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
@SuppressWarnings({"rawtypes", "unchecked"})
|
||||
private static void removeWorldFromCraftServerMap(String worldName) throws ReflectiveOperationException {
|
||||
Object bukkitServer = Bukkit.getServer();
|
||||
if (bukkitServer == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Field worldsField = CapabilityResolution.resolveField(bukkitServer.getClass(), "worlds");
|
||||
Object rawWorlds = worldsField.get(bukkitServer);
|
||||
if (rawWorlds instanceof Map map) {
|
||||
map.remove(worldName);
|
||||
map.remove(worldName.toLowerCase(Locale.ROOT));
|
||||
}
|
||||
}
|
||||
|
||||
private static void detachServerLevel(CapabilitySnapshot capabilities, Object serverLevel, String worldName) throws Throwable {
|
||||
Runnable detachTask = () -> {
|
||||
try {
|
||||
capabilities.removeLevelMethod().invoke(capabilities.minecraftServer(), serverLevel);
|
||||
removeWorldFromCraftServerMap(worldName);
|
||||
} catch (Throwable e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
};
|
||||
|
||||
if (!J.isFolia() || isGlobalTickThread()) {
|
||||
detachTask.run();
|
||||
return;
|
||||
}
|
||||
|
||||
CompletableFuture<Void> detachFuture = J.sfut(detachTask);
|
||||
if (detachFuture == null) {
|
||||
throw new IllegalStateException("Failed to schedule global detach task for world \"" + worldName + "\".");
|
||||
}
|
||||
detachFuture.get(15, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
static boolean isGlobalTickThread() {
|
||||
Object server = Bukkit.getServer();
|
||||
if (server == null) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
Method method = server.getClass().getMethod("isGlobalTickThread");
|
||||
return Boolean.TRUE.equals(method.invoke(server));
|
||||
} catch (Throwable ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package art.arcane.iris.core.lifecycle;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.WorldType;
|
||||
|
||||
import java.io.File;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
final class WorldsProviderBackend implements WorldLifecycleBackend {
|
||||
private final CapabilitySnapshot capabilities;
|
||||
|
||||
WorldsProviderBackend(CapabilitySnapshot capabilities) {
|
||||
this.capabilities = capabilities;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supports(WorldLifecycleRequest request, CapabilitySnapshot capabilities) {
|
||||
return request.studio() && capabilities.hasWorldsProvider();
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public CompletableFuture<World> create(WorldLifecycleRequest request) {
|
||||
try {
|
||||
Path worldPath = new File(Bukkit.getWorldContainer(), request.worldName()).toPath();
|
||||
Object builder = WorldLifecycleSupport.invokeNamed(capabilities.worldsProvider(), "levelBuilder", new Class[]{Path.class}, worldPath);
|
||||
builder = WorldLifecycleSupport.invokeNamed(builder, "name", new Class[]{String.class}, request.worldName());
|
||||
builder = WorldLifecycleSupport.invokeNamed(builder, "seed", new Class[]{long.class}, request.seed());
|
||||
builder = WorldLifecycleSupport.invokeNamed(builder, "levelStem", new Class[]{capabilities.worldsLevelStemClass()}, resolveLevelStem(request.environment()));
|
||||
builder = WorldLifecycleSupport.invokeNamed(builder, "chunkGenerator", new Class[]{org.bukkit.generator.ChunkGenerator.class}, request.generator());
|
||||
builder = WorldLifecycleSupport.invokeNamed(builder, "biomeProvider", new Class[]{org.bukkit.generator.BiomeProvider.class}, request.biomeProvider());
|
||||
builder = WorldLifecycleSupport.invokeNamed(builder, "generatorType", new Class[]{capabilities.worldsGeneratorTypeClass()}, resolveGeneratorType(request.worldType()));
|
||||
builder = WorldLifecycleSupport.invokeNamed(builder, "structures", new Class[]{boolean.class}, request.generateStructures());
|
||||
builder = WorldLifecycleSupport.invokeNamed(builder, "hardcore", new Class[]{boolean.class}, request.hardcore());
|
||||
Object levelBuilder = WorldLifecycleSupport.invokeNamed(builder, "build", new Class[0]);
|
||||
Object async = WorldLifecycleSupport.invokeNamed(levelBuilder, "createAsync", new Class[0]);
|
||||
if (async instanceof CompletableFuture<?> future) {
|
||||
return future.thenApply(world -> (World) world);
|
||||
}
|
||||
return CompletableFuture.failedFuture(new IllegalStateException("Worlds provider createAsync did not return CompletableFuture."));
|
||||
} catch (Throwable e) {
|
||||
return CompletableFuture.failedFuture(WorldLifecycleSupport.unwrap(e));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean unload(World world, boolean save) {
|
||||
return WorldLifecycleSupport.unloadWorld(capabilities, world, save);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String backendName() {
|
||||
return "worlds_provider";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String describeSelectionReason() {
|
||||
return "external Worlds provider is registered and healthy";
|
||||
}
|
||||
|
||||
@SuppressWarnings({"rawtypes", "unchecked"})
|
||||
private Object resolveLevelStem(World.Environment environment) {
|
||||
String key;
|
||||
if (environment == World.Environment.NETHER) {
|
||||
key = "NETHER";
|
||||
} else if (environment == World.Environment.THE_END) {
|
||||
key = "END";
|
||||
} else {
|
||||
key = "OVERWORLD";
|
||||
}
|
||||
Class<? extends Enum> enumClass = capabilities.worldsLevelStemClass().asSubclass(Enum.class);
|
||||
return Enum.valueOf(enumClass, key);
|
||||
}
|
||||
|
||||
@SuppressWarnings({"rawtypes", "unchecked"})
|
||||
private Object resolveGeneratorType(WorldType worldType) {
|
||||
String typeName = worldType == null ? "NORMAL" : worldType.getName();
|
||||
String key;
|
||||
if ("FLAT".equalsIgnoreCase(typeName)) {
|
||||
key = "FLAT";
|
||||
} else if ("AMPLIFIED".equalsIgnoreCase(typeName)) {
|
||||
key = "AMPLIFIED";
|
||||
} else if ("LARGE_BIOMES".equalsIgnoreCase(typeName) || "LARGEBIOMES".equalsIgnoreCase(typeName)) {
|
||||
key = "LARGE_BIOMES";
|
||||
} else {
|
||||
key = "NORMAL";
|
||||
}
|
||||
Class<? extends Enum> enumClass = capabilities.worldsGeneratorTypeClass().asSubclass(Enum.class);
|
||||
return Enum.valueOf(enumClass, key.toUpperCase(Locale.ROOT));
|
||||
}
|
||||
}
|
||||
@@ -157,7 +157,7 @@ public abstract class ExternalDataProvider implements Listener {
|
||||
protected static List<BlockProperty> YAW_FACE_BIOME_PROPERTIES = List.of(
|
||||
BlockProperty.ofEnum(BiomeColor.class, "matchBiome", null),
|
||||
BlockProperty.ofBoolean("randomYaw", false),
|
||||
BlockProperty.ofFloat("yaw", 0, 0, 360f, false, true),
|
||||
BlockProperty.ofDouble("yaw", 0, 0, 360f, false, true),
|
||||
BlockProperty.ofBoolean("randomFace", true),
|
||||
new BlockProperty(
|
||||
"face",
|
||||
|
||||
@@ -1,841 +0,0 @@
|
||||
package art.arcane.iris.core.link;
|
||||
|
||||
import art.arcane.iris.Iris;
|
||||
import art.arcane.iris.core.nms.INMS;
|
||||
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
|
||||
import art.arcane.iris.util.common.scheduling.J;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.configuration.ConfigurationSection;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.bukkit.plugin.RegisteredServiceProvider;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.WorldCreator;
|
||||
import org.bukkit.WorldType;
|
||||
import org.bukkit.generator.ChunkGenerator;
|
||||
|
||||
import java.io.File;
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Collection;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
public class FoliaWorldsLink {
|
||||
private static volatile FoliaWorldsLink instance;
|
||||
private final Object provider;
|
||||
private final Class<?> levelStemClass;
|
||||
private final Class<?> generatorTypeClass;
|
||||
private final Object minecraftServer;
|
||||
private final Method minecraftServerCreateLevelMethod;
|
||||
|
||||
private FoliaWorldsLink(
|
||||
Object provider,
|
||||
Class<?> levelStemClass,
|
||||
Class<?> generatorTypeClass,
|
||||
Object minecraftServer,
|
||||
Method minecraftServerCreateLevelMethod
|
||||
) {
|
||||
this.provider = provider;
|
||||
this.levelStemClass = levelStemClass;
|
||||
this.generatorTypeClass = generatorTypeClass;
|
||||
this.minecraftServer = minecraftServer;
|
||||
this.minecraftServerCreateLevelMethod = minecraftServerCreateLevelMethod;
|
||||
}
|
||||
|
||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||
public static FoliaWorldsLink get() {
|
||||
FoliaWorldsLink current = instance;
|
||||
if (current != null && current.isActive()) {
|
||||
return current;
|
||||
}
|
||||
|
||||
synchronized (FoliaWorldsLink.class) {
|
||||
if (instance != null && instance.isActive()) {
|
||||
return instance;
|
||||
}
|
||||
|
||||
Object loadedProvider = null;
|
||||
Class<?> loadedLevelStemClass = null;
|
||||
Class<?> loadedGeneratorTypeClass = null;
|
||||
Object loadedMinecraftServer = null;
|
||||
Method loadedMinecraftServerCreateLevelMethod = null;
|
||||
|
||||
try {
|
||||
Server.class.getDeclaredMethod("isGlobalTickThread");
|
||||
try {
|
||||
Class<?> worldsProviderClass = Class.forName("net.thenextlvl.worlds.api.WorldsProvider");
|
||||
loadedLevelStemClass = Class.forName("net.thenextlvl.worlds.api.generator.LevelStem");
|
||||
loadedGeneratorTypeClass = Class.forName("net.thenextlvl.worlds.api.generator.GeneratorType");
|
||||
loadedProvider = Bukkit.getServicesManager().load((Class) worldsProviderClass);
|
||||
} catch (Throwable ignored) {
|
||||
Object[] resolved = resolveProviderFromServices();
|
||||
loadedProvider = resolved[0];
|
||||
loadedLevelStemClass = (Class<?>) resolved[1];
|
||||
loadedGeneratorTypeClass = (Class<?>) resolved[2];
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
|
||||
try {
|
||||
Object bukkitServer = Bukkit.getServer();
|
||||
if (bukkitServer != null) {
|
||||
Method getServerMethod = bukkitServer.getClass().getMethod("getServer");
|
||||
Object candidateMinecraftServer = getServerMethod.invoke(bukkitServer);
|
||||
Class<?> minecraftServerClass = Class.forName("net.minecraft.server.MinecraftServer");
|
||||
if (minecraftServerClass.isInstance(candidateMinecraftServer)) {
|
||||
loadedMinecraftServerCreateLevelMethod = minecraftServerClass.getMethod(
|
||||
"createLevel",
|
||||
Class.forName("net.minecraft.world.level.dimension.LevelStem"),
|
||||
Class.forName("io.papermc.paper.world.PaperWorldLoader$WorldLoadingInfo"),
|
||||
Class.forName("net.minecraft.world.level.storage.LevelStorageSource$LevelStorageAccess"),
|
||||
Class.forName("net.minecraft.world.level.storage.PrimaryLevelData")
|
||||
);
|
||||
loadedMinecraftServer = candidateMinecraftServer;
|
||||
}
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
|
||||
instance = new FoliaWorldsLink(
|
||||
loadedProvider,
|
||||
loadedLevelStemClass,
|
||||
loadedGeneratorTypeClass,
|
||||
loadedMinecraftServer,
|
||||
loadedMinecraftServerCreateLevelMethod
|
||||
);
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isActive() {
|
||||
if (!J.isFolia()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isWorldsProviderActive() || isPaperWorldLoaderActive();
|
||||
}
|
||||
|
||||
public CompletableFuture<World> createWorld(WorldCreator creator) {
|
||||
if (isWorldsProviderActive()) {
|
||||
CompletableFuture<World> providerFuture = createWorldViaProvider(creator);
|
||||
if (providerFuture != null) {
|
||||
return providerFuture;
|
||||
}
|
||||
}
|
||||
|
||||
if (isPaperWorldLoaderActive()) {
|
||||
return createWorldViaPaperWorldLoader(creator);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public boolean unloadWorld(World world, boolean save) {
|
||||
if (world == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
CompletableFuture<Boolean> asyncWorldUnload = unloadWorldViaAsyncApi(world, save);
|
||||
if (asyncWorldUnload != null) {
|
||||
return resolveAsyncUnload(asyncWorldUnload);
|
||||
}
|
||||
|
||||
try {
|
||||
return Bukkit.unloadWorld(world, save);
|
||||
} catch (UnsupportedOperationException unsupported) {
|
||||
if (minecraftServer == null) {
|
||||
throw unsupported;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (save) {
|
||||
world.save();
|
||||
}
|
||||
|
||||
Object serverLevel = invoke(world, "getHandle");
|
||||
closeServerLevel(world, serverLevel);
|
||||
detachServerLevel(serverLevel, world.getName());
|
||||
return Bukkit.getWorld(world.getName()) == null;
|
||||
} catch (Throwable e) {
|
||||
throw new IllegalStateException("Failed to unload world \"" + world.getName() + "\" via Folia runtime world-loader bridge.", unwrap(e));
|
||||
}
|
||||
}
|
||||
|
||||
private boolean resolveAsyncUnload(CompletableFuture<Boolean> asyncWorldUnload) {
|
||||
if (J.isPrimaryThread()) {
|
||||
if (!asyncWorldUnload.isDone()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
return Boolean.TRUE.equals(asyncWorldUnload.join());
|
||||
} catch (Throwable e) {
|
||||
throw new IllegalStateException("Failed to consume async world unload result.", unwrap(e));
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return Boolean.TRUE.equals(asyncWorldUnload.get(120, TimeUnit.SECONDS));
|
||||
} catch (Throwable e) {
|
||||
throw new IllegalStateException("Failed while waiting for async world unload result.", unwrap(e));
|
||||
}
|
||||
}
|
||||
|
||||
private CompletableFuture<Boolean> unloadWorldViaAsyncApi(World world, boolean save) {
|
||||
Object bukkitServer = Bukkit.getServer();
|
||||
if (bukkitServer == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Method unloadWorldAsyncMethod;
|
||||
try {
|
||||
unloadWorldAsyncMethod = bukkitServer.getClass().getMethod("unloadWorldAsync", World.class, boolean.class, Consumer.class);
|
||||
} catch (Throwable ignored) {
|
||||
return null;
|
||||
}
|
||||
|
||||
CompletableFuture<Boolean> callbackFuture = new CompletableFuture<>();
|
||||
Runnable invokeTask = () -> {
|
||||
Consumer<Boolean> callback = result -> callbackFuture.complete(Boolean.TRUE.equals(result));
|
||||
try {
|
||||
unloadWorldAsyncMethod.invoke(bukkitServer, world, save, callback);
|
||||
} catch (Throwable e) {
|
||||
callbackFuture.completeExceptionally(unwrap(e));
|
||||
}
|
||||
};
|
||||
|
||||
if (J.isFolia() && !isGlobalTickThread()) {
|
||||
CompletableFuture<Void> scheduled = J.sfut(invokeTask);
|
||||
if (scheduled == null) {
|
||||
callbackFuture.completeExceptionally(new IllegalStateException("Failed to schedule global world-unload task."));
|
||||
return callbackFuture;
|
||||
}
|
||||
scheduled.whenComplete((unused, throwable) -> {
|
||||
if (throwable != null) {
|
||||
callbackFuture.completeExceptionally(unwrap(throwable));
|
||||
}
|
||||
});
|
||||
} else {
|
||||
invokeTask.run();
|
||||
}
|
||||
|
||||
return callbackFuture;
|
||||
}
|
||||
|
||||
private boolean isWorldsProviderActive() {
|
||||
return provider != null && levelStemClass != null && generatorTypeClass != null;
|
||||
}
|
||||
|
||||
private boolean isPaperWorldLoaderActive() {
|
||||
return minecraftServer != null && minecraftServerCreateLevelMethod != null;
|
||||
}
|
||||
|
||||
private CompletableFuture<World> createWorldViaProvider(WorldCreator creator) {
|
||||
try {
|
||||
Path worldPath = new File(Bukkit.getWorldContainer(), creator.name()).toPath();
|
||||
Object builder = invoke(provider, "levelBuilder", worldPath);
|
||||
builder = invoke(builder, "name", creator.name());
|
||||
builder = invoke(builder, "seed", creator.seed());
|
||||
builder = invoke(builder, "levelStem", resolveLevelStem(creator.environment()));
|
||||
builder = invoke(builder, "chunkGenerator", creator.generator());
|
||||
builder = invoke(builder, "biomeProvider", creator.biomeProvider());
|
||||
builder = invoke(builder, "generatorType", resolveGeneratorType(creator.type()));
|
||||
builder = invoke(builder, "structures", creator.generateStructures());
|
||||
builder = invoke(builder, "hardcore", creator.hardcore());
|
||||
Object levelBuilder = invoke(builder, "build");
|
||||
Object async = invoke(levelBuilder, "createAsync");
|
||||
if (async instanceof CompletableFuture<?> future) {
|
||||
return future.thenApply(world -> (World) world);
|
||||
}
|
||||
|
||||
return CompletableFuture.failedFuture(new IllegalStateException("Worlds provider createAsync did not return CompletableFuture."));
|
||||
} catch (Throwable e) {
|
||||
return CompletableFuture.failedFuture(e);
|
||||
}
|
||||
}
|
||||
|
||||
private CompletableFuture<World> createWorldViaPaperWorldLoader(WorldCreator creator) {
|
||||
Object levelStorageAccess = null;
|
||||
try {
|
||||
if (creator.environment() != World.Environment.NORMAL) {
|
||||
return CompletableFuture.failedFuture(new UnsupportedOperationException("PaperWorldLoader fallback only supports OVERWORLD worlds."));
|
||||
}
|
||||
|
||||
World existing = Bukkit.getWorld(creator.name());
|
||||
if (existing != null) {
|
||||
return CompletableFuture.completedFuture(existing);
|
||||
}
|
||||
|
||||
stageRuntimeGenerator(creator);
|
||||
levelStorageAccess = createRuntimeStorageAccess(creator.name());
|
||||
Object primaryLevelData = createPrimaryLevelData(levelStorageAccess, creator.name());
|
||||
Object runtimeStemKey = createRuntimeLevelStemKey(creator.name());
|
||||
Object worldLoadingInfo = createWorldLoadingInfo(creator.name(), runtimeStemKey);
|
||||
Object levelStem = resolveCreateLevelStem(creator);
|
||||
Object[] createLevelArgs = new Object[]{levelStem, worldLoadingInfo, levelStorageAccess, primaryLevelData};
|
||||
Method createLevelMethod = minecraftServerCreateLevelMethod;
|
||||
if (createLevelMethod == null || !matches(createLevelMethod.getParameterTypes(), createLevelArgs)) {
|
||||
createLevelMethod = resolveMethod(minecraftServer.getClass(), "createLevel", createLevelArgs);
|
||||
}
|
||||
|
||||
try {
|
||||
createLevelMethod.invoke(minecraftServer, createLevelArgs);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new IllegalStateException("createLevel argument mismatch. Method=" + formatMethod(createLevelMethod) + " Args=" + formatArgs(createLevelArgs), exception);
|
||||
}
|
||||
|
||||
World loaded = Bukkit.getWorld(creator.name());
|
||||
if (loaded == null) {
|
||||
Iris.clearStagedRuntimeWorldGenerator(creator.name());
|
||||
closeLevelStorageAccess(levelStorageAccess);
|
||||
return CompletableFuture.failedFuture(new IllegalStateException("PaperWorldLoader did not load world \"" + creator.name() + "\"."));
|
||||
}
|
||||
|
||||
Iris.clearStagedRuntimeWorldGenerator(creator.name());
|
||||
return CompletableFuture.completedFuture(loaded);
|
||||
} catch (Throwable e) {
|
||||
Iris.clearStagedRuntimeWorldGenerator(creator.name());
|
||||
closeLevelStorageAccess(levelStorageAccess);
|
||||
return CompletableFuture.failedFuture(unwrap(e));
|
||||
}
|
||||
}
|
||||
|
||||
private Object createRuntimeStorageAccess(String worldName) throws ReflectiveOperationException {
|
||||
Class<?> levelStorageSourceClass = Class.forName("net.minecraft.world.level.storage.LevelStorageSource");
|
||||
Object levelStorageSource = levelStorageSourceClass
|
||||
.getMethod("createDefault", Path.class)
|
||||
.invoke(null, Bukkit.getWorldContainer().toPath());
|
||||
|
||||
Object overworldStemKey = Class.forName("net.minecraft.world.level.dimension.LevelStem")
|
||||
.getField("OVERWORLD")
|
||||
.get(null);
|
||||
Method validateAndCreateAccess = resolveMethod(levelStorageSourceClass, "validateAndCreateAccess", worldName, overworldStemKey);
|
||||
return validateAndCreateAccess.invoke(levelStorageSource, worldName, overworldStemKey);
|
||||
}
|
||||
|
||||
private Object createPrimaryLevelData(Object levelStorageAccess, String worldName) throws ReflectiveOperationException {
|
||||
Class<?> paperWorldLoaderClass = Class.forName("io.papermc.paper.world.PaperWorldLoader");
|
||||
Class<?> levelStorageAccessClass = Class.forName("net.minecraft.world.level.storage.LevelStorageSource$LevelStorageAccess");
|
||||
Object levelDataResult = paperWorldLoaderClass
|
||||
.getMethod("getLevelData", levelStorageAccessClass)
|
||||
.invoke(null, levelStorageAccess);
|
||||
boolean fatalError = (boolean) invoke(levelDataResult, "fatalError");
|
||||
if (fatalError) {
|
||||
throw new IllegalStateException("PaperWorldLoader reported a fatal world-data error for \"" + worldName + "\".");
|
||||
}
|
||||
|
||||
Object dataTag = invoke(levelDataResult, "dataTag");
|
||||
if (dataTag != null) {
|
||||
throw new IllegalStateException("Runtime studio world folder \"" + worldName + "\" already contains level data.");
|
||||
}
|
||||
|
||||
Object worldLoaderContext = getPublicField(minecraftServer, "worldLoaderContext");
|
||||
Object datapackDimensions = invoke(worldLoaderContext, "datapackDimensions");
|
||||
Object levelStemRegistryKey = Class.forName("net.minecraft.core.registries.Registries")
|
||||
.getField("LEVEL_STEM")
|
||||
.get(null);
|
||||
Object levelStemRegistry = invoke(datapackDimensions, "lookupOrThrow", levelStemRegistryKey);
|
||||
Object dedicatedSettings = getPublicField(minecraftServer, "settings");
|
||||
boolean demo = (boolean) invoke(minecraftServer, "isDemo");
|
||||
Object options = getPublicField(minecraftServer, "options");
|
||||
boolean bonusChest = (boolean) invoke(options, "has", "bonusChest");
|
||||
|
||||
Class<?> mainClass = Class.forName("net.minecraft.server.Main");
|
||||
Method createNewWorldDataMethod = resolveMethod(mainClass, "createNewWorldData", dedicatedSettings, worldLoaderContext, levelStemRegistry, demo, bonusChest);
|
||||
Object dataLoadOutput = createNewWorldDataMethod.invoke(null, dedicatedSettings, worldLoaderContext, levelStemRegistry, demo, bonusChest);
|
||||
|
||||
Object primaryLevelData = invoke(dataLoadOutput, "cookie");
|
||||
invoke(primaryLevelData, "checkName", worldName);
|
||||
Object modCheck = invoke(minecraftServer, "getModdedStatus");
|
||||
boolean modified = (boolean) invoke(modCheck, "shouldReportAsModified");
|
||||
String modName = (String) invoke(minecraftServer, "getServerModName");
|
||||
invoke(primaryLevelData, "setModdedInfo", modName, modified);
|
||||
return primaryLevelData;
|
||||
}
|
||||
|
||||
private Object createWorldLoadingInfo(String worldName, Object runtimeStemKey) throws ReflectiveOperationException {
|
||||
Class<?> worldLoadingInfoClass = Class.forName("io.papermc.paper.world.PaperWorldLoader$WorldLoadingInfo");
|
||||
Constructor<?> constructor = resolveConstructor(worldLoadingInfoClass, 0, worldName, "normal", runtimeStemKey, true);
|
||||
return constructor.newInstance(0, worldName, "normal", runtimeStemKey, true);
|
||||
}
|
||||
|
||||
private Object createRuntimeLevelStemKey(String worldName) throws ReflectiveOperationException {
|
||||
String sanitized = worldName.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9/_-]", "_");
|
||||
String path = "runtime/" + sanitized;
|
||||
Object identifier = Class.forName("net.minecraft.resources.Identifier")
|
||||
.getMethod("fromNamespaceAndPath", String.class, String.class)
|
||||
.invoke(null, "iris", path);
|
||||
Object levelStemRegistryKey = Class.forName("net.minecraft.core.registries.Registries")
|
||||
.getField("LEVEL_STEM")
|
||||
.get(null);
|
||||
Class<?> resourceKeyClass = Class.forName("net.minecraft.resources.ResourceKey");
|
||||
Method createMethod = resolveMethod(resourceKeyClass, "create", levelStemRegistryKey, identifier);
|
||||
return createMethod.invoke(null, levelStemRegistryKey, identifier);
|
||||
}
|
||||
|
||||
private Object resolveCreateLevelStem(WorldCreator creator) throws ReflectiveOperationException {
|
||||
Object irisLevelStem = resolveIrisLevelStem(creator);
|
||||
if (irisLevelStem != null) {
|
||||
return irisLevelStem;
|
||||
}
|
||||
|
||||
return getOverworldLevelStem();
|
||||
}
|
||||
|
||||
private Object resolveIrisLevelStem(WorldCreator creator) throws ReflectiveOperationException {
|
||||
ChunkGenerator generator = creator.generator();
|
||||
if (!(generator instanceof PlatformChunkGenerator)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Object registryAccess = invoke(minecraftServer, "registryAccess");
|
||||
Object binding = INMS.get();
|
||||
Method levelStemMethod;
|
||||
try {
|
||||
levelStemMethod = resolveMethod(binding.getClass(), "levelStem", registryAccess, generator);
|
||||
} catch (NoSuchMethodException e) {
|
||||
throw new IllegalStateException("Iris NMS binding does not expose levelStem(RegistryAccess, ChunkGenerator) for runtime world \"" + creator.name() + "\".", e);
|
||||
}
|
||||
|
||||
Object levelStem;
|
||||
try {
|
||||
levelStem = levelStemMethod.invoke(binding, registryAccess, generator);
|
||||
} catch (InvocationTargetException e) {
|
||||
Throwable cause = unwrap(e);
|
||||
throw new IllegalStateException("Iris failed to resolve runtime level stem for world \"" + creator.name() + "\".", cause);
|
||||
}
|
||||
|
||||
if (levelStem == null) {
|
||||
throw new IllegalStateException("Iris resolved a null runtime level stem for world \"" + creator.name() + "\".");
|
||||
}
|
||||
return levelStem;
|
||||
}
|
||||
|
||||
private Object getOverworldLevelStem() throws ReflectiveOperationException {
|
||||
Object levelStemRegistryKey = Class.forName("net.minecraft.core.registries.Registries")
|
||||
.getField("LEVEL_STEM")
|
||||
.get(null);
|
||||
Object registryAccess = invoke(minecraftServer, "registryAccess");
|
||||
Object levelStemRegistry = invoke(registryAccess, "lookupOrThrow", levelStemRegistryKey);
|
||||
Object overworldStemKey = Class.forName("net.minecraft.world.level.dimension.LevelStem")
|
||||
.getField("OVERWORLD")
|
||||
.get(null);
|
||||
Object levelStem;
|
||||
try {
|
||||
levelStem = invoke(levelStemRegistry, "getValue", overworldStemKey);
|
||||
} catch (NoSuchMethodException ignored) {
|
||||
Object rawLevelStem = invoke(levelStemRegistry, "get", overworldStemKey);
|
||||
levelStem = extractRegistryValue(rawLevelStem);
|
||||
}
|
||||
if (levelStem == null) {
|
||||
throw new IllegalStateException("Unable to resolve OVERWORLD LevelStem from registry.");
|
||||
}
|
||||
return levelStem;
|
||||
}
|
||||
|
||||
private static Object extractRegistryValue(Object rawValue) throws ReflectiveOperationException {
|
||||
if (rawValue == null) {
|
||||
return null;
|
||||
}
|
||||
if (rawValue instanceof java.util.Optional<?> optionalValue) {
|
||||
Object nestedValue = optionalValue.orElse(null);
|
||||
if (nestedValue == null) {
|
||||
return null;
|
||||
}
|
||||
return extractRegistryValue(nestedValue);
|
||||
}
|
||||
|
||||
try {
|
||||
Method valueMethod = rawValue.getClass().getMethod("value");
|
||||
return valueMethod.invoke(rawValue);
|
||||
} catch (NoSuchMethodException ignored) {
|
||||
return rawValue;
|
||||
}
|
||||
}
|
||||
|
||||
private static Object getPublicField(Object target, String fieldName) throws ReflectiveOperationException {
|
||||
Field field = target.getClass().getField(fieldName);
|
||||
return field.get(target);
|
||||
}
|
||||
|
||||
private static void closeLevelStorageAccess(Object levelStorageAccess) {
|
||||
if (levelStorageAccess == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Method close = levelStorageAccess.getClass().getMethod("close");
|
||||
close.invoke(levelStorageAccess);
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
private static void stageRuntimeGenerator(WorldCreator creator) throws ReflectiveOperationException {
|
||||
ChunkGenerator generator = creator.generator();
|
||||
if (generator == null) {
|
||||
throw new IllegalStateException("Runtime world creation requires a non-null chunk generator.");
|
||||
}
|
||||
|
||||
Iris.stageRuntimeWorldGenerator(creator.name(), generator, creator.biomeProvider());
|
||||
Object bukkitServer = Bukkit.getServer();
|
||||
if (bukkitServer == null) {
|
||||
throw new IllegalStateException("Bukkit server is unavailable.");
|
||||
}
|
||||
|
||||
Field configurationField = bukkitServer.getClass().getDeclaredField("configuration");
|
||||
configurationField.setAccessible(true);
|
||||
Object rawConfiguration = configurationField.get(bukkitServer);
|
||||
if (!(rawConfiguration instanceof YamlConfiguration configuration)) {
|
||||
throw new IllegalStateException("CraftServer configuration field is unavailable.");
|
||||
}
|
||||
|
||||
ConfigurationSection worldsSection = configuration.getConfigurationSection("worlds");
|
||||
if (worldsSection == null) {
|
||||
worldsSection = configuration.createSection("worlds");
|
||||
}
|
||||
|
||||
ConfigurationSection worldSection = worldsSection.getConfigurationSection(creator.name());
|
||||
if (worldSection == null) {
|
||||
worldSection = worldsSection.createSection(creator.name());
|
||||
}
|
||||
|
||||
worldSection.set("generator", "Iris:runtime");
|
||||
}
|
||||
|
||||
@SuppressWarnings({"rawtypes", "unchecked"})
|
||||
private static void removeWorldFromCraftServerMap(String worldName) throws ReflectiveOperationException {
|
||||
Object bukkitServer = Bukkit.getServer();
|
||||
if (bukkitServer == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Field worldsField = bukkitServer.getClass().getDeclaredField("worlds");
|
||||
worldsField.setAccessible(true);
|
||||
Object worldsRaw = worldsField.get(bukkitServer);
|
||||
if (worldsRaw instanceof Map worldsMap) {
|
||||
worldsMap.remove(worldName);
|
||||
worldsMap.remove(worldName.toLowerCase(Locale.ROOT));
|
||||
}
|
||||
}
|
||||
|
||||
private static void closeServerLevel(World world, Object serverLevel) throws Throwable {
|
||||
Method closeLevelMethod = resolveMethod(serverLevel.getClass(), "close");
|
||||
if (!J.isFolia()) {
|
||||
closeLevelMethod.invoke(serverLevel);
|
||||
return;
|
||||
}
|
||||
|
||||
Location spawn = world.getSpawnLocation();
|
||||
int chunkX = spawn == null ? 0 : spawn.getBlockX() >> 4;
|
||||
int chunkZ = spawn == null ? 0 : spawn.getBlockZ() >> 4;
|
||||
CompletableFuture<Void> closeFuture = new CompletableFuture<>();
|
||||
boolean scheduled = J.runRegion(world, chunkX, chunkZ, () -> {
|
||||
try {
|
||||
closeLevelMethod.invoke(serverLevel);
|
||||
closeFuture.complete(null);
|
||||
} catch (Throwable e) {
|
||||
closeFuture.completeExceptionally(unwrap(e));
|
||||
}
|
||||
});
|
||||
if (!scheduled) {
|
||||
throw new IllegalStateException("Failed to schedule region close task for world \"" + world.getName() + "\".");
|
||||
}
|
||||
closeFuture.get(90, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private void detachServerLevel(Object serverLevel, String worldName) throws Throwable {
|
||||
Runnable detachTask = () -> {
|
||||
try {
|
||||
Method removeLevelMethod = resolveMethod(minecraftServer.getClass(), "removeLevel", serverLevel);
|
||||
removeLevelMethod.invoke(minecraftServer, serverLevel);
|
||||
removeWorldFromCraftServerMap(worldName);
|
||||
} catch (Throwable e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
};
|
||||
|
||||
if (!J.isFolia()) {
|
||||
detachTask.run();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isGlobalTickThread()) {
|
||||
detachTask.run();
|
||||
return;
|
||||
}
|
||||
|
||||
CompletableFuture<Void> detachFuture = J.sfut(() -> detachTask.run());
|
||||
if (detachFuture == null) {
|
||||
throw new IllegalStateException("Failed to schedule global detach task for world \"" + worldName + "\".");
|
||||
}
|
||||
detachFuture.get(15, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private static boolean isGlobalTickThread() {
|
||||
Server server = Bukkit.getServer();
|
||||
if (server == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
Method isGlobalTickThreadMethod = server.getClass().getMethod("isGlobalTickThread");
|
||||
return Boolean.TRUE.equals(isGlobalTickThreadMethod.invoke(server));
|
||||
} catch (Throwable ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static Throwable unwrap(Throwable throwable) {
|
||||
if (throwable instanceof InvocationTargetException invocationTargetException && invocationTargetException.getCause() != null) {
|
||||
return unwrap(invocationTargetException.getCause());
|
||||
}
|
||||
if (throwable instanceof java.util.concurrent.CompletionException completionException && completionException.getCause() != null) {
|
||||
return unwrap(completionException.getCause());
|
||||
}
|
||||
if (throwable instanceof java.util.concurrent.ExecutionException executionException && executionException.getCause() != null) {
|
||||
return unwrap(executionException.getCause());
|
||||
}
|
||||
return throwable;
|
||||
}
|
||||
|
||||
private static Object[] resolveProviderFromServices() {
|
||||
Object provider = null;
|
||||
Class<?> levelStem = null;
|
||||
Class<?> generatorType = null;
|
||||
|
||||
try {
|
||||
Collection<Class<?>> knownServices = Bukkit.getServicesManager().getKnownServices();
|
||||
for (Class<?> serviceClass : knownServices) {
|
||||
if (!"net.thenextlvl.worlds.api.WorldsProvider".equals(serviceClass.getName())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
RegisteredServiceProvider<?> registration = Bukkit.getServicesManager().getRegistration((Class) serviceClass);
|
||||
if (registration == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
provider = registration.getProvider();
|
||||
ClassLoader loader = serviceClass.getClassLoader();
|
||||
if (loader == null && provider != null) {
|
||||
loader = provider.getClass().getClassLoader();
|
||||
}
|
||||
if (loader != null) {
|
||||
levelStem = Class.forName("net.thenextlvl.worlds.api.generator.LevelStem", false, loader);
|
||||
generatorType = Class.forName("net.thenextlvl.worlds.api.generator.GeneratorType", false, loader);
|
||||
}
|
||||
break;
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
|
||||
return new Object[]{provider, levelStem, generatorType};
|
||||
}
|
||||
|
||||
private Object resolveLevelStem(World.Environment environment) {
|
||||
String key;
|
||||
if (environment == World.Environment.NETHER) {
|
||||
key = "NETHER";
|
||||
} else if (environment == World.Environment.THE_END) {
|
||||
key = "END";
|
||||
} else {
|
||||
key = "OVERWORLD";
|
||||
}
|
||||
|
||||
return enumValue(levelStemClass, key);
|
||||
}
|
||||
|
||||
private Object resolveGeneratorType(WorldType worldType) {
|
||||
String typeName = worldType == null ? "NORMAL" : worldType.getName();
|
||||
String key;
|
||||
if ("FLAT".equalsIgnoreCase(typeName)) {
|
||||
key = "FLAT";
|
||||
} else if ("AMPLIFIED".equalsIgnoreCase(typeName)) {
|
||||
key = "AMPLIFIED";
|
||||
} else if ("LARGE_BIOMES".equalsIgnoreCase(typeName) || "LARGEBIOMES".equalsIgnoreCase(typeName)) {
|
||||
key = "LARGE_BIOMES";
|
||||
} else {
|
||||
key = "NORMAL";
|
||||
}
|
||||
|
||||
return enumValue(generatorTypeClass, key);
|
||||
}
|
||||
|
||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||
private static Object enumValue(Class<?> enumClass, String key) {
|
||||
Class<? extends Enum> typed = enumClass.asSubclass(Enum.class);
|
||||
return Enum.valueOf(typed, key);
|
||||
}
|
||||
|
||||
private static Method resolveMethod(Class<?> owner, String methodName, Object... args) throws NoSuchMethodException {
|
||||
Method selected = findMatchingMethod(owner.getMethods(), methodName, args);
|
||||
if (selected != null) {
|
||||
return selected;
|
||||
}
|
||||
|
||||
Class<?> current = owner;
|
||||
while (current != null) {
|
||||
selected = findMatchingMethod(current.getDeclaredMethods(), methodName, args);
|
||||
if (selected != null) {
|
||||
selected.setAccessible(true);
|
||||
return selected;
|
||||
}
|
||||
current = current.getSuperclass();
|
||||
}
|
||||
|
||||
throw new NoSuchMethodException(owner.getName() + "#" + methodName);
|
||||
}
|
||||
|
||||
private static Constructor<?> resolveConstructor(Class<?> owner, Object... args) throws NoSuchMethodException {
|
||||
Constructor<?> selected = findMatchingConstructor(owner.getConstructors(), args);
|
||||
if (selected != null) {
|
||||
return selected;
|
||||
}
|
||||
|
||||
selected = findMatchingConstructor(owner.getDeclaredConstructors(), args);
|
||||
if (selected != null) {
|
||||
selected.setAccessible(true);
|
||||
return selected;
|
||||
}
|
||||
|
||||
throw new NoSuchMethodException(owner.getName() + "#<init>");
|
||||
}
|
||||
|
||||
private static Method findMatchingMethod(Method[] methods, String methodName, Object... args) {
|
||||
Method selected = null;
|
||||
for (Method method : methods) {
|
||||
if (!method.getName().equals(methodName)) {
|
||||
continue;
|
||||
}
|
||||
Class<?>[] params = method.getParameterTypes();
|
||||
if (params.length != args.length) {
|
||||
continue;
|
||||
}
|
||||
if (matches(params, args)) {
|
||||
selected = method;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return selected;
|
||||
}
|
||||
|
||||
private static String formatMethod(Method method) {
|
||||
if (method == null) {
|
||||
return "<null>";
|
||||
}
|
||||
|
||||
StringBuilder builder = new StringBuilder();
|
||||
builder.append(method.getDeclaringClass().getName())
|
||||
.append("#")
|
||||
.append(method.getName())
|
||||
.append("(");
|
||||
Class<?>[] parameterTypes = method.getParameterTypes();
|
||||
for (int i = 0; i < parameterTypes.length; i++) {
|
||||
if (i > 0) {
|
||||
builder.append(", ");
|
||||
}
|
||||
builder.append(parameterTypes[i].getName());
|
||||
}
|
||||
builder.append(")");
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private static String formatArgs(Object... args) {
|
||||
if (args == null) {
|
||||
return "<null>";
|
||||
}
|
||||
|
||||
StringBuilder builder = new StringBuilder();
|
||||
builder.append("[");
|
||||
for (int i = 0; i < args.length; i++) {
|
||||
if (i > 0) {
|
||||
builder.append(", ");
|
||||
}
|
||||
Object argument = args[i];
|
||||
builder.append(argument == null ? "null" : argument.getClass().getName());
|
||||
}
|
||||
builder.append("]");
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private static Constructor<?> findMatchingConstructor(Constructor<?>[] constructors, Object... args) {
|
||||
Constructor<?> selected = null;
|
||||
for (Constructor<?> constructor : constructors) {
|
||||
Class<?>[] params = constructor.getParameterTypes();
|
||||
if (params.length != args.length) {
|
||||
continue;
|
||||
}
|
||||
if (matches(params, args)) {
|
||||
selected = constructor;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return selected;
|
||||
}
|
||||
|
||||
private static Object invoke(Object target, String methodName, Object... args) throws ReflectiveOperationException {
|
||||
Method selected = resolveMethod(target.getClass(), methodName, args);
|
||||
return selected.invoke(target, args);
|
||||
}
|
||||
|
||||
private static boolean matches(Class<?>[] params, Object[] args) {
|
||||
for (int i = 0; i < params.length; i++) {
|
||||
Object arg = args[i];
|
||||
Class<?> parameterType = params[i];
|
||||
if (arg == null) {
|
||||
if (parameterType.isPrimitive()) {
|
||||
return false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Class<?> boxedParameterType = box(parameterType);
|
||||
if (!boxedParameterType.isAssignableFrom(arg.getClass())) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static Class<?> box(Class<?> type) {
|
||||
if (!type.isPrimitive()) {
|
||||
return type;
|
||||
}
|
||||
if (type == boolean.class) {
|
||||
return Boolean.class;
|
||||
}
|
||||
if (type == byte.class) {
|
||||
return Byte.class;
|
||||
}
|
||||
if (type == short.class) {
|
||||
return Short.class;
|
||||
}
|
||||
if (type == int.class) {
|
||||
return Integer.class;
|
||||
}
|
||||
if (type == long.class) {
|
||||
return Long.class;
|
||||
}
|
||||
if (type == float.class) {
|
||||
return Float.class;
|
||||
}
|
||||
if (type == double.class) {
|
||||
return Double.class;
|
||||
}
|
||||
if (type == char.class) {
|
||||
return Character.class;
|
||||
}
|
||||
return Void.class;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package art.arcane.iris.core.link.data;
|
||||
|
||||
import art.arcane.iris.core.link.ExternalDataProvider;
|
||||
import art.arcane.iris.core.link.Identifier;
|
||||
import art.arcane.iris.core.nms.container.BlockProperty;
|
||||
import art.arcane.iris.core.service.ExternalDataSVC;
|
||||
import art.arcane.iris.engine.data.cache.Cache;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.util.common.data.B;
|
||||
import art.arcane.iris.util.common.data.IrisCustomData;
|
||||
import art.arcane.volmlib.util.collection.KMap;
|
||||
import art.arcane.volmlib.util.math.RNG;
|
||||
import net.momirealms.craftengine.bukkit.api.CraftEngineBlocks;
|
||||
import net.momirealms.craftengine.bukkit.api.CraftEngineFurniture;
|
||||
import net.momirealms.craftengine.bukkit.api.CraftEngineItems;
|
||||
import net.momirealms.craftengine.core.block.ImmutableBlockState;
|
||||
import net.momirealms.craftengine.core.block.properties.BooleanProperty;
|
||||
import net.momirealms.craftengine.core.block.properties.IntegerProperty;
|
||||
import net.momirealms.craftengine.core.block.properties.Property;
|
||||
import net.momirealms.craftengine.core.util.Key;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.MissingResourceException;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public class CraftEngineDataProvider extends ExternalDataProvider {
|
||||
private static final BlockProperty[] FURNITURE_PROPERTIES = new BlockProperty[]{
|
||||
BlockProperty.ofBoolean("randomYaw", false),
|
||||
BlockProperty.ofDouble("yaw", 0, 0, 360f, false, true),
|
||||
BlockProperty.ofBoolean("randomPitch", false),
|
||||
BlockProperty.ofDouble("pitch", 0, 0, 360f, false, true),
|
||||
};
|
||||
|
||||
public CraftEngineDataProvider() {
|
||||
super("CraftEngine");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void init() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NotNull List<BlockProperty> getBlockProperties(@NotNull Identifier blockId) throws MissingResourceException {
|
||||
Key key = Key.of(blockId.namespace(), blockId.key());
|
||||
net.momirealms.craftengine.core.block.CustomBlock block = CraftEngineBlocks.byId(key);
|
||||
if (block != null) {
|
||||
return block.properties().stream().map(CraftEngineDataProvider::convert).toList();
|
||||
}
|
||||
|
||||
net.momirealms.craftengine.core.entity.furniture.CustomFurniture furniture = CraftEngineFurniture.byId(key);
|
||||
if (furniture != null) {
|
||||
BlockProperty[] properties = Arrays.copyOf(FURNITURE_PROPERTIES, 5);
|
||||
properties[4] = new BlockProperty(
|
||||
"variant",
|
||||
String.class,
|
||||
furniture.anyVariantName(),
|
||||
furniture.variants().keySet(),
|
||||
Function.identity()
|
||||
);
|
||||
return List.of(properties);
|
||||
}
|
||||
|
||||
throw new MissingResourceException("Failed to find BlockData!", blockId.namespace(), blockId.key());
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NotNull ItemStack getItemStack(@NotNull Identifier itemId, @NotNull KMap<String, Object> customNbt) throws MissingResourceException {
|
||||
net.momirealms.craftengine.core.item.CustomItem<ItemStack> item = CraftEngineItems.byId(Key.of(itemId.namespace(), itemId.key()));
|
||||
if (item == null) {
|
||||
throw new MissingResourceException("Failed to find ItemData!", itemId.namespace(), itemId.key());
|
||||
}
|
||||
|
||||
return item.buildItemStack();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NotNull BlockData getBlockData(@NotNull Identifier blockId, @NotNull KMap<String, String> state) throws MissingResourceException {
|
||||
Key key = Key.of(blockId.namespace(), blockId.key());
|
||||
if (CraftEngineBlocks.byId(key) == null && CraftEngineFurniture.byId(key) == null) {
|
||||
throw new MissingResourceException("Failed to find BlockData!", blockId.namespace(), blockId.key());
|
||||
}
|
||||
|
||||
return IrisCustomData.of(B.getAir(), ExternalDataSVC.buildState(blockId, state));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void processUpdate(@NotNull Engine engine, @NotNull Block block, @NotNull Identifier blockId) {
|
||||
art.arcane.iris.core.nms.container.Pair<Identifier, KMap<String, String>> statePair = ExternalDataSVC.parseState(blockId);
|
||||
Identifier baseBlockId = statePair.getA();
|
||||
KMap<String, String> state = statePair.getB();
|
||||
Key key = Key.of(baseBlockId.namespace(), baseBlockId.key());
|
||||
|
||||
net.momirealms.craftengine.core.block.CustomBlock customBlock = CraftEngineBlocks.byId(key);
|
||||
if (customBlock != null) {
|
||||
ImmutableBlockState blockState = customBlock.defaultState();
|
||||
|
||||
for (Map.Entry<String, String> entry : state.entrySet()) {
|
||||
Property<?> property = customBlock.getProperty(entry.getKey());
|
||||
if (property == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Comparable<?> tag = property.optional(entry.getValue()).orElse(null);
|
||||
if (tag == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
blockState = ImmutableBlockState.with(blockState, property, tag);
|
||||
}
|
||||
|
||||
CraftEngineBlocks.place(block.getLocation(), blockState, false);
|
||||
return;
|
||||
}
|
||||
|
||||
net.momirealms.craftengine.core.entity.furniture.CustomFurniture furniture = CraftEngineFurniture.byId(key);
|
||||
if (furniture == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Location location = parseYawAndPitch(engine, block, state);
|
||||
String variant = state.getOrDefault("variant", furniture.anyVariantName());
|
||||
CraftEngineFurniture.place(location, furniture, variant, false);
|
||||
}
|
||||
|
||||
private static Location parseYawAndPitch(@NotNull Engine engine, @NotNull Block block, @NotNull Map<String, String> state) {
|
||||
Location location = block.getLocation();
|
||||
long seed = engine.getSeedManager().getSeed() + Cache.key(block.getX(), block.getZ()) + block.getY();
|
||||
RNG rng = new RNG(seed);
|
||||
|
||||
if ("true".equals(state.get("randomYaw"))) {
|
||||
location.setYaw(rng.f(0, 360));
|
||||
} else if (state.containsKey("yaw")) {
|
||||
location.setYaw(Float.parseFloat(state.get("yaw")));
|
||||
}
|
||||
|
||||
if ("true".equals(state.get("randomPitch"))) {
|
||||
location.setPitch(rng.f(0, 360));
|
||||
} else if (state.containsKey("pitch")) {
|
||||
location.setPitch(Float.parseFloat(state.get("pitch")));
|
||||
}
|
||||
|
||||
return location;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NotNull Collection<@NotNull Identifier> getTypes(@NotNull DataType dataType) {
|
||||
Stream<Key> keys = switch (dataType) {
|
||||
case ENTITY -> Stream.<Key>empty();
|
||||
case ITEM -> CraftEngineItems.loadedItems().keySet().stream();
|
||||
case BLOCK -> Stream.concat(CraftEngineBlocks.loadedBlocks().keySet().stream(),
|
||||
CraftEngineFurniture.loadedFurniture().keySet().stream());
|
||||
};
|
||||
return keys.map(key -> new Identifier(key.namespace(), key.value())).toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isValidProvider(@NotNull Identifier id, DataType dataType) {
|
||||
Key key = Key.of(id.namespace(), id.key());
|
||||
return switch (dataType) {
|
||||
case ENTITY -> false;
|
||||
case ITEM -> CraftEngineItems.byId(key) != null;
|
||||
case BLOCK -> CraftEngineBlocks.byId(key) != null || CraftEngineFurniture.byId(key) != null;
|
||||
};
|
||||
}
|
||||
|
||||
private static <T extends Comparable<T>> BlockProperty convert(Property<T> raw) {
|
||||
return switch (raw) {
|
||||
case BooleanProperty property -> BlockProperty.ofBoolean(property.name(), property.defaultValue());
|
||||
case IntegerProperty property -> BlockProperty.ofLong(property.name(), property.defaultValue(), property.min, property.max, false, false);
|
||||
default -> new BlockProperty(raw.name(), raw.valueClass(), raw.defaultValue(), raw.possibleValues(), raw::valueName);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,9 @@ import art.arcane.iris.core.IrisSettings;
|
||||
import art.arcane.iris.core.nms.v1X.NMSBinding1X;
|
||||
import org.bukkit.Bukkit;
|
||||
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
public class INMS {
|
||||
private static final Version CURRENT = Boolean.getBoolean("iris.no-version-limit") ?
|
||||
@@ -69,51 +71,42 @@ public class INMS {
|
||||
|
||||
private static INMSBinding bind() {
|
||||
String code = getNMSTag();
|
||||
Iris.info("Locating NMS Binding for " + code);
|
||||
|
||||
try {
|
||||
Class<?> clazz = Class.forName("art.arcane.iris.core.nms." + code + ".NMSBinding");
|
||||
try {
|
||||
Object b = clazz.getConstructor().newInstance();
|
||||
if (b instanceof INMSBinding binding) {
|
||||
Iris.info("Craftbukkit " + code + " <-> " + b.getClass().getSimpleName() + " Successfully Bound");
|
||||
return binding;
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
Iris.reportError(e);
|
||||
e.printStackTrace();
|
||||
}
|
||||
} catch (ClassNotFoundException | NoClassDefFoundError classNotFoundException) {
|
||||
Iris.warn("Failed to load NMS binding class for " + code + ": " + classNotFoundException.getMessage());
|
||||
boolean disableNms = IrisSettings.get().getGeneral().isDisableNMS();
|
||||
List<String> probeCodes = NmsBindingProbeSupport.getBindingProbeCodes(code, disableNms, getFallbackBindingCodes());
|
||||
if ("BUKKIT".equals(code) && !disableNms) {
|
||||
Iris.info("NMS tag resolution fell back to Bukkit; probing supported revision bindings.");
|
||||
}
|
||||
|
||||
if (IrisSettings.get().getGeneral().isDisableNMS()) {
|
||||
for (int i = 0; i < probeCodes.size(); i++) {
|
||||
INMSBinding resolvedBinding = tryBind(probeCodes.get(i), i == 0);
|
||||
if (resolvedBinding != null) {
|
||||
return resolvedBinding;
|
||||
}
|
||||
}
|
||||
|
||||
if (disableNms) {
|
||||
Iris.info("Craftbukkit " + code + " <-> " + NMSBinding1X.class.getSimpleName() + " Successfully Bound");
|
||||
Iris.warn("Note: NMS support is disabled. Iris is running in limited Bukkit fallback mode.");
|
||||
return new NMSBinding1X();
|
||||
}
|
||||
|
||||
String serverVersion = Bukkit.getServer().getBukkitVersion().split("-")[0];
|
||||
MinecraftVersion detectedVersion = getMinecraftVersion();
|
||||
String serverVersion = detectedVersion == null ? Bukkit.getServer().getVersion() : detectedVersion.value();
|
||||
throw new IllegalStateException("Iris requires Minecraft 1.21.11 or newer. Detected server version: " + serverVersion);
|
||||
}
|
||||
|
||||
private static String getTag(List<Version> versions, String def) {
|
||||
String[] version = Bukkit.getServer().getBukkitVersion().split("-")[0].split("\\.", 3);
|
||||
int major = 0;
|
||||
int minor = 0;
|
||||
|
||||
if (version.length > 2) {
|
||||
major = Integer.parseInt(version[1]);
|
||||
minor = Integer.parseInt(version[2]);
|
||||
} else if (version.length == 2) {
|
||||
major = Integer.parseInt(version[1]);
|
||||
MinecraftVersion detectedVersion = getMinecraftVersion();
|
||||
if (detectedVersion == null) {
|
||||
return def;
|
||||
}
|
||||
if (CURRENT.major < major || CURRENT.minor < minor) {
|
||||
|
||||
if (detectedVersion.isNewerThan(CURRENT.major, CURRENT.minor)) {
|
||||
return versions.getFirst().tag;
|
||||
}
|
||||
|
||||
for (Version p : versions) {
|
||||
if (p.major > major || p.minor > minor) {
|
||||
if (!detectedVersion.isAtLeast(p.major, p.minor)) {
|
||||
continue;
|
||||
}
|
||||
return p.tag;
|
||||
@@ -121,5 +114,50 @@ public class INMS {
|
||||
return def;
|
||||
}
|
||||
|
||||
private static MinecraftVersion getMinecraftVersion() {
|
||||
try {
|
||||
return MinecraftVersion.detect(Bukkit.getServer());
|
||||
} catch (Throwable e) {
|
||||
Iris.reportError(e);
|
||||
Iris.error("Failed to determine server minecraft version!");
|
||||
e.printStackTrace();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static INMSBinding tryBind(String code, boolean announce) {
|
||||
if (announce) {
|
||||
Iris.info("Locating NMS Binding for " + code);
|
||||
} else {
|
||||
Iris.info("Probing NMS Binding for " + code);
|
||||
}
|
||||
|
||||
try {
|
||||
Class<?> clazz = Class.forName("art.arcane.iris.core.nms." + code + ".NMSBinding");
|
||||
Object candidate = clazz.getConstructor().newInstance();
|
||||
if (candidate instanceof INMSBinding binding) {
|
||||
Iris.info("Craftbukkit " + code + " <-> " + candidate.getClass().getSimpleName() + " Successfully Bound");
|
||||
return binding;
|
||||
}
|
||||
} catch (ClassNotFoundException | NoClassDefFoundError classNotFoundException) {
|
||||
Iris.warn("Failed to load NMS binding class for " + code + ": " + classNotFoundException.getMessage());
|
||||
} catch (Throwable e) {
|
||||
Iris.reportError(e);
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Set<String> getFallbackBindingCodes() {
|
||||
Set<String> codes = new LinkedHashSet<>();
|
||||
for (Version version : REVISION) {
|
||||
if (version.tag != null && !version.tag.isBlank()) {
|
||||
codes.add(version.tag);
|
||||
}
|
||||
}
|
||||
return codes;
|
||||
}
|
||||
|
||||
private record Version(int major, int minor, String tag) {}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,10 @@
|
||||
|
||||
package art.arcane.iris.core.nms;
|
||||
|
||||
import art.arcane.iris.core.link.FoliaWorldsLink;
|
||||
import art.arcane.iris.core.link.Identifier;
|
||||
import art.arcane.iris.core.lifecycle.WorldLifecycleCaller;
|
||||
import art.arcane.iris.core.lifecycle.WorldLifecycleRequest;
|
||||
import art.arcane.iris.core.lifecycle.WorldLifecycleService;
|
||||
import art.arcane.iris.core.nms.container.BiomeColor;
|
||||
import art.arcane.iris.core.nms.container.BlockProperty;
|
||||
import art.arcane.iris.core.nms.container.StructurePlacement;
|
||||
@@ -40,6 +42,7 @@ import org.bukkit.block.Biome;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.EntityType;
|
||||
import org.bukkit.event.entity.CreatureSpawnEvent;
|
||||
import org.bukkit.generator.ChunkGenerator;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
|
||||
import java.awt.Color;
|
||||
@@ -96,34 +99,33 @@ public interface INMSBinding {
|
||||
MCABiomeContainer newBiomeContainer(int min, int max);
|
||||
|
||||
default World createWorld(WorldCreator c) {
|
||||
if (c.generator() instanceof PlatformChunkGenerator gen
|
||||
&& missingDimensionTypes(gen.getTarget().getDimension().getDimensionTypeKey()))
|
||||
throw new IllegalStateException("Missing dimension types to create world");
|
||||
return c.createWorld();
|
||||
WorldLifecycleRequest request = WorldLifecycleRequest.fromCreator(c, false, false, WorldLifecycleCaller.CREATE);
|
||||
return createWorld(c, request);
|
||||
}
|
||||
|
||||
default CompletableFuture<World> createWorldAsync(WorldCreator c) {
|
||||
try {
|
||||
if (c.generator() instanceof PlatformChunkGenerator gen
|
||||
&& missingDimensionTypes(gen.getTarget().getDimension().getDimensionTypeKey())) {
|
||||
return CompletableFuture.failedFuture(new IllegalStateException("Missing dimension types to create world"));
|
||||
}
|
||||
WorldLifecycleRequest request = WorldLifecycleRequest.fromCreator(c, false, false, WorldLifecycleCaller.CREATE);
|
||||
return createWorldAsync(c, request);
|
||||
}
|
||||
|
||||
if (J.isFolia()) {
|
||||
FoliaWorldsLink link = FoliaWorldsLink.get();
|
||||
if (link.isActive()) {
|
||||
CompletableFuture<World> future = link.createWorld(c);
|
||||
if (future != null) {
|
||||
return future;
|
||||
}
|
||||
}
|
||||
}
|
||||
return CompletableFuture.completedFuture(createWorld(c));
|
||||
default World createWorld(WorldCreator c, WorldLifecycleRequest request) {
|
||||
validateDimensionTypes(c);
|
||||
return WorldLifecycleService.get().createBlocking(request);
|
||||
}
|
||||
|
||||
default CompletableFuture<World> createWorldAsync(WorldCreator c, WorldLifecycleRequest request) {
|
||||
try {
|
||||
validateDimensionTypes(c);
|
||||
return WorldLifecycleService.get().create(request);
|
||||
} catch (Throwable e) {
|
||||
return CompletableFuture.failedFuture(e);
|
||||
}
|
||||
}
|
||||
|
||||
default Object createRuntimeLevelStem(Object registryAccess, ChunkGenerator raw) {
|
||||
throw new UnsupportedOperationException("Active NMS binding does not support runtime LevelStem creation.");
|
||||
}
|
||||
|
||||
int countCustomBiomes();
|
||||
|
||||
default boolean supportsDataPacks() {
|
||||
@@ -169,4 +171,11 @@ public interface INMSBinding {
|
||||
void placeStructures(Chunk chunk);
|
||||
|
||||
KMap<Identifier, StructurePlacement> collectStructures();
|
||||
|
||||
private void validateDimensionTypes(WorldCreator c) {
|
||||
if (c.generator() instanceof PlatformChunkGenerator gen
|
||||
&& missingDimensionTypes(gen.getTarget().getDimension().getDimensionTypeKey())) {
|
||||
throw new IllegalStateException("Missing dimension types to create world");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package art.arcane.iris.core.nms;
|
||||
|
||||
import org.bukkit.Server;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
final class MinecraftVersion {
|
||||
private static final Pattern DECORATED_VERSION_PATTERN = Pattern.compile("\\(MC: ([0-9]+(?:\\.[0-9]+){0,2})\\)");
|
||||
|
||||
private final String value;
|
||||
private final int major;
|
||||
private final int minor;
|
||||
|
||||
private MinecraftVersion(String value, int major, int minor) {
|
||||
this.value = value;
|
||||
this.major = major;
|
||||
this.minor = minor;
|
||||
}
|
||||
|
||||
public static MinecraftVersion detect(Server server) {
|
||||
if (server == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
MinecraftVersion runtimeVersion = fromRuntimeMinecraftVersion(server);
|
||||
if (runtimeVersion != null) {
|
||||
return runtimeVersion;
|
||||
}
|
||||
|
||||
MinecraftVersion decoratedVersion = fromDecoratedVersion(server.getVersion());
|
||||
if (decoratedVersion != null) {
|
||||
return decoratedVersion;
|
||||
}
|
||||
|
||||
return fromBukkitVersion(server.getBukkitVersion());
|
||||
}
|
||||
|
||||
static MinecraftVersion fromRuntimeMinecraftVersion(Server server) {
|
||||
try {
|
||||
Method method = server.getClass().getMethod("getMinecraftVersion");
|
||||
Object value = method.invoke(server);
|
||||
if (value instanceof String version) {
|
||||
return fromVersionToken(version);
|
||||
}
|
||||
} catch (ReflectiveOperationException ignored) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static MinecraftVersion fromDecoratedVersion(String input) {
|
||||
if (input == null || input.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Matcher matcher = DECORATED_VERSION_PATTERN.matcher(input);
|
||||
if (!matcher.find()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return fromVersionToken(matcher.group(1));
|
||||
}
|
||||
|
||||
static MinecraftVersion fromBukkitVersion(String input) {
|
||||
if (input == null || input.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String versionToken = input.split("-", 2)[0].trim();
|
||||
return fromVersionToken(versionToken);
|
||||
}
|
||||
|
||||
private static MinecraftVersion fromVersionToken(String input) {
|
||||
if (input == null || input.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String[] parts = input.split("\\.");
|
||||
if (parts.length < 2 || !"1".equals(parts[0])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
int major = Integer.parseInt(parts[1]);
|
||||
int minor = parts.length > 2 ? Integer.parseInt(parts[2]) : 0;
|
||||
return new MinecraftVersion(input, major, minor);
|
||||
} catch (NumberFormatException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public String value() {
|
||||
return value;
|
||||
}
|
||||
|
||||
public int major() {
|
||||
return major;
|
||||
}
|
||||
|
||||
public int minor() {
|
||||
return minor;
|
||||
}
|
||||
|
||||
public boolean isAtLeast(int major, int minor) {
|
||||
return this.major > major || (this.major == major && this.minor >= minor);
|
||||
}
|
||||
|
||||
public boolean isNewerThan(int major, int minor) {
|
||||
return this.major > major || (this.major == major && this.minor > minor);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package art.arcane.iris.core.nms;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
final class NmsBindingProbeSupport {
|
||||
private NmsBindingProbeSupport() {
|
||||
}
|
||||
|
||||
static List<String> getBindingProbeCodes(String code, boolean disableNms, Collection<String> fallbackCodes) {
|
||||
List<String> probeCodes = new ArrayList<>();
|
||||
if (code == null || code.isBlank()) {
|
||||
return probeCodes;
|
||||
}
|
||||
|
||||
if (!"BUKKIT".equals(code)) {
|
||||
probeCodes.add(code);
|
||||
return probeCodes;
|
||||
}
|
||||
|
||||
if (disableNms || fallbackCodes == null) {
|
||||
return probeCodes;
|
||||
}
|
||||
|
||||
probeCodes.addAll(fallbackCodes);
|
||||
return probeCodes;
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ public class BlockProperty {
|
||||
private final Function<Object, String> nameFunction;
|
||||
private final Function<Object, Object> jsonFunction;
|
||||
|
||||
public <T extends Comparable<T>> BlockProperty(
|
||||
public <T extends Comparable<T>> BlockProperty(
|
||||
String name,
|
||||
Class<T> type,
|
||||
T defaultValue,
|
||||
@@ -42,7 +42,7 @@ public class BlockProperty {
|
||||
);
|
||||
}
|
||||
|
||||
public static BlockProperty ofFloat(String name, float defaultValue, float min, float max, boolean exclusiveMin, boolean exclusiveMax) {
|
||||
public static BlockProperty ofDouble(String name, float defaultValue, float min, float max, boolean exclusiveMin, boolean exclusiveMax) {
|
||||
return new BoundedDouble(
|
||||
name,
|
||||
defaultValue,
|
||||
@@ -54,6 +54,18 @@ public class BlockProperty {
|
||||
);
|
||||
}
|
||||
|
||||
public static BlockProperty ofLong(String name, long defaultValue, long min, long max, boolean exclusiveMin, boolean exclusiveMax) {
|
||||
return new BoundedLong(
|
||||
name,
|
||||
defaultValue,
|
||||
min,
|
||||
max,
|
||||
exclusiveMin,
|
||||
exclusiveMax,
|
||||
value -> Long.toString(value)
|
||||
);
|
||||
}
|
||||
|
||||
public static BlockProperty ofBoolean(String name, boolean defaultValue) {
|
||||
return new BlockProperty(
|
||||
name,
|
||||
@@ -122,6 +134,38 @@ public class BlockProperty {
|
||||
return Objects.hash(name, values, type);
|
||||
}
|
||||
|
||||
private static class BoundedLong extends BlockProperty {
|
||||
private final long min;
|
||||
private final long max;
|
||||
private final boolean exclusiveMin;
|
||||
private final boolean exclusiveMax;
|
||||
|
||||
public BoundedLong(
|
||||
String name,
|
||||
long defaultValue,
|
||||
long min,
|
||||
long max,
|
||||
boolean exclusiveMin,
|
||||
boolean exclusiveMax,
|
||||
Function<Long, String> nameFunction
|
||||
) {
|
||||
super(name, Long.class, defaultValue, List.of(), nameFunction);
|
||||
this.min = min;
|
||||
this.max = max;
|
||||
this.exclusiveMin = exclusiveMin;
|
||||
this.exclusiveMax = exclusiveMax;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONObject buildJson() {
|
||||
return super.buildJson()
|
||||
.put("minimum", min)
|
||||
.put("maximum", max)
|
||||
.put("exclusiveMinimum", exclusiveMin)
|
||||
.put("exclusiveMaximum", exclusiveMax);
|
||||
}
|
||||
}
|
||||
|
||||
private static class BoundedDouble extends BlockProperty {
|
||||
private final double min, max;
|
||||
private final boolean exclusiveMin, exclusiveMax;
|
||||
|
||||
@@ -12,6 +12,7 @@ public class DataFixerV1217 extends DataFixerV1213 {
|
||||
Dimension.OVERWORLD, """
|
||||
{
|
||||
"ambient_light": 0.0,
|
||||
"has_ender_dragon_fight": false,
|
||||
"attributes": {
|
||||
"minecraft:audio/ambient_sounds": {
|
||||
"mood": {
|
||||
@@ -42,6 +43,7 @@ public class DataFixerV1217 extends DataFixerV1213 {
|
||||
Dimension.NETHER, """
|
||||
{
|
||||
"ambient_light": 0.1,
|
||||
"has_ender_dragon_fight": false,
|
||||
"attributes": {
|
||||
"minecraft:gameplay/sky_light_level": 4.0,
|
||||
"minecraft:gameplay/snow_golem_melts": true,
|
||||
@@ -57,6 +59,7 @@ public class DataFixerV1217 extends DataFixerV1213 {
|
||||
Dimension.END, """
|
||||
{
|
||||
"ambient_light": 0.25,
|
||||
"has_ender_dragon_fight": true,
|
||||
"attributes": {
|
||||
"minecraft:audio/ambient_sounds": {
|
||||
"mood": {
|
||||
@@ -96,9 +99,9 @@ public class DataFixerV1217 extends DataFixerV1213 {
|
||||
|
||||
JSONObject particle = (JSONObject) effects.remove("particle");
|
||||
if (particle != null) {
|
||||
particle.put("particle", particle.remove("options"));
|
||||
attributes.put("minecraft:visual/ambient_particles", new JSONArray()
|
||||
.put(particle.getJSONObject("options")
|
||||
.put("probability", particle.get("probability"))));
|
||||
.put(particle));
|
||||
}
|
||||
json.put("attributes", attributes);
|
||||
|
||||
|
||||
@@ -21,10 +21,11 @@ package art.arcane.iris.core.project;
|
||||
import com.google.gson.Gson;
|
||||
import art.arcane.iris.Iris;
|
||||
import art.arcane.iris.core.IrisSettings;
|
||||
import art.arcane.iris.core.link.FoliaWorldsLink;
|
||||
import art.arcane.iris.core.lifecycle.WorldLifecycleService;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.loader.IrisRegistrant;
|
||||
import art.arcane.iris.core.loader.ResourceLoader;
|
||||
import art.arcane.iris.core.runtime.StudioOpenCoordinator;
|
||||
import art.arcane.iris.core.tools.IrisToolbelt;
|
||||
import art.arcane.iris.engine.object.*;
|
||||
import art.arcane.iris.engine.object.annotations.Snippet;
|
||||
@@ -63,6 +64,8 @@ import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
@@ -228,209 +231,117 @@ public class IrisProject {
|
||||
return foundWork;
|
||||
}
|
||||
|
||||
public void open(VolmitSender sender, long seed, Consumer<World> onDone) throws IrisException {
|
||||
public CompletableFuture<StudioOpenCoordinator.StudioOpenResult> open(VolmitSender sender, long seed, Consumer<World> onDone) throws IrisException {
|
||||
if (isOpen()) {
|
||||
close();
|
||||
return close().thenCompose(ignored -> openInternal(sender, seed, onDone));
|
||||
}
|
||||
|
||||
return openInternal(sender, seed, onDone);
|
||||
}
|
||||
|
||||
private CompletableFuture<StudioOpenCoordinator.StudioOpenResult> openInternal(VolmitSender sender, long seed, Consumer<World> onDone) {
|
||||
AtomicReference<String> stage = new AtomicReference<>("Queued");
|
||||
AtomicReference<Double> progress = new AtomicReference<>(0.01D);
|
||||
AtomicBoolean complete = new AtomicBoolean(false);
|
||||
AtomicBoolean failed = new AtomicBoolean(false);
|
||||
CompletableFuture<StudioOpenCoordinator.StudioOpenResult> future = StudioOpenCoordinator.get().open(
|
||||
StudioOpenCoordinator.StudioOpenRequest.studioProject(
|
||||
this,
|
||||
sender,
|
||||
seed,
|
||||
update -> {
|
||||
if (update.stage() != null && !update.stage().isBlank()) {
|
||||
stage.set(update.stage());
|
||||
}
|
||||
progress.set(Math.max(0D, Math.min(0.99D, update.progress())));
|
||||
},
|
||||
onDone
|
||||
)
|
||||
);
|
||||
startStudioOpenReporter(sender, stage, progress, complete, failed);
|
||||
|
||||
J.a(() -> {
|
||||
future.whenComplete((result, throwable) -> {
|
||||
World maintenanceWorld = null;
|
||||
boolean maintenanceActive = false;
|
||||
try {
|
||||
stage.set("Loading dimension");
|
||||
progress.set(0.05D);
|
||||
IrisDimension d = IrisData.loadAnyDimension(getName(), null);
|
||||
if (d == null) {
|
||||
if (throwable != null) {
|
||||
failed.set(true);
|
||||
sender.sendMessage(C.RED + "Can't find dimension: " + getName());
|
||||
Throwable error = throwable instanceof java.util.concurrent.CompletionException completionException && completionException.getCause() != null
|
||||
? completionException.getCause()
|
||||
: throwable;
|
||||
Iris.reportError("Studio open failed for project \"" + getName() + "\".", error);
|
||||
sender.sendMessage(C.RED + "Studio open failed: " + error.getMessage());
|
||||
return;
|
||||
} else if (sender.isPlayer()) {
|
||||
}
|
||||
|
||||
if (sender.isPlayer() && sender.player() != null) {
|
||||
J.runEntity(sender.player(), () -> sender.player().setGameMode(GameMode.SPECTATOR));
|
||||
}
|
||||
|
||||
stage.set("Creating world");
|
||||
progress.set(0.12D);
|
||||
activeProvider = (PlatformChunkGenerator) IrisToolbelt.createWorld()
|
||||
.seed(seed)
|
||||
.sender(sender)
|
||||
.studio(true)
|
||||
.name("iris-" + UUID.randomUUID())
|
||||
.dimension(d.getLoadKey())
|
||||
.studioProgressConsumer((value, currentStage) -> {
|
||||
if (currentStage != null && !currentStage.isBlank()) {
|
||||
stage.set(currentStage);
|
||||
}
|
||||
progress.set(Math.max(0D, Math.min(0.99D, value)));
|
||||
})
|
||||
.create().getGenerator();
|
||||
|
||||
if (activeProvider != null) {
|
||||
maintenanceWorld = activeProvider.getTarget().getWorld().realWorld();
|
||||
if (maintenanceWorld != null) {
|
||||
IrisToolbelt.beginWorldMaintenance(maintenanceWorld, "studio-open");
|
||||
maintenanceActive = true;
|
||||
}
|
||||
onDone.accept(maintenanceWorld);
|
||||
activeProvider = IrisToolbelt.access(result.world());
|
||||
maintenanceWorld = result.world();
|
||||
if (maintenanceWorld != null) {
|
||||
IrisToolbelt.beginWorldMaintenance(maintenanceWorld, "studio-open");
|
||||
maintenanceActive = true;
|
||||
}
|
||||
} catch (IrisException e) {
|
||||
failed.set(true);
|
||||
Iris.reportError(e);
|
||||
sender.sendMessage(C.RED + "Failed to open studio world: " + e.getMessage());
|
||||
} catch (Throwable e) {
|
||||
failed.set(true);
|
||||
Iris.reportError(e);
|
||||
sender.sendMessage(C.RED + "Studio open failed: " + e.getMessage());
|
||||
} finally {
|
||||
if (activeProvider != null) {
|
||||
stage.set("Opening workspace");
|
||||
progress.set(Math.max(progress.get(), 0.95D));
|
||||
openVSCode(sender);
|
||||
}
|
||||
|
||||
if (maintenanceActive && maintenanceWorld != null) {
|
||||
World worldToRelease = maintenanceWorld;
|
||||
J.a(() -> {
|
||||
J.sleep(15000);
|
||||
IrisToolbelt.endWorldMaintenance(worldToRelease, "studio-open");
|
||||
});
|
||||
maintenanceActive = false;
|
||||
}
|
||||
|
||||
if (maintenanceActive && maintenanceWorld != null) {
|
||||
IrisToolbelt.endWorldMaintenance(maintenanceWorld, "studio-open");
|
||||
J.a(() -> IrisToolbelt.endWorldMaintenance(worldToRelease, "studio-open"), 300);
|
||||
}
|
||||
complete.set(true);
|
||||
}
|
||||
});
|
||||
return future;
|
||||
}
|
||||
|
||||
private void startStudioOpenReporter(VolmitSender sender, AtomicReference<String> stage, AtomicReference<Double> progress, AtomicBoolean complete, AtomicBoolean failed) {
|
||||
J.a(() -> {
|
||||
String[] spinner = {"|", "/", "-", "\\"};
|
||||
int spinIndex = 0;
|
||||
long nextConsoleUpdate = 0L;
|
||||
String[] spinner = {"|", "/", "-", "\\"};
|
||||
AtomicInteger spinIndex = new AtomicInteger(0);
|
||||
AtomicLong nextConsoleUpdate = new AtomicLong(0L);
|
||||
AtomicInteger taskId = new AtomicInteger(-1);
|
||||
|
||||
while (!complete.get()) {
|
||||
double currentProgress = Math.max(0D, Math.min(0.97D, progress.get()));
|
||||
String currentStage = stage.get();
|
||||
String currentSpinner = spinner[spinIndex % spinner.length];
|
||||
|
||||
if (sender.isPlayer()) {
|
||||
sender.sendProgress(currentProgress, "Studio " + currentSpinner + " " + currentStage);
|
||||
} else {
|
||||
long now = System.currentTimeMillis();
|
||||
if (now >= nextConsoleUpdate) {
|
||||
sender.sendMessage(C.WHITE + "Studio " + Form.pc(currentProgress, 0) + C.GRAY + " - " + currentStage);
|
||||
nextConsoleUpdate = now + 1500L;
|
||||
int scheduledTaskId = J.ar(() -> {
|
||||
if (complete.get()) {
|
||||
J.car(taskId.get());
|
||||
if (failed.get()) {
|
||||
if (sender.isPlayer()) {
|
||||
sender.sendProgress(1D, "Studio open failed");
|
||||
} else {
|
||||
sender.sendMessage(C.RED + "Studio open failed.");
|
||||
}
|
||||
}
|
||||
|
||||
spinIndex++;
|
||||
J.sleep(120);
|
||||
}
|
||||
|
||||
if (failed.get()) {
|
||||
if (sender.isPlayer()) {
|
||||
sender.sendProgress(1D, "Studio open failed");
|
||||
} else if (sender.isPlayer()) {
|
||||
sender.sendProgress(1D, "Studio ready");
|
||||
} else {
|
||||
sender.sendMessage(C.RED + "Studio open failed.");
|
||||
sender.sendMessage(C.GREEN + "Studio ready.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
double currentProgress = Math.max(0D, Math.min(0.97D, progress.get()));
|
||||
String currentStage = stage.get();
|
||||
String currentSpinner = spinner[Math.floorMod(spinIndex.getAndIncrement(), spinner.length)];
|
||||
|
||||
if (sender.isPlayer()) {
|
||||
sender.sendProgress(1D, "Studio ready");
|
||||
} else {
|
||||
sender.sendMessage(C.GREEN + "Studio ready.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void close() {
|
||||
if (activeProvider == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Iris.debug("Closing Active Provider");
|
||||
final PlatformChunkGenerator provider = activeProvider;
|
||||
final World studioWorld = provider.getTarget().getWorld().realWorld();
|
||||
final File folder = provider.getTarget().getWorld().worldFolder();
|
||||
final String worldName = provider.getTarget().getWorld().name();
|
||||
|
||||
final Runnable closeTask = () -> {
|
||||
IrisToolbelt.beginWorldMaintenance(studioWorld, "studio-close", true);
|
||||
try {
|
||||
IrisToolbelt.evacuate(studioWorld);
|
||||
provider.close();
|
||||
Iris.linkMultiverseCore.removeFromConfig(worldName);
|
||||
boolean unloaded = FoliaWorldsLink.get().unloadWorld(studioWorld, false);
|
||||
if (!unloaded) {
|
||||
Iris.warn("Failed to unload studio world \"" + worldName + "\".");
|
||||
}
|
||||
} finally {
|
||||
IrisToolbelt.endWorldMaintenance(studioWorld, "studio-close");
|
||||
}
|
||||
};
|
||||
|
||||
if (J.isPrimaryThread()) {
|
||||
closeTask.run();
|
||||
} else {
|
||||
final CompletableFuture<Void> closeFuture = J.sfut(closeTask);
|
||||
if (closeFuture != null) {
|
||||
try {
|
||||
closeFuture.join();
|
||||
} catch (Throwable e) {
|
||||
Iris.reportError(e);
|
||||
e.printStackTrace();
|
||||
}
|
||||
} else {
|
||||
closeTask.run();
|
||||
}
|
||||
}
|
||||
|
||||
J.attemptAsync(() -> deleteStudioFolderWithRetry(folder, worldName));
|
||||
Iris.debug("Closed Active Provider " + worldName);
|
||||
activeProvider = null;
|
||||
}
|
||||
|
||||
private static void deleteStudioFolderWithRetry(File folder, String worldName) {
|
||||
if (folder == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
long unloadWaitDeadlineMs = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(20);
|
||||
while (Bukkit.getWorld(worldName) != null && System.currentTimeMillis() < unloadWaitDeadlineMs) {
|
||||
J.sleep(100);
|
||||
}
|
||||
|
||||
int attempts = 0;
|
||||
while (folder.exists() && attempts < 40) {
|
||||
IO.delete(folder);
|
||||
if (!folder.exists()) {
|
||||
sender.sendProgress(currentProgress, "Studio " + currentSpinner + " " + currentStage);
|
||||
return;
|
||||
}
|
||||
|
||||
attempts++;
|
||||
J.sleep(250);
|
||||
long now = System.currentTimeMillis();
|
||||
long nextUpdate = nextConsoleUpdate.get();
|
||||
if (now >= nextUpdate) {
|
||||
sender.sendMessage(C.WHITE + "Studio " + Form.pc(currentProgress, 0) + C.GRAY + " - " + currentStage);
|
||||
nextConsoleUpdate.set(now + 1500L);
|
||||
}
|
||||
}, 3);
|
||||
|
||||
taskId.set(scheduledTaskId);
|
||||
}
|
||||
|
||||
public CompletableFuture<StudioOpenCoordinator.StudioCloseResult> close() {
|
||||
if (activeProvider == null) {
|
||||
return CompletableFuture.completedFuture(new StudioOpenCoordinator.StudioCloseResult(null, true, true, false, null, null));
|
||||
}
|
||||
|
||||
if (!folder.exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Iris.queueWorldDeletionOnStartup(java.util.Collections.singleton(worldName));
|
||||
Iris.warn("Queued deferred deletion for studio world folder \"" + worldName + "\".");
|
||||
} catch (IOException e) {
|
||||
Iris.warn("Failed to queue deferred deletion for studio world folder \"" + worldName + "\".");
|
||||
Iris.reportError(e);
|
||||
}
|
||||
return StudioOpenCoordinator.get().closeProject(this);
|
||||
}
|
||||
|
||||
public File getCodeWorkspaceFile() {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package art.arcane.iris.core.runtime;
|
||||
|
||||
import art.arcane.iris.core.lifecycle.CapabilitySnapshot;
|
||||
import io.papermc.lib.PaperLib;
|
||||
import org.bukkit.Chunk;
|
||||
import org.bukkit.World;
|
||||
|
||||
import java.util.OptionalLong;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
final class BukkitPublicRuntimeControlBackend implements WorldRuntimeControlBackend {
|
||||
private final CapabilitySnapshot capabilities;
|
||||
|
||||
BukkitPublicRuntimeControlBackend(CapabilitySnapshot capabilities) {
|
||||
this.capabilities = capabilities;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String backendName() {
|
||||
return "bukkit_public_runtime";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String describeCapabilities() {
|
||||
String chunkAsync = capabilities.chunkAtAsyncMethod() != null ? "world#getChunkAtAsync" : "paperlib";
|
||||
return "time=bukkit_world#setTime, chunkAsync=" + chunkAsync + ", teleport=entity_scheduler";
|
||||
}
|
||||
|
||||
@Override
|
||||
public OptionalLong readDayTime(World world) {
|
||||
if (world == null) {
|
||||
return OptionalLong.empty();
|
||||
}
|
||||
|
||||
return OptionalLong.of(world.getTime());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean writeDayTime(World world, long dayTime) {
|
||||
if (world == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
world.setTime(dayTime);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void syncTime(World world) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Chunk> requestChunkAsync(World world, int chunkX, int chunkZ, boolean generate) {
|
||||
if (world == null) {
|
||||
return CompletableFuture.failedFuture(new IllegalStateException("World is null."));
|
||||
}
|
||||
|
||||
if (capabilities.chunkAtAsyncMethod() != null) {
|
||||
try {
|
||||
Object result = capabilities.chunkAtAsyncMethod().invoke(world, chunkX, chunkZ, generate);
|
||||
if (result instanceof CompletableFuture<?>) {
|
||||
@SuppressWarnings("unchecked")
|
||||
CompletableFuture<Chunk> future = (CompletableFuture<Chunk>) result;
|
||||
return future;
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
CompletableFuture<Chunk> future = PaperLib.getChunkAtAsync(world, chunkX, chunkZ, generate);
|
||||
if (future == null) {
|
||||
return CompletableFuture.failedFuture(new IllegalStateException("PaperLib did not return a chunk future."));
|
||||
}
|
||||
|
||||
return future;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package art.arcane.iris.core.runtime;
|
||||
|
||||
import com.google.gson.GsonBuilder;
|
||||
import art.arcane.iris.core.ServerConfigurator;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.volmlib.util.collection.KMap;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public final class DatapackReadinessResult {
|
||||
private final String requestedPackKey;
|
||||
private final List<String> resolvedDatapackFolders;
|
||||
private final String externalDatapackInstallResult;
|
||||
private final boolean verificationPassed;
|
||||
private final List<String> verifiedPaths;
|
||||
private final List<String> missingPaths;
|
||||
private final boolean restartRequired;
|
||||
|
||||
public String toJson() {
|
||||
return new GsonBuilder().setPrettyPrinting().create().toJson(this);
|
||||
}
|
||||
|
||||
public static DatapackReadinessResult installForStudioWorld(
|
||||
String requestedPackKey,
|
||||
String dimensionTypeKey,
|
||||
File worldFolder,
|
||||
boolean verifyDataPacks,
|
||||
boolean includeExternalDataPacks,
|
||||
KMap<String, KList<File>> extraWorldDatapackFoldersByPack
|
||||
) {
|
||||
ArrayList<String> resolvedFolders = new ArrayList<>();
|
||||
File datapacksFolder = ServerConfigurator.resolveDatapacksFolder(worldFolder);
|
||||
resolvedFolders.add(datapacksFolder.getAbsolutePath());
|
||||
if (extraWorldDatapackFoldersByPack != null) {
|
||||
KList<File> extraFolders = extraWorldDatapackFoldersByPack.get(requestedPackKey);
|
||||
if (extraFolders != null) {
|
||||
for (File extraFolder : extraFolders) {
|
||||
if (extraFolder == null) {
|
||||
continue;
|
||||
}
|
||||
String path = extraFolder.getAbsolutePath();
|
||||
if (!resolvedFolders.contains(path)) {
|
||||
resolvedFolders.add(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String externalResult = "ok";
|
||||
boolean restartRequired = false;
|
||||
try {
|
||||
restartRequired = ServerConfigurator.installDataPacks(verifyDataPacks, includeExternalDataPacks, extraWorldDatapackFoldersByPack);
|
||||
} catch (Throwable e) {
|
||||
externalResult = e.getClass().getSimpleName() + ": " + String.valueOf(e.getMessage());
|
||||
}
|
||||
|
||||
ArrayList<String> verifiedPaths = new ArrayList<>();
|
||||
ArrayList<String> missingPaths = new ArrayList<>();
|
||||
String verificationDimensionTypeKey = (dimensionTypeKey == null || dimensionTypeKey.isBlank())
|
||||
? requestedPackKey
|
||||
: dimensionTypeKey;
|
||||
for (String folderPath : resolvedFolders) {
|
||||
File folder = new File(folderPath);
|
||||
collectVerificationPaths(folder, verificationDimensionTypeKey, verifiedPaths, missingPaths);
|
||||
}
|
||||
|
||||
boolean verificationPassed = missingPaths.isEmpty() && "ok".equals(externalResult);
|
||||
return new DatapackReadinessResult(
|
||||
requestedPackKey,
|
||||
List.copyOf(resolvedFolders),
|
||||
externalResult,
|
||||
verificationPassed,
|
||||
List.copyOf(verifiedPaths),
|
||||
List.copyOf(missingPaths),
|
||||
restartRequired
|
||||
);
|
||||
}
|
||||
|
||||
static void collectVerificationPaths(File folder, String dimensionTypeKey, List<String> verifiedPaths, List<String> missingPaths) {
|
||||
File packMeta = new File(folder, "iris/pack.mcmeta");
|
||||
File dimensionType = new File(folder, "iris/data/iris/dimension_type/" + dimensionTypeKey + ".json");
|
||||
if (packMeta.exists()) {
|
||||
verifiedPaths.add(packMeta.getAbsolutePath());
|
||||
} else {
|
||||
missingPaths.add(packMeta.getAbsolutePath());
|
||||
}
|
||||
if (dimensionType.exists()) {
|
||||
verifiedPaths.add(dimensionType.getAbsolutePath());
|
||||
} else {
|
||||
missingPaths.add(dimensionType.getAbsolutePath());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
package art.arcane.iris.core.runtime;
|
||||
|
||||
import art.arcane.iris.core.lifecycle.CapabilitySnapshot;
|
||||
import io.papermc.lib.PaperLib;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Chunk;
|
||||
import org.bukkit.World;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.OptionalLong;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
final class PaperLikeRuntimeControlBackend implements WorldRuntimeControlBackend {
|
||||
private final CapabilitySnapshot capabilities;
|
||||
private final AtomicReference<TimeAccessStrategy> timeAccessStrategy;
|
||||
|
||||
PaperLikeRuntimeControlBackend(CapabilitySnapshot capabilities) {
|
||||
this.capabilities = capabilities;
|
||||
this.timeAccessStrategy = new AtomicReference<>();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String backendName() {
|
||||
return "paper_like_runtime";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String describeCapabilities() {
|
||||
TimeAccessStrategy strategy = timeAccessStrategy.get();
|
||||
String timeAccess = strategy == null ? "deferred" : strategy.description();
|
||||
String chunkAsync = capabilities.chunkAtAsyncMethod() != null ? "world#getChunkAtAsync" : "paperlib";
|
||||
return "time=" + timeAccess + ", chunkAsync=" + chunkAsync + ", teleport=entity_scheduler";
|
||||
}
|
||||
|
||||
@Override
|
||||
public OptionalLong readDayTime(World world) {
|
||||
if (world == null) {
|
||||
return OptionalLong.empty();
|
||||
}
|
||||
|
||||
TimeAccessStrategy strategy = resolveTimeAccessStrategy(world);
|
||||
if (strategy == null) {
|
||||
return OptionalLong.empty();
|
||||
}
|
||||
|
||||
try {
|
||||
Object handle = strategy.handleMethod().invoke(world);
|
||||
if (handle == null) {
|
||||
return OptionalLong.empty();
|
||||
}
|
||||
|
||||
Object value = strategy.readMethod().invoke(strategy.readOwner(handle), strategy.readArguments(handle));
|
||||
if (value instanceof Long longValue) {
|
||||
return OptionalLong.of(longValue.longValue());
|
||||
}
|
||||
if (value instanceof Number number) {
|
||||
return OptionalLong.of(number.longValue());
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
|
||||
return OptionalLong.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean writeDayTime(World world, long dayTime) throws ReflectiveOperationException {
|
||||
if (world == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
TimeAccessStrategy strategy = resolveTimeAccessStrategy(world);
|
||||
if (strategy == null || !strategy.writable()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Object handle = strategy.handleMethod().invoke(world);
|
||||
if (handle == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Object writeOwner = strategy.writeOwner(handle);
|
||||
if (writeOwner == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
strategy.writeMethod().invoke(writeOwner, dayTime);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void syncTime(World world) {
|
||||
TimeAccessStrategy strategy = timeAccessStrategy.get();
|
||||
if (strategy == null || strategy.syncMethod() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Object craftServer = Bukkit.getServer();
|
||||
if (craftServer == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object serverHandle = strategy.serverHandleMethod() == null ? null : strategy.serverHandleMethod().invoke(craftServer);
|
||||
if (serverHandle == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
strategy.syncMethod().invoke(serverHandle);
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Chunk> requestChunkAsync(World world, int chunkX, int chunkZ, boolean generate) {
|
||||
if (world == null) {
|
||||
return CompletableFuture.failedFuture(new IllegalStateException("World is null."));
|
||||
}
|
||||
|
||||
if (capabilities.chunkAtAsyncMethod() != null) {
|
||||
try {
|
||||
Object result = capabilities.chunkAtAsyncMethod().invoke(world, chunkX, chunkZ, generate);
|
||||
if (result instanceof CompletableFuture<?>) {
|
||||
@SuppressWarnings("unchecked")
|
||||
CompletableFuture<Chunk> future = (CompletableFuture<Chunk>) result;
|
||||
return future;
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
return CompletableFuture.failedFuture(e);
|
||||
}
|
||||
}
|
||||
|
||||
CompletableFuture<Chunk> future = PaperLib.getChunkAtAsync(world, chunkX, chunkZ, generate);
|
||||
if (future == null) {
|
||||
return CompletableFuture.failedFuture(new IllegalStateException("PaperLib did not return a chunk future."));
|
||||
}
|
||||
|
||||
return future;
|
||||
}
|
||||
|
||||
private TimeAccessStrategy resolveTimeAccessStrategy(World world) {
|
||||
TimeAccessStrategy current = timeAccessStrategy.get();
|
||||
if (current != null) {
|
||||
return current;
|
||||
}
|
||||
|
||||
synchronized (timeAccessStrategy) {
|
||||
current = timeAccessStrategy.get();
|
||||
if (current != null) {
|
||||
return current;
|
||||
}
|
||||
|
||||
TimeAccessStrategy resolved = probeTimeAccessStrategy(world);
|
||||
timeAccessStrategy.set(resolved);
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
|
||||
private TimeAccessStrategy probeTimeAccessStrategy(World world) {
|
||||
if (world == null) {
|
||||
return TimeAccessStrategy.unsupported();
|
||||
}
|
||||
|
||||
try {
|
||||
Method handleMethod = resolveZeroArgMethod(world.getClass(), "getHandle");
|
||||
if (handleMethod == null) {
|
||||
return TimeAccessStrategy.unsupported();
|
||||
}
|
||||
|
||||
Object handle = handleMethod.invoke(world);
|
||||
if (handle == null) {
|
||||
return TimeAccessStrategy.unsupported();
|
||||
}
|
||||
|
||||
Method readMethod = resolveZeroArgMethod(handle.getClass(), "getDayTime");
|
||||
Method writeMethod = resolveLongArgMethod(handle.getClass(), "setDayTime");
|
||||
if (readMethod != null && writeMethod != null) {
|
||||
return TimeAccessStrategy.forHandle(handleMethod, readMethod, writeMethod, "runtime_handle#setDayTime");
|
||||
}
|
||||
|
||||
Method levelDataMethod = resolveZeroArgMethod(handle.getClass(), "serverLevelData");
|
||||
if (levelDataMethod == null) {
|
||||
levelDataMethod = resolveZeroArgMethod(handle.getClass(), "getLevelData");
|
||||
}
|
||||
if (levelDataMethod != null) {
|
||||
Object levelData = levelDataMethod.invoke(handle);
|
||||
if (levelData != null) {
|
||||
Method levelDataReadMethod = resolveZeroArgMethod(levelData.getClass(), "getDayTime");
|
||||
Method levelDataWriteMethod = resolveLongArgMethod(levelData.getClass(), "setDayTime");
|
||||
if (levelDataReadMethod != null && levelDataWriteMethod != null) {
|
||||
return TimeAccessStrategy.forLevelData(handleMethod, levelDataMethod, levelDataReadMethod, levelDataWriteMethod, "world_data#setDayTime");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return TimeAccessStrategy.unsupported(handleMethod);
|
||||
} catch (Throwable ignored) {
|
||||
return TimeAccessStrategy.unsupported();
|
||||
}
|
||||
}
|
||||
|
||||
private static Method resolveZeroArgMethod(Class<?> type, String name) {
|
||||
Class<?> current = type;
|
||||
while (current != null) {
|
||||
try {
|
||||
Method method = current.getDeclaredMethod(name);
|
||||
method.setAccessible(true);
|
||||
return method;
|
||||
} catch (NoSuchMethodException ignored) {
|
||||
current = current.getSuperclass();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Method resolveLongArgMethod(Class<?> type, String name) {
|
||||
Class<?> current = type;
|
||||
while (current != null) {
|
||||
try {
|
||||
Method method = current.getDeclaredMethod(name, long.class);
|
||||
method.setAccessible(true);
|
||||
return method;
|
||||
} catch (NoSuchMethodException ignored) {
|
||||
current = current.getSuperclass();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private record TimeAccessStrategy(
|
||||
Method handleMethod,
|
||||
Method levelDataMethod,
|
||||
Method readMethod,
|
||||
Method writeMethod,
|
||||
Method serverHandleMethod,
|
||||
Method syncMethod,
|
||||
String description
|
||||
) {
|
||||
static TimeAccessStrategy forHandle(Method handleMethod, Method readMethod, Method writeMethod, String description) {
|
||||
Method serverHandleMethod = resolveCraftServerMethod("getHandle");
|
||||
Method syncMethod = resolveServerMethod(serverHandleMethod, "forceTimeSynchronization");
|
||||
return new TimeAccessStrategy(handleMethod, null, readMethod, writeMethod, serverHandleMethod, syncMethod, description);
|
||||
}
|
||||
|
||||
static TimeAccessStrategy forLevelData(Method handleMethod, Method levelDataMethod, Method readMethod, Method writeMethod, String description) {
|
||||
Method serverHandleMethod = resolveCraftServerMethod("getHandle");
|
||||
Method syncMethod = resolveServerMethod(serverHandleMethod, "forceTimeSynchronization");
|
||||
return new TimeAccessStrategy(handleMethod, levelDataMethod, readMethod, writeMethod, serverHandleMethod, syncMethod, description);
|
||||
}
|
||||
|
||||
static TimeAccessStrategy unsupported() {
|
||||
return new TimeAccessStrategy(null, null, null, null, null, null, "unsupported");
|
||||
}
|
||||
|
||||
static TimeAccessStrategy unsupported(Method handleMethod) {
|
||||
return new TimeAccessStrategy(handleMethod, null, null, null, null, null, "unsupported");
|
||||
}
|
||||
|
||||
boolean writable() {
|
||||
return handleMethod != null && readMethod != null && writeMethod != null;
|
||||
}
|
||||
|
||||
Object readOwner(Object handle) throws ReflectiveOperationException {
|
||||
if (levelDataMethod == null) {
|
||||
return handle;
|
||||
}
|
||||
|
||||
return levelDataMethod.invoke(handle);
|
||||
}
|
||||
|
||||
Object[] readArguments(Object handle) {
|
||||
return new Object[0];
|
||||
}
|
||||
|
||||
Object writeOwner(Object handle) throws ReflectiveOperationException {
|
||||
if (levelDataMethod == null) {
|
||||
return handle;
|
||||
}
|
||||
|
||||
return levelDataMethod.invoke(handle);
|
||||
}
|
||||
|
||||
private static Method resolveCraftServerMethod(String name) {
|
||||
try {
|
||||
Method method = Bukkit.getServer().getClass().getMethod(name);
|
||||
method.setAccessible(true);
|
||||
return method;
|
||||
} catch (Throwable ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static Method resolveServerMethod(Method serverHandleMethod, String name) {
|
||||
if (serverHandleMethod == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
Object craftServer = Bukkit.getServer();
|
||||
if (craftServer == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Object serverHandle = serverHandleMethod.invoke(craftServer);
|
||||
if (serverHandle == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Method method = serverHandle.getClass().getMethod(name);
|
||||
method.setAccessible(true);
|
||||
return method;
|
||||
} catch (Throwable ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
package art.arcane.iris.core.runtime;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import art.arcane.iris.Iris;
|
||||
import art.arcane.volmlib.util.io.IO;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.PrintWriter;
|
||||
import java.io.StringWriter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
public final class SmokeDiagnosticsService {
|
||||
private static volatile SmokeDiagnosticsService instance;
|
||||
|
||||
private final ConcurrentHashMap<String, SmokeRunReport> reports;
|
||||
private final AtomicReference<String> latestRunId;
|
||||
private final AtomicLong runCounter;
|
||||
private final Gson gson;
|
||||
|
||||
private SmokeDiagnosticsService() {
|
||||
this.reports = new ConcurrentHashMap<>();
|
||||
this.latestRunId = new AtomicReference<>();
|
||||
this.runCounter = new AtomicLong(1L);
|
||||
this.gson = new GsonBuilder().setPrettyPrinting().create();
|
||||
}
|
||||
|
||||
public static SmokeDiagnosticsService get() {
|
||||
SmokeDiagnosticsService current = instance;
|
||||
if (current != null) {
|
||||
return current;
|
||||
}
|
||||
|
||||
synchronized (SmokeDiagnosticsService.class) {
|
||||
if (instance != null) {
|
||||
return instance;
|
||||
}
|
||||
|
||||
instance = new SmokeDiagnosticsService();
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
|
||||
public SmokeRunHandle beginRun(SmokeRunMode mode, String worldName, boolean studio, boolean headless, String playerName, boolean retainOnFailure) {
|
||||
long ordinal = runCounter.getAndIncrement();
|
||||
String runId = String.format("%s-%05d", mode.id(), ordinal);
|
||||
SmokeRunReport report = new SmokeRunReport();
|
||||
report.setRunId(runId);
|
||||
report.setMode(mode.id());
|
||||
report.setWorldName(worldName);
|
||||
report.setStudio(studio);
|
||||
report.setHeadless(headless);
|
||||
report.setPlayerName(playerName);
|
||||
report.setRetainOnFailure(retainOnFailure);
|
||||
report.setStartedAt(System.currentTimeMillis());
|
||||
report.setOutcome("running");
|
||||
report.setStage("queued");
|
||||
report.setLifecycleBackend(art.arcane.iris.core.lifecycle.WorldLifecycleService.get().capabilities().serverFamily().id());
|
||||
report.setRuntimeBackend(WorldRuntimeControlService.get().backendName());
|
||||
reports.put(runId, report);
|
||||
latestRunId.set(runId);
|
||||
persist(report);
|
||||
return new SmokeRunHandle(report);
|
||||
}
|
||||
|
||||
public SmokeRunReport latest() {
|
||||
String runId = latestRunId.get();
|
||||
if (runId == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return get(runId);
|
||||
}
|
||||
|
||||
public SmokeRunReport get(String runId) {
|
||||
if (runId == null || runId.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
SmokeRunReport report = reports.get(runId);
|
||||
if (report != null) {
|
||||
return snapshot(report);
|
||||
}
|
||||
|
||||
return load(runId);
|
||||
}
|
||||
|
||||
public SmokeRunReport latestPersisted() {
|
||||
File latestFile = latestFile();
|
||||
if (!latestFile.exists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return gson.fromJson(IO.readAll(latestFile), SmokeRunReport.class);
|
||||
} catch (Throwable e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private SmokeRunReport load(String runId) {
|
||||
File file = reportFile(runId);
|
||||
if (!file.exists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return gson.fromJson(IO.readAll(file), SmokeRunReport.class);
|
||||
} catch (Throwable e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void persist(SmokeRunReport report) {
|
||||
if (report == null || !SmokeRunMode.shouldPersist(report.getMode())) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
String json = gson.toJson(report);
|
||||
File file = reportFile(report.getRunId());
|
||||
IO.writeAll(file, json);
|
||||
IO.writeAll(latestFile(), json);
|
||||
} catch (Throwable e) {
|
||||
Iris.reportError("Failed to persist smoke report \"" + report.getRunId() + "\".", e);
|
||||
}
|
||||
}
|
||||
|
||||
private SmokeRunReport snapshot(SmokeRunReport report) {
|
||||
String json = gson.toJson(report);
|
||||
return gson.fromJson(json, SmokeRunReport.class);
|
||||
}
|
||||
|
||||
private File reportFile(String runId) {
|
||||
if (Iris.instance == null) {
|
||||
File root = new File("plugins/Iris/diagnostics/smoke");
|
||||
root.mkdirs();
|
||||
return new File(root, runId + ".json");
|
||||
}
|
||||
|
||||
return Iris.instance.getDataFile("diagnostics", "smoke", runId + ".json");
|
||||
}
|
||||
|
||||
private File latestFile() {
|
||||
if (Iris.instance == null) {
|
||||
File root = new File("plugins/Iris/diagnostics/smoke");
|
||||
root.mkdirs();
|
||||
return new File(root, "latest.json");
|
||||
}
|
||||
|
||||
return Iris.instance.getDataFile("diagnostics", "smoke", "latest.json");
|
||||
}
|
||||
|
||||
public enum SmokeRunMode {
|
||||
FULL("full", true),
|
||||
STUDIO("studio", true),
|
||||
CREATE("create", true),
|
||||
BENCHMARK("benchmark", true),
|
||||
STUDIO_OPEN("studio_open", false),
|
||||
STUDIO_CLOSE("studio_close", false);
|
||||
|
||||
private final String id;
|
||||
private final boolean persisted;
|
||||
|
||||
SmokeRunMode(String id, boolean persisted) {
|
||||
this.id = id;
|
||||
this.persisted = persisted;
|
||||
}
|
||||
|
||||
public String id() {
|
||||
return id;
|
||||
}
|
||||
|
||||
static boolean shouldPersist(String id) {
|
||||
for (SmokeRunMode mode : values()) {
|
||||
if (mode.id.equals(id)) {
|
||||
return mode.persisted;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public final class SmokeRunHandle {
|
||||
private final SmokeRunReport report;
|
||||
|
||||
private SmokeRunHandle(SmokeRunReport report) {
|
||||
this.report = report;
|
||||
}
|
||||
|
||||
public String runId() {
|
||||
return report.getRunId();
|
||||
}
|
||||
|
||||
public SmokeRunReport snapshot() {
|
||||
synchronized (report) {
|
||||
report.setElapsedMs(System.currentTimeMillis() - report.getStartedAt());
|
||||
return SmokeDiagnosticsService.this.snapshot(report);
|
||||
}
|
||||
}
|
||||
|
||||
public void setWorldName(String worldName) {
|
||||
synchronized (report) {
|
||||
report.setWorldName(worldName);
|
||||
report.setElapsedMs(System.currentTimeMillis() - report.getStartedAt());
|
||||
persist(report);
|
||||
}
|
||||
}
|
||||
|
||||
public void setLifecycleBackend(String backend) {
|
||||
synchronized (report) {
|
||||
report.setLifecycleBackend(backend);
|
||||
persist(report);
|
||||
}
|
||||
}
|
||||
|
||||
public void setRuntimeBackend(String backend) {
|
||||
synchronized (report) {
|
||||
report.setRuntimeBackend(backend);
|
||||
persist(report);
|
||||
}
|
||||
}
|
||||
|
||||
public void setEntryChunk(int chunkX, int chunkZ) {
|
||||
synchronized (report) {
|
||||
report.setEntryChunkX(chunkX);
|
||||
report.setEntryChunkZ(chunkZ);
|
||||
report.setElapsedMs(System.currentTimeMillis() - report.getStartedAt());
|
||||
persist(report);
|
||||
}
|
||||
}
|
||||
|
||||
public void setGenerationSession(long sessionId, int activeLeases) {
|
||||
synchronized (report) {
|
||||
report.setGenerationSessionId(sessionId);
|
||||
report.setGenerationActiveLeases(activeLeases);
|
||||
report.setElapsedMs(System.currentTimeMillis() - report.getStartedAt());
|
||||
persist(report);
|
||||
}
|
||||
}
|
||||
|
||||
public void setDatapackReadiness(DatapackReadinessResult readiness) {
|
||||
synchronized (report) {
|
||||
report.setDatapackReadiness(readiness);
|
||||
report.setElapsedMs(System.currentTimeMillis() - report.getStartedAt());
|
||||
persist(report);
|
||||
}
|
||||
}
|
||||
|
||||
public void setCloseState(boolean unloadCompletedLive, boolean folderDeletionCompletedLive, boolean startupCleanupQueued) {
|
||||
synchronized (report) {
|
||||
report.setCloseUnloadCompletedLive(unloadCompletedLive);
|
||||
report.setCloseFolderDeletionCompletedLive(folderDeletionCompletedLive);
|
||||
report.setCloseStartupCleanupQueued(startupCleanupQueued);
|
||||
report.setElapsedMs(System.currentTimeMillis() - report.getStartedAt());
|
||||
persist(report);
|
||||
}
|
||||
}
|
||||
|
||||
public void note(String text) {
|
||||
synchronized (report) {
|
||||
ArrayList<String> notes = new ArrayList<>(report.getNotes());
|
||||
notes.add(text);
|
||||
report.setNotes(List.copyOf(notes));
|
||||
report.setElapsedMs(System.currentTimeMillis() - report.getStartedAt());
|
||||
persist(report);
|
||||
}
|
||||
}
|
||||
|
||||
public void stage(String stage) {
|
||||
stage(stage, null);
|
||||
}
|
||||
|
||||
public void stage(String stage, String detail) {
|
||||
synchronized (report) {
|
||||
report.setStage(stage);
|
||||
report.setStageDetail(detail);
|
||||
report.setElapsedMs(System.currentTimeMillis() - report.getStartedAt());
|
||||
persist(report);
|
||||
}
|
||||
}
|
||||
|
||||
public void completeSuccess(String finalStage, boolean cleanupApplied) {
|
||||
synchronized (report) {
|
||||
report.setStage(finalStage);
|
||||
report.setOutcome("success");
|
||||
report.setCleanupApplied(cleanupApplied);
|
||||
report.setCompletedAt(System.currentTimeMillis());
|
||||
report.setElapsedMs(report.getCompletedAt() - report.getStartedAt());
|
||||
persist(report);
|
||||
}
|
||||
}
|
||||
|
||||
public void completeFailure(String finalStage, Throwable throwable, boolean cleanupApplied) {
|
||||
synchronized (report) {
|
||||
report.setStage(finalStage);
|
||||
report.setOutcome("failed");
|
||||
report.setCleanupApplied(cleanupApplied);
|
||||
report.setCompletedAt(System.currentTimeMillis());
|
||||
report.setElapsedMs(report.getCompletedAt() - report.getStartedAt());
|
||||
if (throwable != null) {
|
||||
report.setFailureType(throwable.getClass().getName());
|
||||
report.setFailureMessage(String.valueOf(throwable.getMessage()));
|
||||
report.setFailureChain(failureChain(throwable));
|
||||
report.setFailureStacktrace(stacktrace(throwable));
|
||||
}
|
||||
persist(report);
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> failureChain(Throwable throwable) {
|
||||
ArrayList<String> chain = new ArrayList<>();
|
||||
Throwable cursor = throwable;
|
||||
while (cursor != null) {
|
||||
chain.add(cursor.getClass().getName() + ": " + String.valueOf(cursor.getMessage()));
|
||||
cursor = cursor.getCause();
|
||||
}
|
||||
return List.copyOf(chain);
|
||||
}
|
||||
|
||||
private String stacktrace(Throwable throwable) {
|
||||
StringWriter writer = new StringWriter();
|
||||
PrintWriter printWriter = new PrintWriter(writer);
|
||||
throwable.printStackTrace(printWriter);
|
||||
printWriter.flush();
|
||||
return writer.toString();
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
public static final class SmokeRunReport {
|
||||
private String runId;
|
||||
private String mode;
|
||||
private String worldName;
|
||||
private String stage;
|
||||
private String stageDetail;
|
||||
private long startedAt;
|
||||
private long completedAt;
|
||||
private long elapsedMs;
|
||||
private String outcome;
|
||||
private String lifecycleBackend;
|
||||
private String runtimeBackend;
|
||||
private long generationSessionId;
|
||||
private int generationActiveLeases;
|
||||
private Integer entryChunkX;
|
||||
private Integer entryChunkZ;
|
||||
private boolean studio;
|
||||
private boolean headless;
|
||||
private String playerName;
|
||||
private boolean retainOnFailure;
|
||||
private boolean cleanupApplied;
|
||||
private boolean closeUnloadCompletedLive;
|
||||
private boolean closeFolderDeletionCompletedLive;
|
||||
private boolean closeStartupCleanupQueued;
|
||||
private DatapackReadinessResult datapackReadiness;
|
||||
private String failureType;
|
||||
private String failureMessage;
|
||||
private List<String> failureChain = List.of();
|
||||
private String failureStacktrace;
|
||||
private List<String> notes = List.of();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
package art.arcane.iris.core.runtime;
|
||||
|
||||
import art.arcane.iris.Iris;
|
||||
import art.arcane.iris.core.ServerConfigurator;
|
||||
import art.arcane.iris.core.lifecycle.WorldLifecycleService;
|
||||
import art.arcane.iris.core.tools.IrisCreator;
|
||||
import art.arcane.iris.core.tools.IrisToolbelt;
|
||||
import art.arcane.iris.engine.IrisEngine;
|
||||
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
|
||||
import art.arcane.iris.util.common.plugin.VolmitSender;
|
||||
import art.arcane.iris.util.common.scheduling.J;
|
||||
import art.arcane.volmlib.util.exceptions.IrisException;
|
||||
import art.arcane.volmlib.util.io.IO;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.World;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
public final class SmokeTestService {
|
||||
private static volatile SmokeTestService instance;
|
||||
|
||||
private final SmokeDiagnosticsService diagnostics;
|
||||
|
||||
private SmokeTestService() {
|
||||
this.diagnostics = SmokeDiagnosticsService.get();
|
||||
}
|
||||
|
||||
public static SmokeTestService get() {
|
||||
SmokeTestService current = instance;
|
||||
if (current != null) {
|
||||
return current;
|
||||
}
|
||||
|
||||
synchronized (SmokeTestService.class) {
|
||||
if (instance != null) {
|
||||
return instance;
|
||||
}
|
||||
|
||||
instance = new SmokeTestService();
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
|
||||
public String startCreateSmoke(VolmitSender sender, String dimensionKey, long seed, boolean retainOnFailure) {
|
||||
SmokeDiagnosticsService.SmokeRunHandle handle = diagnostics.beginRun(
|
||||
SmokeDiagnosticsService.SmokeRunMode.CREATE,
|
||||
nextWorldName("create"),
|
||||
false,
|
||||
true,
|
||||
null,
|
||||
retainOnFailure
|
||||
);
|
||||
J.a(() -> executeCreateSmoke(handle, sender, dimensionKey, seed, false, true));
|
||||
return handle.runId();
|
||||
}
|
||||
|
||||
public String startBenchmarkSmoke(VolmitSender sender, String dimensionKey, long seed, boolean retainOnFailure) {
|
||||
SmokeDiagnosticsService.SmokeRunHandle handle = diagnostics.beginRun(
|
||||
SmokeDiagnosticsService.SmokeRunMode.BENCHMARK,
|
||||
nextWorldName("benchmark"),
|
||||
false,
|
||||
true,
|
||||
null,
|
||||
retainOnFailure
|
||||
);
|
||||
J.a(() -> executeCreateSmoke(handle, sender, dimensionKey, seed, true, true));
|
||||
return handle.runId();
|
||||
}
|
||||
|
||||
public String startStudioSmoke(VolmitSender sender, String dimensionKey, long seed, String playerName, boolean retainOnFailure) {
|
||||
String normalizedPlayer = normalizePlayerName(playerName);
|
||||
SmokeDiagnosticsService.SmokeRunHandle handle = diagnostics.beginRun(
|
||||
SmokeDiagnosticsService.SmokeRunMode.STUDIO,
|
||||
nextWorldName("studio"),
|
||||
true,
|
||||
normalizedPlayer == null,
|
||||
normalizedPlayer,
|
||||
retainOnFailure
|
||||
);
|
||||
J.a(() -> executeStudioSmoke(handle, sender, dimensionKey, seed, normalizedPlayer, retainOnFailure, true));
|
||||
return handle.runId();
|
||||
}
|
||||
|
||||
public String startFullSmoke(VolmitSender sender, String dimensionKey, long seed, String playerName, boolean retainOnFailure) {
|
||||
String normalizedPlayer = normalizePlayerName(playerName);
|
||||
SmokeDiagnosticsService.SmokeRunHandle handle = diagnostics.beginRun(
|
||||
SmokeDiagnosticsService.SmokeRunMode.FULL,
|
||||
nextWorldName("full"),
|
||||
false,
|
||||
normalizedPlayer == null,
|
||||
normalizedPlayer,
|
||||
retainOnFailure
|
||||
);
|
||||
J.a(() -> executeFullSmoke(handle, sender, dimensionKey, seed, normalizedPlayer, retainOnFailure));
|
||||
return handle.runId();
|
||||
}
|
||||
|
||||
public SmokeDiagnosticsService.SmokeRunReport latest() {
|
||||
SmokeDiagnosticsService.SmokeRunReport latest = diagnostics.latest();
|
||||
if (latest != null) {
|
||||
return latest;
|
||||
}
|
||||
|
||||
return diagnostics.latestPersisted();
|
||||
}
|
||||
|
||||
public SmokeDiagnosticsService.SmokeRunReport get(String runId) {
|
||||
return diagnostics.get(runId);
|
||||
}
|
||||
|
||||
public WorldInspection inspectWorld(String worldName) {
|
||||
World world = Bukkit.getWorld(worldName);
|
||||
if (world == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
PlatformChunkGenerator provider = IrisToolbelt.access(world);
|
||||
boolean studio = provider != null && provider.isStudio();
|
||||
boolean engineClosed = false;
|
||||
boolean engineFailing = false;
|
||||
long generationSessionId = 0L;
|
||||
int activeLeases = 0;
|
||||
if (provider != null && provider.getEngine() instanceof IrisEngine irisEngine) {
|
||||
engineClosed = irisEngine.isClosed();
|
||||
engineFailing = irisEngine.isFailing();
|
||||
generationSessionId = irisEngine.getGenerationSessionId();
|
||||
activeLeases = irisEngine.getGenerationSessions().activeLeases();
|
||||
}
|
||||
|
||||
ArrayList<String> datapackFolders = new ArrayList<>();
|
||||
File datapacksFolder = ServerConfigurator.resolveDatapacksFolder(world.getWorldFolder());
|
||||
datapackFolders.add(datapacksFolder.getAbsolutePath());
|
||||
return new WorldInspection(
|
||||
world.getName(),
|
||||
WorldLifecycleService.get().backendNameForWorld(world.getName()),
|
||||
WorldRuntimeControlService.get().backendName(),
|
||||
studio,
|
||||
engineClosed,
|
||||
engineFailing,
|
||||
generationSessionId,
|
||||
activeLeases,
|
||||
List.copyOf(datapackFolders),
|
||||
IrisToolbelt.isWorldMaintenanceActive(world)
|
||||
);
|
||||
}
|
||||
|
||||
private void executeFullSmoke(
|
||||
SmokeDiagnosticsService.SmokeRunHandle handle,
|
||||
VolmitSender sender,
|
||||
String dimensionKey,
|
||||
long seed,
|
||||
String playerName,
|
||||
boolean retainOnFailure
|
||||
) {
|
||||
try {
|
||||
handle.stage("create");
|
||||
executeCreateSmoke(handle, sender, dimensionKey, seed, false, false);
|
||||
handle.note("create smoke complete");
|
||||
|
||||
handle.stage("benchmark");
|
||||
executeCreateSmoke(handle, sender, dimensionKey, seed, true, false);
|
||||
handle.note("benchmark smoke complete");
|
||||
|
||||
handle.stage("studio");
|
||||
executeStudioSmoke(handle, sender, dimensionKey, seed, playerName, retainOnFailure, false);
|
||||
handle.note("studio smoke complete");
|
||||
|
||||
handle.completeSuccess("cleanup", true);
|
||||
} catch (Throwable e) {
|
||||
handle.completeFailure("cleanup", e, !retainOnFailure);
|
||||
}
|
||||
}
|
||||
|
||||
private void executeCreateSmoke(
|
||||
SmokeDiagnosticsService.SmokeRunHandle handle,
|
||||
VolmitSender sender,
|
||||
String dimensionKey,
|
||||
long seed,
|
||||
boolean benchmark,
|
||||
boolean completeHandle
|
||||
) {
|
||||
String worldName = nextWorldName(benchmark ? "benchmark" : "create");
|
||||
handle.setWorldName(worldName);
|
||||
cleanupTransientPrefix("iris-smoke-");
|
||||
World world = null;
|
||||
PlatformChunkGenerator provider = null;
|
||||
boolean cleanupApplied = false;
|
||||
try {
|
||||
IrisCreator creator = IrisToolbelt.createWorld()
|
||||
.dimension(dimensionKey)
|
||||
.name(worldName)
|
||||
.seed(seed)
|
||||
.sender(sender)
|
||||
.studio(false)
|
||||
.benchmark(benchmark)
|
||||
.studioProgressConsumer((progress, stage) -> handle.stage(mapCreateStage(stage)));
|
||||
world = creator.create();
|
||||
provider = IrisToolbelt.access(world);
|
||||
handle.setLifecycleBackend(WorldLifecycleService.get().backendNameForWorld(world.getName()));
|
||||
handle.setRuntimeBackend(WorldRuntimeControlService.get().backendName());
|
||||
handle.setDatapackReadiness(creator.getLastDatapackReadinessResult());
|
||||
captureGenerationSession(provider, handle);
|
||||
|
||||
if (benchmark) {
|
||||
handle.stage("apply_world_rules");
|
||||
WorldRuntimeControlService.get().applyStudioWorldRules(world);
|
||||
}
|
||||
|
||||
handle.stage("cleanup");
|
||||
cleanupWorld(world, worldName);
|
||||
cleanupApplied = true;
|
||||
if (completeHandle) {
|
||||
handle.completeSuccess("cleanup", true);
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
Iris.reportError("Smoke create failed for world \"" + worldName + "\".", e);
|
||||
if (!handle.snapshot().isRetainOnFailure()) {
|
||||
try {
|
||||
cleanupWorld(world, worldName);
|
||||
cleanupApplied = true;
|
||||
} catch (Throwable cleanupError) {
|
||||
Iris.reportError("Smoke cleanup failed for world \"" + worldName + "\".", cleanupError);
|
||||
}
|
||||
}
|
||||
if (completeHandle) {
|
||||
handle.completeFailure("cleanup", e, cleanupApplied);
|
||||
} else {
|
||||
if (e instanceof RuntimeException runtimeException) {
|
||||
throw runtimeException;
|
||||
}
|
||||
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void executeStudioSmoke(
|
||||
SmokeDiagnosticsService.SmokeRunHandle handle,
|
||||
VolmitSender sender,
|
||||
String dimensionKey,
|
||||
long seed,
|
||||
String playerName,
|
||||
boolean retainOnFailure,
|
||||
boolean completeHandle
|
||||
) {
|
||||
String worldName = nextWorldName("studio");
|
||||
handle.setWorldName(worldName);
|
||||
cleanupTransientPrefix("iris-smoke-");
|
||||
World world = null;
|
||||
boolean cleanupApplied = false;
|
||||
CompletableFuture<StudioOpenCoordinator.StudioOpenResult> future = StudioOpenCoordinator.get().open(
|
||||
new StudioOpenCoordinator.StudioOpenRequest(
|
||||
dimensionKey,
|
||||
null,
|
||||
sender,
|
||||
seed,
|
||||
worldName,
|
||||
playerName,
|
||||
false,
|
||||
retainOnFailure,
|
||||
SmokeDiagnosticsService.SmokeRunMode.STUDIO,
|
||||
handle,
|
||||
completeHandle,
|
||||
update -> handle.stage(update.stage()),
|
||||
openedWorld -> {
|
||||
}
|
||||
)
|
||||
);
|
||||
try {
|
||||
StudioOpenCoordinator.StudioOpenResult result = future.join();
|
||||
world = result == null ? null : result.world();
|
||||
handle.stage("cleanup");
|
||||
cleanupWorld(world, worldName);
|
||||
cleanupApplied = true;
|
||||
if (completeHandle) {
|
||||
handle.completeSuccess("cleanup", true);
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
if (world != null && !cleanupApplied) {
|
||||
try {
|
||||
cleanupWorld(world, worldName);
|
||||
cleanupApplied = true;
|
||||
} catch (Throwable cleanupError) {
|
||||
Iris.reportError("Smoke cleanup failed for world \"" + worldName + "\".", cleanupError);
|
||||
}
|
||||
}
|
||||
if (completeHandle && !"failed".equalsIgnoreCase(handle.snapshot().getOutcome())) {
|
||||
handle.completeFailure("cleanup", e, cleanupApplied);
|
||||
}
|
||||
if (!completeHandle) {
|
||||
if (e instanceof RuntimeException runtimeException) {
|
||||
throw runtimeException;
|
||||
}
|
||||
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void captureGenerationSession(PlatformChunkGenerator provider, SmokeDiagnosticsService.SmokeRunHandle handle) {
|
||||
if (provider == null || provider.getEngine() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (provider.getEngine() instanceof IrisEngine irisEngine) {
|
||||
handle.setGenerationSession(irisEngine.getGenerationSessionId(), irisEngine.getGenerationSessions().activeLeases());
|
||||
}
|
||||
}
|
||||
|
||||
private void cleanupWorld(World world, String worldName) {
|
||||
if (world != null) {
|
||||
PlatformChunkGenerator provider = IrisToolbelt.access(world);
|
||||
if (provider != null) {
|
||||
provider.close();
|
||||
}
|
||||
WorldLifecycleService.get().unload(world, false);
|
||||
}
|
||||
|
||||
File container = Bukkit.getWorldContainer();
|
||||
deleteFolder(new File(container, worldName), worldName);
|
||||
deleteFolder(new File(container, worldName + "_nether"), null);
|
||||
deleteFolder(new File(container, worldName + "_the_end"), null);
|
||||
}
|
||||
|
||||
private void deleteFolder(File folder, String worldName) {
|
||||
if (folder == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
IO.delete(folder);
|
||||
if (!folder.exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (worldName == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Iris.queueWorldDeletionOnStartup(Collections.singleton(worldName));
|
||||
} catch (IOException e) {
|
||||
Iris.reportError("Failed to queue smoke world deletion for \"" + worldName + "\".", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void cleanupTransientPrefix(String prefix) {
|
||||
File container = Bukkit.getWorldContainer();
|
||||
File[] children = container.listFiles();
|
||||
if (children == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (File child : children) {
|
||||
if (!child.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
if (!child.getName().startsWith(prefix)) {
|
||||
continue;
|
||||
}
|
||||
if (Bukkit.getWorld(child.getName()) != null) {
|
||||
continue;
|
||||
}
|
||||
IO.delete(child);
|
||||
}
|
||||
}
|
||||
|
||||
private String nextWorldName(String mode) {
|
||||
return "iris-smoke-" + mode + "-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
}
|
||||
|
||||
private String normalizePlayerName(String playerName) {
|
||||
if (playerName == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String trimmed = playerName.trim();
|
||||
if (trimmed.isEmpty() || trimmed.equalsIgnoreCase("none")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private String mapCreateStage(String stage) {
|
||||
if (stage == null || stage.isBlank()) {
|
||||
return "create_world";
|
||||
}
|
||||
|
||||
String normalized = stage.trim().toLowerCase();
|
||||
return switch (normalized) {
|
||||
case "resolve_dimension", "resolving dimension" -> "resolve_dimension";
|
||||
case "prepare_world_pack", "preparing world pack" -> "prepare_world_pack";
|
||||
case "install_datapacks", "installing datapacks" -> "install_datapacks";
|
||||
case "create_world", "creating world", "world created" -> "create_world";
|
||||
default -> normalized.replace(' ', '_');
|
||||
};
|
||||
}
|
||||
|
||||
public record WorldInspection(
|
||||
String worldName,
|
||||
String lifecycleBackend,
|
||||
String runtimeBackend,
|
||||
boolean studio,
|
||||
boolean engineClosed,
|
||||
boolean engineFailing,
|
||||
long generationSessionId,
|
||||
int activeLeaseCount,
|
||||
List<String> datapackFolders,
|
||||
boolean maintenanceActive
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,660 @@
|
||||
package art.arcane.iris.core.runtime;
|
||||
|
||||
import art.arcane.iris.Iris;
|
||||
import art.arcane.iris.core.lifecycle.WorldLifecycleService;
|
||||
import art.arcane.iris.core.project.IrisProject;
|
||||
import art.arcane.iris.core.tools.IrisCreator;
|
||||
import art.arcane.iris.core.tools.IrisToolbelt;
|
||||
import art.arcane.iris.engine.IrisEngine;
|
||||
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
|
||||
import art.arcane.iris.util.common.plugin.VolmitSender;
|
||||
import art.arcane.iris.util.common.scheduling.J;
|
||||
import art.arcane.volmlib.util.exceptions.IrisException;
|
||||
import art.arcane.volmlib.util.io.IO;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
public final class StudioOpenCoordinator {
|
||||
private static volatile StudioOpenCoordinator instance;
|
||||
|
||||
private final SmokeDiagnosticsService diagnostics;
|
||||
|
||||
private StudioOpenCoordinator() {
|
||||
this.diagnostics = SmokeDiagnosticsService.get();
|
||||
}
|
||||
|
||||
public static StudioOpenCoordinator get() {
|
||||
StudioOpenCoordinator current = instance;
|
||||
if (current != null) {
|
||||
return current;
|
||||
}
|
||||
|
||||
synchronized (StudioOpenCoordinator.class) {
|
||||
if (instance != null) {
|
||||
return instance;
|
||||
}
|
||||
|
||||
instance = new StudioOpenCoordinator();
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
|
||||
public CompletableFuture<StudioOpenResult> open(StudioOpenRequest request) {
|
||||
CompletableFuture<StudioOpenResult> future = new CompletableFuture<>();
|
||||
J.aBukkit(() -> executeOpen(request, future));
|
||||
return future;
|
||||
}
|
||||
|
||||
public CompletableFuture<StudioCloseResult> closeProject(IrisProject project) {
|
||||
CompletableFuture<StudioCloseResult> future = new CompletableFuture<>();
|
||||
J.aBukkit(() -> future.complete(executeClose(project)));
|
||||
return future;
|
||||
}
|
||||
|
||||
private StudioCloseResult executeClose(IrisProject project) {
|
||||
if (project == null) {
|
||||
return new StudioCloseResult(null, true, true, false, null, null);
|
||||
}
|
||||
|
||||
PlatformChunkGenerator provider = project.getActiveProvider();
|
||||
if (provider == null) {
|
||||
return new StudioCloseResult(null, true, true, false, null, null);
|
||||
}
|
||||
|
||||
World world = provider.getTarget().getWorld().realWorld();
|
||||
String worldName = world == null ? provider.getTarget().getWorld().name() : world.getName();
|
||||
SmokeDiagnosticsService.SmokeRunHandle handle = diagnostics.beginRun(
|
||||
SmokeDiagnosticsService.SmokeRunMode.STUDIO_CLOSE,
|
||||
worldName,
|
||||
true,
|
||||
true,
|
||||
null,
|
||||
false
|
||||
);
|
||||
StudioCloseResult result;
|
||||
try {
|
||||
handle.setRuntimeBackend(WorldRuntimeControlService.get().backendName());
|
||||
if (world != null) {
|
||||
handle.setLifecycleBackend(WorldLifecycleService.get().backendNameForWorld(world.getName()));
|
||||
captureGenerationSession(provider, handle);
|
||||
}
|
||||
result = closeWorld(provider, worldName, world, true, handle, project);
|
||||
handle.setCloseState(result.unloadCompletedLive(), result.folderDeletionCompletedLive(), result.startupCleanupQueued());
|
||||
if (result.failureCause() != null) {
|
||||
handle.completeFailure("finalize_close", result.failureCause(), result.folderDeletionCompletedLive() || result.startupCleanupQueued());
|
||||
} else {
|
||||
handle.completeSuccess("finalize_close", result.folderDeletionCompletedLive() || result.startupCleanupQueued());
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
project.setActiveProvider(null);
|
||||
handle.completeFailure("finalize_close", e, false);
|
||||
result = new StudioCloseResult(worldName, false, false, false, e, handle.runId());
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private void executeOpen(StudioOpenRequest request, CompletableFuture<StudioOpenResult> future) {
|
||||
boolean ownsHandle = request.runHandle() == null;
|
||||
SmokeDiagnosticsService.SmokeRunHandle handle = ownsHandle
|
||||
? diagnostics.beginRun(
|
||||
request.mode(),
|
||||
request.worldName(),
|
||||
true,
|
||||
request.playerName() == null || request.playerName().isBlank(),
|
||||
request.playerName(),
|
||||
request.retainOnFailure()
|
||||
)
|
||||
: request.runHandle();
|
||||
World world = null;
|
||||
PlatformChunkGenerator provider = null;
|
||||
boolean cleanupApplied = false;
|
||||
try {
|
||||
updateStage(handle, request, "resolve_dimension", 0.04D);
|
||||
if (IrisToolbelt.getDimension(request.dimensionKey()) == null) {
|
||||
throw new IrisException("Dimension cannot be found for id " + request.dimensionKey() + ".");
|
||||
}
|
||||
|
||||
updateStage(handle, request, "prepare_world_pack", 0.10D);
|
||||
cleanupStaleTransientWorlds(request.worldName());
|
||||
|
||||
updateStage(handle, request, "install_datapacks", 0.18D);
|
||||
IrisCreator creator = IrisToolbelt.createWorld()
|
||||
.seed(request.seed())
|
||||
.sender(request.sender())
|
||||
.studio(true)
|
||||
.name(request.worldName())
|
||||
.dimension(request.dimensionKey())
|
||||
.studioProgressConsumer((progress, stage) -> updateStage(handle, request, mapCreatorStage(stage), progress));
|
||||
world = creator.create();
|
||||
provider = IrisToolbelt.access(world);
|
||||
if (provider == null) {
|
||||
throw new IllegalStateException("Studio runtime provider is unavailable for world \"" + request.worldName() + "\".");
|
||||
}
|
||||
|
||||
handle.setLifecycleBackend(WorldLifecycleService.get().backendNameForWorld(world.getName()));
|
||||
handle.setRuntimeBackend(WorldRuntimeControlService.get().backendName());
|
||||
handle.setDatapackReadiness(creator.getLastDatapackReadinessResult());
|
||||
captureGenerationSession(provider, handle);
|
||||
|
||||
updateStage(handle, request, "apply_world_rules", 0.72D);
|
||||
WorldRuntimeControlService.get().applyStudioWorldRules(world);
|
||||
|
||||
updateStage(handle, request, "prepare_generator", 0.78D);
|
||||
WorldRuntimeControlService.get().prepareGenerator(world);
|
||||
|
||||
Location entryAnchor = WorldRuntimeControlService.get().resolveEntryAnchor(world);
|
||||
if (entryAnchor == null) {
|
||||
throw new IllegalStateException("Studio entry anchor could not be resolved.");
|
||||
}
|
||||
|
||||
long deadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(120L);
|
||||
updateStage(handle, request, "request_entry_chunk", 0.84D);
|
||||
requestEntryChunk(world, entryAnchor, deadline, handle);
|
||||
|
||||
updateStage(handle, request, "resolve_safe_entry", 0.90D);
|
||||
Location safeEntry = resolveSafeEntry(world, entryAnchor, deadline);
|
||||
if (safeEntry == null) {
|
||||
throw new IllegalStateException("Studio safe entry resolution timed out.");
|
||||
}
|
||||
|
||||
if (request.playerName() != null && !request.playerName().isBlank()) {
|
||||
updateStage(handle, request, "teleport_player", 0.96D);
|
||||
Player player = resolvePlayer(request.playerName());
|
||||
if (player == null) {
|
||||
throw new IllegalStateException("Player \"" + request.playerName() + "\" is not online.");
|
||||
}
|
||||
|
||||
long remaining = Math.max(1000L, deadline - System.currentTimeMillis());
|
||||
Boolean teleported = WorldRuntimeControlService.get().teleport(player, safeEntry).get(remaining, TimeUnit.MILLISECONDS);
|
||||
if (!Boolean.TRUE.equals(teleported)) {
|
||||
throw new IllegalStateException("Studio teleport did not complete successfully.");
|
||||
}
|
||||
}
|
||||
|
||||
updateStage(handle, request, "finalize_open", 1.00D);
|
||||
if (request.project() != null) {
|
||||
request.project().setActiveProvider(provider);
|
||||
}
|
||||
if (request.openWorkspace() && request.project() != null) {
|
||||
request.project().openVSCode(request.sender());
|
||||
}
|
||||
if (request.onDone() != null) {
|
||||
request.onDone().accept(world);
|
||||
}
|
||||
|
||||
if (request.completeHandle()) {
|
||||
handle.completeSuccess("finalize_open", false);
|
||||
} else {
|
||||
handle.stage("finalize_open");
|
||||
}
|
||||
future.complete(new StudioOpenResult(world, handle.runId(), safeEntry, creator.getLastDatapackReadinessResult()));
|
||||
} catch (Throwable e) {
|
||||
Iris.reportError("Studio open failed for world \"" + request.worldName() + "\".", e);
|
||||
if (!request.retainOnFailure()) {
|
||||
try {
|
||||
updateStage(handle, request, "cleanup", 1.00D);
|
||||
StudioCloseResult cleanupResult = closeWorld(provider, request.worldName(), world, true, handle, request.project());
|
||||
cleanupApplied = cleanupResult.folderDeletionCompletedLive() || cleanupResult.startupCleanupQueued();
|
||||
} catch (Throwable cleanupError) {
|
||||
Iris.reportError("Studio cleanup failed for world \"" + request.worldName() + "\".", cleanupError);
|
||||
}
|
||||
}
|
||||
if (request.completeHandle()) {
|
||||
handle.completeFailure("cleanup", e, cleanupApplied);
|
||||
} else {
|
||||
handle.stage("cleanup", String.valueOf(e.getMessage()));
|
||||
}
|
||||
future.completeExceptionally(e);
|
||||
}
|
||||
}
|
||||
|
||||
private void requestEntryChunk(World world, Location entryAnchor, long deadline, SmokeDiagnosticsService.SmokeRunHandle handle) throws Exception {
|
||||
int chunkX = entryAnchor.getBlockX() >> 4;
|
||||
int chunkZ = entryAnchor.getBlockZ() >> 4;
|
||||
handle.setEntryChunk(chunkX, chunkZ);
|
||||
long remaining = Math.max(1000L, deadline - System.currentTimeMillis());
|
||||
waitForEntryChunk(world, chunkX, chunkZ, deadline, null).get(remaining, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
private Location resolveSafeEntry(World world, Location entryAnchor, long deadline) throws Exception {
|
||||
long remaining = Math.max(1000L, deadline - System.currentTimeMillis());
|
||||
return waitForSafeEntry(world, entryAnchor, deadline, null).get(remaining, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
private StudioCloseResult closeWorld(
|
||||
PlatformChunkGenerator provider,
|
||||
String worldName,
|
||||
World world,
|
||||
boolean deleteFolder,
|
||||
SmokeDiagnosticsService.SmokeRunHandle handle,
|
||||
IrisProject project
|
||||
) {
|
||||
Throwable failure = null;
|
||||
boolean unloadCompletedLive = world == null || !isWorldFamilyLoaded(worldName);
|
||||
boolean folderDeletionCompletedLive = !deleteFolder;
|
||||
boolean startupCleanupQueued = false;
|
||||
CompletableFuture<Void> closeFuture = provider == null ? CompletableFuture.completedFuture(null) : CompletableFuture.completedFuture(null);
|
||||
|
||||
updateCloseStage(handle, "prepare_close");
|
||||
if (world != null) {
|
||||
handle.setWorldName(world.getName());
|
||||
handle.setLifecycleBackend(WorldLifecycleService.get().backendNameForWorld(world.getName()));
|
||||
handle.setRuntimeBackend(WorldRuntimeControlService.get().backendName());
|
||||
captureGenerationSession(provider, handle);
|
||||
}
|
||||
|
||||
if (world != null) {
|
||||
updateCloseStage(handle, "evacuate_players");
|
||||
try {
|
||||
evacuatePlayers(world);
|
||||
} catch (Throwable e) {
|
||||
failure = e;
|
||||
}
|
||||
}
|
||||
|
||||
if (world != null) {
|
||||
IrisToolbelt.beginWorldMaintenance(world, "studio-close", true);
|
||||
}
|
||||
|
||||
try {
|
||||
updateCloseStage(handle, "seal_runtime");
|
||||
if (project != null) {
|
||||
project.setActiveProvider(null);
|
||||
}
|
||||
if (provider != null) {
|
||||
captureGenerationSession(provider, handle);
|
||||
closeFuture = provider.closeAsync();
|
||||
}
|
||||
|
||||
updateCloseStage(handle, "request_unload");
|
||||
if (worldName != null && !worldName.isBlank()) {
|
||||
requestWorldFamilyUnload(worldName);
|
||||
}
|
||||
|
||||
updateCloseStage(handle, "await_unload");
|
||||
if (worldName != null && !worldName.isBlank()) {
|
||||
long unloadDeadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(20L);
|
||||
CompletableFuture<Void> unloadFuture = waitForWorldFamilyUnload(worldName, unloadDeadline);
|
||||
try {
|
||||
unloadFuture.get(Math.max(1000L, unloadDeadline - System.currentTimeMillis()), TimeUnit.MILLISECONDS);
|
||||
unloadCompletedLive = true;
|
||||
} catch (TimeoutException e) {
|
||||
unloadCompletedLive = !isWorldFamilyLoaded(worldName);
|
||||
} catch (Throwable e) {
|
||||
failure = failure == null ? unwrapFailure(e) : failure;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
closeFuture.get(20L, TimeUnit.SECONDS);
|
||||
} catch (Throwable e) {
|
||||
Throwable cause = unwrapFailure(e);
|
||||
if (failure == null) {
|
||||
failure = cause;
|
||||
}
|
||||
}
|
||||
|
||||
if (deleteFolder && worldName != null && !worldName.isBlank()) {
|
||||
updateCloseStage(handle, "delete_world_family");
|
||||
WorldFamilyDeleteResult deleteResult = deleteWorldFamily(worldName, unloadCompletedLive);
|
||||
folderDeletionCompletedLive = deleteResult.liveDeleted();
|
||||
startupCleanupQueued = deleteResult.startupCleanupQueued();
|
||||
}
|
||||
|
||||
updateCloseStage(handle, "finalize_close");
|
||||
} finally {
|
||||
if (world != null) {
|
||||
IrisToolbelt.endWorldMaintenance(world, "studio-close");
|
||||
}
|
||||
}
|
||||
|
||||
handle.setCloseState(unloadCompletedLive, folderDeletionCompletedLive, startupCleanupQueued);
|
||||
return new StudioCloseResult(worldName, unloadCompletedLive, folderDeletionCompletedLive, startupCleanupQueued, failure, handle.runId());
|
||||
}
|
||||
|
||||
private void evacuatePlayers(World world) throws Exception {
|
||||
if (world == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
CompletableFuture<Void> future = J.sfut(() -> {
|
||||
IrisToolbelt.evacuate(world);
|
||||
return null;
|
||||
});
|
||||
if (future != null) {
|
||||
future.get(10L, TimeUnit.SECONDS);
|
||||
}
|
||||
}
|
||||
|
||||
private void requestWorldFamilyUnload(String worldName) {
|
||||
if (worldName == null || worldName.isBlank()) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (String familyWorldName : TransientWorldCleanupSupport.worldFamilyNames(worldName)) {
|
||||
World familyWorld = Bukkit.getWorld(familyWorldName);
|
||||
if (familyWorld == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Iris.linkMultiverseCore.removeFromConfig(familyWorld);
|
||||
WorldLifecycleService.get().unload(familyWorld, false);
|
||||
}
|
||||
}
|
||||
|
||||
private WorldFamilyDeleteResult deleteWorldFamily(String worldName, boolean unloadCompletedLive) {
|
||||
if (worldName == null || worldName.isBlank()) {
|
||||
return new WorldFamilyDeleteResult(true, false);
|
||||
}
|
||||
|
||||
File container = Bukkit.getWorldContainer();
|
||||
boolean liveDeleted = true;
|
||||
for (String familyWorldName : TransientWorldCleanupSupport.worldFamilyNames(worldName)) {
|
||||
File folder = new File(container, familyWorldName);
|
||||
if (!folder.exists()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
deleteWorldFolderAsync(folder, 40).get(15L, TimeUnit.SECONDS);
|
||||
} catch (Throwable e) {
|
||||
liveDeleted = false;
|
||||
Iris.reportError("Studio folder deletion retries failed for \"" + folder.getAbsolutePath() + "\".", unwrapFailure(e));
|
||||
}
|
||||
|
||||
if (folder.exists()) {
|
||||
liveDeleted = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (liveDeleted) {
|
||||
return new WorldFamilyDeleteResult(true, false);
|
||||
}
|
||||
|
||||
try {
|
||||
Iris.queueWorldDeletionOnStartup(Collections.singleton(worldName));
|
||||
return new WorldFamilyDeleteResult(false, true);
|
||||
} catch (IOException e) {
|
||||
if (unloadCompletedLive) {
|
||||
Iris.reportError("Failed to queue deferred deletion for world \"" + worldName + "\".", e);
|
||||
}
|
||||
return new WorldFamilyDeleteResult(false, false);
|
||||
}
|
||||
}
|
||||
|
||||
private void cleanupStaleTransientWorlds(String worldName) {
|
||||
File container = Bukkit.getWorldContainer();
|
||||
LinkedHashSet<String> staleWorldNames = TransientWorldCleanupSupport.collectTransientStudioWorldNames(container);
|
||||
String requestedBaseName = TransientWorldCleanupSupport.transientStudioBaseWorldName(worldName);
|
||||
if (requestedBaseName != null) {
|
||||
staleWorldNames.add(requestedBaseName);
|
||||
}
|
||||
|
||||
for (String staleWorldName : staleWorldNames) {
|
||||
if (Bukkit.getWorld(staleWorldName) != null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
deleteWorldFamily(staleWorldName, true);
|
||||
}
|
||||
}
|
||||
|
||||
private void captureGenerationSession(PlatformChunkGenerator provider, SmokeDiagnosticsService.SmokeRunHandle handle) {
|
||||
if (provider == null || provider.getEngine() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (provider.getEngine() instanceof IrisEngine irisEngine) {
|
||||
handle.setGenerationSession(irisEngine.getGenerationSessionId(), irisEngine.getGenerationSessions().activeLeases());
|
||||
}
|
||||
}
|
||||
|
||||
private void updateStage(SmokeDiagnosticsService.SmokeRunHandle handle, StudioOpenRequest request, String stage, double progress) {
|
||||
handle.stage(stage);
|
||||
if (request.progressConsumer() != null) {
|
||||
request.progressConsumer().accept(new StudioOpenProgress(progress, stage));
|
||||
}
|
||||
}
|
||||
|
||||
private String mapCreatorStage(String stage) {
|
||||
if (stage == null || stage.isBlank()) {
|
||||
return "create_world";
|
||||
}
|
||||
|
||||
String normalized = stage.trim().toLowerCase();
|
||||
return switch (normalized) {
|
||||
case "resolve_dimension", "resolving dimension" -> "resolve_dimension";
|
||||
case "prepare_world_pack", "preparing world pack" -> "prepare_world_pack";
|
||||
case "install_datapacks", "installing datapacks", "datapacks ready" -> "install_datapacks";
|
||||
case "create_world", "creating world", "world created" -> "create_world";
|
||||
default -> normalized.replace(' ', '_');
|
||||
};
|
||||
}
|
||||
|
||||
private CompletableFuture<Void> waitForEntryChunk(World world, int chunkX, int chunkZ, long deadline, Throwable lastFailure) {
|
||||
long now = System.currentTimeMillis();
|
||||
if (now >= deadline) {
|
||||
return CompletableFuture.failedFuture(timeoutFailure("Studio entry chunk request timed out.", lastFailure));
|
||||
}
|
||||
|
||||
long attemptTimeout = Math.min(Math.max(1000L, deadline - now), 3000L);
|
||||
CompletableFuture<org.bukkit.Chunk> request = withAttemptTimeout(
|
||||
WorldRuntimeControlService.get().requestChunkAsync(world, chunkX, chunkZ, true),
|
||||
attemptTimeout,
|
||||
"Studio entry chunk request attempt timed out."
|
||||
);
|
||||
return request.handle((chunk, throwable) -> {
|
||||
if (throwable == null && world.isChunkLoaded(chunkX, chunkZ)) {
|
||||
return CompletableFuture.<Void>completedFuture(null);
|
||||
}
|
||||
|
||||
Throwable nextFailure = throwable == null ? lastFailure : unwrapFailure(throwable);
|
||||
if (System.currentTimeMillis() >= deadline) {
|
||||
return CompletableFuture.<Void>failedFuture(timeoutFailure("Studio entry chunk request timed out.", nextFailure));
|
||||
}
|
||||
|
||||
return delayFuture(1000L).thenCompose(ignored -> waitForEntryChunk(world, chunkX, chunkZ, deadline, nextFailure));
|
||||
}).thenCompose(next -> next);
|
||||
}
|
||||
|
||||
private CompletableFuture<Location> waitForSafeEntry(World world, Location entryAnchor, long deadline, Throwable lastFailure) {
|
||||
long now = System.currentTimeMillis();
|
||||
if (now >= deadline) {
|
||||
return CompletableFuture.failedFuture(timeoutFailure("Studio safe-entry resolution timed out.", lastFailure));
|
||||
}
|
||||
|
||||
long attemptTimeout = Math.min(Math.max(1000L, deadline - now), 3000L);
|
||||
CompletableFuture<Location> resolve = withAttemptTimeout(
|
||||
WorldRuntimeControlService.get().resolveSafeEntry(world, entryAnchor),
|
||||
attemptTimeout,
|
||||
"Studio safe-entry resolution attempt timed out."
|
||||
);
|
||||
return resolve.handle((location, throwable) -> {
|
||||
if (throwable == null && location != null) {
|
||||
return CompletableFuture.completedFuture(location);
|
||||
}
|
||||
|
||||
Throwable nextFailure = throwable == null ? lastFailure : unwrapFailure(throwable);
|
||||
if (System.currentTimeMillis() >= deadline) {
|
||||
return CompletableFuture.<Location>failedFuture(timeoutFailure("Studio safe-entry resolution timed out.", nextFailure));
|
||||
}
|
||||
|
||||
return delayFuture(250L).thenCompose(ignored -> waitForSafeEntry(world, entryAnchor, deadline, nextFailure));
|
||||
}).thenCompose(next -> next);
|
||||
}
|
||||
|
||||
private CompletableFuture<Void> waitForWorldFamilyUnload(String worldName, long deadline) {
|
||||
if (worldName == null || !isWorldFamilyLoaded(worldName) || System.currentTimeMillis() >= deadline) {
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
|
||||
return delayFuture(100L).thenCompose(ignored -> waitForWorldFamilyUnload(worldName, deadline));
|
||||
}
|
||||
|
||||
private CompletableFuture<Void> deleteWorldFolderAsync(File folder, int attemptsRemaining) {
|
||||
if (folder == null || !folder.exists()) {
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
|
||||
IO.delete(folder);
|
||||
if (!folder.exists()) {
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
|
||||
if (attemptsRemaining <= 1) {
|
||||
return CompletableFuture.failedFuture(new IllegalStateException("World folder still exists after deletion retries: " + folder.getAbsolutePath()));
|
||||
}
|
||||
|
||||
return delayFuture(250L).thenCompose(ignored -> deleteWorldFolderAsync(folder, attemptsRemaining - 1));
|
||||
}
|
||||
|
||||
private CompletableFuture<Void> delayFuture(long delayMillis) {
|
||||
long safeDelay = Math.max(0L, delayMillis);
|
||||
return CompletableFuture.runAsync(() -> {
|
||||
}, CompletableFuture.delayedExecutor(safeDelay, TimeUnit.MILLISECONDS));
|
||||
}
|
||||
|
||||
private <T> CompletableFuture<T> withAttemptTimeout(CompletableFuture<T> source, long timeoutMillis, String message) {
|
||||
CompletableFuture<T> future = new CompletableFuture<>();
|
||||
source.whenComplete((value, throwable) -> {
|
||||
if (throwable != null) {
|
||||
future.completeExceptionally(unwrapFailure(throwable));
|
||||
return;
|
||||
}
|
||||
|
||||
future.complete(value);
|
||||
});
|
||||
delayFuture(timeoutMillis).whenComplete((ignored, throwable) -> {
|
||||
if (!future.isDone()) {
|
||||
future.completeExceptionally(new TimeoutException(message));
|
||||
}
|
||||
});
|
||||
return future;
|
||||
}
|
||||
|
||||
private IllegalStateException timeoutFailure(String message, Throwable lastFailure) {
|
||||
if (lastFailure == null) {
|
||||
return new IllegalStateException(message);
|
||||
}
|
||||
|
||||
return new IllegalStateException(message, lastFailure);
|
||||
}
|
||||
|
||||
private Throwable unwrapFailure(Throwable throwable) {
|
||||
Throwable cursor = throwable;
|
||||
while (cursor instanceof CompletionException || cursor instanceof ExecutionException) {
|
||||
if (cursor.getCause() == null) {
|
||||
break;
|
||||
}
|
||||
|
||||
cursor = cursor.getCause();
|
||||
}
|
||||
|
||||
return cursor;
|
||||
}
|
||||
|
||||
private Player resolvePlayer(String playerName) {
|
||||
Player exact = Bukkit.getPlayerExact(playerName);
|
||||
if (exact != null) {
|
||||
return exact;
|
||||
}
|
||||
|
||||
for (Player player : Bukkit.getOnlinePlayers()) {
|
||||
if (player.getName().equalsIgnoreCase(playerName)) {
|
||||
return player;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private void updateCloseStage(SmokeDiagnosticsService.SmokeRunHandle handle, String stage) {
|
||||
handle.stage(stage);
|
||||
}
|
||||
|
||||
private boolean isWorldFamilyLoaded(String worldName) {
|
||||
if (worldName == null || worldName.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (String familyWorldName : TransientWorldCleanupSupport.worldFamilyNames(worldName)) {
|
||||
if (Bukkit.getWorld(familyWorldName) != null) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public record StudioOpenRequest(
|
||||
String dimensionKey,
|
||||
IrisProject project,
|
||||
VolmitSender sender,
|
||||
long seed,
|
||||
String worldName,
|
||||
String playerName,
|
||||
boolean openWorkspace,
|
||||
boolean retainOnFailure,
|
||||
SmokeDiagnosticsService.SmokeRunMode mode,
|
||||
SmokeDiagnosticsService.SmokeRunHandle runHandle,
|
||||
boolean completeHandle,
|
||||
Consumer<StudioOpenProgress> progressConsumer,
|
||||
Consumer<World> onDone
|
||||
) {
|
||||
public static StudioOpenRequest studioProject(IrisProject project, VolmitSender sender, long seed, Consumer<StudioOpenProgress> progressConsumer, Consumer<World> onDone) {
|
||||
String playerName = sender != null && sender.isPlayer() && sender.player() != null ? sender.player().getName() : null;
|
||||
return new StudioOpenRequest(
|
||||
project.getName(),
|
||||
project,
|
||||
sender,
|
||||
seed,
|
||||
"iris-" + UUID.randomUUID(),
|
||||
playerName,
|
||||
true,
|
||||
false,
|
||||
SmokeDiagnosticsService.SmokeRunMode.STUDIO_OPEN,
|
||||
null,
|
||||
true,
|
||||
progressConsumer,
|
||||
onDone
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public record StudioOpenProgress(double progress, String stage) {
|
||||
}
|
||||
|
||||
public record StudioOpenResult(World world, String runId, Location entryLocation, DatapackReadinessResult datapackReadiness) {
|
||||
}
|
||||
|
||||
public record StudioCloseResult(
|
||||
String worldName,
|
||||
boolean unloadCompletedLive,
|
||||
boolean folderDeletionCompletedLive,
|
||||
boolean startupCleanupQueued,
|
||||
Throwable failureCause,
|
||||
String runId
|
||||
) {
|
||||
public boolean successful() {
|
||||
return failureCause == null;
|
||||
}
|
||||
}
|
||||
|
||||
private record WorldFamilyDeleteResult(boolean liveDeleted, boolean startupCleanupQueued) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package art.arcane.iris.core.runtime;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public final class TransientWorldCleanupSupport {
|
||||
private static final Pattern TRANSIENT_STUDIO_WORLD_PATTERN = Pattern.compile("^iris-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", Pattern.CASE_INSENSITIVE);
|
||||
|
||||
private TransientWorldCleanupSupport() {
|
||||
}
|
||||
|
||||
public static boolean isTransientStudioWorldName(String worldName) {
|
||||
return transientStudioBaseWorldName(worldName) != null;
|
||||
}
|
||||
|
||||
public static String transientStudioBaseWorldName(String worldName) {
|
||||
if (worldName == null || worldName.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String candidate = worldName.trim();
|
||||
if (candidate.endsWith("_nether")) {
|
||||
candidate = candidate.substring(0, candidate.length() - "_nether".length());
|
||||
} else if (candidate.endsWith("_the_end")) {
|
||||
candidate = candidate.substring(0, candidate.length() - "_the_end".length());
|
||||
}
|
||||
|
||||
if (!TRANSIENT_STUDIO_WORLD_PATTERN.matcher(candidate).matches()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
public static List<String> worldFamilyNames(String worldName) {
|
||||
ArrayList<String> names = new ArrayList<>();
|
||||
String normalized = normalizeWorldName(worldName);
|
||||
if (normalized == null) {
|
||||
return names;
|
||||
}
|
||||
|
||||
names.add(normalized);
|
||||
names.add(normalized + "_nether");
|
||||
names.add(normalized + "_the_end");
|
||||
return names;
|
||||
}
|
||||
|
||||
public static LinkedHashSet<String> collectTransientStudioWorldNames(File worldContainer) {
|
||||
LinkedHashSet<String> names = new LinkedHashSet<>();
|
||||
if (worldContainer == null) {
|
||||
return names;
|
||||
}
|
||||
|
||||
File[] children = worldContainer.listFiles();
|
||||
if (children == null) {
|
||||
return names;
|
||||
}
|
||||
|
||||
for (File child : children) {
|
||||
if (child == null || !child.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String baseName = transientStudioBaseWorldName(child.getName());
|
||||
if (baseName == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
names.add(baseName);
|
||||
}
|
||||
|
||||
return names;
|
||||
}
|
||||
|
||||
private static String normalizeWorldName(String worldName) {
|
||||
if (worldName == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String normalized = worldName.trim();
|
||||
if (normalized.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalized.toLowerCase(Locale.ROOT).equals(normalized) ? normalized : normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package art.arcane.iris.core.runtime;
|
||||
|
||||
import org.bukkit.Chunk;
|
||||
import org.bukkit.World;
|
||||
|
||||
import java.util.OptionalLong;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
interface WorldRuntimeControlBackend {
|
||||
String backendName();
|
||||
|
||||
String describeCapabilities();
|
||||
|
||||
OptionalLong readDayTime(World world);
|
||||
|
||||
boolean writeDayTime(World world, long dayTime) throws ReflectiveOperationException;
|
||||
|
||||
void syncTime(World world);
|
||||
|
||||
CompletableFuture<Chunk> requestChunkAsync(World world, int chunkX, int chunkZ, boolean generate);
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
package art.arcane.iris.core.runtime;
|
||||
|
||||
import art.arcane.iris.Iris;
|
||||
import art.arcane.iris.core.IrisSettings;
|
||||
import art.arcane.iris.core.lifecycle.CapabilitySnapshot;
|
||||
import art.arcane.iris.core.lifecycle.ServerFamily;
|
||||
import art.arcane.iris.core.lifecycle.WorldLifecycleService;
|
||||
import art.arcane.iris.core.service.BoardSVC;
|
||||
import art.arcane.iris.core.tools.IrisToolbelt;
|
||||
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
|
||||
import art.arcane.iris.util.common.format.C;
|
||||
import art.arcane.iris.util.common.scheduling.J;
|
||||
import io.papermc.lib.PaperLib;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Chunk;
|
||||
import org.bukkit.GameRule;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.Tag;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.world.TimeSkipEvent;
|
||||
import org.bukkit.plugin.PluginManager;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Optional;
|
||||
import java.util.OptionalLong;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
public final class WorldRuntimeControlService {
|
||||
private static volatile WorldRuntimeControlService instance;
|
||||
|
||||
private final CapabilitySnapshot capabilities;
|
||||
private final WorldRuntimeControlBackend backend;
|
||||
private final String capabilityDescription;
|
||||
|
||||
private WorldRuntimeControlService(CapabilitySnapshot capabilities) {
|
||||
this.capabilities = capabilities;
|
||||
this.backend = selectBackend(capabilities);
|
||||
this.capabilityDescription = "family=" + capabilities.serverFamily().id()
|
||||
+ ", backend=" + backend.backendName()
|
||||
+ ", " + backend.describeCapabilities();
|
||||
}
|
||||
|
||||
public static WorldRuntimeControlService get() {
|
||||
WorldRuntimeControlService current = instance;
|
||||
if (current != null) {
|
||||
return current;
|
||||
}
|
||||
|
||||
synchronized (WorldRuntimeControlService.class) {
|
||||
if (instance != null) {
|
||||
return instance;
|
||||
}
|
||||
|
||||
CapabilitySnapshot capabilities = WorldLifecycleService.get().capabilities();
|
||||
instance = new WorldRuntimeControlService(capabilities);
|
||||
Iris.info("WorldRuntimeControl capabilities: %s", instance.capabilityDescription);
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
|
||||
public String backendName() {
|
||||
return backend.backendName();
|
||||
}
|
||||
|
||||
public String capabilityDescription() {
|
||||
return capabilityDescription;
|
||||
}
|
||||
|
||||
public OptionalLong readDayTime(World world) {
|
||||
return backend.readDayTime(world);
|
||||
}
|
||||
|
||||
public boolean applyStudioWorldRules(World world) {
|
||||
if (world == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Iris.linkMultiverseCore.removeFromConfig(world);
|
||||
if (!IrisSettings.get().getStudio().isDisableTimeAndWeather()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
setBooleanGameRule(world, false, "ADVANCE_WEATHER", "DO_WEATHER_CYCLE", "WEATHER_CYCLE", "doWeatherCycle", "weatherCycle");
|
||||
setBooleanGameRule(world, false, "ADVANCE_TIME", "DO_DAYLIGHT_CYCLE", "DAYLIGHT_CYCLE", "doDaylightCycle", "daylightCycle");
|
||||
applyNoonTimeLock(world);
|
||||
return true;
|
||||
}
|
||||
|
||||
public boolean applyNoonTimeLock(World world) {
|
||||
if (world == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!hasMutableClock(world)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
OptionalLong currentTime = readDayTime(world);
|
||||
if (currentTime.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
long skipAmount = (6000L - currentTime.getAsLong()) % 24000L;
|
||||
if (skipAmount < 0L) {
|
||||
skipAmount += 24000L;
|
||||
}
|
||||
|
||||
TimeSkipEvent event = new TimeSkipEvent(world, TimeSkipEvent.SkipReason.CUSTOM, skipAmount);
|
||||
PluginManager pluginManager = Bukkit.getPluginManager();
|
||||
if (pluginManager != null) {
|
||||
pluginManager.callEvent(event);
|
||||
}
|
||||
if (event.isCancelled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
boolean written = backend.writeDayTime(world, currentTime.getAsLong() + event.getSkipAmount());
|
||||
if (!written) {
|
||||
return false;
|
||||
}
|
||||
backend.syncTime(world);
|
||||
return true;
|
||||
} catch (Throwable e) {
|
||||
Iris.reportError("Runtime time control failed for world \"" + world.getName() + "\".", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public CompletableFuture<Chunk> requestChunkAsync(World world, int chunkX, int chunkZ, boolean generate) {
|
||||
return backend.requestChunkAsync(world, chunkX, chunkZ, generate);
|
||||
}
|
||||
|
||||
public void prepareGenerator(World world) {
|
||||
if (world == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
art.arcane.iris.engine.platform.PlatformChunkGenerator provider = art.arcane.iris.core.tools.IrisToolbelt.access(world);
|
||||
if (provider == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
art.arcane.iris.engine.framework.Engine engine = provider.getEngine();
|
||||
if (engine == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
engine.getMantle().getComponents();
|
||||
engine.getMantle().getRealRadius();
|
||||
} catch (Throwable e) {
|
||||
Iris.reportError("Failed to prepare generator state for world \"" + world.getName() + "\".", e);
|
||||
}
|
||||
}
|
||||
|
||||
public Location resolveEntryAnchor(World world) {
|
||||
if (world == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
PlatformChunkGenerator provider = IrisToolbelt.access(world);
|
||||
return resolveEntryAnchor(world, provider);
|
||||
}
|
||||
|
||||
static Location resolveEntryAnchor(World world, PlatformChunkGenerator provider) {
|
||||
if (world == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (provider != null && provider.isStudio()) {
|
||||
Location initialSpawn = provider.getInitialSpawnLocation(world);
|
||||
if (initialSpawn != null) {
|
||||
return initialSpawn.clone();
|
||||
}
|
||||
}
|
||||
|
||||
Location spawnLocation = world.getSpawnLocation();
|
||||
if (spawnLocation != null) {
|
||||
return spawnLocation.clone();
|
||||
}
|
||||
|
||||
int minY = world.getMinHeight() + 1;
|
||||
int y = Math.max(minY, 96);
|
||||
return new Location(world, 0.5D, y, 0.5D);
|
||||
}
|
||||
|
||||
public CompletableFuture<Location> resolveSafeEntry(World world, Location source) {
|
||||
if (world == null || source == null) {
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
|
||||
int chunkX = source.getBlockX() >> 4;
|
||||
int chunkZ = source.getBlockZ() >> 4;
|
||||
return requestChunkAsync(world, chunkX, chunkZ, true).thenCompose(chunk -> {
|
||||
CompletableFuture<Location> future = new CompletableFuture<>();
|
||||
boolean scheduled = J.runRegion(world, chunkX, chunkZ, () -> {
|
||||
try {
|
||||
future.complete(findTopSafeLocation(world, source));
|
||||
} catch (Throwable e) {
|
||||
future.completeExceptionally(e);
|
||||
}
|
||||
});
|
||||
if (!scheduled) {
|
||||
return CompletableFuture.failedFuture(new IllegalStateException("Failed to schedule safe-entry surface resolve for " + world.getName() + "@" + chunkX + "," + chunkZ + "."));
|
||||
}
|
||||
|
||||
return future;
|
||||
});
|
||||
}
|
||||
|
||||
public CompletableFuture<Boolean> teleport(Player player, Location location) {
|
||||
if (player == null || location == null) {
|
||||
return CompletableFuture.completedFuture(false);
|
||||
}
|
||||
|
||||
CompletableFuture<Boolean> future = new CompletableFuture<>();
|
||||
boolean scheduled = J.runEntity(player, () -> {
|
||||
CompletableFuture<Boolean> teleportFuture = PaperLib.teleportAsync(player, location);
|
||||
if (teleportFuture == null) {
|
||||
future.complete(false);
|
||||
return;
|
||||
}
|
||||
|
||||
teleportFuture.whenComplete((success, throwable) -> {
|
||||
if (throwable != null) {
|
||||
future.completeExceptionally(throwable);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Boolean.TRUE.equals(success)) {
|
||||
J.runEntity(player, () -> Iris.service(BoardSVC.class).updatePlayer(player));
|
||||
future.complete(true);
|
||||
return;
|
||||
}
|
||||
|
||||
future.complete(false);
|
||||
});
|
||||
});
|
||||
if (!scheduled) {
|
||||
return CompletableFuture.failedFuture(new IllegalStateException("Failed to schedule teleport for " + player.getName() + "."));
|
||||
}
|
||||
|
||||
return future;
|
||||
}
|
||||
|
||||
public boolean hasMutableClock(World world) {
|
||||
try {
|
||||
Object handle = invokeNoArg(world, "getHandle");
|
||||
if (handle == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Object dimensionTypeHolder = invokeNoArg(handle, "dimensionTypeRegistration");
|
||||
Object dimensionType = unwrapDimensionType(dimensionTypeHolder);
|
||||
if (dimensionType == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !dimensionTypeHasFixedTime(dimensionType);
|
||||
} catch (Throwable e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static WorldRuntimeControlBackend selectBackend(CapabilitySnapshot capabilities) {
|
||||
ServerFamily family = capabilities.serverFamily();
|
||||
if (family.isPaperLike()) {
|
||||
return new PaperLikeRuntimeControlBackend(capabilities);
|
||||
}
|
||||
|
||||
return new BukkitPublicRuntimeControlBackend(capabilities);
|
||||
}
|
||||
|
||||
static Location findTopSafeLocation(World world, Location source) {
|
||||
int x = source.getBlockX();
|
||||
int z = source.getBlockZ();
|
||||
float yaw = source.getYaw();
|
||||
float pitch = source.getPitch();
|
||||
|
||||
for (int y : buildSafeLocationScanOrder(world, source)) {
|
||||
if (isSafeStandingLocation(world, x, y, z)) {
|
||||
return new Location(world, x + 0.5D, y, z + 0.5D, yaw, pitch);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static int[] buildSafeLocationScanOrder(World world, Location source) {
|
||||
int x = source.getBlockX();
|
||||
int z = source.getBlockZ();
|
||||
int minY = world.getMinHeight() + 1;
|
||||
int maxY = world.getMaxHeight() - 2;
|
||||
int highestY = Math.max(minY, Math.min(maxY, world.getHighestBlockYAt(x, z) + 1));
|
||||
int[] scanOrder = new int[maxY - minY + 1];
|
||||
int index = 0;
|
||||
|
||||
for (int y = highestY; y >= minY; y--) {
|
||||
scanOrder[index++] = y;
|
||||
}
|
||||
|
||||
for (int y = highestY + 1; y <= maxY; y++) {
|
||||
scanOrder[index++] = y;
|
||||
}
|
||||
|
||||
return scanOrder;
|
||||
}
|
||||
|
||||
private static boolean isSafeStandingLocation(World world, int x, int y, int z) {
|
||||
if (y <= world.getMinHeight() || y >= world.getMaxHeight() - 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Block below = world.getBlockAt(x, y - 1, z);
|
||||
Block feet = world.getBlockAt(x, y, z);
|
||||
Block head = world.getBlockAt(x, y + 1, z);
|
||||
Material belowType = below.getType();
|
||||
if (!belowType.isSolid()) {
|
||||
return false;
|
||||
}
|
||||
if (Tag.LEAVES.isTagged(belowType)) {
|
||||
return false;
|
||||
}
|
||||
if (belowType == Material.LAVA
|
||||
|| belowType == Material.MAGMA_BLOCK
|
||||
|| belowType == Material.FIRE
|
||||
|| belowType == Material.SOUL_FIRE
|
||||
|| belowType == Material.CAMPFIRE
|
||||
|| belowType == Material.SOUL_CAMPFIRE) {
|
||||
return false;
|
||||
}
|
||||
if (feet.getType().isSolid() || head.getType().isSolid()) {
|
||||
return false;
|
||||
}
|
||||
if (feet.isLiquid() || head.isLiquid()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static void setBooleanGameRule(World world, boolean value, String... names) {
|
||||
GameRule<Boolean> gameRule = resolveBooleanGameRule(world, names);
|
||||
if (gameRule != null) {
|
||||
world.setGameRule(gameRule, value);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static GameRule<Boolean> resolveBooleanGameRule(World world, String... names) {
|
||||
if (world == null || names == null || names.length == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Set<String> candidates = buildRuleNameCandidates(names);
|
||||
for (String name : candidates) {
|
||||
if (name == null || name.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
Field field = GameRule.class.getField(name);
|
||||
Object value = field.get(null);
|
||||
if (value instanceof GameRule<?> gameRule && Boolean.class.equals(gameRule.getType())) {
|
||||
return (GameRule<Boolean>) gameRule;
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
|
||||
try {
|
||||
GameRule<?> byName = GameRule.getByName(name);
|
||||
if (byName != null && Boolean.class.equals(byName.getType())) {
|
||||
return (GameRule<Boolean>) byName;
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
String[] availableRules = world.getGameRules();
|
||||
if (availableRules == null || availableRules.length == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Set<String> normalizedCandidates = new LinkedHashSet<>();
|
||||
for (String candidate : candidates) {
|
||||
if (candidate != null && !candidate.isBlank()) {
|
||||
normalizedCandidates.add(normalizeRuleName(candidate));
|
||||
}
|
||||
}
|
||||
|
||||
for (String availableRule : availableRules) {
|
||||
String normalizedAvailable = normalizeRuleName(availableRule);
|
||||
if (!normalizedCandidates.contains(normalizedAvailable)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
GameRule<?> byName = GameRule.getByName(availableRule);
|
||||
if (byName != null && Boolean.class.equals(byName.getType())) {
|
||||
return (GameRule<Boolean>) byName;
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Set<String> buildRuleNameCandidates(String... names) {
|
||||
Set<String> candidates = new LinkedHashSet<>();
|
||||
for (String name : names) {
|
||||
if (name == null || name.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
candidates.add(name);
|
||||
candidates.add(name.toUpperCase());
|
||||
candidates.add(name.toLowerCase());
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
private static String normalizeRuleName(String name) {
|
||||
if (name == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
StringBuilder builder = new StringBuilder(name.length());
|
||||
for (int i = 0; i < name.length(); i++) {
|
||||
char current = name.charAt(i);
|
||||
if (Character.isLetterOrDigit(current)) {
|
||||
builder.append(Character.toLowerCase(current));
|
||||
}
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private static boolean dimensionTypeHasFixedTime(Object dimensionType) throws ReflectiveOperationException {
|
||||
Object fixedTimeFlag;
|
||||
try {
|
||||
fixedTimeFlag = invokeNoArg(dimensionType, "hasFixedTime");
|
||||
} catch (NoSuchMethodException ignored) {
|
||||
Object fixedTime = invokeNoArg(dimensionType, "fixedTime");
|
||||
if (fixedTime instanceof OptionalLong optionalLong) {
|
||||
return optionalLong.isPresent();
|
||||
}
|
||||
if (fixedTime instanceof Optional<?> optional) {
|
||||
return optional.isPresent();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return fixedTimeFlag instanceof Boolean && (Boolean) fixedTimeFlag;
|
||||
}
|
||||
|
||||
private static Object unwrapDimensionType(Object dimensionTypeHolder) throws ReflectiveOperationException {
|
||||
if (dimensionTypeHolder == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Class<?> holderClass = dimensionTypeHolder.getClass();
|
||||
if (holderClass.getName().startsWith("net.minecraft.world.level.dimension.")) {
|
||||
return dimensionTypeHolder;
|
||||
}
|
||||
|
||||
Method valueMethod = holderClass.getMethod("value");
|
||||
return valueMethod.invoke(dimensionTypeHolder);
|
||||
}
|
||||
|
||||
private static Object invokeNoArg(Object instance, String methodName) throws ReflectiveOperationException {
|
||||
Method method = instance.getClass().getMethod(methodName);
|
||||
return method.invoke(instance);
|
||||
}
|
||||
}
|
||||
@@ -74,7 +74,7 @@ public enum Mode {
|
||||
|
||||
String[] info = new String[]{
|
||||
"",
|
||||
padd2 + color + " Iris, " + C.AQUA + "Dimension Engine " + C.RED + "[" + releaseTrain + " RELEASE]",
|
||||
padd2 + color + " Iris, " + C.AQUA + "Dimension Engine " + C.RED + "[" + releaseTrain + " RC.1]",
|
||||
padd2 + C.GRAY + " Version: " + color + version,
|
||||
padd2 + C.GRAY + " By: " + color + "Volmit Software (Arcane Arts)",
|
||||
padd2 + C.GRAY + " Server: " + color + serverVersion,
|
||||
@@ -89,10 +89,8 @@ public enum Mode {
|
||||
StringBuilder builder = new StringBuilder("\n\n");
|
||||
for (int i = 0; i < splash.length; i++) {
|
||||
builder.append(splash[i]);
|
||||
if (i < info.length) {
|
||||
builder.append(info[i]);
|
||||
}
|
||||
builder.append("\n");
|
||||
builder.append(info[i]);
|
||||
builder.append("\n");
|
||||
}
|
||||
|
||||
Iris.info(builder.toString());
|
||||
|
||||
@@ -22,10 +22,12 @@ import com.google.gson.JsonSyntaxException;
|
||||
import art.arcane.iris.Iris;
|
||||
import art.arcane.iris.core.IrisSettings;
|
||||
import art.arcane.iris.core.ServerConfigurator;
|
||||
import art.arcane.iris.core.lifecycle.WorldLifecycleService;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.nms.INMS;
|
||||
import art.arcane.iris.core.pack.IrisPack;
|
||||
import art.arcane.iris.core.project.IrisProject;
|
||||
import art.arcane.iris.core.runtime.TransientWorldCleanupSupport;
|
||||
import art.arcane.iris.core.tools.IrisToolbelt;
|
||||
import art.arcane.iris.engine.data.cache.AtomicCache;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
@@ -46,7 +48,10 @@ import org.zeroturnaround.zip.commons.FileUtils;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
public class StudioSVC implements IrisService {
|
||||
@@ -55,6 +60,7 @@ public class StudioSVC implements IrisService {
|
||||
private static final AtomicCache<Integer> counter = new AtomicCache<>();
|
||||
private final KMap<String, String> cacheListing = null;
|
||||
private IrisProject activeProject;
|
||||
private CompletableFuture<art.arcane.iris.core.runtime.StudioOpenCoordinator.StudioCloseResult> activeClose;
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
@@ -78,21 +84,41 @@ public class StudioSVC implements IrisService {
|
||||
public void onDisable() {
|
||||
Iris.debug("Studio Mode Active: Closing Projects");
|
||||
boolean stopping = IrisToolbelt.isServerStopping();
|
||||
LinkedHashSet<String> worldNamesToDelete = new LinkedHashSet<>(TransientWorldCleanupSupport.collectTransientStudioWorldNames(Bukkit.getWorldContainer()));
|
||||
|
||||
for (World i : Bukkit.getWorlds()) {
|
||||
if (IrisToolbelt.isIrisWorld(i)) {
|
||||
if (IrisToolbelt.isStudio(i)) {
|
||||
PlatformChunkGenerator generator = IrisToolbelt.access(i);
|
||||
if (!stopping) {
|
||||
IrisToolbelt.evacuate(i);
|
||||
}
|
||||
|
||||
if (generator != null) {
|
||||
generator.close();
|
||||
}
|
||||
if (activeProject != null) {
|
||||
PlatformChunkGenerator activeProvider = activeProject.getActiveProvider();
|
||||
if (activeProvider != null) {
|
||||
String activeWorldName = activeProvider.getTarget().getWorld().name();
|
||||
if (activeWorldName != null && !activeWorldName.isBlank()) {
|
||||
worldNamesToDelete.add(activeWorldName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (World i : Bukkit.getWorlds()) {
|
||||
if (!IrisToolbelt.isIrisWorld(i) || !IrisToolbelt.isStudio(i)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
worldNamesToDelete.add(i.getName());
|
||||
PlatformChunkGenerator generator = IrisToolbelt.access(i);
|
||||
if (!stopping) {
|
||||
destroyStudioWorld(i, generator);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (generator != null) {
|
||||
try {
|
||||
generator.close();
|
||||
} catch (Throwable e) {
|
||||
Iris.reportError("Failed to close studio generator for \"" + i.getName() + "\" during shutdown.", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
activeProject = null;
|
||||
queueStudioWorldDeletionOnStartup(worldNamesToDelete);
|
||||
}
|
||||
|
||||
public IrisDimension installIntoWorld(VolmitSender sender, String type, File folder) {
|
||||
@@ -348,20 +374,46 @@ public class StudioSVC implements IrisService {
|
||||
open(sender, seed, dimm, (w) -> {
|
||||
});
|
||||
} catch (Exception e) {
|
||||
Iris.reportError(e);
|
||||
Iris.reportError("Failed to open studio world \"" + dimm + "\".", e);
|
||||
sender.sendMessage("Failed to open studio world: " + e.getMessage());
|
||||
Iris.error("Studio world creation failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public void open(VolmitSender sender, long seed, String dimm, Consumer<World> onDone) throws IrisException {
|
||||
if (isProjectOpen()) {
|
||||
close();
|
||||
}
|
||||
CompletableFuture<art.arcane.iris.core.runtime.StudioOpenCoordinator.StudioCloseResult> pendingClose = close();
|
||||
pendingClose.whenComplete((closeResult, closeThrowable) -> {
|
||||
if (closeThrowable != null) {
|
||||
Iris.reportError("Failed while closing an existing studio project before opening \"" + dimm + "\".", closeThrowable);
|
||||
J.s(() -> sender.sendMessage("Failed to close the existing studio project: " + closeThrowable.getMessage()));
|
||||
return;
|
||||
}
|
||||
|
||||
IrisProject project = new IrisProject(new File(getWorkspaceFolder(), dimm));
|
||||
activeProject = project;
|
||||
project.open(sender, seed, onDone);
|
||||
if (closeResult != null && closeResult.failureCause() != null) {
|
||||
Throwable failure = closeResult.failureCause();
|
||||
Iris.reportError("Failed while closing an existing studio project before opening \"" + dimm + "\".", failure);
|
||||
J.s(() -> sender.sendMessage("Failed to close the existing studio project: " + failure.getMessage()));
|
||||
return;
|
||||
}
|
||||
|
||||
IrisProject project = new IrisProject(new File(getWorkspaceFolder(), dimm));
|
||||
activeProject = project;
|
||||
try {
|
||||
project.open(sender, seed, onDone).whenComplete((result, throwable) -> {
|
||||
if (throwable == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeProject == project && !project.isOpen()) {
|
||||
activeProject = null;
|
||||
}
|
||||
});
|
||||
} catch (IrisException e) {
|
||||
if (activeProject == project) {
|
||||
activeProject = null;
|
||||
}
|
||||
J.s(() -> sender.sendMessage("Failed to open studio world: " + e.getMessage()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void openVSCode(VolmitSender sender, String dim) {
|
||||
@@ -376,11 +428,89 @@ public class StudioSVC implements IrisService {
|
||||
return Iris.instance.getDataFileList(WORKSPACE_NAME, sub);
|
||||
}
|
||||
|
||||
public void close() {
|
||||
if (isProjectOpen()) {
|
||||
Iris.debug("Closing Active Project");
|
||||
activeProject.close();
|
||||
activeProject = null;
|
||||
public CompletableFuture<art.arcane.iris.core.runtime.StudioOpenCoordinator.StudioCloseResult> close() {
|
||||
if (activeClose != null && !activeClose.isDone()) {
|
||||
return activeClose;
|
||||
}
|
||||
|
||||
if (activeProject == null) {
|
||||
return CompletableFuture.completedFuture(new art.arcane.iris.core.runtime.StudioOpenCoordinator.StudioCloseResult(null, true, true, false, null, null));
|
||||
}
|
||||
|
||||
Iris.debug("Closing Active Project");
|
||||
IrisProject project = activeProject;
|
||||
activeProject = null;
|
||||
activeClose = project.close();
|
||||
activeClose.whenComplete((result, throwable) -> activeClose = null);
|
||||
return activeClose;
|
||||
}
|
||||
|
||||
private void destroyStudioWorld(World world, PlatformChunkGenerator generator) {
|
||||
try {
|
||||
IrisToolbelt.evacuate(world);
|
||||
} catch (Throwable e) {
|
||||
Iris.reportError("Failed to evacuate studio world \"" + world.getName() + "\" during shutdown cleanup.", e);
|
||||
}
|
||||
|
||||
if (generator != null) {
|
||||
try {
|
||||
generator.close();
|
||||
} catch (Throwable e) {
|
||||
Iris.reportError("Failed to close studio generator for \"" + world.getName() + "\" during shutdown cleanup.", e);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
WorldLifecycleService.get().unload(world, false);
|
||||
} catch (Throwable e) {
|
||||
Iris.reportError("Failed to unload studio world \"" + world.getName() + "\" during shutdown cleanup.", e);
|
||||
}
|
||||
|
||||
deleteTransientStudioFolders(world.getName());
|
||||
}
|
||||
|
||||
private void deleteTransientStudioFolders(String worldName) {
|
||||
if (worldName == null || worldName.isBlank()) {
|
||||
return;
|
||||
}
|
||||
|
||||
File container = Bukkit.getWorldContainer();
|
||||
for (String familyWorldName : TransientWorldCleanupSupport.worldFamilyNames(worldName)) {
|
||||
File folder = new File(container, familyWorldName);
|
||||
if (!folder.exists()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
IO.delete(folder);
|
||||
}
|
||||
}
|
||||
|
||||
private void queueStudioWorldDeletionOnStartup(LinkedHashSet<String> worldNamesToDelete) {
|
||||
if (worldNamesToDelete.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
LinkedHashSet<String> normalizedNames = new LinkedHashSet<>();
|
||||
for (String worldName : worldNamesToDelete) {
|
||||
String baseWorldName = TransientWorldCleanupSupport.transientStudioBaseWorldName(worldName);
|
||||
if (baseWorldName != null) {
|
||||
normalizedNames.add(baseWorldName);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (worldName != null && !worldName.isBlank()) {
|
||||
normalizedNames.add(worldName);
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedNames.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Iris.queueWorldDeletionOnStartup(List.copyOf(normalizedNames));
|
||||
} catch (IOException e) {
|
||||
Iris.reportError("Failed to queue studio world deletion on startup.", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,14 +24,16 @@ import art.arcane.iris.core.IrisRuntimeSchedulerMode;
|
||||
import art.arcane.iris.core.IrisWorlds;
|
||||
import art.arcane.iris.core.IrisSettings;
|
||||
import art.arcane.iris.core.ServerConfigurator;
|
||||
import art.arcane.iris.core.link.FoliaWorldsLink;
|
||||
import art.arcane.iris.core.lifecycle.WorldLifecycleCaller;
|
||||
import art.arcane.iris.core.lifecycle.WorldLifecycleRequest;
|
||||
import art.arcane.iris.core.lifecycle.WorldLifecycleService;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.nms.INMS;
|
||||
import art.arcane.iris.core.pregenerator.PregenTask;
|
||||
import art.arcane.iris.core.service.BoardSVC;
|
||||
import art.arcane.iris.core.service.StudioSVC;
|
||||
import art.arcane.iris.engine.IrisNoisemapPrebakePipeline;
|
||||
import art.arcane.iris.engine.framework.SeedManager;
|
||||
import art.arcane.iris.core.runtime.DatapackReadinessResult;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
@@ -42,7 +44,6 @@ import art.arcane.volmlib.util.format.Form;
|
||||
import art.arcane.volmlib.util.io.IO;
|
||||
import art.arcane.iris.util.common.plugin.VolmitSender;
|
||||
import art.arcane.iris.util.common.scheduling.J;
|
||||
import art.arcane.volmlib.util.scheduling.O;
|
||||
import art.arcane.volmlib.util.scheduling.FoliaScheduler;
|
||||
import io.papermc.lib.PaperLib;
|
||||
import lombok.Data;
|
||||
@@ -52,21 +53,22 @@ import org.bukkit.block.Block;
|
||||
import org.bukkit.configuration.ConfigurationSection;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.world.TimeSkipEvent;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Field;
|
||||
import java.time.Duration;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Optional;
|
||||
import java.util.OptionalLong;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.IntSupplier;
|
||||
@@ -79,9 +81,6 @@ import static art.arcane.iris.util.common.misc.ServerProperties.BUKKIT_YML;
|
||||
@Data
|
||||
@Accessors(fluent = true, chain = true)
|
||||
public class IrisCreator {
|
||||
private static final int STUDIO_PREWARM_RADIUS_CHUNKS = 1;
|
||||
private static final Duration STUDIO_PREWARM_TIMEOUT = Duration.ofSeconds(45L);
|
||||
|
||||
/**
|
||||
* Specify an area to pregenerate during creation
|
||||
*/
|
||||
@@ -114,6 +113,11 @@ public class IrisCreator {
|
||||
*/
|
||||
private boolean benchmark = false;
|
||||
private BiConsumer<Double, String> studioProgressConsumer;
|
||||
private DatapackReadinessResult lastDatapackReadinessResult;
|
||||
|
||||
public DatapackReadinessResult getLastDatapackReadinessResult() {
|
||||
return lastDatapackReadinessResult;
|
||||
}
|
||||
|
||||
public static boolean removeFromBukkitYml(String name) throws IOException {
|
||||
YamlConfiguration yml = YamlConfiguration.loadConfiguration(BUKKIT_YML);
|
||||
@@ -144,7 +148,7 @@ public class IrisCreator {
|
||||
throw new IrisException("You cannot invoke create() on the main thread.");
|
||||
}
|
||||
|
||||
reportStudioProgress(0.02D, "Preparing studio open");
|
||||
reportStudioProgress(0.02D, "resolve_dimension");
|
||||
|
||||
if (studio()) {
|
||||
World existing = Bukkit.getWorld(name());
|
||||
@@ -155,7 +159,7 @@ public class IrisCreator {
|
||||
}
|
||||
}
|
||||
|
||||
reportStudioProgress(0.08D, "Resolving dimension");
|
||||
reportStudioProgress(0.08D, "resolve_dimension");
|
||||
IrisDimension d = IrisToolbelt.getDimension(dimension());
|
||||
|
||||
if (d == null) {
|
||||
@@ -165,7 +169,7 @@ public class IrisCreator {
|
||||
if (sender == null)
|
||||
sender = Iris.getSender();
|
||||
|
||||
reportStudioProgress(0.16D, "Preparing world pack");
|
||||
reportStudioProgress(0.16D, "prepare_world_pack");
|
||||
if (!studio() || benchmark) {
|
||||
Iris.service(StudioSVC.class).installIntoWorld(sender, d.getLoadKey(), new File(Bukkit.getWorldContainer(), name()));
|
||||
}
|
||||
@@ -174,12 +178,10 @@ public class IrisCreator {
|
||||
Iris.info("Studio create scheduling: mode=" + runtimeSchedulerMode.name().toLowerCase(Locale.ROOT)
|
||||
+ ", regionizedRuntime=" + FoliaScheduler.isRegionizedRuntime(Bukkit.getServer()));
|
||||
}
|
||||
prebakeNoisemapsBeforeWorldCreate(d);
|
||||
|
||||
reportStudioProgress(0.28D, "Installing datapacks");
|
||||
reportStudioProgress(0.28D, "install_datapacks");
|
||||
AtomicDouble pp = new AtomicDouble(0);
|
||||
O<Boolean> done = new O<>();
|
||||
done.set(false);
|
||||
AtomicBoolean done = new AtomicBoolean(false);
|
||||
WorldCreator wc = new IrisWorldCreator()
|
||||
.dimension(dimension)
|
||||
.name(name)
|
||||
@@ -199,104 +201,63 @@ public class IrisCreator {
|
||||
extraWorldDatapackFoldersByPack = new KMap<>();
|
||||
extraWorldDatapackFoldersByPack.put(d.getLoadKey(), studioDatapackFolders);
|
||||
}
|
||||
if (ServerConfigurator.installDataPacks(verifyDataPacks, includeExternalDataPacks, extraWorldDatapackFoldersByPack)) {
|
||||
throw new IrisException("Datapacks were missing!");
|
||||
lastDatapackReadinessResult = DatapackReadinessResult.installForStudioWorld(
|
||||
d.getLoadKey(),
|
||||
d.getDimensionTypeKey(),
|
||||
new File(Bukkit.getWorldContainer(), name()),
|
||||
verifyDataPacks,
|
||||
includeExternalDataPacks,
|
||||
extraWorldDatapackFoldersByPack
|
||||
);
|
||||
if (!"ok".equals(lastDatapackReadinessResult.getExternalDatapackInstallResult())) {
|
||||
throw new IrisException("Datapack external install failed: " + lastDatapackReadinessResult.getExternalDatapackInstallResult());
|
||||
}
|
||||
reportStudioProgress(0.40D, "Datapacks ready");
|
||||
if (lastDatapackReadinessResult.isRestartRequired()) {
|
||||
throw new IrisException("Datapack install requested a server restart for "
|
||||
+ d.getLoadKey()
|
||||
+ ". folders="
|
||||
+ lastDatapackReadinessResult.getResolvedDatapackFolders());
|
||||
}
|
||||
if (!lastDatapackReadinessResult.isVerificationPassed()) {
|
||||
throw new IrisException("Datapack readiness verification failed for "
|
||||
+ d.getLoadKey()
|
||||
+ ". missingPaths="
|
||||
+ lastDatapackReadinessResult.getMissingPaths()
|
||||
+ ", folders="
|
||||
+ lastDatapackReadinessResult.getResolvedDatapackFolders());
|
||||
}
|
||||
reportStudioProgress(0.40D, "install_datapacks");
|
||||
|
||||
PlatformChunkGenerator access = (PlatformChunkGenerator) wc.generator();
|
||||
if (access == null) throw new IrisException("Access is null. Something bad happened.");
|
||||
|
||||
J.a(() -> {
|
||||
IntSupplier g = () -> {
|
||||
if (access.getEngine() == null) {
|
||||
return 0;
|
||||
}
|
||||
return access.getEngine().getGenerated();
|
||||
};
|
||||
if(!benchmark) {
|
||||
int req = access.getSpawnChunks().join();
|
||||
for (int c = 0; c < req && !done.get(); c = g.getAsInt()) {
|
||||
double v = (double) c / req;
|
||||
if (studioProgressConsumer != null) {
|
||||
reportStudioProgress(0.40D + (0.42D * v), "Generating spawn");
|
||||
J.sleep(16);
|
||||
} else if (sender.isPlayer()) {
|
||||
sender.sendProgress(v, "Generating");
|
||||
J.sleep(16);
|
||||
} else {
|
||||
sender.sendMessage(C.WHITE + "Generating " + Form.pc(v) + ((C.GRAY + " (" + (req - c) + " Left)")));
|
||||
J.sleep(1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
AtomicInteger createProgressTask = startCreateProgressReporter(access, done);
|
||||
|
||||
|
||||
World world;
|
||||
reportStudioProgress(0.46D, "Creating world");
|
||||
reportStudioProgress(0.46D, "create_world");
|
||||
try {
|
||||
world = J.sfut(() -> INMS.get().createWorldAsync(wc))
|
||||
WorldLifecycleCaller callerKind = benchmark ? WorldLifecycleCaller.BENCHMARK : studio() ? WorldLifecycleCaller.STUDIO : WorldLifecycleCaller.CREATE;
|
||||
WorldLifecycleRequest request = WorldLifecycleRequest.fromCreator(wc, studio(), benchmark, callerKind);
|
||||
world = J.sfut(() -> INMS.get().createWorldAsync(wc, request))
|
||||
.thenCompose(Function.identity())
|
||||
.get();
|
||||
} catch (Throwable e) {
|
||||
done.set(true);
|
||||
cancelRepeatingTask(createProgressTask);
|
||||
if (J.isFolia() && containsCreateWorldUnsupportedOperation(e)) {
|
||||
if (FoliaWorldsLink.get().isActive()) {
|
||||
throw new IrisException("Runtime world creation is blocked and async Folia runtime world-loader creation also failed.", e);
|
||||
}
|
||||
throw new IrisException("Runtime world creation is blocked and no async Folia runtime world-loader path is active.", e);
|
||||
throw new IrisException("Runtime world creation is blocked and the selected world lifecycle backend could not create the world.", e);
|
||||
}
|
||||
throw new IrisException("Failed to create world!", e);
|
||||
throw new IrisException("Failed to create world with backend family " + WorldLifecycleService.get().capabilities().serverFamily().id() + "!", e);
|
||||
}
|
||||
|
||||
done.set(true);
|
||||
reportStudioProgress(0.86D, "World created");
|
||||
cancelRepeatingTask(createProgressTask);
|
||||
reportStudioProgress(0.86D, "create_world");
|
||||
|
||||
if (sender.isPlayer() && !benchmark) {
|
||||
Player senderPlayer = sender.player();
|
||||
if (senderPlayer == null) {
|
||||
Iris.warn("Studio opened, but sender player reference is unavailable for teleport.");
|
||||
} else {
|
||||
Location studioEntryLocation = resolveStudioEntryLocation(world);
|
||||
if (studioEntryLocation == null) {
|
||||
sender.sendMessage(C.YELLOW + "Studio opened, but entry location could not be resolved safely.");
|
||||
} else {
|
||||
prewarmStudioEntryChunks(world, studioEntryLocation, STUDIO_PREWARM_RADIUS_CHUNKS, STUDIO_PREWARM_TIMEOUT);
|
||||
CompletableFuture<Boolean> teleportFuture = PaperLib.teleportAsync(senderPlayer, studioEntryLocation);
|
||||
if (teleportFuture != null) {
|
||||
teleportFuture.thenAccept(success -> {
|
||||
if (Boolean.TRUE.equals(success)) {
|
||||
J.runEntity(senderPlayer, () -> Iris.service(BoardSVC.class).updatePlayer(senderPlayer));
|
||||
}
|
||||
});
|
||||
teleportFuture.exceptionally(throwable -> {
|
||||
Iris.warn("Failed to schedule studio teleport task for " + senderPlayer.getName() + ".");
|
||||
Iris.reportError(throwable);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (studio || benchmark) {
|
||||
Runnable applyStudioWorldSettings = () -> {
|
||||
Iris.linkMultiverseCore.removeFromConfig(world);
|
||||
|
||||
if (IrisSettings.get().getStudio().isDisableTimeAndWeather()) {
|
||||
setBooleanGameRule(world, false, "ADVANCE_WEATHER", "DO_WEATHER_CYCLE", "WEATHER_CYCLE", "doWeatherCycle", "weatherCycle");
|
||||
setBooleanGameRule(world, false, "ADVANCE_TIME", "DO_DAYLIGHT_CYCLE", "DAYLIGHT_CYCLE", "doDaylightCycle", "daylightCycle");
|
||||
world.setTime(6000);
|
||||
}
|
||||
};
|
||||
|
||||
J.s(applyStudioWorldSettings);
|
||||
} else {
|
||||
if (!studio && !benchmark) {
|
||||
addToBukkitYml();
|
||||
J.s(() -> Iris.linkMultiverseCore.updateWorld(world, dimension));
|
||||
}
|
||||
reportStudioProgress(0.93D, "Applying world settings");
|
||||
|
||||
if (pregen != null) {
|
||||
CompletableFuture<Boolean> ff = new CompletableFuture<>();
|
||||
@@ -305,28 +266,18 @@ public class IrisCreator {
|
||||
.onProgress(pp::set)
|
||||
.whenDone(() -> ff.complete(true));
|
||||
|
||||
AtomicBoolean dx = new AtomicBoolean(false);
|
||||
AtomicInteger pregenProgressTask = startPregenProgressReporter(pp, dx);
|
||||
try {
|
||||
AtomicBoolean dx = new AtomicBoolean(false);
|
||||
|
||||
J.a(() -> {
|
||||
while (!dx.get()) {
|
||||
if (sender.isPlayer()) {
|
||||
sender.sendProgress(pp.get(), "Pregenerating");
|
||||
J.sleep(16);
|
||||
} else {
|
||||
sender.sendMessage(C.WHITE + "Pregenerating " + Form.pc(pp.get()));
|
||||
J.sleep(1000);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ff.get();
|
||||
dx.set(true);
|
||||
cancelRepeatingTask(pregenProgressTask);
|
||||
} catch (Throwable e) {
|
||||
dx.set(true);
|
||||
cancelRepeatingTask(pregenProgressTask);
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
reportStudioProgress(0.98D, "Finalizing");
|
||||
return world;
|
||||
}
|
||||
|
||||
@@ -340,360 +291,89 @@ public class IrisCreator {
|
||||
try {
|
||||
consumer.accept(clamped, stage);
|
||||
} catch (Throwable e) {
|
||||
Iris.reportError(e);
|
||||
Iris.reportError("Studio progress consumer failed for world \"" + name() + "\".", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void prebakeNoisemapsBeforeWorldCreate(IrisDimension dimension) {
|
||||
IrisSettings.IrisSettingsPregen pregenSettings = IrisSettings.get().getPregen();
|
||||
if (!pregenSettings.isStartupNoisemapPrebake()) {
|
||||
private AtomicInteger startCreateProgressReporter(PlatformChunkGenerator access, AtomicBoolean done) {
|
||||
AtomicInteger taskId = new AtomicInteger(-1);
|
||||
if (benchmark) {
|
||||
return taskId;
|
||||
}
|
||||
|
||||
IntSupplier generatedSupplier = () -> {
|
||||
if (access.getEngine() == null) {
|
||||
return 0;
|
||||
}
|
||||
return access.getEngine().getGenerated();
|
||||
};
|
||||
access.getSpawnChunks().whenComplete((required, throwable) -> {
|
||||
if (throwable != null) {
|
||||
Iris.reportError("Failed to resolve studio spawn chunk target for world \"" + name() + "\".", throwable);
|
||||
return;
|
||||
}
|
||||
|
||||
if (done.get() || required == null || required <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
int interval = studioProgressConsumer != null || sender.isPlayer() ? 1 : 20;
|
||||
taskId.set(J.ar(() -> {
|
||||
if (done.get()) {
|
||||
cancelRepeatingTask(taskId);
|
||||
return;
|
||||
}
|
||||
|
||||
int generated = generatedSupplier.getAsInt();
|
||||
if (generated >= required) {
|
||||
cancelRepeatingTask(taskId);
|
||||
return;
|
||||
}
|
||||
|
||||
double progress = (double) generated / required;
|
||||
if (studioProgressConsumer != null) {
|
||||
reportStudioProgress(0.40D + (0.42D * progress), "create_world");
|
||||
return;
|
||||
}
|
||||
|
||||
if (sender.isPlayer()) {
|
||||
sender.sendProgress(progress, "Generating");
|
||||
return;
|
||||
}
|
||||
|
||||
sender.sendMessage(C.WHITE + "Generating " + Form.pc(progress) + ((C.GRAY + " (" + (required - generated) + " Left)")));
|
||||
}, interval));
|
||||
});
|
||||
return taskId;
|
||||
}
|
||||
|
||||
private AtomicInteger startPregenProgressReporter(AtomicDouble progress, AtomicBoolean done) {
|
||||
AtomicInteger taskId = new AtomicInteger(-1);
|
||||
int interval = sender.isPlayer() ? 1 : 20;
|
||||
taskId.set(J.ar(() -> {
|
||||
if (done.get()) {
|
||||
cancelRepeatingTask(taskId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sender.isPlayer()) {
|
||||
sender.sendProgress(progress.get(), "Pregenerating");
|
||||
return;
|
||||
}
|
||||
|
||||
sender.sendMessage(C.WHITE + "Pregenerating " + Form.pc(progress.get()));
|
||||
}, interval));
|
||||
return taskId;
|
||||
}
|
||||
|
||||
private void cancelRepeatingTask(AtomicInteger taskId) {
|
||||
if (taskId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (studio() && !benchmark) {
|
||||
boolean startupPrebakeReady = IrisNoisemapPrebakePipeline.awaitInstalledPacksPrebakeForStudio();
|
||||
if (startupPrebakeReady) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
File targetDataFolder = new File(Bukkit.getWorldContainer(), name());
|
||||
if (studio() && !benchmark) {
|
||||
IrisData studioData = dimension.getLoader();
|
||||
if (studioData != null) {
|
||||
targetDataFolder = studioData.getDataFolder();
|
||||
}
|
||||
}
|
||||
|
||||
IrisData targetData = IrisData.get(targetDataFolder);
|
||||
SeedManager seedManager = new SeedManager(seed());
|
||||
IrisNoisemapPrebakePipeline.prebake(targetData, seedManager, name(), dimension.getLoadKey());
|
||||
} catch (Throwable throwable) {
|
||||
Iris.warn("Failed pre-create noisemap pre-bake for " + name() + "/" + dimension.getLoadKey() + ": " + throwable.getMessage());
|
||||
Iris.reportError(throwable);
|
||||
}
|
||||
}
|
||||
|
||||
private Location resolveStudioEntryLocation(World world) {
|
||||
CompletableFuture<Location> locationFuture = J.sfut(() -> {
|
||||
Location spawnLocation = world.getSpawnLocation();
|
||||
if (spawnLocation != null) {
|
||||
return spawnLocation.clone();
|
||||
}
|
||||
|
||||
int x = 0;
|
||||
int z = 0;
|
||||
int y = Math.max(world.getMinHeight() + 1, 96);
|
||||
return new Location(world, x + 0.5D, y, z + 0.5D);
|
||||
});
|
||||
if (locationFuture == null) {
|
||||
Iris.warn("Failed to schedule studio entry-location resolve task on the global scheduler for world \"" + world.getName() + "\".");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
Location rawLocation = locationFuture.get(15, TimeUnit.SECONDS);
|
||||
return resolveTopSafeStudioLocation(world, rawLocation);
|
||||
} catch (Throwable e) {
|
||||
Iris.warn("Failed to resolve studio entry location for world \"" + world.getName() + "\".");
|
||||
Iris.reportError(e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Location resolveTopSafeStudioLocation(World world, Location rawLocation) {
|
||||
if (world == null || rawLocation == null) {
|
||||
return rawLocation;
|
||||
}
|
||||
|
||||
int chunkX = rawLocation.getBlockX() >> 4;
|
||||
int chunkZ = rawLocation.getBlockZ() >> 4;
|
||||
try {
|
||||
CompletableFuture<Chunk> chunkFuture = PaperLib.getChunkAtAsync(world, chunkX, chunkZ, false);
|
||||
if (chunkFuture != null) {
|
||||
chunkFuture.get(10, TimeUnit.SECONDS);
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
return rawLocation;
|
||||
}
|
||||
|
||||
if (!world.isChunkLoaded(chunkX, chunkZ)) {
|
||||
return rawLocation;
|
||||
}
|
||||
|
||||
CompletableFuture<Location> regionFuture = new CompletableFuture<>();
|
||||
boolean scheduled = J.runRegion(world, chunkX, chunkZ, () -> {
|
||||
try {
|
||||
regionFuture.complete(findTopSafeStudioLocation(world, rawLocation));
|
||||
} catch (Throwable e) {
|
||||
regionFuture.completeExceptionally(e);
|
||||
}
|
||||
});
|
||||
if (!scheduled) {
|
||||
return rawLocation;
|
||||
}
|
||||
|
||||
try {
|
||||
Location resolved = regionFuture.get(15, TimeUnit.SECONDS);
|
||||
return resolved == null ? rawLocation : resolved;
|
||||
} catch (Throwable e) {
|
||||
Iris.warn("Failed to resolve safe studio entry surface for world \"" + world.getName() + "\".");
|
||||
Iris.reportError(e);
|
||||
return rawLocation;
|
||||
}
|
||||
}
|
||||
|
||||
private Location findTopSafeStudioLocation(World world, Location source) {
|
||||
int x = source.getBlockX();
|
||||
int z = source.getBlockZ();
|
||||
int minY = world.getMinHeight() + 1;
|
||||
int maxY = world.getMaxHeight() - 2;
|
||||
int sourceY = source.getBlockY();
|
||||
int startY = Math.max(minY, Math.min(maxY, sourceY));
|
||||
float yaw = source.getYaw();
|
||||
float pitch = source.getPitch();
|
||||
|
||||
int upperBound = Math.min(maxY, startY + 32);
|
||||
for (int y = startY; y <= upperBound; y++) {
|
||||
if (isSafeStandingLocation(world, x, y, z)) {
|
||||
return new Location(world, x + 0.5D, y, z + 0.5D, yaw, pitch);
|
||||
}
|
||||
}
|
||||
|
||||
int lowerBound = Math.max(minY, startY - 64);
|
||||
for (int y = startY - 1; y >= lowerBound; y--) {
|
||||
if (isSafeStandingLocation(world, x, y, z)) {
|
||||
return new Location(world, x + 0.5D, y, z + 0.5D, yaw, pitch);
|
||||
}
|
||||
}
|
||||
|
||||
int fallbackY = Math.max(minY, Math.min(maxY, source.getBlockY()));
|
||||
return new Location(world, x + 0.5D, fallbackY, z + 0.5D, yaw, pitch);
|
||||
}
|
||||
|
||||
private boolean isSafeStandingLocation(World world, int x, int y, int z) {
|
||||
if (y <= world.getMinHeight() || y >= world.getMaxHeight() - 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Block below = world.getBlockAt(x, y - 1, z);
|
||||
Block feet = world.getBlockAt(x, y, z);
|
||||
Block head = world.getBlockAt(x, y + 1, z);
|
||||
|
||||
Material belowType = below.getType();
|
||||
if (!belowType.isSolid()) {
|
||||
return false;
|
||||
}
|
||||
if (Tag.LEAVES.isTagged(belowType)) {
|
||||
return false;
|
||||
}
|
||||
if (belowType == Material.LAVA
|
||||
|| belowType == Material.MAGMA_BLOCK
|
||||
|| belowType == Material.FIRE
|
||||
|| belowType == Material.SOUL_FIRE
|
||||
|| belowType == Material.CAMPFIRE
|
||||
|| belowType == Material.SOUL_CAMPFIRE) {
|
||||
return false;
|
||||
}
|
||||
if (feet.getType().isSolid() || head.getType().isSolid()) {
|
||||
return false;
|
||||
}
|
||||
if (feet.isLiquid() || head.isLiquid()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void prewarmStudioEntryChunks(World world, Location entry, int radiusChunks, Duration timeout) throws IrisException {
|
||||
if (world == null || entry == null) {
|
||||
throw new IrisException("Studio prewarm failed: world or entry location is null.");
|
||||
}
|
||||
|
||||
int centerChunkX = entry.getBlockX() >> 4;
|
||||
int centerChunkZ = entry.getBlockZ() >> 4;
|
||||
List<StudioChunkCoordinate> chunkTargets = resolveStudioPrewarmTargets(centerChunkX, centerChunkZ, radiusChunks);
|
||||
if (chunkTargets.isEmpty()) {
|
||||
throw new IrisException("Studio prewarm failed: no target chunks were resolved.");
|
||||
}
|
||||
|
||||
int loadedBefore = 0;
|
||||
Map<StudioChunkCoordinate, CompletableFuture<Chunk>> futures = new LinkedHashMap<>();
|
||||
for (StudioChunkCoordinate coordinate : chunkTargets) {
|
||||
if (world.isChunkLoaded(coordinate.getX(), coordinate.getZ())) {
|
||||
loadedBefore++;
|
||||
}
|
||||
|
||||
CompletableFuture<Chunk> chunkFuture = PaperLib.getChunkAtAsync(world, coordinate.getX(), coordinate.getZ(), true);
|
||||
if (chunkFuture == null) {
|
||||
throw new IrisException("Studio prewarm failed: async chunk future was null for " + coordinate + ".");
|
||||
}
|
||||
|
||||
futures.put(coordinate, chunkFuture);
|
||||
}
|
||||
|
||||
int total = chunkTargets.size();
|
||||
int completed = 0;
|
||||
Set<StudioChunkCoordinate> remaining = new LinkedHashSet<>(chunkTargets);
|
||||
long startNanos = System.nanoTime();
|
||||
long timeoutNanos = Math.max(1L, timeout.toNanos());
|
||||
reportStudioProgress(0.88D, "Prewarming entry chunks (0/" + total + ")");
|
||||
|
||||
while (!remaining.isEmpty()) {
|
||||
long elapsedNanos = System.nanoTime() - startNanos;
|
||||
if (elapsedNanos >= timeoutNanos) {
|
||||
StudioPrewarmDiagnostics diagnostics = buildStudioPrewarmDiagnostics(world, chunkTargets, remaining, loadedBefore, elapsedNanos);
|
||||
throw new IrisException("Studio prewarm timed out: " + diagnostics.toMessage());
|
||||
}
|
||||
|
||||
boolean progressed = false;
|
||||
List<StudioChunkCoordinate> completedCoordinates = new ArrayList<>();
|
||||
for (StudioChunkCoordinate coordinate : remaining) {
|
||||
CompletableFuture<Chunk> chunkFuture = futures.get(coordinate);
|
||||
if (chunkFuture == null || !chunkFuture.isDone()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
Chunk loadedChunk = chunkFuture.get();
|
||||
if (loadedChunk == null) {
|
||||
throw new IrisException("Studio prewarm failed: chunk " + coordinate + " resolved to null.");
|
||||
}
|
||||
} catch (IrisException e) {
|
||||
throw e;
|
||||
} catch (Throwable e) {
|
||||
throw new IrisException("Studio prewarm failed while loading chunk " + coordinate + ".", e);
|
||||
}
|
||||
|
||||
completedCoordinates.add(coordinate);
|
||||
progressed = true;
|
||||
}
|
||||
|
||||
if (!completedCoordinates.isEmpty()) {
|
||||
for (StudioChunkCoordinate completedCoordinate : completedCoordinates) {
|
||||
remaining.remove(completedCoordinate);
|
||||
}
|
||||
|
||||
completed += completedCoordinates.size();
|
||||
double ratio = (double) completed / (double) total;
|
||||
reportStudioProgress(0.88D + (0.04D * ratio), "Prewarming entry chunks (" + completed + "/" + total + ")");
|
||||
}
|
||||
|
||||
if (!progressed) {
|
||||
J.sleep(20);
|
||||
}
|
||||
}
|
||||
|
||||
long elapsedNanos = System.nanoTime() - startNanos;
|
||||
StudioPrewarmDiagnostics diagnostics = buildStudioPrewarmDiagnostics(world, chunkTargets, new LinkedHashSet<>(), loadedBefore, elapsedNanos);
|
||||
Iris.info("Studio prewarm complete: " + diagnostics.toMessage());
|
||||
}
|
||||
|
||||
private StudioPrewarmDiagnostics buildStudioPrewarmDiagnostics(
|
||||
World world,
|
||||
List<StudioChunkCoordinate> chunkTargets,
|
||||
Set<StudioChunkCoordinate> timedOutChunks,
|
||||
int loadedBefore,
|
||||
long elapsedNanos
|
||||
) {
|
||||
int loadedAfter = 0;
|
||||
for (StudioChunkCoordinate coordinate : chunkTargets) {
|
||||
if (world.isChunkLoaded(coordinate.getX(), coordinate.getZ())) {
|
||||
loadedAfter++;
|
||||
}
|
||||
}
|
||||
|
||||
int generatedDuring = Math.max(0, loadedAfter - loadedBefore);
|
||||
List<String> timedOut = new ArrayList<>();
|
||||
for (StudioChunkCoordinate timedOutChunk : timedOutChunks) {
|
||||
timedOut.add(timedOutChunk.toString());
|
||||
}
|
||||
|
||||
long elapsedMs = TimeUnit.NANOSECONDS.toMillis(Math.max(0L, elapsedNanos));
|
||||
return new StudioPrewarmDiagnostics(elapsedMs, loadedBefore, loadedAfter, generatedDuring, timedOut);
|
||||
}
|
||||
|
||||
private List<StudioChunkCoordinate> resolveStudioPrewarmTargets(int centerChunkX, int centerChunkZ, int radiusChunks) {
|
||||
int safeRadius = Math.max(0, radiusChunks);
|
||||
List<StudioChunkCoordinate> targets = new ArrayList<>();
|
||||
targets.add(new StudioChunkCoordinate(centerChunkX, centerChunkZ));
|
||||
|
||||
for (int x = -safeRadius; x <= safeRadius; x++) {
|
||||
for (int z = -safeRadius; z <= safeRadius; z++) {
|
||||
if (x == 0 && z == 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
targets.add(new StudioChunkCoordinate(centerChunkX + x, centerChunkZ + z));
|
||||
}
|
||||
}
|
||||
|
||||
return targets;
|
||||
}
|
||||
|
||||
private static final class StudioChunkCoordinate {
|
||||
private final int x;
|
||||
private final int z;
|
||||
|
||||
private StudioChunkCoordinate(int x, int z) {
|
||||
this.x = x;
|
||||
this.z = z;
|
||||
}
|
||||
|
||||
private int getX() {
|
||||
return x;
|
||||
}
|
||||
|
||||
private int getZ() {
|
||||
return z;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
if (this == other) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!(other instanceof StudioChunkCoordinate coordinate)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return x == coordinate.x && z == coordinate.z;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return 31 * x + z;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return x + "," + z;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class StudioPrewarmDiagnostics {
|
||||
private final long elapsedMs;
|
||||
private final int loadedBefore;
|
||||
private final int loadedAfter;
|
||||
private final int generatedDuring;
|
||||
private final List<String> timedOutChunks;
|
||||
|
||||
private StudioPrewarmDiagnostics(long elapsedMs, int loadedBefore, int loadedAfter, int generatedDuring, List<String> timedOutChunks) {
|
||||
this.elapsedMs = elapsedMs;
|
||||
this.loadedBefore = loadedBefore;
|
||||
this.loadedAfter = loadedAfter;
|
||||
this.generatedDuring = generatedDuring;
|
||||
this.timedOutChunks = new ArrayList<>(timedOutChunks);
|
||||
}
|
||||
|
||||
private String toMessage() {
|
||||
return "elapsedMs=" + elapsedMs
|
||||
+ ", loadedBefore=" + loadedBefore
|
||||
+ ", loadedAfter=" + loadedAfter
|
||||
+ ", generatedDuring=" + generatedDuring
|
||||
+ ", timedOut=" + timedOutChunks;
|
||||
int id = taskId.getAndSet(-1);
|
||||
if (id >= 0) {
|
||||
J.car(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -713,138 +393,6 @@ public class IrisCreator {
|
||||
return false;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static void setBooleanGameRule(World world, boolean value, String... names) {
|
||||
GameRule<Boolean> gameRule = resolveBooleanGameRule(world, names);
|
||||
if (gameRule != null) {
|
||||
world.setGameRule(gameRule, value);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static GameRule<Boolean> resolveBooleanGameRule(World world, String... names) {
|
||||
if (world == null || names == null || names.length == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Set<String> candidates = buildRuleNameCandidates(names);
|
||||
for (String name : candidates) {
|
||||
if (name == null || name.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
Field field = GameRule.class.getField(name);
|
||||
Object value = field.get(null);
|
||||
if (value instanceof GameRule<?> gameRule && Boolean.class.equals(gameRule.getType())) {
|
||||
return (GameRule<Boolean>) gameRule;
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
|
||||
try {
|
||||
GameRule<?> byName = GameRule.getByName(name);
|
||||
if (byName != null && Boolean.class.equals(byName.getType())) {
|
||||
return (GameRule<Boolean>) byName;
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
String[] availableRules = world.getGameRules();
|
||||
if (availableRules == null || availableRules.length == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Set<String> normalizedCandidates = new LinkedHashSet<>();
|
||||
for (String candidate : candidates) {
|
||||
if (candidate != null && !candidate.isBlank()) {
|
||||
normalizedCandidates.add(normalizeRuleName(candidate));
|
||||
}
|
||||
}
|
||||
|
||||
for (String availableRule : availableRules) {
|
||||
String normalizedAvailable = normalizeRuleName(availableRule);
|
||||
if (!normalizedCandidates.contains(normalizedAvailable)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
GameRule<?> byName = GameRule.getByName(availableRule);
|
||||
if (byName != null && Boolean.class.equals(byName.getType())) {
|
||||
return (GameRule<Boolean>) byName;
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Set<String> buildRuleNameCandidates(String... names) {
|
||||
Set<String> candidates = new LinkedHashSet<>();
|
||||
for (String name : names) {
|
||||
if (name == null || name.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
candidates.add(name);
|
||||
candidates.add(name.toLowerCase(Locale.ROOT));
|
||||
|
||||
String lowerCamel = toLowerCamel(name);
|
||||
if (!lowerCamel.isEmpty()) {
|
||||
candidates.add(lowerCamel);
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
private static String toLowerCamel(String name) {
|
||||
if (name == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
String raw = name.trim();
|
||||
if (raw.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
String[] parts = raw.split("_+");
|
||||
if (parts.length == 0) {
|
||||
return raw;
|
||||
}
|
||||
|
||||
StringBuilder builder = new StringBuilder();
|
||||
builder.append(parts[0].toLowerCase(Locale.ROOT));
|
||||
for (int i = 1; i < parts.length; i++) {
|
||||
String part = parts[i].toLowerCase(Locale.ROOT);
|
||||
if (part.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
builder.append(Character.toUpperCase(part.charAt(0)));
|
||||
if (part.length() > 1) {
|
||||
builder.append(part.substring(1));
|
||||
}
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private static String normalizeRuleName(String name) {
|
||||
if (name == null || name.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
StringBuilder builder = new StringBuilder(name.length());
|
||||
for (int i = 0; i < name.length(); i++) {
|
||||
char c = name.charAt(i);
|
||||
if (Character.isLetterOrDigit(c)) {
|
||||
builder.append(Character.toLowerCase(c));
|
||||
}
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private void addToBukkitYml() {
|
||||
YamlConfiguration yml = YamlConfiguration.loadConfiguration(BUKKIT_YML);
|
||||
String gen = "Iris:" + dimension;
|
||||
|
||||
@@ -2,7 +2,7 @@ package art.arcane.iris.core.tools;
|
||||
|
||||
|
||||
import art.arcane.iris.Iris;
|
||||
import art.arcane.iris.core.link.FoliaWorldsLink;
|
||||
import art.arcane.iris.core.lifecycle.WorldLifecycleService;
|
||||
import art.arcane.iris.core.pregenerator.PregenTask;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
@@ -100,10 +100,10 @@ public class IrisPackBenchmarking {
|
||||
}
|
||||
|
||||
J.s(() -> {
|
||||
var world = Bukkit.getWorld("benchmark");
|
||||
org.bukkit.World world = Bukkit.getWorld("benchmark");
|
||||
if (world == null) return;
|
||||
IrisToolbelt.evacuate(world);
|
||||
FoliaWorldsLink.get().unloadWorld(world, true);
|
||||
WorldLifecycleService.get().unload(world, true);
|
||||
});
|
||||
|
||||
stopwatch.end();
|
||||
|
||||
@@ -90,9 +90,10 @@ public class IrisEngine implements Engine {
|
||||
private final int art;
|
||||
private final AtomicCache<IrisEngineData> engineData = new AtomicCache<>();
|
||||
private final AtomicBoolean cleaning;
|
||||
private final AtomicBoolean noisemapPrebakeRunning;
|
||||
private final ChronoLatch cleanLatch;
|
||||
private final SeedManager seedManager;
|
||||
private final GenerationSessionManager generationSessions;
|
||||
private final AtomicBoolean closing;
|
||||
private CompletableFuture<Long> hash32;
|
||||
private EngineMode mode;
|
||||
private EngineEffects effects;
|
||||
@@ -113,6 +114,8 @@ public class IrisEngine implements Engine {
|
||||
getEngineData();
|
||||
verifySeed();
|
||||
this.seedManager = new SeedManager(target.getWorld().getRawWorldSeed());
|
||||
this.generationSessions = new GenerationSessionManager();
|
||||
this.closing = new AtomicBoolean(false);
|
||||
bud = new AtomicInteger(0);
|
||||
buds = new AtomicInteger(0);
|
||||
metrics = new EngineMetrics(32);
|
||||
@@ -127,7 +130,6 @@ public class IrisEngine implements Engine {
|
||||
mantle = new IrisEngineMantle(this);
|
||||
context = new IrisContext(this);
|
||||
cleaning = new AtomicBoolean(false);
|
||||
noisemapPrebakeRunning = new AtomicBoolean(false);
|
||||
modeFallbackLogged = new AtomicBoolean(false);
|
||||
if (studio) {
|
||||
getData().dump();
|
||||
@@ -164,6 +166,13 @@ public class IrisEngine implements Engine {
|
||||
}
|
||||
|
||||
private void prehotload() {
|
||||
closing.set(true);
|
||||
try {
|
||||
generationSessions.sealAndAwait("hotload", 15000L);
|
||||
} catch (GenerationSessionException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
|
||||
EngineWorldManager currentWorldManager = worldManager;
|
||||
worldManager = null;
|
||||
if (currentWorldManager != null) {
|
||||
@@ -193,6 +202,8 @@ public class IrisEngine implements Engine {
|
||||
|
||||
private void setupEngine() {
|
||||
try {
|
||||
generationSessions.activateNextSession();
|
||||
closing.set(false);
|
||||
Iris.debug("Setup Engine " + getCacheID());
|
||||
cacheId = RNG.r.nextInt();
|
||||
complex = ensureComplex();
|
||||
@@ -215,7 +226,6 @@ public class IrisEngine implements Engine {
|
||||
.toArray(File[]::new);
|
||||
hash32.complete(IO.hashRecursive(roots));
|
||||
});
|
||||
scheduleStartupNoisemapPrebake();
|
||||
} catch (Throwable e) {
|
||||
Iris.error("FAILED TO SETUP ENGINE!");
|
||||
e.printStackTrace();
|
||||
@@ -297,30 +307,6 @@ public class IrisEngine implements Engine {
|
||||
}
|
||||
}
|
||||
|
||||
private void scheduleStartupNoisemapPrebake() {
|
||||
if (!IrisSettings.get().getPregen().isStartupNoisemapPrebake()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (studio) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!noisemapPrebakeRunning.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
J.a(() -> {
|
||||
try {
|
||||
IrisNoisemapPrebakePipeline.prebake(this);
|
||||
} catch (Throwable throwable) {
|
||||
Iris.reportError(throwable);
|
||||
} finally {
|
||||
noisemapPrebakeRunning.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void generateMatter(int x, int z, boolean multicore, ChunkContext context) {
|
||||
getMantle().generateMatter(x, z, multicore, context);
|
||||
@@ -532,8 +518,14 @@ public class IrisEngine implements Engine {
|
||||
@Override
|
||||
public void close() {
|
||||
PregeneratorJob.shutdownInstance();
|
||||
closing.set(true);
|
||||
closed = true;
|
||||
J.car(art);
|
||||
try {
|
||||
generationSessions.sealAndAwait("close", 15000L, true);
|
||||
} catch (GenerationSessionException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
EngineWorldManager currentWorldManager = getWorldManager();
|
||||
if (currentWorldManager != null) {
|
||||
currentWorldManager.close();
|
||||
@@ -574,6 +566,10 @@ public class IrisEngine implements Engine {
|
||||
return closed;
|
||||
}
|
||||
|
||||
public boolean isClosing() {
|
||||
return closing.get();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void recycle() {
|
||||
if (!cleanLatch.flip()) {
|
||||
@@ -602,13 +598,14 @@ public class IrisEngine implements Engine {
|
||||
@BlockCoordinates
|
||||
@Override
|
||||
public void generate(int x, int z, Hunk<BlockData> vblocks, Hunk<Biome> vbiomes, boolean multicore) throws WrongEngineBroException {
|
||||
if (closed) {
|
||||
throw new WrongEngineBroException();
|
||||
if (closing.get() || closed) {
|
||||
throw new GenerationSessionException("Generation session is closed for world \"" + getWorld().name() + "\".", true);
|
||||
}
|
||||
|
||||
context.touch();
|
||||
getEngineData().getStatistics().generatedChunk();
|
||||
try {
|
||||
try (GenerationSessionLease lease = acquireGenerationLease("chunk_generate")) {
|
||||
context.touch();
|
||||
context.setGenerationSessionId(lease.sessionId());
|
||||
getEngineData().getStatistics().generatedChunk();
|
||||
PrecisionStopwatch p = PrecisionStopwatch.start();
|
||||
Hunk<BlockData> blocks = vblocks.listen((xx, y, zz, t) -> catchBlockUpdates(x + xx, y, z + zz, t));
|
||||
|
||||
@@ -634,12 +631,19 @@ public class IrisEngine implements Engine {
|
||||
if (generated.get() == 661) {
|
||||
J.a(() -> getData().savePrefetch(this));
|
||||
}
|
||||
} catch (GenerationSessionException e) {
|
||||
throw e;
|
||||
} catch (Throwable e) {
|
||||
Iris.reportError(e);
|
||||
fail("Failed to generate " + x + ", " + z, e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public GenerationSessionManager getGenerationSessions() {
|
||||
return generationSessions;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveEngineData() {
|
||||
//TODO: Method this file
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -111,6 +111,10 @@ public interface Engine extends DataProvider, Fallible, LootProvider, BlockUpdat
|
||||
|
||||
void close();
|
||||
|
||||
default boolean isClosing() {
|
||||
return isClosed();
|
||||
}
|
||||
|
||||
IrisContext getContext();
|
||||
|
||||
double getMaxBiomeObjectDensity();
|
||||
@@ -121,6 +125,24 @@ public interface Engine extends DataProvider, Fallible, LootProvider, BlockUpdat
|
||||
|
||||
boolean isClosed();
|
||||
|
||||
default GenerationSessionManager getGenerationSessions() {
|
||||
return null;
|
||||
}
|
||||
|
||||
default GenerationSessionLease acquireGenerationLease(String operation) throws GenerationSessionException {
|
||||
GenerationSessionManager generationSessions = getGenerationSessions();
|
||||
if (generationSessions == null) {
|
||||
return GenerationSessionLease.noop();
|
||||
}
|
||||
|
||||
return generationSessions.acquire(operation);
|
||||
}
|
||||
|
||||
default long getGenerationSessionId() {
|
||||
GenerationSessionManager generationSessions = getGenerationSessions();
|
||||
return generationSessions == null ? 0L : generationSessions.currentSessionId();
|
||||
}
|
||||
|
||||
EngineWorldManager getWorldManager();
|
||||
|
||||
default UUID getBiomeID(int x, int z) {
|
||||
|
||||
@@ -71,6 +71,7 @@ public interface EngineMode extends Staged {
|
||||
|
||||
@BlockCoordinates
|
||||
default void generate(int x, int z, Hunk<BlockData> blocks, Hunk<Biome> biomes, boolean multicore) {
|
||||
IrisContext context = IrisContext.getOr(getEngine());
|
||||
boolean cacheContext = true;
|
||||
if (J.isFolia()) {
|
||||
org.bukkit.World world = getEngine().getWorld().realWorld();
|
||||
@@ -79,8 +80,8 @@ public interface EngineMode extends Staged {
|
||||
}
|
||||
}
|
||||
ChunkContext.PrefillPlan prefillPlan = cacheContext ? ChunkContext.PrefillPlan.NO_CAVE : ChunkContext.PrefillPlan.NONE;
|
||||
ChunkContext ctx = new ChunkContext(x, z, getComplex(), cacheContext, prefillPlan, getEngine().getMetrics());
|
||||
IrisContext.getOr(getEngine()).setChunkContext(ctx);
|
||||
ChunkContext ctx = new ChunkContext(x, z, getComplex(), context.getGenerationSessionId(), cacheContext, prefillPlan, getEngine().getMetrics());
|
||||
context.setChunkContext(ctx);
|
||||
|
||||
EngineStage[] stages = getStages().toArray(new EngineStage[0]);
|
||||
for (EngineStage i : stages) {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package art.arcane.iris.engine.framework;
|
||||
|
||||
public class GenerationSessionException extends WrongEngineBroException {
|
||||
private final boolean expectedTeardown;
|
||||
|
||||
public GenerationSessionException(String message) {
|
||||
this(message, false);
|
||||
}
|
||||
|
||||
public GenerationSessionException(String message, boolean expectedTeardown) {
|
||||
super(message);
|
||||
this.expectedTeardown = expectedTeardown;
|
||||
}
|
||||
|
||||
public boolean isExpectedTeardown() {
|
||||
return expectedTeardown;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package art.arcane.iris.engine.framework;
|
||||
|
||||
public final class GenerationSessionLease implements AutoCloseable {
|
||||
private static final GenerationSessionLease NOOP = new GenerationSessionLease(null, null, 0L);
|
||||
|
||||
private final GenerationSessionManager manager;
|
||||
private final GenerationSessionManager.GenerationSessionState state;
|
||||
private final long sessionId;
|
||||
private boolean released;
|
||||
|
||||
GenerationSessionLease(GenerationSessionManager manager, GenerationSessionManager.GenerationSessionState state, long sessionId) {
|
||||
this.manager = manager;
|
||||
this.state = state;
|
||||
this.sessionId = sessionId;
|
||||
this.released = false;
|
||||
}
|
||||
|
||||
public static GenerationSessionLease noop() {
|
||||
return NOOP;
|
||||
}
|
||||
|
||||
public long sessionId() {
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (released || state == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
released = true;
|
||||
manager.releaseLease(state);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package art.arcane.iris.engine.framework;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
public final class GenerationSessionManager {
|
||||
private final AtomicLong sessionSequence;
|
||||
private final AtomicReference<GenerationSessionState> current;
|
||||
private final Object drainMonitor;
|
||||
|
||||
public GenerationSessionManager() {
|
||||
this.sessionSequence = new AtomicLong(0L);
|
||||
this.current = new AtomicReference<>(new GenerationSessionState(nextSessionId(), new AtomicBoolean(true), new AtomicInteger(0), new AtomicBoolean(false), new AtomicReference<>(null)));
|
||||
this.drainMonitor = new Object();
|
||||
}
|
||||
|
||||
public GenerationSessionLease acquire(String operation) throws GenerationSessionException {
|
||||
while (true) {
|
||||
GenerationSessionState state = current.get();
|
||||
if (state == null || !state.accepting().get()) {
|
||||
throw rejected(operation, state == null ? null : state);
|
||||
}
|
||||
|
||||
state.activeLeases().incrementAndGet();
|
||||
if (state != current.get()) {
|
||||
state.activeLeases().decrementAndGet();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!state.accepting().get()) {
|
||||
releaseLease(state);
|
||||
throw rejected(operation, state);
|
||||
}
|
||||
|
||||
return new GenerationSessionLease(this, state, state.sessionId());
|
||||
}
|
||||
}
|
||||
|
||||
public long currentSessionId() {
|
||||
GenerationSessionState state = current.get();
|
||||
return state == null ? 0L : state.sessionId();
|
||||
}
|
||||
|
||||
public int activeLeases() {
|
||||
GenerationSessionState state = current.get();
|
||||
return state == null ? 0 : state.activeLeases().get();
|
||||
}
|
||||
|
||||
public void sealAndAwait(String reason, long timeoutMs) throws GenerationSessionException {
|
||||
sealAndAwait(reason, timeoutMs, false);
|
||||
}
|
||||
|
||||
public void sealAndAwait(String reason, long timeoutMs, boolean teardown) throws GenerationSessionException {
|
||||
GenerationSessionState state = current.get();
|
||||
if (state == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.accepting().set(false);
|
||||
state.teardown().set(teardown);
|
||||
state.sealReason().set(reason);
|
||||
long deadline = System.currentTimeMillis() + Math.max(0L, timeoutMs);
|
||||
synchronized (drainMonitor) {
|
||||
while (state.activeLeases().get() > 0) {
|
||||
long remaining = deadline - System.currentTimeMillis();
|
||||
if (remaining <= 0L) {
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
drainMonitor.wait(remaining);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new GenerationSessionException("Generation session " + state.sessionId() + " was interrupted while draining for " + reason + ".", teardown);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.activeLeases().get() > 0) {
|
||||
throw new GenerationSessionException("Generation session " + state.sessionId() + " failed to drain for " + reason + " after " + timeoutMs + "ms. Active leases=" + state.activeLeases().get() + ".", teardown);
|
||||
}
|
||||
}
|
||||
|
||||
public void activateNextSession() {
|
||||
current.set(new GenerationSessionState(nextSessionId(), new AtomicBoolean(true), new AtomicInteger(0), new AtomicBoolean(false), new AtomicReference<>(null)));
|
||||
}
|
||||
|
||||
private long nextSessionId() {
|
||||
return sessionSequence.incrementAndGet();
|
||||
}
|
||||
|
||||
void releaseLease(GenerationSessionState state) {
|
||||
int remaining = state.activeLeases().decrementAndGet();
|
||||
if (remaining <= 0) {
|
||||
synchronized (drainMonitor) {
|
||||
drainMonitor.notifyAll();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private GenerationSessionException rejected(String operation, GenerationSessionState state) {
|
||||
long sessionId = state == null ? currentSessionId() : state.sessionId();
|
||||
boolean teardown = state != null && state.teardown().get();
|
||||
String reason = state == null ? null : state.sealReason().get();
|
||||
if (teardown && reason != null && !reason.isBlank()) {
|
||||
return new GenerationSessionException("Generation session " + sessionId + " rejected new work for " + operation + " during " + reason + ".", true);
|
||||
}
|
||||
|
||||
return new GenerationSessionException("Generation session " + sessionId + " rejected new work for " + operation + ".", teardown);
|
||||
}
|
||||
|
||||
record GenerationSessionState(long sessionId, AtomicBoolean accepting, AtomicInteger activeLeases, AtomicBoolean teardown, AtomicReference<String> sealReason) {
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import art.arcane.iris.core.tools.IrisToolbelt;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisObject;
|
||||
import art.arcane.iris.engine.object.IrisRegion;
|
||||
import art.arcane.iris.util.project.context.IrisContext;
|
||||
import art.arcane.iris.util.project.context.ChunkContext;
|
||||
import art.arcane.iris.util.common.format.C;
|
||||
import art.arcane.volmlib.util.format.Form;
|
||||
@@ -90,7 +91,12 @@ public interface Locator<T> {
|
||||
static Locator<IrisBiome> caveOrMantleBiome(String loadKey) {
|
||||
return (e, c) -> {
|
||||
AtomicBoolean found = new AtomicBoolean(false);
|
||||
e.generateMatter(c.getX(), c.getZ(), true, new ChunkContext(c.getX() << 4, c.getZ() << 4, e.getComplex(), false));
|
||||
try (GenerationSessionLease lease = e.acquireGenerationLease("locator_generate_matter")) {
|
||||
IrisContext.getOr(e).setGenerationSessionId(lease.sessionId());
|
||||
e.generateMatter(c.getX(), c.getZ(), true, new ChunkContext(c.getX() << 4, c.getZ() << 4, e.getComplex(), lease.sessionId(), false, ChunkContext.PrefillPlan.NONE, null));
|
||||
} catch (GenerationSessionException sessionException) {
|
||||
throw new IllegalStateException(sessionException);
|
||||
}
|
||||
e.getMantle().getMantle().iterateChunk(c.getX(), c.getZ(), MatterCavern.class, (x, y, z, t) -> {
|
||||
if (found.get()) {
|
||||
return;
|
||||
|
||||
@@ -19,4 +19,11 @@
|
||||
package art.arcane.iris.engine.framework;
|
||||
|
||||
public class WrongEngineBroException extends Exception {
|
||||
public WrongEngineBroException() {
|
||||
super();
|
||||
}
|
||||
|
||||
public WrongEngineBroException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
package art.arcane.iris.engine.mantle.components;
|
||||
|
||||
import art.arcane.iris.engine.IrisComplex;
|
||||
import art.arcane.iris.engine.data.cache.Cache;
|
||||
import art.arcane.iris.engine.mantle.ComponentFlag;
|
||||
import art.arcane.iris.engine.mantle.EngineMantle;
|
||||
@@ -81,12 +82,13 @@ public class MantleCarvingComponent extends IrisMantleComponent {
|
||||
|
||||
@Override
|
||||
public void generateLayer(MantleWriter writer, int x, int z, ChunkContext context) {
|
||||
IrisComplex complex = context.getComplex();
|
||||
IrisDimensionCarvingResolver.State resolverState = new IrisDimensionCarvingResolver.State();
|
||||
Long2ObjectOpenHashMap<IrisBiome> caveBiomeCache = new Long2ObjectOpenHashMap<>(FIELD_SIZE * FIELD_SIZE);
|
||||
BlendScratch blendScratch = BLEND_SCRATCH.get();
|
||||
int[] chunkSurfaceHeights = prepareChunkSurfaceHeights(x, z, context, blendScratch.chunkSurfaceHeights);
|
||||
PrecisionStopwatch resolveStopwatch = PrecisionStopwatch.start();
|
||||
List<WeightedProfile> weightedProfiles = resolveWeightedProfiles(x, z, resolverState, caveBiomeCache);
|
||||
List<WeightedProfile> weightedProfiles = resolveWeightedProfiles(x, z, complex, resolverState, caveBiomeCache);
|
||||
getEngineMantle().getEngine().getMetrics().getCarveResolve().put(resolveStopwatch.getMilliseconds());
|
||||
for (WeightedProfile weightedProfile : weightedProfiles) {
|
||||
carveProfile(weightedProfile, writer, x, z, chunkSurfaceHeights);
|
||||
@@ -99,7 +101,7 @@ public class MantleCarvingComponent extends IrisMantleComponent {
|
||||
carver.carve(writer, cx, cz, weightedProfile.columnWeights, MIN_WEIGHT, THRESHOLD_PENALTY, weightedProfile.worldYRange, chunkSurfaceHeights);
|
||||
}
|
||||
|
||||
private List<WeightedProfile> resolveWeightedProfiles(int chunkX, int chunkZ, IrisDimensionCarvingResolver.State resolverState, Long2ObjectOpenHashMap<IrisBiome> caveBiomeCache) {
|
||||
private List<WeightedProfile> resolveWeightedProfiles(int chunkX, int chunkZ, IrisComplex complex, IrisDimensionCarvingResolver.State resolverState, Long2ObjectOpenHashMap<IrisBiome> caveBiomeCache) {
|
||||
BlendScratch blendScratch = BLEND_SCRATCH.get();
|
||||
IrisCaveProfile[] profileField = blendScratch.profileField;
|
||||
Map<IrisCaveProfile, double[]> tileProfileWeights = blendScratch.tileProfileWeights;
|
||||
@@ -107,7 +109,7 @@ public class MantleCarvingComponent extends IrisMantleComponent {
|
||||
IrisCaveProfile[] kernelProfiles = blendScratch.kernelProfiles;
|
||||
double[] kernelProfileWeights = blendScratch.kernelProfileWeights;
|
||||
activeProfiles.clear();
|
||||
fillProfileField(profileField, chunkX, chunkZ, resolverState, caveBiomeCache);
|
||||
fillProfileField(profileField, chunkX, chunkZ, complex, resolverState, caveBiomeCache);
|
||||
|
||||
for (int tileX = 0; tileX < TILE_COUNT; tileX++) {
|
||||
for (int tileZ = 0; tileZ < TILE_COUNT; tileZ++) {
|
||||
@@ -313,7 +315,7 @@ public class MantleCarvingComponent extends IrisMantleComponent {
|
||||
return (tileX * TILE_COUNT) + tileZ;
|
||||
}
|
||||
|
||||
private void fillProfileField(IrisCaveProfile[] profileField, int chunkX, int chunkZ, IrisDimensionCarvingResolver.State resolverState, Long2ObjectOpenHashMap<IrisBiome> caveBiomeCache) {
|
||||
private void fillProfileField(IrisCaveProfile[] profileField, int chunkX, int chunkZ, IrisComplex complex, IrisDimensionCarvingResolver.State resolverState, Long2ObjectOpenHashMap<IrisBiome> caveBiomeCache) {
|
||||
int startX = (chunkX << 4) - BLEND_RADIUS;
|
||||
int startZ = (chunkZ << 4) - BLEND_RADIUS;
|
||||
|
||||
@@ -321,7 +323,7 @@ public class MantleCarvingComponent extends IrisMantleComponent {
|
||||
int worldX = startX + fieldX;
|
||||
for (int fieldZ = 0; fieldZ < FIELD_SIZE; fieldZ++) {
|
||||
int worldZ = startZ + fieldZ;
|
||||
profileField[(fieldX * FIELD_SIZE) + fieldZ] = resolveColumnProfile(worldX, worldZ, resolverState, caveBiomeCache);
|
||||
profileField[(fieldX * FIELD_SIZE) + fieldZ] = resolveColumnProfile(worldX, worldZ, complex, resolverState, caveBiomeCache);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -336,14 +338,14 @@ public class MantleCarvingComponent extends IrisMantleComponent {
|
||||
return -1;
|
||||
}
|
||||
|
||||
private IrisCaveProfile resolveColumnProfile(int worldX, int worldZ, IrisDimensionCarvingResolver.State resolverState, Long2ObjectOpenHashMap<IrisBiome> caveBiomeCache) {
|
||||
private IrisCaveProfile resolveColumnProfile(int worldX, int worldZ, IrisComplex complex, IrisDimensionCarvingResolver.State resolverState, Long2ObjectOpenHashMap<IrisBiome> caveBiomeCache) {
|
||||
IrisCaveProfile resolved = null;
|
||||
IrisCaveProfile dimensionProfile = getDimension().getCaveProfile();
|
||||
if (isProfileEnabled(dimensionProfile)) {
|
||||
resolved = dimensionProfile;
|
||||
}
|
||||
|
||||
IrisRegion region = getComplex().getRegionStream().get(worldX, worldZ);
|
||||
IrisRegion region = complex.getRegionStream().get(worldX, worldZ);
|
||||
if (region != null) {
|
||||
IrisCaveProfile regionProfile = region.getCaveProfile();
|
||||
if (isProfileEnabled(regionProfile)) {
|
||||
@@ -351,7 +353,7 @@ public class MantleCarvingComponent extends IrisMantleComponent {
|
||||
}
|
||||
}
|
||||
|
||||
IrisBiome surfaceBiome = getComplex().getTrueBiomeStream().get(worldX, worldZ);
|
||||
IrisBiome surfaceBiome = complex.getTrueBiomeStream().get(worldX, worldZ);
|
||||
if (surfaceBiome != null) {
|
||||
IrisCaveProfile surfaceProfile = surfaceBiome.getCaveProfile();
|
||||
if (isProfileEnabled(surfaceProfile)) {
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
package art.arcane.iris.engine.mantle.components;
|
||||
|
||||
import art.arcane.iris.engine.IrisComplex;
|
||||
import art.arcane.iris.engine.data.cache.Cache;
|
||||
import art.arcane.iris.engine.mantle.ComponentFlag;
|
||||
import art.arcane.iris.engine.mantle.EngineMantle;
|
||||
@@ -39,11 +40,12 @@ public class MantleFluidBodyComponent extends IrisMantleComponent {
|
||||
|
||||
@Override
|
||||
public void generateLayer(MantleWriter writer, int x, int z, ChunkContext context) {
|
||||
IrisComplex complex = context.getComplex();
|
||||
RNG rng = new RNG(Cache.key(x, z) + seed() + 405666);
|
||||
int xxx = 8 + (x << 4);
|
||||
int zzz = 8 + (z << 4);
|
||||
IrisRegion region = getComplex().getRegionStream().get(xxx, zzz);
|
||||
IrisBiome biome = getComplex().getTrueBiomeStream().get(xxx, zzz);
|
||||
IrisRegion region = complex.getRegionStream().get(xxx, zzz);
|
||||
IrisBiome biome = complex.getTrueBiomeStream().get(xxx, zzz);
|
||||
generate(writer, rng, x, z, region, biome);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,8 +20,8 @@ package art.arcane.iris.engine.mantle.components;
|
||||
|
||||
import art.arcane.iris.Iris;
|
||||
import art.arcane.iris.core.IrisSettings;
|
||||
import art.arcane.iris.core.tools.IrisToolbelt;
|
||||
import art.arcane.iris.engine.data.cache.Cache;
|
||||
import art.arcane.iris.engine.IrisComplex;
|
||||
import art.arcane.iris.engine.mantle.ComponentFlag;
|
||||
import art.arcane.iris.engine.mantle.EngineMantle;
|
||||
import art.arcane.iris.engine.mantle.IrisMantleComponent;
|
||||
@@ -40,8 +40,6 @@ import art.arcane.volmlib.util.math.RNG;
|
||||
import art.arcane.volmlib.util.matter.MatterStructurePOI;
|
||||
import art.arcane.iris.util.project.noise.CNG;
|
||||
import art.arcane.iris.util.project.noise.NoiseType;
|
||||
import art.arcane.iris.util.common.parallel.BurstExecutor;
|
||||
import art.arcane.iris.util.common.scheduling.J;
|
||||
import org.bukkit.util.BlockVector;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -64,12 +62,13 @@ public class MantleObjectComponent extends IrisMantleComponent {
|
||||
|
||||
@Override
|
||||
public void generateLayer(MantleWriter writer, int x, int z, ChunkContext context) {
|
||||
IrisComplex complex = context.getComplex();
|
||||
boolean traceRegen = isRegenTraceThread();
|
||||
RNG rng = applyNoise(x, z, Cache.key(x, z) + seed());
|
||||
int xxx = 8 + (x << 4);
|
||||
int zzz = 8 + (z << 4);
|
||||
IrisRegion region = getComplex().getRegionStream().get(xxx, zzz);
|
||||
IrisBiome surfaceBiome = getComplex().getTrueBiomeStream().get(xxx, zzz);
|
||||
IrisRegion region = complex.getRegionStream().get(xxx, zzz);
|
||||
IrisBiome surfaceBiome = complex.getTrueBiomeStream().get(xxx, zzz);
|
||||
int surfaceY = getEngineMantle().getEngine().getHeight(xxx, zzz, true);
|
||||
IrisBiome caveBiome = resolveCaveObjectBiome(xxx, zzz, surfaceY, surfaceBiome);
|
||||
if (traceRegen) {
|
||||
@@ -82,7 +81,7 @@ public class MantleObjectComponent extends IrisMantleComponent {
|
||||
+ " regionSurfacePlacers=" + region.getSurfaceObjects().size()
|
||||
+ " regionCavePlacers=" + region.getCarvingObjects().size());
|
||||
}
|
||||
ObjectPlacementSummary summary = placeObjects(writer, rng, x, z, surfaceBiome, caveBiome, region, traceRegen);
|
||||
ObjectPlacementSummary summary = placeObjects(writer, rng, x, z, surfaceBiome, caveBiome, region, complex, traceRegen);
|
||||
if (traceRegen) {
|
||||
Iris.info("Regen object layer done: chunk=" + x + "," + z
|
||||
+ " biomeSurfacePlacersChecked=" + summary.biomeSurfacePlacersChecked()
|
||||
@@ -142,7 +141,7 @@ public class MantleObjectComponent extends IrisMantleComponent {
|
||||
}
|
||||
|
||||
@ChunkCoordinates
|
||||
private ObjectPlacementSummary placeObjects(MantleWriter writer, RNG rng, int x, int z, IrisBiome surfaceBiome, IrisBiome caveBiome, IrisRegion region, boolean traceRegen) {
|
||||
private ObjectPlacementSummary placeObjects(MantleWriter writer, RNG rng, int x, int z, IrisBiome surfaceBiome, IrisBiome caveBiome, IrisRegion region, IrisComplex complex, boolean traceRegen) {
|
||||
int biomeSurfaceChecked = 0;
|
||||
int biomeSurfaceTriggered = 0;
|
||||
int biomeCaveChecked = 0;
|
||||
@@ -175,7 +174,7 @@ public class MantleObjectComponent extends IrisMantleComponent {
|
||||
if (chance) {
|
||||
biomeSurfaceTriggered++;
|
||||
try {
|
||||
ObjectPlacementResult result = placeObject(writer, rng, x << 4, z << 4, i, biomeSurfaceExclusionDepth, traceRegen, x, z, "biome-surface");
|
||||
ObjectPlacementResult result = placeObject(writer, rng, x << 4, z << 4, i, biomeSurfaceExclusionDepth, complex, traceRegen, x, z, "biome-surface");
|
||||
attempts += result.attempts();
|
||||
placed += result.placed();
|
||||
rejected += result.rejected();
|
||||
@@ -209,7 +208,7 @@ public class MantleObjectComponent extends IrisMantleComponent {
|
||||
if (chance) {
|
||||
biomeCaveTriggered++;
|
||||
try {
|
||||
ObjectPlacementResult result = placeCaveObject(writer, rng, x, z, i, biomeCaveProfile, traceRegen, x, z, "biome-cave");
|
||||
ObjectPlacementResult result = placeCaveObject(writer, rng, x, z, i, biomeCaveProfile, complex, traceRegen, x, z, "biome-cave");
|
||||
attempts += result.attempts();
|
||||
placed += result.placed();
|
||||
rejected += result.rejected();
|
||||
@@ -240,7 +239,7 @@ public class MantleObjectComponent extends IrisMantleComponent {
|
||||
if (chance) {
|
||||
regionSurfaceTriggered++;
|
||||
try {
|
||||
ObjectPlacementResult result = placeObject(writer, rng, x << 4, z << 4, i, regionSurfaceExclusionDepth, traceRegen, x, z, "region-surface");
|
||||
ObjectPlacementResult result = placeObject(writer, rng, x << 4, z << 4, i, regionSurfaceExclusionDepth, complex, traceRegen, x, z, "region-surface");
|
||||
attempts += result.attempts();
|
||||
placed += result.placed();
|
||||
rejected += result.rejected();
|
||||
@@ -274,7 +273,7 @@ public class MantleObjectComponent extends IrisMantleComponent {
|
||||
if (chance) {
|
||||
regionCaveTriggered++;
|
||||
try {
|
||||
ObjectPlacementResult result = placeCaveObject(writer, rng, x, z, i, regionCaveProfile, traceRegen, x, z, "region-cave");
|
||||
ObjectPlacementResult result = placeCaveObject(writer, rng, x, z, i, regionCaveProfile, complex, traceRegen, x, z, "region-cave");
|
||||
attempts += result.attempts();
|
||||
placed += result.placed();
|
||||
rejected += result.rejected();
|
||||
@@ -316,6 +315,7 @@ public class MantleObjectComponent extends IrisMantleComponent {
|
||||
int z,
|
||||
IrisObjectPlacement objectPlacement,
|
||||
int surfaceObjectExclusionDepth,
|
||||
IrisComplex complex,
|
||||
boolean traceRegen,
|
||||
int chunkX,
|
||||
int chunkZ,
|
||||
@@ -330,7 +330,7 @@ public class MantleObjectComponent extends IrisMantleComponent {
|
||||
|
||||
for (int i = 0; i < density; i++) {
|
||||
attempts++;
|
||||
IrisObject v = objectPlacement.getScale().get(rng, objectPlacement.getObject(getComplex(), rng));
|
||||
IrisObject v = objectPlacement.getScale().get(rng, objectPlacement.getObject(complex, rng));
|
||||
if (v == null) {
|
||||
nullObjects++;
|
||||
if (traceRegen) {
|
||||
@@ -398,6 +398,7 @@ public class MantleObjectComponent extends IrisMantleComponent {
|
||||
int chunkZ,
|
||||
IrisObjectPlacement objectPlacement,
|
||||
IrisCaveProfile caveProfile,
|
||||
IrisComplex complex,
|
||||
boolean traceRegen,
|
||||
int metricChunkX,
|
||||
int metricChunkZ,
|
||||
@@ -419,7 +420,7 @@ public class MantleObjectComponent extends IrisMantleComponent {
|
||||
|
||||
for (int i = 0; i < density; i++) {
|
||||
attempts++;
|
||||
IrisObject object = objectPlacement.getScale().get(rng, objectPlacement.getObject(getComplex(), rng));
|
||||
IrisObject object = objectPlacement.getScale().get(rng, objectPlacement.getObject(complex, rng));
|
||||
if (object == null) {
|
||||
nullObjects++;
|
||||
if (traceRegen) {
|
||||
@@ -903,15 +904,15 @@ public class MantleObjectComponent extends IrisMantleComponent {
|
||||
}
|
||||
|
||||
protected int computeRadius() {
|
||||
var dimension = getDimension();
|
||||
IrisDimension dimension = getDimension();
|
||||
|
||||
AtomicInteger xg = new AtomicInteger();
|
||||
AtomicInteger zg = new AtomicInteger();
|
||||
|
||||
KSet<String> objects = new KSet<>();
|
||||
KMap<IrisObjectScale, KList<String>> scalars = new KMap<>();
|
||||
for (var region : dimension.getAllRegions(this::getData)) {
|
||||
for (var j : region.getObjects()) {
|
||||
for (IrisRegion region : dimension.getAllRegions(this::getData)) {
|
||||
for (IrisObjectPlacement j : region.getObjects()) {
|
||||
if (j.getScale().canScaleBeyond()) {
|
||||
scalars.put(j.getScale(), j.getPlace());
|
||||
} else {
|
||||
@@ -919,8 +920,8 @@ public class MantleObjectComponent extends IrisMantleComponent {
|
||||
}
|
||||
}
|
||||
}
|
||||
for (var biome : dimension.getAllBiomes(this::getData)) {
|
||||
for (var j : biome.getObjects()) {
|
||||
for (IrisBiome biome : dimension.getAllBiomes(this::getData)) {
|
||||
for (IrisObjectPlacement j : biome.getObjects()) {
|
||||
if (j.getScale().canScaleBeyond()) {
|
||||
scalars.put(j.getScale(), j.getPlace());
|
||||
} else {
|
||||
@@ -929,93 +930,59 @@ public class MantleObjectComponent extends IrisMantleComponent {
|
||||
}
|
||||
}
|
||||
|
||||
BurstExecutor e = getEngineMantle().getTarget().getBurster().burst(objects.size());
|
||||
boolean maintenanceFolia = false;
|
||||
if (J.isFolia()) {
|
||||
var world = getEngineMantle().getEngine().getWorld().realWorld();
|
||||
maintenanceFolia = world != null && IrisToolbelt.isWorldMaintenanceActive(world);
|
||||
}
|
||||
if (maintenanceFolia) {
|
||||
Iris.info("MantleObjectComponent radius scan using single-threaded mode during maintenance regen.");
|
||||
e.setMulticore(false);
|
||||
}
|
||||
KMap<String, BlockVector> sizeCache = new KMap<>();
|
||||
for (String i : objects) {
|
||||
e.queue(() -> {
|
||||
try {
|
||||
BlockVector bv = sizeCache.computeIfAbsent(i, (k) -> {
|
||||
try {
|
||||
return IrisObject.sampleSize(getData().getObjectLoader().findFile(i));
|
||||
} catch (IOException ex) {
|
||||
Iris.reportError(ex);
|
||||
ex.printStackTrace();
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
if (bv == null) {
|
||||
throw new RuntimeException();
|
||||
}
|
||||
|
||||
if (Math.max(bv.getBlockX(), bv.getBlockZ()) > 128) {
|
||||
Iris.warn("Object " + i + " has a large size (" + bv + ") and may increase memory usage!");
|
||||
}
|
||||
|
||||
synchronized (xg) {
|
||||
xg.getAndSet(Math.max(bv.getBlockX(), xg.get()));
|
||||
}
|
||||
|
||||
synchronized (zg) {
|
||||
zg.getAndSet(Math.max(bv.getBlockZ(), zg.get()));
|
||||
}
|
||||
} catch (Throwable ed) {
|
||||
Iris.reportError(ed);
|
||||
|
||||
}
|
||||
});
|
||||
updateRadiusBounds(sizeCache, xg, zg, i, 1D);
|
||||
}
|
||||
|
||||
for (Map.Entry<IrisObjectScale, KList<String>> entry : scalars.entrySet()) {
|
||||
double ms = entry.getKey().getMaximumScale();
|
||||
for (String j : entry.getValue()) {
|
||||
e.queue(() -> {
|
||||
try {
|
||||
BlockVector bv = sizeCache.computeIfAbsent(j, (k) -> {
|
||||
try {
|
||||
return IrisObject.sampleSize(getData().getObjectLoader().findFile(j));
|
||||
} catch (IOException ioException) {
|
||||
Iris.reportError(ioException);
|
||||
ioException.printStackTrace();
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
if (bv == null) {
|
||||
throw new RuntimeException();
|
||||
}
|
||||
|
||||
if (Math.max(bv.getBlockX(), bv.getBlockZ()) > 128) {
|
||||
Iris.warn("Object " + j + " has a large size (" + bv + ") and may increase memory usage! (Object scaled up to " + Form.pc(ms, 2) + ")");
|
||||
}
|
||||
|
||||
synchronized (xg) {
|
||||
xg.getAndSet((int) Math.max(Math.ceil(bv.getBlockX() * ms), xg.get()));
|
||||
}
|
||||
|
||||
synchronized (zg) {
|
||||
zg.getAndSet((int) Math.max(Math.ceil(bv.getBlockZ() * ms), zg.get()));
|
||||
}
|
||||
} catch (Throwable ee) {
|
||||
Iris.reportError(ee);
|
||||
|
||||
}
|
||||
});
|
||||
updateRadiusBounds(sizeCache, xg, zg, j, ms);
|
||||
}
|
||||
}
|
||||
|
||||
e.complete();
|
||||
return Math.max(xg.get(), zg.get());
|
||||
}
|
||||
|
||||
private void updateRadiusBounds(
|
||||
KMap<String, BlockVector> sizeCache,
|
||||
AtomicInteger xg,
|
||||
AtomicInteger zg,
|
||||
String objectKey,
|
||||
double scale
|
||||
) {
|
||||
try {
|
||||
BlockVector bv = loadObjectSize(sizeCache, objectKey);
|
||||
if (bv == null) {
|
||||
throw new RuntimeException();
|
||||
}
|
||||
|
||||
if (Math.max(bv.getBlockX(), bv.getBlockZ()) > 128) {
|
||||
if (scale > 1D) {
|
||||
Iris.warn("Object " + objectKey + " has a large size (" + bv + ") and may increase memory usage! (Object scaled up to " + Form.pc(scale, 2) + ")");
|
||||
} else {
|
||||
Iris.warn("Object " + objectKey + " has a large size (" + bv + ") and may increase memory usage!");
|
||||
}
|
||||
}
|
||||
|
||||
xg.getAndSet(Math.max((int) Math.ceil(bv.getBlockX() * scale), xg.get()));
|
||||
zg.getAndSet(Math.max((int) Math.ceil(bv.getBlockZ() * scale), zg.get()));
|
||||
} catch (Throwable e) {
|
||||
Iris.reportError(e);
|
||||
}
|
||||
}
|
||||
|
||||
private BlockVector loadObjectSize(KMap<String, BlockVector> sizeCache, String objectKey) {
|
||||
return sizeCache.computeIfAbsent(objectKey, k -> {
|
||||
try {
|
||||
return IrisObject.sampleSize(getData().getObjectLoader().findFile(objectKey));
|
||||
} catch (IOException e) {
|
||||
Iris.reportError(e);
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package art.arcane.iris.engine.object;
|
||||
|
||||
import art.arcane.iris.util.common.data.B;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
|
||||
import java.util.function.Function;
|
||||
|
||||
final class BlockDataMergeSupport {
|
||||
private BlockDataMergeSupport() {
|
||||
}
|
||||
|
||||
static BlockData merge(BlockData base, BlockData update) {
|
||||
return merge(base, update, B::get);
|
||||
}
|
||||
|
||||
static BlockData merge(BlockData base, BlockData update, Function<String, BlockData> resolver) {
|
||||
try {
|
||||
return base.merge(update);
|
||||
} catch (IllegalArgumentException e) {
|
||||
BlockData normalizedBase = resolve(base, resolver);
|
||||
BlockData normalizedUpdate = resolve(update, resolver);
|
||||
|
||||
if (normalizedBase != null && normalizedUpdate != null) {
|
||||
try {
|
||||
return normalizedBase.merge(normalizedUpdate);
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
return normalizedUpdate;
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedUpdate != null) {
|
||||
return normalizedUpdate;
|
||||
}
|
||||
|
||||
return update;
|
||||
}
|
||||
}
|
||||
|
||||
private static BlockData resolve(BlockData data, Function<String, BlockData> resolver) {
|
||||
if (data == null || resolver == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String serialized = data.getAsString(false);
|
||||
if (serialized == null || serialized.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return resolver.apply(serialized);
|
||||
}
|
||||
}
|
||||
@@ -564,33 +564,52 @@ public class IrisDimension extends IrisRegistrant {
|
||||
}
|
||||
}
|
||||
|
||||
public Dimension getBaseDimension() {
|
||||
return switch (getEnvironment()) {
|
||||
case NETHER -> Dimension.NETHER;
|
||||
case THE_END -> Dimension.END;
|
||||
default -> Dimension.OVERWORLD;
|
||||
};
|
||||
}
|
||||
|
||||
public String getDimensionTypeKey() {
|
||||
return getDimensionType().key();
|
||||
}
|
||||
|
||||
public IrisDimensionType getDimensionType() {
|
||||
return new IrisDimensionType(getBaseDimension(), getDimensionOptions(), getLogicalHeight(), getMaxHeight() - getMinHeight(), getMinHeight());
|
||||
}
|
||||
|
||||
public void installDimensionType(IDataFixer fixer, KList<File> folders) {
|
||||
IrisDimensionType type = getDimensionType();
|
||||
String json = type.toJson(fixer);
|
||||
|
||||
Iris.verbose(" Installing Data Pack Dimension Type: \"iris:" + type.key() + '"');
|
||||
for (File datapacks : folders) {
|
||||
File output = new File(datapacks, "iris/data/iris/dimension_type/" + type.key() + ".json");
|
||||
output.getParentFile().mkdirs();
|
||||
try {
|
||||
IO.writeAll(output, json);
|
||||
} catch (IOException e) {
|
||||
public Dimension getBaseDimension() {
|
||||
return switch (getEnvironment()) {
|
||||
case NETHER -> Dimension.NETHER;
|
||||
case THE_END -> Dimension.END;
|
||||
default -> Dimension.OVERWORLD;
|
||||
};
|
||||
}
|
||||
|
||||
public String getDimensionTypeKey() {
|
||||
return sanitizeDimensionTypeKeyValue(getLoadKey());
|
||||
}
|
||||
|
||||
public static String sanitizeDimensionTypeKeyValue(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return "dimension";
|
||||
}
|
||||
|
||||
String sanitized = value.trim().toLowerCase(Locale.ROOT).replace("\\", "/");
|
||||
sanitized = sanitized.replaceAll("[^a-z0-9_\\-./]", "_");
|
||||
sanitized = sanitized.replaceAll("/+", "/");
|
||||
sanitized = sanitized.replaceAll("^/+", "");
|
||||
sanitized = sanitized.replaceAll("/+$", "");
|
||||
if (sanitized.contains("..")) {
|
||||
sanitized = sanitized.replace("..", "_");
|
||||
}
|
||||
|
||||
sanitized = sanitized.replace("/", "_");
|
||||
return sanitized.isBlank() ? "dimension" : sanitized;
|
||||
}
|
||||
|
||||
public IrisDimensionType getDimensionType() {
|
||||
return new IrisDimensionType(getBaseDimension(), getDimensionOptions(), getLogicalHeight(), getMaxHeight() - getMinHeight(), getMinHeight());
|
||||
}
|
||||
|
||||
public void installDimensionType(IDataFixer fixer, KList<File> folders) {
|
||||
IrisDimensionType type = getDimensionType();
|
||||
String json = type.toJson(fixer);
|
||||
String dimensionTypeKey = getDimensionTypeKey();
|
||||
|
||||
Iris.verbose(" Installing Data Pack Dimension Type: \"iris:" + dimensionTypeKey + '"');
|
||||
for (File datapacks : folders) {
|
||||
File output = new File(datapacks, "iris/data/iris/dimension_type/" + dimensionTypeKey + ".json");
|
||||
output.getParentFile().mkdirs();
|
||||
try {
|
||||
IO.writeAll(output, json);
|
||||
} catch (IOException e) {
|
||||
Iris.reportError(e);
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
@@ -969,7 +969,7 @@ public class IrisObject extends IrisRegistrant {
|
||||
BlockData newData = j.getReplace(rng, i.getX() + x, i.getY() + y, i.getZ() + z, rdata).clone();
|
||||
|
||||
if (newData.getMaterial() == data.getMaterial() && !(newData instanceof IrisCustomData || data instanceof IrisCustomData))
|
||||
data = data.merge(newData);
|
||||
data = BlockDataMergeSupport.merge(data, newData);
|
||||
else
|
||||
data = newData;
|
||||
|
||||
@@ -1093,7 +1093,7 @@ public class IrisObject extends IrisRegistrant {
|
||||
BlockData newData = j.getReplace(rng, i.getX() + x, i.getY() + y, i.getZ() + z, rdata).clone();
|
||||
|
||||
if (newData.getMaterial() == d.getMaterial()) {
|
||||
d = d.merge(newData);
|
||||
d = BlockDataMergeSupport.merge(d, newData);
|
||||
} else {
|
||||
d = newData;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import art.arcane.iris.engine.data.cache.AtomicCache;
|
||||
import art.arcane.iris.engine.data.chunk.TerrainChunk;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.EngineTarget;
|
||||
import art.arcane.iris.engine.framework.GenerationSessionException;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.engine.object.IrisWorld;
|
||||
import art.arcane.iris.engine.object.StudioMode;
|
||||
@@ -93,10 +94,12 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
|
||||
private final AtomicInteger a = new AtomicInteger(0);
|
||||
private final CompletableFuture<Integer> spawnChunks = new CompletableFuture<>();
|
||||
private final AtomicCache<EngineTarget> targetCache = new AtomicCache<>();
|
||||
private final AtomicReference<CompletableFuture<Void>> closeFuture = new AtomicReference<>();
|
||||
private volatile Engine engine;
|
||||
private volatile Looper hotloader;
|
||||
private volatile StudioMode lastMode;
|
||||
private volatile DummyBiomeProvider dummyBiomeProvider;
|
||||
private volatile boolean closing;
|
||||
@Setter
|
||||
private volatile StudioGenerator studioGenerator;
|
||||
|
||||
@@ -118,6 +121,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
|
||||
new KList<>(".iris"),
|
||||
new KList<>()
|
||||
);
|
||||
this.closing = false;
|
||||
Bukkit.getServer().getPluginManager().registerEvents(this, Iris.instance);
|
||||
}
|
||||
|
||||
@@ -142,6 +146,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
|
||||
try {
|
||||
INMS.get().inject(world.getSeed(), engine, world);
|
||||
Iris.info("Injected Iris Biome Source into " + world.getName());
|
||||
J.s(() -> updateSpawnLocation(world), 1);
|
||||
} catch (Throwable e) {
|
||||
Iris.reportError(e);
|
||||
Iris.error("Failed to inject biome source into " + world.getName());
|
||||
@@ -156,15 +161,65 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
|
||||
@Nullable
|
||||
@Override
|
||||
public Location getFixedSpawnLocation(@NotNull World world, @NotNull Random random) {
|
||||
Location location = new Location(world, 0, 64, 0);
|
||||
PaperLib.getChunkAtAsync(location)
|
||||
.thenAccept(c -> {
|
||||
World w = c.getWorld();
|
||||
if (!w.getSpawnLocation().equals(location))
|
||||
return;
|
||||
w.setSpawnLocation(location.add(0, w.getHighestBlockYAt(location) - 64, 0));
|
||||
});
|
||||
return location;
|
||||
return getInitialSpawnLocation(world);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Location getInitialSpawnLocation(World world) {
|
||||
int minY = world.getMinHeight() + 1;
|
||||
int maxY = world.getMaxHeight() - 2;
|
||||
int y = Math.max(minY, Math.min(maxY, 96));
|
||||
return new Location(world, 0.5D, y, 0.5D);
|
||||
}
|
||||
|
||||
private void updateSpawnLocation(World world) {
|
||||
Location initialSpawn = getInitialSpawnLocation(world);
|
||||
int chunkX = initialSpawn.getBlockX() >> 4;
|
||||
int chunkZ = initialSpawn.getBlockZ() >> 4;
|
||||
CompletableFuture<Chunk> chunkFuture = requestChunkAsync(world, chunkX, chunkZ, true);
|
||||
if (chunkFuture == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
chunkFuture.thenAccept(chunk ->
|
||||
J.runRegion(chunk.getWorld(), chunk.getX(), chunk.getZ(), () -> applySpawnLocation(chunk.getWorld(), initialSpawn)));
|
||||
}
|
||||
|
||||
private void applySpawnLocation(World world, Location initialSpawn) {
|
||||
Location currentSpawn = world.getSpawnLocation();
|
||||
if (currentSpawn == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!studio && (currentSpawn.getBlockX() != initialSpawn.getBlockX() || currentSpawn.getBlockZ() != initialSpawn.getBlockZ())) {
|
||||
return;
|
||||
}
|
||||
|
||||
int minY = world.getMinHeight() + 1;
|
||||
int maxY = world.getMaxHeight() - 2;
|
||||
int y = Math.max(minY, Math.min(maxY, world.getHighestBlockYAt(initialSpawn)));
|
||||
world.setSpawnLocation(new Location(world, initialSpawn.getX(), y, initialSpawn.getZ(), initialSpawn.getYaw(), initialSpawn.getPitch()));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private CompletableFuture<Chunk> requestChunkAsync(World world, int chunkX, int chunkZ, boolean generate) {
|
||||
try {
|
||||
Object result = World.class
|
||||
.getMethod("getChunkAtAsync", int.class, int.class, boolean.class)
|
||||
.invoke(world, chunkX, chunkZ, generate);
|
||||
if (result instanceof CompletableFuture<?>) {
|
||||
return (CompletableFuture<Chunk>) result;
|
||||
}
|
||||
if (PaperLib.isPaper()) {
|
||||
return CompletableFuture.failedFuture(new IllegalStateException("Paper World#getChunkAtAsync returned a non-future result."));
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
if (PaperLib.isPaper()) {
|
||||
return CompletableFuture.failedFuture(new IllegalStateException("Paper World#getChunkAtAsync is unavailable.", e));
|
||||
}
|
||||
}
|
||||
|
||||
return PaperLib.getChunkAtAsync(world, chunkX, chunkZ, generate);
|
||||
}
|
||||
|
||||
private void setupEngine() {
|
||||
@@ -530,18 +585,48 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
withExclusiveControl(() -> {
|
||||
if (isStudio()) {
|
||||
hotloader.interrupt();
|
||||
closeAsync();
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Void> closeAsync() {
|
||||
CompletableFuture<Void> existing = closeFuture.get();
|
||||
if (existing != null && !existing.isDone()) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
closing = true;
|
||||
CompletableFuture<Void> future = withExclusiveControlFuture(() -> {
|
||||
Looper activeHotloader = hotloader;
|
||||
hotloader = null;
|
||||
if (isStudio() && activeHotloader != null) {
|
||||
activeHotloader.interrupt();
|
||||
try {
|
||||
activeHotloader.join(1000L);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
Iris.reportError(e);
|
||||
}
|
||||
}
|
||||
|
||||
final Engine engine = getEngine();
|
||||
if (engine != null && !engine.isClosed())
|
||||
engine.close();
|
||||
Engine currentEngine = engine;
|
||||
if (currentEngine != null && !currentEngine.isClosed()) {
|
||||
currentEngine.close();
|
||||
}
|
||||
folder.clear();
|
||||
populators.clear();
|
||||
|
||||
});
|
||||
if (!closeFuture.compareAndSet(existing, future)) {
|
||||
CompletableFuture<Void> winningFuture = closeFuture.get();
|
||||
return winningFuture == null ? future : winningFuture;
|
||||
}
|
||||
|
||||
future.whenComplete((ignored, throwable) -> {
|
||||
if (throwable != null) {
|
||||
closeFuture.compareAndSet(future, null);
|
||||
}
|
||||
});
|
||||
return future;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -551,7 +636,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
|
||||
|
||||
@Override
|
||||
public void hotload() {
|
||||
if (!isStudio()) {
|
||||
if (!isStudio() || closing) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -570,6 +655,22 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
|
||||
});
|
||||
}
|
||||
|
||||
public CompletableFuture<Void> withExclusiveControlFuture(Runnable r) {
|
||||
CompletableFuture<Void> future = new CompletableFuture<>();
|
||||
J.a(() -> {
|
||||
try {
|
||||
loadLock.acquire(LOAD_LOCKS);
|
||||
r.run();
|
||||
future.complete(null);
|
||||
} catch (Throwable e) {
|
||||
future.completeExceptionally(e);
|
||||
} finally {
|
||||
loadLock.release(LOAD_LOCKS);
|
||||
}
|
||||
});
|
||||
return future;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void touch(World world) {
|
||||
getEngine(world);
|
||||
@@ -577,6 +678,10 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
|
||||
|
||||
@Override
|
||||
public void generateNoise(@NotNull WorldInfo world, @NotNull Random random, int x, int z, @NotNull ChunkGenerator.ChunkData d) {
|
||||
if (closing) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Engine engine = getEngine(world);
|
||||
computeStudioGenerator();
|
||||
@@ -592,6 +697,21 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
|
||||
}
|
||||
|
||||
Iris.debug("Generated " + x + " " + z);
|
||||
} catch (GenerationSessionException e) {
|
||||
if (closing || isExpectedTeardown(engine, e)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Iris.error("======================================");
|
||||
e.printStackTrace();
|
||||
Iris.reportErrorChunk(x, z, e, "CHUNK");
|
||||
Iris.error("======================================");
|
||||
|
||||
for (int i = 0; i < 16; i++) {
|
||||
for (int j = 0; j < 16; j++) {
|
||||
d.setBlock(i, 0, j, Material.RED_GLAZED_TERRACOTTA.createBlockData());
|
||||
}
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
Iris.error("======================================");
|
||||
e.printStackTrace();
|
||||
@@ -606,6 +726,19 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isExpectedTeardown(Engine currentEngine, Throwable throwable) {
|
||||
if (throwable instanceof GenerationSessionException generationSessionException && generationSessionException.isExpectedTeardown()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (currentEngine != null && currentEngine.isClosing()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
World realWorld = this.world.realWorld();
|
||||
return realWorld != null && IrisToolbelt.isWorldMaintenanceActive(realWorld);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getBaseHeight(@NotNull WorldInfo worldInfo, @NotNull Random random, int x, int z, @NotNull HeightMap heightMap) {
|
||||
Engine currentEngine = engine;
|
||||
|
||||
@@ -23,6 +23,7 @@ import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.EngineTarget;
|
||||
import art.arcane.iris.engine.framework.Hotloadable;
|
||||
import art.arcane.iris.util.common.data.DataProvider;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
@@ -53,9 +54,21 @@ public interface PlatformChunkGenerator extends Hotloadable, DataProvider {
|
||||
|
||||
void close();
|
||||
|
||||
default CompletableFuture<Void> closeAsync() {
|
||||
close();
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
|
||||
boolean isStudio();
|
||||
|
||||
default boolean isClosing() {
|
||||
return false;
|
||||
}
|
||||
|
||||
void touch(World world);
|
||||
|
||||
CompletableFuture<Integer> getSpawnChunks();
|
||||
|
||||
@Nullable
|
||||
Location getInitialSpawnLocation(World world);
|
||||
}
|
||||
|
||||
@@ -431,6 +431,10 @@ public class VolmitSender implements CommandSender {
|
||||
return m.removeDuplicates().convert((iff) -> iff.replaceAll("\\Q \\E", " ")).toString("\n");
|
||||
}
|
||||
|
||||
static String escapeMiniMessageQuotedText(String text) {
|
||||
return text.replace("\\", "\\\\").replace("'", "\\'");
|
||||
}
|
||||
|
||||
public void sendHeader(String name, int overrideLength) {
|
||||
int len = overrideLength;
|
||||
int h = name.length() + 2;
|
||||
@@ -469,7 +473,8 @@ public class VolmitSender implements CommandSender {
|
||||
if (v.getNodes().isNotEmpty()) {
|
||||
sendHeader(v.getPath() + (page > 0 ? (" {" + (page + 1) + "}") : ""));
|
||||
if (isPlayer() && v.getParent() != null) {
|
||||
sendMessageRaw("<hover:show_text:'" + "<#2b7a3f>Click to go back to <#32bfad>" + Form.capitalize(v.getParent().getName()) + " Help" + "'><click:run_command:" + v.getParent().getPath() + "><font:minecraft:uniform><#6fe98f>〈 Back</click></hover>");
|
||||
String backHover = escapeMiniMessageQuotedText("<#2b7a3f>Click to go back to <#32bfad>" + Form.capitalize(v.getParent().getName()) + " Help");
|
||||
sendMessageRaw("<hover:show_text:'" + backHover + "'><click:run_command:" + v.getParent().getPath() + "><font:minecraft:uniform><#6fe98f>〈 Back</click></hover>");
|
||||
}
|
||||
|
||||
AtomicBoolean next = new AtomicBoolean(false);
|
||||
@@ -481,13 +486,15 @@ public class VolmitSender implements CommandSender {
|
||||
int l = 75 - (page > 0 ? 10 : 0) - (next.get() ? 10 : 0);
|
||||
|
||||
if (page > 0) {
|
||||
s += "<hover:show_text:'<green>Click to go back to page " + page + "'><click:run_command:" + v.getPath() + " help=" + page + "><gradient:#34eb6b:#1f8f4d>〈 Page " + page + "</click></hover><reset> ";
|
||||
String previousPageHover = escapeMiniMessageQuotedText("<green>Click to go back to page " + page);
|
||||
s += "<hover:show_text:'" + previousPageHover + "'><click:run_command:" + v.getPath() + " help=" + page + "><gradient:#34eb6b:#1f8f4d>〈 Page " + page + "</click></hover><reset> ";
|
||||
}
|
||||
|
||||
s += "<reset><font:minecraft:uniform><strikethrough><gradient:#32bfad:#34eb6b>" + Form.repeat(" ", l) + "<reset>";
|
||||
|
||||
if (next.get()) {
|
||||
s += " <hover:show_text:'<green>Click to go to back to page " + (page + 2) + "'><click:run_command:" + v.getPath() + " help=" + (page + 2) + "><gradient:#1f8f4d:#34eb6b>Page " + (page + 2) + " ❭</click></hover>";
|
||||
String nextPageHover = escapeMiniMessageQuotedText("<green>Click to go to back to page " + (page + 2));
|
||||
s += " <hover:show_text:'" + nextPageHover + "'><click:run_command:" + v.getPath() + " help=" + (page + 2) + "><gradient:#1f8f4d:#34eb6b>Page " + (page + 2) + " ❭</click></hover>";
|
||||
}
|
||||
|
||||
sendMessageRaw(s);
|
||||
@@ -550,13 +557,11 @@ public class VolmitSender implements CommandSender {
|
||||
nUsage = "<#3fbe6f>✔ <#9de5b6><font:minecraft:uniform>This parameter is optional.";
|
||||
}
|
||||
String type = "<#4fbf7f>✢ <#8ad9af><font:minecraft:uniform>This parameter is of type " + p.getType().getSimpleName() + ".";
|
||||
String parameterHover = escapeMiniMessageQuotedText(nHoverTitle + newline + nDescription + newline + nUsage + newline + type);
|
||||
|
||||
nodes
|
||||
.append("<hover:show_text:'")
|
||||
.append(nHoverTitle).append(newline)
|
||||
.append(nDescription).append(newline)
|
||||
.append(nUsage).append(newline)
|
||||
.append(type)
|
||||
.append(parameterHover)
|
||||
.append("'>")
|
||||
.append(fullTitle)
|
||||
.append("</hover>");
|
||||
@@ -565,12 +570,16 @@ public class VolmitSender implements CommandSender {
|
||||
nodes = new StringBuilder("<gradient:#b7eecb:#9de5b6> - Category of Commands");
|
||||
}
|
||||
|
||||
return "<hover:show_text:'" +
|
||||
String entryHover = escapeMiniMessageQuotedText(
|
||||
hoverTitle + newline +
|
||||
description + newline +
|
||||
usage +
|
||||
suggestion +
|
||||
suggestions +
|
||||
description + newline +
|
||||
usage +
|
||||
suggestion +
|
||||
suggestions
|
||||
);
|
||||
|
||||
return "<hover:show_text:'" +
|
||||
entryHover +
|
||||
"'>" +
|
||||
"<click:" +
|
||||
onClick +
|
||||
|
||||
@@ -617,18 +617,38 @@ public class J {
|
||||
}
|
||||
|
||||
private static boolean runAsyncImmediate(Runnable runnable) {
|
||||
if (!isFolia()) {
|
||||
if (!isPluginEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isFolia()) {
|
||||
try {
|
||||
Bukkit.getScheduler().runTaskAsynchronously(Iris.instance, runnable);
|
||||
return true;
|
||||
} catch (Throwable e) {
|
||||
Iris.reportError(e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return FoliaScheduler.runAsync(Iris.instance, runnable);
|
||||
}
|
||||
|
||||
private static boolean runAsyncDelayed(Runnable runnable, int delayTicks) {
|
||||
if (!isFolia()) {
|
||||
if (!isPluginEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isFolia()) {
|
||||
try {
|
||||
Bukkit.getScheduler().runTaskLaterAsynchronously(Iris.instance, runnable, Math.max(0, delayTicks));
|
||||
return true;
|
||||
} catch (Throwable e) {
|
||||
Iris.reportError(e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return FoliaScheduler.runAsync(Iris.instance, runnable, Math.max(0, delayTicks));
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ import java.util.concurrent.CompletableFuture;
|
||||
public class ChunkContext {
|
||||
private final int x;
|
||||
private final int z;
|
||||
private final IrisComplex complex;
|
||||
private final long generationSessionId;
|
||||
private final ChunkedDataCache<Double> height;
|
||||
private final ChunkedDataCache<IrisBiome> biome;
|
||||
private final ChunkedDataCache<IrisBiome> cave;
|
||||
@@ -23,20 +25,26 @@ public class ChunkContext {
|
||||
private final ChunkedDataCache<IrisRegion> region;
|
||||
|
||||
public ChunkContext(int x, int z, IrisComplex complex) {
|
||||
this(x, z, complex, true, PrefillPlan.NO_CAVE, null);
|
||||
this(x, z, complex, 0L, true, PrefillPlan.NO_CAVE, null);
|
||||
}
|
||||
|
||||
public ChunkContext(int x, int z, IrisComplex complex, boolean cache) {
|
||||
this(x, z, complex, cache, PrefillPlan.NO_CAVE, null);
|
||||
this(x, z, complex, 0L, cache, PrefillPlan.NO_CAVE, null);
|
||||
}
|
||||
|
||||
public ChunkContext(int x, int z, IrisComplex complex, boolean cache, EngineMetrics metrics) {
|
||||
this(x, z, complex, cache, PrefillPlan.NO_CAVE, metrics);
|
||||
this(x, z, complex, 0L, cache, PrefillPlan.NO_CAVE, metrics);
|
||||
}
|
||||
|
||||
public ChunkContext(int x, int z, IrisComplex complex, boolean cache, PrefillPlan prefillPlan, EngineMetrics metrics) {
|
||||
this(x, z, complex, 0L, cache, prefillPlan, metrics);
|
||||
}
|
||||
|
||||
public ChunkContext(int x, int z, IrisComplex complex, long generationSessionId, boolean cache, PrefillPlan prefillPlan, EngineMetrics metrics) {
|
||||
this.x = x;
|
||||
this.z = z;
|
||||
this.complex = complex;
|
||||
this.generationSessionId = generationSessionId;
|
||||
this.height = new ChunkedDataCache<>(complex.getHeightStream(), x, z, cache);
|
||||
this.biome = new ChunkedDataCache<>(complex.getTrueBiomeStream(), x, z, cache);
|
||||
this.cave = new ChunkedDataCache<>(complex.getCaveBiomeStream(), x, z, cache);
|
||||
@@ -68,7 +76,7 @@ public class ChunkContext {
|
||||
fillTasks.add(new PrefillFillTask(cave));
|
||||
}
|
||||
|
||||
if (fillTasks.size() <= 1 || Iris.instance == null) {
|
||||
if (!shouldPrefillAsync(fillTasks.size())) {
|
||||
for (PrefillFillTask fillTask : fillTasks) {
|
||||
fillTask.run();
|
||||
}
|
||||
@@ -88,6 +96,15 @@ public class ChunkContext {
|
||||
}
|
||||
}
|
||||
|
||||
static boolean shouldPrefillAsync(int fillTaskCount) {
|
||||
if (fillTaskCount <= 1 || Iris.instance == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String threadName = Thread.currentThread().getName();
|
||||
return threadName != null && threadName.startsWith("Iris ");
|
||||
}
|
||||
|
||||
public int getX() {
|
||||
return x;
|
||||
}
|
||||
@@ -96,6 +113,10 @@ public class ChunkContext {
|
||||
return z;
|
||||
}
|
||||
|
||||
public IrisComplex getComplex() {
|
||||
return complex;
|
||||
}
|
||||
|
||||
public ChunkedDataCache<Double> getHeight() {
|
||||
return height;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ public class IrisContext {
|
||||
private static final ChronoLatch cl = new ChronoLatch(60000);
|
||||
private final Engine engine;
|
||||
private ChunkContext chunkContext;
|
||||
private long generationSessionId;
|
||||
|
||||
public IrisContext(Engine engine) {
|
||||
this.engine = engine;
|
||||
@@ -100,6 +101,7 @@ public class IrisContext {
|
||||
return new KMap<String, Object>()
|
||||
.qput("studio", engine.isStudio())
|
||||
.qput("closed", engine.isClosed())
|
||||
.qput("generationSessionId", generationSessionId)
|
||||
.qput("pack", new KMap<>()
|
||||
.qput("key", dimension == null ? "" : dimension.getLoadKey())
|
||||
.qput("version", dimension == null ? "" : dimension.getVersion())
|
||||
|
||||
74
core/src/test/java/art/arcane/iris/IrisDiagnosticsTest.java
Normal file
74
core/src/test/java/art/arcane/iris/IrisDiagnosticsTest.java
Normal file
@@ -0,0 +1,74 @@
|
||||
package art.arcane.iris;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.PrintStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class IrisDiagnosticsTest {
|
||||
@Test
|
||||
public void reportErrorWithContextPrintsFullStacktrace() {
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
PrintStream originalErr = System.err;
|
||||
System.setErr(new PrintStream(output, true, StandardCharsets.UTF_8));
|
||||
try {
|
||||
Iris.reportError("Runtime world creation failed.", new IllegalStateException("outer", new IllegalArgumentException("inner")));
|
||||
} finally {
|
||||
System.setErr(originalErr);
|
||||
}
|
||||
|
||||
String text = output.toString(StandardCharsets.UTF_8);
|
||||
assertTrue(text.contains("Runtime world creation failed."));
|
||||
assertTrue(text.contains("IllegalStateException"));
|
||||
assertTrue(text.contains("IllegalArgumentException"));
|
||||
assertTrue(text.contains("inner"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void collectSplashPacksSkipsInternalAndInvalidFolders() throws Exception {
|
||||
Path root = Files.createTempDirectory("iris-splash");
|
||||
try {
|
||||
Path validPack = root.resolve("overworld");
|
||||
Files.createDirectories(validPack.resolve("dimensions"));
|
||||
Files.writeString(validPack.resolve("dimensions").resolve("overworld.json"), "{\"version\":\"4000\"}");
|
||||
|
||||
Files.createDirectories(root.resolve("datapack-imports"));
|
||||
|
||||
Path brokenPack = root.resolve("broken");
|
||||
Files.createDirectories(brokenPack.resolve("dimensions"));
|
||||
Files.writeString(brokenPack.resolve("dimensions").resolve("broken.json"), "{");
|
||||
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
PrintStream originalErr = System.err;
|
||||
System.setErr(new PrintStream(output, true, StandardCharsets.UTF_8));
|
||||
List<Iris.SplashPackMetadata> packs;
|
||||
try {
|
||||
packs = Iris.collectSplashPacks(root.toFile());
|
||||
} finally {
|
||||
System.setErr(originalErr);
|
||||
}
|
||||
|
||||
assertEquals(1, packs.size());
|
||||
assertEquals("overworld", packs.get(0).name());
|
||||
assertEquals("4000", packs.get(0).version());
|
||||
|
||||
String text = output.toString(StandardCharsets.UTF_8);
|
||||
assertTrue(text.contains("Failed to read splash metadata for dimension pack \"broken\"."));
|
||||
assertTrue(text.contains("Json"));
|
||||
} finally {
|
||||
Files.walk(root)
|
||||
.sorted(Comparator.reverseOrder())
|
||||
.map(Path::toFile)
|
||||
.forEach(File::delete);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,16 @@ public class IrisRuntimeSchedulerModeRoutingTest {
|
||||
assertEquals(IrisRuntimeSchedulerMode.FOLIA, resolved);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void autoResolvesToPaperLikeOnCanvasBranding() {
|
||||
installServer("Canvas", "git-Canvas-101 (MC: 1.21.11)");
|
||||
IrisSettings.IrisSettingsPregen pregen = new IrisSettings.IrisSettingsPregen();
|
||||
pregen.runtimeSchedulerMode = IrisRuntimeSchedulerMode.AUTO;
|
||||
|
||||
IrisRuntimeSchedulerMode resolved = IrisRuntimeSchedulerMode.resolve(pregen);
|
||||
assertEquals(IrisRuntimeSchedulerMode.PAPER_LIKE, resolved);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void explicitModeBypassesAutoDetection() {
|
||||
installServer("Purpur", "git-Purpur-2562 (MC: 1.21.11)");
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package art.arcane.iris.core;
|
||||
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.volmlib.util.collection.KMap;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class ServerConfiguratorDatapackFolderTest {
|
||||
@Test
|
||||
public void resolvesDimensionWorldFolderBackToRootDatapacks() {
|
||||
File folder = new File("/tmp/server/world/dimensions/minecraft/overworld");
|
||||
File datapacks = ServerConfigurator.resolveDatapacksFolder(folder);
|
||||
assertEquals(new File("/tmp/server/world/datapacks").getAbsolutePath(), datapacks.getAbsolutePath());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void keepsStandaloneWorldFolderDatapacksUnchanged() {
|
||||
File folder = new File("/tmp/server/custom_world");
|
||||
File datapacks = ServerConfigurator.resolveDatapacksFolder(folder);
|
||||
assertEquals(new File("/tmp/server/custom_world/datapacks").getAbsolutePath(), datapacks.getAbsolutePath());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void installFoldersIncludeExtraStudioWorldDatapackTargets() {
|
||||
File baseFolder = new File("/tmp/server/world/datapacks");
|
||||
File extraFolder = new File("/tmp/server/iris-studio/datapacks");
|
||||
KList<File> baseFolders = new KList<>();
|
||||
baseFolders.add(baseFolder);
|
||||
KList<File> extraFolders = new KList<>();
|
||||
extraFolders.add(extraFolder);
|
||||
KMap<String, KList<File>> extrasByPack = new KMap<>();
|
||||
extrasByPack.put("overworld", extraFolders);
|
||||
|
||||
KList<File> folders = ServerConfigurator.collectInstallDatapackFolders(baseFolders, extrasByPack);
|
||||
|
||||
assertEquals(2, folders.size());
|
||||
assertTrue(folders.contains(baseFolder));
|
||||
assertTrue(folders.contains(extraFolder));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package art.arcane.iris.core;
|
||||
|
||||
import art.arcane.iris.core.commands.CommandStudio;
|
||||
import art.arcane.iris.core.tools.IrisCreator;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
|
||||
public class StudioRuntimeCleanupTest {
|
||||
@Test
|
||||
public void pregenSettingsNoLongerExposeStartupNoisemapPrebake() {
|
||||
boolean found = Arrays.stream(IrisSettings.IrisSettingsPregen.class.getDeclaredFields())
|
||||
.anyMatch(field -> field.getName().equals("startupNoisemapPrebake"));
|
||||
|
||||
assertFalse(found);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void studioCommandNoLongerExposesProfilecache() {
|
||||
boolean found = Arrays.stream(CommandStudio.class.getDeclaredMethods())
|
||||
.anyMatch(method -> method.getName().equals("profilecache"));
|
||||
|
||||
assertFalse(found);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void studioCreatorNoLongerContainsPrewarmOrPrebakeHelpers() {
|
||||
boolean found = Arrays.stream(IrisCreator.class.getDeclaredMethods())
|
||||
.map(method -> method.getName().toLowerCase())
|
||||
.anyMatch(name -> name.contains("prewarm") || name.contains("prebake"));
|
||||
|
||||
assertFalse(found);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void noisemapPrebakePipelineClassIsRemoved() {
|
||||
try {
|
||||
Class.forName("art.arcane.iris.engine.IrisNoisemapPrebakePipeline");
|
||||
} catch (ClassNotFoundException ignored) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new AssertionError("IrisNoisemapPrebakePipeline should not exist.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package art.arcane.iris.core.lifecycle;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
public class CapabilityResolutionTest {
|
||||
@Test
|
||||
public void resolvesExactCreateLevelMethod() throws Exception {
|
||||
Method method = CapabilityResolution.resolveCreateLevelMethod(CurrentCreateLevelOwner.class);
|
||||
|
||||
assertEquals("createLevel", method.getName());
|
||||
assertEquals(3, method.getParameterCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolvesDeclaredLegacyCreateLevelMethod() throws Exception {
|
||||
Method method = CapabilityResolution.resolveCreateLevelMethod(DeclaredLegacyCreateLevelOwner.class);
|
||||
|
||||
assertEquals("createLevel", method.getName());
|
||||
assertEquals(4, method.getParameterCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolvesTwoArgLevelStorageAccessMethod() throws Exception {
|
||||
Method method = CapabilityResolution.resolveLevelStorageAccessMethod(TwoArgLevelStorageSource.class);
|
||||
|
||||
assertEquals("validateAndCreateAccess", method.getName());
|
||||
assertEquals(2, method.getParameterCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolvesOneArgLevelStorageAccessMethod() throws Exception {
|
||||
Method method = CapabilityResolution.resolveLevelStorageAccessMethod(OneArgLevelStorageSource.class);
|
||||
|
||||
assertEquals("validateAndCreateAccess", method.getName());
|
||||
assertEquals(1, method.getParameterCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolvesPublicWorldDataHelper() throws Exception {
|
||||
Method method = CapabilityResolution.resolvePaperWorldDataMethod(PublicWorldLoader.class);
|
||||
|
||||
assertEquals("loadWorldData", method.getName());
|
||||
assertEquals(3, method.getParameterCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolvesDeclaredWorldDataHelper() throws Exception {
|
||||
Method method = CapabilityResolution.resolvePaperWorldDataMethod(DeclaredWorldLoader.class);
|
||||
|
||||
assertEquals("loadWorldData", method.getName());
|
||||
assertEquals(3, method.getParameterCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolvesDeclaredServerRegistryAccessMethod() throws Exception {
|
||||
Method method = CapabilityResolution.resolveServerRegistryAccessMethod(DeclaredRegistryAccessOwner.class);
|
||||
|
||||
assertEquals("registryAccess", method.getName());
|
||||
assertEquals(0, method.getParameterCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolvesCurrentWorldLoadingInfoConstructor() throws Exception {
|
||||
Constructor<?> constructor = CapabilityResolution.resolveWorldLoadingInfoConstructor(CurrentWorldLoadingInfo.class);
|
||||
|
||||
assertEquals(4, constructor.getParameterCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolvesLegacyWorldLoadingInfoConstructor() throws Exception {
|
||||
Constructor<?> constructor = CapabilityResolution.resolveWorldLoadingInfoConstructor(LegacyWorldLoadingInfo.class);
|
||||
|
||||
assertEquals(5, constructor.getParameterCount());
|
||||
}
|
||||
|
||||
public static final class LevelStem {
|
||||
}
|
||||
|
||||
public static final class WorldLoadingInfo {
|
||||
}
|
||||
|
||||
public static final class WorldLoadingInfoAndData {
|
||||
}
|
||||
|
||||
public static final class WorldDataAndGenSettings {
|
||||
}
|
||||
|
||||
public static final class PrimaryLevelData {
|
||||
}
|
||||
|
||||
public static final class LevelStorageAccess {
|
||||
}
|
||||
|
||||
public static final class ResourceKey {
|
||||
}
|
||||
|
||||
public static final class LoadedWorldData {
|
||||
}
|
||||
|
||||
public static final class MinecraftServer {
|
||||
}
|
||||
|
||||
public static final class RegistryAccess {
|
||||
}
|
||||
|
||||
public enum Environment {
|
||||
NORMAL
|
||||
}
|
||||
|
||||
public static final class CurrentCreateLevelOwner {
|
||||
public void createLevel(LevelStem levelStem, WorldLoadingInfoAndData worldLoadingInfoAndData, WorldDataAndGenSettings worldDataAndGenSettings) {
|
||||
}
|
||||
}
|
||||
|
||||
public static final class DeclaredLegacyCreateLevelOwner {
|
||||
private void createLevel(LevelStem levelStem, WorldLoadingInfo worldLoadingInfo, LevelStorageAccess levelStorageAccess, PrimaryLevelData primaryLevelData) {
|
||||
}
|
||||
}
|
||||
|
||||
public static final class TwoArgLevelStorageSource {
|
||||
public LevelStorageAccess validateAndCreateAccess(String worldName, ResourceKey resourceKey) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class OneArgLevelStorageSource {
|
||||
public LevelStorageAccess validateAndCreateAccess(String worldName) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class PublicWorldLoader {
|
||||
public static LoadedWorldData loadWorldData(MinecraftServer minecraftServer, ResourceKey dimensionKey, String worldName) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class DeclaredWorldLoader {
|
||||
private static LoadedWorldData loadWorldData(MinecraftServer minecraftServer, ResourceKey dimensionKey, String worldName) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class DeclaredRegistryAccessOwner {
|
||||
private RegistryAccess registryAccess() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class CurrentWorldLoadingInfo {
|
||||
public CurrentWorldLoadingInfo(Environment environment, ResourceKey stemKey, ResourceKey dimensionKey, boolean enabled) {
|
||||
}
|
||||
}
|
||||
|
||||
public static final class LegacyWorldLoadingInfo {
|
||||
private LegacyWorldLoadingInfo(int index, String worldName, String environment, ResourceKey stemKey, boolean enabled) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package art.arcane.iris.core.lifecycle;
|
||||
|
||||
import org.bukkit.World;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.PrintStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.concurrent.CompletionException;
|
||||
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.fail;
|
||||
|
||||
public class WorldLifecycleDiagnosticsTest {
|
||||
@Test
|
||||
public void studioCreateSelectionFailurePrintsFullStacktrace() {
|
||||
WorldLifecycleService service = new WorldLifecycleService(CapabilitySnapshot.forTesting(ServerFamily.PAPER, false, false, false));
|
||||
WorldLifecycleRequest request = new WorldLifecycleRequest("studio", World.Environment.NORMAL, null, null, null, true, false, 1337L, true, false, WorldLifecycleCaller.STUDIO);
|
||||
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
PrintStream originalErr = System.err;
|
||||
System.setErr(new PrintStream(output, true, StandardCharsets.UTF_8));
|
||||
try {
|
||||
try {
|
||||
service.create(request).join();
|
||||
fail("Expected lifecycle create to fail when paper_like_runtime is unavailable.");
|
||||
} catch (CompletionException | IllegalStateException ignored) {
|
||||
}
|
||||
} finally {
|
||||
System.setErr(originalErr);
|
||||
}
|
||||
|
||||
String text = output.toString(StandardCharsets.UTF_8);
|
||||
assertTrue(text.contains("WorldLifecycle create backend selection failed"));
|
||||
assertTrue(text.contains("paper_like_runtime"));
|
||||
assertTrue(text.contains("IllegalStateException"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package art.arcane.iris.core.lifecycle;
|
||||
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.nms.INMSBinding;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.EngineTarget;
|
||||
import art.arcane.iris.engine.platform.ChunkReplacementListener;
|
||||
import art.arcane.iris.engine.platform.ChunkReplacementOptions;
|
||||
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.generator.ChunkGenerator;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.lang.reflect.InvocationHandler;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotSame;
|
||||
import static org.junit.Assert.assertSame;
|
||||
|
||||
public class WorldLifecycleRuntimeLevelStemTest {
|
||||
@Test
|
||||
public void runtimeStemUsesFullServerRegistryAccessForPlatformGenerators() throws Exception {
|
||||
Object datapackDimensions = new MissingDimensionTypeRegistry();
|
||||
Object serverRegistryAccess = new Object();
|
||||
CapabilitySnapshot capabilities = CapabilitySnapshot.forTestingRuntimeRegistries(ServerFamily.PURPUR, false, datapackDimensions, serverRegistryAccess);
|
||||
WorldLifecycleRequest request = new WorldLifecycleRequest(
|
||||
"studio",
|
||||
World.Environment.NORMAL,
|
||||
new TestingPlatformChunkGenerator(),
|
||||
null,
|
||||
null,
|
||||
true,
|
||||
false,
|
||||
1337L,
|
||||
true,
|
||||
false,
|
||||
WorldLifecycleCaller.STUDIO
|
||||
);
|
||||
AtomicReference<Object> seenRegistryAccess = new AtomicReference<>();
|
||||
INMSBinding binding = createBinding((registryAccess, generator) -> {
|
||||
seenRegistryAccess.set(registryAccess);
|
||||
return "runtime-stem";
|
||||
});
|
||||
|
||||
Object levelStem = WorldLifecycleSupport.resolveRuntimeLevelStem(capabilities, request, binding);
|
||||
|
||||
assertEquals("runtime-stem", levelStem);
|
||||
assertSame(serverRegistryAccess, seenRegistryAccess.get());
|
||||
assertNotSame(datapackDimensions, seenRegistryAccess.get());
|
||||
}
|
||||
|
||||
private static INMSBinding createBinding(RuntimeStemFactory factory) {
|
||||
InvocationHandler handler = (proxy, method, args) -> {
|
||||
if ("createRuntimeLevelStem".equals(method.getName())) {
|
||||
return factory.create(args[0], (ChunkGenerator) args[1]);
|
||||
}
|
||||
Class<?> returnType = method.getReturnType();
|
||||
if (boolean.class.equals(returnType)) {
|
||||
return false;
|
||||
}
|
||||
if (int.class.equals(returnType)) {
|
||||
return 0;
|
||||
}
|
||||
if (long.class.equals(returnType)) {
|
||||
return 0L;
|
||||
}
|
||||
if (float.class.equals(returnType)) {
|
||||
return 0F;
|
||||
}
|
||||
if (double.class.equals(returnType)) {
|
||||
return 0D;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return (INMSBinding) Proxy.newProxyInstance(
|
||||
INMSBinding.class.getClassLoader(),
|
||||
new Class[]{INMSBinding.class},
|
||||
handler
|
||||
);
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface RuntimeStemFactory {
|
||||
Object create(Object registryAccess, ChunkGenerator generator);
|
||||
}
|
||||
|
||||
private static final class MissingDimensionTypeRegistry {
|
||||
}
|
||||
|
||||
private static final class TestingPlatformChunkGenerator extends ChunkGenerator implements PlatformChunkGenerator {
|
||||
@Override
|
||||
public Engine getEngine() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public IrisData getData() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public EngineTarget getTarget() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void injectChunkReplacement(World world, int x, int z, Executor syncExecutor, ChunkReplacementOptions options, ChunkReplacementListener listener) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isStudio() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void touch(World world) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Integer> getSpawnChunks() {
|
||||
return CompletableFuture.completedFuture(0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Location getInitialSpawnLocation(World world) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void hotload() {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package art.arcane.iris.core.lifecycle;
|
||||
|
||||
import org.bukkit.World;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
public class WorldLifecycleSelectionTest {
|
||||
@Test
|
||||
public void studioSelectsPaperLikeBackendOnPaper() {
|
||||
WorldLifecycleService service = new WorldLifecycleService(CapabilitySnapshot.forTesting(ServerFamily.PAPER, false, false, true));
|
||||
WorldLifecycleRequest request = new WorldLifecycleRequest("studio", World.Environment.NORMAL, null, null, null, true, false, 1337L, true, false, WorldLifecycleCaller.STUDIO);
|
||||
|
||||
assertEquals("paper_like_runtime", service.selectCreateBackend(request).backendName());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void studioSelectsPaperLikeBackendOnPurpur() {
|
||||
WorldLifecycleService service = new WorldLifecycleService(CapabilitySnapshot.forTesting(ServerFamily.PURPUR, false, false, true));
|
||||
WorldLifecycleRequest request = new WorldLifecycleRequest("studio", World.Environment.NORMAL, null, null, null, true, false, 1337L, true, false, WorldLifecycleCaller.STUDIO);
|
||||
|
||||
assertEquals("paper_like_runtime", service.selectCreateBackend(request).backendName());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void studioSelectsPaperLikeBackendOnCanvas() {
|
||||
WorldLifecycleService service = new WorldLifecycleService(CapabilitySnapshot.forTesting(ServerFamily.CANVAS, true, false, true));
|
||||
WorldLifecycleRequest request = new WorldLifecycleRequest("studio", World.Environment.NORMAL, null, null, null, true, false, 1337L, true, false, WorldLifecycleCaller.STUDIO);
|
||||
|
||||
assertEquals("paper_like_runtime", service.selectCreateBackend(request).backendName());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void studioSelectsPaperLikeBackendOnFolia() {
|
||||
WorldLifecycleService service = new WorldLifecycleService(CapabilitySnapshot.forTesting(ServerFamily.FOLIA, true, false, true));
|
||||
WorldLifecycleRequest request = new WorldLifecycleRequest("studio", World.Environment.NORMAL, null, null, null, true, false, 1337L, true, false, WorldLifecycleCaller.STUDIO);
|
||||
|
||||
assertEquals("paper_like_runtime", service.selectCreateBackend(request).backendName());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void studioSelectsBukkitBackendOnSpigot() {
|
||||
WorldLifecycleService service = new WorldLifecycleService(CapabilitySnapshot.forTesting(ServerFamily.SPIGOT, false, false, false));
|
||||
WorldLifecycleRequest request = new WorldLifecycleRequest("studio", World.Environment.NORMAL, null, null, null, true, false, 1337L, true, false, WorldLifecycleCaller.STUDIO);
|
||||
|
||||
assertEquals("bukkit_public", service.selectCreateBackend(request).backendName());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void persistentCreatePrefersBukkitBackendOnPaperLikeServers() {
|
||||
WorldLifecycleService service = new WorldLifecycleService(CapabilitySnapshot.forTesting(ServerFamily.PURPUR, false, false, true));
|
||||
WorldLifecycleRequest request = new WorldLifecycleRequest("persistent", World.Environment.NORMAL, null, null, null, true, false, 1337L, false, false, WorldLifecycleCaller.CREATE);
|
||||
|
||||
assertEquals("bukkit_public", service.selectCreateBackend(request).backendName());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unloadUsesRememberedBackendFamily() {
|
||||
WorldLifecycleService service = new WorldLifecycleService(CapabilitySnapshot.forTesting(ServerFamily.PURPUR, false, false, true));
|
||||
|
||||
service.rememberBackend("studio", "paper_like_runtime");
|
||||
assertEquals("paper_like_runtime", service.selectUnloadBackend("studio").backendName());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package art.arcane.iris.core.lifecycle;
|
||||
|
||||
import org.bukkit.generator.BiomeProvider;
|
||||
import org.bukkit.generator.ChunkGenerator;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
public class WorldLifecycleStagingTest {
|
||||
@Test
|
||||
public void stagedGeneratorIsConsumedExactlyOnce() {
|
||||
ChunkGenerator generator = mock(ChunkGenerator.class);
|
||||
|
||||
WorldLifecycleStaging.stageGenerator("world", generator, null);
|
||||
|
||||
assertSame(generator, WorldLifecycleStaging.consumeGenerator("world"));
|
||||
assertNull(WorldLifecycleStaging.consumeGenerator("world"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void stagedStemGeneratorIsIndependentFromGeneratorConsumption() {
|
||||
ChunkGenerator generator = mock(ChunkGenerator.class);
|
||||
|
||||
WorldLifecycleStaging.stageGenerator("world", generator, null);
|
||||
WorldLifecycleStaging.stageStemGenerator("world", generator);
|
||||
|
||||
assertSame(generator, WorldLifecycleStaging.consumeGenerator("world"));
|
||||
assertSame(generator, WorldLifecycleStaging.consumeStemGenerator("world"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void clearAllRemovesGeneratorBiomeAndStemState() {
|
||||
ChunkGenerator generator = mock(ChunkGenerator.class);
|
||||
BiomeProvider biomeProvider = mock(BiomeProvider.class);
|
||||
|
||||
WorldLifecycleStaging.stageGenerator("world", generator, biomeProvider);
|
||||
WorldLifecycleStaging.stageStemGenerator("world", generator);
|
||||
WorldLifecycleStaging.clearAll("world");
|
||||
|
||||
assertNull(WorldLifecycleStaging.consumeGenerator("world"));
|
||||
assertNull(WorldLifecycleStaging.consumeBiomeProvider("world"));
|
||||
assertNull(WorldLifecycleStaging.consumeStemGenerator("world"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package art.arcane.iris.core.nms;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class INMSBindingProbeCodesTest {
|
||||
@Test
|
||||
public void skipsSyntheticBukkitBindingWhenNmsIsEnabled() {
|
||||
List<String> probeCodes = NmsBindingProbeSupport.getBindingProbeCodes("BUKKIT", false, List.of("v1_21_R7"));
|
||||
|
||||
assertEquals(List.of("v1_21_R7"), probeCodes);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void leavesBukkitFallbackEmptyWhenNmsIsDisabled() {
|
||||
List<String> probeCodes = NmsBindingProbeSupport.getBindingProbeCodes("BUKKIT", true, List.of("v1_21_R7"));
|
||||
|
||||
assertTrue(probeCodes.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void keepsConcreteBindingCodesAsPrimaryProbe() {
|
||||
List<String> probeCodes = NmsBindingProbeSupport.getBindingProbeCodes("v1_21_R7", false, List.of("v1_21_R7"));
|
||||
|
||||
assertEquals(List.of("v1_21_R7"), probeCodes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package art.arcane.iris.core.nms;
|
||||
|
||||
import org.bukkit.Server;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
public class MinecraftVersionTest {
|
||||
private interface PaperLikeServer extends Server {
|
||||
String getMinecraftVersion();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void detectsMinecraftVersionFromPurpurDecoratedVersion() {
|
||||
Server server = mock(Server.class);
|
||||
doReturn("git-Purpur-2570 (MC: 1.21.11)").when(server).getVersion();
|
||||
doReturn("26.1.2.build.2570-experimental").when(server).getBukkitVersion();
|
||||
|
||||
MinecraftVersion version = MinecraftVersion.detect(server);
|
||||
assertEquals("1.21.11", version.value());
|
||||
assertEquals(21, version.major());
|
||||
assertEquals(11, version.minor());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void prefersRuntimeMinecraftVersionMethodWhenPresent() {
|
||||
PaperLikeServer server = mock(PaperLikeServer.class);
|
||||
doReturn("1.21.11").when(server).getMinecraftVersion();
|
||||
doReturn("26.1.2-2570-e64b1b2 (MC: 26.1.2)").when(server).getVersion();
|
||||
doReturn("26.1.2.build.2570-experimental").when(server).getBukkitVersion();
|
||||
|
||||
MinecraftVersion version = MinecraftVersion.detect(server);
|
||||
assertEquals("1.21.11", version.value());
|
||||
assertEquals(21, version.major());
|
||||
assertEquals(11, version.minor());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsPurpurApiBuildNumbersAsMinecraftVersion() {
|
||||
MinecraftVersion version = MinecraftVersion.fromBukkitVersion("26.1.2.build.2570-experimental");
|
||||
assertNull(version);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parsesStandardBukkitSnapshotVersion() {
|
||||
MinecraftVersion version = MinecraftVersion.fromBukkitVersion("1.21.11-R0.1-SNAPSHOT");
|
||||
assertEquals("1.21.11", version.value());
|
||||
assertEquals(21, version.major());
|
||||
assertEquals(11, version.minor());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void comparesMajorBeforeMinor() {
|
||||
MinecraftVersion version = MinecraftVersion.fromBukkitVersion("1.20.12-R0.1-SNAPSHOT");
|
||||
assertFalse(version.isAtLeast(21, 11));
|
||||
assertTrue(version.isNewerThan(20, 11));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package art.arcane.iris.core.nms.datapack.v1217;
|
||||
|
||||
import art.arcane.iris.core.nms.datapack.IDataFixer.Dimension;
|
||||
import art.arcane.volmlib.util.json.JSONObject;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class DataFixerV1217DimensionTypeTest {
|
||||
private final DataFixerV1217 fixer = new DataFixerV1217();
|
||||
|
||||
@Test
|
||||
public void createsOverworldDimensionWithDragonFightDisabled() {
|
||||
JSONObject json = fixer.createDimension(Dimension.OVERWORLD, -256, 768, 512, null);
|
||||
|
||||
assertTrue(json.has("has_ender_dragon_fight"));
|
||||
assertEquals(false, json.getBoolean("has_ender_dragon_fight"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void createsEndDimensionWithDragonFightEnabled() {
|
||||
JSONObject json = fixer.createDimension(Dimension.END, 0, 256, 256, null);
|
||||
|
||||
assertTrue(json.has("has_ender_dragon_fight"));
|
||||
assertEquals(true, json.getBoolean("has_ender_dragon_fight"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package art.arcane.iris.core.runtime;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class DatapackReadinessResultTest {
|
||||
@Test
|
||||
public void verificationUsesDimensionTypeKeyPath() throws Exception {
|
||||
Path root = Files.createTempDirectory("iris-datapack-readiness");
|
||||
Path datapackRoot = root.resolve("iris");
|
||||
Files.createDirectories(datapackRoot.resolve("data/iris/dimension_type"));
|
||||
Files.writeString(datapackRoot.resolve("pack.mcmeta"), "{}");
|
||||
Files.writeString(datapackRoot.resolve("data/iris/dimension_type/runtime-key.json"), "{}");
|
||||
|
||||
ArrayList<String> verifiedPaths = new ArrayList<>();
|
||||
ArrayList<String> missingPaths = new ArrayList<>();
|
||||
DatapackReadinessResult.collectVerificationPaths(root.toFile(), "runtime-key", verifiedPaths, missingPaths);
|
||||
|
||||
assertTrue(missingPaths.isEmpty());
|
||||
assertEquals(2, verifiedPaths.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void verificationMarksMissingDimensionTypePath() throws Exception {
|
||||
Path root = Files.createTempDirectory("iris-datapack-readiness-missing");
|
||||
Path datapackRoot = root.resolve("iris");
|
||||
Files.createDirectories(datapackRoot);
|
||||
Files.writeString(datapackRoot.resolve("pack.mcmeta"), "{}");
|
||||
|
||||
ArrayList<String> verifiedPaths = new ArrayList<>();
|
||||
ArrayList<String> missingPaths = new ArrayList<>();
|
||||
DatapackReadinessResult.collectVerificationPaths(root.toFile(), "runtime-key", verifiedPaths, missingPaths);
|
||||
|
||||
assertEquals(1, verifiedPaths.size());
|
||||
assertEquals(1, missingPaths.size());
|
||||
assertTrue(missingPaths.get(0).endsWith(File.separator + "iris" + File.separator + "data" + File.separator + "iris" + File.separator + "dimension_type" + File.separator + "runtime-key.json"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package art.arcane.iris.core.runtime;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class SmokeDiagnosticsServiceCloseStateTest {
|
||||
@Test
|
||||
public void closeStateIsPersistedIntoRunSnapshot() {
|
||||
SmokeDiagnosticsService service = SmokeDiagnosticsService.get();
|
||||
SmokeDiagnosticsService.SmokeRunHandle handle = service.beginRun(
|
||||
SmokeDiagnosticsService.SmokeRunMode.STUDIO_CLOSE,
|
||||
"iris-test-world",
|
||||
true,
|
||||
true,
|
||||
null,
|
||||
false
|
||||
);
|
||||
|
||||
handle.setCloseState(true, false, true);
|
||||
|
||||
SmokeDiagnosticsService.SmokeRunReport report = handle.snapshot();
|
||||
assertTrue(report.isCloseUnloadCompletedLive());
|
||||
assertFalse(report.isCloseFolderDeletionCompletedLive());
|
||||
assertTrue(report.isCloseStartupCleanupQueued());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package art.arcane.iris.core.runtime;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class TransientWorldCleanupSupportTest {
|
||||
@Test
|
||||
public void identifiesTransientStudioBaseNamesAndSidecars() {
|
||||
String baseName = "iris-123e4567-e89b-12d3-a456-426614174000";
|
||||
|
||||
assertTrue(TransientWorldCleanupSupport.isTransientStudioWorldName(baseName));
|
||||
assertEquals(baseName, TransientWorldCleanupSupport.transientStudioBaseWorldName(baseName));
|
||||
assertEquals(baseName, TransientWorldCleanupSupport.transientStudioBaseWorldName(baseName + "_nether"));
|
||||
assertEquals(baseName, TransientWorldCleanupSupport.transientStudioBaseWorldName(baseName + "_the_end"));
|
||||
assertFalse(TransientWorldCleanupSupport.isTransientStudioWorldName("iris-smoke-studio-deadbeef"));
|
||||
assertNull(TransientWorldCleanupSupport.transientStudioBaseWorldName("overworld"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void expandsWorldFamilyNamesForDeletion() {
|
||||
String baseName = "iris-123e4567-e89b-12d3-a456-426614174000";
|
||||
|
||||
List<String> names = TransientWorldCleanupSupport.worldFamilyNames(baseName);
|
||||
|
||||
assertEquals(List.of(baseName, baseName + "_nether", baseName + "_the_end"), names);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void collectsOnlyTransientStudioWorldFamiliesFromContainer() throws IOException {
|
||||
File container = Files.createTempDirectory("transient-world-cleanup-test").toFile();
|
||||
String baseName = "iris-123e4567-e89b-12d3-a456-426614174000";
|
||||
File baseFolder = new File(container, baseName);
|
||||
File netherFolder = new File(container, baseName + "_nether");
|
||||
File smokeFolder = new File(container, "iris-smoke-studio-deadbeef");
|
||||
File regularFolder = new File(container, "overworld");
|
||||
baseFolder.mkdirs();
|
||||
netherFolder.mkdirs();
|
||||
smokeFolder.mkdirs();
|
||||
regularFolder.mkdirs();
|
||||
|
||||
try {
|
||||
LinkedHashSet<String> names = TransientWorldCleanupSupport.collectTransientStudioWorldNames(container);
|
||||
|
||||
assertEquals(new LinkedHashSet<>(List.of(baseName)), names);
|
||||
} finally {
|
||||
Files.deleteIfExists(baseFolder.toPath());
|
||||
Files.deleteIfExists(netherFolder.toPath());
|
||||
Files.deleteIfExists(smokeFolder.toPath());
|
||||
Files.deleteIfExists(regularFolder.toPath());
|
||||
Files.deleteIfExists(container.toPath());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package art.arcane.iris.core.runtime;
|
||||
|
||||
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
public class WorldRuntimeControlServiceSafeEntryTest {
|
||||
@Test
|
||||
public void resolvesStudioEntryAnchorFromGeneratorInsteadOfMutableWorldSpawn() {
|
||||
World world = mock(World.class);
|
||||
PlatformChunkGenerator provider = mock(PlatformChunkGenerator.class);
|
||||
Location initialSpawn = new Location(world, 0.5D, 96D, 0.5D);
|
||||
Location mutableWorldSpawn = new Location(world, 128.5D, 80D, -64.5D);
|
||||
|
||||
doReturn(true).when(provider).isStudio();
|
||||
doReturn(initialSpawn).when(provider).getInitialSpawnLocation(world);
|
||||
doReturn(mutableWorldSpawn).when(world).getSpawnLocation();
|
||||
|
||||
Location resolved = WorldRuntimeControlService.resolveEntryAnchor(world, provider);
|
||||
|
||||
assertEquals(initialSpawn, resolved);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void fallsBackToWorldSpawnWhenGeneratorIsNotStudio() {
|
||||
World world = mock(World.class);
|
||||
PlatformChunkGenerator provider = mock(PlatformChunkGenerator.class);
|
||||
Location mutableWorldSpawn = new Location(world, 128.5D, 80D, -64.5D);
|
||||
|
||||
doReturn(false).when(provider).isStudio();
|
||||
doReturn(mutableWorldSpawn).when(world).getSpawnLocation();
|
||||
|
||||
Location resolved = WorldRuntimeControlService.resolveEntryAnchor(world, provider);
|
||||
|
||||
assertEquals(mutableWorldSpawn, resolved);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void scansFromRuntimeSurfaceBeforeFallingThroughRemainingWorldHeight() {
|
||||
World world = mock(World.class);
|
||||
|
||||
doReturn(0).when(world).getMinHeight();
|
||||
doReturn(256).when(world).getMaxHeight();
|
||||
doReturn(179).when(world).getHighestBlockYAt(0, 0);
|
||||
|
||||
int[] scanOrder = WorldRuntimeControlService.buildSafeLocationScanOrder(world, new Location(world, 0.5D, 96D, 0.5D));
|
||||
|
||||
assertEquals(180, scanOrder[0]);
|
||||
assertEquals(179, scanOrder[1]);
|
||||
assertEquals(1, scanOrder[179]);
|
||||
assertEquals(181, scanOrder[180]);
|
||||
assertEquals(254, scanOrder[scanOrder.length - 1]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package art.arcane.iris.core.runtime;
|
||||
|
||||
import art.arcane.iris.core.lifecycle.CapabilitySnapshot;
|
||||
import art.arcane.iris.core.lifecycle.ServerFamily;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.plugin.PluginManager;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.OptionalLong;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
public class WorldRuntimeControlServiceTimeLockTest {
|
||||
@Before
|
||||
public void ensureBukkitServer() {
|
||||
if (Bukkit.getServer() != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Server server = mock(Server.class);
|
||||
PluginManager pluginManager = mock(PluginManager.class);
|
||||
doReturn(pluginManager).when(server).getPluginManager();
|
||||
doReturn(Logger.getLogger("WorldRuntimeControlServiceTimeLockTest")).when(server).getLogger();
|
||||
Bukkit.setServer(server);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void skipsTimeLockWhenWorldDoesNotExposeMutableClock() throws Exception {
|
||||
AtomicBoolean setTimeCalled = new AtomicBoolean(false);
|
||||
AtomicLong dayTime = new AtomicLong(0L);
|
||||
World world = createWorldProxy("fixed", true, setTimeCalled, dayTime, false);
|
||||
|
||||
boolean applied = createService().applyNoonTimeLock(world);
|
||||
|
||||
assertFalse(applied);
|
||||
assertFalse(setTimeCalled.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void skipsTimeLockWhenRuntimeSetterRejectsClockMutation() throws Exception {
|
||||
AtomicBoolean setTimeCalled = new AtomicBoolean(false);
|
||||
AtomicLong dayTime = new AtomicLong(0L);
|
||||
World world = createWorldProxy("no-clock", false, setTimeCalled, dayTime, true);
|
||||
|
||||
boolean applied = createService().applyNoonTimeLock(world);
|
||||
|
||||
assertFalse(applied);
|
||||
assertTrue(setTimeCalled.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void appliesTimeLockWhenWorldHasMutableClock() throws Exception {
|
||||
AtomicBoolean setTimeCalled = new AtomicBoolean(false);
|
||||
AtomicLong dayTime = new AtomicLong(0L);
|
||||
World world = createWorldProxy("mutable", false, setTimeCalled, dayTime, false);
|
||||
|
||||
boolean applied = createService().applyNoonTimeLock(world);
|
||||
|
||||
assertTrue(applied);
|
||||
assertTrue(setTimeCalled.get());
|
||||
assertTrue(dayTime.get() == 6000L);
|
||||
}
|
||||
|
||||
private WorldRuntimeControlService createService() throws Exception {
|
||||
Constructor<CapabilitySnapshot> snapshotConstructor = CapabilitySnapshot.class.getDeclaredConstructor(
|
||||
ServerFamily.class,
|
||||
boolean.class,
|
||||
Object.class,
|
||||
Class.class,
|
||||
Class.class,
|
||||
String.class,
|
||||
Object.class,
|
||||
Object.class,
|
||||
java.lang.reflect.Method.class,
|
||||
CapabilitySnapshot.PaperLikeFlavor.class,
|
||||
Class.class,
|
||||
java.lang.reflect.Method.class,
|
||||
java.lang.reflect.Constructor.class,
|
||||
java.lang.reflect.Constructor.class,
|
||||
java.lang.reflect.Method.class,
|
||||
java.lang.reflect.Method.class,
|
||||
java.lang.reflect.Field.class,
|
||||
java.lang.reflect.Method.class,
|
||||
java.lang.reflect.Field.class,
|
||||
java.lang.reflect.Field.class,
|
||||
java.lang.reflect.Method.class,
|
||||
java.lang.reflect.Method.class,
|
||||
java.lang.reflect.Method.class,
|
||||
java.lang.reflect.Method.class,
|
||||
String.class
|
||||
);
|
||||
snapshotConstructor.setAccessible(true);
|
||||
CapabilitySnapshot snapshot = snapshotConstructor.newInstance(
|
||||
ServerFamily.PAPER,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"test",
|
||||
Bukkit.getServer(),
|
||||
null,
|
||||
null,
|
||||
CapabilitySnapshot.PaperLikeFlavor.UNSUPPORTED,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"test"
|
||||
);
|
||||
|
||||
Constructor<WorldRuntimeControlService> serviceConstructor = WorldRuntimeControlService.class.getDeclaredConstructor(CapabilitySnapshot.class);
|
||||
serviceConstructor.setAccessible(true);
|
||||
return serviceConstructor.newInstance(snapshot);
|
||||
}
|
||||
|
||||
private World createWorldProxy(String name, boolean fixedTime, AtomicBoolean setTimeCalled, AtomicLong dayTime, boolean throwOnSetTime) {
|
||||
Object dimensionType = Proxy.newProxyInstance(
|
||||
World.class.getClassLoader(),
|
||||
new Class[]{DimensionTypeProbe.class},
|
||||
(proxy, method, args) -> {
|
||||
if ("hasFixedTime".equals(method.getName())) {
|
||||
return fixedTime;
|
||||
}
|
||||
if ("fixedTime".equals(method.getName())) {
|
||||
return fixedTime ? OptionalLong.of(6000L) : OptionalLong.empty();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
Object holder = Proxy.newProxyInstance(
|
||||
World.class.getClassLoader(),
|
||||
new Class[]{HolderProbe.class},
|
||||
(proxy, method, args) -> {
|
||||
if ("value".equals(method.getName())) {
|
||||
return dimensionType;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
Object handle = Proxy.newProxyInstance(
|
||||
World.class.getClassLoader(),
|
||||
new Class[]{HandleProbe.class},
|
||||
(proxy, method, args) -> {
|
||||
if ("dimensionTypeRegistration".equals(method.getName())) {
|
||||
return holder;
|
||||
}
|
||||
if ("getDayTime".equals(method.getName())) {
|
||||
return dayTime.get();
|
||||
}
|
||||
if ("setDayTime".equals(method.getName())) {
|
||||
setTimeCalled.set(true);
|
||||
if (throwOnSetTime) {
|
||||
throw new IllegalArgumentException("Cannot set time in world without world clock");
|
||||
}
|
||||
dayTime.set(((Long) args[0]).longValue());
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
return (World) Proxy.newProxyInstance(
|
||||
World.class.getClassLoader(),
|
||||
new Class[]{World.class, WorldHandleProbe.class},
|
||||
(proxy, method, args) -> {
|
||||
if ("getName".equals(method.getName())) {
|
||||
return name;
|
||||
}
|
||||
if ("getHandle".equals(method.getName())) {
|
||||
return handle;
|
||||
}
|
||||
if ("getFullTime".equals(method.getName())) {
|
||||
return dayTime.get();
|
||||
}
|
||||
|
||||
Class<?> returnType = method.getReturnType();
|
||||
if (boolean.class.equals(returnType)) {
|
||||
return false;
|
||||
}
|
||||
if (int.class.equals(returnType)) {
|
||||
return 0;
|
||||
}
|
||||
if (long.class.equals(returnType)) {
|
||||
return 0L;
|
||||
}
|
||||
if (float.class.equals(returnType)) {
|
||||
return 0F;
|
||||
}
|
||||
if (double.class.equals(returnType)) {
|
||||
return 0D;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private interface WorldHandleProbe {
|
||||
Object getHandle();
|
||||
}
|
||||
|
||||
private interface HandleProbe {
|
||||
Object dimensionTypeRegistration();
|
||||
|
||||
long getDayTime();
|
||||
|
||||
void setDayTime(long time);
|
||||
}
|
||||
|
||||
private interface HolderProbe {
|
||||
Object value();
|
||||
}
|
||||
|
||||
private interface DimensionTypeProbe {
|
||||
boolean hasFixedTime();
|
||||
|
||||
OptionalLong fixedTime();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package art.arcane.iris.engine.framework;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class GenerationSessionManagerTest {
|
||||
@Test
|
||||
public void teardownSealMarksRejectedWorkAsExpected() throws Exception {
|
||||
GenerationSessionManager manager = new GenerationSessionManager();
|
||||
|
||||
manager.sealAndAwait("close", 1000L, true);
|
||||
|
||||
try {
|
||||
manager.acquire("chunk_generate");
|
||||
} catch (GenerationSessionException e) {
|
||||
assertTrue(e.isExpectedTeardown());
|
||||
assertTrue(e.getMessage().contains("during close"));
|
||||
return;
|
||||
}
|
||||
|
||||
throw new AssertionError("Expected teardown rejection.");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sealAndAwaitCompletesWhenOutstandingLeaseReleases() throws Exception {
|
||||
GenerationSessionManager manager = new GenerationSessionManager();
|
||||
GenerationSessionLease lease = manager.acquire("chunk_generate");
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
|
||||
Thread releaser = new Thread(() -> {
|
||||
try {
|
||||
latch.await(200L, TimeUnit.MILLISECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
lease.close();
|
||||
});
|
||||
releaser.start();
|
||||
latch.countDown();
|
||||
|
||||
manager.sealAndAwait("close", 1000L, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package art.arcane.iris.engine.object;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
public class IrisDimensionTypeKeyTest {
|
||||
@Test
|
||||
public void dimensionTypeKeyUsesSanitizedSemanticPackKey() {
|
||||
IrisDimension dimension = new IrisDimension();
|
||||
dimension.setLoadKey("Overworld");
|
||||
|
||||
assertEquals("overworld", dimension.getDimensionTypeKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void dimensionTypeKeySanitizesUnsafePackCharacters() {
|
||||
IrisDimension dimension = new IrisDimension();
|
||||
dimension.setLoadKey("Worlds/My Pack");
|
||||
|
||||
assertEquals("worlds_my_pack", dimension.getDimensionTypeKey());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package art.arcane.iris.engine.object;
|
||||
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
public class IrisObjectBlockDataMergeTest {
|
||||
@Test
|
||||
public void reparsesBlockDataBeforeRetryingMerge() {
|
||||
BlockData base = mock(BlockData.class);
|
||||
BlockData update = mock(BlockData.class);
|
||||
BlockData parsedBase = mock(BlockData.class);
|
||||
BlockData parsedUpdate = mock(BlockData.class);
|
||||
BlockData merged = mock(BlockData.class);
|
||||
Function<String, BlockData> resolver = createResolver(
|
||||
"minecraft:oak_log[axis=x]", parsedBase,
|
||||
"minecraft:oak_log[axis=y]", parsedUpdate
|
||||
);
|
||||
|
||||
doThrow(new IllegalArgumentException("Block data not created via string parsing")).when(base).merge(update);
|
||||
doReturn("minecraft:oak_log[axis=x]").when(base).getAsString(false);
|
||||
doReturn("minecraft:oak_log[axis=y]").when(update).getAsString(false);
|
||||
doReturn(merged).when(parsedBase).merge(parsedUpdate);
|
||||
|
||||
BlockData result = BlockDataMergeSupport.merge(base, update, resolver);
|
||||
|
||||
assertSame(merged, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void fallsBackToNormalizedUpdateWhenRetryMergeStillFails() {
|
||||
BlockData base = mock(BlockData.class);
|
||||
BlockData update = mock(BlockData.class);
|
||||
BlockData parsedBase = mock(BlockData.class);
|
||||
BlockData parsedUpdate = mock(BlockData.class);
|
||||
Function<String, BlockData> resolver = createResolver(
|
||||
"minecraft:stone", parsedBase,
|
||||
"minecraft:stone[waterlogged=true]", parsedUpdate
|
||||
);
|
||||
|
||||
doThrow(new IllegalArgumentException("Block data not created via string parsing")).when(base).merge(update);
|
||||
doReturn("minecraft:stone").when(base).getAsString(false);
|
||||
doReturn("minecraft:stone[waterlogged=true]").when(update).getAsString(false);
|
||||
doThrow(new IllegalArgumentException("normalized merge failed")).when(parsedBase).merge(parsedUpdate);
|
||||
|
||||
BlockData result = BlockDataMergeSupport.merge(base, update, resolver);
|
||||
|
||||
assertSame(parsedUpdate, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void keepsDirectMergeWhenBukkitAcceptsIt() {
|
||||
BlockData base = mock(BlockData.class);
|
||||
BlockData update = mock(BlockData.class);
|
||||
BlockData merged = mock(BlockData.class);
|
||||
|
||||
doReturn(Material.STONE).when(base).getMaterial();
|
||||
doReturn(Material.STONE).when(update).getMaterial();
|
||||
doReturn(merged).when(base).merge(update);
|
||||
|
||||
BlockData result = BlockDataMergeSupport.merge(base, update, key -> null);
|
||||
|
||||
assertSame(merged, result);
|
||||
}
|
||||
|
||||
private Function<String, BlockData> createResolver(String firstKey, BlockData firstValue, String secondKey, BlockData secondValue) {
|
||||
Map<String, BlockData> resolved = new HashMap<>();
|
||||
resolved.put(firstKey, firstValue);
|
||||
resolved.put(secondKey, secondValue);
|
||||
return resolved::get;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package art.arcane.iris.util.common.parallel;
|
||||
|
||||
import art.arcane.volmlib.util.parallel.BurstExecutorSupport;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.concurrent.ForkJoinPool;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class BurstExecutorSupportReentrantTest {
|
||||
@Test
|
||||
public void runsNestedBurstInlineOnSameForkJoinPoolWorker() throws Exception {
|
||||
ForkJoinPool pool = new ForkJoinPool(1);
|
||||
AtomicBoolean nestedExecuted = new AtomicBoolean(false);
|
||||
|
||||
try {
|
||||
Future<?> future = pool.submit(() -> {
|
||||
BurstExecutorSupport burst = new BurstExecutorSupport(pool, 1);
|
||||
burst.queue(() -> {
|
||||
BurstExecutorSupport nested = new BurstExecutorSupport(pool, 1);
|
||||
nested.queue(() -> nestedExecuted.set(true));
|
||||
nested.complete();
|
||||
});
|
||||
burst.complete();
|
||||
});
|
||||
|
||||
future.get(5, TimeUnit.SECONDS);
|
||||
assertTrue(nestedExecuted.get());
|
||||
} finally {
|
||||
pool.shutdownNow();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package art.arcane.iris.util.common.plugin;
|
||||
|
||||
import net.kyori.adventure.text.minimessage.MiniMessage;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
public class VolmitSenderMiniMessageEscapeTest {
|
||||
@Test
|
||||
public void escapesApostrophesForQuotedHoverText() {
|
||||
String escaped = VolmitSender.escapeMiniMessageQuotedText("This world's dimension config");
|
||||
|
||||
assertEquals("This world\\'s dimension config", escaped);
|
||||
MiniMessage.miniMessage().deserialize("<hover:show_text:'" + escaped + "'>ok</hover>");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void escapesBackslashesBeforeQuotedHoverText() {
|
||||
String escaped = VolmitSender.escapeMiniMessageQuotedText("Path \\\\ data");
|
||||
|
||||
assertEquals("Path \\\\\\\\ data", escaped);
|
||||
MiniMessage.miniMessage().deserialize("<hover:show_text:'" + escaped + "'>ok</hover>");
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package art.arcane.iris.util.project.context;
|
||||
|
||||
import art.arcane.iris.Iris;
|
||||
import art.arcane.iris.engine.IrisComplex;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisRegion;
|
||||
@@ -7,9 +8,16 @@ import art.arcane.iris.util.project.stream.ProceduralStream;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.anyDouble;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
@@ -76,6 +84,16 @@ public class ChunkContextPrefillPlanTest {
|
||||
assertEquals(256, caveCalls.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void paperCommonWorkerThreadsDisableAsyncPrefillWhenPluginLoaded() throws Exception {
|
||||
assertPrefillAsyncDecision("Paper Common Worker #0", false);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void irisWorkerThreadsKeepAsyncPrefillWhenPluginLoaded() throws Exception {
|
||||
assertPrefillAsyncDecision("Iris 42", true);
|
||||
}
|
||||
|
||||
private ChunkContext createContext(
|
||||
ChunkContext.PrefillPlan prefillPlan,
|
||||
AtomicInteger caveCalls,
|
||||
@@ -145,4 +163,26 @@ public class ChunkContextPrefillPlanTest {
|
||||
|
||||
return new ChunkContext(32, 48, complex, true, prefillPlan, null);
|
||||
}
|
||||
|
||||
private void assertPrefillAsyncDecision(String threadName, boolean expected) throws InterruptedException, ExecutionException, java.util.concurrent.TimeoutException {
|
||||
Iris previous = Iris.instance;
|
||||
ExecutorService executor = Executors.newSingleThreadExecutor(runnable -> {
|
||||
Thread thread = new Thread(runnable);
|
||||
thread.setName(threadName);
|
||||
return thread;
|
||||
});
|
||||
try {
|
||||
Iris.instance = mock(Iris.class);
|
||||
Future<Boolean> future = executor.submit(() -> ChunkContext.shouldPrefillAsync(2));
|
||||
boolean actual = future.get(10, TimeUnit.SECONDS);
|
||||
if (expected) {
|
||||
assertTrue(actual);
|
||||
} else {
|
||||
assertFalse(actual);
|
||||
}
|
||||
} finally {
|
||||
Iris.instance = previous;
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
Paper 1.21.11
|
||||
/iris create matrix-paper overworld
|
||||
/iris std o overworld
|
||||
close Studio world
|
||||
benchmark create/unload
|
||||
|
||||
Purpur 1.21.11
|
||||
/iris create matrix-purpur overworld
|
||||
/iris std o overworld
|
||||
close Studio world
|
||||
benchmark create/unload
|
||||
|
||||
Canvas 1.21.11
|
||||
/iris create matrix-canvas overworld
|
||||
/iris std o overworld
|
||||
close Studio world
|
||||
benchmark create/unload
|
||||
|
||||
Folia 1.21.11
|
||||
/iris create matrix-folia overworld
|
||||
/iris std o overworld
|
||||
close Studio world
|
||||
benchmark create/unload
|
||||
|
||||
Spigot 1.21.11
|
||||
/iris create matrix-spigot overworld
|
||||
/iris std o overworld
|
||||
close Studio world
|
||||
unload/remove
|
||||
Reference in New Issue
Block a user