mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-12 03:24:14 +00:00
Datapack imports from Modrinth, HTTP(S) and local file ZIPs
Work in progress parked from the working tree so the hydrology merge could land; amend or squash as needed.
This commit is contained in:
@@ -67,7 +67,9 @@ the server. First boot performs no pack download. Run `/iris download pack=overw
|
||||
each download to finish before starting another. The current built-in packs declare no external
|
||||
datapack imports. Restart after download so Minecraft loads their dimension types and biomes.
|
||||
Custom packs that declare `datapackImports` must complete their external-datapack installation and
|
||||
registry restart first. Plain Spigot supports ordinary managed `/iris create`, but not
|
||||
registry restart first. Bukkit accepts Modrinth, direct HTTP(S), and absolute local `file:` ZIP URLs;
|
||||
ZIPs under `plugins/Iris/datapacks/imports/` are discovered for every Iris dimension. Plain Spigot
|
||||
supports ordinary managed `/iris create`, but not
|
||||
the early-bootstrap `/iris replace` path for canonical Overworld, Nether, or End slots.
|
||||
|
||||
**Mod (Fabric/Forge/NeoForge):** drop the mod jar into `mods/` and start the server. The jar is
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ import java.util.List;
|
||||
import art.arcane.iris.core.localization.IrisLanguage;
|
||||
import art.arcane.iris.core.localization.BukkitCommandMessages;
|
||||
import art.arcane.volmlib.util.localization.MessageArgument;
|
||||
@Director(name = "datapack", aliases = {"datapacks", "dp"}, description = "Download & manage external datapack imports (Modrinth)", descriptionKey = "iris.director.commanddatapack.director.download_manage_external_datapack_imports_modrinth")
|
||||
@Director(name = "datapack", aliases = {"datapacks", "dp"}, description = "Download & manage external datapack imports", descriptionKey = "iris.director.commanddatapack.director.download_manage_external_datapack_imports_modrinth")
|
||||
public class CommandDatapack implements DirectorExecutor {
|
||||
@Director(description = "Download/update every datapack listed in a pack dimension's 'datapackImports' and install it into the world so its structures register like vanilla", descriptionKey = "iris.director.commanddatapack.director.download_update_every_datapack_listed_pack_dimension_s_datapackimports_install_it_into", aliases = {"pull"})
|
||||
public void ingest(
|
||||
|
||||
@@ -334,7 +334,7 @@ public class CommandFind implements DirectorExecutor {
|
||||
}
|
||||
|
||||
Set<String> configuredImportUrls = normalizedImportUrls(
|
||||
engine.getDimension().getDatapackImports());
|
||||
DatapackIngestService.configuredImports(engine.getDimension()));
|
||||
return new StructureExclusionReport(
|
||||
List.copyOf(exclusions), Set.copyOf(registeredKeys.keySet()),
|
||||
configuredImportUrls, registeredExcluded, configuredUnregistered,
|
||||
|
||||
+1
-1
@@ -116,6 +116,6 @@ public final class DatapackStructureScopeSVC implements IrisService {
|
||||
return Set.of();
|
||||
}
|
||||
return scopeIndex.declaredSources(
|
||||
generator.getTarget().getDimension().getDatapackImports());
|
||||
DatapackIngestService.configuredImports(generator.getTarget().getDimension()));
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -205,7 +205,7 @@ public class IrisStructureLocateCommandContractTest {
|
||||
assertTrue(method.contains("non-generating structure candidate(s) and their reasons to the server console"));
|
||||
assertTrue(method.contains("no chunks were searched"));
|
||||
assertTrue(method.indexOf("DatapackIngestService.installed()") > method.indexOf("J.a(() ->"));
|
||||
assertTrue(source.contains("engine.getDimension().getDatapackImports()"));
|
||||
assertTrue(source.contains("DatapackIngestService.configuredImports(engine.getDimension())"));
|
||||
assertTrue(source.contains("snapshot.configuredImportUrls().contains(importUrl)"));
|
||||
assertTrue(source.contains("engine.getData().getStructureLoader().getPossibleKeys()"));
|
||||
assertFalse(method.contains("commandSender.sendMessage(exclusion"));
|
||||
|
||||
@@ -102,6 +102,7 @@ public final class DatapackIngestService {
|
||||
private static final String TRANSACTION_JOURNAL = "journal.json";
|
||||
private static final String TRANSACTION_JOURNAL_NEXT = "journal.next.json";
|
||||
private static final String STARTUP_VALIDATION_CACHE = "startup-validation.json";
|
||||
private static final String LOCAL_IMPORT_DIRECTORY = "imports";
|
||||
private static final int OWNERSHIP_SCHEMA = 1;
|
||||
private static final int TRANSACTION_SCHEMA = 2;
|
||||
private static final int STARTUP_VALIDATION_SCHEMA = 1;
|
||||
@@ -158,7 +159,7 @@ public final class DatapackIngestService {
|
||||
StartupValidationCache cached = readStartupValidationCache(cacheFile);
|
||||
if (startupValidationContextMatches(
|
||||
cached, mcVersion, irisVersion, autoIngest, stripOverrides, urls)) {
|
||||
String localFingerprint = startupValidationFingerprint(root, worldFolders);
|
||||
String localFingerprint = startupValidationFingerprint(root, worldFolders, urls);
|
||||
if (startupValidationCacheMatches(
|
||||
cached,
|
||||
mcVersion,
|
||||
@@ -243,7 +244,7 @@ public final class DatapackIngestService {
|
||||
autoIngest,
|
||||
stripOverrides,
|
||||
urls,
|
||||
startupValidationFingerprint(root, worldFolders));
|
||||
startupValidationFingerprint(root, worldFolders, urls));
|
||||
writeStartupValidationCache(cacheFile, cache);
|
||||
return cache;
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
@@ -301,7 +302,7 @@ public final class DatapackIngestService {
|
||||
validated.autoIngest,
|
||||
validated.stripOverrides,
|
||||
validated.urls,
|
||||
startupValidationFingerprint(root, worldFolders));
|
||||
startupValidationFingerprint(root, worldFolders, validated.urls));
|
||||
}
|
||||
|
||||
private static StartupValidationCache createStartupValidationCache(
|
||||
@@ -411,6 +412,14 @@ public final class DatapackIngestService {
|
||||
}
|
||||
|
||||
static String startupValidationFingerprint(File root, KList<File> worldFolders) throws IOException {
|
||||
return startupValidationFingerprint(root, worldFolders, List.of());
|
||||
}
|
||||
|
||||
static String startupValidationFingerprint(
|
||||
File root,
|
||||
KList<File> worldFolders,
|
||||
Iterable<String> sources
|
||||
) throws IOException {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
Path manifestPath = new File(root, "manifest.json").toPath();
|
||||
@@ -456,12 +465,61 @@ public final class DatapackIngestService {
|
||||
new File(worldFolder, entry.id));
|
||||
}
|
||||
}
|
||||
updateLocalSourceFingerprint(digest, sources);
|
||||
return hex(digest.digest());
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IOException("SHA-256 algorithm unavailable", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static void updateLocalSourceFingerprint(
|
||||
MessageDigest digest,
|
||||
Iterable<String> sources
|
||||
) throws IOException {
|
||||
if (sources == null) {
|
||||
return;
|
||||
}
|
||||
List<String> localSources = new ArrayList<>();
|
||||
for (String source : sources) {
|
||||
URI uri = parseSourceUri(source);
|
||||
if (uri != null && "file".equalsIgnoreCase(uri.getScheme())) {
|
||||
localSources.add(uri.normalize().toASCIIString());
|
||||
}
|
||||
}
|
||||
localSources.sort(String::compareTo);
|
||||
byte[] buffer = new byte[HASH_BUFFER_BYTES];
|
||||
for (String source : localSources) {
|
||||
Path path = requireLocalDatapackPath(parseSourceUri(source));
|
||||
updateFingerprintValue(digest, "local-source:" + source);
|
||||
try (InputStream input = Files.newInputStream(
|
||||
path,
|
||||
StandardOpenOption.READ,
|
||||
LinkOption.NOFOLLOW_LINKS)) {
|
||||
long bytes = 0;
|
||||
int length;
|
||||
while ((length = input.read(buffer)) > 0) {
|
||||
bytes += length;
|
||||
if (bytes > MAX_DOWNLOAD_BYTES) {
|
||||
throw new IOException("Local datapack exceeds " + MAX_DOWNLOAD_BYTES + " bytes: " + path);
|
||||
}
|
||||
digest.update(buffer, 0, length);
|
||||
}
|
||||
updateDigestLong(digest, bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static URI parseSourceUri(String source) throws IOException {
|
||||
if (source == null || source.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new URI(source.trim());
|
||||
} catch (URISyntaxException exception) {
|
||||
throw new IOException("Invalid datapack URL " + source, exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static void updateDirectoryFingerprint(
|
||||
MessageDigest digest,
|
||||
String identity,
|
||||
@@ -574,7 +632,7 @@ public final class DatapackIngestService {
|
||||
private static Report ingestLocked(VolmitSender sender, KList<String> urls, boolean restart) {
|
||||
Report report = new Report();
|
||||
if (urls == null || urls.isEmpty()) {
|
||||
message(sender, C.YELLOW + "No datapackImports configured in any loaded pack. Add Modrinth URLs to a dimension's 'datapackImports' list, then run /iris datapack ingest.");
|
||||
message(sender, C.YELLOW + "No external datapacks found. Add an HTTP(S) or file URL to a dimension's 'datapackImports', or place a ZIP in Iris/datapacks/imports, then run /iris datapack ingest.");
|
||||
return report;
|
||||
}
|
||||
|
||||
@@ -676,7 +734,7 @@ public final class DatapackIngestService {
|
||||
|
||||
if (report.changed()) {
|
||||
message(sender, C.YELLOW + "New datapack structures were installed. A server restart is required for them to register and generate.");
|
||||
message(sender, C.GRAY + "After the restart they generate natively only in Iris dimensions that declare their source URL - no import needed. To get editable Iris copies (jigsaw pools, pieces & objects written into the pack) run /iris structure import <dimension>, or set general.autoImportDatapackStructures=true to do it on every ingest. Place any registered key directly with a 'structures' placement using nativeStructures.");
|
||||
message(sender, C.GRAY + "After the restart they generate natively in Iris dimensions that declare their source URL; ZIPs from Iris/datapacks/imports are enabled for every Iris dimension. To get editable Iris copies (jigsaw pools, pieces & objects written into the pack) run /iris structure import <dimension>, or set general.autoImportDatapackStructures=true to do it on every ingest. Place any registered key directly with a 'structures' placement using nativeStructures.");
|
||||
message(sender, C.GRAY + "Datapacks replace matching vanilla structure keys by default. Set 'importedStructures.datapackOverrides' to false to keep minecraft-namespaced structure definitions untouched; deny non-minecraft datapack and mod structure families with importedStructures.disabled or complete keys with importedStructures.disabledExact.");
|
||||
if (!restart) {
|
||||
message(sender, C.GRAY + "Run with restart=true to restart now, or restart manually. After restart, run /iris structure list <dimension> to see the new keys.");
|
||||
@@ -1434,15 +1492,74 @@ public final class DatapackIngestService {
|
||||
}
|
||||
|
||||
public static KList<String> collectConfiguredImports() {
|
||||
LinkedHashSet<String> urls = new LinkedHashSet<>();
|
||||
LinkedHashSet<String> sources = new LinkedHashSet<>();
|
||||
try (Stream<IrisData> stream = ServerConfigurator.allPacks()) {
|
||||
stream.forEach(data -> collectImports(data, urls));
|
||||
stream.forEach(data -> collectImports(data, sources));
|
||||
}
|
||||
sources.addAll(localDatapackImports());
|
||||
KList<String> result = new KList<>();
|
||||
result.addAll(urls);
|
||||
result.addAll(sources);
|
||||
return result;
|
||||
}
|
||||
|
||||
public static Set<String> configuredImports(IrisDimension dimension) {
|
||||
Iterable<String> explicit = dimension == null ? List.of() : dimension.getDatapackImports();
|
||||
return mergeConfiguredImports(explicit, localDatapackImports());
|
||||
}
|
||||
|
||||
static Set<String> mergeConfiguredImports(
|
||||
Iterable<String> explicit,
|
||||
Iterable<String> discovered
|
||||
) {
|
||||
LinkedHashSet<String> sources = new LinkedHashSet<>();
|
||||
if (explicit != null) {
|
||||
addImports(explicit, sources);
|
||||
}
|
||||
if (discovered != null) {
|
||||
addImports(discovered, sources);
|
||||
}
|
||||
return sources.isEmpty() ? Set.of() : Set.copyOf(sources);
|
||||
}
|
||||
|
||||
static List<String> discoverLocalDatapackImports(File directory) throws IOException {
|
||||
Path root = directory.toPath().toAbsolutePath().normalize();
|
||||
if (Files.notExists(root, LinkOption.NOFOLLOW_LINKS)) {
|
||||
Files.createDirectories(root);
|
||||
}
|
||||
if (Files.isSymbolicLink(root)
|
||||
|| !Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Local datapack import path is not a safe directory: " + root);
|
||||
}
|
||||
try (Stream<Path> stream = Files.list(root)) {
|
||||
return stream
|
||||
.filter(path -> !Files.isSymbolicLink(path))
|
||||
.filter(path -> Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS))
|
||||
.filter(DatapackIngestService::isLocalDatapackArchive)
|
||||
.sorted(Comparator.comparing(
|
||||
(Path path) -> path.getFileName().toString(),
|
||||
String.CASE_INSENSITIVE_ORDER)
|
||||
.thenComparing((Path path) -> path.getFileName().toString()))
|
||||
.map(path -> path.toAbsolutePath().normalize().toUri().toASCIIString())
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isLocalDatapackArchive(Path path) {
|
||||
String name = path.getFileName().toString();
|
||||
return !name.startsWith(".") && name.toLowerCase(Locale.ROOT).endsWith(".zip");
|
||||
}
|
||||
|
||||
private static List<String> localDatapackImports() {
|
||||
File directory = IrisPlatforms.get().dataFolder("datapacks", LOCAL_IMPORT_DIRECTORY);
|
||||
try {
|
||||
return discoverLocalDatapackImports(directory);
|
||||
} catch (IOException exception) {
|
||||
IrisLogging.reportError("Could not discover local datapack imports under "
|
||||
+ directory.getPath() + ".", exception);
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
public static List<Entry> installed() {
|
||||
TRANSACTION_LOCK.lock();
|
||||
try {
|
||||
@@ -1883,34 +2000,39 @@ public final class DatapackIngestService {
|
||||
if (imports == null) {
|
||||
continue;
|
||||
}
|
||||
for (String url : imports) {
|
||||
if (url != null && !url.isBlank()) {
|
||||
urls.add(url.trim());
|
||||
}
|
||||
addImports(imports, urls);
|
||||
}
|
||||
}
|
||||
|
||||
private static void addImports(Iterable<String> imports, LinkedHashSet<String> sources) {
|
||||
for (String source : imports) {
|
||||
if (source != null && !source.isBlank()) {
|
||||
sources.add(normalizeConfiguredSource(source));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static String normalizeConfiguredSource(String source) {
|
||||
String normalized = source.trim();
|
||||
try {
|
||||
URI uri = new URI(normalized);
|
||||
if ("file".equalsIgnoreCase(uri.getScheme())) {
|
||||
return Path.of(uri).toAbsolutePath().normalize().toUri().toASCIIString();
|
||||
}
|
||||
} catch (IllegalArgumentException | URISyntaxException ignored) {
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static Set<String> configuredImports(IrisData data) {
|
||||
LinkedHashSet<String> urls = new LinkedHashSet<>();
|
||||
collectImports(data, urls);
|
||||
urls.addAll(localDatapackImports());
|
||||
return urls;
|
||||
}
|
||||
|
||||
private static boolean hasImports(IrisData data) {
|
||||
if (data.getDimensionLoader() == null) {
|
||||
return false;
|
||||
}
|
||||
for (IrisDimension dimension : data.getDimensionLoader().loadAll(data.getDimensionLoader().getPossibleKeys())) {
|
||||
if (dimension == null) {
|
||||
continue;
|
||||
}
|
||||
KList<String> imports = dimension.getDatapackImports();
|
||||
if (imports != null && !imports.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
return !configuredImports(data).isEmpty();
|
||||
}
|
||||
|
||||
static InstallResult install(File stagedDir, KList<File> worldFolders, Entry entry, boolean stripOverrides) throws IOException {
|
||||
@@ -3532,11 +3654,12 @@ public final class DatapackIngestService {
|
||||
}
|
||||
|
||||
static DownloadResult download(String url, File dest, String etag, String lastModified) throws IOException {
|
||||
URI current;
|
||||
try {
|
||||
current = new URI(url);
|
||||
} catch (URISyntaxException e) {
|
||||
throw new IOException("Invalid datapack URL " + url, e);
|
||||
URI current = parseSourceUri(url);
|
||||
if (current == null) {
|
||||
throw new IOException("Empty datapack URL");
|
||||
}
|
||||
if ("file".equalsIgnoreCase(current.getScheme())) {
|
||||
return copyLocalDatapack(current, dest);
|
||||
}
|
||||
for (int attempt = 0; attempt < MAX_REDIRECTS; attempt++) {
|
||||
if (!"http".equalsIgnoreCase(current.getScheme()) && !"https".equalsIgnoreCase(current.getScheme())) {
|
||||
@@ -3614,6 +3737,84 @@ public final class DatapackIngestService {
|
||||
throw new IOException("Too many redirects downloading " + url);
|
||||
}
|
||||
|
||||
private static DownloadResult copyLocalDatapack(URI source, File destination) throws IOException {
|
||||
Path sourcePath = requireLocalDatapackPath(source);
|
||||
Path destinationPath = destination.toPath().toAbsolutePath().normalize();
|
||||
if (Files.exists(destinationPath, LinkOption.NOFOLLOW_LINKS)
|
||||
&& Files.isSameFile(sourcePath, destinationPath)) {
|
||||
throw new IOException("Local datapack source and cache destination are the same file: " + sourcePath);
|
||||
}
|
||||
Path parent = destinationPath.getParent();
|
||||
if (parent == null) {
|
||||
throw new IOException("Local datapack cache destination has no parent: " + destinationPath);
|
||||
}
|
||||
ensureScratchDirectory(parent.toFile(), "datapack download cache");
|
||||
BasicFileAttributes before = Files.readAttributes(
|
||||
sourcePath,
|
||||
BasicFileAttributes.class,
|
||||
LinkOption.NOFOLLOW_LINKS);
|
||||
Path temporary = Files.createTempFile(parent, destination.getName() + "-", ".part");
|
||||
try {
|
||||
long copied = 0;
|
||||
try (InputStream input = Files.newInputStream(
|
||||
sourcePath,
|
||||
StandardOpenOption.READ,
|
||||
LinkOption.NOFOLLOW_LINKS);
|
||||
OutputStream output = Files.newOutputStream(temporary)) {
|
||||
byte[] buffer = new byte[HASH_BUFFER_BYTES];
|
||||
int length;
|
||||
while ((length = input.read(buffer)) > 0) {
|
||||
copied += length;
|
||||
if (copied > MAX_DOWNLOAD_BYTES) {
|
||||
throw new IOException("Local datapack exceeds " + MAX_DOWNLOAD_BYTES + " bytes: " + sourcePath);
|
||||
}
|
||||
output.write(buffer, 0, length);
|
||||
}
|
||||
}
|
||||
BasicFileAttributes after = Files.readAttributes(
|
||||
sourcePath,
|
||||
BasicFileAttributes.class,
|
||||
LinkOption.NOFOLLOW_LINKS);
|
||||
if (!after.isRegularFile()
|
||||
|| before.size() != after.size()
|
||||
|| !before.lastModifiedTime().equals(after.lastModifiedTime())
|
||||
|| !Objects.equals(before.fileKey(), after.fileKey())
|
||||
|| copied != after.size()) {
|
||||
throw new IOException("Local datapack changed while Iris was copying it: " + sourcePath);
|
||||
}
|
||||
move(temporary, destinationPath);
|
||||
return new DownloadResult(false, null, null);
|
||||
} finally {
|
||||
Files.deleteIfExists(temporary);
|
||||
}
|
||||
}
|
||||
|
||||
private static Path requireLocalDatapackPath(URI source) throws IOException {
|
||||
if (source == null
|
||||
|| !"file".equalsIgnoreCase(source.getScheme())
|
||||
|| source.isOpaque()
|
||||
|| source.getRawAuthority() != null
|
||||
|| source.getRawQuery() != null
|
||||
|| source.getRawFragment() != null) {
|
||||
throw new IOException("Datapack file URL must be an absolute local file URI without authority, query, or fragment: " + source);
|
||||
}
|
||||
Path path;
|
||||
try {
|
||||
path = Path.of(source).toAbsolutePath().normalize();
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new IOException("Invalid local datapack file URL: " + source, exception);
|
||||
}
|
||||
if (Files.isSymbolicLink(path)
|
||||
|| !Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Local datapack is not a regular non-symbolic-link file: " + path);
|
||||
}
|
||||
long size = Files.size(path);
|
||||
if (size > MAX_DOWNLOAD_BYTES) {
|
||||
throw new IOException("Local datapack exceeds " + MAX_DOWNLOAD_BYTES + " bytes: " + path);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
private static String headerOrFallback(HttpURLConnection connection, String name, String fallback) {
|
||||
String value = connection.getHeaderField(name);
|
||||
return value == null || value.isBlank() ? fallback : value;
|
||||
|
||||
@@ -32,6 +32,7 @@ import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
@@ -228,22 +229,33 @@ public final class ModrinthResolver {
|
||||
}
|
||||
|
||||
private static ResolvedDatapack directResolve(String url) {
|
||||
String filename = url;
|
||||
int slash = filename.lastIndexOf('/');
|
||||
if (slash >= 0) {
|
||||
filename = filename.substring(slash + 1);
|
||||
}
|
||||
int query = filename.indexOf('?');
|
||||
if (query >= 0) {
|
||||
filename = filename.substring(0, query);
|
||||
}
|
||||
if (filename.isBlank()) {
|
||||
filename = "datapack.zip";
|
||||
}
|
||||
String filename = directFilename(url);
|
||||
String identity = directIdentity(url);
|
||||
return new ResolvedDatapack(url, filename, null, "direct-" + identity, "direct", null, true);
|
||||
}
|
||||
|
||||
private static String directFilename(String url) {
|
||||
try {
|
||||
URI uri = new URI(url);
|
||||
if ("file".equalsIgnoreCase(uri.getScheme())) {
|
||||
Path fileName = Path.of(uri).getFileName();
|
||||
return fileName == null || fileName.toString().isBlank()
|
||||
? "datapack.zip"
|
||||
: fileName.toString();
|
||||
}
|
||||
String path = uri.getPath();
|
||||
if (path != null && !path.isBlank()) {
|
||||
int slash = path.lastIndexOf('/');
|
||||
String fileName = slash >= 0 ? path.substring(slash + 1) : path;
|
||||
if (!fileName.isBlank()) {
|
||||
return fileName;
|
||||
}
|
||||
}
|
||||
} catch (IllegalArgumentException | URISyntaxException ignored) {
|
||||
}
|
||||
return "datapack.zip";
|
||||
}
|
||||
|
||||
static String directIdentity(String url) {
|
||||
String normalized = url;
|
||||
try {
|
||||
|
||||
@@ -29,7 +29,7 @@ public final class BukkitCommandMessages {
|
||||
);
|
||||
public static final TextKey COMMAND_DATAPACK_ADD_MODRINTH_URLS_DIMENSION_S_DATAPACKIMPORTS_LIST_THEN_RUN_IRIS = TextKey.of(
|
||||
"iris.bukkit.commanddatapack.add_modrinth_urls_dimension_s_datapackimports_list_then_run_iris",
|
||||
C.YELLOW + "Add Modrinth URLs to a dimension's 'datapackImports' list, then run /iris datapack ingest."
|
||||
C.YELLOW + "Add source URLs to a dimension's 'datapackImports' list or use the local import folder, then run /iris datapack ingest."
|
||||
);
|
||||
public static final TextKey COMMAND_DEVELOPER_GENHASH_STARTED_CHUNKS = TextKey.of(
|
||||
"iris.bukkit.commanddeveloper.genhash_started_chunks",
|
||||
|
||||
@@ -8,7 +8,7 @@ import java.util.List;
|
||||
public final class DirectorCommandMessages {
|
||||
public static final TextKey COMMAND_DATAPACK_DIRECTOR_DOWNLOAD_MANAGE_EXTERNAL_DATAPACK_IMPORTS_MODRINTH = TextKey.of(
|
||||
"iris.director.commanddatapack.director.download_manage_external_datapack_imports_modrinth",
|
||||
"Download & manage external datapack imports (Modrinth)"
|
||||
"Download & manage external datapack imports"
|
||||
);
|
||||
public static final TextKey COMMAND_DATAPACK_DIRECTOR_DOWNLOAD_UPDATE_EVERY_DATAPACK_LISTED_PACK_DIMENSION_S_DATAPACKIMPORTS_INSTALL_IT_INTO = TextKey.of(
|
||||
"iris.director.commanddatapack.director.download_update_every_datapack_listed_pack_dimension_s_datapackimports_install_it_into",
|
||||
|
||||
@@ -276,7 +276,7 @@ public class IrisDimension extends IrisRegistrant {
|
||||
@Desc("Controls native vanilla, mod, and ingested datapack PLACED FEATURE generation (ores, trees, plants, springs, geodes) for this dimension. Disabled by default: leaving this out generates exactly the terrain Iris always has. Set 'enabled' true to run the vanilla decoration feature pass over Iris terrain. Carvers are never imported.")
|
||||
private IrisImportedFeatureControl importedFeatures = new IrisImportedFeatureControl();
|
||||
@ArrayType(type = String.class, min = 1)
|
||||
@Desc("External datapack sources owned by this dimension. Bukkit registers their resources server-wide, but Iris excludes their structure sets and definitions from generation and locate state in vanilla worlds and Iris dimensions that do not declare the same source. Editable auto-import is limited to resources supplied by the URLs declared by each Iris pack. Any registered datapack structure can be placed directly through nativeStructures without conversion. Replacing native generation requires a dimension-level structure placement with nativeSuppression set to REPLACE_SOURCE; provenance alone never disables native structures.")
|
||||
@Desc("External HTTP(S) or file URL datapack sources owned by this dimension. Bukkit registers their resources server-wide, but Iris excludes their structure sets and definitions from generation and locate state in vanilla worlds and Iris dimensions that do not declare the same source. Local ZIPs under Iris/datapacks/imports are discovered automatically and enabled for every Iris dimension. Editable auto-import is limited to resources supplied by configured or discovered sources. Any registered datapack structure can be placed directly through nativeStructures without conversion. Replacing native generation requires a dimension-level structure placement with nativeSuppression set to REPLACE_SOURCE; provenance alone never disables native structures.")
|
||||
private KList<String> datapackImports = new KList<>();
|
||||
@MinNumber(0)
|
||||
@MaxNumber(318)
|
||||
|
||||
@@ -357,6 +357,97 @@ public class DatapackIngestServiceTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void downloadCopiesLocalFileUrl() throws Exception {
|
||||
Path source = temporaryFolder.newFile("local source.zip").toPath();
|
||||
Files.writeString(source, "local-archive", StandardCharsets.UTF_8);
|
||||
File destination = new File(temporaryFolder.newFolder("local-download"), "pack.zip");
|
||||
|
||||
DatapackIngestService.DownloadResult result = DatapackIngestService.download(
|
||||
source.toUri().toASCIIString(),
|
||||
destination,
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
assertFalse(result.notModified());
|
||||
assertEquals("local-archive", Files.readString(destination.toPath(), StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedLocalDownloadPreservesExistingDestination() throws Exception {
|
||||
Path missing = temporaryFolder.getRoot().toPath().resolve("missing.zip");
|
||||
File destination = new File(temporaryFolder.newFolder("local-download-existing"), "pack.zip");
|
||||
Files.writeString(destination.toPath(), "existing", StandardCharsets.UTF_8);
|
||||
|
||||
try {
|
||||
DatapackIngestService.download(
|
||||
missing.toUri().toASCIIString(),
|
||||
destination,
|
||||
null,
|
||||
null
|
||||
);
|
||||
fail("Expected missing local source rejection");
|
||||
} catch (IOException expected) {
|
||||
assertTrue(expected.getMessage().contains("regular non-symbolic-link file"));
|
||||
}
|
||||
|
||||
assertEquals("existing", Files.readString(destination.toPath(), StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void localDropFolderDiscoversOnlyTopLevelZipFilesInStableOrder() throws Exception {
|
||||
File imports = temporaryFolder.newFolder("local-imports");
|
||||
Path first = new File(imports, "Alpha Pack.ZIP").toPath();
|
||||
Path second = new File(imports, "bravo.zip").toPath();
|
||||
Files.writeString(first, "alpha", StandardCharsets.UTF_8);
|
||||
Files.writeString(second, "bravo", StandardCharsets.UTF_8);
|
||||
Files.writeString(new File(imports, "ignored.txt").toPath(), "ignored", StandardCharsets.UTF_8);
|
||||
Path nested = new File(imports, "nested/hidden.zip").toPath();
|
||||
Files.createDirectories(nested.getParent());
|
||||
Files.writeString(nested, "nested", StandardCharsets.UTF_8);
|
||||
|
||||
List<String> discovered = DatapackIngestService.discoverLocalDatapackImports(imports);
|
||||
|
||||
assertEquals(List.of(
|
||||
first.toAbsolutePath().normalize().toUri().toASCIIString(),
|
||||
second.toAbsolutePath().normalize().toUri().toASCIIString()
|
||||
), discovered);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void configuredFileSourceNormalizesToTheDiscoveredArchiveUri() throws Exception {
|
||||
Path source = temporaryFolder.newFile("normalized-local.zip").toPath();
|
||||
String discovered = source.toAbsolutePath().normalize().toUri().toASCIIString();
|
||||
String alternate = discovered.replace("file:///", "file:/");
|
||||
|
||||
assertEquals(discovered, DatapackIngestService.normalizeConfiguredSource(alternate));
|
||||
assertEquals(
|
||||
Set.of("https://example.test/dimension.zip", discovered),
|
||||
DatapackIngestService.mergeConfiguredImports(
|
||||
List.of("https://example.test/dimension.zip", alternate),
|
||||
List.of(discovered)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void startupValidationFingerprintChangesWithLocalSourceBytes() throws Exception {
|
||||
File root = temporaryFolder.newFolder("startup-local-source-fingerprint");
|
||||
Path source = temporaryFolder.newFile("startup-local.zip").toPath();
|
||||
KList<File> worldFolders = new KList<>();
|
||||
String sourceUrl = source.toUri().toASCIIString();
|
||||
Files.writeString(source, "alpha", StandardCharsets.UTF_8);
|
||||
String before = DatapackIngestService.startupValidationFingerprint(
|
||||
root, worldFolders, List.of(sourceUrl));
|
||||
|
||||
FileTime originalTime = Files.getLastModifiedTime(source);
|
||||
Files.writeString(source, "bravo", StandardCharsets.UTF_8);
|
||||
Files.setLastModifiedTime(source, originalTime);
|
||||
String after = DatapackIngestService.startupValidationFingerprint(
|
||||
root, worldFolders, List.of(sourceUrl));
|
||||
|
||||
assertFalse(before.equals(after));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void removalRequiresMatchingIrisOwnership() throws Exception {
|
||||
File unmanaged = datapackDirectory("unmanaged");
|
||||
|
||||
@@ -8,6 +8,9 @@ import org.junit.Test;
|
||||
import java.io.IOException;
|
||||
import java.io.Reader;
|
||||
import java.io.StringReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Arrays;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
@@ -104,6 +107,25 @@ public class ModrinthResolverTest {
|
||||
assertFalse(resolved.getVersionId().isBlank());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void fileUrlResolvesAsDirectArchiveWithDecodedFilename() throws Exception {
|
||||
Path directory = Files.createTempDirectory("iris-local-resolver");
|
||||
Path archive = directory.resolve("local pack.zip");
|
||||
try {
|
||||
Files.writeString(archive, "archive", StandardCharsets.UTF_8);
|
||||
|
||||
ModrinthResolver.ResolvedDatapack resolved = ModrinthResolver.resolve(
|
||||
archive.toUri().toASCIIString(), "26.2");
|
||||
|
||||
assertTrue(resolved.isDirect());
|
||||
assertEquals("local pack.zip", resolved.getFileName());
|
||||
assertFalse(resolved.getVersionId().isBlank());
|
||||
} finally {
|
||||
Files.deleteIfExists(archive);
|
||||
Files.deleteIfExists(directory);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void boundedApiReaderAcceptsTheExactCharacterLimit() throws Exception {
|
||||
String response = "1234\n5678";
|
||||
|
||||
Reference in New Issue
Block a user