mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-07-13 02:15:46 +00:00
O-o .o-o. o-O
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
String apiVersion = providers.gradleProperty('minecraftVersion').get()
|
||||
def mainClass = 'art.arcane.iris.Iris'
|
||||
def bootstrapperClass = 'art.arcane.iris.IrisBootstrap'
|
||||
|
||||
String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate')
|
||||
.orElse('com.github.VolmitSoftware:VolmLib:cdf7e8bb880199734dacd386a6616bda799fcde1')
|
||||
@@ -29,9 +30,10 @@ tasks.named('processResources').configure {
|
||||
version : rootProject.version,
|
||||
apiVersion: apiVersion,
|
||||
main : mainClass,
|
||||
bootstrapper: bootstrapperClass,
|
||||
]
|
||||
inputs.properties(pluginProperties)
|
||||
filesMatching('**/plugin.yml') {
|
||||
filesMatching(['**/paper-plugin.yml', '**/plugin.yml']) {
|
||||
expand(pluginProperties)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package art.arcane.iris;
|
||||
|
||||
import art.arcane.iris.core.pack.DefaultPackBootstrapProvisioner;
|
||||
import art.arcane.iris.core.pack.DefaultPackBootstrapProvisioner.ProvisionResult;
|
||||
import io.papermc.paper.datapack.Datapack;
|
||||
import io.papermc.paper.datapack.DatapackRegistrar;
|
||||
import io.papermc.paper.datapack.DiscoveredDatapack;
|
||||
import io.papermc.paper.plugin.bootstrap.BootstrapContext;
|
||||
import io.papermc.paper.plugin.bootstrap.PluginBootstrap;
|
||||
import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEvents;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
|
||||
@SuppressWarnings("UnstableApiUsage")
|
||||
public final class IrisBootstrap implements PluginBootstrap {
|
||||
static final String PACK_ID = "generated";
|
||||
|
||||
@Override
|
||||
public void bootstrap(BootstrapContext context) {
|
||||
ProvisionResult provisioned = provision(context);
|
||||
Path datapackRoot = provisioned.datapackRoot();
|
||||
context.getLogger().info("Iris startup datapack is {} at {}", provisioned.status(), datapackRoot);
|
||||
context.getLifecycleManager().registerEventHandler(LifecycleEvents.DATAPACK_DISCOVERY, event -> {
|
||||
try {
|
||||
discoverPack(event.registrar(), datapackRoot);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Unable to discover the Iris startup datapack at " + datapackRoot, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static DiscoveredDatapack discoverPack(DatapackRegistrar registrar, Path datapackRoot) throws IOException {
|
||||
DiscoveredDatapack datapack = registrar.discoverPack(
|
||||
datapackRoot,
|
||||
PACK_ID,
|
||||
configurer -> configurer
|
||||
.autoEnableOnServerStart(true)
|
||||
.position(true, Datapack.Position.TOP)
|
||||
);
|
||||
if (datapack == null) {
|
||||
throw new IllegalStateException("Paper did not accept the Iris startup datapack at " + datapackRoot);
|
||||
}
|
||||
return datapack;
|
||||
}
|
||||
|
||||
private static ProvisionResult provision(BootstrapContext context) {
|
||||
try {
|
||||
return DefaultPackBootstrapProvisioner.provision(
|
||||
context.getDataDirectory(),
|
||||
message -> context.getLogger().info(message)
|
||||
);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Unable to provision the Iris startup datapack", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import art.arcane.iris.core.IrisWorlds;
|
||||
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.pack.PackDownloader;
|
||||
import art.arcane.iris.core.service.StudioSVC;
|
||||
import art.arcane.iris.core.tools.IrisToolbelt;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
@@ -460,10 +461,11 @@ public class CommandIris implements DirectorExecutor {
|
||||
@Param(name = "overwrite", description = "Whether or not to overwrite the pack with the downloaded one", aliases = "force", defaultValue = "false")
|
||||
boolean overwrite
|
||||
) {
|
||||
sender().sendMessage(C.GREEN + "Downloading pack: " + pack + "/" + branch + (overwrite ? " overwriting" : ""));
|
||||
if (pack.equals("overworld")) {
|
||||
Iris.service(StudioSVC.class).downloadBranch(sender(), "IrisDimensions/overworld", "master", overwrite);
|
||||
if (PackDownloader.isDefaultOverworld(pack)) {
|
||||
sender().sendMessage(C.GREEN + "Downloading pack: " + pack + " (beta release)" + (overwrite ? " overwriting" : ""));
|
||||
Iris.service(StudioSVC.class).downloadDefaultOverworld(sender(), overwrite);
|
||||
} else {
|
||||
sender().sendMessage(C.GREEN + "Downloading pack: " + pack + "/" + branch + (overwrite ? " overwriting" : ""));
|
||||
Iris.service(StudioSVC.class).downloadSearch(sender(), "IrisDimensions/" + pack + "/" + branch, overwrite);
|
||||
}
|
||||
ServerConfigurator.installDataPacksIfChanged(true);
|
||||
|
||||
@@ -50,6 +50,8 @@ import org.bukkit.entity.Player;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -64,14 +66,13 @@ public class CommandSVC implements IrisService, CommandExecutor, TabCompleter, D
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
PluginCommand command = Iris.instance.getCommand(ROOT_COMMAND);
|
||||
if (command == null) {
|
||||
Iris.warn("Failed to find command '" + ROOT_COMMAND + "'");
|
||||
return;
|
||||
PluginCommand command = findBukkitCommand();
|
||||
if (command != null) {
|
||||
command.setExecutor(this);
|
||||
command.setTabCompleter(this);
|
||||
} else {
|
||||
registerPaperCommand();
|
||||
}
|
||||
|
||||
command.setExecutor(this);
|
||||
command.setTabCompleter(this);
|
||||
J.a(this::getDirector);
|
||||
}
|
||||
|
||||
@@ -139,12 +140,7 @@ public class CommandSVC implements IrisService, CommandExecutor, TabCompleter, D
|
||||
return List.of();
|
||||
}
|
||||
|
||||
List<String> v = runDirectorTab(sender, alias, args);
|
||||
if (sender instanceof Player player && IrisSettings.get().getGeneral().isCommandSounds()) {
|
||||
player.playSound(player.getLocation(), Sound.BLOCK_AMETHYST_BLOCK_CHIME, 0.25f, RNG.r.f(0.125f, 1.95f));
|
||||
}
|
||||
|
||||
return v;
|
||||
return tabCompleteRoot(sender, alias, args);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -153,13 +149,52 @@ public class CommandSVC implements IrisService, CommandExecutor, TabCompleter, D
|
||||
return false;
|
||||
}
|
||||
|
||||
executeRoot(sender, label, args);
|
||||
return true;
|
||||
}
|
||||
|
||||
void executeRoot(CommandSender sender, String label, String[] args) {
|
||||
if (!sender.hasPermission(ROOT_PERMISSION)) {
|
||||
sender.sendMessage("You lack the Permission '" + ROOT_PERMISSION + "'");
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
|
||||
J.aBukkit(() -> executeCommand(sender, label, args));
|
||||
return true;
|
||||
}
|
||||
|
||||
List<String> tabCompleteRoot(CommandSender sender, String alias, String[] args) {
|
||||
List<String> suggestions = runDirectorTab(sender, alias, args);
|
||||
if (sender instanceof Player player && IrisSettings.get().getGeneral().isCommandSounds()) {
|
||||
player.playSound(player.getLocation(), Sound.BLOCK_AMETHYST_BLOCK_CHIME, 0.25f, RNG.r.f(0.125f, 1.95f));
|
||||
}
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
private PluginCommand findBukkitCommand() {
|
||||
try {
|
||||
return Iris.instance.getCommand(ROOT_COMMAND);
|
||||
} catch (UnsupportedOperationException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void registerPaperCommand() {
|
||||
try {
|
||||
Class<?> registrarType = Class.forName(
|
||||
"art.arcane.iris.core.service.PaperCommandRegistrar",
|
||||
true,
|
||||
getClass().getClassLoader()
|
||||
);
|
||||
Method register = registrarType.getDeclaredMethod("register", Iris.class, CommandSVC.class);
|
||||
register.invoke(null, Iris.instance, this);
|
||||
} catch (InvocationTargetException e) {
|
||||
Throwable cause = e.getCause() == null ? e : e.getCause();
|
||||
throw new IllegalStateException("Paper command registration failed", cause);
|
||||
} catch (ReflectiveOperationException e) {
|
||||
throw new IllegalStateException("Paper command registrar is unavailable", e);
|
||||
} catch (LinkageError e) {
|
||||
throw new IllegalStateException("Paper command APIs are unavailable", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void executeCommand(CommandSender sender, String label, String[] args) {
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package art.arcane.iris.core.service;
|
||||
|
||||
import art.arcane.iris.Iris;
|
||||
import io.papermc.paper.command.brigadier.BasicCommand;
|
||||
import io.papermc.paper.command.brigadier.CommandSourceStack;
|
||||
import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEvents;
|
||||
import org.bukkit.command.CommandSender;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
final class PaperCommandRegistrar {
|
||||
private static final String ROOT_COMMAND = "iris";
|
||||
|
||||
private PaperCommandRegistrar() {
|
||||
}
|
||||
|
||||
static void register(Iris plugin, CommandSVC commandService) {
|
||||
plugin.getLifecycleManager().registerEventHandler(LifecycleEvents.COMMANDS, event ->
|
||||
event.registrar().register(
|
||||
ROOT_COMMAND,
|
||||
"Iris world generation command.",
|
||||
List.of("ir", "irs"),
|
||||
command(commandService)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
static BasicCommand command(CommandSVC commandService) {
|
||||
return new PaperCommand(commandService);
|
||||
}
|
||||
|
||||
private record PaperCommand(CommandSVC commandService) implements BasicCommand {
|
||||
@Override
|
||||
public void execute(CommandSourceStack source, String[] args) {
|
||||
commandService.executeRoot(source.getSender(), ROOT_COMMAND, args);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<String> suggest(CommandSourceStack source, String[] args) {
|
||||
return commandService.tabCompleteRoot(source.getSender(), ROOT_COMMAND, args);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean canUse(CommandSender sender) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
name: ${name}
|
||||
version: ${version}
|
||||
main: ${main}
|
||||
bootstrapper: ${bootstrapper}
|
||||
folia-supported: true
|
||||
api-version: '${apiVersion}'
|
||||
load: STARTUP
|
||||
authors: [ cyberpwn, NextdoorPsycho, Vatuu ]
|
||||
website: volmit.com
|
||||
description: More than a Dimension!
|
||||
@@ -0,0 +1,199 @@
|
||||
package art.arcane.iris;
|
||||
|
||||
import io.papermc.paper.datapack.Datapack;
|
||||
import io.papermc.paper.datapack.DatapackRegistrar;
|
||||
import io.papermc.paper.datapack.DatapackSource;
|
||||
import io.papermc.paper.datapack.DiscoveredDatapack;
|
||||
import io.papermc.paper.plugin.configuration.PluginMeta;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.bukkit.FeatureFlag;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class IrisBootstrapTest {
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void discoverPackAutoEnablesAtFixedTop() throws Exception {
|
||||
File datapackDirectory = temporaryFolder.newFolder("datapack");
|
||||
RecordingRegistrar registrar = new RecordingRegistrar(DiscoveryBehavior.ACCEPT);
|
||||
|
||||
DiscoveredDatapack discovered = IrisBootstrap.discoverPack(registrar, datapackDirectory.toPath());
|
||||
|
||||
assertSame(registrar.discoveredDatapack, discovered);
|
||||
assertEquals(datapackDirectory.toPath(), registrar.discoveredPath);
|
||||
assertEquals(IrisBootstrap.PACK_ID, registrar.discoveredId);
|
||||
assertTrue(registrar.configurer.autoEnableOnServerStart);
|
||||
assertTrue(registrar.configurer.fixedPosition);
|
||||
assertEquals(Datapack.Position.TOP, registrar.configurer.position);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectedDiscoveryReportsDatapackPath() throws Exception {
|
||||
File datapackDirectory = temporaryFolder.newFolder("datapack");
|
||||
RecordingRegistrar registrar = new RecordingRegistrar(DiscoveryBehavior.REJECT);
|
||||
|
||||
IllegalStateException failure = assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> IrisBootstrap.discoverPack(registrar, datapackDirectory.toPath())
|
||||
);
|
||||
|
||||
assertTrue(failure.getMessage().contains(datapackDirectory.toPath().toString()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void discoveryIoFailurePropagates() throws Exception {
|
||||
File datapackDirectory = temporaryFolder.newFolder("datapack");
|
||||
RecordingRegistrar registrar = new RecordingRegistrar(DiscoveryBehavior.FAIL);
|
||||
|
||||
IOException failure = assertThrows(
|
||||
IOException.class,
|
||||
() -> IrisBootstrap.discoverPack(registrar, datapackDirectory.toPath())
|
||||
);
|
||||
|
||||
assertEquals("discovery failed", failure.getMessage());
|
||||
}
|
||||
|
||||
private enum DiscoveryBehavior {
|
||||
ACCEPT,
|
||||
REJECT,
|
||||
FAIL
|
||||
}
|
||||
|
||||
private static final class RecordingConfigurer implements DatapackRegistrar.Configurer {
|
||||
private boolean autoEnableOnServerStart;
|
||||
private boolean fixedPosition;
|
||||
private Datapack.Position position;
|
||||
|
||||
@Override
|
||||
public DatapackRegistrar.Configurer title(Component title) {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public DatapackRegistrar.Configurer autoEnableOnServerStart(boolean autoEnableOnServerStart) {
|
||||
this.autoEnableOnServerStart = autoEnableOnServerStart;
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public DatapackRegistrar.Configurer position(boolean fixed, Datapack.Position position) {
|
||||
this.fixedPosition = fixed;
|
||||
this.position = position;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingRegistrar implements DatapackRegistrar {
|
||||
private final DiscoveryBehavior behavior;
|
||||
private final RecordingConfigurer configurer;
|
||||
private final DiscoveredDatapack discoveredDatapack;
|
||||
private Path discoveredPath;
|
||||
private String discoveredId;
|
||||
|
||||
private RecordingRegistrar(DiscoveryBehavior behavior) {
|
||||
this.behavior = behavior;
|
||||
this.configurer = new RecordingConfigurer();
|
||||
this.discoveredDatapack = new StubDiscoveredDatapack();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasPackDiscovered(String name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public DiscoveredDatapack getDiscoveredPack(String name) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean removeDiscoveredPack(String name) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, DiscoveredDatapack> getDiscoveredPacks() {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
|
||||
@Override
|
||||
public DiscoveredDatapack discoverPack(URI uri, String id, Consumer<Configurer> configurer) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public DiscoveredDatapack discoverPack(Path path, String id, Consumer<Configurer> configurer) throws IOException {
|
||||
this.discoveredPath = path;
|
||||
this.discoveredId = id;
|
||||
if (behavior == DiscoveryBehavior.FAIL) {
|
||||
throw new IOException("discovery failed");
|
||||
}
|
||||
configurer.accept(this.configurer);
|
||||
return behavior == DiscoveryBehavior.ACCEPT ? discoveredDatapack : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public DiscoveredDatapack discoverPack(PluginMeta pluginMeta, URI uri, String id, Consumer<Configurer> configurer) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public DiscoveredDatapack discoverPack(PluginMeta pluginMeta, Path path, String id, Consumer<Configurer> configurer) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
}
|
||||
|
||||
private static final class StubDiscoveredDatapack implements DiscoveredDatapack {
|
||||
@Override
|
||||
public String getName() {
|
||||
return IrisBootstrap.PACK_ID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Component getTitle() {
|
||||
return Component.text(IrisBootstrap.PACK_ID);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Component getDescription() {
|
||||
return Component.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRequired() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Datapack.Compatibility getCompatibility() {
|
||||
return Datapack.Compatibility.COMPATIBLE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<FeatureFlag> getRequiredFeatures() {
|
||||
return Collections.emptySet();
|
||||
}
|
||||
|
||||
@Override
|
||||
public DatapackSource getSource() {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package art.arcane.iris;
|
||||
|
||||
import org.bukkit.plugin.PluginDescriptionFile;
|
||||
import org.bukkit.plugin.PluginLoadOrder;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class PaperPluginMetadataTest {
|
||||
@Test
|
||||
public void paperMetadataDeclaresBootstrapAndFoliaSupport() throws Exception {
|
||||
String metadata;
|
||||
try (InputStream stream = PaperPluginMetadataTest.class.getResourceAsStream("/paper-plugin.yml")) {
|
||||
assertNotNull(stream);
|
||||
metadata = new String(stream.readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
assertTrue(metadata.contains("bootstrapper: " + IrisBootstrap.class.getName()));
|
||||
assertTrue(metadata.contains("folia-supported: true"));
|
||||
assertTrue(metadata.contains("load: STARTUP"));
|
||||
assertFalse(metadata.contains("commands:"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bukkitMetadataRetainsStartupCommandAndAliases() throws Exception {
|
||||
PluginDescriptionFile metadata;
|
||||
try (InputStream stream = PaperPluginMetadataTest.class.getResourceAsStream("/plugin.yml")) {
|
||||
assertNotNull(stream);
|
||||
metadata = new PluginDescriptionFile(stream);
|
||||
}
|
||||
|
||||
assertEquals(Iris.class.getName(), metadata.getMain());
|
||||
assertEquals(PluginLoadOrder.STARTUP, metadata.getLoad());
|
||||
Map<String, Map<String, Object>> commands = metadata.getCommands();
|
||||
assertTrue(commands.containsKey("iris"));
|
||||
assertEquals(List.of("ir", "irs"), commands.get("iris").get("aliases"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void processedResourcesContainBothMetadataFormats() throws Exception {
|
||||
URL paperMetadata = PaperPluginMetadataTest.class.getResource("/paper-plugin.yml");
|
||||
assertNotNull(paperMetadata);
|
||||
assertEquals("file", paperMetadata.getProtocol());
|
||||
Path bukkitMetadata = Path.of(paperMetadata.toURI()).resolveSibling("plugin.yml");
|
||||
assertTrue(Files.isRegularFile(bukkitMetadata));
|
||||
assertFalse(Files.readString(bukkitMetadata, StandardCharsets.UTF_8).contains("${"));
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
package art.arcane.iris.core.service;
|
||||
|
||||
import io.papermc.paper.command.brigadier.BasicCommand;
|
||||
import io.papermc.paper.command.brigadier.CommandSourceStack;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class PaperCommandRegistrarTest {
|
||||
@Test
|
||||
public void paperCommandDelegatesExecutionAndSuggestions() {
|
||||
RecordingCommandService service = new RecordingCommandService();
|
||||
BasicCommand command = PaperCommandRegistrar.command(service);
|
||||
CommandSender sender = sender();
|
||||
CommandSourceStack source = new TestCommandSourceStack(sender);
|
||||
String[] arguments = {"create", "world"};
|
||||
|
||||
command.execute(source, arguments);
|
||||
assertSame(sender, service.sender);
|
||||
assertEquals("iris", service.label);
|
||||
assertArrayEquals(arguments, service.arguments);
|
||||
assertEquals(List.of("first", "second"), command.suggest(source, new String[]{"cr"}));
|
||||
assertTrue(command.canUse(sender));
|
||||
}
|
||||
|
||||
private static CommandSender sender() {
|
||||
return (CommandSender) Proxy.newProxyInstance(
|
||||
CommandSender.class.getClassLoader(),
|
||||
new Class<?>[]{CommandSender.class},
|
||||
(proxy, method, arguments) -> primitiveDefault(method.getReturnType())
|
||||
);
|
||||
}
|
||||
|
||||
private static Object primitiveDefault(Class<?> type) {
|
||||
if (type == boolean.class) {
|
||||
return false;
|
||||
}
|
||||
if (type == byte.class) {
|
||||
return (byte) 0;
|
||||
}
|
||||
if (type == short.class) {
|
||||
return (short) 0;
|
||||
}
|
||||
if (type == int.class) {
|
||||
return 0;
|
||||
}
|
||||
if (type == long.class) {
|
||||
return 0L;
|
||||
}
|
||||
if (type == float.class) {
|
||||
return 0F;
|
||||
}
|
||||
if (type == double.class) {
|
||||
return 0D;
|
||||
}
|
||||
if (type == char.class) {
|
||||
return '\0';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static final class RecordingCommandService extends CommandSVC {
|
||||
private CommandSender sender;
|
||||
private String label;
|
||||
private String[] arguments;
|
||||
|
||||
@Override
|
||||
void executeRoot(CommandSender sender, String label, String[] args) {
|
||||
this.sender = sender;
|
||||
this.label = label;
|
||||
this.arguments = args;
|
||||
}
|
||||
|
||||
@Override
|
||||
List<String> tabCompleteRoot(CommandSender sender, String alias, String[] args) {
|
||||
return List.of("first", "second");
|
||||
}
|
||||
}
|
||||
|
||||
private record TestCommandSourceStack(CommandSender sender) implements CommandSourceStack {
|
||||
@Override
|
||||
public Location getLocation() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CommandSender getSender() {
|
||||
return sender;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Entity getExecutor() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CommandSourceStack withLocation(Location location) {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CommandSourceStack withExecutor(Entity executor) {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -121,7 +121,7 @@ public final class ModdedForcedDatapack {
|
||||
|
||||
File packFolder = packDirectory.toFile();
|
||||
KList<File> folders = new KList<>();
|
||||
folders.add(packFolder.getParentFile());
|
||||
folders.add(packFolder);
|
||||
KSet<String> seenBiomes = new KSet<>();
|
||||
IDataFixer fixer = DataVersion.getLatest().get();
|
||||
|
||||
@@ -185,8 +185,8 @@ public final class ModdedForcedDatapack {
|
||||
private static void writeWorldPreset(KList<File> folders, IrisDimension dimension, String packName, String dimensionKey, String presetKey) throws IOException {
|
||||
String dimensionRef = dimensionKey.equals(packName) ? packName : packName + ":" + dimensionKey;
|
||||
String json = worldPresetJson(dimensionRef, dimensionTypeRef(dimension));
|
||||
for (File parent : folders) {
|
||||
Path output = parent.toPath().resolve(PACK_FOLDER).resolve("data").resolve("irisworldgen").resolve("worldgen").resolve("world_preset").resolve(presetKey + ".json");
|
||||
for (File datapackRoot : folders) {
|
||||
Path output = datapackRoot.toPath().resolve("data").resolve("irisworldgen").resolve("worldgen").resolve("world_preset").resolve(presetKey + ".json");
|
||||
Files.createDirectories(output.getParent());
|
||||
Files.writeString(output, json, StandardCharsets.UTF_8);
|
||||
}
|
||||
@@ -257,8 +257,8 @@ public final class ModdedForcedDatapack {
|
||||
IrisDimensionType type = dimension.getDimensionType();
|
||||
String json = type.toJson(fixer);
|
||||
String typeKey = dimension.getDimensionTypeKey();
|
||||
for (File parent : folders) {
|
||||
Path output = parent.toPath().resolve(PACK_FOLDER).resolve("data").resolve("irisworldgen").resolve("dimension_type").resolve(typeKey + ".json");
|
||||
for (File datapackRoot : folders) {
|
||||
Path output = datapackRoot.toPath().resolve("data").resolve("irisworldgen").resolve("dimension_type").resolve(typeKey + ".json");
|
||||
Files.createDirectories(output.getParent());
|
||||
Files.writeString(output, json, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
@@ -48,6 +48,9 @@ public final class ModdedPackInstaller {
|
||||
|
||||
File packs = configDir.resolve("irisworldgen").resolve("packs").toFile();
|
||||
try {
|
||||
if (PackDownloader.isDefaultOverworld(pack)) {
|
||||
return PackDownloader.downloadDefaultOverworld(packs, true, feedback) != null;
|
||||
}
|
||||
return PackDownloader.download(packs, "IrisDimensions/" + pack, branch, true, false, feedback) != null;
|
||||
} catch (IOException error) {
|
||||
LOGGER.error("Iris pack download failed for IrisDimensions/{} ({})", pack, branch, error);
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import art.arcane.iris.core.pack.PackDownloader;
|
||||
import art.arcane.iris.core.pack.PackValidationRegistry;
|
||||
import art.arcane.iris.core.pack.PackValidationResult;
|
||||
import art.arcane.iris.core.pack.PackValidator;
|
||||
@@ -131,7 +132,8 @@ public final class ModdedStartup {
|
||||
if (new File(packFolder, "dimensions/" + pack + ".json").isFile()) {
|
||||
return;
|
||||
}
|
||||
LOGGER.info("Iris default pack '{}' missing; downloading IrisDimensions/{} (master)", pack, pack);
|
||||
String source = PackDownloader.isDefaultOverworld(pack) ? "beta release" : "master branch";
|
||||
LOGGER.info("Iris default pack '{}' missing; downloading IrisDimensions/{} ({})", pack, pack, source);
|
||||
boolean installed = ModdedPackInstaller.install(configDir, pack, "master", (String line) -> LOGGER.info("Iris: {}", line));
|
||||
if (!installed) {
|
||||
LOGGER.warn("Iris default pack '{}' could not be downloaded; install it with /iris download {}", pack, pack);
|
||||
|
||||
+6
-4
@@ -21,6 +21,7 @@ package art.arcane.iris.modded.command;
|
||||
import art.arcane.iris.core.IrisSettings;
|
||||
import art.arcane.iris.core.gui.GuiHost;
|
||||
import art.arcane.iris.core.loader.IrisRegistrant;
|
||||
import art.arcane.iris.core.pack.PackDownloader;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.IrisStructureLocator;
|
||||
import art.arcane.iris.engine.framework.Locator;
|
||||
@@ -983,15 +984,16 @@ public final class IrisModdedCommands {
|
||||
|
||||
private static int download(CommandSourceStack source, String pack, String branch) {
|
||||
MinecraftServer server = source.getServer();
|
||||
String effectiveBranch = pack.equals("overworld") ? "master" : branch;
|
||||
ok(source, "Downloading IrisDimensions/" + pack + " (branch " + effectiveBranch + ")...");
|
||||
boolean defaultOverworld = PackDownloader.isDefaultOverworld(pack);
|
||||
String downloadSource = defaultOverworld ? "beta release" : "branch " + branch;
|
||||
ok(source, "Downloading IrisDimensions/" + pack + " (" + downloadSource + ")...");
|
||||
Thread thread = new Thread(() -> {
|
||||
boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), pack, effectiveBranch,
|
||||
boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), pack, branch,
|
||||
(String message) -> server.execute(() -> ok(source, message)));
|
||||
if (installed) {
|
||||
server.execute(() -> ok(source, "Pack '" + pack + "' installed. Its dimension types and custom biomes join the forced Iris datapack on the next server restart; worlds created before restarting run with fallback heights until then."));
|
||||
} else {
|
||||
server.execute(() -> fail(source, "Pack download failed for " + pack + "/" + effectiveBranch + " (see console)."));
|
||||
server.execute(() -> fail(source, "Pack download failed for " + pack + " (" + downloadSource + "; see console)."));
|
||||
}
|
||||
}, "Iris Pack Download");
|
||||
thread.setDaemon(true);
|
||||
|
||||
+3
-3
@@ -87,6 +87,9 @@ dependencies {
|
||||
implementation(volmLibCoordinate) {
|
||||
transitive = false
|
||||
}
|
||||
implementation(libs.gson)
|
||||
implementation(libs.lru)
|
||||
implementation(libs.caffeine)
|
||||
|
||||
// Dynamically Loaded
|
||||
slim(libs.paralithic)
|
||||
@@ -104,11 +107,8 @@ dependencies {
|
||||
slim(libs.oshi)
|
||||
slim(libs.lz4)
|
||||
slim(libs.fastutil)
|
||||
slim(libs.lru)
|
||||
slim(libs.zip)
|
||||
slim(libs.gson)
|
||||
slim(libs.asm)
|
||||
slim(libs.caffeine)
|
||||
slim(libs.byteBuddy.core)
|
||||
slim(libs.byteBuddy.agent)
|
||||
slim(libs.dom4j)
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
package art.arcane.iris.core;
|
||||
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.loader.ResourceLoader;
|
||||
import art.arcane.iris.core.nms.datapack.IDataFixer;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.volmlib.util.collection.KSet;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.atomic.AtomicIntegerArray;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public final class IrisDatapackCompiler {
|
||||
private static final int WORLD_PACK_SCAN_DEPTH = 8;
|
||||
|
||||
private IrisDatapackCompiler() {
|
||||
}
|
||||
|
||||
public static List<File> collectPackRoots(Path dataDirectory, Path serverRoot) throws IOException {
|
||||
LinkedHashMap<Path, File> roots = new LinkedHashMap<>();
|
||||
collectInstalledPackRoots(dataDirectory.resolve("packs"), roots);
|
||||
collectWorldPackRoots(serverRoot.resolve("dimensions"), roots);
|
||||
return new ArrayList<>(roots.values());
|
||||
}
|
||||
|
||||
public static CompilationResult compile(
|
||||
List<File> packRoots,
|
||||
KList<File> datapackRoots,
|
||||
IDataFixer fixer,
|
||||
int packFormat,
|
||||
boolean adjustVanillaHeight
|
||||
) throws IOException {
|
||||
Objects.requireNonNull(packRoots, "packRoots");
|
||||
Objects.requireNonNull(datapackRoots, "datapackRoots");
|
||||
Objects.requireNonNull(fixer, "fixer");
|
||||
if (datapackRoots.isEmpty()) {
|
||||
throw new IOException("No Iris datapack output roots were provided");
|
||||
}
|
||||
|
||||
for (File datapackRoot : datapackRoots) {
|
||||
Files.createDirectories(datapackRoot.toPath());
|
||||
}
|
||||
IrisDimension.clearGeneratedBiomeTags(datapackRoots);
|
||||
|
||||
DimensionHeight height = new DimensionHeight(fixer);
|
||||
Map<String, KSet<String>> biomes = new LinkedHashMap<>();
|
||||
int packCount = 0;
|
||||
int dimensionCount = 0;
|
||||
for (File packRoot : packRoots) {
|
||||
if (!hasDimensions(packRoot.toPath())) {
|
||||
continue;
|
||||
}
|
||||
IrisData data = IrisData.openDatapackCompiler(packRoot);
|
||||
try {
|
||||
ResourceLoader<IrisDimension> loader = data.getDimensionLoader();
|
||||
String[] possibleKeys = loader.getPossibleKeys();
|
||||
if (possibleKeys == null || possibleKeys.length == 0) {
|
||||
throw new IOException("Iris pack has no dimension definitions: " + packRoot);
|
||||
}
|
||||
|
||||
int installedDimensions = 0;
|
||||
for (String possibleKey : possibleKeys) {
|
||||
IrisDimension dimension = loader.load(possibleKey);
|
||||
if (dimension == null) {
|
||||
throw new IOException("Unable to load Iris dimension '" + possibleKey + "' from " + packRoot);
|
||||
}
|
||||
IrisLogging.debug(" Compiling Dimension " + dimension.getLoadFile().getPath());
|
||||
height.merge(dimension);
|
||||
KSet<String> seenBiomes = biomes.computeIfAbsent(dimension.getLoadKey(), ignored -> new KSet<>());
|
||||
dimension.installBiomes(fixer, dimension::getLoader, datapackRoots, seenBiomes);
|
||||
dimension.installDimensionType(fixer, datapackRoots);
|
||||
installedDimensions++;
|
||||
dimensionCount++;
|
||||
}
|
||||
if (installedDimensions > 0) {
|
||||
packCount++;
|
||||
}
|
||||
} finally {
|
||||
data.close();
|
||||
}
|
||||
}
|
||||
|
||||
IrisDimension.writeShared(datapackRoots, height, packFormat, adjustVanillaHeight);
|
||||
validateOutputs(datapackRoots);
|
||||
return new CompilationResult(packCount, dimensionCount, countBiomes(biomes));
|
||||
}
|
||||
|
||||
private static void collectInstalledPackRoots(Path packsRoot, Map<Path, File> roots) throws IOException {
|
||||
if (!Files.isDirectory(packsRoot)) {
|
||||
return;
|
||||
}
|
||||
try (Stream<Path> stream = Files.list(packsRoot)) {
|
||||
List<Path> candidates = stream
|
||||
.filter(Files::isDirectory)
|
||||
.sorted(Comparator.comparing(Path::toString))
|
||||
.toList();
|
||||
for (Path candidate : candidates) {
|
||||
addPackRoot(candidate, roots);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void collectWorldPackRoots(Path dimensionsRoot, Map<Path, File> roots) throws IOException {
|
||||
if (!Files.isDirectory(dimensionsRoot)) {
|
||||
return;
|
||||
}
|
||||
try (Stream<Path> stream = Files.find(
|
||||
dimensionsRoot,
|
||||
WORLD_PACK_SCAN_DEPTH,
|
||||
(path, attributes) -> attributes.isDirectory()
|
||||
&& "pack".equals(path.getFileName().toString())
|
||||
&& path.getParent() != null
|
||||
&& "iris".equals(path.getParent().getFileName().toString())
|
||||
&& hasDimensions(path)
|
||||
)) {
|
||||
List<Path> candidates = stream.sorted(Comparator.comparing(Path::toString)).toList();
|
||||
for (Path candidate : candidates) {
|
||||
addPackRoot(candidate, roots);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void addPackRoot(Path root, Map<Path, File> roots) throws IOException {
|
||||
if (!hasDimensions(root)) {
|
||||
return;
|
||||
}
|
||||
Path normalized = root.toAbsolutePath().normalize();
|
||||
Path identity = normalized.toRealPath();
|
||||
roots.putIfAbsent(identity, normalized.toFile());
|
||||
}
|
||||
|
||||
private static boolean hasDimensions(Path root) {
|
||||
Path dimensions = root.resolve("dimensions");
|
||||
if (!Files.isDirectory(dimensions)) {
|
||||
return false;
|
||||
}
|
||||
try (Stream<Path> stream = Files.list(dimensions)) {
|
||||
return stream.anyMatch(path -> Files.isRegularFile(path) && path.getFileName().toString().endsWith(".json"));
|
||||
} catch (IOException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateOutputs(Collection<File> datapackRoots) throws IOException {
|
||||
for (File datapackRoot : datapackRoots) {
|
||||
Path root = datapackRoot.toPath();
|
||||
if (!Files.isRegularFile(root.resolve("pack.mcmeta"))) {
|
||||
throw new IOException("Iris datapack metadata was not generated at " + root);
|
||||
}
|
||||
if (!Files.isDirectory(root.resolve("data/iris/dimension_type"))) {
|
||||
throw new IOException("Iris dimension types were not generated at " + root);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int countBiomes(Map<String, KSet<String>> biomes) {
|
||||
int count = 0;
|
||||
for (KSet<String> values : biomes.values()) {
|
||||
count += values.size();
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
public record CompilationResult(int packCount, int dimensionCount, int biomeCount) {
|
||||
}
|
||||
|
||||
public static final class DimensionHeight {
|
||||
private final IDataFixer fixer;
|
||||
private final AtomicIntegerArray[] dimensions = new AtomicIntegerArray[3];
|
||||
|
||||
public DimensionHeight(IDataFixer fixer) {
|
||||
this.fixer = Objects.requireNonNull(fixer, "fixer");
|
||||
for (int index = 0; index < dimensions.length; index++) {
|
||||
dimensions[index] = new AtomicIntegerArray(new int[]{
|
||||
Integer.MAX_VALUE, Integer.MIN_VALUE, Integer.MIN_VALUE
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public void merge(IrisDimension dimension) {
|
||||
AtomicIntegerArray values = dimensions[dimension.getBaseDimension().ordinal()];
|
||||
values.updateAndGet(0, current -> Math.min(current, dimension.getMinHeight()));
|
||||
values.updateAndGet(1, current -> Math.max(current, dimension.getMaxHeight()));
|
||||
values.updateAndGet(2, current -> Math.max(current, dimension.getLogicalHeight()));
|
||||
}
|
||||
|
||||
public String[] jsonStrings() {
|
||||
IDataFixer.Dimension[] types = IDataFixer.Dimension.values();
|
||||
String[] output = new String[types.length];
|
||||
for (int index = 0; index < types.length; index++) {
|
||||
output[index] = jsonString(types[index]);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
private String jsonString(IDataFixer.Dimension dimension) {
|
||||
AtomicIntegerArray values = dimensions[dimension.ordinal()];
|
||||
int minY = values.get(0);
|
||||
int maxY = values.get(1);
|
||||
int logicalHeight = values.get(2);
|
||||
if (minY == Integer.MAX_VALUE || maxY == Integer.MIN_VALUE || logicalHeight == Integer.MIN_VALUE) {
|
||||
return null;
|
||||
}
|
||||
return fixer.createDimension(dimension, minY, maxY - minY, logicalHeight, null).toString(4);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,8 @@ import art.arcane.iris.core.loader.IrisData;
|
||||
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.core.pack.DefaultPackBootstrapProvisioner;
|
||||
import art.arcane.iris.platform.bukkit.BukkitPlatform;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisBiomeCustom;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
@@ -56,7 +58,6 @@ import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicIntegerArray;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public class ServerConfigurator {
|
||||
@@ -70,7 +71,11 @@ public class ServerConfigurator {
|
||||
J.attempt(ServerConfigurator::increasePaperWatchdog);
|
||||
}
|
||||
|
||||
installDataPacks(true);
|
||||
if (DefaultPackBootstrapProvisioner.wasProvisionedThisStartup()) {
|
||||
IrisLogging.info("Paper loaded the Iris datapack during bootstrap; skipping the legacy startup install.");
|
||||
} else {
|
||||
installDataPacks(true);
|
||||
}
|
||||
}
|
||||
|
||||
private static void increaseKeepAliveSpigot() throws IOException, InvalidConfigurationException {
|
||||
@@ -108,6 +113,14 @@ public class ServerConfigurator {
|
||||
return new KList<File>().qadd(new File(IrisWorldStorage.levelRoot(), "datapacks"));
|
||||
}
|
||||
|
||||
public static KList<File> getIrisDatapackRoots() {
|
||||
KList<File> roots = new KList<>();
|
||||
for (File datapacksFolder : getDatapacksFolder()) {
|
||||
roots.add(new File(datapacksFolder, "iris"));
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
public static boolean installDataPacks(boolean fullInstall) {
|
||||
IDataFixer fixer = DataVersion.getDefault();
|
||||
if (fixer == null) {
|
||||
@@ -128,22 +141,29 @@ public class ServerConfigurator {
|
||||
} else {
|
||||
IrisLogging.debug("Checking Data Packs...");
|
||||
}
|
||||
DimensionHeight height = new DimensionHeight(fixer);
|
||||
KList<File> folders = getDatapacksFolder();
|
||||
IrisDimension.clearGeneratedBiomeTags(folders);
|
||||
DatapackIngestService.reapplyFromStaging(folders);
|
||||
java.util.concurrent.ConcurrentMap<String, KSet<String>> biomes = new java.util.concurrent.ConcurrentHashMap<>();
|
||||
|
||||
KList<File> datapacksFolders = getDatapacksFolder();
|
||||
DatapackIngestService.reapplyFromStaging(datapacksFolders);
|
||||
List<File> packRoots;
|
||||
try (Stream<IrisData> stream = allPacks()) {
|
||||
stream.flatMap(height::merge)
|
||||
.parallel()
|
||||
.forEach(dim -> {
|
||||
IrisLogging.debug(" Checking Dimension " + dim.getLoadFile().getPath());
|
||||
dim.installBiomes(fixer, dim::getLoader, folders, biomes.computeIfAbsent(dim.getLoadKey(), k -> new KSet<>()));
|
||||
dim.installDimensionType(fixer, folders);
|
||||
});
|
||||
packRoots = stream
|
||||
.map(IrisData::getDataFolder)
|
||||
.map(File::getAbsoluteFile)
|
||||
.distinct()
|
||||
.toList();
|
||||
}
|
||||
|
||||
try {
|
||||
IrisDatapackCompiler.compile(
|
||||
packRoots,
|
||||
getIrisDatapackRoots(),
|
||||
fixer,
|
||||
BukkitPlatform.dataPackFormat(),
|
||||
IrisSettings.get().getGeneral().adjustVanillaHeight
|
||||
);
|
||||
} catch (IOException e) {
|
||||
IrisLogging.reportError("Unable to compile Iris datapacks", e);
|
||||
return false;
|
||||
}
|
||||
IrisDimension.writeShared(folders, height);
|
||||
if (fullInstall) {
|
||||
IrisLogging.info("Data Packs Setup!");
|
||||
} else {
|
||||
@@ -263,7 +283,7 @@ public class ServerConfigurator {
|
||||
|
||||
for (Player i : Bukkit.getOnlinePlayers()) {
|
||||
if (i.isOp() || i.hasPermission("iris.all")) {
|
||||
VolmitSender sender = new VolmitSender(i, art.arcane.iris.platform.bukkit.BukkitPlatform.volmitPlugin().getTag("WARNING"));
|
||||
VolmitSender sender = new VolmitSender(i, BukkitPlatform.volmitPlugin().getTag("WARNING"));
|
||||
sender.sendMessage("There are some Iris Packs that have custom biomes in them");
|
||||
sender.sendMessage("You need to restart your server to use these packs.");
|
||||
}
|
||||
@@ -364,52 +384,4 @@ public class ServerConfigurator {
|
||||
return key == null ? null : key.toString();
|
||||
}
|
||||
|
||||
public static class DimensionHeight {
|
||||
private final IDataFixer fixer;
|
||||
private final AtomicIntegerArray[] dimensions = new AtomicIntegerArray[3];
|
||||
|
||||
public DimensionHeight(IDataFixer fixer) {
|
||||
this.fixer = fixer;
|
||||
for (int i = 0; i < 3; i++) {
|
||||
dimensions[i] = new AtomicIntegerArray(new int[]{
|
||||
Integer.MAX_VALUE, Integer.MIN_VALUE, Integer.MIN_VALUE
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public Stream<IrisDimension> merge(IrisData data) {
|
||||
IrisLogging.debug("Checking Pack: " + data.getDataFolder().getPath());
|
||||
var loader = data.getDimensionLoader();
|
||||
return loader.loadAll(loader.getPossibleKeys())
|
||||
.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.peek(this::merge);
|
||||
}
|
||||
|
||||
public void merge(IrisDimension dimension) {
|
||||
AtomicIntegerArray array = dimensions[dimension.getBaseDimension().ordinal()];
|
||||
array.updateAndGet(0, min -> Math.min(min, dimension.getMinHeight()));
|
||||
array.updateAndGet(1, max -> Math.max(max, dimension.getMaxHeight()));
|
||||
array.updateAndGet(2, logical -> Math.max(logical, dimension.getLogicalHeight()));
|
||||
}
|
||||
|
||||
public String[] jsonStrings() {
|
||||
var dims = IDataFixer.Dimension.values();
|
||||
var arr = new String[3];
|
||||
for (int i = 0; i < 3; i++) {
|
||||
arr[i] = jsonString(dims[i]);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
public String jsonString(IDataFixer.Dimension dimension) {
|
||||
var data = dimensions[dimension.ordinal()];
|
||||
int minY = data.get(0);
|
||||
int maxY = data.get(1);
|
||||
int logicalHeight = data.get(2);
|
||||
if (minY == Integer.MAX_VALUE || maxY == Integer.MIN_VALUE || Integer.MIN_VALUE == logicalHeight)
|
||||
return null;
|
||||
return fixer.createDimension(dimension, minY, maxY - minY, logicalHeight, null).toString(4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,8 +34,8 @@ import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
public class ImageResourceLoader extends ResourceLoader<IrisImage> {
|
||||
public ImageResourceLoader(File root, IrisData idm, String folderName, String resourceTypeName) {
|
||||
super(root, idm, folderName, resourceTypeName, IrisImage.class);
|
||||
public ImageResourceLoader(File root, IrisData idm, String folderName, String resourceTypeName, Options options) {
|
||||
super(root, idm, folderName, resourceTypeName, IrisImage.class, options);
|
||||
loadCache = new KCache<>(this::loadRaw, IrisSettings.get().getPerformance().getObjectLoaderCacheSize());
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
|
||||
private static final KMap<File, IrisData> dataLoaders = new KMap<>();
|
||||
private final File dataFolder;
|
||||
private final int id;
|
||||
private final boolean datapackCompiler;
|
||||
private boolean closed = false;
|
||||
private ResourceLoader<IrisBiome> biomeLoader;
|
||||
private ResourceLoader<IrisLootTable> lootLoader;
|
||||
@@ -106,16 +107,29 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
|
||||
private Engine engine;
|
||||
|
||||
private IrisData(File dataFolder) {
|
||||
this(dataFolder, false);
|
||||
}
|
||||
|
||||
private IrisData(File dataFolder, boolean datapackCompiler) {
|
||||
this.engine = null;
|
||||
this.dataFolder = dataFolder;
|
||||
this.id = RNG.r.imax();
|
||||
hotloaded();
|
||||
this.datapackCompiler = datapackCompiler;
|
||||
if (datapackCompiler) {
|
||||
hotloadedDatapackCompiler();
|
||||
} else {
|
||||
hotloaded();
|
||||
}
|
||||
}
|
||||
|
||||
public static IrisData get(File dataFolder) {
|
||||
return dataLoaders.computeIfAbsent(dataFolder, IrisData::new);
|
||||
}
|
||||
|
||||
public static IrisData openDatapackCompiler(File dataFolder) {
|
||||
return new IrisData(dataFolder, true);
|
||||
}
|
||||
|
||||
public static Optional<IrisData> getLoaded(File dataFolder) {
|
||||
return Optional.ofNullable(dataLoaders.get(dataFolder));
|
||||
}
|
||||
@@ -273,7 +287,9 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
|
||||
public void close() {
|
||||
closed = true;
|
||||
dump();
|
||||
dataLoaders.remove(dataFolder);
|
||||
if (dataLoaders.get(dataFolder) == this) {
|
||||
dataLoaders.remove(dataFolder);
|
||||
}
|
||||
}
|
||||
|
||||
public IrisData copy() {
|
||||
@@ -284,18 +300,21 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
|
||||
try {
|
||||
IrisRegistrant rr = registrant.getConstructor().newInstance();
|
||||
ResourceLoader<T> r = null;
|
||||
ResourceLoader.Options options = datapackCompiler
|
||||
? ResourceLoader.Options.datapackCompiler()
|
||||
: ResourceLoader.Options.runtime();
|
||||
if (registrant.equals(IrisObject.class)) {
|
||||
r = (ResourceLoader<T>) new ObjectResourceLoader(dataFolder, this, rr.getFolderName(),
|
||||
rr.getTypeName());
|
||||
rr.getTypeName(), options);
|
||||
} else if (registrant.equals(IrisMatterObject.class)) {
|
||||
r = (ResourceLoader<T>) new MatterObjectResourceLoader(dataFolder, this, rr.getFolderName(),
|
||||
rr.getTypeName());
|
||||
rr.getTypeName(), options);
|
||||
} else if (registrant.equals(IrisImage.class)) {
|
||||
r = (ResourceLoader<T>) new ImageResourceLoader(dataFolder, this, rr.getFolderName(),
|
||||
rr.getTypeName());
|
||||
rr.getTypeName(), options);
|
||||
} else {
|
||||
J.attempt(() -> registrant.getConstructor().newInstance().registerTypeAdapters(builder));
|
||||
r = new ResourceLoader<>(dataFolder, this, rr.getFolderName(), rr.getTypeName(), registrant);
|
||||
r = new ResourceLoader<>(dataFolder, this, rr.getFolderName(), rr.getTypeName(), registrant, options);
|
||||
}
|
||||
|
||||
loaders.put(registrant, r);
|
||||
@@ -349,6 +368,27 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
|
||||
}
|
||||
}
|
||||
|
||||
private void hotloadedDatapackCompiler() {
|
||||
closed = false;
|
||||
possibleSnippets = new KMap<>();
|
||||
builder = new GsonBuilder()
|
||||
.addDeserializationExclusionStrategy(this)
|
||||
.addSerializationExclusionStrategy(this)
|
||||
.setStrictness(Strictness.LENIENT)
|
||||
.registerTypeAdapterFactory(this)
|
||||
.registerTypeAdapter(MantleFlag.class, new MantleFlagAdapter())
|
||||
.setPrettyPrinting();
|
||||
loaders.clear();
|
||||
dataFolder.mkdirs();
|
||||
biomeLoader = registerLoader(IrisBiome.class);
|
||||
dimensionLoader = registerLoader(IrisDimension.class);
|
||||
builder.registerTypeAdapterFactory(KeyedType::createTypeAdapter);
|
||||
gson = builder.create();
|
||||
if (biomeLoader == null || dimensionLoader == null) {
|
||||
throw new IllegalStateException("Unable to initialize Iris datapack compiler loaders for " + dataFolder);
|
||||
}
|
||||
}
|
||||
|
||||
public void dump() {
|
||||
for (ResourceLoader<?> i : loaders.values()) {
|
||||
i.clearCache();
|
||||
|
||||
@@ -33,8 +33,8 @@ import java.util.HashSet;
|
||||
public class MatterObjectResourceLoader extends ResourceLoader<IrisMatterObject> {
|
||||
private String[] possibleKeys;
|
||||
|
||||
public MatterObjectResourceLoader(File root, IrisData idm, String folderName, String resourceTypeName) {
|
||||
super(root, idm, folderName, resourceTypeName, IrisMatterObject.class);
|
||||
public MatterObjectResourceLoader(File root, IrisData idm, String folderName, String resourceTypeName, Options options) {
|
||||
super(root, idm, folderName, resourceTypeName, IrisMatterObject.class, options);
|
||||
loadCache = new KCache<>(this::loadRaw, IrisSettings.get().getPerformance().getObjectLoaderCacheSize());
|
||||
}
|
||||
|
||||
|
||||
@@ -31,8 +31,8 @@ import java.io.IOException;
|
||||
import java.util.HashSet;
|
||||
|
||||
public class ObjectResourceLoader extends ResourceLoader<IrisObject> {
|
||||
public ObjectResourceLoader(File root, IrisData idm, String folderName, String resourceTypeName) {
|
||||
super(root, idm, folderName, resourceTypeName, IrisObject.class);
|
||||
public ObjectResourceLoader(File root, IrisData idm, String folderName, String resourceTypeName, Options options) {
|
||||
super(root, idm, folderName, resourceTypeName, IrisObject.class, options);
|
||||
loadCache = new KCache<>(this::loadRaw, IrisSettings.get().getPerformance().getObjectLoaderCacheSize());
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
@@ -93,8 +94,17 @@ public class ResourceLoader<T extends IrisRegistrant> implements MeteredCache {
|
||||
protected IrisData manager;
|
||||
protected AtomicInteger loads;
|
||||
protected ChronoLatch sec;
|
||||
private final Options options;
|
||||
|
||||
public ResourceLoader(File root, IrisData manager, String folderName, String resourceTypeName, Class<? extends T> objectClass) {
|
||||
public ResourceLoader(
|
||||
File root,
|
||||
IrisData manager,
|
||||
String folderName,
|
||||
String resourceTypeName,
|
||||
Class<? extends T> objectClass,
|
||||
Options options
|
||||
) {
|
||||
this.options = Objects.requireNonNull(options, "options");
|
||||
this.manager = manager;
|
||||
firstAccess = new KSet<>();
|
||||
folderCache = new AtomicCache<>();
|
||||
@@ -105,12 +115,14 @@ public class ResourceLoader<T extends IrisRegistrant> implements MeteredCache {
|
||||
this.resourceTypeName = resourceTypeName;
|
||||
this.root = root;
|
||||
this.folderName = folderName;
|
||||
loadCache = new KCache<>(this::loadRaw, IrisSettings.get().getPerformance().getResourceLoaderCacheSize());
|
||||
loadCache = new KCache<>(this::loadRaw, options.cacheSize());
|
||||
IrisLogging.debug("Loader<" + C.GREEN + resourceTypeName + C.LIGHT_PURPLE + "> created in " + C.RED + "IDM/" + manager.getId() + C.LIGHT_PURPLE + " on " + C.GRAY + manager.getDataFolder().getPath());
|
||||
IrisServices.get(PreservationRegistry.class).registerCache(this);
|
||||
PreservationRegistry preservation = IrisServices.getOrNull(PreservationRegistry.class);
|
||||
if (preservation != null && schemaBuildExecutorRegistered.compareAndSet(false, true)) {
|
||||
preservation.register(schemaBuildExecutor);
|
||||
if (options.registerPreservation()) {
|
||||
IrisServices.get(PreservationRegistry.class).registerCache(this);
|
||||
PreservationRegistry preservation = IrisServices.getOrNull(PreservationRegistry.class);
|
||||
if (preservation != null && schemaBuildExecutorRegistered.compareAndSet(false, true)) {
|
||||
preservation.register(schemaBuildExecutor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,10 +213,15 @@ public class ResourceLoader<T extends IrisRegistrant> implements MeteredCache {
|
||||
}
|
||||
|
||||
if (sec.flip()) {
|
||||
J.a(() -> {
|
||||
Runnable summary = () -> {
|
||||
IrisLogging.debug("Loaded " + C.WHITE + loads.get() + " " + resourceTypeName + (loads.get() == 1 ? "" : "s") + C.GRAY + " (" + Form.f(getLoadCache().getSize()) + " " + resourceTypeName + (loadCache.getSize() == 1 ? "" : "s") + " Loaded)");
|
||||
loads.set(0);
|
||||
});
|
||||
};
|
||||
if (options.synchronousReporting()) {
|
||||
summary.run();
|
||||
} else {
|
||||
J.a(summary);
|
||||
}
|
||||
}
|
||||
|
||||
IrisLogging.debug("Loader<" + C.GREEN + resourceTypeName + C.LIGHT_PURPLE + "> iload " + C.YELLOW + t.getLoadKey() + C.LIGHT_PURPLE + " in " + C.GRAY + t.getLoadFile().getPath() + C.LIGHT_PURPLE + " TLT: " + C.RED + Form.duration(tlt.get(), 2));
|
||||
@@ -215,7 +232,12 @@ public class ResourceLoader<T extends IrisRegistrant> implements MeteredCache {
|
||||
}
|
||||
|
||||
public void failLoad(File path, String rawText, Throwable e) {
|
||||
J.a(() -> JsonSchemaValidator.reportLoadFailure(path, rawText, resourceTypeName, e));
|
||||
Runnable report = () -> JsonSchemaValidator.reportLoadFailure(path, rawText, resourceTypeName, e);
|
||||
if (options.synchronousReporting()) {
|
||||
report.run();
|
||||
} else {
|
||||
J.a(report);
|
||||
}
|
||||
}
|
||||
|
||||
private KList<File> matchAllFiles(File root, Predicate<File> f) {
|
||||
@@ -577,4 +599,24 @@ public class ResourceLoader<T extends IrisRegistrant> implements MeteredCache {
|
||||
public long getTotalStorage() {
|
||||
return getSize();
|
||||
}
|
||||
|
||||
public record Options(int cacheSize, boolean registerPreservation, boolean synchronousReporting) {
|
||||
public Options {
|
||||
if (cacheSize < 1) {
|
||||
throw new IllegalArgumentException("Resource loader cache size must be positive");
|
||||
}
|
||||
}
|
||||
|
||||
public static Options runtime() {
|
||||
return new Options(
|
||||
IrisSettings.get().getPerformance().getResourceLoaderCacheSize(),
|
||||
true,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
public static Options datapackCompiler() {
|
||||
return new Options(CACHE_SIZE, false, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,789 @@
|
||||
package art.arcane.iris.core.pack;
|
||||
|
||||
import art.arcane.iris.core.IrisDatapackCompiler;
|
||||
import art.arcane.iris.core.nms.datapack.DataVersion;
|
||||
import art.arcane.iris.core.nms.datapack.IDataFixer;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.DirectoryStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Properties;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.stream.Stream;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
public final class DefaultPackBootstrapProvisioner {
|
||||
private static final URI DEFAULT_SOURCE = URI.create("https://github.com/IrisDimensions/overworld/releases/download/beta/overworld.zip");
|
||||
private static final int PACK_FORMAT = 107;
|
||||
private static final int MARKER_SCHEMA = 1;
|
||||
private static final int MAX_ARCHIVE_ENTRIES = 100_000;
|
||||
private static final long MAX_ARCHIVE_BYTES = 512L * 1024L * 1024L;
|
||||
private static final long MAX_EXPANDED_BYTES = 2L * 1024L * 1024L * 1024L;
|
||||
private static final AtomicBoolean PROVISIONED_THIS_STARTUP = new AtomicBoolean(false);
|
||||
|
||||
private DefaultPackBootstrapProvisioner() {
|
||||
}
|
||||
|
||||
public static ProvisionResult provision(Path dataDirectory, Consumer<String> feedback) throws IOException {
|
||||
Objects.requireNonNull(dataDirectory, "dataDirectory");
|
||||
Objects.requireNonNull(feedback, "feedback");
|
||||
Path serverRoot = Path.of("").toAbsolutePath().normalize();
|
||||
Path levelRoot = resolveLevelRoot(serverRoot);
|
||||
HttpClient client = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(15))
|
||||
.followRedirects(HttpClient.Redirect.ALWAYS)
|
||||
.build();
|
||||
ProvisionOptions options = new ProvisionOptions(
|
||||
DEFAULT_SOURCE,
|
||||
client,
|
||||
Clock.systemUTC(),
|
||||
Duration.ofMinutes(30),
|
||||
Duration.ofSeconds(45),
|
||||
3,
|
||||
Duration.ofMillis(250),
|
||||
MAX_ARCHIVE_BYTES,
|
||||
levelRoot
|
||||
);
|
||||
return provision(dataDirectory, feedback, options);
|
||||
}
|
||||
|
||||
public static boolean isProvisioned(Path dataDirectory) {
|
||||
if (dataDirectory == null) {
|
||||
return false;
|
||||
}
|
||||
Path bootstrapRoot = dataDirectory.toAbsolutePath().normalize().resolve("bootstrap");
|
||||
Path datapackRoot = bootstrapRoot.resolve("datapack");
|
||||
Path packRoot = dataDirectory.toAbsolutePath().normalize().resolve("packs/overworld");
|
||||
Path markerFile = bootstrapRoot.resolve("provisioned.properties");
|
||||
if (!Files.isRegularFile(markerFile) || !isPackRoot(packRoot) || !isDatapackRoot(datapackRoot)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
Properties marker = loadProperties(markerFile);
|
||||
if (!Integer.toString(MARKER_SCHEMA).equals(marker.getProperty("schema"))) {
|
||||
return false;
|
||||
}
|
||||
return directoryFingerprint(packRoot).equals(marker.getProperty("defaultPackFingerprint"))
|
||||
&& directoryFingerprint(datapackRoot).equals(marker.getProperty("datapackFingerprint"))
|
||||
&& packRootsFingerprint(IrisDatapackCompiler.collectPackRoots(
|
||||
dataDirectory.toAbsolutePath().normalize(),
|
||||
resolveLevelRoot(Path.of("").toAbsolutePath().normalize())
|
||||
)).equals(marker.getProperty("aggregateFingerprint"));
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean wasProvisionedThisStartup() {
|
||||
return PROVISIONED_THIS_STARTUP.get();
|
||||
}
|
||||
|
||||
static ProvisionResult provision(Path dataDirectory, Consumer<String> feedback, ProvisionOptions options) throws IOException {
|
||||
Path normalizedData = dataDirectory.toAbsolutePath().normalize();
|
||||
Path packsRoot = normalizedData.resolve("packs");
|
||||
Path packRoot = packsRoot.resolve("overworld");
|
||||
Path bootstrapRoot = normalizedData.resolve("bootstrap");
|
||||
Path datapackRoot = bootstrapRoot.resolve("datapack");
|
||||
Path markerFile = bootstrapRoot.resolve("provisioned.properties");
|
||||
Path cacheRoot = normalizedData.resolve("cache/bootstrap");
|
||||
Files.createDirectories(packsRoot);
|
||||
Files.createDirectories(bootstrapRoot);
|
||||
Files.createDirectories(cacheRoot);
|
||||
|
||||
Properties previousMarker = Files.isRegularFile(markerFile) ? loadProperties(markerFile) : new Properties();
|
||||
boolean existingPack = isPackRoot(packRoot);
|
||||
boolean existingDatapack = isDatapackRoot(datapackRoot);
|
||||
String currentPackFingerprint = existingPack ? directoryFingerprint(packRoot) : "";
|
||||
boolean markerOwned = "true".equals(previousMarker.getProperty("managedDefault"));
|
||||
boolean unchangedManagedPack = markerOwned
|
||||
&& currentPackFingerprint.equals(previousMarker.getProperty("defaultPackFingerprint"));
|
||||
boolean managedDefault = !existingPack || unchangedManagedPack;
|
||||
if (Files.isSymbolicLink(packRoot)) {
|
||||
managedDefault = false;
|
||||
}
|
||||
Archive archive = managedDefault
|
||||
? acquireArchive(cacheRoot, previousMarker, feedback, options)
|
||||
: new Archive(null, currentPackFingerprint);
|
||||
boolean replacePack = !existingPack || managedDefault
|
||||
&& (!archive.sha256().equals(previousMarker.getProperty("sourceSha256"))
|
||||
|| !currentPackFingerprint.equals(previousMarker.getProperty("defaultPackFingerprint")));
|
||||
|
||||
Path stagedPack = null;
|
||||
Path extractionRoot = null;
|
||||
Path compileContainer = null;
|
||||
Path stagedDatapack = null;
|
||||
Path packBackup = null;
|
||||
Path datapackBackup = null;
|
||||
boolean packReplaced = false;
|
||||
boolean datapackReplaced = false;
|
||||
try {
|
||||
if (replacePack) {
|
||||
extractionRoot = cacheRoot.resolve(".extract-" + UUID.randomUUID());
|
||||
Files.createDirectories(extractionRoot);
|
||||
Path extractedPack = extractArchive(archive.path(), extractionRoot);
|
||||
stagedPack = packsRoot.resolve(".overworld-stage-" + UUID.randomUUID());
|
||||
copyDirectory(extractedPack, stagedPack);
|
||||
validatePackRoot(stagedPack);
|
||||
packBackup = replaceWithBackup(stagedPack, packRoot);
|
||||
packReplaced = true;
|
||||
}
|
||||
|
||||
List<File> packRoots = IrisDatapackCompiler.collectPackRoots(normalizedData, options.levelRoot());
|
||||
if (packRoots.isEmpty()) {
|
||||
throw new IOException("No Iris pack roots were available for bootstrap datapack compilation");
|
||||
}
|
||||
String aggregateFingerprint = packRootsFingerprint(packRoots);
|
||||
boolean rebuildDatapack = replacePack
|
||||
|| !existingDatapack
|
||||
|| !aggregateFingerprint.equals(previousMarker.getProperty("aggregateFingerprint"))
|
||||
|| !directoryFingerprint(datapackRoot).equals(previousMarker.getProperty("datapackFingerprint"));
|
||||
if (rebuildDatapack) {
|
||||
compileContainer = bootstrapRoot.resolve(".compile-" + UUID.randomUUID());
|
||||
Files.createDirectories(compileContainer);
|
||||
KList<File> outputFolders = new KList<File>().qadd(compileContainer.toFile());
|
||||
IDataFixer fixer = DataVersion.getLatest().get();
|
||||
if (fixer == null) {
|
||||
throw new IOException("Latest Iris datapack fixer is unavailable during bootstrap");
|
||||
}
|
||||
IrisDatapackCompiler.compile(packRoots, outputFolders, fixer, PACK_FORMAT, false);
|
||||
Path canonicalOutput = compileContainer;
|
||||
if (!isDatapackRoot(canonicalOutput)) {
|
||||
throw new IOException("Canonical Iris datapack compiler produced incomplete output at " + canonicalOutput);
|
||||
}
|
||||
stagedDatapack = bootstrapRoot.resolve(".datapack-stage-" + UUID.randomUUID());
|
||||
move(canonicalOutput, stagedDatapack, false);
|
||||
compileContainer = null;
|
||||
datapackBackup = replaceWithBackup(stagedDatapack, datapackRoot);
|
||||
datapackReplaced = true;
|
||||
}
|
||||
|
||||
validatePackRoot(packRoot);
|
||||
if (!isDatapackRoot(datapackRoot)) {
|
||||
throw new IOException("Bootstrap datapack output is incomplete at " + datapackRoot);
|
||||
}
|
||||
String finalPackFingerprint = directoryFingerprint(packRoot);
|
||||
String finalAggregateFingerprint = packRootsFingerprint(
|
||||
IrisDatapackCompiler.collectPackRoots(normalizedData, options.levelRoot())
|
||||
);
|
||||
String finalDatapackFingerprint = directoryFingerprint(datapackRoot);
|
||||
Properties marker = new Properties();
|
||||
marker.setProperty("schema", Integer.toString(MARKER_SCHEMA));
|
||||
marker.setProperty("source", options.source().toString());
|
||||
marker.setProperty("sourceSha256", archive.sha256());
|
||||
marker.setProperty("managedDefault", Boolean.toString(managedDefault));
|
||||
marker.setProperty("defaultPackFingerprint", finalPackFingerprint);
|
||||
marker.setProperty("aggregateFingerprint", finalAggregateFingerprint);
|
||||
marker.setProperty("datapackFingerprint", finalDatapackFingerprint);
|
||||
marker.setProperty("completedAt", Long.toString(options.clock().millis()));
|
||||
storePropertiesAtomic(markerFile, marker);
|
||||
PROVISIONED_THIS_STARTUP.set(true);
|
||||
deleteQuietly(packBackup, feedback);
|
||||
deleteQuietly(datapackBackup, feedback);
|
||||
|
||||
ProvisionStatus status;
|
||||
if (!existingPack && !existingDatapack) {
|
||||
status = ProvisionStatus.INSTALLED;
|
||||
} else if (replacePack || rebuildDatapack) {
|
||||
status = ProvisionStatus.UPDATED;
|
||||
} else {
|
||||
status = ProvisionStatus.UNCHANGED;
|
||||
}
|
||||
feedback.accept("Iris bootstrap pack is " + status.name().toLowerCase() + ".");
|
||||
return new ProvisionResult(packRoot, datapackRoot, status);
|
||||
} catch (IOException failure) {
|
||||
IOException rollbackFailure = rollback(packRoot, packBackup, packReplaced, datapackRoot, datapackBackup, datapackReplaced);
|
||||
if (rollbackFailure != null) {
|
||||
failure.addSuppressed(rollbackFailure);
|
||||
}
|
||||
throw failure;
|
||||
} catch (RuntimeException | LinkageError failure) {
|
||||
IOException rollbackFailure = rollback(packRoot, packBackup, packReplaced, datapackRoot, datapackBackup, datapackReplaced);
|
||||
if (rollbackFailure != null) {
|
||||
failure.addSuppressed(rollbackFailure);
|
||||
}
|
||||
throw new IOException("Iris bootstrap provisioning failed", failure);
|
||||
} finally {
|
||||
deleteQuietly(stagedPack, feedback);
|
||||
deleteQuietly(stagedDatapack, feedback);
|
||||
deleteQuietly(extractionRoot, feedback);
|
||||
deleteQuietly(compileContainer, feedback);
|
||||
}
|
||||
}
|
||||
|
||||
private static Archive acquireArchive(
|
||||
Path cacheRoot,
|
||||
Properties marker,
|
||||
Consumer<String> feedback,
|
||||
ProvisionOptions options
|
||||
) throws IOException {
|
||||
Path archivePath = cacheRoot.resolve("default-overworld.zip");
|
||||
Path metadataPath = cacheRoot.resolve("default-overworld.properties");
|
||||
Properties metadata = Files.isRegularFile(metadataPath) ? loadProperties(metadataPath) : new Properties();
|
||||
boolean validCache = false;
|
||||
if (Files.isRegularFile(archivePath)) {
|
||||
try {
|
||||
validCache = validateArchive(archivePath);
|
||||
} catch (IOException exception) {
|
||||
feedback.accept("Cached default overworld archive is invalid; downloading a replacement.");
|
||||
}
|
||||
}
|
||||
long fetchedAt = parseLong(metadata.getProperty("fetchedAt"), 0L);
|
||||
boolean fresh = validCache && options.clock().millis() - fetchedAt < options.refreshInterval().toMillis();
|
||||
if (fresh) {
|
||||
return new Archive(archivePath, sha256(archivePath));
|
||||
}
|
||||
|
||||
IOException networkFailure = null;
|
||||
for (int attempt = 1; attempt <= options.attempts(); attempt++) {
|
||||
try {
|
||||
HttpRequest.Builder request = HttpRequest.newBuilder(options.source())
|
||||
.timeout(options.requestTimeout())
|
||||
.header("Accept", "application/octet-stream")
|
||||
.header("User-Agent", "Iris-Bootstrap")
|
||||
.GET();
|
||||
if (validCache) {
|
||||
String etag = metadata.getProperty("etag");
|
||||
String lastModified = metadata.getProperty("lastModified");
|
||||
if (etag != null && !etag.isBlank()) {
|
||||
request.header("If-None-Match", etag);
|
||||
}
|
||||
if (lastModified != null && !lastModified.isBlank()) {
|
||||
request.header("If-Modified-Since", lastModified);
|
||||
}
|
||||
}
|
||||
HttpResponse<InputStream> response = options.client().send(request.build(), HttpResponse.BodyHandlers.ofInputStream());
|
||||
int status = response.statusCode();
|
||||
if (status == 304 && validCache) {
|
||||
close(response.body());
|
||||
metadata.setProperty("fetchedAt", Long.toString(options.clock().millis()));
|
||||
storePropertiesAtomic(metadataPath, metadata);
|
||||
return new Archive(archivePath, sha256(archivePath));
|
||||
}
|
||||
if (status == 200) {
|
||||
Path temporary = cacheRoot.resolve(".download-" + UUID.randomUUID() + ".zip");
|
||||
try {
|
||||
try (InputStream input = response.body(); OutputStream output = Files.newOutputStream(temporary)) {
|
||||
copyLimited(input, output, options.maxArchiveBytes());
|
||||
}
|
||||
if (!validateArchive(temporary)) {
|
||||
throw new IOException("Downloaded default overworld archive is invalid");
|
||||
}
|
||||
move(temporary, archivePath, true);
|
||||
} finally {
|
||||
Files.deleteIfExists(temporary);
|
||||
}
|
||||
Properties updated = new Properties();
|
||||
response.headers().firstValue("etag").ifPresent(value -> updated.setProperty("etag", value));
|
||||
response.headers().firstValue("last-modified").ifPresent(value -> updated.setProperty("lastModified", value));
|
||||
updated.setProperty("fetchedAt", Long.toString(options.clock().millis()));
|
||||
updated.setProperty("sha256", sha256(archivePath));
|
||||
storePropertiesAtomic(metadataPath, updated);
|
||||
feedback.accept("Downloaded the Iris default overworld beta archive.");
|
||||
return new Archive(archivePath, updated.getProperty("sha256"));
|
||||
}
|
||||
close(response.body());
|
||||
IOException statusFailure = new IOException("Default overworld download returned HTTP " + status);
|
||||
if (!retryableStatus(status)) {
|
||||
networkFailure = statusFailure;
|
||||
break;
|
||||
}
|
||||
networkFailure = statusFailure;
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("Default overworld download was interrupted", exception);
|
||||
} catch (IOException exception) {
|
||||
networkFailure = exception;
|
||||
}
|
||||
if (attempt < options.attempts()) {
|
||||
pause(options.retryDelay().multipliedBy(attempt));
|
||||
}
|
||||
}
|
||||
|
||||
if (validCache) {
|
||||
feedback.accept("Default overworld download failed; using the validated cached archive.");
|
||||
return new Archive(archivePath, sha256(archivePath));
|
||||
}
|
||||
if (isProvisionedOutputUsable(marker, cacheRoot.getParent().getParent())) {
|
||||
String sourceSha = marker.getProperty("sourceSha256");
|
||||
return new Archive(null, sourceSha);
|
||||
}
|
||||
throw networkFailure == null
|
||||
? new IOException("Default overworld archive is unavailable and no valid cache exists")
|
||||
: new IOException("Default overworld archive is unavailable and no valid cache exists", networkFailure);
|
||||
}
|
||||
|
||||
private static boolean isProvisionedOutputUsable(Properties marker, Path dataDirectory) {
|
||||
String sourceSha = marker.getProperty("sourceSha256");
|
||||
return sourceSha != null && !sourceSha.isBlank() && isProvisioned(dataDirectory);
|
||||
}
|
||||
|
||||
static Path resolveLevelRoot(Path serverRoot) throws IOException {
|
||||
Path normalizedServerRoot = serverRoot.toAbsolutePath().normalize();
|
||||
String levelName = readConfiguredLevelName(normalizedServerRoot);
|
||||
Path configured = Path.of(levelName);
|
||||
return configured.isAbsolute()
|
||||
? configured.normalize()
|
||||
: normalizedServerRoot.resolve(configured).normalize();
|
||||
}
|
||||
|
||||
private static String readConfiguredLevelName(Path serverRoot) throws IOException {
|
||||
String levelName = "world";
|
||||
Path propertiesFile = serverRoot.resolve("server.properties");
|
||||
if (Files.isRegularFile(propertiesFile)) {
|
||||
Properties properties = loadProperties(propertiesFile);
|
||||
levelName = properties.getProperty("level-name", levelName);
|
||||
}
|
||||
|
||||
String[] arguments = ProcessHandle.current().info().arguments().orElse(new String[0]);
|
||||
for (int index = 0; index < arguments.length; index++) {
|
||||
String argument = arguments[index];
|
||||
String following = index + 1 < arguments.length ? arguments[index + 1] : null;
|
||||
String parsed = parseLevelArgument(argument, following);
|
||||
if (parsed != null) {
|
||||
levelName = parsed;
|
||||
}
|
||||
}
|
||||
if (levelName.isBlank()) {
|
||||
throw new IOException("Configured level name is empty");
|
||||
}
|
||||
return levelName;
|
||||
}
|
||||
|
||||
private static String parseLevelArgument(String argument, String following) {
|
||||
for (String key : List.of("-w", "--level-name", "--world")) {
|
||||
if (argument.equals(key) && following != null && !following.isBlank()) {
|
||||
return following;
|
||||
}
|
||||
String prefix = key + "=";
|
||||
if (argument.startsWith(prefix) && argument.length() > prefix.length()) {
|
||||
return argument.substring(prefix.length());
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean validateArchive(Path archive) throws IOException {
|
||||
int entries = 0;
|
||||
long expanded = 0L;
|
||||
boolean dimensionFound = false;
|
||||
Set<String> paths = new HashSet<>();
|
||||
try (InputStream input = Files.newInputStream(archive); ZipInputStream zip = new ZipInputStream(input)) {
|
||||
ZipEntry entry;
|
||||
while ((entry = zip.getNextEntry()) != null) {
|
||||
entries++;
|
||||
if (entries > MAX_ARCHIVE_ENTRIES) {
|
||||
throw new IOException("Default overworld archive contains too many entries");
|
||||
}
|
||||
String name = normalizedZipEntry(entry.getName());
|
||||
if (!paths.add(name)) {
|
||||
throw new IOException("Default overworld archive contains duplicate path " + name);
|
||||
}
|
||||
if (name.equals("dimensions/overworld.json") || name.endsWith("/dimensions/overworld.json")) {
|
||||
dimensionFound = true;
|
||||
}
|
||||
if (!entry.isDirectory()) {
|
||||
byte[] buffer = new byte[8192];
|
||||
int read;
|
||||
while ((read = zip.read(buffer)) >= 0) {
|
||||
expanded += read;
|
||||
if (expanded > MAX_EXPANDED_BYTES) {
|
||||
throw new IOException("Default overworld archive expands beyond the safety limit");
|
||||
}
|
||||
}
|
||||
}
|
||||
zip.closeEntry();
|
||||
}
|
||||
}
|
||||
return entries > 0 && dimensionFound;
|
||||
}
|
||||
|
||||
private static Path extractArchive(Path archive, Path extractionRoot) throws IOException {
|
||||
if (archive == null) {
|
||||
throw new IOException("Cached default pack archive is unavailable for required pack rebuild");
|
||||
}
|
||||
long expanded = 0L;
|
||||
int entries = 0;
|
||||
try (InputStream input = Files.newInputStream(archive); ZipInputStream zip = new ZipInputStream(input)) {
|
||||
ZipEntry entry;
|
||||
while ((entry = zip.getNextEntry()) != null) {
|
||||
entries++;
|
||||
if (entries > MAX_ARCHIVE_ENTRIES) {
|
||||
throw new IOException("Default overworld archive contains too many entries");
|
||||
}
|
||||
String name = normalizedZipEntry(entry.getName());
|
||||
Path output = extractionRoot.resolve(name).normalize();
|
||||
if (!output.startsWith(extractionRoot)) {
|
||||
throw new IOException("Unsafe path in default overworld archive: " + entry.getName());
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
Files.createDirectories(output);
|
||||
} else {
|
||||
Files.createDirectories(output.getParent());
|
||||
try (OutputStream file = Files.newOutputStream(output)) {
|
||||
byte[] buffer = new byte[8192];
|
||||
int read;
|
||||
while ((read = zip.read(buffer)) >= 0) {
|
||||
expanded += read;
|
||||
if (expanded > MAX_EXPANDED_BYTES) {
|
||||
throw new IOException("Default overworld archive expands beyond the safety limit");
|
||||
}
|
||||
file.write(buffer, 0, read);
|
||||
}
|
||||
}
|
||||
}
|
||||
zip.closeEntry();
|
||||
}
|
||||
}
|
||||
if (isPackRoot(extractionRoot)) {
|
||||
return extractionRoot;
|
||||
}
|
||||
List<Path> candidates = new ArrayList<>();
|
||||
try (DirectoryStream<Path> children = Files.newDirectoryStream(extractionRoot)) {
|
||||
for (Path child : children) {
|
||||
if (Files.isDirectory(child) && isPackRoot(child)) {
|
||||
candidates.add(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (candidates.size() != 1) {
|
||||
throw new IOException("Default overworld archive has an invalid root layout");
|
||||
}
|
||||
return candidates.getFirst();
|
||||
}
|
||||
|
||||
private static String normalizedZipEntry(String raw) throws IOException {
|
||||
if (raw == null || raw.isBlank() || raw.indexOf('\0') >= 0 || raw.startsWith("/") || raw.startsWith("\\")) {
|
||||
throw new IOException("Invalid path in default overworld archive");
|
||||
}
|
||||
String normalized = raw.replace('\\', '/');
|
||||
Path path = Path.of(normalized).normalize();
|
||||
if (path.isAbsolute() || path.startsWith("..") || normalized.matches("^[A-Za-z]:.*")) {
|
||||
throw new IOException("Unsafe path in default overworld archive: " + raw);
|
||||
}
|
||||
return path.toString().replace('\\', '/');
|
||||
}
|
||||
|
||||
private static boolean isPackRoot(Path path) {
|
||||
return path != null && Files.isRegularFile(path.resolve("dimensions/overworld.json"));
|
||||
}
|
||||
|
||||
private static void validatePackRoot(Path path) throws IOException {
|
||||
if (!isPackRoot(path)) {
|
||||
throw new IOException("Default overworld pack is missing dimensions/overworld.json at " + path);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isDatapackRoot(Path path) {
|
||||
return path != null
|
||||
&& Files.isRegularFile(path.resolve("pack.mcmeta"))
|
||||
&& Files.isDirectory(path.resolve("data/iris/dimension_type"));
|
||||
}
|
||||
|
||||
private static String packRootsFingerprint(List<File> packRoots) throws IOException {
|
||||
MessageDigest digest = digest();
|
||||
List<Path> roots = packRoots.stream()
|
||||
.map(File::toPath)
|
||||
.map(path -> path.toAbsolutePath().normalize())
|
||||
.distinct()
|
||||
.sorted(Comparator.comparing(Path::toString))
|
||||
.toList();
|
||||
for (Path root : roots) {
|
||||
digest.update(root.toString().getBytes(StandardCharsets.UTF_8));
|
||||
digest.update(directoryFingerprint(root).getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
return HexFormat.of().formatHex(digest.digest());
|
||||
}
|
||||
|
||||
private static String directoryFingerprint(Path root) throws IOException {
|
||||
if (!Files.isDirectory(root)) {
|
||||
return "";
|
||||
}
|
||||
Path scanRoot = root.toRealPath();
|
||||
MessageDigest digest = digest();
|
||||
try (Stream<Path> stream = Files.walk(scanRoot)) {
|
||||
List<Path> files = stream.filter(Files::isRegularFile)
|
||||
.sorted(Comparator.comparing(path -> scanRoot.relativize(path).toString()))
|
||||
.toList();
|
||||
byte[] buffer = new byte[8192];
|
||||
for (Path file : files) {
|
||||
String relative = scanRoot.relativize(file).toString().replace('\\', '/');
|
||||
digest.update(relative.getBytes(StandardCharsets.UTF_8));
|
||||
try (InputStream input = Files.newInputStream(file)) {
|
||||
int read;
|
||||
while ((read = input.read(buffer)) >= 0) {
|
||||
digest.update(buffer, 0, read);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return HexFormat.of().formatHex(digest.digest());
|
||||
}
|
||||
|
||||
private static String sha256(Path file) throws IOException {
|
||||
MessageDigest digest = digest();
|
||||
byte[] buffer = new byte[8192];
|
||||
try (InputStream input = Files.newInputStream(file)) {
|
||||
int read;
|
||||
while ((read = input.read(buffer)) >= 0) {
|
||||
digest.update(buffer, 0, read);
|
||||
}
|
||||
}
|
||||
return HexFormat.of().formatHex(digest.digest());
|
||||
}
|
||||
|
||||
private static MessageDigest digest() {
|
||||
try {
|
||||
return MessageDigest.getInstance("SHA-256");
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("SHA-256 is unavailable", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static Path replaceWithBackup(Path staged, Path target) throws IOException {
|
||||
Path backup = target.resolveSibling("." + target.getFileName() + "-backup-" + UUID.randomUUID());
|
||||
if (Files.exists(target)) {
|
||||
move(target, backup, false);
|
||||
} else {
|
||||
backup = null;
|
||||
}
|
||||
try {
|
||||
move(staged, target, false);
|
||||
return backup;
|
||||
} catch (IOException failure) {
|
||||
if (backup != null && Files.exists(backup)) {
|
||||
move(backup, target, false);
|
||||
}
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
private static IOException rollback(
|
||||
Path packRoot,
|
||||
Path packBackup,
|
||||
boolean packReplaced,
|
||||
Path datapackRoot,
|
||||
Path datapackBackup,
|
||||
boolean datapackReplaced
|
||||
) {
|
||||
IOException failure = null;
|
||||
try {
|
||||
restore(packRoot, packBackup, packReplaced);
|
||||
} catch (IOException exception) {
|
||||
failure = exception;
|
||||
}
|
||||
try {
|
||||
restore(datapackRoot, datapackBackup, datapackReplaced);
|
||||
} catch (IOException exception) {
|
||||
if (failure == null) {
|
||||
failure = exception;
|
||||
} else {
|
||||
failure.addSuppressed(exception);
|
||||
}
|
||||
}
|
||||
return failure;
|
||||
}
|
||||
|
||||
private static void restore(Path target, Path backup, boolean replaced) throws IOException {
|
||||
if (!replaced) {
|
||||
return;
|
||||
}
|
||||
delete(target);
|
||||
if (backup != null && Files.exists(backup)) {
|
||||
move(backup, target, false);
|
||||
}
|
||||
}
|
||||
|
||||
private static void copyDirectory(Path source, Path target) throws IOException {
|
||||
try (Stream<Path> stream = Files.walk(source)) {
|
||||
for (Path entry : stream.toList()) {
|
||||
Path relative = source.relativize(entry);
|
||||
Path output = target.resolve(relative);
|
||||
if (Files.isDirectory(entry)) {
|
||||
Files.createDirectories(output);
|
||||
} else if (Files.isRegularFile(entry)) {
|
||||
Files.createDirectories(output.getParent());
|
||||
Files.copy(entry, output, StandardCopyOption.COPY_ATTRIBUTES);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void move(Path source, Path target, boolean replace) throws IOException {
|
||||
if (source == null) {
|
||||
return;
|
||||
}
|
||||
Files.createDirectories(target.getParent());
|
||||
try {
|
||||
if (replace) {
|
||||
Files.move(source, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
} else {
|
||||
Files.move(source, target, StandardCopyOption.ATOMIC_MOVE);
|
||||
}
|
||||
} catch (AtomicMoveNotSupportedException exception) {
|
||||
if (replace) {
|
||||
Files.move(source, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
} else {
|
||||
Files.move(source, target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void delete(Path path) throws IOException {
|
||||
if (path == null || !Files.exists(path)) {
|
||||
return;
|
||||
}
|
||||
try (Stream<Path> stream = Files.walk(path)) {
|
||||
List<Path> entries = stream.sorted(Comparator.reverseOrder()).toList();
|
||||
for (Path entry : entries) {
|
||||
Files.deleteIfExists(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void deleteQuietly(Path path, Consumer<String> feedback) {
|
||||
try {
|
||||
delete(path);
|
||||
} catch (IOException exception) {
|
||||
feedback.accept("Unable to clean temporary Iris bootstrap path " + path + ": " + exception.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static void copyLimited(InputStream input, OutputStream output, long limit) throws IOException {
|
||||
byte[] buffer = new byte[8192];
|
||||
long total = 0L;
|
||||
int read;
|
||||
while ((read = input.read(buffer)) >= 0) {
|
||||
total += read;
|
||||
if (total > limit) {
|
||||
throw new IOException("Default overworld archive exceeds the download size limit");
|
||||
}
|
||||
output.write(buffer, 0, read);
|
||||
}
|
||||
}
|
||||
|
||||
private static Properties loadProperties(Path path) throws IOException {
|
||||
Properties properties = new Properties();
|
||||
try (InputStream input = Files.newInputStream(path)) {
|
||||
properties.load(input);
|
||||
}
|
||||
return properties;
|
||||
}
|
||||
|
||||
private static void storePropertiesAtomic(Path path, Properties properties) throws IOException {
|
||||
Files.createDirectories(path.getParent());
|
||||
Path temporary = Files.createTempFile(path.getParent(), path.getFileName().toString(), ".tmp");
|
||||
try {
|
||||
try (OutputStream output = Files.newOutputStream(temporary)) {
|
||||
properties.store(output, null);
|
||||
}
|
||||
move(temporary, path, true);
|
||||
} finally {
|
||||
Files.deleteIfExists(temporary);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean retryableStatus(int status) {
|
||||
return status == 408 || status == 425 || status == 429 || status >= 500;
|
||||
}
|
||||
|
||||
private static void pause(Duration duration) throws IOException {
|
||||
if (duration.isZero() || duration.isNegative()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Thread.sleep(duration.toMillis());
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("Default overworld retry wait was interrupted", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static long parseLong(String value, long fallback) {
|
||||
if (value == null) {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
return Long.parseLong(value);
|
||||
} catch (NumberFormatException exception) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
private static void close(InputStream input) {
|
||||
try {
|
||||
input.close();
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
public enum ProvisionStatus {
|
||||
INSTALLED,
|
||||
UPDATED,
|
||||
UNCHANGED
|
||||
}
|
||||
|
||||
public record ProvisionResult(Path packRoot, Path datapackRoot, ProvisionStatus status) {
|
||||
public ProvisionResult {
|
||||
Objects.requireNonNull(packRoot, "packRoot");
|
||||
Objects.requireNonNull(datapackRoot, "datapackRoot");
|
||||
Objects.requireNonNull(status, "status");
|
||||
}
|
||||
}
|
||||
|
||||
record ProvisionOptions(
|
||||
URI source,
|
||||
HttpClient client,
|
||||
Clock clock,
|
||||
Duration refreshInterval,
|
||||
Duration requestTimeout,
|
||||
int attempts,
|
||||
Duration retryDelay,
|
||||
long maxArchiveBytes,
|
||||
Path levelRoot
|
||||
) {
|
||||
ProvisionOptions {
|
||||
Objects.requireNonNull(source, "source");
|
||||
Objects.requireNonNull(client, "client");
|
||||
Objects.requireNonNull(clock, "clock");
|
||||
Objects.requireNonNull(refreshInterval, "refreshInterval");
|
||||
Objects.requireNonNull(requestTimeout, "requestTimeout");
|
||||
Objects.requireNonNull(retryDelay, "retryDelay");
|
||||
Objects.requireNonNull(levelRoot, "levelRoot");
|
||||
if (attempts < 1 || maxArchiveBytes < 1L) {
|
||||
throw new IllegalArgumentException("Invalid bootstrap provisioning options");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private record Archive(Path path, String sha256) {
|
||||
private Archive {
|
||||
Objects.requireNonNull(sha256, "sha256");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,9 @@ import java.util.function.Consumer;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public final class PackDownloader {
|
||||
private static final String DEFAULT_OVERWORLD_PACK = "overworld";
|
||||
private static final String DEFAULT_OVERWORLD_REPOSITORY = "IrisDimensions/overworld";
|
||||
private static final String DEFAULT_OVERWORLD_RELEASE_URL = "https://github.com/IrisDimensions/overworld/releases/download/beta/overworld.zip";
|
||||
private static final Pattern GITHUB_REPOSITORY = Pattern.compile("[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+");
|
||||
private static final Pattern GITHUB_REF = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._/-]*");
|
||||
private static final Pattern COMMIT_SHA = Pattern.compile("[0-9a-fA-F]{40}");
|
||||
@@ -40,6 +43,14 @@ public final class PackDownloader {
|
||||
private PackDownloader() {
|
||||
}
|
||||
|
||||
public static boolean isDefaultOverworld(String pack) {
|
||||
return DEFAULT_OVERWORLD_PACK.equals(pack);
|
||||
}
|
||||
|
||||
public static String downloadDefaultOverworld(File packsFolder, boolean forceOverwrite, Consumer<String> feedback) throws IOException {
|
||||
return download(packsFolder, DEFAULT_OVERWORLD_REPOSITORY, defaultOverworldReleaseUrl(), forceOverwrite, true, feedback);
|
||||
}
|
||||
|
||||
public static String download(File packsFolder, String repo, String ref, boolean forceOverwrite, boolean directUrl, Consumer<String> feedback) throws IOException {
|
||||
String url = directUrl ? ref : resolveGithubArchiveUrl(repo, ref);
|
||||
feedback.accept("Downloading " + url + " "); //The extra space stops a bug in adventure API from repeating the last letter of the URL
|
||||
@@ -170,6 +181,10 @@ public final class PackDownloader {
|
||||
return "https://codeload.github.com/" + repo + "/zip/" + qualifiedRef;
|
||||
}
|
||||
|
||||
static String defaultOverworldReleaseUrl() {
|
||||
return DEFAULT_OVERWORLD_RELEASE_URL;
|
||||
}
|
||||
|
||||
private static void validateGithubRef(String qualifiedRef) {
|
||||
String refPath = qualifiedRef.startsWith("refs/heads/")
|
||||
? qualifiedRef.substring("refs/heads/".length())
|
||||
|
||||
@@ -37,6 +37,7 @@ import art.arcane.iris.core.tools.IrisToolbelt;
|
||||
import art.arcane.iris.engine.data.cache.AtomicCache;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
|
||||
import art.arcane.iris.platform.bukkit.BukkitPlatform;
|
||||
import art.arcane.volmlib.util.collection.KMap;
|
||||
import art.arcane.volmlib.util.exceptions.IrisException;
|
||||
import art.arcane.volmlib.util.io.IO;
|
||||
@@ -72,9 +73,9 @@ public class StudioSVC implements IrisService {
|
||||
File f = IrisPack.packsPack(pack);
|
||||
|
||||
if (!f.exists()) {
|
||||
if (pack.equals("overworld")) {
|
||||
IrisLogging.info("Downloading Default Pack " + pack + " (latest on master)");
|
||||
IrisServices.get(StudioSVC.class).downloadBranch(art.arcane.iris.platform.bukkit.BukkitPlatform.console(), "IrisDimensions/overworld", "master", false);
|
||||
if (PackDownloader.isDefaultOverworld(pack)) {
|
||||
IrisLogging.info("Downloading Default Pack " + pack + " (beta release)");
|
||||
IrisServices.get(StudioSVC.class).downloadDefaultOverworld(BukkitPlatform.console(), false);
|
||||
ServerConfigurator.installDataPacksIfChanged(true);
|
||||
} else {
|
||||
IrisLogging.warn("Default pack '" + pack + "' is not installed. Please download it manually with /iris download " + pack);
|
||||
@@ -244,13 +245,16 @@ public class StudioSVC implements IrisService {
|
||||
}
|
||||
}
|
||||
|
||||
public void downloadRelease(VolmitSender sender, String url, boolean forceOverwrite) {
|
||||
public void downloadDefaultOverworld(VolmitSender sender, boolean forceOverwrite) {
|
||||
try {
|
||||
download(sender, "IrisDimensions", url, forceOverwrite, true);
|
||||
String key = PackDownloader.downloadDefaultOverworld(getWorkspaceFolder(), forceOverwrite, sender::sendMessage);
|
||||
if (key != null) {
|
||||
ServerConfigurator.installDataPacks(true);
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.reportError(e);
|
||||
e.printStackTrace();
|
||||
sender.sendMessage("Failed to download 'IrisDimensions/overworld' from " + url + ".");
|
||||
sender.sendMessage("Failed to download the IrisDimensions/overworld beta release.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,10 +18,8 @@
|
||||
|
||||
package art.arcane.iris.engine.object;
|
||||
|
||||
import art.arcane.iris.core.IrisSettings;
|
||||
import art.arcane.iris.core.ServerConfigurator.DimensionHeight;
|
||||
import art.arcane.iris.core.IrisDatapackCompiler.DimensionHeight;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.platform.bukkit.BukkitPlatform;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
import art.arcane.iris.core.loader.IrisRegistrant;
|
||||
@@ -508,7 +506,7 @@ public class IrisDimension extends IrisRegistrant {
|
||||
return landBiomeStyle;
|
||||
}
|
||||
|
||||
public void installBiomes(IDataFixer fixer, DataProvider data, KList<File> folders, KSet<String> biomes) {
|
||||
public void installBiomes(IDataFixer fixer, DataProvider data, KList<File> datapackRoots, KSet<String> biomes) throws IOException {
|
||||
String namespace = getLoadKey().toLowerCase(Locale.ROOT);
|
||||
|
||||
for (IrisBiome irisBiome : getAllBiomes(data)) {
|
||||
@@ -527,26 +525,21 @@ public class IrisDimension extends IrisRegistrant {
|
||||
}
|
||||
}
|
||||
|
||||
for (File datapacks : folders) {
|
||||
File output = new File(datapacks, "iris/data/" + namespace + "/worldgen/biome/" + customBiomeId + ".json");
|
||||
for (File datapackRoot : datapackRoots) {
|
||||
File output = new File(datapackRoot, "data/" + namespace + "/worldgen/biome/" + customBiomeId + ".json");
|
||||
|
||||
IrisLogging.debug(" Installing Data Pack Biome: " + output.getPath());
|
||||
output.getParentFile().mkdirs();
|
||||
try {
|
||||
IO.writeAll(output, json);
|
||||
installBiomeTags(datapacks, namespace + ":" + customBiomeId, customBiome.getTags());
|
||||
} catch (IOException e) {
|
||||
IrisLogging.reportError(e);
|
||||
e.printStackTrace();
|
||||
}
|
||||
IO.writeAll(output, json);
|
||||
installBiomeTags(datapackRoot, namespace + ":" + customBiomeId, customBiome.getTags());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void clearGeneratedBiomeTags(KList<File> folders) {
|
||||
for (File datapacks : folders) {
|
||||
File dataFolder = new File(datapacks, "iris/data");
|
||||
public static void clearGeneratedBiomeTags(KList<File> datapackRoots) {
|
||||
for (File datapackRoot : datapackRoots) {
|
||||
File dataFolder = new File(datapackRoot, "data");
|
||||
File[] namespaces = dataFolder.listFiles(File::isDirectory);
|
||||
if (namespaces == null) {
|
||||
continue;
|
||||
@@ -557,7 +550,7 @@ public class IrisDimension extends IrisRegistrant {
|
||||
}
|
||||
}
|
||||
|
||||
static void installBiomeTags(File datapacks, String biomeKey, KList<String> tags) throws IOException {
|
||||
static void installBiomeTags(File datapackRoot, String biomeKey, KList<String> tags) throws IOException {
|
||||
if (tags == null || tags.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
@@ -570,7 +563,7 @@ public class IrisDimension extends IrisRegistrant {
|
||||
int separator = tag.indexOf(':');
|
||||
String namespace = tag.substring(0, separator);
|
||||
String path = tag.substring(separator + 1);
|
||||
Path tagRoot = new File(datapacks, "iris/data/" + namespace + "/tags/worldgen/biome").toPath()
|
||||
Path tagRoot = new File(datapackRoot, "data/" + namespace + "/tags/worldgen/biome").toPath()
|
||||
.toAbsolutePath().normalize();
|
||||
Path output = tagRoot.resolve(path + ".json").normalize();
|
||||
if (!output.startsWith(tagRoot)) {
|
||||
@@ -677,21 +670,16 @@ public class IrisDimension extends IrisRegistrant {
|
||||
return upperDimension != null && !upperDimension.isEmpty() && !upperDimension.equalsIgnoreCase("none");
|
||||
}
|
||||
|
||||
public void installDimensionType(IDataFixer fixer, KList<File> folders) {
|
||||
public void installDimensionType(IDataFixer fixer, KList<File> datapackRoots) throws IOException {
|
||||
IrisDimensionType type = getDimensionType();
|
||||
String json = type.toJson(fixer);
|
||||
String dimensionTypeKey = getDimensionTypeKey();
|
||||
|
||||
IrisLogging.debug(" Installing Data Pack Dimension Type: \"iris:" + dimensionTypeKey + '"');
|
||||
for (File datapacks : folders) {
|
||||
File output = new File(datapacks, "iris/data/iris/dimension_type/" + dimensionTypeKey + ".json");
|
||||
for (File datapackRoot : datapackRoots) {
|
||||
File output = new File(datapackRoot, "data/iris/dimension_type/" + dimensionTypeKey + ".json");
|
||||
output.getParentFile().mkdirs();
|
||||
try {
|
||||
IO.writeAll(output, json);
|
||||
} catch (IOException e) {
|
||||
IrisLogging.reportError(e);
|
||||
e.printStackTrace();
|
||||
}
|
||||
IO.writeAll(output, json);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -710,13 +698,18 @@ public class IrisDimension extends IrisRegistrant {
|
||||
|
||||
}
|
||||
|
||||
public static void writeShared(KList<File> folders, DimensionHeight height) {
|
||||
public static void writeShared(
|
||||
KList<File> datapackRoots,
|
||||
DimensionHeight height,
|
||||
int packFormat,
|
||||
boolean adjustVanillaHeight
|
||||
) throws IOException {
|
||||
IrisLogging.debug(" Installing Data Pack Vanilla Dimension Types");
|
||||
String[] jsonStrings = height.jsonStrings();
|
||||
for (File datapacks : folders) {
|
||||
write(datapacks, "overworld", jsonStrings[0]);
|
||||
write(datapacks, "the_nether", jsonStrings[1]);
|
||||
write(datapacks, "the_end", jsonStrings[2]);
|
||||
for (File datapackRoot : datapackRoots) {
|
||||
write(datapackRoot, "overworld", jsonStrings[0], adjustVanillaHeight);
|
||||
write(datapackRoot, "the_nether", jsonStrings[1], adjustVanillaHeight);
|
||||
write(datapackRoot, "the_end", jsonStrings[2], adjustVanillaHeight);
|
||||
}
|
||||
|
||||
String raw = """
|
||||
@@ -728,32 +721,24 @@ public class IrisDimension extends IrisRegistrant {
|
||||
"max_format": {}
|
||||
}
|
||||
}
|
||||
""".replace("{}", BukkitPlatform.dataPackFormat() + "");
|
||||
""".replace("{}", Integer.toString(packFormat));
|
||||
|
||||
for (File datapacks : folders) {
|
||||
File mcm = new File(datapacks, "iris/pack.mcmeta");
|
||||
try {
|
||||
IO.writeAll(mcm, raw);
|
||||
} catch (IOException e) {
|
||||
IrisLogging.reportError(e);
|
||||
e.printStackTrace();
|
||||
}
|
||||
for (File datapackRoot : datapackRoots) {
|
||||
File mcm = new File(datapackRoot, "pack.mcmeta");
|
||||
IO.writeAll(mcm, raw);
|
||||
IrisLogging.debug(" Installing Data Pack MCMeta: " + mcm.getPath());
|
||||
}
|
||||
}
|
||||
|
||||
private static void write(File datapacks, String type, String json) {
|
||||
if (json == null) return;
|
||||
File dimTypeVanilla = new File(datapacks, "iris/data/minecraft/dimension_type/" + type + ".json");
|
||||
private static void write(File datapackRoot, String type, String json, boolean adjustVanillaHeight) throws IOException {
|
||||
if (json == null) {
|
||||
return;
|
||||
}
|
||||
File dimTypeVanilla = new File(datapackRoot, "data/minecraft/dimension_type/" + type + ".json");
|
||||
|
||||
if (IrisSettings.get().getGeneral().adjustVanillaHeight || dimTypeVanilla.exists()) {
|
||||
if (adjustVanillaHeight || dimTypeVanilla.exists()) {
|
||||
dimTypeVanilla.getParentFile().mkdirs();
|
||||
try {
|
||||
IO.writeAll(dimTypeVanilla, json);
|
||||
} catch (IOException e) {
|
||||
IrisLogging.reportError(e);
|
||||
e.printStackTrace();
|
||||
}
|
||||
IO.writeAll(dimTypeVanilla, json);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package art.arcane.iris.core;
|
||||
|
||||
import art.arcane.iris.core.nms.datapack.v1217.DataFixerV1217;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.Assume;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class IrisDatapackCompilerTest {
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void compilesInstalledAndWorldLocalPacksIntoOneDatapack() throws Exception {
|
||||
Path dataDirectory = temporaryFolder.newFolder("data").toPath();
|
||||
Path serverRoot = temporaryFolder.newFolder("server").toPath();
|
||||
createPack(dataDirectory.resolve("packs/alpha"), "alpha", "alpha_custom");
|
||||
createPack(dataDirectory.resolve("packs/beta"), "beta", "beta_custom");
|
||||
createPack(serverRoot.resolve("dimensions/example/world/iris/pack"), "world_local", "world_custom");
|
||||
|
||||
List<File> packRoots = IrisDatapackCompiler.collectPackRoots(dataDirectory, serverRoot);
|
||||
Path datapackRoot = temporaryFolder.newFolder("datapack").toPath();
|
||||
IrisDatapackCompiler.CompilationResult result = IrisDatapackCompiler.compile(
|
||||
packRoots,
|
||||
new KList<File>().qadd(datapackRoot.toFile()),
|
||||
new DataFixerV1217(),
|
||||
107,
|
||||
false
|
||||
);
|
||||
|
||||
assertEquals(3, result.packCount());
|
||||
assertEquals(3, result.dimensionCount());
|
||||
assertEquals(3, result.biomeCount());
|
||||
assertTrue(Files.isRegularFile(datapackRoot.resolve("pack.mcmeta")));
|
||||
assertTrue(Files.isRegularFile(datapackRoot.resolve("data/iris/dimension_type/alpha.json")));
|
||||
assertTrue(Files.isRegularFile(datapackRoot.resolve("data/iris/dimension_type/beta.json")));
|
||||
assertTrue(Files.isRegularFile(datapackRoot.resolve("data/iris/dimension_type/world_local.json")));
|
||||
assertTrue(Files.isRegularFile(datapackRoot.resolve("data/alpha/worldgen/biome/alpha_custom.json")));
|
||||
assertTrue(Files.isRegularFile(datapackRoot.resolve("data/beta/worldgen/biome/beta_custom.json")));
|
||||
assertTrue(Files.isRegularFile(datapackRoot.resolve("data/world_local/worldgen/biome/world_custom.json")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void compilesExternalPackFixtureWhenConfigured() throws Exception {
|
||||
String configuredPack = System.getenv("IRIS_TEST_PACK");
|
||||
Assume.assumeTrue(configuredPack != null && !configuredPack.isBlank());
|
||||
Path packRoot = Path.of(configuredPack).toAbsolutePath().normalize();
|
||||
Assume.assumeTrue(Files.isDirectory(packRoot.resolve("dimensions")));
|
||||
Path datapackRoot = temporaryFolder.newFolder("external-datapack").toPath();
|
||||
|
||||
IrisDatapackCompiler.CompilationResult result = IrisDatapackCompiler.compile(
|
||||
List.of(packRoot.toFile()),
|
||||
new KList<File>().qadd(datapackRoot.toFile()),
|
||||
new DataFixerV1217(),
|
||||
107,
|
||||
false
|
||||
);
|
||||
|
||||
assertTrue(result.packCount() > 0);
|
||||
assertTrue(result.dimensionCount() > 0);
|
||||
assertTrue(result.biomeCount() > 0);
|
||||
assertTrue(Files.isRegularFile(datapackRoot.resolve("pack.mcmeta")));
|
||||
}
|
||||
|
||||
private static void createPack(Path root, String dimensionKey, String biomeId) throws Exception {
|
||||
Files.createDirectories(root.resolve("dimensions"));
|
||||
Files.createDirectories(root.resolve("biomes"));
|
||||
Files.writeString(
|
||||
root.resolve("dimensions").resolve(dimensionKey + ".json"),
|
||||
"""
|
||||
{
|
||||
"name": "Test Dimension",
|
||||
"environment": "NORMAL",
|
||||
"logicalHeight": 256,
|
||||
"dimensionHeight": {
|
||||
"min": -64,
|
||||
"max": 320
|
||||
}
|
||||
}
|
||||
""",
|
||||
StandardCharsets.UTF_8
|
||||
);
|
||||
Files.writeString(
|
||||
root.resolve("biomes/test.json"),
|
||||
"""
|
||||
{
|
||||
"name": "Test Biome",
|
||||
"derivative": "minecraft:plains",
|
||||
"vanillaDerivative": "minecraft:plains",
|
||||
"customDerivitives": [
|
||||
{
|
||||
"id": "%s"
|
||||
}
|
||||
]
|
||||
}
|
||||
""".formatted(biomeId),
|
||||
StandardCharsets.UTF_8
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package art.arcane.iris.core.loader;
|
||||
|
||||
import art.arcane.iris.engine.framework.MeteredCache;
|
||||
import art.arcane.iris.engine.framework.PreservationRegistry;
|
||||
import art.arcane.iris.spi.IrisServices;
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
|
||||
import static org.junit.Assert.assertSame;
|
||||
|
||||
public class IrisDataDatapackCompilerCacheTest {
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
private IrisData runtimeData;
|
||||
private IrisData compilerData;
|
||||
|
||||
@Before
|
||||
public void registerPreservationService() {
|
||||
IrisServices.register(PreservationRegistry.class, new NoOpPreservationRegistry());
|
||||
}
|
||||
|
||||
@After
|
||||
public void closeDataAndServices() {
|
||||
if (compilerData != null) {
|
||||
compilerData.close();
|
||||
}
|
||||
if (runtimeData != null) {
|
||||
runtimeData.close();
|
||||
}
|
||||
IrisServices.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void closingCompilerDoesNotEvictRuntimeDataForSameDirectory() throws Exception {
|
||||
File packDirectory = temporaryFolder.newFolder("pack");
|
||||
runtimeData = IrisData.get(packDirectory);
|
||||
compilerData = IrisData.openDatapackCompiler(packDirectory);
|
||||
|
||||
compilerData.close();
|
||||
compilerData = null;
|
||||
|
||||
assertSame(runtimeData, IrisData.getLoaded(packDirectory).orElseThrow());
|
||||
}
|
||||
|
||||
private static final class NoOpPreservationRegistry implements PreservationRegistry {
|
||||
@Override
|
||||
public void register(Thread thread) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void register(ExecutorService service) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerCache(MeteredCache cache) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dereference() {
|
||||
}
|
||||
}
|
||||
}
|
||||
+389
@@ -0,0 +1,389 @@
|
||||
package art.arcane.iris.core.pack;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class DefaultPackBootstrapProvisionerTest {
|
||||
@Test
|
||||
public void coldInstallUsesFreshCacheWithoutSecondRequest() throws Exception {
|
||||
byte[] archive = packArchive("overworld", "bootstrap_biome");
|
||||
AtomicInteger requests = new AtomicInteger();
|
||||
HttpServer server = server(archive, requests);
|
||||
Path root = Files.createTempDirectory("iris-bootstrap-cold");
|
||||
try {
|
||||
Path dataDirectory = root.resolve("plugins/Iris");
|
||||
DefaultPackBootstrapProvisioner.ProvisionOptions options = options(server, root, Duration.ofHours(1));
|
||||
DefaultPackBootstrapProvisioner.ProvisionResult installed = DefaultPackBootstrapProvisioner.provision(
|
||||
dataDirectory,
|
||||
ignored -> {
|
||||
},
|
||||
options
|
||||
);
|
||||
DefaultPackBootstrapProvisioner.ProvisionResult unchanged = DefaultPackBootstrapProvisioner.provision(
|
||||
dataDirectory,
|
||||
ignored -> {
|
||||
},
|
||||
options
|
||||
);
|
||||
|
||||
assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.INSTALLED, installed.status());
|
||||
assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.UNCHANGED, unchanged.status());
|
||||
assertEquals(1, requests.get());
|
||||
assertTrue(Files.isRegularFile(installed.packRoot().resolve("dimensions/overworld.json")));
|
||||
assertTrue(Files.isRegularFile(installed.datapackRoot().resolve("pack.mcmeta")));
|
||||
assertTrue(Files.isRegularFile(installed.datapackRoot().resolve("data/overworld/worldgen/biome/bootstrap_biome.json")));
|
||||
assertTrue(DefaultPackBootstrapProvisioner.isProvisioned(dataDirectory));
|
||||
assertTrue(DefaultPackBootstrapProvisioner.wasProvisionedThisStartup());
|
||||
} finally {
|
||||
server.stop(0);
|
||||
delete(root);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void corruptCacheRedownloadsAndRepairsArchive() throws Exception {
|
||||
byte[] archive = packArchive("overworld", "bootstrap_biome");
|
||||
AtomicInteger requests = new AtomicInteger();
|
||||
HttpServer server = server(archive, requests);
|
||||
Path root = Files.createTempDirectory("iris-bootstrap-corrupt-cache");
|
||||
try {
|
||||
Path dataDirectory = root.resolve("plugins/Iris");
|
||||
DefaultPackBootstrapProvisioner.ProvisionOptions options = options(server, root, Duration.ofHours(1));
|
||||
DefaultPackBootstrapProvisioner.provision(dataDirectory, ignored -> {
|
||||
}, options);
|
||||
Path cache = dataDirectory.resolve("cache/bootstrap/default-overworld.zip");
|
||||
Files.writeString(cache, "corrupt", StandardCharsets.UTF_8);
|
||||
|
||||
DefaultPackBootstrapProvisioner.provision(dataDirectory, ignored -> {
|
||||
}, options);
|
||||
|
||||
assertEquals(2, requests.get());
|
||||
try (ZipInputStream zip = new ZipInputStream(Files.newInputStream(cache))) {
|
||||
assertTrue(zip.getNextEntry() != null);
|
||||
}
|
||||
} finally {
|
||||
server.stop(0);
|
||||
delete(root);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void traversalArchiveFailsWithoutMarkerOrEscape() throws Exception {
|
||||
LinkedHashMap<String, String> files = new LinkedHashMap<>();
|
||||
files.put("dimensions/overworld.json", dimensionJson("overworld"));
|
||||
files.put("../escape.txt", "unsafe");
|
||||
AtomicInteger requests = new AtomicInteger();
|
||||
HttpServer server = server(zip(files), requests);
|
||||
Path root = Files.createTempDirectory("iris-bootstrap-traversal");
|
||||
try {
|
||||
Path dataDirectory = root.resolve("plugins/Iris");
|
||||
DefaultPackBootstrapProvisioner.ProvisionOptions options = options(server, root, Duration.ZERO);
|
||||
|
||||
assertThrows(IOException.class, () -> DefaultPackBootstrapProvisioner.provision(
|
||||
dataDirectory,
|
||||
ignored -> {
|
||||
},
|
||||
options
|
||||
));
|
||||
assertFalse(Files.exists(dataDirectory.resolve("bootstrap/provisioned.properties")));
|
||||
assertFalse(Files.exists(root.resolve("escape.txt")));
|
||||
} finally {
|
||||
server.stop(0);
|
||||
delete(root);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void missingDimensionLayoutFailsWithoutInstallingPack() throws Exception {
|
||||
AtomicInteger requests = new AtomicInteger();
|
||||
HttpServer server = server(zip(Map.of("README.md", "not an Iris pack")), requests);
|
||||
Path root = Files.createTempDirectory("iris-bootstrap-layout");
|
||||
try {
|
||||
Path dataDirectory = root.resolve("plugins/Iris");
|
||||
DefaultPackBootstrapProvisioner.ProvisionOptions options = options(server, root, Duration.ZERO);
|
||||
|
||||
assertThrows(IOException.class, () -> DefaultPackBootstrapProvisioner.provision(
|
||||
dataDirectory,
|
||||
ignored -> {
|
||||
},
|
||||
options
|
||||
));
|
||||
assertFalse(Files.exists(dataDirectory.resolve("packs/overworld")));
|
||||
assertFalse(Files.exists(dataDirectory.resolve("bootstrap/provisioned.properties")));
|
||||
} finally {
|
||||
server.stop(0);
|
||||
delete(root);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void existingSymlinkedPackIsCompiledWithoutDownloadOrReplacement() throws Exception {
|
||||
AtomicInteger requests = new AtomicInteger();
|
||||
HttpServer server = server(packArchive("remote", "remote_biome"), requests);
|
||||
Path root = Files.createTempDirectory("iris-bootstrap-symlink");
|
||||
try {
|
||||
Path dataDirectory = root.resolve("plugins/Iris");
|
||||
Path target = root.resolve("custom-overworld");
|
||||
writePack(target, "overworld", "local_biome");
|
||||
Files.createDirectories(dataDirectory.resolve("packs"));
|
||||
Path link = dataDirectory.resolve("packs/overworld");
|
||||
Files.createSymbolicLink(link, target);
|
||||
DefaultPackBootstrapProvisioner.ProvisionOptions options = options(server, root, Duration.ZERO);
|
||||
|
||||
DefaultPackBootstrapProvisioner.ProvisionResult result = DefaultPackBootstrapProvisioner.provision(
|
||||
dataDirectory,
|
||||
ignored -> {
|
||||
},
|
||||
options
|
||||
);
|
||||
|
||||
assertEquals(0, requests.get());
|
||||
assertTrue(Files.isSymbolicLink(link));
|
||||
assertEquals(target.toRealPath(), link.toRealPath());
|
||||
assertTrue(Files.isRegularFile(result.datapackRoot().resolve("data/overworld/worldgen/biome/local_biome.json")));
|
||||
|
||||
Files.writeString(target.resolve("biomes/local.json"), biomeJson("changed_biome"), StandardCharsets.UTF_8);
|
||||
assertFalse(DefaultPackBootstrapProvisioner.isProvisioned(dataDirectory));
|
||||
DefaultPackBootstrapProvisioner.ProvisionResult updated = DefaultPackBootstrapProvisioner.provision(
|
||||
dataDirectory,
|
||||
ignored -> {
|
||||
},
|
||||
options
|
||||
);
|
||||
assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.UPDATED, updated.status());
|
||||
assertEquals(0, requests.get());
|
||||
assertTrue(Files.isSymbolicLink(link));
|
||||
} finally {
|
||||
server.stop(0);
|
||||
delete(root);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void additionalPackRebuildsAggregateWithoutNetwork() throws Exception {
|
||||
AtomicInteger requests = new AtomicInteger();
|
||||
HttpServer server = server(packArchive("overworld", "first_biome"), requests);
|
||||
Path root = Files.createTempDirectory("iris-bootstrap-aggregate");
|
||||
try {
|
||||
Path dataDirectory = root.resolve("plugins/Iris");
|
||||
DefaultPackBootstrapProvisioner.ProvisionOptions options = options(server, root, Duration.ofHours(1));
|
||||
DefaultPackBootstrapProvisioner.provision(dataDirectory, ignored -> {
|
||||
}, options);
|
||||
writePack(dataDirectory.resolve("packs/second"), "second", "second_biome");
|
||||
writePack(root.resolve("dimensions/example/world/iris/pack"), "world_local", "world_local_biome");
|
||||
|
||||
assertFalse(DefaultPackBootstrapProvisioner.isProvisioned(dataDirectory));
|
||||
DefaultPackBootstrapProvisioner.ProvisionResult updated = DefaultPackBootstrapProvisioner.provision(
|
||||
dataDirectory,
|
||||
ignored -> {
|
||||
},
|
||||
options
|
||||
);
|
||||
|
||||
assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.UPDATED, updated.status());
|
||||
assertEquals(1, requests.get());
|
||||
assertTrue(Files.isRegularFile(updated.datapackRoot().resolve("data/second/worldgen/biome/second_biome.json")));
|
||||
assertTrue(Files.isRegularFile(updated.datapackRoot().resolve("data/world_local/worldgen/biome/world_local_biome.json")));
|
||||
} finally {
|
||||
server.stop(0);
|
||||
delete(root);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void localEditRelinquishesManagedOwnershipWithoutRemoteReplacement() throws Exception {
|
||||
AtomicInteger requests = new AtomicInteger();
|
||||
HttpServer server = server(packArchive("overworld", "managed_biome"), requests);
|
||||
Path root = Files.createTempDirectory("iris-bootstrap-managed-edit");
|
||||
try {
|
||||
Path dataDirectory = root.resolve("plugins/Iris");
|
||||
DefaultPackBootstrapProvisioner.ProvisionOptions initialOptions = options(server, root, Duration.ofHours(1));
|
||||
DefaultPackBootstrapProvisioner.provision(dataDirectory, ignored -> {
|
||||
}, initialOptions);
|
||||
Path editedBiome = dataDirectory.resolve("packs/overworld/biomes/local.json");
|
||||
Files.writeString(editedBiome, biomeJson("locally_edited_biome"), StandardCharsets.UTF_8);
|
||||
DefaultPackBootstrapProvisioner.ProvisionOptions refreshOptions = options(server, root, Duration.ZERO);
|
||||
|
||||
DefaultPackBootstrapProvisioner.ProvisionResult updated = DefaultPackBootstrapProvisioner.provision(
|
||||
dataDirectory,
|
||||
ignored -> {
|
||||
},
|
||||
refreshOptions
|
||||
);
|
||||
|
||||
assertEquals(1, requests.get());
|
||||
assertTrue(Files.readString(editedBiome).contains("locally_edited_biome"));
|
||||
assertTrue(Files.isRegularFile(updated.datapackRoot().resolve("data/overworld/worldgen/biome/locally_edited_biome.json")));
|
||||
Properties marker = new Properties();
|
||||
try (java.io.InputStream input = Files.newInputStream(dataDirectory.resolve("bootstrap/provisioned.properties"))) {
|
||||
marker.load(input);
|
||||
}
|
||||
assertEquals("false", marker.getProperty("managedDefault"));
|
||||
} finally {
|
||||
server.stop(0);
|
||||
delete(root);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedAggregateCompilationPreservesPreviousOutputAndMarker() throws Exception {
|
||||
AtomicInteger requests = new AtomicInteger();
|
||||
HttpServer server = server(packArchive("overworld", "first_biome"), requests);
|
||||
Path root = Files.createTempDirectory("iris-bootstrap-rollback");
|
||||
try {
|
||||
Path dataDirectory = root.resolve("plugins/Iris");
|
||||
DefaultPackBootstrapProvisioner.ProvisionOptions options = options(server, root, Duration.ofHours(1));
|
||||
DefaultPackBootstrapProvisioner.ProvisionResult first = DefaultPackBootstrapProvisioner.provision(
|
||||
dataDirectory,
|
||||
ignored -> {
|
||||
},
|
||||
options
|
||||
);
|
||||
byte[] marker = Files.readAllBytes(dataDirectory.resolve("bootstrap/provisioned.properties"));
|
||||
byte[] metadata = Files.readAllBytes(first.datapackRoot().resolve("pack.mcmeta"));
|
||||
Path invalidPack = dataDirectory.resolve("packs/invalid");
|
||||
Files.createDirectories(invalidPack.resolve("dimensions"));
|
||||
Files.writeString(invalidPack.resolve("dimensions/broken.json"), "{", StandardCharsets.UTF_8);
|
||||
|
||||
assertThrows(IOException.class, () -> DefaultPackBootstrapProvisioner.provision(
|
||||
dataDirectory,
|
||||
ignored -> {
|
||||
},
|
||||
options
|
||||
));
|
||||
assertTrue(java.util.Arrays.equals(marker, Files.readAllBytes(dataDirectory.resolve("bootstrap/provisioned.properties"))));
|
||||
assertTrue(java.util.Arrays.equals(metadata, Files.readAllBytes(first.datapackRoot().resolve("pack.mcmeta"))));
|
||||
} finally {
|
||||
server.stop(0);
|
||||
delete(root);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolvesConfiguredLevelRootFromServerProperties() throws Exception {
|
||||
Path serverRoot = Files.createTempDirectory("iris-bootstrap-level-root");
|
||||
try {
|
||||
Files.writeString(
|
||||
serverRoot.resolve("server.properties"),
|
||||
"level-name=levels/primary\n",
|
||||
StandardCharsets.UTF_8
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
serverRoot.resolve("levels/primary").normalize(),
|
||||
DefaultPackBootstrapProvisioner.resolveLevelRoot(serverRoot)
|
||||
);
|
||||
} finally {
|
||||
delete(serverRoot);
|
||||
}
|
||||
}
|
||||
|
||||
private static DefaultPackBootstrapProvisioner.ProvisionOptions options(
|
||||
HttpServer server,
|
||||
Path serverRoot,
|
||||
Duration refreshInterval
|
||||
) {
|
||||
URI source = URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/overworld.zip");
|
||||
return new DefaultPackBootstrapProvisioner.ProvisionOptions(
|
||||
source,
|
||||
HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build(),
|
||||
Clock.fixed(Instant.parse("2026-07-12T12:00:00Z"), ZoneOffset.UTC),
|
||||
refreshInterval,
|
||||
Duration.ofSeconds(2),
|
||||
1,
|
||||
Duration.ZERO,
|
||||
8L * 1024L * 1024L,
|
||||
serverRoot
|
||||
);
|
||||
}
|
||||
|
||||
private static HttpServer server(byte[] response, AtomicInteger requests) throws IOException {
|
||||
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/overworld.zip", exchange -> respond(exchange, response, requests));
|
||||
server.start();
|
||||
return server;
|
||||
}
|
||||
|
||||
private static void respond(HttpExchange exchange, byte[] response, AtomicInteger requests) throws IOException {
|
||||
requests.incrementAndGet();
|
||||
exchange.getResponseHeaders().add("Content-Type", "application/zip");
|
||||
exchange.sendResponseHeaders(200, response.length);
|
||||
exchange.getResponseBody().write(response);
|
||||
exchange.close();
|
||||
}
|
||||
|
||||
private static byte[] packArchive(String dimensionKey, String biomeId) throws IOException {
|
||||
LinkedHashMap<String, String> files = new LinkedHashMap<>();
|
||||
files.put("dimensions/" + dimensionKey + ".json", dimensionJson(dimensionKey));
|
||||
files.put("regions/local.json", "{\"name\":\"Local\",\"landBiomes\":[\"local\"]}");
|
||||
files.put("biomes/local.json", biomeJson(biomeId));
|
||||
return zip(files);
|
||||
}
|
||||
|
||||
private static void writePack(Path root, String dimensionKey, String biomeId) throws IOException {
|
||||
Files.createDirectories(root.resolve("dimensions"));
|
||||
Files.createDirectories(root.resolve("regions"));
|
||||
Files.createDirectories(root.resolve("biomes"));
|
||||
Files.writeString(root.resolve("dimensions/" + dimensionKey + ".json"), dimensionJson(dimensionKey), StandardCharsets.UTF_8);
|
||||
Files.writeString(root.resolve("regions/local.json"), "{\"name\":\"Local\",\"landBiomes\":[\"local\"]}", StandardCharsets.UTF_8);
|
||||
Files.writeString(root.resolve("biomes/local.json"), biomeJson(biomeId), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static String dimensionJson(String name) {
|
||||
return "{\"name\":\"" + name + "\",\"regions\":[\"local\"],\"logicalHeight\":256,\"dimensionHeight\":{\"min\":-64,\"max\":320}}";
|
||||
}
|
||||
|
||||
private static String biomeJson(String biomeId) {
|
||||
return "{\"name\":\"Local\",\"derivative\":\"minecraft:plains\",\"customDerivitives\":[{\"id\":\""
|
||||
+ biomeId + "\",\"category\":\"plains\"}]}";
|
||||
}
|
||||
|
||||
private static byte[] zip(Map<String, String> files) throws IOException {
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
try (ZipOutputStream zip = new ZipOutputStream(bytes)) {
|
||||
for (Map.Entry<String, String> entry : files.entrySet()) {
|
||||
zip.putNextEntry(new ZipEntry(entry.getKey()));
|
||||
zip.write(entry.getValue().getBytes(StandardCharsets.UTF_8));
|
||||
zip.closeEntry();
|
||||
}
|
||||
}
|
||||
return bytes.toByteArray();
|
||||
}
|
||||
|
||||
private static void delete(Path root) throws IOException {
|
||||
if (!Files.exists(root)) {
|
||||
return;
|
||||
}
|
||||
try (java.util.stream.Stream<Path> stream = Files.walk(root)) {
|
||||
for (Path path : stream.sorted(java.util.Comparator.reverseOrder()).toList()) {
|
||||
Files.deleteIfExists(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,9 +21,27 @@ package art.arcane.iris.core.pack;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class PackDownloaderTest {
|
||||
@Test
|
||||
public void resolvesDefaultOverworldBetaRelease() {
|
||||
assertEquals(
|
||||
"https://github.com/IrisDimensions/overworld/releases/download/beta/overworld.zip",
|
||||
PackDownloader.defaultOverworldReleaseUrl()
|
||||
);
|
||||
assertTrue(PackDownloader.isDefaultOverworld("overworld"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsOtherPacksAsDefaultOverworld() {
|
||||
assertFalse(PackDownloader.isDefaultOverworld("theend"));
|
||||
assertFalse(PackDownloader.isDefaultOverworld(""));
|
||||
assertFalse(PackDownloader.isDefaultOverworld(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolvesBranchReference() {
|
||||
assertEquals(
|
||||
|
||||
@@ -41,7 +41,7 @@ public class IrisDimensionBiomeTagTest {
|
||||
IrisDimension.installBiomeTags(temporaryFolder.getRoot(), "overworld:swamp", tags);
|
||||
|
||||
Path output = temporaryFolder.getRoot().toPath()
|
||||
.resolve("iris/data/minecraft/tags/worldgen/biome/allows_surface_slime_spawns.json");
|
||||
.resolve("data/minecraft/tags/worldgen/biome/allows_surface_slime_spawns.json");
|
||||
JSONObject tag = new JSONObject(Files.readString(output, StandardCharsets.UTF_8));
|
||||
|
||||
assertEquals("overworld:swamp", tag.getJSONArray("values").getString(0));
|
||||
|
||||
Reference in New Issue
Block a user