diff --git a/core/src/main/java/art/arcane/iris/pack/ingest/BiomeRole.java b/core/src/main/java/art/arcane/iris/pack/ingest/BiomeRole.java
new file mode 100644
index 000000000..c114254f3
--- /dev/null
+++ b/core/src/main/java/art/arcane/iris/pack/ingest/BiomeRole.java
@@ -0,0 +1,42 @@
+/*
+ * Iris is a World Generator for Minecraft Bukkit Servers
+ * Copyright (c) 2022 Arcane Arts (Volmit Software)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package art.arcane.iris.pack.ingest;
+
+enum BiomeRole {
+ LAND("landBiomes", "land"),
+ SEA("seaBiomes", "sea"),
+ SHORE("shoreBiomes", "shore"),
+ CAVE("caveBiomes", "cave");
+
+ private final String poolField;
+ private final String label;
+
+ BiomeRole(String poolField, String label) {
+ this.poolField = poolField;
+ this.label = label;
+ }
+
+ String poolField() {
+ return poolField;
+ }
+
+ String label() {
+ return label;
+ }
+}
diff --git a/core/src/main/java/art/arcane/iris/pack/ingest/ImportedFeatureWriter.java b/core/src/main/java/art/arcane/iris/pack/ingest/ImportedFeatureWriter.java
new file mode 100644
index 000000000..dcf6f9a5d
--- /dev/null
+++ b/core/src/main/java/art/arcane/iris/pack/ingest/ImportedFeatureWriter.java
@@ -0,0 +1,106 @@
+/*
+ * Iris is a World Generator for Minecraft Bukkit Servers
+ * Copyright (c) 2022 Arcane Arts (Volmit Software)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package art.arcane.iris.pack.ingest;
+
+import art.arcane.iris.generation.decoration.IrisDecorationStep;
+import art.arcane.iris.structure.nativegen.IrisImportedFeatureControl;
+import art.arcane.iris.structure.nativegen.NativeFeatureGenerationStatus;
+import art.arcane.volmlib.util.collection.KList;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+final class ImportedFeatureWriter {
+ static final String VANILLA_PREFIX = "minecraft:";
+
+ private ImportedFeatureWriter() {
+ }
+
+ static IrisImportedFeatureControl write(JsonObject dimension, Set nativeKeys) {
+ JsonElement existing = dimension.get("importedFeatures");
+ JsonObject imported = existing != null && existing.isJsonObject() ? existing.getAsJsonObject() : new JsonObject();
+ Set disabled = new LinkedHashSet<>(IngestJson.strings(imported.get("disabled")));
+ disabled.add(VANILLA_PREFIX);
+ disabled.addAll(nativeKeys);
+ imported.addProperty("enabled", true);
+ imported.add("disabled", IngestJson.stringArray(disabled));
+ dimension.add("importedFeatures", imported);
+ IrisImportedFeatureControl control = new IrisImportedFeatureControl();
+ control.setEnabled(true);
+ control.setDisabled(new KList<>(disabled));
+ control.setSteps(steps(imported.get("steps")));
+ control.setDisabledSteps(steps(imported.get("disabledSteps")));
+ return control;
+ }
+
+ static List applySuppression(List items, Map plans,
+ IrisImportedFeatureControl control) {
+ List result = new ArrayList<>(items.size());
+ for (ModIngestItem item : items) {
+ ModFeatureIngester.FeaturePlan plan = plans.get(item.sourceKey());
+ if (item.tier() != ModIngestTier.PASSTHROUGH || plan == null) {
+ result.add(item);
+ continue;
+ }
+ String suppression = suppression(item.sourceKey(), plan.steps(), control);
+ result.add(suppression == null
+ ? item
+ : new ModIngestItem(item.namespace(), item.sourceKey(), item.kind(), ModIngestTier.SKIPPED, "", suppression));
+ }
+ return result;
+ }
+
+ private static String suppression(String key, Set steps, IrisImportedFeatureControl control) {
+ NativeFeatureGenerationStatus status = null;
+ for (int step : steps) {
+ status = control.resolve(key, IrisDecorationStep.byOrdinal(step));
+ if (status == NativeFeatureGenerationStatus.GENERATE_NATIVE) {
+ return null;
+ }
+ }
+ if (status == NativeFeatureGenerationStatus.STEP_DISABLED) {
+ return "its decoration step is disabled by importedFeatures";
+ }
+ for (String entry : control.getDisabled()) {
+ IrisImportedFeatureControl single = new IrisImportedFeatureControl();
+ single.setEnabled(true);
+ single.setDisabled(new KList<>(entry));
+ if (!single.shouldGenerate(key)) {
+ return "suppressed by importedFeatures.disabled entry " + entry;
+ }
+ }
+ return "suppressed by importedFeatures";
+ }
+
+ private static KList steps(JsonElement value) {
+ KList steps = new KList<>();
+ for (String name : IngestJson.strings(value)) {
+ IrisDecorationStep step = IrisDecorationStep.byKey(name);
+ if (step != null) {
+ steps.add(step);
+ }
+ }
+ return steps;
+ }
+}
diff --git a/core/src/main/java/art/arcane/iris/pack/ingest/IngestClimate.java b/core/src/main/java/art/arcane/iris/pack/ingest/IngestClimate.java
new file mode 100644
index 000000000..d2ae02bf6
--- /dev/null
+++ b/core/src/main/java/art/arcane/iris/pack/ingest/IngestClimate.java
@@ -0,0 +1,51 @@
+/*
+ * Iris is a World Generator for Minecraft Bukkit Servers
+ * Copyright (c) 2022 Arcane Arts (Volmit Software)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package art.arcane.iris.pack.ingest;
+
+import art.arcane.iris.pack.ingest.catalog.CatalogBiome;
+
+import java.util.List;
+
+record IngestClimate(double temperature, double downfall) {
+ static IngestClimate of(CatalogBiome biome) {
+ return new IngestClimate(biome.temperature(), biome.downfall());
+ }
+
+ static IngestClimate average(List climates) {
+ if (climates.isEmpty()) {
+ return null;
+ }
+ double temperature = 0D;
+ double downfall = 0D;
+ for (IngestClimate climate : climates) {
+ temperature += climate.temperature();
+ downfall += climate.downfall();
+ }
+ return new IngestClimate(temperature / climates.size(), downfall / climates.size());
+ }
+
+ double distance(IngestClimate other) {
+ if (other == null) {
+ return Double.POSITIVE_INFINITY;
+ }
+ double temperatureDelta = temperature - other.temperature();
+ double downfallDelta = downfall - other.downfall();
+ return temperatureDelta * temperatureDelta + downfallDelta * downfallDelta;
+ }
+}
diff --git a/core/src/main/java/art/arcane/iris/pack/ingest/IngestConversionException.java b/core/src/main/java/art/arcane/iris/pack/ingest/IngestConversionException.java
new file mode 100644
index 000000000..6bfd54e41
--- /dev/null
+++ b/core/src/main/java/art/arcane/iris/pack/ingest/IngestConversionException.java
@@ -0,0 +1,25 @@
+/*
+ * Iris is a World Generator for Minecraft Bukkit Servers
+ * Copyright (c) 2022 Arcane Arts (Volmit Software)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package art.arcane.iris.pack.ingest;
+
+final class IngestConversionException extends Exception {
+ IngestConversionException(String reason) {
+ super(reason);
+ }
+}
diff --git a/core/src/main/java/art/arcane/iris/pack/ingest/IngestJson.java b/core/src/main/java/art/arcane/iris/pack/ingest/IngestJson.java
new file mode 100644
index 000000000..db2566f3f
--- /dev/null
+++ b/core/src/main/java/art/arcane/iris/pack/ingest/IngestJson.java
@@ -0,0 +1,88 @@
+/*
+ * Iris is a World Generator for Minecraft Bukkit Servers
+ * Copyright (c) 2022 Arcane Arts (Volmit Software)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package art.arcane.iris.pack.ingest;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParseException;
+import com.google.gson.JsonParser;
+
+import java.io.IOException;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+final class IngestJson {
+ private static final Gson GSON = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create();
+ private static final int DECIMALS = 6;
+ private static final double SMALLEST = 0.000001D;
+
+ private IngestJson() {
+ }
+
+ static double round(double value) {
+ double rounded = BigDecimal.valueOf(value).setScale(DECIMALS, RoundingMode.HALF_UP).doubleValue();
+ return rounded == 0D && value > 0D ? SMALLEST : rounded;
+ }
+
+ static JsonArray stringArray(Collection values) {
+ JsonArray array = new JsonArray(values.size());
+ for (String value : values) {
+ array.add(value);
+ }
+ return array;
+ }
+
+ static List strings(JsonElement element) {
+ List values = new ArrayList<>();
+ if (element == null || !element.isJsonArray()) {
+ return values;
+ }
+ for (JsonElement value : element.getAsJsonArray()) {
+ if (value.isJsonPrimitive() && value.getAsJsonPrimitive().isString()) {
+ values.add(value.getAsString());
+ }
+ }
+ return values;
+ }
+
+ static JsonObject read(Path file) throws IOException {
+ try {
+ JsonElement element = JsonParser.parseString(Files.readString(file, StandardCharsets.UTF_8));
+ if (!element.isJsonObject()) {
+ throw new IOException("Pack file is not a JSON object: " + file);
+ }
+ return element.getAsJsonObject();
+ } catch (JsonParseException e) {
+ throw new IOException("Pack file is not valid JSON: " + file, e);
+ }
+ }
+
+ static String format(JsonElement element) {
+ return GSON.toJson(element) + "\n";
+ }
+}
diff --git a/core/src/main/java/art/arcane/iris/pack/ingest/IngestManifest.java b/core/src/main/java/art/arcane/iris/pack/ingest/IngestManifest.java
new file mode 100644
index 000000000..2e024391a
--- /dev/null
+++ b/core/src/main/java/art/arcane/iris/pack/ingest/IngestManifest.java
@@ -0,0 +1,88 @@
+/*
+ * Iris is a World Generator for Minecraft Bukkit Servers
+ * Copyright (c) 2022 Arcane Arts (Volmit Software)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package art.arcane.iris.pack.ingest;
+
+import art.arcane.iris.pack.ingest.catalog.WorldgenCatalog;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.EnumMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+final class IngestManifest {
+ static final String PATH = "ingest/manifest.json";
+ static final int FORMAT = 1;
+
+ private IngestManifest() {
+ }
+
+ static void write(IngestPack pack, WorldgenCatalog catalog, String sourceDimension, String dimension,
+ Set namespaces, List items) throws IOException {
+ JsonObject manifest = new JsonObject();
+ manifest.addProperty("format", FORMAT);
+ manifest.addProperty("catalogFingerprint", catalog.contentFingerprint());
+ manifest.addProperty("catalogCapturedAt", catalog.capturedAtEpochMillis());
+ manifest.addProperty("minecraftVersion", catalog.minecraftVersion());
+ manifest.addProperty("platform", catalog.platform());
+ manifest.addProperty("sourceDimension", sourceDimension);
+ manifest.addProperty("dimension", dimension);
+ manifest.add("namespaces", IngestJson.stringArray(namespaces));
+ manifest.add("generatedFiles", IngestJson.stringArray(pack.generatedFiles()));
+ manifest.add("modifiedFiles", IngestJson.stringArray(pack.modifiedFiles()));
+ JsonArray itemArray = new JsonArray(items.size());
+ for (ModIngestItem item : items) {
+ JsonObject entry = new JsonObject();
+ entry.addProperty("namespace", item.namespace());
+ entry.addProperty("sourceKey", item.sourceKey());
+ entry.addProperty("kind", item.kind());
+ entry.addProperty("tier", item.tier().name());
+ entry.addProperty("target", item.target());
+ entry.addProperty("reason", item.reason());
+ itemArray.add(entry);
+ }
+ manifest.add("items", itemArray);
+ pack.write(PATH, manifest);
+ }
+
+ static List summary(String dimension, IngestPack pack, Set namespaces, List items) {
+ List lines = new ArrayList<>(namespaces.size() + 1);
+ lines.add("pack " + dimension + ": " + pack.generatedFiles().size() + " generated files, "
+ + pack.modifiedFiles().size() + " modified files");
+ for (String namespace : namespaces) {
+ Map counts = new EnumMap<>(ModIngestTier.class);
+ for (ModIngestTier tier : ModIngestTier.values()) {
+ counts.put(tier, 0);
+ }
+ for (ModIngestItem item : items) {
+ if (item.namespace().equals(namespace)) {
+ counts.merge(item.tier(), 1, Integer::sum);
+ }
+ }
+ lines.add(namespace + ": " + counts.get(ModIngestTier.NATIVE) + " native, "
+ + counts.get(ModIngestTier.ADOPTED) + " adopted, "
+ + counts.get(ModIngestTier.PASSTHROUGH) + " passthrough, "
+ + counts.get(ModIngestTier.SKIPPED) + " skipped");
+ }
+ return lines;
+ }
+}
diff --git a/core/src/main/java/art/arcane/iris/pack/ingest/IngestPack.java b/core/src/main/java/art/arcane/iris/pack/ingest/IngestPack.java
new file mode 100644
index 000000000..12d9d5d6c
--- /dev/null
+++ b/core/src/main/java/art/arcane/iris/pack/ingest/IngestPack.java
@@ -0,0 +1,155 @@
+/*
+ * Iris is a World Generator for Minecraft Bukkit Servers
+ * Copyright (c) 2022 Arcane Arts (Volmit Software)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package art.arcane.iris.pack.ingest;
+
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.TreeSet;
+
+final class IngestPack {
+ private static final String SNIPPET_PREFIX = "snippet/";
+
+ private final Path root;
+ private final Map loaded = new HashMap<>();
+ private final Map pending = new TreeMap<>();
+ private final Set generated = new TreeSet<>();
+
+ IngestPack(Path root) {
+ this.root = root;
+ }
+
+ Path root() {
+ return root;
+ }
+
+ static String biomePath(String biomeKey) {
+ return "biomes/" + biomeKey + ".json";
+ }
+
+ static String regionPath(String regionKey) {
+ return "regions/" + regionKey + ".json";
+ }
+
+ static String dimensionPath(String dimensionKey) {
+ return "dimensions/" + dimensionKey + ".json";
+ }
+
+ Optional find(String relative) throws IOException {
+ JsonObject cached = loaded.get(relative);
+ if (cached != null) {
+ return Optional.of(cached);
+ }
+ Path file = resolve(relative);
+ if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) {
+ return Optional.empty();
+ }
+ JsonObject json = IngestJson.read(file);
+ loaded.put(relative, json);
+ return Optional.of(json);
+ }
+
+ JsonObject require(String relative) throws IOException {
+ Optional json = find(relative);
+ if (json.isEmpty()) {
+ throw new IOException("Pack file is missing: " + resolve(relative));
+ }
+ return json.get();
+ }
+
+ JsonObject edit(String relative) throws IOException {
+ JsonObject json = require(relative);
+ pending.put(relative, json);
+ return json;
+ }
+
+ void create(String relative, JsonObject json) throws IOException {
+ if (loaded.containsKey(relative) || Files.exists(resolve(relative), LinkOption.NOFOLLOW_LINKS)) {
+ throw new IOException("Ingest would overwrite an existing pack file: " + resolve(relative));
+ }
+ loaded.put(relative, json);
+ pending.put(relative, json);
+ generated.add(relative);
+ }
+
+ JsonObject snippet(JsonElement value, String snippetType) throws IOException {
+ if (value == null || value.isJsonNull()) {
+ return null;
+ }
+ if (value.isJsonObject()) {
+ return value.getAsJsonObject();
+ }
+ if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) {
+ return null;
+ }
+ String reference = value.getAsString();
+ if (!reference.startsWith(SNIPPET_PREFIX)) {
+ return null;
+ }
+ String base = SNIPPET_PREFIX + snippetType + "/";
+ String key = reference.startsWith(base) ? reference : base + reference.substring(SNIPPET_PREFIX.length());
+ return find(key + ".json").map(JsonObject::deepCopy).orElse(null);
+ }
+
+ List generatedFiles() {
+ return new ArrayList<>(generated);
+ }
+
+ List modifiedFiles() {
+ List modified = new ArrayList<>();
+ for (String relative : pending.keySet()) {
+ if (!generated.contains(relative)) {
+ modified.add(relative);
+ }
+ }
+ return modified;
+ }
+
+ void flush() throws IOException {
+ for (Map.Entry entry : pending.entrySet()) {
+ write(entry.getKey(), entry.getValue());
+ }
+ }
+
+ void write(String relative, JsonElement json) throws IOException {
+ Path file = resolve(relative);
+ Files.createDirectories(file.getParent());
+ Files.writeString(file, IngestJson.format(json), StandardCharsets.UTF_8);
+ }
+
+ private Path resolve(String relative) throws IOException {
+ Path file = root.resolve(relative).normalize();
+ if (!file.startsWith(root)) {
+ throw new IOException("Pack path escapes the pack root: " + relative);
+ }
+ return file;
+ }
+}
diff --git a/core/src/main/java/art/arcane/iris/pack/ingest/IngestPackModel.java b/core/src/main/java/art/arcane/iris/pack/ingest/IngestPackModel.java
new file mode 100644
index 000000000..a9fe360c8
--- /dev/null
+++ b/core/src/main/java/art/arcane/iris/pack/ingest/IngestPackModel.java
@@ -0,0 +1,202 @@
+/*
+ * Iris is a World Generator for Minecraft Bukkit Servers
+ * Copyright (c) 2022 Arcane Arts (Volmit Software)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package art.arcane.iris.pack.ingest;
+
+import art.arcane.iris.pack.ingest.catalog.CatalogBiome;
+import art.arcane.iris.pack.ingest.catalog.CatalogDimensionType;
+import art.arcane.iris.pack.ingest.catalog.WorldgenCatalogIndex;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+
+import java.io.IOException;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.EnumMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+
+final class IngestPackModel {
+ private static final int DEFAULT_MIN_Y = -64;
+ private static final int DEFAULT_MAX_Y = 320;
+ private static final String DEFAULT_DERIVATIVE = "minecraft:the_void";
+
+ private final IngestPack pack;
+ private final WorldgenCatalogIndex index;
+ private final String dimensionPath;
+ private final String environment;
+ private final int packMinY;
+ private final int packMaxY;
+ private final List regions;
+ private final Map reachableBiomes;
+
+ private IngestPackModel(IngestPack pack, WorldgenCatalogIndex index, String dimensionPath, String environment,
+ int[] heightRange, List regions, Map reachableBiomes) {
+ this.pack = pack;
+ this.index = index;
+ this.dimensionPath = dimensionPath;
+ this.environment = environment;
+ this.packMinY = heightRange[0];
+ this.packMaxY = heightRange[1];
+ this.regions = regions;
+ this.reachableBiomes = reachableBiomes;
+ }
+
+ static IngestPackModel load(IngestPack pack, String dimensionKey, WorldgenCatalogIndex index) throws IOException {
+ String dimensionPath = IngestPack.dimensionPath(dimensionKey);
+ JsonObject dimension = pack.require(dimensionPath);
+ JsonElement environmentValue = dimension.get("environment");
+ String environment = environmentValue != null && environmentValue.isJsonPrimitive()
+ ? environmentValue.getAsString().trim().toUpperCase(Locale.ROOT)
+ : "NORMAL";
+ Map reachable = new LinkedHashMap<>();
+ List regionKeys = IngestJson.strings(dimension.get("regions"));
+ List regionJson = new ArrayList<>(regionKeys.size());
+ List loadedRegionKeys = new ArrayList<>(regionKeys.size());
+ for (String regionKey : regionKeys) {
+ Optional region = pack.find(IngestPack.regionPath(regionKey));
+ if (region.isPresent()) {
+ regionJson.add(region.get());
+ loadedRegionKeys.add(regionKey);
+ }
+ }
+ collectReachable(pack, regionJson, reachable);
+ List profiles = new ArrayList<>(regionJson.size());
+ for (int position = 0; position < regionJson.size(); position++) {
+ profiles.add(profile(loadedRegionKeys.get(position), regionJson.get(position), reachable, index));
+ }
+ return new IngestPackModel(pack, index, dimensionPath, environment, heightRange(pack, dimension),
+ Collections.unmodifiableList(profiles), Collections.unmodifiableMap(reachable));
+ }
+
+ IngestPack pack() {
+ return pack;
+ }
+
+ String dimensionPath() {
+ return dimensionPath;
+ }
+
+ String environment() {
+ return environment;
+ }
+
+ int packMinY() {
+ return packMinY;
+ }
+
+ List regions() {
+ return regions;
+ }
+
+ Map reachableBiomes() {
+ return reachableBiomes;
+ }
+
+ IngestClimate biomeClimate(String biomeKey) {
+ String derivative = reachableBiomes.get(biomeKey);
+ return derivative == null ? null : index.biome(derivative).map(IngestClimate::of).orElse(null);
+ }
+
+ VanillaHeights.VerticalBounds verticalBounds() {
+ String typeKey = switch (environment) {
+ case "NORMAL" -> "minecraft:overworld";
+ case "NETHER" -> "minecraft:the_nether";
+ case "THE_END" -> "minecraft:the_end";
+ default -> "";
+ };
+ Optional type = index.dimensionType(typeKey);
+ if (type.isPresent() && type.get().height() > 0) {
+ return new VanillaHeights.VerticalBounds(type.get().minY(), type.get().height());
+ }
+ return new VanillaHeights.VerticalBounds(packMinY, Math.max(1, packMaxY - packMinY));
+ }
+
+ private static RegionProfile profile(String key, JsonObject region, Map reachable, WorldgenCatalogIndex index) {
+ Map> pools = new EnumMap<>(BiomeRole.class);
+ for (BiomeRole role : BiomeRole.values()) {
+ List pool = new ArrayList<>();
+ for (String biome : IngestJson.strings(region.get(role.poolField()))) {
+ if (reachable.containsKey(biome) && !pool.contains(biome)) {
+ pool.add(biome);
+ }
+ }
+ pools.put(role, Collections.unmodifiableList(pool));
+ }
+ List landClimates = new ArrayList<>();
+ for (String biome : pools.get(BiomeRole.LAND)) {
+ Optional catalogBiome = index.biome(reachable.get(biome));
+ catalogBiome.ifPresent(value -> landClimates.add(IngestClimate.of(value)));
+ }
+ return new RegionProfile(key, IngestClimate.average(landClimates), pools);
+ }
+
+ private static void collectReachable(IngestPack pack, List regions, Map reachable) throws IOException {
+ ArrayDeque queue = new ArrayDeque<>();
+ for (JsonObject region : regions) {
+ for (BiomeRole role : BiomeRole.values()) {
+ queue.addAll(IngestJson.strings(region.get(role.poolField())));
+ }
+ }
+ while (!queue.isEmpty()) {
+ String biomeKey = queue.removeFirst();
+ if (reachable.containsKey(biomeKey)) {
+ continue;
+ }
+ Optional biome = pack.find(IngestPack.biomePath(biomeKey));
+ if (biome.isEmpty()) {
+ continue;
+ }
+ JsonElement derivative = biome.get().get("derivative");
+ String derivativeKey = derivative != null && derivative.isJsonPrimitive()
+ ? VanillaJson.namespaced(derivative.getAsString())
+ : DEFAULT_DERIVATIVE;
+ reachable.put(biomeKey, derivativeKey);
+ queue.addAll(IngestJson.strings(biome.get().get("children")));
+ }
+ }
+
+ private static int[] heightRange(IngestPack pack, JsonObject dimension) throws IOException {
+ JsonObject range = pack.snippet(dimension.get("dimensionHeight"), "range");
+ if (range == null) {
+ return new int[]{DEFAULT_MIN_Y, DEFAULT_MAX_Y};
+ }
+ return new int[]{(int) number(range.get("min"), DEFAULT_MIN_Y), (int) number(range.get("max"), DEFAULT_MAX_Y)};
+ }
+
+ private static double number(JsonElement value, double fallback) {
+ if (value == null || !value.isJsonPrimitive()) {
+ return fallback;
+ }
+ try {
+ return Double.parseDouble(value.getAsString().trim());
+ } catch (NumberFormatException e) {
+ return fallback;
+ }
+ }
+
+ record RegionProfile(String key, IngestClimate climate, Map> pools) {
+ List pool(BiomeRole role) {
+ return pools.get(role);
+ }
+ }
+}
diff --git a/core/src/main/java/art/arcane/iris/pack/ingest/ModBiomeAdopter.java b/core/src/main/java/art/arcane/iris/pack/ingest/ModBiomeAdopter.java
new file mode 100644
index 000000000..1ae4a5b0b
--- /dev/null
+++ b/core/src/main/java/art/arcane/iris/pack/ingest/ModBiomeAdopter.java
@@ -0,0 +1,173 @@
+/*
+ * Iris is a World Generator for Minecraft Bukkit Servers
+ * Copyright (c) 2022 Arcane Arts (Volmit Software)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package art.arcane.iris.pack.ingest;
+
+import art.arcane.iris.generation.decoration.IrisDecorationStep;
+import art.arcane.iris.pack.ingest.catalog.CatalogBiome;
+import art.arcane.iris.pack.ingest.catalog.WorldgenCatalogIndex;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+
+import java.io.IOException;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+
+final class ModBiomeAdopter {
+ static final String INGEST_BIOME_FOLDER = "ingest/";
+ private static final List OVERRIDE_FIELDS = List.of("customDerivitives", "vanillaDerivative", "biomeScatter", "biomeSkyScatter", "children");
+ private static final int VEGETAL_STEP = IrisDecorationStep.VEGETAL_DECORATION.ordinal();
+
+ private final IngestPackModel model;
+ private final ModBiomeClassifier classifier;
+ private final Set decoratorFeatures;
+
+ ModBiomeAdopter(IngestPackModel model, WorldgenCatalogIndex index, Set decoratorFeatures) {
+ this.model = model;
+ this.classifier = new ModBiomeClassifier(model.environment(), index.catalog());
+ this.decoratorFeatures = decoratorFeatures;
+ }
+
+ Map adopt(List modBiomes, List items) throws IOException {
+ Map adopted = new LinkedHashMap<>();
+ for (CatalogBiome biome : modBiomes) {
+ String namespace = WorldgenCatalogIndex.namespaceOf(biome.key());
+ ModBiomeClassifier.Classification classification = classifier.classify(biome);
+ if (classification.skipped()) {
+ items.add(new ModIngestItem(namespace, biome.key(), "biome", ModIngestTier.SKIPPED, "", classification.skipReason()));
+ continue;
+ }
+ BiomeRole role = classification.role();
+ IngestClimate climate = IngestClimate.of(biome);
+ IngestPackModel.RegionProfile region = nearestRegion(role, climate);
+ if (region == null) {
+ items.add(new ModIngestItem(namespace, biome.key(), "biome", ModIngestTier.SKIPPED, "",
+ "no region in this dimension has a " + role.label() + " biome pool"));
+ continue;
+ }
+ String template = nearestTemplate(region, role, climate);
+ String irisKey = irisKey(biome.key());
+ JsonObject json = model.pack().require(IngestPack.biomePath(template)).deepCopy();
+ transform(json, biome, ownsVegetation(biome, namespace));
+ model.pack().create(IngestPack.biomePath(irisKey), json);
+ appendToPool(region.key(), role, irisKey);
+ adopted.put(biome.key(), new AdoptedBiome(biome.key(), irisKey, role));
+ items.add(new ModIngestItem(namespace, biome.key(), "biome", ModIngestTier.ADOPTED, irisKey,
+ role.label() + " biome in region " + region.key() + " from template " + template));
+ }
+ return adopted;
+ }
+
+ static String irisKey(String modBiomeKey) {
+ int colon = modBiomeKey.indexOf(':');
+ String namespace = colon < 0 ? "minecraft" : modBiomeKey.substring(0, colon);
+ return INGEST_BIOME_FOLDER + namespace + "/" + modBiomeKey.substring(colon + 1);
+ }
+
+ private IngestPackModel.RegionProfile nearestRegion(BiomeRole role, IngestClimate climate) {
+ IngestPackModel.RegionProfile best = null;
+ double bestDistance = Double.POSITIVE_INFINITY;
+ for (IngestPackModel.RegionProfile region : model.regions()) {
+ if (region.pool(role).isEmpty()) {
+ continue;
+ }
+ double distance = climate.distance(region.climate());
+ if (best == null || distance < bestDistance) {
+ best = region;
+ bestDistance = distance;
+ }
+ }
+ return best;
+ }
+
+ private String nearestTemplate(IngestPackModel.RegionProfile region, BiomeRole role, IngestClimate climate) {
+ String best = null;
+ double bestDistance = Double.POSITIVE_INFINITY;
+ for (String candidate : region.pool(role)) {
+ IngestClimate candidateClimate = model.biomeClimate(candidate);
+ double distance = climate.distance(candidateClimate == null ? region.climate() : candidateClimate);
+ if (best == null || distance < bestDistance) {
+ best = candidate;
+ bestDistance = distance;
+ }
+ }
+ return best;
+ }
+
+ private void transform(JsonObject json, CatalogBiome biome, boolean ownsVegetation) throws IOException {
+ json.addProperty("name", readableName(biome.key()));
+ json.addProperty("derivative", biome.key());
+ for (String field : OVERRIDE_FIELDS) {
+ json.remove(field);
+ }
+ if (!ownsVegetation) {
+ return;
+ }
+ json.remove("objects");
+ JsonObject procedural = model.pack().snippet(json.get("proceduralObjects"), "procedural-objects");
+ if (procedural == null) {
+ return;
+ }
+ JsonObject withoutTrees = procedural.deepCopy();
+ withoutTrees.remove("trees");
+ json.add("proceduralObjects", withoutTrees);
+ }
+
+ private boolean ownsVegetation(CatalogBiome biome, String namespace) {
+ if (biome.featureSteps().size() <= VEGETAL_STEP) {
+ return false;
+ }
+ for (String feature : biome.featureSteps().get(VEGETAL_STEP)) {
+ if (WorldgenCatalogIndex.namespaceOf(feature).equals(namespace) && !decoratorFeatures.contains(feature)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private void appendToPool(String regionKey, BiomeRole role, String irisKey) throws IOException {
+ JsonObject region = model.pack().edit(IngestPack.regionPath(regionKey));
+ JsonElement pool = region.get(role.poolField());
+ JsonArray array = pool != null && pool.isJsonArray() ? pool.getAsJsonArray() : new JsonArray();
+ array.add(irisKey);
+ region.add(role.poolField(), array);
+ }
+
+ private static String readableName(String key) {
+ String path = key.substring(key.indexOf(':') + 1);
+ String leaf = path.substring(path.lastIndexOf('/') + 1);
+ StringBuilder name = new StringBuilder(leaf.length());
+ for (String word : leaf.split("[_\\-]+")) {
+ if (word.isEmpty()) {
+ continue;
+ }
+ if (!name.isEmpty()) {
+ name.append(' ');
+ }
+ name.append(word.substring(0, 1).toUpperCase(Locale.ROOT)).append(word.substring(1));
+ }
+ return name.isEmpty() ? key : name.toString();
+ }
+
+ record AdoptedBiome(String modKey, String irisKey, BiomeRole role) {
+ }
+}
diff --git a/core/src/main/java/art/arcane/iris/pack/ingest/ModBiomeClassifier.java b/core/src/main/java/art/arcane/iris/pack/ingest/ModBiomeClassifier.java
new file mode 100644
index 000000000..43293928d
--- /dev/null
+++ b/core/src/main/java/art/arcane/iris/pack/ingest/ModBiomeClassifier.java
@@ -0,0 +1,112 @@
+/*
+ * Iris is a World Generator for Minecraft Bukkit Servers
+ * Copyright (c) 2022 Arcane Arts (Volmit Software)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package art.arcane.iris.pack.ingest;
+
+import art.arcane.iris.pack.ingest.catalog.CatalogBiome;
+import art.arcane.iris.pack.ingest.catalog.WorldgenCatalog;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+
+final class ModBiomeClassifier {
+ private static final Set CAVE_TAGS = Set.of("is_cave", "is_caves", "caves", "is_underground", "underground");
+ private static final Set END_TAGS = Set.of("is_end", "is_the_end", "is_outer_end_island");
+
+ private final String environment;
+ private final String requiredTag;
+ private final boolean requiredTagCaptured;
+
+ ModBiomeClassifier(String environment, WorldgenCatalog catalog) {
+ this.environment = environment;
+ this.requiredTag = switch (environment) {
+ case "NORMAL" -> "is_overworld";
+ case "NETHER" -> "is_nether";
+ case "THE_END" -> "is_end";
+ default -> "";
+ };
+ this.requiredTagCaptured = !requiredTag.isEmpty() && anyBiomeTagged(catalog, requiredTag);
+ }
+
+ Classification classify(CatalogBiome biome) {
+ Set tags = conventionTags(biome.tags());
+ if (environment.equals("NORMAL") && tags.contains("is_nether")) {
+ return Classification.skip("tagged for the nether, not this overworld dimension");
+ }
+ if (environment.equals("NORMAL") && !Collections.disjoint(tags, END_TAGS)) {
+ return Classification.skip("tagged for the end, not this overworld dimension");
+ }
+ if (requiredTagCaptured && !tags.contains(requiredTag)) {
+ return Classification.skip("not tagged minecraft:" + requiredTag + " for this " + environment.toLowerCase(Locale.ROOT) + " dimension");
+ }
+ if (tags.contains("is_river")) {
+ return Classification.skip("river biome; Iris hydrology owns rivers");
+ }
+ if (tags.contains("is_ocean") || tags.contains("is_deep_ocean")) {
+ return Classification.of(BiomeRole.SEA);
+ }
+ if (tags.contains("is_beach")) {
+ return Classification.of(BiomeRole.SHORE);
+ }
+ for (String tag : tags) {
+ if (CAVE_TAGS.contains(tag)) {
+ return Classification.of(BiomeRole.CAVE);
+ }
+ }
+ return Classification.of(BiomeRole.LAND);
+ }
+
+ private static boolean anyBiomeTagged(WorldgenCatalog catalog, String tag) {
+ for (CatalogBiome biome : catalog.biomes()) {
+ if (conventionTags(biome.tags()).contains(tag)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static Set conventionTags(List tags) {
+ Set paths = new HashSet<>();
+ for (String tag : tags) {
+ String key = VanillaJson.namespaced(tag);
+ int colon = key.indexOf(':');
+ String namespace = key.substring(0, colon);
+ if (namespace.equals("minecraft") || namespace.equals("c")) {
+ paths.add(key.substring(colon + 1));
+ }
+ }
+ return paths;
+ }
+
+ record Classification(BiomeRole role, String skipReason) {
+ static Classification of(BiomeRole role) {
+ return new Classification(role, null);
+ }
+
+ static Classification skip(String reason) {
+ return new Classification(null, reason);
+ }
+
+ boolean skipped() {
+ return role == null;
+ }
+ }
+}
diff --git a/core/src/main/java/art/arcane/iris/pack/ingest/ModFeatureIngester.java b/core/src/main/java/art/arcane/iris/pack/ingest/ModFeatureIngester.java
new file mode 100644
index 000000000..e87ab8d55
--- /dev/null
+++ b/core/src/main/java/art/arcane/iris/pack/ingest/ModFeatureIngester.java
@@ -0,0 +1,219 @@
+/*
+ * Iris is a World Generator for Minecraft Bukkit Servers
+ * Copyright (c) 2022 Arcane Arts (Volmit Software)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package art.arcane.iris.pack.ingest;
+
+import art.arcane.iris.pack.ingest.catalog.CatalogBiome;
+import art.arcane.iris.pack.ingest.catalog.CatalogPlacedFeature;
+import art.arcane.iris.pack.ingest.catalog.WorldgenCatalogIndex;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.TreeSet;
+
+final class ModFeatureIngester {
+ private final IngestPackModel model;
+ private final WorldgenCatalogIndex index;
+ private final Map plans = new TreeMap<>();
+ private final Set nativeKeys = new TreeSet<>();
+
+ ModFeatureIngester(IngestPackModel model, WorldgenCatalogIndex index, Set namespaces) {
+ this.model = model;
+ this.index = index;
+ Map> declaring = new TreeMap<>();
+ Map> steps = new TreeMap<>();
+ for (CatalogBiome biome : index.catalog().biomes()) {
+ for (int step = 0; step < biome.featureSteps().size(); step++) {
+ for (String feature : biome.featureSteps().get(step)) {
+ if (!namespaces.contains(WorldgenCatalogIndex.namespaceOf(feature))) {
+ continue;
+ }
+ declaring.computeIfAbsent(feature, ignored -> new LinkedHashSet<>()).add(biome.key());
+ steps.computeIfAbsent(feature, ignored -> new TreeSet<>()).add(step);
+ }
+ }
+ }
+ for (Map.Entry> entry : declaring.entrySet()) {
+ plans.put(entry.getKey(), plan(entry.getKey(), entry.getValue(), steps.get(entry.getKey())));
+ }
+ }
+
+ Set decoratorKeys() {
+ Set keys = new TreeSet<>();
+ for (FeaturePlan plan : plans.values()) {
+ if (plan.decorator() != null) {
+ keys.add(plan.key());
+ }
+ }
+ return keys;
+ }
+
+ Set nativeKeys() {
+ return nativeKeys;
+ }
+
+ Map plans() {
+ return plans;
+ }
+
+ List apply(Map adopted) throws IOException {
+ Set universe = new TreeSet<>(adopted.keySet());
+ universe.addAll(model.reachableBiomes().values());
+ Set caveKeys = caveKeys(adopted);
+ List items = new ArrayList<>(plans.size());
+ for (FeaturePlan plan : plans.values()) {
+ String namespace = WorldgenCatalogIndex.namespaceOf(plan.key());
+ Set hosts = new TreeSet<>(plan.declaring());
+ hosts.retainAll(universe);
+ if (hosts.isEmpty()) {
+ items.add(new ModIngestItem(namespace, plan.key(), "feature", ModIngestTier.SKIPPED, "",
+ "no biome in the target dimension declares it"));
+ continue;
+ }
+ if (plan.deposits() != null) {
+ items.add(new ModIngestItem(namespace, plan.key(), "feature", ModIngestTier.NATIVE,
+ applyDeposits(plan, adopted, universe, caveKeys), "ore converted to Iris deposits"));
+ nativeKeys.add(plan.key());
+ continue;
+ }
+ if (plan.decorator() != null) {
+ items.add(new ModIngestItem(namespace, plan.key(), "feature", ModIngestTier.NATIVE,
+ applyDecorator(plan, adopted), "patch converted to an Iris decorator"));
+ nativeKeys.add(plan.key());
+ continue;
+ }
+ items.add(new ModIngestItem(namespace, plan.key(), "feature", ModIngestTier.PASSTHROUGH, "importedFeatures", plan.reason()));
+ }
+ return items;
+ }
+
+ private FeaturePlan plan(String key, Set declaring, Set steps) {
+ Optional feature = index.placedFeature(key);
+ if (feature.isEmpty()) {
+ return FeaturePlan.passthrough(key, declaring, steps, "placed feature is missing from the catalog");
+ }
+ CatalogPlacedFeature placed = feature.get();
+ try {
+ if (OreDepositConverter.handles(placed)) {
+ List deposits = OreDepositConverter.convert(placed, model.verticalBounds(), model.packMinY());
+ return new FeaturePlan(key, declaring, steps, deposits, null, "");
+ }
+ if (PatchDecoratorConverter.handles(placed)) {
+ return new FeaturePlan(key, declaring, steps, null, PatchDecoratorConverter.convert(placed, index), "");
+ }
+ } catch (IngestConversionException e) {
+ return FeaturePlan.passthrough(key, declaring, steps, "generated natively: " + e.getMessage());
+ } catch (RuntimeException e) {
+ return FeaturePlan.passthrough(key, declaring, steps, "generated natively: malformed " + placed.featureType() + " data (" + e.getMessage() + ")");
+ }
+ return FeaturePlan.passthrough(key, declaring, steps, "generated natively: feature type " + placed.featureType() + " has no Iris equivalent");
+ }
+
+ private String applyDeposits(FeaturePlan plan, Map adopted, Set universe, Set caveKeys) throws IOException {
+ List adoptedTargets = new ArrayList<>();
+ boolean vanillaDeclared = false;
+ for (String biome : plan.declaring()) {
+ vanillaDeclared |= WorldgenCatalogIndex.namespaceOf(biome).equals("minecraft");
+ ModBiomeAdopter.AdoptedBiome adoptedBiome = adopted.get(biome);
+ if (adoptedBiome != null) {
+ adoptedTargets.add(adoptedBiome.irisKey());
+ }
+ }
+ if (!vanillaDeclared && !adoptedTargets.isEmpty()) {
+ for (String irisKey : adoptedTargets) {
+ appendAll(model.pack().require(IngestPack.biomePath(irisKey)), "deposits", plan.deposits());
+ }
+ return String.join(",", adoptedTargets);
+ }
+ Set included = new TreeSet<>(plan.declaring());
+ included.retainAll(universe);
+ List deposits = new ArrayList<>(plan.deposits().size());
+ for (JsonObject deposit : plan.deposits()) {
+ JsonObject copy = deposit.deepCopy();
+ if (!included.containsAll(universe)) {
+ copy.add("includedBiomes", IngestJson.stringArray(included));
+ copy.addProperty("biomeScope", caveKeys.containsAll(included) ? "CAVE" : "SURFACE");
+ }
+ deposits.add(copy);
+ }
+ appendAll(model.pack().edit(model.dimensionPath()), "deposits", deposits);
+ return "dimension";
+ }
+
+ private String applyDecorator(FeaturePlan plan, Map adopted) throws IOException {
+ List targets = new ArrayList<>();
+ for (String biome : plan.declaring()) {
+ ModBiomeAdopter.AdoptedBiome adoptedBiome = adopted.get(biome);
+ if (adoptedBiome != null) {
+ appendAll(model.pack().require(IngestPack.biomePath(adoptedBiome.irisKey())), "decorators", List.of(plan.decorator()));
+ targets.add(adoptedBiome.irisKey());
+ }
+ }
+ for (Map.Entry biome : model.reachableBiomes().entrySet()) {
+ if (plan.declaring().contains(biome.getValue())) {
+ appendAll(model.pack().edit(IngestPack.biomePath(biome.getKey())), "decorators", List.of(plan.decorator()));
+ targets.add(biome.getKey());
+ }
+ }
+ return String.join(",", targets);
+ }
+
+ private Set caveKeys(Map adopted) {
+ Set caves = new TreeSet<>();
+ for (ModBiomeAdopter.AdoptedBiome biome : adopted.values()) {
+ if (biome.role() == BiomeRole.CAVE) {
+ caves.add(biome.modKey());
+ }
+ }
+ Set surface = new TreeSet<>();
+ for (IngestPackModel.RegionProfile region : model.regions()) {
+ for (BiomeRole role : BiomeRole.values()) {
+ for (String biome : region.pool(role)) {
+ String derivative = model.reachableBiomes().get(biome);
+ (role == BiomeRole.CAVE ? caves : surface).add(derivative);
+ }
+ }
+ }
+ caves.removeAll(surface);
+ return caves;
+ }
+
+ private static void appendAll(JsonObject target, String field, List values) {
+ JsonElement existing = target.get(field);
+ JsonArray array = existing != null && existing.isJsonArray() ? existing.getAsJsonArray() : new JsonArray();
+ for (JsonObject value : values) {
+ array.add(value.deepCopy());
+ }
+ target.add(field, array);
+ }
+
+ record FeaturePlan(String key, Set declaring, Set steps, List deposits, JsonObject decorator, String reason) {
+ static FeaturePlan passthrough(String key, Set declaring, Set steps, String reason) {
+ return new FeaturePlan(key, declaring, steps, null, null, reason);
+ }
+ }
+}
diff --git a/core/src/main/java/art/arcane/iris/pack/ingest/ModIngester.java b/core/src/main/java/art/arcane/iris/pack/ingest/ModIngester.java
index 5483dce24..305fe1f21 100644
--- a/core/src/main/java/art/arcane/iris/pack/ingest/ModIngester.java
+++ b/core/src/main/java/art/arcane/iris/pack/ingest/ModIngester.java
@@ -16,16 +16,142 @@
* along with this program. If not, see .
*/
-
package art.arcane.iris.pack.ingest;
+import art.arcane.iris.pack.AtomicDirectoryPublisher;
+import art.arcane.iris.pack.ingest.catalog.CatalogBiome;
+import art.arcane.iris.pack.ingest.catalog.CatalogNamespace;
+import art.arcane.iris.pack.ingest.catalog.CatalogPlacedFeature;
+import art.arcane.iris.pack.ingest.catalog.CatalogStructure;
+import art.arcane.iris.pack.ingest.catalog.WorldgenCatalog;
+import art.arcane.iris.pack.ingest.catalog.WorldgenCatalogIndex;
+import art.arcane.iris.structure.nativegen.IrisImportedFeatureControl;
+import art.arcane.iris.studio.workspace.IrisProjectCopier;
+import com.google.gson.JsonObject;
+
import java.io.IOException;
+import java.nio.file.FileAlreadyExistsException;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.TreeSet;
public final class ModIngester {
+ private static final Set RESERVED_NAMESPACES = Set.of("minecraft", "c", "iris");
+
private ModIngester() {
}
public static ModIngestReport ingest(ModIngestRequest request) throws IOException {
- throw new UnsupportedOperationException("Mod ingest is not implemented yet");
+ Objects.requireNonNull(request, "request");
+ Path source = request.sourcePack().toAbsolutePath().normalize();
+ Path target = request.targetPack().toAbsolutePath().normalize();
+ requireSeparate(source, target);
+ if (Files.exists(source.resolve(IngestManifest.PATH), LinkOption.NOFOLLOW_LINKS)) {
+ throw new IOException("Source pack " + source + " was written by mod ingest; ingest from its original pack instead.");
+ }
+ Set namespaces = selectNamespaces(request);
+ prepareTarget(target, request.replaceTarget());
+ String targetKey = target.getFileName().toString();
+ IrisProjectCopier.copyProject(source.toFile(), target.getParent().toFile(), request.dimensionKey(), targetKey);
+ try {
+ return populate(request, target, targetKey, namespaces);
+ } catch (IOException | RuntimeException e) {
+ try {
+ AtomicDirectoryPublisher.deleteTree(target);
+ } catch (IOException cleanupFailure) {
+ e.addSuppressed(cleanupFailure);
+ }
+ throw e;
+ }
+ }
+
+ private static ModIngestReport populate(ModIngestRequest request, Path target, String targetKey, Set namespaces) throws IOException {
+ WorldgenCatalogIndex index = WorldgenCatalogIndex.of(request.catalog());
+ IngestPack pack = new IngestPack(target);
+ IngestPackModel model = IngestPackModel.load(pack, targetKey, index);
+ ModFeatureIngester features = new ModFeatureIngester(model, index, namespaces);
+ List biomeItems = new ArrayList<>();
+ Map adopted = new ModBiomeAdopter(model, index, features.decoratorKeys())
+ .adopt(modBiomes(index, namespaces), biomeItems);
+ List featureItems = features.apply(adopted);
+ JsonObject dimension = pack.edit(model.dimensionPath());
+ IrisImportedFeatureControl control = ImportedFeatureWriter.write(dimension, features.nativeKeys());
+ featureItems = ImportedFeatureWriter.applySuppression(featureItems, features.plans(), control);
+ Set hostBiomes = new TreeSet<>(adopted.keySet());
+ hostBiomes.addAll(model.reachableBiomes().values());
+ List items = new ArrayList<>(biomeItems);
+ items.addAll(featureItems);
+ items.addAll(ModStructureIngester.ingest(request.catalog().structures(), namespaces, hostBiomes, dimension));
+ pack.flush();
+ IngestManifest.write(pack, request.catalog(), request.dimensionKey(), targetKey, namespaces, items);
+ return new ModIngestReport(target, items, IngestManifest.summary(targetKey, pack, namespaces, items));
+ }
+
+ private static Set selectNamespaces(ModIngestRequest request) {
+ Set selected = new TreeSet<>();
+ if (!request.namespaces().isEmpty()) {
+ for (String namespace : request.namespaces()) {
+ String normalized = namespace.trim().toLowerCase(Locale.ROOT);
+ if (RESERVED_NAMESPACES.contains(normalized)) {
+ throw new IllegalArgumentException("Namespace " + normalized + " cannot be ingested; it belongs to Minecraft or Iris.");
+ }
+ selected.add(normalized);
+ }
+ return selected;
+ }
+ WorldgenCatalog catalog = request.catalog();
+ for (CatalogNamespace namespace : catalog.namespaces()) {
+ selected.add(namespace.namespace());
+ }
+ for (CatalogBiome biome : catalog.biomes()) {
+ selected.add(WorldgenCatalogIndex.namespaceOf(biome.key()));
+ }
+ for (CatalogPlacedFeature feature : catalog.placedFeatures()) {
+ selected.add(WorldgenCatalogIndex.namespaceOf(feature.key()));
+ }
+ for (CatalogStructure structure : catalog.structures()) {
+ selected.add(WorldgenCatalogIndex.namespaceOf(structure.key()));
+ }
+ selected.removeAll(RESERVED_NAMESPACES);
+ return selected;
+ }
+
+ private static List modBiomes(WorldgenCatalogIndex index, Set namespaces) {
+ List biomes = new ArrayList<>();
+ for (String namespace : namespaces) {
+ biomes.addAll(index.biomesIn(namespace));
+ }
+ biomes.sort(Comparator.comparing(CatalogBiome::key));
+ return biomes;
+ }
+
+ private static void requireSeparate(Path source, Path target) throws IOException {
+ Path realSource = Files.exists(source) ? source.toRealPath() : source;
+ if (target.getParent() == null) {
+ throw new IOException("Target pack needs a parent directory: " + target);
+ }
+ for (Path candidate : List.of(source, realSource)) {
+ if (candidate.equals(target) || target.startsWith(candidate) || candidate.startsWith(target)) {
+ throw new IOException("Target pack " + target + " overlaps the source pack " + candidate);
+ }
+ }
+ }
+
+ private static void prepareTarget(Path target, boolean replace) throws IOException {
+ if (Files.exists(target, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(target)) {
+ if (!replace) {
+ throw new FileAlreadyExistsException(target.toString());
+ }
+ AtomicDirectoryPublisher.deleteTree(target);
+ }
+ Files.createDirectories(target.getParent());
}
}
diff --git a/core/src/main/java/art/arcane/iris/pack/ingest/ModStructureIngester.java b/core/src/main/java/art/arcane/iris/pack/ingest/ModStructureIngester.java
new file mode 100644
index 000000000..d94917a70
--- /dev/null
+++ b/core/src/main/java/art/arcane/iris/pack/ingest/ModStructureIngester.java
@@ -0,0 +1,75 @@
+/*
+ * Iris is a World Generator for Minecraft Bukkit Servers
+ * Copyright (c) 2022 Arcane Arts (Volmit Software)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package art.arcane.iris.pack.ingest;
+
+import art.arcane.iris.pack.ingest.catalog.CatalogStructure;
+import art.arcane.iris.pack.ingest.catalog.WorldgenCatalogIndex;
+import art.arcane.iris.structure.nativegen.IrisImportedStructureControl;
+import art.arcane.volmlib.util.collection.KList;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Set;
+
+final class ModStructureIngester {
+ private ModStructureIngester() {
+ }
+
+ static List ingest(List structures, Set namespaces, Set hostBiomes, JsonObject dimension) {
+ IrisImportedStructureControl control = control(dimension.get("importedStructures"));
+ List selected = new ArrayList<>();
+ for (CatalogStructure structure : structures) {
+ if (namespaces.contains(WorldgenCatalogIndex.namespaceOf(structure.key()))) {
+ selected.add(structure);
+ }
+ }
+ selected.sort(Comparator.comparing(CatalogStructure::key));
+ List items = new ArrayList<>(selected.size());
+ for (CatalogStructure structure : selected) {
+ String namespace = WorldgenCatalogIndex.namespaceOf(structure.key());
+ if (!control.shouldGenerate(structure.key())) {
+ items.add(new ModIngestItem(namespace, structure.key(), "structure", ModIngestTier.SKIPPED, "",
+ "disabled by the dimension's importedStructures"));
+ continue;
+ }
+ if (!structure.biomes().isEmpty() && structure.biomes().stream().noneMatch(hostBiomes::contains)) {
+ items.add(new ModIngestItem(namespace, structure.key(), "structure", ModIngestTier.SKIPPED, "",
+ "none of its biomes generate in the target dimension"));
+ continue;
+ }
+ items.add(new ModIngestItem(namespace, structure.key(), "structure", ModIngestTier.ADOPTED, "importedStructures",
+ "generates natively in its biomes"));
+ }
+ return items;
+ }
+
+ private static IrisImportedStructureControl control(JsonElement value) {
+ IrisImportedStructureControl control = new IrisImportedStructureControl();
+ if (value == null || !value.isJsonObject()) {
+ return control;
+ }
+ JsonObject object = value.getAsJsonObject();
+ control.setDisabled(new KList<>(IngestJson.strings(object.get("disabled"))));
+ control.setDisabledExact(new KList<>(IngestJson.strings(object.get("disabledExact"))));
+ return control;
+ }
+}
diff --git a/core/src/main/java/art/arcane/iris/pack/ingest/OreDepositConverter.java b/core/src/main/java/art/arcane/iris/pack/ingest/OreDepositConverter.java
new file mode 100644
index 000000000..461b8980b
--- /dev/null
+++ b/core/src/main/java/art/arcane/iris/pack/ingest/OreDepositConverter.java
@@ -0,0 +1,146 @@
+/*
+ * Iris is a World Generator for Minecraft Bukkit Servers
+ * Copyright (c) 2022 Arcane Arts (Volmit Software)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package art.arcane.iris.pack.ingest;
+
+import art.arcane.iris.pack.ingest.catalog.CatalogPlacedFeature;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.SortedSet;
+import java.util.TreeSet;
+
+final class OreDepositConverter {
+ static final Set ORE_TYPES = Set.of("minecraft:ore", "minecraft:scattered_ore");
+ private static final int MAX_PER_CHUNK = 2048;
+ private static final int MAX_SIZE = 8192;
+
+ private OreDepositConverter() {
+ }
+
+ static boolean handles(CatalogPlacedFeature feature) {
+ return ORE_TYPES.contains(VanillaJson.namespaced(feature.featureType()));
+ }
+
+ static List convert(CatalogPlacedFeature feature, VanillaHeights.VerticalBounds bounds, int packMinY) throws IngestConversionException {
+ JsonObject config = VanillaJson.featureConfig(feature.config());
+ Placement placement = placement(VanillaJson.placement(feature.placement()), bounds);
+ int size = VanillaJson.integer(config.get("size"), "ore size");
+ if (size <= 0) {
+ throw new IngestConversionException("ore size " + size + " never places blocks");
+ }
+ double discard = VanillaJson.number(config, "discard_chance_on_air_exposure", 0D, "ore");
+ String shape = VanillaJson.namespaced(feature.featureType()).equals("minecraft:scattered_ore") ? "VANILLA_SCATTERED" : "VANILLA_ELLIPSOID";
+ List deposits = new ArrayList<>();
+ for (Target target : targets(VanillaJson.array(config.get("targets"), "ore targets"))) {
+ JsonObject deposit = new JsonObject();
+ deposit.addProperty("minHeight", placement.band().min() - packMinY);
+ deposit.addProperty("maxHeight", placement.band().max() - packMinY);
+ deposit.addProperty("heightDistribution", placement.band().distribution());
+ deposit.addProperty("surfaceClearance", 0);
+ deposit.addProperty("minPerChunk", placement.minPerChunk());
+ deposit.addProperty("maxPerChunk", placement.maxPerChunk());
+ if (placement.spawnChance() < 1D) {
+ deposit.addProperty("spawnChance", IngestJson.round(placement.spawnChance()));
+ }
+ deposit.addProperty("minSize", Math.min(size, MAX_SIZE));
+ deposit.addProperty("maxSize", Math.min(size, MAX_SIZE));
+ deposit.addProperty("shape", shape);
+ if (discard > 0D) {
+ deposit.addProperty("discardChanceOnAirExposure", IngestJson.round(Math.min(1D, discard)));
+ }
+ if (!target.hosts().any()) {
+ deposit.add("replaceableBlocks", IngestJson.stringArray(target.hosts().blocks()));
+ }
+ JsonArray palette = new JsonArray();
+ palette.add(target.block());
+ deposit.add("palette", palette);
+ deposits.add(deposit);
+ }
+ if (deposits.isEmpty()) {
+ throw new IngestConversionException("ore has no reachable targets");
+ }
+ return deposits;
+ }
+
+ private static List targets(JsonArray targets) throws IngestConversionException {
+ List result = new ArrayList<>(targets.size());
+ SortedSet claimed = new TreeSet<>();
+ for (JsonElement entry : targets) {
+ JsonObject target = VanillaJson.object(entry, "ore target");
+ JsonObject block = VanillaBlockStates.irisBlock(target.get("state"));
+ VanillaRuleTests.HostSet hosts = VanillaRuleTests.hosts(target.get("target"));
+ if (hosts.any()) {
+ if (!claimed.isEmpty()) {
+ throw new IngestConversionException("ore target accepting any block follows restricted targets");
+ }
+ result.add(new Target(block, hosts));
+ return result;
+ }
+ SortedSet remaining = new TreeSet<>(hosts.blocks());
+ remaining.removeAll(claimed);
+ if (remaining.isEmpty()) {
+ continue;
+ }
+ claimed.addAll(remaining);
+ result.add(new Target(block, VanillaRuleTests.HostSet.of(remaining)));
+ }
+ return result;
+ }
+
+ private static Placement placement(JsonArray modifiers, VanillaHeights.VerticalBounds bounds) throws IngestConversionException {
+ long minCount = 1L;
+ long maxCount = 1L;
+ double spawnChance = 1D;
+ VanillaHeights.HeightBand band = null;
+ for (JsonElement modifier : modifiers) {
+ JsonObject object = VanillaJson.object(modifier, "placement modifier");
+ String type = VanillaJson.type(object, "type");
+ switch (type) {
+ case "minecraft:count" -> {
+ VanillaIntProviders.IntStats stats = VanillaIntProviders.stats(object.get("count"));
+ minCount = Math.min(MAX_PER_CHUNK, minCount * Math.max(0, stats.min()));
+ maxCount = Math.min(MAX_PER_CHUNK, maxCount * Math.max(0, stats.max()));
+ }
+ case "minecraft:rarity_filter" -> spawnChance /= Math.max(1D, VanillaJson.number(object.get("chance"), "rarity_filter.chance"));
+ case "minecraft:random_chance" -> spawnChance *= Math.max(0D, Math.min(1D, VanillaJson.number(object.get("chance"), "random_chance.chance")));
+ case "minecraft:in_square", "minecraft:biome" -> {
+ }
+ case "minecraft:height_range" -> band = VanillaHeights.band(object.get("height"), bounds);
+ default -> throw new IngestConversionException("unsupported ore placement modifier " + (type.isEmpty() ? object.toString() : type));
+ }
+ }
+ if (band == null) {
+ throw new IngestConversionException("ore placement has no height_range");
+ }
+ if (maxCount <= 0L || spawnChance <= 0D) {
+ throw new IngestConversionException("ore placement never places");
+ }
+ return new Placement((int) minCount, (int) maxCount, spawnChance, band);
+ }
+
+ private record Target(JsonObject block, VanillaRuleTests.HostSet hosts) {
+ }
+
+ private record Placement(int minPerChunk, int maxPerChunk, double spawnChance, VanillaHeights.HeightBand band) {
+ }
+}
diff --git a/core/src/main/java/art/arcane/iris/pack/ingest/PatchDecoratorConverter.java b/core/src/main/java/art/arcane/iris/pack/ingest/PatchDecoratorConverter.java
new file mode 100644
index 000000000..83759d3f0
--- /dev/null
+++ b/core/src/main/java/art/arcane/iris/pack/ingest/PatchDecoratorConverter.java
@@ -0,0 +1,133 @@
+/*
+ * Iris is a World Generator for Minecraft Bukkit Servers
+ * Copyright (c) 2022 Arcane Arts (Volmit Software)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package art.arcane.iris.pack.ingest;
+
+import art.arcane.iris.pack.ingest.catalog.CatalogPlacedFeature;
+import art.arcane.iris.pack.ingest.catalog.WorldgenCatalog;
+import art.arcane.iris.pack.ingest.catalog.WorldgenCatalogIndex;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+final class PatchDecoratorConverter {
+ static final String SIMPLE_BLOCK = "minecraft:simple_block";
+ private static final Set LEGACY_PATCH_TYPES = Set.of("minecraft:flower", "minecraft:random_patch", "minecraft:no_bonemeal_flower");
+ private static final int LEGACY_DEFAULT_TRIES = 128;
+ private static final int LEGACY_DEFAULT_Y_SPREAD = 3;
+ private static final double COLUMNS_PER_CHUNK = 256D;
+
+ private PatchDecoratorConverter() {
+ }
+
+ static boolean handles(CatalogPlacedFeature feature) {
+ String type = VanillaJson.namespaced(feature.featureType());
+ return type.equals(SIMPLE_BLOCK) || LEGACY_PATCH_TYPES.contains(type);
+ }
+
+ static JsonObject convert(CatalogPlacedFeature feature, WorldgenCatalogIndex index) throws IngestConversionException {
+ String type = VanillaJson.namespaced(feature.featureType());
+ JsonObject config = VanillaJson.featureConfig(feature.config());
+ VanillaPlacementChain outer = VanillaPlacementChain.surfaceChain(VanillaJson.placement(feature.placement()));
+ Set whitelist = new LinkedHashSet<>(outer.whitelist());
+ double expected;
+ JsonElement provider;
+ if (LEGACY_PATCH_TYPES.contains(type)) {
+ int tries = VanillaJson.integer(config, "tries", LEGACY_DEFAULT_TRIES, "patch");
+ int verticalSpread = Math.abs(VanillaJson.integer(config, "y_spread", LEGACY_DEFAULT_Y_SPREAD, "patch"));
+ double surfaceHitChance = outer.surfaceHitChance() / (verticalSpread + 1D);
+ NestedBlock nested = nestedBlock(config.get("feature"), index);
+ whitelist.addAll(nested.whitelist());
+ provider = nested.provider();
+ expected = outer.expectedPlacements() * tries * surfaceHitChance;
+ } else {
+ provider = config.get("to_place");
+ expected = outer.expectedPlacements() * outer.surfaceHitChance();
+ }
+ if (expected <= 0D) {
+ throw new IngestConversionException("patch placement never places");
+ }
+ List palette = VanillaBlockStates.palette(provider);
+ JsonObject decorator = new JsonObject();
+ decorator.addProperty("chance", IngestJson.round(columnCoverage(expected)));
+ JsonArray paletteArray = new JsonArray(palette.size());
+ for (JsonObject entry : palette) {
+ paletteArray.add(entry);
+ }
+ decorator.add("palette", paletteArray);
+ if (!whitelist.isEmpty()) {
+ JsonArray whitelistArray = new JsonArray(whitelist.size());
+ for (String block : whitelist) {
+ JsonObject entry = new JsonObject();
+ entry.addProperty("block", block);
+ whitelistArray.add(entry);
+ }
+ decorator.add("whitelist", whitelistArray);
+ }
+ return decorator;
+ }
+
+ static double columnCoverage(double expectedPlacements) {
+ return 1D - Math.exp(-expectedPlacements / COLUMNS_PER_CHUNK);
+ }
+
+ private static NestedBlock nestedBlock(JsonElement holder, WorldgenCatalogIndex index) throws IngestConversionException {
+ if (holder != null && holder.isJsonPrimitive()) {
+ String key = VanillaJson.namespaced(holder.getAsString());
+ Optional placed = index.placedFeature(key);
+ if (placed.isEmpty() || !VanillaJson.namespaced(placed.get().featureType()).equals(SIMPLE_BLOCK)) {
+ throw new IngestConversionException("patch feature " + key + " is not a captured simple_block feature");
+ }
+ return new NestedBlock(VanillaJson.featureConfig(placed.get().config()).get("to_place"),
+ VanillaPlacementChain.filterWhitelist(VanillaJson.placement(placed.get().placement())));
+ }
+ JsonObject placed = VanillaJson.object(holder, "patch feature");
+ JsonElement placement = placed.get("placement");
+ Set whitelist = placement == null || placement.isJsonNull()
+ ? Set.of()
+ : VanillaPlacementChain.filterWhitelist(VanillaJson.array(placement, "patch feature placement"));
+ return new NestedBlock(configuredToPlace(placed.get("feature"), index.catalog()), whitelist);
+ }
+
+ private static JsonElement configuredToPlace(JsonElement configured, WorldgenCatalog catalog) throws IngestConversionException {
+ if (configured != null && configured.isJsonPrimitive()) {
+ String key = VanillaJson.namespaced(configured.getAsString());
+ for (CatalogPlacedFeature candidate : catalog.placedFeatures()) {
+ if (VanillaJson.namespaced(candidate.configuredFeature()).equals(key)
+ && VanillaJson.namespaced(candidate.featureType()).equals(SIMPLE_BLOCK)) {
+ return VanillaJson.featureConfig(candidate.config()).get("to_place");
+ }
+ }
+ throw new IngestConversionException("patch configured feature " + key + " is not a captured simple_block feature");
+ }
+ JsonObject object = VanillaJson.object(configured, "patch configured feature");
+ String type = VanillaJson.type(object, "type");
+ if (!type.equals(SIMPLE_BLOCK)) {
+ throw new IngestConversionException("patch places " + (type.isEmpty() ? "an unknown feature" : type) + " instead of simple_block");
+ }
+ return VanillaJson.featureConfig(object).get("to_place");
+ }
+
+ private record NestedBlock(JsonElement provider, Set whitelist) {
+ }
+}
diff --git a/core/src/main/java/art/arcane/iris/pack/ingest/VanillaBlockStates.java b/core/src/main/java/art/arcane/iris/pack/ingest/VanillaBlockStates.java
new file mode 100644
index 000000000..207071aef
--- /dev/null
+++ b/core/src/main/java/art/arcane/iris/pack/ingest/VanillaBlockStates.java
@@ -0,0 +1,154 @@
+/*
+ * Iris is a World Generator for Minecraft Bukkit Servers
+ * Copyright (c) 2022 Arcane Arts (Volmit Software)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package art.arcane.iris.pack.ingest;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+final class VanillaBlockStates {
+ private VanillaBlockStates() {
+ }
+
+ static JsonObject irisBlock(JsonElement state) throws IngestConversionException {
+ if (state != null && state.isJsonPrimitive() && state.getAsJsonPrimitive().isString()) {
+ return parseStateString(state.getAsString());
+ }
+ JsonObject object = VanillaJson.object(state, "block state");
+ if (object.has("id")) {
+ return block(VanillaJson.string(object.get("id"), "block state id"), object.get("properties"));
+ }
+ if (object.has("Name")) {
+ return block(VanillaJson.string(object.get("Name"), "block state Name"), object.get("Properties"));
+ }
+ throw new IngestConversionException("unsupported block state " + object);
+ }
+
+ static List palette(JsonElement provider) throws IngestConversionException {
+ if (provider != null && provider.isJsonPrimitive()) {
+ throw new IngestConversionException("block state provider reference " + provider.getAsString() + " is not captured in the catalog");
+ }
+ JsonObject object = VanillaJson.object(provider, "block state provider");
+ String type = VanillaJson.type(object, "type");
+ Map entries = new LinkedHashMap<>();
+ switch (type) {
+ case "" -> add(entries, irisBlock(object), 1);
+ case "minecraft:simple_state_provider", "minecraft:simple" -> add(entries, irisBlock(object.get("state")), 1);
+ case "minecraft:weighted_state_provider", "minecraft:weighted" -> addWeighted(entries, VanillaJson.array(object.get("entries"), "weighted entries"));
+ case "minecraft:noise_provider", "minecraft:noise", "minecraft:dual_noise_provider", "minecraft:dual_noise" ->
+ addAll(entries, VanillaJson.array(object.get("states"), "noise states"));
+ case "minecraft:noise_threshold_provider", "minecraft:noise_threshold" -> {
+ add(entries, irisBlock(object.get("default_state")), 1);
+ addAll(entries, VanillaJson.array(object.get("low_states"), "noise_threshold low_states"));
+ addAll(entries, VanillaJson.array(object.get("high_states"), "noise_threshold high_states"));
+ }
+ default -> throw new IngestConversionException("unsupported block state provider " + type);
+ }
+ if (entries.isEmpty()) {
+ throw new IngestConversionException("block state provider has no states");
+ }
+ List palette = new ArrayList<>(entries.size());
+ for (JsonObject entry : entries.values()) {
+ JsonObject copy = entry.deepCopy();
+ if (copy.has("weight") && copy.get("weight").getAsInt() == 1) {
+ copy.remove("weight");
+ }
+ palette.add(copy);
+ }
+ return palette;
+ }
+
+ private static void addWeighted(Map entries, JsonArray weighted) throws IngestConversionException {
+ for (JsonElement entry : weighted) {
+ JsonObject entryObject = VanillaJson.object(entry, "weighted entry");
+ int weight = VanillaJson.integer(entryObject, "weight", 1, "weighted entry");
+ if (weight > 0) {
+ add(entries, irisBlock(entryObject.get("data")), weight);
+ }
+ }
+ }
+
+ private static void addAll(Map entries, JsonArray states) throws IngestConversionException {
+ for (JsonElement state : states) {
+ add(entries, irisBlock(state), 1);
+ }
+ }
+
+ private static void add(Map entries, JsonObject block, int weight) {
+ String key = block.toString();
+ JsonObject existing = entries.get(key);
+ if (existing == null) {
+ JsonObject entry = block.deepCopy();
+ entry.addProperty("weight", weight);
+ entries.put(key, entry);
+ return;
+ }
+ existing.addProperty("weight", existing.get("weight").getAsInt() + weight);
+ }
+
+ private static JsonObject parseStateString(String value) throws IngestConversionException {
+ String trimmed = value.trim();
+ int bracket = trimmed.indexOf('[');
+ if (bracket < 0) {
+ return block(trimmed, null);
+ }
+ if (!trimmed.endsWith("]")) {
+ throw new IngestConversionException("malformed block state " + value);
+ }
+ JsonObject properties = new JsonObject();
+ String body = trimmed.substring(bracket + 1, trimmed.length() - 1);
+ for (String pair : body.split(",")) {
+ if (pair.isBlank()) {
+ continue;
+ }
+ int equals = pair.indexOf('=');
+ if (equals <= 0) {
+ throw new IngestConversionException("malformed block state " + value);
+ }
+ properties.addProperty(pair.substring(0, equals).trim(), pair.substring(equals + 1).trim());
+ }
+ return block(trimmed.substring(0, bracket), properties);
+ }
+
+ private static JsonObject block(String id, JsonElement properties) throws IngestConversionException {
+ if (id.isBlank()) {
+ throw new IngestConversionException("block state has an empty id");
+ }
+ JsonObject block = new JsonObject();
+ block.addProperty("block", VanillaJson.namespaced(id));
+ if (properties == null || properties.isJsonNull()) {
+ return block;
+ }
+ JsonObject propertyObject = VanillaJson.object(properties, "block state properties");
+ if (propertyObject.size() == 0) {
+ return block;
+ }
+ JsonObject data = new JsonObject();
+ for (Map.Entry property : propertyObject.entrySet()) {
+ data.addProperty(property.getKey(), property.getValue().getAsString());
+ }
+ block.add("data", data);
+ return block;
+ }
+}
diff --git a/core/src/main/java/art/arcane/iris/pack/ingest/VanillaHeights.java b/core/src/main/java/art/arcane/iris/pack/ingest/VanillaHeights.java
new file mode 100644
index 000000000..dc85021fd
--- /dev/null
+++ b/core/src/main/java/art/arcane/iris/pack/ingest/VanillaHeights.java
@@ -0,0 +1,90 @@
+/*
+ * Iris is a World Generator for Minecraft Bukkit Servers
+ * Copyright (c) 2022 Arcane Arts (Volmit Software)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package art.arcane.iris.pack.ingest;
+
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+
+final class VanillaHeights {
+ static final String UNIFORM = "UNIFORM";
+ static final String TRIANGLE = "TRIANGLE";
+
+ private VanillaHeights() {
+ }
+
+ static HeightBand band(JsonElement provider, VerticalBounds bounds) throws IngestConversionException {
+ JsonObject object = VanillaJson.object(provider, "height provider");
+ String type = VanillaJson.type(object, "type");
+ if (type.isEmpty() && isAnchor(object)) {
+ int y = bounds.resolve(object);
+ return new HeightBand(y, y, UNIFORM);
+ }
+ return switch (type) {
+ case "minecraft:constant" -> constant(object, bounds);
+ case "minecraft:uniform" -> range(object, bounds, UNIFORM);
+ case "minecraft:trapezoid" -> range(object, bounds,
+ VanillaJson.integer(object, "plateau", 0, "trapezoid") == 0 ? TRIANGLE : UNIFORM);
+ default -> throw new IngestConversionException("unsupported height provider " + (type.isEmpty() ? object.toString() : type));
+ };
+ }
+
+ private static HeightBand constant(JsonObject object, VerticalBounds bounds) throws IngestConversionException {
+ int y = bounds.resolve(object.get("value"));
+ return new HeightBand(y, y, UNIFORM);
+ }
+
+ private static HeightBand range(JsonObject object, VerticalBounds bounds, String distribution) throws IngestConversionException {
+ int min = bounds.resolve(object.get("min_inclusive"));
+ int max = bounds.resolve(object.get("max_inclusive"));
+ if (max < min) {
+ throw new IngestConversionException("empty height range " + min + ".." + max);
+ }
+ return new HeightBand(min, max, distribution);
+ }
+
+ private static boolean isAnchor(JsonObject object) {
+ return object.has("absolute") || object.has("above_bottom") || object.has("below_top");
+ }
+
+ record VerticalBounds(int minY, int height) {
+ int top() {
+ return minY + height - 1;
+ }
+
+ int resolve(JsonElement anchor) throws IngestConversionException {
+ if (anchor != null && anchor.isJsonPrimitive()) {
+ return VanillaJson.integer(anchor, "vertical anchor");
+ }
+ JsonObject object = VanillaJson.object(anchor, "vertical anchor");
+ if (object.has("absolute")) {
+ return VanillaJson.integer(object.get("absolute"), "vertical anchor absolute");
+ }
+ if (object.has("above_bottom")) {
+ return minY + VanillaJson.integer(object.get("above_bottom"), "vertical anchor above_bottom");
+ }
+ if (object.has("below_top")) {
+ return top() - VanillaJson.integer(object.get("below_top"), "vertical anchor below_top");
+ }
+ throw new IngestConversionException("unsupported vertical anchor " + object);
+ }
+ }
+
+ record HeightBand(int min, int max, String distribution) {
+ }
+}
diff --git a/core/src/main/java/art/arcane/iris/pack/ingest/VanillaIntProviders.java b/core/src/main/java/art/arcane/iris/pack/ingest/VanillaIntProviders.java
new file mode 100644
index 000000000..9b9a1380f
--- /dev/null
+++ b/core/src/main/java/art/arcane/iris/pack/ingest/VanillaIntProviders.java
@@ -0,0 +1,184 @@
+/*
+ * Iris is a World Generator for Minecraft Bukkit Servers
+ * Copyright (c) 2022 Arcane Arts (Volmit Software)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package art.arcane.iris.pack.ingest;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+
+final class VanillaIntProviders {
+ private static final int MAX_ENUMERATED_VALUES = 4096;
+
+ private VanillaIntProviders() {
+ }
+
+ static IntStats stats(JsonElement provider) throws IngestConversionException {
+ if (provider != null && provider.isJsonPrimitive()) {
+ int value = VanillaJson.integer(provider, "int provider");
+ return new IntStats(value, value, value);
+ }
+ JsonObject object = VanillaJson.object(provider, "int provider");
+ String type = VanillaJson.type(object, "type");
+ return switch (type) {
+ case "minecraft:constant" -> stats(object.get("value"));
+ case "minecraft:uniform" -> uniform(bounds(object));
+ case "minecraft:biased_to_bottom" -> biasedToBottom(bounds(object));
+ case "minecraft:trapezoid" -> trapezoid(object);
+ case "minecraft:clamped" -> clamped(object);
+ case "minecraft:clamped_normal" -> clampedNormal(object);
+ case "minecraft:weighted_list" -> weightedList(object);
+ default -> throw new IngestConversionException("unsupported int provider " + (type.isEmpty() ? object.toString() : type));
+ };
+ }
+
+ static double probabilityOf(JsonElement provider, int value) throws IngestConversionException {
+ if (provider != null && provider.isJsonPrimitive()) {
+ return VanillaJson.integer(provider, "int provider") == value ? 1D : 0D;
+ }
+ JsonObject object = VanillaJson.object(provider, "int provider");
+ String type = VanillaJson.type(object, "type");
+ if (type.equals("minecraft:constant")) {
+ return probabilityOf(object.get("value"), value);
+ }
+ if (type.equals("minecraft:trapezoid")) {
+ return trapezoidProbability(object, value);
+ }
+ IntStats stats = stats(provider);
+ if (value < stats.min() || value > stats.max()) {
+ return 0D;
+ }
+ return 1D / ((long) stats.max() - stats.min() + 1L);
+ }
+
+ private static double trapezoidProbability(JsonObject object, int value) throws IngestConversionException {
+ int min = VanillaJson.integer(object.get("min"), "trapezoid.min");
+ int max = VanillaJson.integer(object.get("max"), "trapezoid.max");
+ int plateau = VanillaJson.integer(object, "plateau", 0, "trapezoid");
+ int range = max - min;
+ if (range < 0 || value < min || value > max) {
+ return 0D;
+ }
+ int plateauStart = Math.max(0, (range - plateau) / 2);
+ int first = range - plateauStart;
+ int offset = value - min;
+ long pairs = 0L;
+ for (int i = Math.max(0, offset - plateauStart); i <= Math.min(first, offset); i++) {
+ pairs++;
+ }
+ return pairs / ((first + 1D) * (plateauStart + 1D));
+ }
+
+ private static IntStats uniform(int[] bounds) {
+ return new IntStats((bounds[0] + bounds[1]) / 2.0D, bounds[0], bounds[1]);
+ }
+
+ private static IntStats biasedToBottom(int[] bounds) {
+ return new IntStats(bounds[0] + (bounds[1] - bounds[0]) / 4.0D, bounds[0], bounds[1]);
+ }
+
+ private static IntStats trapezoid(JsonObject object) throws IngestConversionException {
+ int min = VanillaJson.integer(object.get("min"), "trapezoid.min");
+ int max = VanillaJson.integer(object.get("max"), "trapezoid.max");
+ return new IntStats((min + max) / 2.0D, Math.min(min, max), Math.max(min, max));
+ }
+
+ private static IntStats clamped(JsonObject object) throws IngestConversionException {
+ int low = VanillaJson.integer(object.get("min_inclusive"), "clamped.min_inclusive");
+ int high = VanillaJson.integer(object.get("max_inclusive"), "clamped.max_inclusive");
+ JsonElement source = object.get("source");
+ int[] uniformSource = enumerableBounds(source);
+ if (uniformSource != null && (long) uniformSource[1] - uniformSource[0] < MAX_ENUMERATED_VALUES) {
+ double total = 0D;
+ int count = uniformSource[1] - uniformSource[0] + 1;
+ for (int value = uniformSource[0]; value <= uniformSource[1]; value++) {
+ total += clamp(value, low, high);
+ }
+ return new IntStats(total / count, clamp(uniformSource[0], low, high), clamp(uniformSource[1], low, high));
+ }
+ IntStats inner = stats(source);
+ return new IntStats(Math.max(low, Math.min(high, inner.mean())), clamp(inner.min(), low, high), clamp(inner.max(), low, high));
+ }
+
+ private static IntStats clampedNormal(JsonObject object) throws IngestConversionException {
+ double mean = VanillaJson.number(object.get("mean"), "clamped_normal.mean");
+ int low = (int) VanillaJson.number(object.has("min_inclusive") ? object.get("min_inclusive") : object.get("min"), "clamped_normal.min");
+ int high = (int) VanillaJson.number(object.has("max_inclusive") ? object.get("max_inclusive") : object.get("max"), "clamped_normal.max");
+ return new IntStats(Math.max(low, Math.min(high, mean)), low, high);
+ }
+
+ private static IntStats weightedList(JsonObject object) throws IngestConversionException {
+ JsonArray distribution = VanillaJson.array(object.get("distribution"), "weighted_list.distribution");
+ double weighted = 0D;
+ double totalWeight = 0D;
+ int min = Integer.MAX_VALUE;
+ int max = Integer.MIN_VALUE;
+ for (JsonElement entry : distribution) {
+ JsonObject entryObject = VanillaJson.object(entry, "weighted_list entry");
+ double weight = VanillaJson.number(entryObject, "weight", 1D, "weighted_list entry");
+ IntStats inner = stats(entryObject.get("data"));
+ weighted += inner.mean() * weight;
+ totalWeight += weight;
+ min = Math.min(min, inner.min());
+ max = Math.max(max, inner.max());
+ }
+ if (totalWeight <= 0D) {
+ throw new IngestConversionException("weighted_list has no weight");
+ }
+ return new IntStats(weighted / totalWeight, min, max);
+ }
+
+ private static int[] enumerableBounds(JsonElement source) throws IngestConversionException {
+ if (source != null && source.isJsonPrimitive()) {
+ int value = VanillaJson.integer(source, "clamped.source");
+ return new int[]{value, value};
+ }
+ if (source == null || !source.isJsonObject()) {
+ return null;
+ }
+ JsonObject object = source.getAsJsonObject();
+ String type = VanillaJson.type(object, "type");
+ if (type.equals("minecraft:uniform")) {
+ return bounds(object);
+ }
+ if (type.equals("minecraft:constant")) {
+ int value = VanillaJson.integer(object.get("value"), "clamped.source.value");
+ return new int[]{value, value};
+ }
+ return null;
+ }
+
+ private static int[] bounds(JsonObject object) throws IngestConversionException {
+ JsonObject holder = object.has("min_inclusive") || !object.has("value")
+ ? object
+ : VanillaJson.object(object.get("value"), "int provider value");
+ int min = VanillaJson.integer(holder.get("min_inclusive"), "int provider min_inclusive");
+ int max = VanillaJson.integer(holder.get("max_inclusive"), "int provider max_inclusive");
+ if (max < min) {
+ throw new IngestConversionException("int provider max_inclusive " + max + " is below min_inclusive " + min);
+ }
+ return new int[]{min, max};
+ }
+
+ private static int clamp(int value, int low, int high) {
+ return Math.max(low, Math.min(high, value));
+ }
+
+ record IntStats(double mean, int min, int max) {
+ }
+}
diff --git a/core/src/main/java/art/arcane/iris/pack/ingest/VanillaJson.java b/core/src/main/java/art/arcane/iris/pack/ingest/VanillaJson.java
new file mode 100644
index 000000000..9e4e1a4a5
--- /dev/null
+++ b/core/src/main/java/art/arcane/iris/pack/ingest/VanillaJson.java
@@ -0,0 +1,132 @@
+/*
+ * Iris is a World Generator for Minecraft Bukkit Servers
+ * Copyright (c) 2022 Arcane Arts (Volmit Software)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package art.arcane.iris.pack.ingest;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonPrimitive;
+
+import java.util.Locale;
+
+final class VanillaJson {
+ private VanillaJson() {
+ }
+
+ static String namespaced(String key) {
+ String normalized = key.trim().toLowerCase(Locale.ROOT);
+ if (normalized.startsWith("#")) {
+ normalized = normalized.substring(1);
+ }
+ return normalized.indexOf(':') < 0 ? "minecraft:" + normalized : normalized;
+ }
+
+ static JsonObject featureConfig(JsonElement config) throws IngestConversionException {
+ if (config == null || config.isJsonNull()) {
+ throw new IngestConversionException("feature config was not captured in the catalog");
+ }
+ JsonObject object = object(config, "feature config");
+ JsonElement nested = object.get("config");
+ if (nested != null && nested.isJsonObject() && !object.has("targets") && !object.has("to_place")) {
+ return nested.getAsJsonObject();
+ }
+ return object;
+ }
+
+ static JsonArray placement(JsonElement placement) throws IngestConversionException {
+ if (placement == null || placement.isJsonNull()) {
+ throw new IngestConversionException("placement modifiers were not captured in the catalog");
+ }
+ return array(placement, "placement modifiers");
+ }
+
+ static String type(JsonObject object, String field) {
+ JsonElement value = object.get(field);
+ if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) {
+ return "";
+ }
+ return namespaced(value.getAsString());
+ }
+
+ static JsonObject object(JsonElement element, String context) throws IngestConversionException {
+ if (element == null || !element.isJsonObject()) {
+ throw new IngestConversionException(context + " is not a JSON object");
+ }
+ return element.getAsJsonObject();
+ }
+
+ static JsonArray array(JsonElement element, String context) throws IngestConversionException {
+ if (element == null || !element.isJsonArray()) {
+ throw new IngestConversionException(context + " is not a JSON array");
+ }
+ return element.getAsJsonArray();
+ }
+
+ static String string(JsonElement element, String context) throws IngestConversionException {
+ if (element == null || !element.isJsonPrimitive() || !element.getAsJsonPrimitive().isString()) {
+ throw new IngestConversionException(context + " is not a string");
+ }
+ return element.getAsString();
+ }
+
+ static double number(JsonElement element, String context) throws IngestConversionException {
+ JsonPrimitive primitive = numeric(element, context);
+ double value = primitive.getAsDouble();
+ if (!Double.isFinite(value)) {
+ throw new IngestConversionException(context + " is not a finite number");
+ }
+ return value;
+ }
+
+ static int integer(JsonElement element, String context) throws IngestConversionException {
+ double value = number(element, context);
+ if (value != Math.rint(value) || value > Integer.MAX_VALUE || value < Integer.MIN_VALUE) {
+ throw new IngestConversionException(context + " is not an integer");
+ }
+ return (int) value;
+ }
+
+ static double number(JsonObject object, String field, double fallback, String context) throws IngestConversionException {
+ JsonElement value = object.get(field);
+ return value == null || value.isJsonNull() ? fallback : number(value, context + "." + field);
+ }
+
+ static int integer(JsonObject object, String field, int fallback, String context) throws IngestConversionException {
+ JsonElement value = object.get(field);
+ return value == null || value.isJsonNull() ? fallback : integer(value, context + "." + field);
+ }
+
+ private static JsonPrimitive numeric(JsonElement element, String context) throws IngestConversionException {
+ if (element == null || !element.isJsonPrimitive()) {
+ throw new IngestConversionException(context + " is not a number");
+ }
+ JsonPrimitive primitive = element.getAsJsonPrimitive();
+ if (primitive.isNumber()) {
+ return primitive;
+ }
+ if (primitive.isString()) {
+ try {
+ return new JsonPrimitive(Double.parseDouble(primitive.getAsString().trim()));
+ } catch (NumberFormatException e) {
+ throw new IngestConversionException(context + " is not a number: " + primitive.getAsString());
+ }
+ }
+ throw new IngestConversionException(context + " is not a number");
+ }
+}
diff --git a/core/src/main/java/art/arcane/iris/pack/ingest/VanillaPlacementChain.java b/core/src/main/java/art/arcane/iris/pack/ingest/VanillaPlacementChain.java
new file mode 100644
index 000000000..70624b952
--- /dev/null
+++ b/core/src/main/java/art/arcane/iris/pack/ingest/VanillaPlacementChain.java
@@ -0,0 +1,158 @@
+/*
+ * Iris is a World Generator for Minecraft Bukkit Servers
+ * Copyright (c) 2022 Arcane Arts (Volmit Software)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package art.arcane.iris.pack.ingest;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+
+import java.util.LinkedHashSet;
+import java.util.Set;
+
+final class VanillaPlacementChain {
+ private static final Set SURFACE_HEIGHTMAPS = Set.of("MOTION_BLOCKING", "MOTION_BLOCKING_NO_LEAVES", "WORLD_SURFACE", "WORLD_SURFACE_WG");
+ private static final int NOISE_SAMPLES = 201;
+
+ private final Set whitelist = new LinkedHashSet<>();
+ private double expectedPlacements = 1D;
+ private double surfaceHitChance = 1D;
+ private boolean surface;
+
+ private VanillaPlacementChain() {
+ }
+
+ static VanillaPlacementChain surfaceChain(JsonArray placement) throws IngestConversionException {
+ VanillaPlacementChain chain = new VanillaPlacementChain();
+ chain.read(placement);
+ if (!chain.surface) {
+ throw new IngestConversionException("placement does not snap to a surface heightmap");
+ }
+ return chain;
+ }
+
+ static Set filterWhitelist(JsonArray placement) throws IngestConversionException {
+ VanillaPlacementChain chain = new VanillaPlacementChain();
+ for (JsonElement modifier : placement) {
+ JsonObject object = VanillaJson.object(modifier, "placement modifier");
+ String type = VanillaJson.type(object, "type");
+ if (!type.equals("minecraft:block_predicate_filter")) {
+ throw new IngestConversionException("unsupported nested placement modifier " + type);
+ }
+ chain.collectWhitelist(object.get("predicate"));
+ }
+ return chain.whitelist;
+ }
+
+ double expectedPlacements() {
+ return expectedPlacements;
+ }
+
+ double surfaceHitChance() {
+ return surfaceHitChance;
+ }
+
+ Set whitelist() {
+ return whitelist;
+ }
+
+ private void read(JsonArray placement) throws IngestConversionException {
+ for (JsonElement modifier : placement) {
+ JsonObject object = VanillaJson.object(modifier, "placement modifier");
+ String type = VanillaJson.type(object, "type");
+ switch (type) {
+ case "minecraft:count" -> expectedPlacements *= Math.max(0D, VanillaIntProviders.stats(object.get("count")).mean());
+ case "minecraft:rarity_filter" -> expectedPlacements /= Math.max(1D, VanillaJson.number(object.get("chance"), "rarity_filter.chance"));
+ case "minecraft:random_chance" -> expectedPlacements *= clampProbability(VanillaJson.number(object.get("chance"), "random_chance.chance"));
+ case "minecraft:noise_threshold_count" -> expectedPlacements *= (VanillaJson.number(object.get("below_noise"), "noise_threshold_count.below_noise")
+ + VanillaJson.number(object.get("above_noise"), "noise_threshold_count.above_noise")) / 2D;
+ case "minecraft:noise_based_count" -> expectedPlacements *= noiseBasedMean(object);
+ case "minecraft:in_square", "minecraft:biome" -> {
+ }
+ case "minecraft:heightmap" -> readHeightmap(object);
+ case "minecraft:offset" -> readVerticalOffset(object.get("y"));
+ case "minecraft:random_offset" -> readVerticalOffset(object.get("y_spread"));
+ case "minecraft:block_predicate_filter" -> collectWhitelist(object.get("predicate"));
+ default -> throw new IngestConversionException("unsupported placement modifier " + (type.isEmpty() ? object.toString() : type));
+ }
+ }
+ }
+
+ private void readHeightmap(JsonObject object) throws IngestConversionException {
+ String heightmap = VanillaJson.string(object.get("heightmap"), "heightmap.heightmap");
+ if (!SURFACE_HEIGHTMAPS.contains(heightmap)) {
+ throw new IngestConversionException("heightmap " + heightmap + " is not a dry surface placement");
+ }
+ surface = true;
+ }
+
+ private void readVerticalOffset(JsonElement provider) throws IngestConversionException {
+ if (!surface || provider == null || provider.isJsonNull()) {
+ return;
+ }
+ surfaceHitChance *= VanillaIntProviders.probabilityOf(provider, 0);
+ }
+
+ private void collectWhitelist(JsonElement predicate) throws IngestConversionException {
+ if (predicate == null || !predicate.isJsonObject()) {
+ return;
+ }
+ JsonObject object = predicate.getAsJsonObject();
+ String type = VanillaJson.type(object, "type");
+ if (type.equals("minecraft:all_of") && object.get("predicates") != null && object.get("predicates").isJsonArray()) {
+ for (JsonElement inner : object.getAsJsonArray("predicates")) {
+ collectWhitelist(inner);
+ }
+ return;
+ }
+ if (!type.equals("minecraft:matching_blocks") || !isBelowOffset(object.get("offset"))) {
+ return;
+ }
+ JsonElement blocks = object.get("blocks");
+ if (blocks != null && blocks.isJsonArray()) {
+ for (JsonElement block : blocks.getAsJsonArray()) {
+ whitelist.add(VanillaJson.namespaced(VanillaJson.string(block, "matching_blocks.blocks")));
+ }
+ return;
+ }
+ whitelist.add(VanillaJson.namespaced(VanillaJson.string(blocks, "matching_blocks.blocks")));
+ }
+
+ private static boolean isBelowOffset(JsonElement offset) {
+ if (offset == null || !offset.isJsonArray() || offset.getAsJsonArray().size() != 3) {
+ return false;
+ }
+ JsonArray values = offset.getAsJsonArray();
+ return values.get(0).getAsInt() == 0 && values.get(1).getAsInt() == -1 && values.get(2).getAsInt() == 0;
+ }
+
+ private static double noiseBasedMean(JsonObject object) throws IngestConversionException {
+ double ratio = VanillaJson.number(object.get("noise_to_count_ratio"), "noise_based_count.noise_to_count_ratio");
+ double offset = VanillaJson.number(object, "noise_offset", 0D, "noise_based_count");
+ double total = 0D;
+ for (int sample = 0; sample < NOISE_SAMPLES; sample++) {
+ double noise = -1D + 2D * sample / (NOISE_SAMPLES - 1D);
+ total += Math.max(0D, Math.ceil((noise + offset) * ratio));
+ }
+ return total / NOISE_SAMPLES;
+ }
+
+ private static double clampProbability(double value) {
+ return Math.max(0D, Math.min(1D, value));
+ }
+}
diff --git a/core/src/main/java/art/arcane/iris/pack/ingest/VanillaRuleTests.java b/core/src/main/java/art/arcane/iris/pack/ingest/VanillaRuleTests.java
new file mode 100644
index 000000000..94b08683b
--- /dev/null
+++ b/core/src/main/java/art/arcane/iris/pack/ingest/VanillaRuleTests.java
@@ -0,0 +1,141 @@
+/*
+ * Iris is a World Generator for Minecraft Bukkit Servers
+ * Copyright (c) 2022 Arcane Arts (Volmit Software)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package art.arcane.iris.pack.ingest;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.SortedSet;
+import java.util.TreeSet;
+
+final class VanillaRuleTests {
+ private static final Map> BLOCK_TAGS = Map.of(
+ "minecraft:stone_ore_replaceables", List.of("minecraft:stone", "minecraft:granite", "minecraft:diorite", "minecraft:andesite"),
+ "minecraft:deepslate_ore_replaceables", List.of("minecraft:deepslate"),
+ "minecraft:height_specific_ore_replaceables", List.of("minecraft:tuff"),
+ "minecraft:base_stone_overworld", List.of("minecraft:stone", "minecraft:granite", "minecraft:diorite",
+ "minecraft:andesite", "minecraft:tuff", "minecraft:deepslate"),
+ "minecraft:base_stone_nether", List.of("minecraft:netherrack", "minecraft:basalt", "minecraft:blackstone"));
+
+ private VanillaRuleTests() {
+ }
+
+ static HostSet hosts(JsonElement ruleTest) throws IngestConversionException {
+ JsonObject object = VanillaJson.object(ruleTest, "ore target predicate");
+ String type = VanillaJson.type(object, "predicate_type");
+ return switch (type) {
+ case "minecraft:always_true" -> HostSet.ANY;
+ case "minecraft:tag_match" -> tag(VanillaJson.namespaced(VanillaJson.string(object.get("tag"), "tag_match.tag")));
+ case "minecraft:block_match" -> HostSet.of(Set.of(VanillaJson.namespaced(VanillaJson.string(object.get("block"), "block_match.block"))));
+ case "minecraft:blockstate_match" -> HostSet.of(Set.of(VanillaBlockStates.irisBlock(object.get("block_state")).get("block").getAsString()));
+ case "minecraft:any_of" -> anyOf(rules(object));
+ case "minecraft:all_of" -> allOf(rules(object));
+ default -> throw new IngestConversionException("unsupported ore target predicate " + (type.isEmpty() ? object.toString() : type));
+ };
+ }
+
+ private static HostSet tag(String tag) throws IngestConversionException {
+ List blocks = BLOCK_TAGS.get(tag);
+ if (blocks == null) {
+ throw new IngestConversionException("ore target uses block tag " + tag + " whose members are not known offline");
+ }
+ return HostSet.of(Set.copyOf(blocks));
+ }
+
+ private static HostSet anyOf(List rules) throws IngestConversionException {
+ SortedSet union = new TreeSet<>();
+ for (JsonElement rule : rules) {
+ HostSet hosts = hosts(rule);
+ if (hosts.any()) {
+ return HostSet.ANY;
+ }
+ union.addAll(hosts.blocks());
+ }
+ return HostSet.of(union);
+ }
+
+ private static HostSet allOf(List rules) throws IngestConversionException {
+ HostSet positive = null;
+ SortedSet negative = new TreeSet<>();
+ for (JsonElement rule : rules) {
+ JsonObject ruleObject = VanillaJson.object(rule, "all_of rule");
+ String type = VanillaJson.type(ruleObject, "predicate_type");
+ if (type.equals("minecraft:height_match")) {
+ continue;
+ }
+ if (type.equals("minecraft:not")) {
+ HostSet excluded = hosts(ruleObject.get("rule"));
+ if (excluded.any()) {
+ throw new IngestConversionException("ore target predicate never matches");
+ }
+ negative.addAll(excluded.blocks());
+ continue;
+ }
+ positive = intersect(positive, hosts(ruleObject));
+ }
+ if (positive == null) {
+ throw new IngestConversionException("ore target all_of has no block constraint");
+ }
+ if (positive.any()) {
+ if (!negative.isEmpty()) {
+ throw new IngestConversionException("ore target excludes blocks from an unrestricted host set");
+ }
+ return HostSet.ANY;
+ }
+ SortedSet result = new TreeSet<>(positive.blocks());
+ result.removeAll(negative);
+ return HostSet.of(result);
+ }
+
+ private static HostSet intersect(HostSet current, HostSet next) {
+ if (current == null || current.any()) {
+ return next;
+ }
+ if (next.any()) {
+ return current;
+ }
+ SortedSet result = new TreeSet<>(current.blocks());
+ result.retainAll(next.blocks());
+ return HostSet.of(result);
+ }
+
+ private static List rules(JsonObject object) throws IngestConversionException {
+ JsonElement value = object.has("rules") ? object.get("rules") : object.get("predicates");
+ JsonArray array = VanillaJson.array(value, "ore target predicate rules");
+ List rules = new ArrayList<>(array.size());
+ for (JsonElement rule : array) {
+ rules.add(rule);
+ }
+ return rules;
+ }
+
+ record HostSet(boolean any, SortedSet blocks) {
+ static final HostSet ANY = new HostSet(true, Collections.emptySortedSet());
+
+ static HostSet of(Set blocks) {
+ return new HostSet(false, Collections.unmodifiableSortedSet(new TreeSet<>(blocks)));
+ }
+ }
+}
diff --git a/core/src/test/java/art/arcane/iris/pack/ingest/FeatureConverterFormatTest.java b/core/src/test/java/art/arcane/iris/pack/ingest/FeatureConverterFormatTest.java
new file mode 100644
index 000000000..065f284b7
--- /dev/null
+++ b/core/src/test/java/art/arcane/iris/pack/ingest/FeatureConverterFormatTest.java
@@ -0,0 +1,147 @@
+/*
+ * Iris is a World Generator for Minecraft Bukkit Servers
+ * Copyright (c) 2022 Arcane Arts (Volmit Software)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package art.arcane.iris.pack.ingest;
+
+import art.arcane.iris.pack.ingest.catalog.CatalogPlacedFeature;
+import art.arcane.iris.pack.ingest.catalog.WorldgenCatalog;
+import art.arcane.iris.pack.ingest.catalog.WorldgenCatalogIndex;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import org.junit.Test;
+
+import java.util.List;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+public class FeatureConverterFormatTest {
+ private static final VanillaHeights.VerticalBounds OVERWORLD = new VanillaHeights.VerticalBounds(-64, 384);
+ private static final String PATCH_TAIL = """
+ {"type": "minecraft:offset",
+ "x": {"type": "minecraft:trapezoid", "max": 7, "min": -7, "plateau": 0},
+ "y": {"type": "minecraft:trapezoid", "max": 3, "min": -3, "plateau": 0},
+ "z": {"type": "minecraft:trapezoid", "max": 7, "min": -7, "plateau": 0}},
+ {"type": "minecraft:block_predicate_filter", "predicate": {"type": "minecraft:matching_block_tag", "tag": "minecraft:air"}}
+ """;
+
+ @Test
+ public void currentVanillaIronOreSplitsIntoStoneAndDeepslateDeposits() throws Exception {
+ CatalogPlacedFeature iron = feature("minecraft:ore", """
+ {"type": "minecraft:ore", "discard_chance_on_air_exposure": 0.0, "size": 9, "targets": [
+ {"state": "minecraft:iron_ore", "target": {"predicate_type": "minecraft:any_of", "rules": [
+ {"predicate_type": "minecraft:all_of", "rules": [
+ {"predicate_type": "minecraft:tag_match", "tag": "minecraft:height_specific_ore_replaceables"},
+ {"max_inclusive": 2031, "min_inclusive": 0, "predicate_type": "minecraft:height_match"}]},
+ {"predicate_type": "minecraft:all_of", "rules": [
+ {"predicate_type": "minecraft:not", "rule": {"predicate_type": "minecraft:tag_match", "tag": "minecraft:height_specific_ore_replaceables"}},
+ {"predicate_type": "minecraft:tag_match", "tag": "minecraft:stone_ore_replaceables"}]}]}},
+ {"state": "minecraft:deepslate_iron_ore", "target": {"predicate_type": "minecraft:any_of", "rules": [
+ {"predicate_type": "minecraft:all_of", "rules": [
+ {"predicate_type": "minecraft:tag_match", "tag": "minecraft:height_specific_ore_replaceables"},
+ {"max_inclusive": 8, "min_inclusive": -2032, "predicate_type": "minecraft:height_match"}]},
+ {"predicate_type": "minecraft:all_of", "rules": [
+ {"predicate_type": "minecraft:not", "rule": {"predicate_type": "minecraft:tag_match", "tag": "minecraft:height_specific_ore_replaceables"}},
+ {"predicate_type": "minecraft:tag_match", "tag": "minecraft:deepslate_ore_replaceables"}]}]}}]}
+ """, """
+ [{"type": "minecraft:count", "count": 10}, {"type": "minecraft:in_square"},
+ {"type": "minecraft:height_range", "height": {"type": "minecraft:trapezoid", "max_inclusive": {"absolute": 56}, "min_inclusive": {"absolute": -24}}},
+ {"type": "minecraft:biome"}]
+ """);
+
+ List deposits = OreDepositConverter.convert(iron, OVERWORLD, -64);
+
+ assertEquals(2, deposits.size());
+ assertEquals("minecraft:iron_ore", deposits.get(0).getAsJsonArray("palette").get(0).getAsJsonObject().get("block").getAsString());
+ assertEquals(40, deposits.get(0).get("minHeight").getAsInt());
+ assertEquals(120, deposits.get(0).get("maxHeight").getAsInt());
+ assertEquals("TRIANGLE", deposits.get(0).get("heightDistribution").getAsString());
+ assertEquals(10, deposits.get(0).get("maxPerChunk").getAsInt());
+ assertEquals(List.of("minecraft:deepslate"), IngestJson.strings(deposits.get(1).get("replaceableBlocks")));
+ }
+
+ @Test
+ public void environmentScanOrePassesThroughWithTheModifierNamed() {
+ CatalogPlacedFeature buried = feature("minecraft:ore",
+ "{\"size\": 4, \"targets\": [{\"state\": \"minecraft:stone\", \"target\": {\"predicate_type\": \"minecraft:always_true\"}}]}",
+ "[{\"type\": \"minecraft:environment_scan\", \"direction_of_search\": \"down\", \"max_steps\": 16, \"target_condition\": {\"type\": \"minecraft:solid\"}}]");
+
+ IngestConversionException failure = assertThrows(IngestConversionException.class, () -> OreDepositConverter.convert(buried, OVERWORLD, -64));
+
+ assertTrue(failure.getMessage(), failure.getMessage().contains("minecraft:environment_scan"));
+ }
+
+ @Test
+ public void densePatchCountsBeforeAndAfterTheHeightmapSaturateCoverage() throws Exception {
+ CatalogPlacedFeature lavender = feature(PatchDecoratorConverter.SIMPLE_BLOCK,
+ "{\"type\": \"minecraft:simple_block\", \"to_place\": {\"id\": \"examplemod:lavender\"}}",
+ "[{\"type\": \"minecraft:count\", \"count\": 15}, {\"type\": \"minecraft:in_square\"},"
+ + "{\"type\": \"minecraft:heightmap\", \"heightmap\": \"WORLD_SURFACE_WG\"}, {\"type\": \"minecraft:biome\"},"
+ + "{\"type\": \"minecraft:count\", \"count\": 96}," + PATCH_TAIL + "]");
+
+ JsonObject decorator = PatchDecoratorConverter.convert(lavender, index());
+
+ assertEquals(1.0D - Math.exp(-(15.0D * 96.0D * 0.25D) / 256.0D), decorator.get("chance").getAsDouble(), 0.000001D);
+ assertTrue(decorator.get("chance").getAsDouble() < 1.0D);
+ }
+
+ @Test
+ public void unnamespacedTypesAndClampedCountsConvert() throws Exception {
+ CatalogPlacedFeature flowers = feature("simple_block", "{\"type\": \"simple_block\", \"to_place\": {\"id\": \"minecraft:poppy\"}}", """
+ [{"type": "rarity_filter", "chance": 2}, {"type": "minecraft:in_square"},
+ {"type": "count", "count": {"type": "minecraft:clamped", "min_inclusive": 0, "max_inclusive": 64,
+ "source": {"type": "minecraft:uniform", "min_inclusive": -3, "max_inclusive": 1}}},
+ {"type": "minecraft:heightmap", "heightmap": "MOTION_BLOCKING_NO_LEAVES"},
+ {"type": "minecraft:offset", "x": {"type": "minecraft:trapezoid", "plateau": 0, "min": -7, "max": 7},
+ "y": {"type": "minecraft:trapezoid", "plateau": 0, "min": -3, "max": 3}, "z": {"type": "minecraft:trapezoid", "plateau": 0, "min": -7, "max": 7}},
+ {"type": "minecraft:block_predicate_filter", "predicate": {"type": "minecraft:all_of", "predicates": [
+ {"type": "minecraft:matching_blocks", "blocks": ["minecraft:grass_block"], "offset": [0, -1, 0]}]}},
+ {"type": "minecraft:biome"}]
+ """);
+
+ JsonObject decorator = PatchDecoratorConverter.convert(flowers, index());
+
+ assertEquals(1.0D - Math.exp(-(0.5D * 0.2D * 0.25D) / 256.0D), decorator.get("chance").getAsDouble(), 0.000001D);
+ assertEquals("minecraft:grass_block", decorator.getAsJsonArray("whitelist").get(0).getAsJsonObject().get("block").getAsString());
+ }
+
+ @Test
+ public void underwaterAndRegistryProviderPatchesPassThrough() {
+ CatalogPlacedFeature seagrass = feature(PatchDecoratorConverter.SIMPLE_BLOCK,
+ "{\"to_place\": {\"id\": \"minecraft:seagrass\"}}",
+ "[{\"type\": \"minecraft:heightmap\", \"heightmap\": \"OCEAN_FLOOR_WG\"}]");
+ CatalogPlacedFeature plains = feature(PatchDecoratorConverter.SIMPLE_BLOCK,
+ "{\"type\": \"minecraft:simple_block\", \"to_place\": \"minecraft:flower_plain\"}",
+ "[{\"type\": \"minecraft:heightmap\", \"heightmap\": \"MOTION_BLOCKING\"}]");
+
+ assertThrows(IngestConversionException.class, () -> PatchDecoratorConverter.convert(seagrass, index()));
+ IngestConversionException reference = assertThrows(IngestConversionException.class, () -> PatchDecoratorConverter.convert(plains, index()));
+ assertTrue(reference.getMessage(), reference.getMessage().contains("minecraft:flower_plain"));
+ }
+
+ private static CatalogPlacedFeature feature(String type, String config, String placement) {
+ return new CatalogPlacedFeature("examplemod:sample", "examplemod:sample", type,
+ JsonParser.parseString(config), JsonParser.parseString(placement));
+ }
+
+ private static WorldgenCatalogIndex index() {
+ return WorldgenCatalogIndex.of(new WorldgenCatalog(WorldgenCatalog.FORMAT, "26.3", "fabric", 0L, "",
+ List.of(), List.of(), List.of(), List.of(), List.of(), List.of()));
+ }
+}
diff --git a/core/src/test/java/art/arcane/iris/pack/ingest/ModIngestFixture.java b/core/src/test/java/art/arcane/iris/pack/ingest/ModIngestFixture.java
new file mode 100644
index 000000000..70a11b2e9
--- /dev/null
+++ b/core/src/test/java/art/arcane/iris/pack/ingest/ModIngestFixture.java
@@ -0,0 +1,411 @@
+/*
+ * Iris is a World Generator for Minecraft Bukkit Servers
+ * Copyright (c) 2022 Arcane Arts (Volmit Software)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package art.arcane.iris.pack.ingest;
+
+import art.arcane.iris.pack.ingest.catalog.CatalogBiome;
+import art.arcane.iris.pack.ingest.catalog.CatalogDimensionType;
+import art.arcane.iris.pack.ingest.catalog.CatalogNamespace;
+import art.arcane.iris.pack.ingest.catalog.CatalogPlacedFeature;
+import art.arcane.iris.pack.ingest.catalog.CatalogStructure;
+import art.arcane.iris.pack.ingest.catalog.WorldgenCatalog;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+
+final class ModIngestFixture {
+ static final String MOD = "examplemod";
+ static final String OTHER_MOD = "othermod";
+ static final int ORE_STEP = 6;
+ static final int VEGETAL_STEP = 9;
+ private static final List OVERWORLD = List.of("minecraft:is_overworld");
+ private static final String STONE_THEN_DEEPSLATE_TIN = """
+ {
+ "type": "minecraft:ore",
+ "discard_chance_on_air_exposure": 0.0,
+ "size": 7,
+ "targets": [
+ {
+ "state": "examplemod:tin_ore",
+ "target": {
+ "predicate_type": "minecraft:any_of",
+ "rules": [
+ {
+ "predicate_type": "minecraft:all_of",
+ "rules": [
+ {"predicate_type": "minecraft:tag_match", "tag": "minecraft:height_specific_ore_replaceables"},
+ {"predicate_type": "minecraft:height_match", "min_inclusive": 0, "max_inclusive": 2031}
+ ]
+ },
+ {
+ "predicate_type": "minecraft:all_of",
+ "rules": [
+ {"predicate_type": "minecraft:not", "rule": {"predicate_type": "minecraft:tag_match", "tag": "minecraft:height_specific_ore_replaceables"}},
+ {"predicate_type": "minecraft:tag_match", "tag": "minecraft:stone_ore_replaceables"}
+ ]
+ }
+ ]
+ }
+ },
+ {
+ "state": {"id": "examplemod:deepslate_tin_ore"},
+ "target": {
+ "predicate_type": "minecraft:any_of",
+ "rules": [
+ {
+ "predicate_type": "minecraft:all_of",
+ "rules": [
+ {"predicate_type": "minecraft:tag_match", "tag": "minecraft:height_specific_ore_replaceables"},
+ {"predicate_type": "minecraft:height_match", "min_inclusive": -2032, "max_inclusive": 8}
+ ]
+ },
+ {
+ "predicate_type": "minecraft:all_of",
+ "rules": [
+ {"predicate_type": "minecraft:not", "rule": {"predicate_type": "minecraft:tag_match", "tag": "minecraft:height_specific_ore_replaceables"}},
+ {"predicate_type": "minecraft:tag_match", "tag": "minecraft:deepslate_ore_replaceables"}
+ ]
+ }
+ ]
+ }
+ }
+ ]
+ }
+ """;
+ private static final String TIN_PLACEMENT = """
+ [
+ {"type": "minecraft:count", "count": 12},
+ {"type": "minecraft:rarity_filter", "chance": 2},
+ {"type": "minecraft:in_square"},
+ {"type": "minecraft:height_range", "height": {"type": "minecraft:uniform", "min_inclusive": {"above_bottom": 16}, "max_inclusive": {"below_top": 20}}},
+ {"type": "minecraft:biome"}
+ ]
+ """;
+ private static final String RUBY_CONFIG = """
+ {
+ "discard_chance_on_air_exposure": 0.5,
+ "size": 3,
+ "targets": [
+ {"state": {"Name": "examplemod:ruby_ore", "Properties": {"lit": "false"}}, "target": {"predicate_type": "minecraft:block_match", "block": "minecraft:stone"}}
+ ]
+ }
+ """;
+ private static final String RUBY_PLACEMENT = """
+ [
+ {"type": "minecraft:count", "count": {"type": "minecraft:uniform", "min_inclusive": 1, "max_inclusive": 3}},
+ {"type": "minecraft:in_square"},
+ {"type": "minecraft:height_range", "height": {"type": "minecraft:trapezoid", "min_inclusive": {"absolute": -16}, "max_inclusive": {"absolute": 48}, "plateau": 0}},
+ {"type": "minecraft:biome"}
+ ]
+ """;
+ private static final String SAPPHIRE_CONFIG = """
+ {"size": 4, "targets": [{"state": "examplemod:sapphire_ore", "target": {"predicate_type": "minecraft:tag_match", "tag": "minecraft:base_stone_overworld"}}]}
+ """;
+ private static final String SAPPHIRE_PLACEMENT = """
+ [
+ {"type": "count", "count": 2},
+ {"type": "minecraft:height_range", "height": {"type": "minecraft:uniform", "min_inclusive": {"absolute": 0}, "max_inclusive": {"absolute": 32}}}
+ ]
+ """;
+ private static final String GLOW_FLOWERS_CONFIG = """
+ {
+ "type": "minecraft:simple_block",
+ "to_place": {
+ "type": "minecraft:weighted",
+ "entries": [
+ {"data": "examplemod:glowcap", "weight": 3},
+ {"data": {"id": "examplemod:glowbell", "properties": {"lit": "true"}}, "weight": 1}
+ ]
+ }
+ }
+ """;
+ private static final String GLOW_FLOWERS_PLACEMENT = """
+ [
+ {"type": "minecraft:rarity_filter", "chance": 4},
+ {"type": "minecraft:in_square"},
+ {"type": "minecraft:heightmap", "heightmap": "MOTION_BLOCKING"},
+ {"type": "minecraft:biome"},
+ {"type": "minecraft:count", "count": 32},
+ {"type": "minecraft:offset", "x": {"type": "minecraft:trapezoid", "min": -7, "max": 7, "plateau": 0}, "y": {"type": "minecraft:trapezoid", "min": -3, "max": 3, "plateau": 0}, "z": {"type": "minecraft:trapezoid", "min": -7, "max": 7, "plateau": 0}},
+ {"type": "minecraft:block_predicate_filter", "predicate": {"type": "minecraft:all_of", "predicates": [{"type": "minecraft:matching_block_tag", "tag": "minecraft:air"}, {"type": "minecraft:matching_blocks", "blocks": "minecraft:grass_block", "offset": [0, -1, 0]}]}}
+ ]
+ """;
+ private static final String BLUEBELL_PATCH_CONFIG = """
+ {
+ "tries": 64,
+ "xz_spread": 7,
+ "y_spread": 3,
+ "feature": {
+ "feature": {"type": "minecraft:simple_block", "config": {"to_place": {"type": "minecraft:simple_state_provider", "state": {"Name": "examplemod:bluebell"}}}},
+ "placement": [{"type": "minecraft:block_predicate_filter", "predicate": {"type": "minecraft:matching_blocks", "blocks": ["minecraft:air"]}}]
+ }
+ }
+ """;
+ private static final String BLUEBELL_PATCH_PLACEMENT = """
+ [
+ {"type": "minecraft:rarity_filter", "chance": 2},
+ {"type": "minecraft:in_square"},
+ {"type": "minecraft:heightmap", "heightmap": "MOTION_BLOCKING"},
+ {"type": "minecraft:biome"}
+ ]
+ """;
+ private static final String BURIED_TIN_PLACEMENT = """
+ [
+ {"type": "minecraft:count", "count": 2},
+ {"type": "minecraft:environment_scan", "direction_of_search": "down", "max_steps": 8, "target_condition": {"type": "minecraft:solid"}}
+ ]
+ """;
+ private static final String WEIRD_ORE_CONFIG = """
+ {"size": 5, "targets": [{"state": "examplemod:weird_ore", "target": {"predicate_type": "minecraft:tag_match", "tag": "examplemod:custom_stone"}}]}
+ """;
+
+ private ModIngestFixture() {
+ }
+
+ static Path writeSourcePack(Path root) throws IOException {
+ Path pack = root.resolve("source");
+ write(pack, "dimensions/overworld.json", """
+ {
+ "name": "Overworld",
+ "environment": "NORMAL",
+ "regions": ["hot", "temperate"],
+ "dimensionHeight": {"min": -128, "max": 320},
+ "deposits": [{"minHeight": 10, "maxHeight": 40, "palette": [{"block": "minecraft:coal_ore"}]}],
+ "importedStructures": {"disabled": ["examplemod:old_ruin"]},
+ "importedFeatures": {"enabled": false, "disabled": ["minecraft:ore_diamond"], "disabledSteps": ["LAKES"]}
+ }
+ """);
+ write(pack, "regions/hot.json", """
+ {
+ "name": "Hot",
+ "landBiomes": ["hot/desert", "hot/savanna"],
+ "seaBiomes": ["hot/warm-ocean"],
+ "shoreBiomes": ["hot/beach"],
+ "caveBiomes": ["caves/dripstone"]
+ }
+ """);
+ write(pack, "regions/temperate.json", """
+ {
+ "name": "Temperate",
+ "landBiomes": ["temperate/forest", "temperate/plains"],
+ "seaBiomes": ["temperate/ocean"],
+ "shoreBiomes": ["temperate/beach"]
+ }
+ """);
+ write(pack, "biomes/hot/desert.json", """
+ {
+ "name": "Desert",
+ "derivative": "minecraft:desert",
+ "layers": [{"palette": [{"block": "minecraft:sand"}]}],
+ "objects": [{"chance": 0.1, "place": ["desert/cactus"]}]
+ }
+ """);
+ write(pack, "biomes/hot/savanna.json", """
+ {"name": "Savanna", "derivative": "minecraft:savanna", "children": ["hot/desert"]}
+ """);
+ write(pack, "biomes/hot/warm-ocean.json", """
+ {"name": "Warm Ocean", "derivative": "minecraft:warm_ocean"}
+ """);
+ write(pack, "biomes/hot/beach.json", """
+ {"name": "Hot Beach", "derivative": "minecraft:beach"}
+ """);
+ write(pack, "biomes/caves/dripstone.json", """
+ {"name": "Dripstone", "derivative": "minecraft:dripstone_caves"}
+ """);
+ write(pack, "biomes/temperate/forest.json", """
+ {
+ "name": "Forest",
+ "derivative": "minecraft:forest",
+ "vanillaDerivative": "minecraft:forest",
+ "customDerivitives": [{"id": "forest", "grassColor": "#91BD59"}],
+ "biomeScatter": ["minecraft:birch_forest"],
+ "children": ["temperate/plains"],
+ "objects": [{"chance": 0.4, "place": ["trees/oak"]}],
+ "proceduralObjects": {"trees": [{"chance": 0.2}], "ruins": [{"chance": 0.01}]},
+ "decorators": [{"chance": 0.3, "palette": [{"block": "minecraft:short_grass"}]}]
+ }
+ """);
+ write(pack, "biomes/temperate/plains.json", """
+ {"name": "Plains", "derivative": "plains"}
+ """);
+ write(pack, "biomes/temperate/ocean.json", """
+ {"name": "Ocean", "derivative": "minecraft:ocean"}
+ """);
+ write(pack, "biomes/temperate/beach.json", """
+ {"name": "Beach", "derivative": "minecraft:beach"}
+ """);
+ write(pack, "biomes/unused/orphan.json", """
+ {"name": "Orphan", "derivative": "minecraft:forest"}
+ """);
+ write(pack, ".git/HEAD", "ref: refs/heads/main\n");
+ write(pack, "source.code-workspace", "{}");
+ return pack;
+ }
+
+ static WorldgenCatalog catalog() {
+ List biomes = new ArrayList<>();
+ List sharedOres = List.of("minecraft:ore_iron", MOD + ":ore_tin", MOD + ":ore_tin_buried");
+ biomes.add(biome("minecraft:desert", 2.0F, 0.0F, OVERWORLD, steps(ORE_STEP, concat(sharedOres, MOD + ":ore_sapphire"))));
+ biomes.add(biome("minecraft:savanna", 1.2F, 0.4F, OVERWORLD, steps(ORE_STEP, sharedOres)));
+ biomes.add(biome("minecraft:warm_ocean", 0.5F, 0.5F, List.of("minecraft:is_overworld", "minecraft:is_ocean"), steps(ORE_STEP, sharedOres)));
+ biomes.add(biome("minecraft:ocean", 0.5F, 0.5F, List.of("minecraft:is_overworld", "minecraft:is_ocean"), steps(ORE_STEP, sharedOres)));
+ biomes.add(biome("minecraft:beach", 0.8F, 0.4F, List.of("minecraft:is_overworld", "minecraft:is_beach"), steps(ORE_STEP, sharedOres)));
+ biomes.add(biome("minecraft:dripstone_caves", 0.8F, 0.4F, OVERWORLD, steps(ORE_STEP, sharedOres)));
+ biomes.add(biome("minecraft:forest", 0.7F, 0.8F, OVERWORLD, merge(steps(ORE_STEP, sharedOres),
+ steps(VEGETAL_STEP, List.of("minecraft:trees_birch_and_oak", MOD + ":glow_flowers")))));
+ biomes.add(biome("minecraft:plains", 0.8F, 0.4F, OVERWORLD, merge(steps(ORE_STEP, sharedOres),
+ steps(VEGETAL_STEP, List.of(MOD + ":bluebell_patch", OTHER_MOD + ":daisies")))));
+ biomes.add(biome("minecraft:river", 0.5F, 0.5F, List.of("minecraft:is_overworld", "minecraft:is_river"), steps(ORE_STEP, sharedOres)));
+ biomes.add(biome("minecraft:nether_wastes", 2.0F, 0.0F, List.of("minecraft:is_nether"), steps(ORE_STEP, List.of())));
+ biomes.add(biome(MOD + ":scorched_mesa", 1.9F, 0.05F, OVERWORLD, steps(ORE_STEP, concat(sharedOres, MOD + ":ore_ruby"))));
+ biomes.add(biome(MOD + ":maple_woods", 0.6F, 0.8F, List.of("minecraft:is_overworld", "c:is_forest"), merge(steps(ORE_STEP, sharedOres),
+ steps(VEGETAL_STEP, List.of(MOD + ":maple_trees", MOD + ":glow_flowers", "minecraft:patch_grass")))));
+ biomes.add(biome(MOD + ":coral_depths", 0.5F, 0.5F, List.of("minecraft:is_overworld", "minecraft:is_ocean"), steps(ORE_STEP, sharedOres)));
+ biomes.add(biome(MOD + ":dune_beach", 1.8F, 0.1F, List.of("minecraft:is_overworld", "minecraft:is_beach"), steps(ORE_STEP, sharedOres)));
+ biomes.add(biome(MOD + ":crystal_caves", 0.8F, 0.4F, List.of("minecraft:is_overworld", "c:is_cave"),
+ steps(ORE_STEP, concat(sharedOres, MOD + ":weird_ore"))));
+ biomes.add(biome(MOD + ":misty_river", 0.5F, 0.5F, List.of("minecraft:is_overworld", "minecraft:is_river"), steps(ORE_STEP, sharedOres)));
+ biomes.add(biome(MOD + ":ashen_wastes", 2.0F, 0.0F, List.of("minecraft:is_nether"), steps(ORE_STEP, List.of(MOD + ":nether_ore"))));
+ biomes.add(biome(MOD + ":void_garden", 0.5F, 0.5F, List.of(), steps(ORE_STEP, List.of())));
+ biomes.add(biome(OTHER_MOD + ":bamboo_marsh", 0.9F, 0.9F, OVERWORLD, steps(VEGETAL_STEP, List.of(OTHER_MOD + ":daisies"))));
+
+ List features = new ArrayList<>();
+ features.add(feature("minecraft:ore_iron", "minecraft:ore", "{\"size\": 9, \"targets\": []}", "[]"));
+ features.add(feature(MOD + ":ore_tin", "minecraft:ore", STONE_THEN_DEEPSLATE_TIN, TIN_PLACEMENT));
+ features.add(feature(MOD + ":ore_tin_buried", "minecraft:ore", STONE_THEN_DEEPSLATE_TIN, BURIED_TIN_PLACEMENT));
+ features.add(feature(MOD + ":ore_ruby", "minecraft:scattered_ore", RUBY_CONFIG, RUBY_PLACEMENT));
+ features.add(feature(MOD + ":ore_sapphire", "minecraft:ore", SAPPHIRE_CONFIG, SAPPHIRE_PLACEMENT));
+ features.add(feature(MOD + ":weird_ore", "minecraft:ore", WEIRD_ORE_CONFIG, SAPPHIRE_PLACEMENT));
+ features.add(feature(MOD + ":nether_ore", "minecraft:ore", WEIRD_ORE_CONFIG, "[]"));
+ features.add(feature(MOD + ":glow_flowers", "minecraft:simple_block", GLOW_FLOWERS_CONFIG, GLOW_FLOWERS_PLACEMENT));
+ features.add(feature(MOD + ":bluebell_patch", "minecraft:random_patch", BLUEBELL_PATCH_CONFIG, BLUEBELL_PATCH_PLACEMENT));
+ features.add(feature(MOD + ":maple_trees", "minecraft:tree", "{\"trunk_provider\": {}}", "[]"));
+ features.add(feature(OTHER_MOD + ":daisies", "minecraft:simple_block",
+ "{\"to_place\": {\"id\": \"othermod:daisy\"}}", GLOW_FLOWERS_PLACEMENT));
+
+ List structures = List.of(
+ structure(MOD + ":maple_tower", List.of(MOD + ":maple_woods")),
+ structure(MOD + ":old_ruin", List.of("minecraft:plains")),
+ structure(MOD + ":nether_fort", List.of(MOD + ":ashen_wastes")),
+ structure("minecraft:village_plains", List.of("minecraft:plains")));
+ return new WorldgenCatalog(WorldgenCatalog.FORMAT, "26.3", "fabric", 1000L, "fingerprint-1",
+ List.of(new CatalogNamespace(MOD, "Example Mod", "1.0.0"), new CatalogNamespace(OTHER_MOD, "Other Mod", "2.0.0")),
+ biomes, features, structures,
+ List.of(new CatalogDimensionType("minecraft:overworld", -64, 384, 384, null)),
+ List.of());
+ }
+
+ static ModIngestRequest request(Path source, Path target, Set namespaces, boolean replace) {
+ return new ModIngestRequest(source, target, "overworld", catalog(), namespaces, replace);
+ }
+
+ static JsonObject readJson(Path file) throws IOException {
+ return JsonParser.parseString(Files.readString(file, StandardCharsets.UTF_8)).getAsJsonObject();
+ }
+
+ static List strings(JsonElement element) {
+ List values = new ArrayList<>();
+ if (element == null || !element.isJsonArray()) {
+ return values;
+ }
+ for (JsonElement value : element.getAsJsonArray()) {
+ values.add(value.getAsString());
+ }
+ return values;
+ }
+
+ static JsonObject findByPaletteBlock(JsonArray entries, String block) {
+ for (JsonElement entry : entries) {
+ if (!entry.isJsonObject()) {
+ continue;
+ }
+ JsonArray palette = entry.getAsJsonObject().getAsJsonArray("palette");
+ if (palette == null) {
+ continue;
+ }
+ for (JsonElement paletteEntry : palette) {
+ if (block.equals(paletteEntry.getAsJsonObject().get("block").getAsString())) {
+ return entry.getAsJsonObject();
+ }
+ }
+ }
+ return null;
+ }
+
+ static ModIngestItem item(ModIngestReport report, String sourceKey) {
+ for (ModIngestItem item : report.items()) {
+ if (item.sourceKey().equals(sourceKey)) {
+ return item;
+ }
+ }
+ return null;
+ }
+
+ static void write(Path root, String relative, String content) throws IOException {
+ Path file = root.resolve(relative);
+ Files.createDirectories(file.getParent());
+ Files.writeString(file, content, StandardCharsets.UTF_8);
+ }
+
+ private static CatalogBiome biome(String key, float temperature, float downfall, List tags, List> steps) {
+ return new CatalogBiome(key, temperature, downfall, true, tags, steps, List.of(), null);
+ }
+
+ private static CatalogPlacedFeature feature(String key, String type, String config, String placement) {
+ return new CatalogPlacedFeature(key, key, type, JsonParser.parseString(config), JsonParser.parseString(placement));
+ }
+
+ private static CatalogStructure structure(String key, List biomes) {
+ return new CatalogStructure(key, "minecraft:jigsaw", "surface_structures", "beard_thin", "", biomes, List.of());
+ }
+
+ private static List> steps(int step, List keys) {
+ List> steps = new ArrayList<>(11);
+ for (int index = 0; index < 11; index++) {
+ steps.add(index == step ? keys : List.of());
+ }
+ return steps;
+ }
+
+ private static List> merge(List> first, List> second) {
+ List> merged = new ArrayList<>(11);
+ for (int index = 0; index < 11; index++) {
+ merged.add(concat(first.get(index), second.get(index)));
+ }
+ return merged;
+ }
+
+ private static List concat(List first, List second) {
+ List values = new ArrayList<>(first);
+ values.addAll(second);
+ return values;
+ }
+
+ private static List concat(List first, String extra) {
+ return concat(first, List.of(extra));
+ }
+}
diff --git a/core/src/test/java/art/arcane/iris/pack/ingest/ModIngesterBiomeTest.java b/core/src/test/java/art/arcane/iris/pack/ingest/ModIngesterBiomeTest.java
new file mode 100644
index 000000000..ec476eb8c
--- /dev/null
+++ b/core/src/test/java/art/arcane/iris/pack/ingest/ModIngesterBiomeTest.java
@@ -0,0 +1,127 @@
+/*
+ * Iris is a World Generator for Minecraft Bukkit Servers
+ * Copyright (c) 2022 Arcane Arts (Volmit Software)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package art.arcane.iris.pack.ingest;
+
+import com.google.gson.JsonObject;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Set;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+public class ModIngesterBiomeTest {
+ @Rule
+ public TemporaryFolder folder = new TemporaryFolder();
+
+ private Path target;
+ private ModIngestReport report;
+
+ @Before
+ public void ingest() throws Exception {
+ Path root = folder.getRoot().toPath();
+ Path source = ModIngestFixture.writeSourcePack(root);
+ target = root.resolve("packs").resolve("modded");
+ report = ModIngester.ingest(ModIngestFixture.request(source, target, Set.of(ModIngestFixture.MOD), false));
+ }
+
+ @Test
+ public void hotModBiomeJoinsHotRegionFromNearestTemplate() throws Exception {
+ JsonObject biome = ModIngestFixture.readJson(target.resolve("biomes/ingest/examplemod/scorched_mesa.json"));
+
+ assertEquals("examplemod:scorched_mesa", biome.get("derivative").getAsString());
+ assertEquals("Scorched Mesa", biome.get("name").getAsString());
+ assertTrue(biome.has("layers"));
+ assertTrue("template objects stay when the mod brings no vegetation", biome.has("objects"));
+ List hotLand = ModIngestFixture.strings(ModIngestFixture.readJson(target.resolve("regions/hot.json")).get("landBiomes"));
+ assertEquals("ingest/examplemod/scorched_mesa", hotLand.get(hotLand.size() - 1));
+ assertFalse(ModIngestFixture.strings(ModIngestFixture.readJson(target.resolve("regions/temperate.json")).get("landBiomes"))
+ .contains("ingest/examplemod/scorched_mesa"));
+
+ ModIngestItem item = ModIngestFixture.item(report, "examplemod:scorched_mesa");
+ assertNotNull(item);
+ assertEquals(ModIngestTier.ADOPTED, item.tier());
+ assertEquals("biome", item.kind());
+ assertEquals("ingest/examplemod/scorched_mesa", item.target());
+ assertTrue(item.reason(), item.reason().contains("hot/desert"));
+ }
+
+ @Test
+ public void forestModBiomeDropsOverridesChildrenAndTreesWhenItBringsItsOwn() throws Exception {
+ JsonObject biome = ModIngestFixture.readJson(target.resolve("biomes/ingest/examplemod/maple_woods.json"));
+
+ assertEquals("examplemod:maple_woods", biome.get("derivative").getAsString());
+ assertFalse(biome.has("vanillaDerivative"));
+ assertFalse(biome.has("customDerivitives"));
+ assertFalse(biome.has("biomeScatter"));
+ assertFalse(biome.has("children"));
+ assertFalse(biome.has("objects"));
+ assertFalse(biome.getAsJsonObject("proceduralObjects").has("trees"));
+ assertTrue(biome.getAsJsonObject("proceduralObjects").has("ruins"));
+ assertTrue(ModIngestFixture.strings(ModIngestFixture.readJson(target.resolve("regions/temperate.json")).get("landBiomes"))
+ .contains("ingest/examplemod/maple_woods"));
+ }
+
+ @Test
+ public void oceanShoreAndCaveBiomesJoinTheMatchingPools() throws Exception {
+ JsonObject temperate = ModIngestFixture.readJson(target.resolve("regions/temperate.json"));
+ JsonObject hot = ModIngestFixture.readJson(target.resolve("regions/hot.json"));
+
+ assertTrue(ModIngestFixture.strings(temperate.get("seaBiomes")).contains("ingest/examplemod/coral_depths"));
+ assertTrue(ModIngestFixture.strings(hot.get("shoreBiomes")).contains("ingest/examplemod/dune_beach"));
+ assertTrue(ModIngestFixture.strings(hot.get("caveBiomes")).contains("ingest/examplemod/crystal_caves"));
+ assertFalse(temperate.has("caveBiomes"));
+ assertEquals("Ocean", ModIngestFixture.readJson(target.resolve("biomes/temperate/ocean.json")).get("name").getAsString());
+ assertTrue(ModIngestFixture.item(report, "examplemod:coral_depths").reason().contains("temperate/ocean"));
+ assertTrue(ModIngestFixture.item(report, "examplemod:dune_beach").reason().contains("hot/beach"));
+ assertTrue(ModIngestFixture.item(report, "examplemod:crystal_caves").reason().contains("caves/dripstone"));
+ }
+
+ @Test
+ public void riverNetherAndUntaggedBiomesAreSkippedWithReasons() {
+ ModIngestItem river = ModIngestFixture.item(report, "examplemod:misty_river");
+ ModIngestItem nether = ModIngestFixture.item(report, "examplemod:ashen_wastes");
+ ModIngestItem untagged = ModIngestFixture.item(report, "examplemod:void_garden");
+
+ assertEquals(ModIngestTier.SKIPPED, river.tier());
+ assertTrue(river.reason(), river.reason().contains("river"));
+ assertEquals(ModIngestTier.SKIPPED, nether.tier());
+ assertTrue(nether.reason(), nether.reason().contains("nether"));
+ assertEquals(ModIngestTier.SKIPPED, untagged.tier());
+ assertTrue(untagged.reason(), untagged.reason().contains("minecraft:is_overworld"));
+ assertFalse(Files.exists(target.resolve("biomes/ingest/examplemod/misty_river.json")));
+ assertFalse(Files.exists(target.resolve("biomes/ingest/examplemod/ashen_wastes.json")));
+ }
+
+ @Test
+ public void unselectedNamespacesAreLeftAlone() {
+ assertNull(ModIngestFixture.item(report, "othermod:bamboo_marsh"));
+ assertNull(ModIngestFixture.item(report, "othermod:daisies"));
+ assertFalse(Files.exists(target.resolve("biomes/ingest/othermod")));
+ }
+}
diff --git a/core/src/test/java/art/arcane/iris/pack/ingest/ModIngesterFeatureTest.java b/core/src/test/java/art/arcane/iris/pack/ingest/ModIngesterFeatureTest.java
new file mode 100644
index 000000000..af26c131f
--- /dev/null
+++ b/core/src/test/java/art/arcane/iris/pack/ingest/ModIngesterFeatureTest.java
@@ -0,0 +1,183 @@
+/*
+ * Iris is a World Generator for Minecraft Bukkit Servers
+ * Copyright (c) 2022 Arcane Arts (Volmit Software)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package art.arcane.iris.pack.ingest;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Set;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+public class ModIngesterFeatureTest {
+ private static final double EPSILON = 0.0000005D;
+
+ @Rule
+ public TemporaryFolder folder = new TemporaryFolder();
+
+ private Path target;
+ private ModIngestReport report;
+ private JsonObject dimension;
+
+ @Before
+ public void ingest() throws Exception {
+ Path root = folder.getRoot().toPath();
+ Path source = ModIngestFixture.writeSourcePack(root);
+ target = root.resolve("packs").resolve("modded");
+ report = ModIngester.ingest(ModIngestFixture.request(source, target, Set.of(ModIngestFixture.MOD), false));
+ dimension = ModIngestFixture.readJson(target.resolve("dimensions/modded.json"));
+ }
+
+ @Test
+ public void oreInjectedIntoVanillaBiomesBecomesDimensionDepositsPerTarget() {
+ JsonArray deposits = dimension.getAsJsonArray("deposits");
+ JsonObject stone = ModIngestFixture.findByPaletteBlock(deposits, "examplemod:tin_ore");
+ JsonObject deepslate = ModIngestFixture.findByPaletteBlock(deposits, "examplemod:deepslate_tin_ore");
+
+ assertEquals(4, deposits.size());
+ assertNotNull(stone);
+ assertNotNull(deepslate);
+ assertEquals(80, stone.get("minHeight").getAsInt());
+ assertEquals(427, stone.get("maxHeight").getAsInt());
+ assertEquals("UNIFORM", stone.get("heightDistribution").getAsString());
+ assertEquals(12, stone.get("minPerChunk").getAsInt());
+ assertEquals(12, stone.get("maxPerChunk").getAsInt());
+ assertEquals(0.5D, stone.get("spawnChance").getAsDouble(), EPSILON);
+ assertEquals(7, stone.get("minSize").getAsInt());
+ assertEquals(7, stone.get("maxSize").getAsInt());
+ assertEquals("VANILLA_ELLIPSOID", stone.get("shape").getAsString());
+ assertEquals(0, stone.get("surfaceClearance").getAsInt());
+ assertEquals(List.of("minecraft:andesite", "minecraft:diorite", "minecraft:granite", "minecraft:stone", "minecraft:tuff"),
+ ModIngestFixture.strings(stone.get("replaceableBlocks")));
+ assertEquals(List.of("minecraft:deepslate"), ModIngestFixture.strings(deepslate.get("replaceableBlocks")));
+ assertFalse(stone.has("includedBiomes"));
+ assertEquals(ModIngestTier.NATIVE, ModIngestFixture.item(report, "examplemod:ore_tin").tier());
+ }
+
+ @Test
+ public void oreDeclaredByOneVanillaBiomeIsFilteredToThatBiome() {
+ JsonObject sapphire = ModIngestFixture.findByPaletteBlock(dimension.getAsJsonArray("deposits"), "examplemod:sapphire_ore");
+
+ assertNotNull(sapphire);
+ assertEquals(List.of("minecraft:desert"), ModIngestFixture.strings(sapphire.get("includedBiomes")));
+ assertEquals("SURFACE", sapphire.get("biomeScope").getAsString());
+ assertEquals(128, sapphire.get("minHeight").getAsInt());
+ assertEquals(160, sapphire.get("maxHeight").getAsInt());
+ assertEquals(2, sapphire.get("maxPerChunk").getAsInt());
+ assertEquals(6, ModIngestFixture.strings(sapphire.get("replaceableBlocks")).size());
+ }
+
+ @Test
+ public void modOnlyScatteredOreBecomesBiomeDeposit() throws Exception {
+ JsonObject biome = ModIngestFixture.readJson(target.resolve("biomes/ingest/examplemod/scorched_mesa.json"));
+ JsonObject ruby = ModIngestFixture.findByPaletteBlock(biome.getAsJsonArray("deposits"), "examplemod:ruby_ore");
+
+ assertNotNull(ruby);
+ assertEquals("false", ruby.getAsJsonArray("palette").get(0).getAsJsonObject().getAsJsonObject("data").get("lit").getAsString());
+ assertEquals(112, ruby.get("minHeight").getAsInt());
+ assertEquals(176, ruby.get("maxHeight").getAsInt());
+ assertEquals("TRIANGLE", ruby.get("heightDistribution").getAsString());
+ assertEquals(1, ruby.get("minPerChunk").getAsInt());
+ assertEquals(3, ruby.get("maxPerChunk").getAsInt());
+ assertEquals("VANILLA_SCATTERED", ruby.get("shape").getAsString());
+ assertEquals(0.5D, ruby.get("discardChanceOnAirExposure").getAsDouble(), EPSILON);
+ assertEquals(List.of("minecraft:stone"), ModIngestFixture.strings(ruby.get("replaceableBlocks")));
+ assertNull(ModIngestFixture.findByPaletteBlock(dimension.getAsJsonArray("deposits"), "examplemod:ruby_ore"));
+ assertEquals("ingest/examplemod/scorched_mesa", ModIngestFixture.item(report, "examplemod:ore_ruby").target());
+ }
+
+ @Test
+ public void flowerPatchLandsInModBiomeAndExistingVanillaDerivativeBiome() throws Exception {
+ JsonObject maple = ModIngestFixture.readJson(target.resolve("biomes/ingest/examplemod/maple_woods.json"));
+ JsonObject forest = ModIngestFixture.readJson(target.resolve("biomes/temperate/forest.json"));
+ JsonObject orphan = ModIngestFixture.readJson(target.resolve("biomes/unused/orphan.json"));
+ JsonObject mapleFlowers = ModIngestFixture.findByPaletteBlock(maple.getAsJsonArray("decorators"), "examplemod:glowcap");
+ JsonObject forestFlowers = ModIngestFixture.findByPaletteBlock(forest.getAsJsonArray("decorators"), "examplemod:glowcap");
+
+ assertNotNull(mapleFlowers);
+ assertNotNull(forestFlowers);
+ assertEquals(2, forest.getAsJsonArray("decorators").size());
+ assertEquals(1.0D - Math.exp(-(1.0D / 4.0D * 32.0D * 0.25D) / 256.0D), forestFlowers.get("chance").getAsDouble(), 0.000001D);
+ JsonArray palette = forestFlowers.getAsJsonArray("palette");
+ assertEquals(3, palette.get(0).getAsJsonObject().get("weight").getAsInt());
+ assertEquals("examplemod:glowbell", palette.get(1).getAsJsonObject().get("block").getAsString());
+ assertEquals("true", palette.get(1).getAsJsonObject().getAsJsonObject("data").get("lit").getAsString());
+ assertEquals("minecraft:grass_block", forestFlowers.getAsJsonArray("whitelist").get(0).getAsJsonObject().get("block").getAsString());
+ assertFalse(orphan.has("decorators"));
+ ModIngestItem item = ModIngestFixture.item(report, "examplemod:glow_flowers");
+ assertEquals(ModIngestTier.NATIVE, item.tier());
+ assertTrue(item.target(), item.target().contains("temperate/forest"));
+ assertTrue(item.target(), item.target().contains("ingest/examplemod/maple_woods"));
+ }
+
+ @Test
+ public void legacyRandomPatchConvertsIntoPlainsDecorator() throws Exception {
+ JsonObject plains = ModIngestFixture.readJson(target.resolve("biomes/temperate/plains.json"));
+ JsonObject bluebell = ModIngestFixture.findByPaletteBlock(plains.getAsJsonArray("decorators"), "examplemod:bluebell");
+
+ assertNotNull(bluebell);
+ assertEquals(1.0D - Math.exp(-(0.5D * 64.0D * 0.25D) / 256.0D), bluebell.get("chance").getAsDouble(), 0.000001D);
+ }
+
+ @Test
+ public void unconvertibleFeaturesPassThroughAndConvertedOnesAreDisabled() {
+ ModIngestItem trees = ModIngestFixture.item(report, "examplemod:maple_trees");
+ ModIngestItem weird = ModIngestFixture.item(report, "examplemod:weird_ore");
+ JsonObject imported = dimension.getAsJsonObject("importedFeatures");
+
+ assertEquals(ModIngestTier.PASSTHROUGH, trees.tier());
+ assertEquals(ModIngestTier.PASSTHROUGH, weird.tier());
+ assertTrue(weird.reason(), weird.reason().contains("examplemod:custom_stone"));
+ assertTrue(imported.get("enabled").getAsBoolean());
+ assertEquals(List.of("minecraft:ore_diamond", "minecraft:", "examplemod:bluebell_patch", "examplemod:glow_flowers",
+ "examplemod:ore_ruby", "examplemod:ore_sapphire", "examplemod:ore_tin"),
+ ModIngestFixture.strings(imported.get("disabled")));
+ assertEquals(List.of("LAKES"), ModIngestFixture.strings(imported.get("disabledSteps")));
+ }
+
+ @Test
+ public void featuresWithoutHostsOrShadowedByDisabledPrefixesAreSkipped() {
+ ModIngestItem nether = ModIngestFixture.item(report, "examplemod:nether_ore");
+ ModIngestItem buried = ModIngestFixture.item(report, "examplemod:ore_tin_buried");
+
+ assertEquals(ModIngestTier.SKIPPED, nether.tier());
+ assertEquals(ModIngestTier.SKIPPED, buried.tier());
+ assertTrue(buried.reason(), buried.reason().contains("examplemod:ore_tin"));
+ }
+
+ @Test
+ public void structuresAreAdoptedUnlessDisabledOrHostless() {
+ assertEquals(ModIngestTier.ADOPTED, ModIngestFixture.item(report, "examplemod:maple_tower").tier());
+ ModIngestItem ruin = ModIngestFixture.item(report, "examplemod:old_ruin");
+ assertEquals(ModIngestTier.SKIPPED, ruin.tier());
+ assertTrue(ruin.reason(), ruin.reason().contains("importedStructures"));
+ assertEquals(ModIngestTier.SKIPPED, ModIngestFixture.item(report, "examplemod:nether_fort").tier());
+ assertNull(ModIngestFixture.item(report, "minecraft:village_plains"));
+ }
+}
diff --git a/core/src/test/java/art/arcane/iris/pack/ingest/ModIngesterPackTest.java b/core/src/test/java/art/arcane/iris/pack/ingest/ModIngesterPackTest.java
new file mode 100644
index 000000000..9ab8e18b9
--- /dev/null
+++ b/core/src/test/java/art/arcane/iris/pack/ingest/ModIngesterPackTest.java
@@ -0,0 +1,167 @@
+/*
+ * Iris is a World Generator for Minecraft Bukkit Servers
+ * Copyright (c) 2022 Arcane Arts (Volmit Software)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package art.arcane.iris.pack.ingest;
+
+import art.arcane.iris.pack.PackValidationResult;
+import art.arcane.iris.pack.PackValidator;
+import com.google.gson.JsonObject;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.FileAlreadyExistsException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.stream.Stream;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+public class ModIngesterPackTest {
+ @Rule
+ public TemporaryFolder folder = new TemporaryFolder();
+
+ @Test
+ public void writesLoadableTargetPackWithoutTouchingSource() throws Exception {
+ Path root = folder.getRoot().toPath();
+ Path source = ModIngestFixture.writeSourcePack(root);
+ Map before = snapshot(source);
+ Path target = root.resolve("packs").resolve("modded");
+
+ ModIngestReport report = ModIngester.ingest(ModIngestFixture.request(source, target, Set.of(ModIngestFixture.MOD), false));
+
+ assertEquals(before, snapshot(source));
+ assertEquals(target, report.targetPack());
+ assertTrue(Files.isRegularFile(target.resolve("dimensions/modded.json")));
+ assertFalse(Files.exists(target.resolve("dimensions/overworld.json")));
+ assertFalse(Files.exists(target.resolve(".git")));
+ assertFalse(Files.exists(target.resolve("source.code-workspace")));
+ PackValidationResult validation = PackValidator.validate(target.toFile());
+ assertTrue(validation.getBlockingErrors().toString(), validation.isLoadable());
+ }
+
+ @Test
+ public void manifestAndSummaryDescribeTheIngest() throws Exception {
+ Path root = folder.getRoot().toPath();
+ Path source = ModIngestFixture.writeSourcePack(root);
+ Path target = root.resolve("packs").resolve("modded");
+
+ ModIngestReport report = ModIngester.ingest(ModIngestFixture.request(source, target, Set.of(ModIngestFixture.MOD), false));
+ JsonObject manifest = ModIngestFixture.readJson(target.resolve("ingest/manifest.json"));
+
+ assertEquals("fingerprint-1", manifest.get("catalogFingerprint").getAsString());
+ assertEquals("modded", manifest.get("dimension").getAsString());
+ assertEquals(List.of("examplemod"), ModIngestFixture.strings(manifest.get("namespaces")));
+ List generated = ModIngestFixture.strings(manifest.get("generatedFiles"));
+ List modified = ModIngestFixture.strings(manifest.get("modifiedFiles"));
+ assertTrue(generated.toString(), generated.contains("biomes/ingest/examplemod/scorched_mesa.json"));
+ assertTrue(modified.toString(), modified.containsAll(List.of("dimensions/modded.json", "regions/hot.json",
+ "regions/temperate.json", "biomes/temperate/forest.json", "biomes/temperate/plains.json")));
+ assertFalse(modified.contains("biomes/unused/orphan.json"));
+ assertEquals(report.items().size(), manifest.getAsJsonArray("items").size());
+ assertTrue(report.summaryLines().toString(),
+ report.summaryLines().contains("examplemod: 5 native, 6 adopted, 2 passthrough, 7 skipped"));
+ }
+
+ @Test
+ public void existingTargetIsRejectedUnlessReplaceIsRequested() throws Exception {
+ Path root = folder.getRoot().toPath();
+ Path source = ModIngestFixture.writeSourcePack(root);
+ Path target = root.resolve("packs").resolve("modded");
+ ModIngester.ingest(ModIngestFixture.request(source, target, Set.of(ModIngestFixture.MOD), false));
+
+ assertThrows(FileAlreadyExistsException.class,
+ () -> ModIngester.ingest(ModIngestFixture.request(source, target, Set.of(ModIngestFixture.MOD), false)));
+ }
+
+ @Test
+ public void replacingTargetProducesIdenticalOutput() throws Exception {
+ Path root = folder.getRoot().toPath();
+ Path source = ModIngestFixture.writeSourcePack(root);
+ Path target = root.resolve("packs").resolve("modded");
+ ModIngester.ingest(ModIngestFixture.request(source, target, Set.of(ModIngestFixture.MOD), true));
+ Map first = snapshot(target);
+ ModIngestFixture.write(target, "biomes/stale.json", "{\"name\": \"Stale\"}");
+
+ ModIngester.ingest(ModIngestFixture.request(source, target, Set.of(ModIngestFixture.MOD), true));
+
+ assertEquals(first, snapshot(target));
+ }
+
+ @Test
+ public void targetInsideSourceIsRejected() throws Exception {
+ Path root = folder.getRoot().toPath();
+ Path source = ModIngestFixture.writeSourcePack(root);
+
+ assertThrows(IOException.class, () -> ModIngester.ingest(
+ ModIngestFixture.request(source, source.resolve("nested"), Set.of(), true)));
+ assertThrows(IOException.class, () -> ModIngester.ingest(
+ ModIngestFixture.request(source, source, Set.of(), true)));
+ assertTrue(Files.isRegularFile(source.resolve("dimensions/overworld.json")));
+ }
+
+ @Test
+ public void ingestedPackCannotBeUsedAsSource() throws Exception {
+ Path root = folder.getRoot().toPath();
+ Path source = ModIngestFixture.writeSourcePack(root);
+ Path target = root.resolve("packs").resolve("modded");
+ ModIngester.ingest(ModIngestFixture.request(source, target, Set.of(ModIngestFixture.MOD), false));
+
+ IOException failure = assertThrows(IOException.class, () -> ModIngester.ingest(new ModIngestRequest(
+ target, root.resolve("packs").resolve("again"), "modded", ModIngestFixture.catalog(), Set.of(), false)));
+
+ assertTrue(failure.getMessage(), failure.getMessage().contains("original pack"));
+ assertFalse(Files.exists(root.resolve("packs").resolve("again")));
+ }
+
+ @Test
+ public void emptyNamespaceSelectionIngestsEveryModNamespace() throws Exception {
+ Path root = folder.getRoot().toPath();
+ Path source = ModIngestFixture.writeSourcePack(root);
+ Path target = root.resolve("packs").resolve("everything");
+
+ ModIngestReport report = ModIngester.ingest(ModIngestFixture.request(source, target, Set.of(), false));
+
+ assertNotNull(ModIngestFixture.item(report, "othermod:bamboo_marsh"));
+ assertEquals(ModIngestTier.NATIVE, ModIngestFixture.item(report, "othermod:daisies").tier());
+ assertTrue(Files.isRegularFile(target.resolve("biomes/ingest/othermod/bamboo_marsh.json")));
+ assertEquals(List.of("examplemod", "othermod"),
+ ModIngestFixture.strings(ModIngestFixture.readJson(target.resolve("ingest/manifest.json")).get("namespaces")));
+ assertTrue(report.items().stream().noneMatch(item -> item.namespace().equals("minecraft")));
+ }
+
+ private static Map snapshot(Path root) throws IOException {
+ Map files = new TreeMap<>();
+ try (Stream walk = Files.walk(root)) {
+ for (Path path : walk.filter(Files::isRegularFile).toList()) {
+ files.put(root.relativize(path).toString().replace('\\', '/'), Files.readString(path, StandardCharsets.UTF_8));
+ }
+ }
+ return files;
+ }
+}
diff --git a/core/src/test/java/art/arcane/iris/pack/ingest/VanillaFeatureParsersTest.java b/core/src/test/java/art/arcane/iris/pack/ingest/VanillaFeatureParsersTest.java
new file mode 100644
index 000000000..694b8fc87
--- /dev/null
+++ b/core/src/test/java/art/arcane/iris/pack/ingest/VanillaFeatureParsersTest.java
@@ -0,0 +1,156 @@
+/*
+ * Iris is a World Generator for Minecraft Bukkit Servers
+ * Copyright (c) 2022 Arcane Arts (Volmit Software)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package art.arcane.iris.pack.ingest;
+
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import org.junit.Test;
+
+import java.util.List;
+import java.util.Set;
+
+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 VanillaFeatureParsersTest {
+ private static final double EPSILON = 0.000001D;
+ private static final VanillaHeights.VerticalBounds OVERWORLD = new VanillaHeights.VerticalBounds(-64, 384);
+
+ @Test
+ public void intProvidersReportMeanAndRange() throws Exception {
+ assertStats(7, 7, 7, VanillaIntProviders.stats(json("7")));
+ assertStats(4, 4, 4, VanillaIntProviders.stats(json("{\"type\": \"minecraft:constant\", \"value\": 4}")));
+ assertStats(2, 1, 3, VanillaIntProviders.stats(json("{\"type\": \"minecraft:uniform\", \"min_inclusive\": 1, \"max_inclusive\": 3}")));
+ assertStats(0.2D, 0, 1, VanillaIntProviders.stats(json("""
+ {"type": "minecraft:clamped", "min_inclusive": 0, "max_inclusive": 64,
+ "source": {"type": "minecraft:uniform", "min_inclusive": -3, "max_inclusive": 1}}
+ """)));
+ assertStats(0, -3, 3, VanillaIntProviders.stats(json("{\"type\": \"minecraft:trapezoid\", \"min\": -3, \"max\": 3, \"plateau\": 0}")));
+ assertStats(2.5D, 1, 4, VanillaIntProviders.stats(json("""
+ {"type": "minecraft:weighted_list", "distribution": [{"data": 1, "weight": 1}, {"data": 4, "weight": 1}]}
+ """)));
+ assertThrows(IngestConversionException.class, () -> VanillaIntProviders.stats(json("{\"type\": \"minecraft:mystery\"}")));
+ }
+
+ @Test
+ public void offsetProvidersReportTheChanceOfLandingOnTheSurface() throws Exception {
+ assertEquals(0.25D, VanillaIntProviders.probabilityOf(json("{\"type\": \"minecraft:trapezoid\", \"min\": -3, \"max\": 3, \"plateau\": 0}"), 0), EPSILON);
+ assertEquals(1.0D / 16.0D, VanillaIntProviders.probabilityOf(json("{\"type\": \"minecraft:trapezoid\", \"min\": -3, \"max\": 3, \"plateau\": 0}"), 3), EPSILON);
+ assertEquals(1.0D / 7.0D, VanillaIntProviders.probabilityOf(json("{\"type\": \"minecraft:trapezoid\", \"min\": -3, \"max\": 3, \"plateau\": 6}"), 0), EPSILON);
+ assertEquals(0.2D, VanillaIntProviders.probabilityOf(json("{\"type\": \"minecraft:uniform\", \"min_inclusive\": -2, \"max_inclusive\": 2}"), 0), EPSILON);
+ assertEquals(1.0D, VanillaIntProviders.probabilityOf(json("0"), 0), EPSILON);
+ assertEquals(0.0D, VanillaIntProviders.probabilityOf(json("2"), 0), EPSILON);
+ }
+
+ @Test
+ public void heightProvidersResolveAnchorsAgainstTheDimensionType() throws Exception {
+ VanillaHeights.HeightBand uniform = VanillaHeights.band(json("""
+ {"type": "minecraft:uniform", "min_inclusive": {"above_bottom": 16}, "max_inclusive": {"below_top": 20}}
+ """), OVERWORLD);
+ VanillaHeights.HeightBand trapezoid = VanillaHeights.band(json("""
+ {"type": "minecraft:trapezoid", "min_inclusive": {"above_bottom": -80}, "max_inclusive": {"above_bottom": 80}}
+ """), OVERWORLD);
+ VanillaHeights.HeightBand flat = VanillaHeights.band(json("""
+ {"type": "minecraft:trapezoid", "min_inclusive": {"absolute": 0}, "max_inclusive": {"absolute": 40}, "plateau": 40}
+ """), OVERWORLD);
+ VanillaHeights.HeightBand constant = VanillaHeights.band(json("{\"absolute\": 12}"), OVERWORLD);
+
+ assertEquals(new VanillaHeights.HeightBand(-48, 299, "UNIFORM"), uniform);
+ assertEquals(new VanillaHeights.HeightBand(-144, 16, "TRIANGLE"), trapezoid);
+ assertEquals("UNIFORM", flat.distribution());
+ assertEquals(new VanillaHeights.HeightBand(12, 12, "UNIFORM"), constant);
+ assertThrows(IngestConversionException.class, () -> VanillaHeights.band(json("""
+ {"type": "minecraft:very_biased_to_bottom", "min_inclusive": {"absolute": 0}, "max_inclusive": {"absolute": 9}}
+ """), OVERWORLD));
+ }
+
+ @Test
+ public void ruleTestsFlattenVanillaHeightSpecificTargets() throws Exception {
+ VanillaRuleTests.HostSet stone = VanillaRuleTests.hosts(json("""
+ {"predicate_type": "minecraft:any_of", "rules": [
+ {"predicate_type": "minecraft:all_of", "rules": [
+ {"predicate_type": "minecraft:tag_match", "tag": "minecraft:height_specific_ore_replaceables"},
+ {"predicate_type": "minecraft:height_match", "min_inclusive": 0, "max_inclusive": 2031}]},
+ {"predicate_type": "minecraft:all_of", "rules": [
+ {"predicate_type": "minecraft:not", "rule": {"predicate_type": "minecraft:tag_match", "tag": "minecraft:height_specific_ore_replaceables"}},
+ {"predicate_type": "minecraft:tag_match", "tag": "minecraft:stone_ore_replaceables"}]}]}
+ """));
+
+ assertFalse(stone.any());
+ assertEquals(Set.of("minecraft:tuff", "minecraft:stone", "minecraft:granite", "minecraft:diorite", "minecraft:andesite"), stone.blocks());
+ assertEquals(Set.of("minecraft:netherrack"),
+ VanillaRuleTests.hosts(json("{\"predicate_type\": \"minecraft:block_match\", \"block\": \"minecraft:netherrack\"}")).blocks());
+ assertEquals(Set.of("minecraft:end_stone"), VanillaRuleTests.hosts(json(
+ "{\"predicate_type\": \"minecraft:blockstate_match\", \"block_state\": {\"Name\": \"minecraft:end_stone\"}}")).blocks());
+ assertTrue(VanillaRuleTests.hosts(json("{\"predicate_type\": \"minecraft:always_true\"}")).any());
+ assertThrows(IngestConversionException.class, () -> VanillaRuleTests.hosts(json(
+ "{\"predicate_type\": \"minecraft:random_block_match\", \"block\": \"minecraft:stone\", \"probability\": 0.5}")));
+ IngestConversionException unknownTag = assertThrows(IngestConversionException.class, () -> VanillaRuleTests.hosts(json(
+ "{\"predicate_type\": \"minecraft:tag_match\", \"tag\": \"examplemod:moon_rock\"}")));
+ assertTrue(unknownTag.getMessage().contains("examplemod:moon_rock"));
+ }
+
+ @Test
+ public void blockStatesAcceptEveryCodecShape() throws Exception {
+ JsonObject compact = VanillaBlockStates.irisBlock(json("\"minecraft:iron_ore\""));
+ JsonObject bracketed = VanillaBlockStates.irisBlock(json("\"minecraft:oak_log[axis=x]\""));
+ JsonObject modern = VanillaBlockStates.irisBlock(json("{\"id\": \"minecraft:sunflower\", \"properties\": {\"half\": \"lower\"}}"));
+ JsonObject legacy = VanillaBlockStates.irisBlock(json("{\"Name\": \"minecraft:sweet_berry_bush\", \"Properties\": {\"age\": \"3\"}}"));
+
+ assertEquals("minecraft:iron_ore", compact.get("block").getAsString());
+ assertFalse(compact.has("data"));
+ assertEquals("x", bracketed.getAsJsonObject("data").get("axis").getAsString());
+ assertEquals("lower", modern.getAsJsonObject("data").get("half").getAsString());
+ assertEquals("3", legacy.getAsJsonObject("data").get("age").getAsString());
+ assertThrows(IngestConversionException.class, () -> VanillaBlockStates.irisBlock(json("{\"oops\": true}")));
+ }
+
+ @Test
+ public void stateProvidersBecomeWeightedPalettes() throws Exception {
+ List threshold = VanillaBlockStates.palette(json("""
+ {"type": "minecraft:noise_threshold", "default_state": "minecraft:dandelion",
+ "low_states": ["minecraft:red_tulip"], "high_states": ["minecraft:poppy", "minecraft:dandelion"],
+ "noise": {"base_amplitude": 1.0, "base_octave": 0}, "scale": 0.005, "seed": 2345, "threshold": -0.8, "high_chance": 0.33}
+ """));
+ List legacy = VanillaBlockStates.palette(json("""
+ {"type": "minecraft:weighted_state_provider", "entries": [{"data": {"Name": "minecraft:poppy"}, "weight": 2}]}
+ """));
+
+ assertEquals(List.of("minecraft:dandelion", "minecraft:red_tulip", "minecraft:poppy"),
+ threshold.stream().map(entry -> entry.get("block").getAsString()).toList());
+ assertEquals(2, legacy.get(0).get("weight").getAsInt());
+ assertEquals("minecraft:short_grass", VanillaBlockStates.palette(json("{\"id\": \"minecraft:short_grass\"}")).get(0).get("block").getAsString());
+ IngestConversionException reference = assertThrows(IngestConversionException.class,
+ () -> VanillaBlockStates.palette(json("\"minecraft:flower_plain\"")));
+ assertTrue(reference.getMessage().contains("minecraft:flower_plain"));
+ }
+
+ private static void assertStats(double mean, int min, int max, VanillaIntProviders.IntStats stats) {
+ assertEquals(mean, stats.mean(), EPSILON);
+ assertEquals(min, stats.min());
+ assertEquals(max, stats.max());
+ }
+
+ private static JsonElement json(String value) {
+ return JsonParser.parseString(value);
+ }
+}