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
This commit is contained in:
Brian Neumann-Fopiano
2026-10-14 20:19:04 -04:00
parent 5633eec009
commit acec550625
3 changed files with 140 additions and 0 deletions
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<String, Object> 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<String, Object> 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);
}
}
}
@@ -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 + ".");
}
@@ -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<String> 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<String> directoryEntries(Path root) throws IOException {
try (Stream<Path> entries = Files.list(root)) {
return entries.map((Path path) -> path.getFileName().toString())