From acec5506253a1d2cc6cfee5926daef42d5f2ffc5 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Wed, 14 Oct 2026 20:19:04 -0400 Subject: [PATCH] Merged forced datapack tags - Staged packs that write the same data/*/tags file now merge into the union of their values - Tag entries dedupe by value or id and replace stays false unless a contributor set it - Every non-tag file keeps strict conflict detection --- .../iris/modded/ModdedDatapackTagMerge.java | 83 +++++++++++++++++++ .../iris/modded/ModdedForcedDatapack.java | 2 + .../iris/modded/ModdedForcedDatapackTest.java | 55 ++++++++++++ 3 files changed, 140 insertions(+) create mode 100644 adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDatapackTagMerge.java diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDatapackTagMerge.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDatapackTagMerge.java new file mode 100644 index 000000000..241efeb91 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDatapackTagMerge.java @@ -0,0 +1,83 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 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.modded; + +import art.arcane.volmlib.util.json.JSONArray; +import art.arcane.volmlib.util.json.JSONObject; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.TreeMap; + +final class ModdedDatapackTagMerge { + private static final String REPLACE = "replace"; + private static final String VALUES = "values"; + + private ModdedDatapackTagMerge() { + } + + static boolean isTag(Path relative) { + return relative.getNameCount() >= 4 + && "data".equals(relative.getName(0).toString()) + && "tags".equals(relative.getName(2).toString()) + && relative.getFileName().toString().endsWith(".json"); + } + + static void merge(Path source, Path destination) throws IOException { + JSONObject incoming = read(source); + JSONObject existing = read(destination); + Map values = new TreeMap<>(); + collect(existing, values, destination); + collect(incoming, values, source); + JSONArray merged = new JSONArray(); + for (Object value : values.values()) { + merged.put(value); + } + JSONObject tag = new JSONObject(); + tag.put(REPLACE, existing.optBoolean(REPLACE, false) || incoming.optBoolean(REPLACE, false)); + tag.put(VALUES, merged); + Files.writeString(destination, tag.toString(4), StandardCharsets.UTF_8); + } + + private static JSONObject read(Path tag) throws IOException { + try { + return new JSONObject(Files.readString(tag, StandardCharsets.UTF_8)); + } catch (RuntimeException invalid) { + throw new IOException("Iris cannot merge the invalid datapack tag " + tag + ".", invalid); + } + } + + private static void collect(JSONObject tag, Map values, Path origin) throws IOException { + JSONArray entries = tag.optJSONArray(VALUES); + if (entries == null) { + return; + } + for (int index = 0; index < entries.length(); index++) { + Object entry = entries.get(index); + String key = entry instanceof JSONObject object ? object.optString("id", "") : entry.toString(); + if (key.isBlank()) { + throw new IOException("Iris cannot merge datapack tag " + origin + ": entry " + index + " has no id."); + } + values.putIfAbsent(key, entry); + } + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java index 194cc2b8d..c90c15a32 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java @@ -464,6 +464,8 @@ public final class ModdedForcedDatapack { Files.createDirectories(destination); } else if (!Files.exists(destination)) { Files.copy(source, destination); + } else if (Files.isRegularFile(destination) && ModdedDatapackTagMerge.isTag(relative)) { + ModdedDatapackTagMerge.merge(source, destination); } else if (!Files.isRegularFile(destination) || Files.mismatch(source, destination) != -1L) { throw new IOException("Conflicting Iris datapack output at " + destination + "."); } diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedForcedDatapackTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedForcedDatapackTest.java index 8a016e11a..58697dc83 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedForcedDatapackTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedForcedDatapackTest.java @@ -260,6 +260,61 @@ public class ModdedForcedDatapackTest { } } + @Test + public void mergesTagFilesFromDifferentPacksIntoTheUnionOfTheirValues() throws IOException { + Path root = Files.createTempDirectory("iris-forced-pack-tag-merge"); + try { + Path tag = Path.of("data/minecraft/tags/worldgen/biome/water_on_map_outlines.json"); + Path source = root.resolve("source"); + Path destination = root.resolve("destination"); + Files.createDirectories(source.resolve(tag).getParent()); + Files.createDirectories(destination.resolve(tag).getParent()); + Files.writeString(source.resolve(tag), + "{\"replace\":false,\"values\":[\"iris:biomes/b\",\"iris:biomes/c\",{\"id\":\"mod:river\",\"required\":false}]}", + StandardCharsets.UTF_8); + Files.writeString(destination.resolve(tag), + "{\"replace\":false,\"values\":[\"iris:biomes/a\",\"iris:biomes/b\"]}", + StandardCharsets.UTF_8); + + ModdedForcedDatapack.mergeDirectory(source, destination); + + JSONObject merged = new JSONObject(Files.readString(destination.resolve(tag), StandardCharsets.UTF_8)); + assertFalse(merged.getBoolean("replace")); + List values = new ArrayList<>(); + for (int index = 0; index < merged.getJSONArray("values").length(); index++) { + Object value = merged.getJSONArray("values").get(index); + values.add(value instanceof JSONObject entry ? entry.getString("id") : value.toString()); + } + assertEquals(List.of("iris:biomes/a", "iris:biomes/b", "iris:biomes/c", "mod:river"), values); + } finally { + deleteTree(root); + } + } + + @Test + public void tagMergeKeepsStrictConflictsForNonTagFilesInTheSameMerge() throws IOException { + Path root = Files.createTempDirectory("iris-forced-pack-tag-strict"); + try { + Path tag = Path.of("data/minecraft/tags/worldgen/biome/is_overworld.json"); + Path biome = Path.of("data/iris/worldgen/biome/shared.json"); + for (String side : List.of("source", "destination")) { + Files.createDirectories(root.resolve(side).resolve(tag).getParent()); + Files.createDirectories(root.resolve(side).resolve(biome).getParent()); + Files.writeString(root.resolve(side).resolve(tag), + "{\"replace\":false,\"values\":[\"iris:biomes/" + side + "\"]}", StandardCharsets.UTF_8); + } + Files.writeString(root.resolve("source").resolve(biome), "{\"temperature\":1}", StandardCharsets.UTF_8); + Files.writeString(root.resolve("destination").resolve(biome), "{\"temperature\":2}", StandardCharsets.UTF_8); + + assertThrows( + IOException.class, + () -> ModdedForcedDatapack.mergeDirectory(root.resolve("source"), root.resolve("destination")) + ); + } finally { + deleteTree(root); + } + } + private List directoryEntries(Path root) throws IOException { try (Stream entries = Files.list(root)) { return entries.map((Path path) -> path.getFileName().toString())