From 26dc1d17cf0bc944363d7abd09ee93ed018d0b2a Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Wed, 14 Oct 2026 20:02:37 -0400 Subject: [PATCH] Implemented mod ingest v0 - ModIngester copies the source pack with IrisProjectCopier into a new target pack whose folder name becomes the dimension key, honouring replaceTarget and rejecting overlapping or already-ingested sources - Mod biomes are classified by minecraft/c tags into land, sea, shore, and cave pools, skipping rivers, other-dimension biomes, and biomes missing the dimension tag - Each adopted biome joins the region with the nearest land climate and copies the nearest-climate template biome with the mod biome as its derivative and no custom-biome overrides - Template trees and procedural trees are dropped when the mod biome brings its own non-decorator vegetation - Ore and scattered ore features become Iris deposits per target with resolved height anchors, counts, rarity, vein size, air-exposure discard, and host blocks - Vanilla-declared ores become dimension deposits filtered to the declaring biomes when they do not cover the whole dimension; mod-only ores become biome deposits - Surface simple_block and legacy random_patch features become Iris decorators on adopted mod biomes and on pack biomes whose derivative declares them - Remaining features pass through native generation, with importedFeatures enabled and minecraft plus every converted key disabled - Passthrough features shadowed by a disabled prefix or a disabled step are reported as skipped - Mod structures are reported as adopted unless importedStructures disables them or none of their biomes generate - The target pack records ingest/manifest.json with the catalog fingerprint, namespaces, generated and modified files, and every item - Tests cover climate placement, biome classification, ore and patch conversion against current 26.3 formats, propagation, passthrough, manifest, idempotent replace, and pack validation --- .../arcane/iris/pack/ingest/BiomeRole.java | 42 ++ .../pack/ingest/ImportedFeatureWriter.java | 106 +++++ .../iris/pack/ingest/IngestClimate.java | 51 +++ .../ingest/IngestConversionException.java | 25 ++ .../arcane/iris/pack/ingest/IngestJson.java | 88 ++++ .../iris/pack/ingest/IngestManifest.java | 88 ++++ .../arcane/iris/pack/ingest/IngestPack.java | 155 +++++++ .../iris/pack/ingest/IngestPackModel.java | 202 +++++++++ .../iris/pack/ingest/ModBiomeAdopter.java | 173 ++++++++ .../iris/pack/ingest/ModBiomeClassifier.java | 112 +++++ .../iris/pack/ingest/ModFeatureIngester.java | 219 ++++++++++ .../arcane/iris/pack/ingest/ModIngester.java | 130 +++++- .../pack/ingest/ModStructureIngester.java | 75 ++++ .../iris/pack/ingest/OreDepositConverter.java | 146 +++++++ .../pack/ingest/PatchDecoratorConverter.java | 133 ++++++ .../iris/pack/ingest/VanillaBlockStates.java | 154 +++++++ .../iris/pack/ingest/VanillaHeights.java | 90 ++++ .../iris/pack/ingest/VanillaIntProviders.java | 184 ++++++++ .../arcane/iris/pack/ingest/VanillaJson.java | 132 ++++++ .../pack/ingest/VanillaPlacementChain.java | 158 +++++++ .../iris/pack/ingest/VanillaRuleTests.java | 141 ++++++ .../ingest/FeatureConverterFormatTest.java | 147 +++++++ .../iris/pack/ingest/ModIngestFixture.java | 411 ++++++++++++++++++ .../pack/ingest/ModIngesterBiomeTest.java | 127 ++++++ .../pack/ingest/ModIngesterFeatureTest.java | 183 ++++++++ .../iris/pack/ingest/ModIngesterPackTest.java | 167 +++++++ .../ingest/VanillaFeatureParsersTest.java | 156 +++++++ 27 files changed, 3793 insertions(+), 2 deletions(-) create mode 100644 core/src/main/java/art/arcane/iris/pack/ingest/BiomeRole.java create mode 100644 core/src/main/java/art/arcane/iris/pack/ingest/ImportedFeatureWriter.java create mode 100644 core/src/main/java/art/arcane/iris/pack/ingest/IngestClimate.java create mode 100644 core/src/main/java/art/arcane/iris/pack/ingest/IngestConversionException.java create mode 100644 core/src/main/java/art/arcane/iris/pack/ingest/IngestJson.java create mode 100644 core/src/main/java/art/arcane/iris/pack/ingest/IngestManifest.java create mode 100644 core/src/main/java/art/arcane/iris/pack/ingest/IngestPack.java create mode 100644 core/src/main/java/art/arcane/iris/pack/ingest/IngestPackModel.java create mode 100644 core/src/main/java/art/arcane/iris/pack/ingest/ModBiomeAdopter.java create mode 100644 core/src/main/java/art/arcane/iris/pack/ingest/ModBiomeClassifier.java create mode 100644 core/src/main/java/art/arcane/iris/pack/ingest/ModFeatureIngester.java create mode 100644 core/src/main/java/art/arcane/iris/pack/ingest/ModStructureIngester.java create mode 100644 core/src/main/java/art/arcane/iris/pack/ingest/OreDepositConverter.java create mode 100644 core/src/main/java/art/arcane/iris/pack/ingest/PatchDecoratorConverter.java create mode 100644 core/src/main/java/art/arcane/iris/pack/ingest/VanillaBlockStates.java create mode 100644 core/src/main/java/art/arcane/iris/pack/ingest/VanillaHeights.java create mode 100644 core/src/main/java/art/arcane/iris/pack/ingest/VanillaIntProviders.java create mode 100644 core/src/main/java/art/arcane/iris/pack/ingest/VanillaJson.java create mode 100644 core/src/main/java/art/arcane/iris/pack/ingest/VanillaPlacementChain.java create mode 100644 core/src/main/java/art/arcane/iris/pack/ingest/VanillaRuleTests.java create mode 100644 core/src/test/java/art/arcane/iris/pack/ingest/FeatureConverterFormatTest.java create mode 100644 core/src/test/java/art/arcane/iris/pack/ingest/ModIngestFixture.java create mode 100644 core/src/test/java/art/arcane/iris/pack/ingest/ModIngesterBiomeTest.java create mode 100644 core/src/test/java/art/arcane/iris/pack/ingest/ModIngesterFeatureTest.java create mode 100644 core/src/test/java/art/arcane/iris/pack/ingest/ModIngesterPackTest.java create mode 100644 core/src/test/java/art/arcane/iris/pack/ingest/VanillaFeatureParsersTest.java 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); + } +}