owners = new HashSet<>();
+ for (Object candidate : (List>) field(group, "candidates")) {
+ owners.add((String) field(candidate, "owner"));
+ }
+ Method outputs = group.getClass().getDeclaredMethod("outputCandidates");
+ outputs.setAccessible(true);
+ if (!owners.contains("baseminerals") || !owners.contains("electricadvantage")
+ || ((List>) outputs.invoke(group)).size() != 3) {
+ throw new IllegalStateException("The profile must expose both providers and all three outputs");
+ }
+ System.out.println("ORE_SOURCES_CLIENT_PROBE providers=2 outputs=3 verified=true");
+ return;
+ }
+ throw new IllegalStateException("Shared Sulfur group is missing");
+ } catch (ReflectiveOperationException failure) {
+ throw new IllegalStateException("Could not inspect the shared-ore editor", failure);
+ }
+ }
+
+ private static Object field(Object object, String name) throws ReflectiveOperationException {
+ Field field = object.getClass().getDeclaredField(name);
+ field.setAccessible(true);
+ return field.get(object);
+ }
+}
diff --git a/src/main/java/zone/moddev/mc/orespawn/OreSpawn.java b/src/main/java/zone/moddev/mc/orespawn/OreSpawn.java
index 7e88c29d..bae7b203 100644
--- a/src/main/java/zone/moddev/mc/orespawn/OreSpawn.java
+++ b/src/main/java/zone/moddev/mc/orespawn/OreSpawn.java
@@ -65,6 +65,7 @@ public OreSpawn() {
MinecraftForge.EVENT_BUS.addListener(WorldMaterialWeather::onChunkLoad);
MinecraftForge.EVENT_BUS.addListener(WorldMaterialWeather::onWorldTick);
MinecraftForge.EVENT_BUS.addListener(WorldGeologyProfileManager::onServerAboutToStart);
+ MinecraftForge.EVENT_BUS.addListener(WorldGeologyProfileManager::onServerStarting);
MinecraftForge.EVENT_BUS.addListener(WorldGeologyProfileManager::onWorldLoad);
MinecraftForge.EVENT_BUS.addListener(WorldGeologyProfileManager::onServerStopped);
MinecraftForge.EVENT_BUS.addListener(OreRetrogenManager::onChunkLoad);
diff --git a/src/main/java/zone/moddev/mc/orespawn/api/OreGenerationContext.java b/src/main/java/zone/moddev/mc/orespawn/api/OreGenerationContext.java
new file mode 100644
index 00000000..88be3e79
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/orespawn/api/OreGenerationContext.java
@@ -0,0 +1,46 @@
+package zone.moddev.mc.orespawn.api;
+
+import java.util.Optional;
+
+import net.minecraft.resources.ResourceLocation;
+
+/**
+ * Extended placement context supplied by OreSpawn 4.1 and later.
+ *
+ * The identity values are stable for the current chunk in both ordinary
+ * generation and supported retrogen. Patterns should combine them with their
+ * own stable definition identifier and salt instead of relying on invocation
+ * order or the mutable state of {@link #random()}.
+ *
+ * This is a secondary interface so implementations compiled against the
+ * original {@link OrePlacementContext} contract remain binary compatible.
+ */
+public interface OreGenerationContext extends OrePlacementContext {
+ /**
+ * Attempts placement using a stable identity shared by every slice of one
+ * logical deposit body. Implementations compiled before this overload retain
+ * the original placement behaviour.
+ */
+ default boolean tryPlace(int x, int y, int z, long outputIdentity) {
+ return tryPlace(x, y, z);
+ }
+
+ /** Stable seed of the world being generated. */
+ long worldSeed();
+
+ /** Stable namespaced identity of the dimension being generated. */
+ ResourceLocation dimension();
+
+ /** X coordinate of the current chunk, not its minimum block coordinate. */
+ int chunkX();
+
+ /** Z coordinate of the current chunk, not its minimum block coordinate. */
+ int chunkZ();
+
+ /**
+ * Returns the active allocation-light geology sampler when the dimension has
+ * an OreSpawn geology configuration. Pattern implementations should cache no
+ * world or implementation objects beyond the placement call.
+ */
+ Optional geologySampler();
+}
diff --git a/src/main/java/zone/moddev/mc/orespawn/api/WorldgenProvider.java b/src/main/java/zone/moddev/mc/orespawn/api/WorldgenProvider.java
index 241f8700..c7e2e584 100644
--- a/src/main/java/zone/moddev/mc/orespawn/api/WorldgenProvider.java
+++ b/src/main/java/zone/moddev/mc/orespawn/api/WorldgenProvider.java
@@ -11,6 +11,7 @@
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
+import java.util.Optional;
import java.util.Set;
import java.util.function.Consumer;
@@ -47,7 +48,7 @@ public int revision() {
return revision;
}
- /** Returns a defensive JSON representation matching provider schema 4. */
+ /** Returns a defensive JSON representation matching provider schema 5. */
public JsonObject toJson() {
return JsonCopies.copy(definition);
}
@@ -209,7 +210,7 @@ public WorldgenProvider build() {
requireOwned(dimensionMaterials.keySet(), "dimension materials");
requireOwned(templates.keySet(), "template");
JsonObject root = new JsonObject();
- root.addProperty("schema_version", 4);
+ root.addProperty("schema_version", 5);
root.addProperty("provider_modid", modId);
root.addProperty("provider_revision", revision);
root.add("rocks", object(rocks));
@@ -347,6 +348,7 @@ public RockDefinition build() {
public static final class OreDefinition implements JsonDefinition {
private final ResourceLocation id;
private final ResourceLocation block;
+ private final ResourceLocation material;
private final boolean enabled;
private final boolean nativeGeneration;
private final ResourceLocation deepOutput;
@@ -360,6 +362,7 @@ public static final class OreDefinition implements JsonDefinition {
private OreDefinition(Builder builder) {
id = builder.id;
block = builder.block;
+ material = builder.material;
enabled = builder.enabled;
nativeGeneration = builder.nativeGeneration;
deepOutput = builder.deepOutput;
@@ -375,6 +378,8 @@ private OreDefinition(Builder builder) {
public static Builder builder(ResourceLocation id, ResourceLocation block) { return new Builder(id, block); }
public ResourceLocation id() { return id; }
public ResourceLocation block() { return block; }
+ /** Material shared by interchangeable ore outputs. */
+ public Optional material() { return Optional.ofNullable(material); }
public boolean enabled() { return enabled; }
public List outputs() { return outputs; }
public boolean suppressVanilla() { return suppressVanilla; }
@@ -386,6 +391,7 @@ private OreDefinition(Builder builder) {
public JsonObject toJson() {
JsonObject json = new JsonObject();
json.addProperty("block", block.toString());
+ if (material != null) json.addProperty("material", material.toString());
json.addProperty("enabled", enabled);
json.addProperty("native_generation", nativeGeneration);
json.addProperty("suppress_vanilla", suppressVanilla);
@@ -411,6 +417,7 @@ public JsonObject toJson() {
public static final class Builder {
private final ResourceLocation id;
private final ResourceLocation block;
+ private ResourceLocation material;
private boolean enabled = true;
private boolean nativeGeneration;
private ResourceLocation deepOutput;
@@ -427,6 +434,7 @@ private Builder(ResourceLocation id, ResourceLocation block) {
this.block = Objects.requireNonNull(block, "block");
}
public Builder enabled(boolean value) { enabled = value; return this; }
+ public Builder material(ResourceLocation value) { material = Objects.requireNonNull(value); return this; }
public Builder nativeGeneration(boolean value) { nativeGeneration = value; return this; }
public Builder suppressVanilla(boolean value) { suppressVanilla = value; return this; }
public Builder retrogen(boolean value) { retrogen = value; return this; }
@@ -518,6 +526,7 @@ public static final class OreDimensionDefinition implements JsonDefinition {
private final int maxQuantity;
private final OrePattern pattern;
private final ResourceLocation patternType;
+ private final ResourceLocation placementChannel;
private final JsonObject patternSettings;
private final OreHeightDistribution heightDistribution;
private final double discardChanceOnAirExposure;
@@ -545,6 +554,9 @@ private OreDimensionDefinition(Builder builder) {
maxQuantity = builder.maxQuantity;
pattern = builder.pattern;
patternType = builder.patternType;
+ placementChannel = builder.placementChannel != null ? builder.placementChannel
+ : builder.patternType != null ? builder.patternType
+ : new ResourceLocation("orespawn", "standard");
patternSettings = JsonCopies.copy(builder.patternSettings);
heightDistribution = builder.heightDistribution;
discardChanceOnAirExposure = builder.discardChanceOnAirExposure;
@@ -576,6 +588,8 @@ private OreDimensionDefinition(Builder builder) {
public int maxQuantity() { return maxQuantity; }
public OrePattern pattern() { return pattern; }
public ResourceLocation patternType() { return patternType; }
+ /** Placement frequency, shape and host budget are selected per channel. */
+ public ResourceLocation placementChannel() { return placementChannel; }
public JsonObject patternSettings() { return JsonCopies.copy(patternSettings); }
public OreHeightDistribution heightDistribution() { return heightDistribution; }
public double discardChanceOnAirExposure() { return discardChanceOnAirExposure; }
@@ -614,6 +628,7 @@ public JsonObject toJson() {
configuredPattern.add("settings", JsonCopies.copy(patternSettings));
json.add("pattern", configuredPattern);
}
+ json.addProperty("placement_channel", placementChannel.toString());
json.addProperty("height_distribution", heightDistribution.configName());
json.addProperty("discard_chance_on_air_exposure", discardChanceOnAirExposure);
json.addProperty("spread", spread);
@@ -642,6 +657,7 @@ public static final class Builder {
private int maxQuantity = 8;
private OrePattern pattern = OrePattern.VEIN;
private ResourceLocation patternType;
+ private ResourceLocation placementChannel;
private JsonObject patternSettings = new JsonObject();
private OreHeightDistribution heightDistribution = OreHeightDistribution.UNIFORM;
private double discardChanceOnAirExposure;
@@ -678,6 +694,10 @@ public Builder pattern(ResourceLocation type, JsonObject settings) {
patternSettings = JsonCopies.copy(Objects.requireNonNull(settings, "settings"));
return this;
}
+ public Builder placementChannel(ResourceLocation value) {
+ placementChannel = Objects.requireNonNull(value);
+ return this;
+ }
public Builder heightDistribution(OreHeightDistribution value) { heightDistribution = Objects.requireNonNull(value); return this; }
public Builder discardChanceOnAirExposure(double value) { discardChanceOnAirExposure = value; return this; }
public Builder spread(int horizontal, int vertical) { spread = horizontal; verticalSpread = vertical; return this; }
diff --git a/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtension.java b/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtension.java
new file mode 100644
index 00000000..439c733d
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtension.java
@@ -0,0 +1,32 @@
+package zone.moddev.mc.orespawn.api.client;
+
+import java.util.Objects;
+
+import net.minecraft.client.gui.screens.Screen;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraftforge.api.distmarker.Dist;
+import net.minecraftforge.api.distmarker.OnlyIn;
+
+/** Immutable registered action displayed by OreSpawn's world-settings screen. */
+@OnlyIn(Dist.CLIENT)
+public final class WorldSettingsExtension {
+ private final ResourceLocation id;
+ private final String buttonTranslationKey;
+ private final WorldSettingsScreenFactory screenFactory;
+
+ WorldSettingsExtension(ResourceLocation id, String buttonTranslationKey,
+ WorldSettingsScreenFactory screenFactory) {
+ this.id = Objects.requireNonNull(id, "id");
+ this.buttonTranslationKey = Objects.requireNonNull(buttonTranslationKey,
+ "buttonTranslationKey");
+ this.screenFactory = Objects.requireNonNull(screenFactory, "screenFactory");
+ }
+
+ public ResourceLocation id() { return id; }
+ public String buttonTranslationKey() { return buttonTranslationKey; }
+
+ /** Creates the extension screen with OreSpawn's Mods directory as its parent. */
+ public Screen createScreen(Screen parent) {
+ return screenFactory.create(Objects.requireNonNull(parent, "parent"));
+ }
+}
diff --git a/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistry.java b/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistry.java
new file mode 100644
index 00000000..3db684ce
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistry.java
@@ -0,0 +1,79 @@
+package zone.moddev.mc.orespawn.api.client;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.regex.Pattern;
+
+import net.minecraft.resources.ResourceLocation;
+import net.minecraftforge.api.distmarker.Dist;
+import net.minecraftforge.api.distmarker.OnlyIn;
+
+/** Client-only registry for add-on screens in OreSpawn's Mods directory. */
+@OnlyIn(Dist.CLIENT)
+public final class WorldSettingsExtensionRegistry {
+ private static final Pattern MOD_ID = Pattern.compile("^[a-z][a-z0-9_.-]{1,63}$");
+ private static final Map EXTENSIONS =
+ new LinkedHashMap<>();
+
+ private WorldSettingsExtensionRegistry() {
+ }
+
+ /**
+ * Registers the one optional OreSpawn-linked configuration screen owned by a
+ * loaded mod. Registration must occur during client initialization.
+ *
+ * @throws IllegalArgumentException if the mod identifier is not canonical
+ * @throws IllegalStateException if the mod already registered a screen
+ */
+ public static synchronized WorldSettingsExtension registerConfigScreen(String modId,
+ WorldSettingsScreenFactory screenFactory) {
+ validateModId(modId);
+ return registerOwned(modId, new ResourceLocation(modId, "configuration"),
+ "button.orespawn.mod.configure", screenFactory);
+ }
+
+ /**
+ * Retains the original translated world-settings registration descriptor for
+ * existing add-ons. The identifier namespace owns the one permitted screen.
+ * Registration must occur during client initialization.
+ *
+ * @throws IllegalStateException if the identifier is already registered
+ */
+ public static synchronized WorldSettingsExtension register(ResourceLocation id,
+ String buttonTranslationKey, WorldSettingsScreenFactory screenFactory) {
+ Objects.requireNonNull(id, "id");
+ if (buttonTranslationKey == null || buttonTranslationKey.trim().isEmpty()) {
+ throw new IllegalArgumentException("buttonTranslationKey cannot be blank");
+ }
+ String modId = id.getNamespace();
+ validateModId(modId);
+ return registerOwned(modId, id, buttonTranslationKey, screenFactory);
+ }
+
+ private static WorldSettingsExtension registerOwned(String modId, ResourceLocation id,
+ String buttonTranslationKey, WorldSettingsScreenFactory screenFactory) {
+ Objects.requireNonNull(screenFactory, "screenFactory");
+ if (EXTENSIONS.containsKey(modId)) {
+ throw new IllegalStateException("Duplicate OreSpawn configuration screen for mod: " + modId);
+ }
+ WorldSettingsExtension extension = new WorldSettingsExtension(id, buttonTranslationKey,
+ screenFactory);
+ EXTENSIONS.put(modId, extension);
+ return extension;
+ }
+
+ private static void validateModId(String modId) {
+ if (modId == null || !MOD_ID.matcher(modId).matches()) {
+ throw new IllegalArgumentException("modId must be a canonical lower-case mod identifier");
+ }
+ }
+
+ /** Returns an immutable snapshot in deterministic registration order. */
+ public static synchronized List extensions() {
+ return Collections.unmodifiableList(new ArrayList<>(EXTENSIONS.values()));
+ }
+}
diff --git a/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsScreenFactory.java b/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsScreenFactory.java
new file mode 100644
index 00000000..03d3f7d5
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsScreenFactory.java
@@ -0,0 +1,12 @@
+package zone.moddev.mc.orespawn.api.client;
+
+import net.minecraft.client.gui.screens.Screen;
+import net.minecraftforge.api.distmarker.Dist;
+import net.minecraftforge.api.distmarker.OnlyIn;
+
+/** Creates an add-on configuration screen whose Done action returns to OreSpawn's mod directory. */
+@FunctionalInterface
+@OnlyIn(Dist.CLIENT)
+public interface WorldSettingsScreenFactory {
+ Screen create(Screen parent);
+}
diff --git a/src/main/java/zone/moddev/mc/orespawn/client/BiomeDirectoryModel.java b/src/main/java/zone/moddev/mc/orespawn/client/BiomeDirectoryModel.java
new file mode 100644
index 00000000..edb33437
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/orespawn/client/BiomeDirectoryModel.java
@@ -0,0 +1,344 @@
+package zone.moddev.mc.orespawn.client;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Set;
+
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import zone.moddev.mc.orespawn.integration.WorldgenIntegrationManager.BiomeProviderDefaultsSnapshot;
+import zone.moddev.mc.orespawn.util.JsonCopies;
+
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.resources.ResourceKey;
+import net.minecraft.world.level.biome.Biome;
+import net.minecraftforge.common.BiomeDictionary;
+import net.minecraftforge.fml.ModList;
+import net.minecraftforge.forgespi.language.IModInfo;
+import net.minecraftforge.registries.ForgeRegistries;
+
+/** Immutable projection of loaded biomes, profile palettes, and provider defaults. */
+final class BiomeDirectoryModel {
+ private static final String OVERWORLD = "minecraft:overworld";
+ private static final String NETHER = "minecraft:the_nether";
+ private static final String END = "minecraft:the_end";
+ private static final Method BIOME_NAME = findBiomeNameMethod();
+
+ private BiomeDirectoryModel() { }
+
+ static Snapshot snapshot(JsonObject profile, BiomeProviderDefaultsSnapshot defaults,
+ Iterable dimensions) {
+ List loaded = new ArrayList<>();
+ Map mods = new LinkedHashMap<>();
+ for (IModInfo mod : ModList.get().getMods()) {
+ mods.put(mod.getModId(), new ModInfo(mod.getModId(), mod.getDisplayName(),
+ mod.getVersion().toString()));
+ }
+ mods.putIfAbsent("minecraft", new ModInfo("minecraft", "Minecraft", "1.17.1"));
+ for (Entry, Biome> entry : ForgeRegistries.BIOMES.getEntries()) {
+ ResourceLocation id = entry.getKey().location();
+ ModInfo owner = mods.get(id.getNamespace());
+ loaded.add(new LoadedBiome(id.toString(), biomeName(id, entry.getValue()),
+ owner == null ? id.getNamespace() : owner.name,
+ owner == null ? "?" : owner.version, routineDimension(entry.getKey())));
+ }
+ return assemble(profile, defaults.biomePalettesCopy(),
+ defaults.activeProviderIds(), loaded, dimensions);
+ }
+
+ private static String biomeName(ResourceLocation id, Biome biome) {
+ if (BIOME_NAME == null) return friendly(id.toString());
+ try {
+ return (String) BIOME_NAME.invoke(biome);
+ } catch (IllegalAccessException | InvocationTargetException exception) {
+ return friendly(id.toString());
+ }
+ }
+
+ private static Method findBiomeNameMethod() {
+ for (String name : new String[] { "getBiomeName", "func_185359_l" }) {
+ try {
+ Method method = Biome.class.getDeclaredMethod(name);
+ method.setAccessible(true);
+ return method;
+ } catch (NoSuchMethodException ignored) {
+ // Use the registry name when this runtime has no public biome-name method.
+ }
+ }
+ return null;
+ }
+
+ static Snapshot assemble(JsonObject profile, JsonObject providerPalettes,
+ Set activeProviders, List loadedBiomes,
+ Iterable requestedDimensions) {
+ Map loaded = new LinkedHashMap<>();
+ for (LoadedBiome biome : loadedBiomes) loaded.put(biome.id, biome);
+ Set dimensions = new LinkedHashSet<>();
+ for (String dimension : requestedDimensions) dimensions.add(dimension);
+ JsonObject profilePalettes = object(profile, "biome_palettes");
+ List palettes = new ArrayList<>();
+ int order = 0;
+ for (Entry entry : profilePalettes.entrySet()) {
+ if (!entry.getValue().isJsonObject()) continue;
+ JsonObject palette = entry.getValue().getAsJsonObject();
+ String dimension = string(palette, "dimension", "minecraft:overworld");
+ dimensions.add(dimension);
+ palettes.add(new Palette(entry.getKey(), dimension,
+ string(palette, "source_provider", owner(entry.getKey())),
+ order++, bool(palette, "enabled", true),
+ BiomeReplacementRules.isOverridePalette(entry.getKey()),
+ palette.has("source_provider"), palette));
+ }
+
+ Map> providerBiomes = providerBiomes(providerPalettes);
+ Map> declaredDimensions = declaredDimensions(palettes, providerBiomes);
+ List entries = new ArrayList<>();
+ for (String dimension : dimensions) {
+ Map> placements = placements(palettes, dimension);
+ Map replacements = BiomeReplacementRules.read(profile, dimension);
+ Set ids = new LinkedHashSet<>();
+ for (LoadedBiome biome : loaded.values()) {
+ Set declared = declaredDimensions.getOrDefault(biome.id, Collections.emptySet());
+ if (declared.isEmpty() ? dimension.equals(biome.routineDimension)
+ : declared.contains(dimension)) ids.add(biome.id);
+ }
+ ids.addAll(placements.keySet());
+ ids.addAll(providerBiomes.getOrDefault(dimension, Collections.emptySet()));
+ ids.addAll(replacements.keySet());
+ ids.addAll(replacements.values());
+ for (String id : ids) {
+ LoadedBiome biome = loaded.get(id);
+ List rules = placements.getOrDefault(id, Collections.emptyList());
+ boolean managed = providerBiomes.getOrDefault(dimension, Collections.emptySet()).contains(id);
+ boolean enabled = false;
+ boolean missingProvider = false;
+ for (Placement placement : rules) enabled |= placement.enabled;
+ for (Placement placement : rules) missingProvider |= placement.providerOwned
+ && !activeProviders.contains(placement.owner);
+ Status status;
+ if (biome == null || missingProvider) status = Status.MISSING;
+ else if (replacements.containsKey(id)) status = Status.USER_REPLACED;
+ else if (rules.size() > 1) status = Status.LAYERED;
+ else if (!rules.isEmpty() && !enabled) status = Status.DISABLED;
+ else if (managed || !rules.isEmpty()) status = Status.PROVIDER_MANAGED;
+ else status = Status.UNMANAGED;
+ entries.add(new BiomeEntry(dimension, id,
+ biome == null ? friendly(id) : biome.name,
+ biome == null ? owner(id) : biome.ownerName,
+ biome == null ? "?" : biome.ownerVersion,
+ status, rules, replacements.get(id), managed));
+ }
+ }
+ Comparator entryOrder = Comparator
+ .comparingInt((BiomeEntry entry) -> entry.status.priority)
+ .thenComparing(entry -> entry.name.toLowerCase(Locale.ROOT))
+ .thenComparing(entry -> entry.id)
+ .thenComparing(entry -> entry.dimension);
+ entries.sort(entryOrder);
+ palettes.sort(Comparator.comparingInt(palette -> palette.order));
+ return new Snapshot(entries, palettes, dimensions, activeProviders);
+ }
+
+ static String routineDimension(ResourceKey biome) {
+ if (BiomeDictionary.hasType(biome, BiomeDictionary.Type.NETHER)) return NETHER;
+ if (BiomeDictionary.hasType(biome, BiomeDictionary.Type.END)) return END;
+ return OVERWORLD;
+ }
+
+ private static Map> declaredDimensions(List palettes,
+ Map> providerBiomes) {
+ Map> result = new LinkedHashMap<>();
+ for (Palette palette : palettes) {
+ if (palette.override) continue;
+ for (String id : JsonCopies.keys(object(palette.definition, "biomes"))) {
+ result.computeIfAbsent(id, ignored -> new LinkedHashSet<>()).add(palette.dimension);
+ }
+ }
+ for (Entry> dimension : providerBiomes.entrySet()) {
+ for (String id : dimension.getValue()) {
+ result.computeIfAbsent(id, ignored -> new LinkedHashSet<>()).add(dimension.getKey());
+ }
+ }
+ return result;
+ }
+
+ private static Map> providerBiomes(JsonObject providerPalettes) {
+ Map> result = new LinkedHashMap<>();
+ for (Entry entry : providerPalettes.entrySet()) {
+ if (!entry.getValue().isJsonObject()) continue;
+ JsonObject palette = entry.getValue().getAsJsonObject();
+ String dimension = string(palette, "dimension", "");
+ Set values = result.computeIfAbsent(dimension, ignored -> new LinkedHashSet<>());
+ for (Entry biome : object(palette, "biomes").entrySet()) {
+ values.add(biome.getKey());
+ }
+ }
+ return result;
+ }
+
+ private static Map> placements(List palettes, String dimension) {
+ Map> result = new LinkedHashMap<>();
+ Map lastSurface = new LinkedHashMap<>();
+ for (Palette palette : palettes) {
+ if (!dimension.equals(palette.dimension) || palette.override) continue;
+ for (Entry entry : object(palette.definition, "biomes").entrySet()) {
+ if (!entry.getValue().isJsonObject()) continue;
+ JsonObject placement = entry.getValue().getAsJsonObject();
+ List list = result.computeIfAbsent(entry.getKey(), ignored -> new ArrayList<>());
+ boolean hasSurface = placement.has("surface") && placement.get("surface").isJsonObject()
+ && !placement.getAsJsonObject("surface").entrySet().isEmpty();
+ list.add(new Placement(palette.id, palette.owner, palette.order,
+ palette.enabled && bool(placement, "enabled", true), hasSurface, false,
+ palette.providerOwned, placement));
+ if (palette.enabled && bool(placement, "enabled", true) && hasSurface) {
+ lastSurface.put(entry.getKey(), list.size() - 1);
+ }
+ }
+ }
+ for (Entry> entry : result.entrySet()) {
+ Integer effective = lastSurface.get(entry.getKey());
+ if (effective == null) continue;
+ List updated = new ArrayList<>();
+ for (int i = 0; i < entry.getValue().size(); i++) {
+ Placement value = entry.getValue().get(i);
+ updated.add(value.withEffectiveSurface(i == effective));
+ }
+ entry.setValue(updated);
+ }
+ return result;
+ }
+
+ private static String owner(String id) {
+ try { return new ResourceLocation(id).getNamespace(); }
+ catch (RuntimeException ignored) { return "unknown"; }
+ }
+
+ private static String friendly(String id) {
+ String value;
+ try { value = new ResourceLocation(id).getPath(); }
+ catch (RuntimeException ignored) { value = id; }
+ StringBuilder result = new StringBuilder();
+ for (String part : value.replace('/', '_').split("_")) {
+ if (part.isEmpty()) continue;
+ if (result.length() > 0) result.append(' ');
+ result.append(Character.toUpperCase(part.charAt(0))).append(part.substring(1));
+ }
+ return result.length() == 0 ? id : result.toString();
+ }
+
+ private static JsonObject object(JsonObject root, String key) {
+ return root.has(key) && root.get(key).isJsonObject() ? root.getAsJsonObject(key) : new JsonObject();
+ }
+ private static String string(JsonObject root, String key, String fallback) {
+ return root.has(key) ? root.get(key).getAsString() : fallback;
+ }
+ private static boolean bool(JsonObject root, String key, boolean fallback) {
+ return root.has(key) ? root.get(key).getAsBoolean() : fallback;
+ }
+
+ enum Status {
+ USER_REPLACED(0), LAYERED(1), DISABLED(2), MISSING(3), PROVIDER_MANAGED(4), UNMANAGED(5);
+ final int priority;
+ Status(int priority) { this.priority = priority; }
+ }
+
+ static final class Snapshot {
+ final List entries;
+ final List palettes;
+ final Set dimensions;
+ final Set activeProviders;
+ Snapshot(List entries, List palettes, Set dimensions,
+ Set activeProviders) {
+ this.entries = Collections.unmodifiableList(new ArrayList<>(entries));
+ this.palettes = Collections.unmodifiableList(new ArrayList<>(palettes));
+ this.dimensions = Collections.unmodifiableSet(new LinkedHashSet<>(dimensions));
+ this.activeProviders = Collections.unmodifiableSet(new LinkedHashSet<>(activeProviders));
+ }
+ List entries(String dimension, boolean showAll) {
+ List result = new ArrayList<>();
+ for (BiomeEntry entry : entries) {
+ if (dimension.equals(entry.dimension) && (showAll || entry.status != Status.UNMANAGED)) result.add(entry);
+ }
+ return Collections.unmodifiableList(result);
+ }
+ List palettes(String dimension) {
+ List result = new ArrayList<>();
+ for (Palette palette : palettes) if (dimension.equals(palette.dimension)) result.add(palette);
+ return Collections.unmodifiableList(result);
+ }
+ }
+
+ static final class LoadedBiome {
+ final String id, name, ownerName, ownerVersion, routineDimension;
+ LoadedBiome(String id, String name, String ownerName, String ownerVersion) {
+ this(id, name, ownerName, ownerVersion, OVERWORLD);
+ }
+ LoadedBiome(String id, String name, String ownerName, String ownerVersion,
+ String routineDimension) {
+ this.id = id; this.name = name; this.ownerName = ownerName; this.ownerVersion = ownerVersion;
+ this.routineDimension = routineDimension;
+ }
+ }
+
+ static final class BiomeEntry {
+ final String dimension, id, name, ownerName, ownerVersion;
+ final Status status;
+ final List placements;
+ final String replacementTarget;
+ final boolean providerDeclared;
+ BiomeEntry(String dimension, String id, String name, String ownerName, String ownerVersion,
+ Status status, List placements, String replacementTarget,
+ boolean providerDeclared) {
+ this.dimension = dimension; this.id = id; this.name = name;
+ this.ownerName = ownerName; this.ownerVersion = ownerVersion; this.status = status;
+ this.placements = Collections.unmodifiableList(new ArrayList<>(placements));
+ this.replacementTarget = replacementTarget; this.providerDeclared = providerDeclared;
+ }
+ }
+
+ static final class Palette {
+ final String id, dimension, owner;
+ final int order;
+ final boolean enabled, override, providerOwned;
+ final JsonObject definition;
+ Palette(String id, String dimension, String owner, int order, boolean enabled,
+ boolean override, boolean providerOwned, JsonObject definition) {
+ this.id = id; this.dimension = dimension; this.owner = owner; this.order = order;
+ this.enabled = enabled; this.override = override; this.providerOwned = providerOwned;
+ this.definition = JsonCopies.copy(definition);
+ }
+ }
+
+ static final class Placement {
+ final String paletteId, owner;
+ final int order;
+ final boolean enabled, hasSurface, effectiveSurface, providerOwned;
+ final JsonObject definition;
+ Placement(String paletteId, String owner, int order, boolean enabled,
+ boolean hasSurface, boolean effectiveSurface, boolean providerOwned,
+ JsonObject definition) {
+ this.paletteId = paletteId; this.owner = owner; this.order = order;
+ this.enabled = enabled; this.hasSurface = hasSurface;
+ this.effectiveSurface = effectiveSurface; this.providerOwned = providerOwned;
+ this.definition = JsonCopies.copy(definition);
+ }
+ Placement withEffectiveSurface(boolean value) {
+ return new Placement(paletteId, owner, order, enabled, hasSurface, value,
+ providerOwned, definition);
+ }
+ }
+
+ private static final class ModInfo {
+ final String id, name, version;
+ ModInfo(String id, String name, String version) { this.id = id; this.name = name; this.version = version; }
+ }
+}
diff --git a/src/main/java/zone/moddev/mc/orespawn/client/BiomePaletteScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/BiomePaletteScreen.java
index b1ccf10c..4160ab4f 100644
--- a/src/main/java/zone/moddev/mc/orespawn/client/BiomePaletteScreen.java
+++ b/src/main/java/zone/moddev/mc/orespawn/client/BiomePaletteScreen.java
@@ -1,23 +1,29 @@
package zone.moddev.mc.orespawn.client;
+import java.util.ArrayList;
import java.util.List;
-import com.mojang.blaze3d.vertex.PoseStack;
-import net.minecraft.client.gui.components.Button;
+import zone.moddev.mc.orespawn.client.BiomeDirectoryModel.Palette;
+
+import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.CommonComponents;
import net.minecraft.network.chat.TextComponent;
import net.minecraft.network.chat.TranslatableComponent;
-final class BiomePaletteScreen extends Screen {
+/** Lists every palette in effective profile order for one dimension. */
+final class BiomePaletteScreen extends OreSpawnScreen {
private final Screen parent;
private final GeologyEditorSession session;
private final String dimension;
- private int page;
+ private CompactScrollList list;
+ private int scroll;
+ private String armedPalette;
+ private int resetScope;
BiomePaletteScreen(Screen parent, GeologyEditorSession session, String dimension) {
- super(new TranslatableComponent("screen.orespawn.biome_palette"));
+ super(new TranslatableComponent("screen.orespawn.biome_palettes"));
this.parent = parent;
this.session = session;
this.dimension = dimension;
@@ -25,56 +31,92 @@ final class BiomePaletteScreen extends Screen {
@Override
protected void init() {
- int contentWidth = Math.min(390, Math.max(280, width - 24));
+ OreSpawnScreenLayout.beginHelp(this);
+ int contentWidth = Math.min(520, Math.max(300, width - 20));
int left = (width - contentWidth) / 2;
- int removeWidth = 70;
- int listTop = 48;
- int controlsY = height - 52;
- List ids = session.biomePlacementIds(dimension);
- int pageSize = Math.max(1, (controlsY - listTop) / 24);
- int pageCount = Math.max(1, (ids.size() + pageSize - 1) / pageSize);
- page = Math.max(0, Math.min(page, pageCount - 1));
- int start = page * pageSize;
- for (int i = 0; i < pageSize && start + i < ids.size(); i++) {
- String id = ids.get(start + i);
- int y = listTop + i * 24;
- addRenderableWidget(OreSpawnScreenLayout.button(this, font, left, y,
- contentWidth - removeWidth - 5, 20, new TextComponent(id),
- button -> minecraft.setScreen(new BiomePlacementScreen(this, session,
- dimension, id))));
- addRenderableWidget(new Button(left + contentWidth - removeWidth, y, removeWidth, 20,
- new TranslatableComponent("button.orespawn.remove"),
- button -> { session.removeBiomePlacement(dimension, id); rebuildWidgets(); }));
- }
- Button previous = addRenderableWidget(new Button(left, controlsY, 45, 20,
- new TextComponent("<"), button -> { page--; rebuildWidgets(); }));
- Button next = addRenderableWidget(new Button(left + 50, controlsY, 45, 20,
- new TextComponent(">"), button -> { page++; rebuildWidgets(); }));
- previous.active = page > 0;
- next.active = page + 1 < pageCount;
- addRenderableWidget(OreSpawnScreenLayout.button(this, font,
- left + contentWidth - 150, controlsY, 150, 20,
- new TranslatableComponent("button.orespawn.add_biome"),
- button -> minecraft.setScreen(new BiomePickerScreen(this, session, id -> {
- session.addBiomePlacement(dimension, id);
- minecraft.setScreen(new BiomePlacementScreen(this, session, dimension, id));
- }))));
- addRenderableWidget(new Button(width / 2 - 75, height - 28, 150, 20,
+ List palettes = session.biomeDirectory().palettes(dimension);
+ int listTop = 38;
+ int listHeight = Math.max(32, height - listTop - 62);
+ list = addButton(new CompactScrollList(this, left, listTop, contentWidth, listHeight) {
+ @Override protected int size() { return palettes.size(); }
+ @Override protected String rowText(int index) {
+ Palette palette = palettes.get(index);
+ return (palette.enabled ? "[x] " : "[ ] ") + (palette.order + 1) + ". " + palette.id;
+ }
+ @Override protected boolean rowEnabled(int index) { return !palettes.get(index).override; }
+ @Override protected int rowColor(int index) {
+ return palettes.get(index).override ? 0x55FFFF : palettes.get(index).enabled ? 0xFFFFFF : 0x888888;
+ }
+ @Override protected void onRowPressed(int index) {
+ Palette palette = palettes.get(index);
+ if (!palette.override) minecraft.setScreen(new BiomePaletteSettingsScreen(
+ BiomePaletteScreen.this, session, palette.id));
+ }
+ @Override protected int rowActionWidth(int index) { return 18; }
+ @Override protected void drawRowAction(Minecraft minecraft, int index, int x, int y,
+ int width, int height, boolean hovered) {
+ drawCenteredString(font, armedPalette != null && armedPalette.equals(palettes.get(index).id)
+ ? "!" : "R", x + width / 2, y + 4, hovered ? 0xFFFFFF : 0xAAAAAA);
+ }
+ @Override protected void onRowActionPressed(int index) { resetPalette(palettes.get(index)); }
+ @Override protected List rowTooltip(int index) {
+ Palette palette = palettes.get(index);
+ List lines = new ArrayList<>();
+ lines.add(palette.id);
+ lines.add(net.minecraft.client.resources.language.I18n.get(
+ "label.orespawn.biome.palette_owner_order", palette.owner, palette.order + 1));
+ if (palette.override) lines.add(net.minecraft.client.resources.language.I18n.get(
+ "tooltip.orespawn.biome.override_palette"));
+ return lines;
+ }
+ @Override protected List rowActionTooltip(int index) {
+ return java.util.Collections.singletonList(net.minecraft.client.resources.language.I18n.get(
+ armedPalette != null && armedPalette.equals(palettes.get(index).id)
+ ? "button.orespawn.biome.confirm_reset_palette"
+ : "button.orespawn.biome.reset_palette"));
+ }
+ });
+ list.setFirstIndex(scroll);
+ int half = (contentWidth - 4) / 2;
+ int resetY = height - 52;
+ addButton(OreSpawnScreenLayout.button(this, font, left, resetY, half, 20,
+ new TranslatableComponent(resetScope == 1 ? "button.orespawn.biome.confirm_reset_dimension"
+ : "button.orespawn.biome.reset_dimension"), button -> resetDimension()));
+ addButton(OreSpawnScreenLayout.button(this, font, left + half + 4, resetY, half, 20,
+ new TranslatableComponent(resetScope == 2 ? "button.orespawn.biome.confirm_reset_all"
+ : "button.orespawn.biome.reset_all"), button -> resetAll()));
+ addButton(new Button(width / 2 - 75, height - 28, 150, 20,
CommonComponents.GUI_DONE, button -> onClose()));
}
- private void rebuildWidgets() {
- clearWidgets();
- init();
+ private void resetPalette(Palette palette) {
+ resetScope = 0;
+ if (!palette.id.equals(armedPalette)) { armedPalette = palette.id; rebuild(); return; }
+ session.resetBiomePalette(palette.id); armedPalette = null; rebuild();
+ }
+
+ private void resetDimension() {
+ armedPalette = null;
+ if (resetScope != 1) { resetScope = 1; rebuild(); return; }
+ session.resetBiomeDimension(dimension); resetScope = 0; rebuild();
}
+ private void resetAll() {
+ armedPalette = null;
+ if (resetScope != 2) { resetScope = 2; rebuild(); return; }
+ session.resetAllBiomeManagement(); resetScope = 0; rebuild();
+ }
+
+ private void rebuild() { if (list != null) scroll = list.firstIndex(); clearWidgets(); init(); }
+
@Override public void onClose() { minecraft.setScreen(parent); }
@Override
- public void render(PoseStack poseStack, int mouseX, int mouseY, float partialTick) {
- renderBackground(poseStack);
- drawCenteredString(poseStack, font, title, width / 2, 12, 0xFFFFFF);
- drawCenteredString(poseStack, font, new TextComponent(dimension), width / 2, 28, 0xCCCCCC);
- super.render(poseStack, mouseX, mouseY, partialTick);
+ public void render(int mouseX, int mouseY, float partialTick) {
+ renderBackground();
+ drawCenteredString(font, title, width / 2, 9, 0xFFFFFF);
+ drawCenteredString(font, new TextComponent(dimension), width / 2, 23, 0xAAAAAA);
+ super.render(mouseX, mouseY, partialTick);
+ OreSpawnScreenLayout.renderExplanations(this, mouseX, mouseY);
}
}
diff --git a/src/main/java/zone/moddev/mc/orespawn/client/BiomePaletteSettingsScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/BiomePaletteSettingsScreen.java
new file mode 100644
index 00000000..57a5ee2a
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/orespawn/client/BiomePaletteSettingsScreen.java
@@ -0,0 +1,166 @@
+package zone.moddev.mc.orespawn.client;
+
+import net.minecraft.network.chat.CommonComponents;
+
+import java.util.Arrays;
+import java.util.LinkedHashSet;
+import java.util.Set;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonPrimitive;
+
+import net.minecraft.client.gui.screens.Screen;
+import net.minecraft.client.gui.components.CycleButton;
+import net.minecraft.network.chat.Component;
+import net.minecraft.network.chat.TextComponent;
+import net.minecraft.network.chat.TranslatableComponent;
+
+/** Edits the complete set of controls already supported by one biome palette. */
+final class BiomePaletteSettingsScreen extends OreSpawnScreen {
+ private final Screen parent;
+ private final GeologyEditorSession session;
+ private final String paletteId;
+ private TextFieldWidget coverage;
+ private TextFieldWidget fallback;
+ private TextFieldWidget included;
+ private TextFieldWidget excluded;
+
+ BiomePaletteSettingsScreen(Screen parent, GeologyEditorSession session, String paletteId) {
+ super(new TranslatableComponent("screen.orespawn.biome_palette_settings"));
+ this.parent = parent;
+ this.session = session;
+ this.paletteId = paletteId;
+ }
+
+ @Override
+ protected void init() {
+ OreSpawnScreenLayout.beginHelp(this);
+ JsonObject palette = session.biomePaletteById(paletteId);
+ if (palette == null) { onClose(); return; }
+ int contentWidth = Math.min(430, Math.max(300, width - 20));
+ int left = (width - contentWidth) / 2;
+ int half = (contentWidth - 4) / 2;
+ int y = 38;
+ addButton(CycleButton.onOffBuilder(bool(palette, "enabled", true)).create(
+ left, y, contentWidth, 20, new TranslatableComponent("option.orespawn.enabled"),
+ (button, value) -> palette.addProperty("enabled", value)));
+ y += 24;
+ addButton(CycleButton.builder(this::modeName)
+ .withValues(Arrays.asList("augment", "replace"))
+ .withInitialValue(string(palette, "mode", "augment"))
+ .create(left, y, half, 20, new TranslatableComponent("option.orespawn.biome_mode"),
+ (button, value) -> palette.addProperty("mode", value)));
+ addButton(CycleButton.builder(this::scopeName)
+ .withValues(Arrays.asList("all", "minecraft_only", "selected_namespaces"))
+ .withInitialValue(string(palette, "scope", "minecraft_only"))
+ .create(left + half + 4, y, half, 20,
+ new TranslatableComponent("option.orespawn.biome_scope"),
+ (button, value) -> palette.addProperty("scope", value)));
+ y += 24;
+ addButton(CycleButton.builder(this::regionName)
+ .withValues(Arrays.asList("tiny", "small", "average", "large", "huge"))
+ .withInitialValue(string(palette, "region_size", "average"))
+ .create(left, y, contentWidth, 20,
+ new TranslatableComponent("option.orespawn.biome_region_size"),
+ (button, value) -> palette.addProperty("region_size", value)));
+ y += 24;
+ coverage = field(left, y, half, decimal(palette, "coverage", 1.0D),
+ "option.orespawn.biome.coverage");
+ fallback = field(left + half + 4, y, half, decimal(palette, "fallback_weight", 1.0D),
+ "option.orespawn.biome.fallback_weight");
+ y += 24;
+ included = addButton(new TextFieldWidget(font, left, y, contentWidth, 20,
+ new TranslatableComponent("option.orespawn.biome.include_namespaces")));
+ included.setValue(join(palette.get("include_namespaces")));
+ y += 24;
+ excluded = addButton(new TextFieldWidget(font, left, y, contentWidth, 20,
+ new TranslatableComponent("option.orespawn.biome.exclude_namespaces")));
+ excluded.setValue(join(palette.get("exclude_namespaces")));
+ y += 24;
+ Button info = addButton(OreSpawnScreenLayout.explainedButton(this, font, left, y,
+ contentWidth, 20, new TranslatableComponent("label.orespawn.biome.palette_entries",
+ session.biomePlacementIdsForPalette(paletteId).size()), button -> { },
+ "tooltip.orespawn.biome.palette_entries_read_only"));
+ info.active = false;
+ addButton(new Button(width / 2 - 75, height - 28, 150, 20,
+ CommonComponents.GUI_DONE, button -> onClose()));
+ }
+
+ private TextFieldWidget field(int x, int y, int width, double value, String label) {
+ TextFieldWidget field = addButton(new TextFieldWidget(font, x, y, width, 20,
+ new TranslatableComponent(label)));
+ field.setValue(Double.toString(value));
+ return field;
+ }
+
+ private void save() {
+ JsonObject palette = session.biomePaletteById(paletteId);
+ if (palette == null) return;
+ putDecimal(palette, "coverage", coverage, 0.0D, 1.0D);
+ putDecimal(palette, "fallback_weight", fallback, 0.0D, 1000.0D);
+ palette.add("include_namespaces", namespaces(included));
+ palette.add("exclude_namespaces", namespaces(excluded));
+ }
+
+ private static void putDecimal(JsonObject root, String key, TextFieldWidget field,
+ double min, double max) {
+ try {
+ double value = Double.parseDouble(field.getValue().trim());
+ if (Double.isFinite(value)) root.addProperty(key, Math.max(min, Math.min(max, value)));
+ } catch (RuntimeException ignored) { }
+ }
+
+ private static JsonArray namespaces(TextFieldWidget field) {
+ Set values = new LinkedHashSet<>();
+ if (field != null) for (String raw : field.getValue().split(",")) {
+ String value = raw.trim().toLowerCase(java.util.Locale.ROOT);
+ if (value.matches("[a-z][a-z0-9_.-]{0,63}")) values.add(value);
+ }
+ JsonArray result = new JsonArray();
+ for (String value : values) result.add(new JsonPrimitive(value));
+ return result;
+ }
+
+ private static String join(JsonElement element) {
+ if (element == null || !element.isJsonArray()) return "";
+ StringBuilder result = new StringBuilder();
+ for (JsonElement value : element.getAsJsonArray()) {
+ if (result.length() > 0) result.append(", ");
+ result.append(value.getAsString());
+ }
+ return result.toString();
+ }
+
+ private Component modeName(String value) {
+ return new TranslatableComponent("value.orespawn.biome_mode." + value);
+ }
+ private Component scopeName(String value) {
+ return new TranslatableComponent("value.orespawn.biome_scope." + value);
+ }
+ private Component regionName(String value) {
+ return new TranslatableComponent("value.orespawn.preset." + value);
+ }
+
+ @Override public void onClose() { save(); minecraft.setScreen(parent); }
+
+ @Override
+ public void render(int mouseX, int mouseY, float partialTick) {
+ renderBackground();
+ drawCenteredString(font, title, width / 2, 8, 0xFFFFFF);
+ drawCenteredString(font, new TextComponent(paletteId), width / 2, 22, 0xAAAAAA);
+ super.render(mouseX, mouseY, partialTick);
+ OreSpawnScreenLayout.renderExplanations(this, mouseX, mouseY);
+ }
+
+ private static String string(JsonObject root, String key, String fallback) {
+ return root.has(key) ? root.get(key).getAsString() : fallback;
+ }
+ private static boolean bool(JsonObject root, String key, boolean fallback) {
+ return root.has(key) ? root.get(key).getAsBoolean() : fallback;
+ }
+ private static double decimal(JsonObject root, String key, double fallback) {
+ return root.has(key) ? root.get(key).getAsDouble() : fallback;
+ }
+}
diff --git a/src/main/java/zone/moddev/mc/orespawn/client/BiomePlacementScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/BiomePlacementScreen.java
index e17e69c2..00f933c4 100644
--- a/src/main/java/zone/moddev/mc/orespawn/client/BiomePlacementScreen.java
+++ b/src/main/java/zone/moddev/mc/orespawn/client/BiomePlacementScreen.java
@@ -21,6 +21,7 @@ private enum Tab { PLACEMENT, CLIMATE, SURFACE }
private final Screen parent;
private final GeologyEditorSession session;
private final String dimension;
+ private final String paletteId;
private final String biomeId;
private Tab tab = Tab.PLACEMENT;
private EditBox weight;
@@ -32,10 +33,21 @@ private enum Tab { PLACEMENT, CLIMATE, SURFACE }
BiomePlacementScreen(Screen parent, GeologyEditorSession session,
String dimension, String biomeId) {
+ this(parent, session, dimension, paletteId(session, dimension), biomeId);
+ }
+
+ private static String paletteId(GeologyEditorSession session, String dimension) {
+ if (session.biomePaletteId(dimension) == null) session.biomePalette(dimension, true);
+ return session.biomePaletteId(dimension);
+ }
+
+ BiomePlacementScreen(Screen parent, GeologyEditorSession session,
+ String dimension, String paletteId, String biomeId) {
super(new TranslatableComponent("screen.orespawn.biome_placement"));
this.parent = parent;
this.session = session;
this.dimension = dimension;
+ this.paletteId = paletteId;
this.biomeId = biomeId;
}
@@ -54,7 +66,7 @@ protected void init() {
selected -> { saveFields(); tab = value; rebuildWidgets(); }));
button.active = value != tab;
}
- JsonObject placement = session.biomePlacement(dimension, biomeId);
+ JsonObject placement = session.biomePlacementByPalette(paletteId, biomeId);
OreSpawnScreenLayout.explain(this, addRenderableWidget(CycleButton.onOffBuilder(
bool(placement, "enabled", true))
.create(left, 64, contentWidth, 20,
@@ -68,7 +80,7 @@ protected void init() {
half, 20, CommonComponents.GUI_DONE, button -> { saveFields(); onClose(); }));
addRenderableWidget(new Button(left + half + 5, OreSpawnScreenLayout.footerY(height),
half, 20, new TranslatableComponent("button.orespawn.remove"), button -> {
- session.removeBiomePlacement(dimension, biomeId);
+ session.removeBiomePlacementByPalette(paletteId, biomeId);
onClose();
}));
}
@@ -158,7 +170,7 @@ private void label(int x, int y, int labelWidth, String key) {
}
private void saveFields() {
- JsonObject placement = session.biomePlacement(dimension, biomeId);
+ JsonObject placement = session.biomePlacementByPalette(paletteId, biomeId);
if (weight != null) putDouble(placement, "weight", weight, 0.0D, 1000.0D);
if (minTemperature != null) putDouble(placement, "min_temperature", minTemperature, -2.0D, 2.0D);
if (maxTemperature != null) putDouble(placement, "max_temperature", maxTemperature, -2.0D, 2.0D);
diff --git a/src/main/java/zone/moddev/mc/orespawn/client/BiomeReplacementRules.java b/src/main/java/zone/moddev/mc/orespawn/client/BiomeReplacementRules.java
new file mode 100644
index 00000000..ec7f0dfa
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/orespawn/client/BiomeReplacementRules.java
@@ -0,0 +1,153 @@
+package zone.moddev.mc.orespawn.client;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Set;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonPrimitive;
+
+import net.minecraft.resources.ResourceLocation;
+
+/** Encodes exact new-terrain biome replacements in a terminal UI palette. */
+final class BiomeReplacementRules {
+ static final String PREFIX = "orespawn:ui/biome_overrides/";
+
+ private BiomeReplacementRules() { }
+
+ static String paletteId(String dimension) {
+ return PREFIX + dimension.toLowerCase(java.util.Locale.ROOT)
+ .replace(':', '_').replaceAll("[^a-z0-9_./-]", "_");
+ }
+
+ static boolean isOverridePalette(String id) {
+ return id != null && id.startsWith(PREFIX);
+ }
+
+ static Map read(JsonObject root, String dimension) {
+ JsonObject palettes = object(root, "biome_palettes", false);
+ if (palettes == null) return Collections.emptyMap();
+ JsonElement value = palettes.get(paletteId(dimension));
+ if (value == null || !value.isJsonObject()) return Collections.emptyMap();
+ JsonObject palette = value.getAsJsonObject();
+ Map result = new LinkedHashMap<>();
+ JsonObject biomes = object(palette, "biomes", false);
+ if (biomes == null) return Collections.emptyMap();
+ for (Entry target : biomes.entrySet()) {
+ if (!target.getValue().isJsonObject()) continue;
+ JsonElement sources = target.getValue().getAsJsonObject().get("similar_biomes");
+ if (sources == null || !sources.isJsonArray()) continue;
+ for (JsonElement source : sources.getAsJsonArray()) {
+ try { result.put(new ResourceLocation(source.getAsString()).toString(),
+ new ResourceLocation(target.getKey()).toString()); }
+ catch (RuntimeException ignored) { }
+ }
+ }
+ return Collections.unmodifiableMap(result);
+ }
+
+ static void set(JsonObject root, String dimension, String source, String target) {
+ String normalizedSource = new ResourceLocation(source).toString();
+ String normalizedTarget = new ResourceLocation(target).toString();
+ if (normalizedSource.equals(normalizedTarget)) {
+ throw new IllegalArgumentException("A biome cannot replace itself");
+ }
+ Map mappings = new LinkedHashMap<>(read(root, dimension));
+ String terminal = terminal(mappings, normalizedTarget, normalizedSource);
+ for (Entry entry : new ArrayList<>(mappings.entrySet())) {
+ if (normalizedSource.equals(entry.getValue())) mappings.put(entry.getKey(), terminal);
+ }
+ mappings.put(normalizedSource, terminal);
+ flatten(mappings);
+ write(root, dimension, mappings);
+ }
+
+ static void remove(JsonObject root, String dimension, String source) {
+ Map mappings = new LinkedHashMap<>(read(root, dimension));
+ mappings.remove(source);
+ write(root, dimension, mappings);
+ }
+
+ static void clearDimension(JsonObject root, String dimension) {
+ JsonObject palettes = object(root, "biome_palettes", false);
+ if (palettes != null) palettes.remove(paletteId(dimension));
+ }
+
+ private static String terminal(Map mappings, String start, String rejected) {
+ Set seen = new LinkedHashSet<>();
+ String current = start;
+ while (mappings.containsKey(current)) {
+ if (!seen.add(current) || rejected.equals(current)) {
+ throw new IllegalArgumentException("Biome replacement cycle");
+ }
+ current = mappings.get(current);
+ }
+ if (rejected.equals(current)) throw new IllegalArgumentException("Biome replacement cycle");
+ return current;
+ }
+
+ private static void flatten(Map mappings) {
+ for (String source : new ArrayList<>(mappings.keySet())) {
+ mappings.put(source, terminal(mappings, mappings.get(source), source));
+ }
+ }
+
+ private static void write(JsonObject root, String dimension, Map mappings) {
+ JsonObject palettes = object(root, "biome_palettes", true);
+ String id = paletteId(dimension);
+ if (mappings.isEmpty()) {
+ palettes.remove(id);
+ return;
+ }
+ JsonObject palette = new JsonObject();
+ palette.addProperty("dimension", dimension);
+ palette.addProperty("enabled", true);
+ palette.addProperty("mode", "replace");
+ palette.addProperty("scope", "all");
+ palette.addProperty("region_size", "tiny");
+ palette.addProperty("coverage", 1.0D);
+ palette.addProperty("fallback_weight", 0.0D);
+ palette.add("include_namespaces", new JsonArray());
+ palette.add("exclude_namespaces", new JsonArray());
+ JsonObject biomes = new JsonObject();
+ Map> byTarget = new LinkedHashMap<>();
+ List sources = new ArrayList<>(mappings.keySet());
+ Collections.sort(sources);
+ for (String source : sources) {
+ byTarget.computeIfAbsent(mappings.get(source), ignored -> new ArrayList<>()).add(source);
+ }
+ List targets = new ArrayList<>(byTarget.keySet());
+ Collections.sort(targets);
+ for (String target : targets) {
+ JsonObject placement = new JsonObject();
+ placement.addProperty("enabled", true);
+ placement.addProperty("weight", 1.0D);
+ JsonArray similar = new JsonArray();
+ for (String source : byTarget.get(target)) similar.add(new JsonPrimitive(source));
+ placement.add("similar_biomes", similar);
+ placement.add("required_similar_biomes", new JsonArray());
+ placement.addProperty("min_temperature", -2.0D);
+ placement.addProperty("max_temperature", 2.0D);
+ placement.addProperty("min_downfall", 0.0D);
+ placement.addProperty("max_downfall", 1.0D);
+ biomes.add(target, placement);
+ }
+ palette.add("biomes", biomes);
+ palettes.add(id, palette);
+ }
+
+ private static JsonObject object(JsonObject root, String key, boolean create) {
+ if (root.has(key) && root.get(key).isJsonObject()) return root.getAsJsonObject(key);
+ if (!create) return null;
+ JsonObject value = new JsonObject();
+ root.add(key, value);
+ return value;
+ }
+}
diff --git a/src/main/java/zone/moddev/mc/orespawn/client/BiomeWorldMaterialsScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/BiomeWorldMaterialsScreen.java
index cfb7e5e6..224227d0 100644
--- a/src/main/java/zone/moddev/mc/orespawn/client/BiomeWorldMaterialsScreen.java
+++ b/src/main/java/zone/moddev/mc/orespawn/client/BiomeWorldMaterialsScreen.java
@@ -1,27 +1,46 @@
package zone.moddev.mc.orespawn.client;
-import java.util.Arrays;
+import net.minecraft.network.chat.CommonComponents;
+
+import java.util.ArrayList;
import java.util.List;
-import com.google.gson.JsonObject;
-import com.mojang.blaze3d.vertex.PoseStack;
-import net.minecraft.client.gui.components.Button;
-import net.minecraft.client.gui.components.CycleButton;
+import zone.moddev.mc.orespawn.client.BiomeDirectoryModel.BiomeEntry;
+import zone.moddev.mc.orespawn.client.BiomeDirectoryModel.Placement;
+import zone.moddev.mc.orespawn.client.BiomeDirectoryModel.Snapshot;
+import zone.moddev.mc.orespawn.client.BiomeDirectoryModel.Status;
+
import net.minecraft.client.gui.screens.Screen;
-import net.minecraft.network.chat.CommonComponents;
+import net.minecraft.client.resources.language.I18n;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.TextComponent;
import net.minecraft.network.chat.TranslatableComponent;
-/** Dimension-level entry point for biome placement and world materials. */
-final class BiomeWorldMaterialsScreen extends Screen {
+/** Responsive directory for biome placement, exact replacements, and diagnostics. */
+final class BiomeWorldMaterialsScreen extends OreSpawnScreen {
+ static final int TWO_PANE_MINIMUM = 400;
+ private static final int CONTENT_TOP = 48;
private final Screen parent;
private final GeologyEditorSession session;
private String dimension;
+ private String selectedId;
+ private boolean showAll;
+ private boolean compactDetail;
+ private boolean resetArmed;
+ private String message;
+ private int biomeScroll;
+ private int ruleScroll;
+ private CompactScrollList biomeList;
+ private CompactScrollList ruleList;
+ private int detailX;
+ private int detailWidth;
+ private int detailRuleTop;
+ private int detailMessageTop;
+ private List detailMessageLines = java.util.Collections.emptyList();
BiomeWorldMaterialsScreen(Screen parent, GeologyEditorSession session) {
- super(new TranslatableComponent("screen.orespawn.biomes_world_materials"));
+ super(new TranslatableComponent("screen.orespawn.biomes"));
this.parent = parent;
this.session = session;
this.dimension = session.availableDimensionIds().get(0);
@@ -30,126 +49,299 @@ final class BiomeWorldMaterialsScreen extends Screen {
@Override
protected void init() {
OreSpawnScreenLayout.beginHelp(this);
- int contentWidth = Math.min(390, Math.max(280, width - 24));
- int left = (width - contentWidth) / 2;
- int half = (contentWidth - 5) / 2;
- List dimensions = session.availableDimensionIds();
+ Snapshot snapshot = session.biomeDirectory();
+ List dimensions = new ArrayList<>(snapshot.dimensions);
+ if (dimensions.isEmpty()) dimensions.add("minecraft:overworld");
if (!dimensions.contains(dimension)) dimension = dimensions.get(0);
- OreSpawnScreenLayout.explain(this, addRenderableWidget(CycleButton.builder(this::dimensionName)
- .withValues(dimensions).withInitialValue(dimension)
- .create(left, 34, contentWidth, 20,
- new TranslatableComponent("option.orespawn.dimension"),
- (button, value) -> { dimension = value; rebuildWidgets(); })),
- "tooltip.orespawn.biome.dimension");
-
- JsonObject palette = session.biomePalette(dimension, false);
- boolean active = palette != null && bool(palette, "enabled", false);
- String mode = palette == null ? "augment" : string(palette, "mode", "augment");
- String scope = palette == null ? "minecraft_only" : string(palette, "scope", "minecraft_only");
- String size = palette == null ? "average" : string(palette, "region_size", "average");
- int y = 64;
- OreSpawnScreenLayout.explain(this, addRenderableWidget(CycleButton.onOffBuilder(active)
- .create(left, y, contentWidth, 20,
- new TranslatableComponent("option.orespawn.biome_palette"),
- (button, value) -> setPaletteEnabled(value))),
- "tooltip.orespawn.biome.palette_enabled");
- y += 26;
- OreSpawnScreenLayout.explain(this, addRenderableWidget(CycleButton.builder(this::modeName)
- .withValues(Arrays.asList("augment", "replace")).withInitialValue(mode)
- .create(left, y, half, 20,
- new TranslatableComponent("option.orespawn.biome_mode"),
- (button, value) -> setPalette("mode", value))),
- "tooltip.orespawn.biome.mode");
- OreSpawnScreenLayout.explain(this, addRenderableWidget(CycleButton.builder(this::scopeName)
- .withValues(Arrays.asList("all", "minecraft_only", "selected_namespaces"))
- .withInitialValue(scope)
- .create(left + half + 5, y, half, 20,
- new TranslatableComponent("option.orespawn.biome_scope"),
- (button, value) -> setPalette("scope", value))),
- "tooltip.orespawn.biome.scope");
- y += 26;
- OreSpawnScreenLayout.explain(this, addRenderableWidget(CycleButton.builder(this::regionName)
- .withValues(Arrays.asList("tiny", "small", "average", "large", "huge"))
- .withInitialValue(size)
- .create(left, y, contentWidth, 20,
- new TranslatableComponent("option.orespawn.biome_region_size"),
- (button, value) -> setPalette("region_size", value))),
- "tooltip.orespawn.biome.region_size");
- y += 30;
- addRenderableWidget(OreSpawnScreenLayout.explainedButton(this, font, left, y, half, 20,
- new TranslatableComponent("button.orespawn.biome_palette_count",
- session.biomePlacementIds(dimension).size()),
- button -> minecraft.setScreen(new BiomePaletteScreen(this, session, dimension)),
- "tooltip.orespawn.biome.entries"));
- addRenderableWidget(OreSpawnScreenLayout.explainedButton(this, font, left + half + 5, y, half, 20,
- new TranslatableComponent("button.orespawn.dimension_materials"),
- button -> minecraft.setScreen(new DimensionMaterialsScreen(this, session, dimension)),
+ List entries = snapshot.entries(dimension, showAll);
+ if (selectedId == null || find(entries, selectedId) == null) {
+ selectedId = entries.isEmpty() ? null : entries.get(0).id;
+ }
+ boolean compact = width < TWO_PANE_MINIMUM;
+ if (compact && compactDetail && selectedId != null) initDetail(snapshot, true);
+ else initDirectory(snapshot, entries, compact);
+ }
+
+ private void initDirectory(Snapshot snapshot, List entries, boolean compact) {
+ int contentWidth = contentWidth(width);
+ int left = (width - contentWidth) / 2;
+ int leftWidth = compact ? contentWidth : leftPaneWidth(width);
+ int headerWidth = Math.max(76, Math.min(leftWidth - 74, 150));
+ addButton(OreSpawnScreenLayout.explainedButton(this, font, left, 24, headerWidth, 20,
+ dimensionSelectorLabel(dimension), button -> cycleDimension(snapshot),
+ "tooltip.orespawn.biome.dimension"));
+ addButton(OreSpawnScreenLayout.button(this, font, left + leftWidth - 68, 24, 68, 20,
+ new TranslatableComponent(showAll ? "button.orespawn.biome.hide_routine"
+ : "button.orespawn.show_all"), button -> {
+ showAll = !showAll; selectedId = null; biomeScroll = 0; rebuild();
+ }));
+ int listHeight = listHeight(height);
+ biomeList = addButton(new CompactScrollList(this, left, CONTENT_TOP, leftWidth, listHeight) {
+ @Override protected int size() { return entries.size(); }
+ @Override protected String rowText(int index) {
+ BiomeEntry entry = entries.get(index);
+ return statusMarker(entry.status) + ' ' + entry.name;
+ }
+ @Override protected int rowColor(int index) { return statusColor(entries.get(index).status); }
+ @Override protected boolean rowSelected(int index) { return entries.get(index).id.equals(selectedId); }
+ @Override protected void onRowPressed(int index) {
+ selectedId = entries.get(index).id;
+ if (compact) compactDetail = true;
+ ruleScroll = 0; resetArmed = false; clearMessage(); rebuild();
+ }
+ @Override protected List rowTooltip(int index) { return biomeTooltip(entries.get(index)); }
+ });
+ biomeList.setFirstIndex(biomeScroll);
+ addButton(OreSpawnScreenLayout.explainedButton(this, font, left,
+ materialsButtonY(height), leftWidth, 20,
+ new TranslatableComponent("button.orespawn.dimension_materials_named",
+ dimensionName(dimension)),
+ button -> minecraft.setScreen(
+ new DimensionMaterialsScreen(this, session, dimension)),
"tooltip.orespawn.biome.dimension_materials"));
- y += 26;
- addRenderableWidget(OreSpawnScreenLayout.explainedButton(this, font, left, y, contentWidth, 20,
- new TranslatableComponent("button.orespawn.geome_influences"),
- button -> minecraft.setScreen(new GeomeBiomeScreen(this, session)),
- "tooltip.orespawn.biome.geome_influences"));
- addRenderableWidget(new Button(width / 2 - 75, OreSpawnScreenLayout.footerY(height),
- 150, 20, CommonComponents.GUI_DONE, button -> onClose()));
- }
-
- private void setPaletteEnabled(boolean enabled) {
- JsonObject palette = session.biomePalette(dimension, true);
- if (enabled && session.biomePlacementIds(dimension).isEmpty()) {
- minecraft.setScreen(new BiomePickerScreen(this, session,
- id -> {
- session.addBiomePlacement(dimension, id);
- minecraft.setScreen(new BiomePlacementScreen(this, session, dimension, id));
+ if (!compact) initDetail(snapshot, false);
+ addButton(new Button(width / 2 - 75, height - 28, 150, 20,
+ CommonComponents.GUI_DONE, button -> onClose()));
+ }
+
+ private void initDetail(Snapshot snapshot, boolean compact) {
+ BiomeEntry selected = find(snapshot.entries(dimension, true), selectedId);
+ if (selected == null) return;
+ int contentWidth = contentWidth(width);
+ int contentLeft = (width - contentWidth) / 2;
+ int leftWidth = compact ? 0 : leftPaneWidth(width);
+ int x = compact ? contentLeft : contentLeft + leftWidth + 10;
+ int paneWidth = compact ? contentWidth : contentWidth - leftWidth - 10;
+ detailX = x;
+ detailWidth = paneWidth;
+ int y = detailTop(compact);
+ if (compact) {
+ addButton(OreSpawnScreenLayout.button(this, font, contentLeft, 8, 48, 20,
+ new TranslatableComponent("button.orespawn.back"), button -> {
+ captureScroll(); compactDetail = false; rebuild();
}));
- return;
}
- palette.addProperty("enabled", enabled);
+ Component replacement = selected.replacementTarget == null
+ ? new TranslatableComponent("button.orespawn.biome.replace_new_terrain")
+ : new TranslatableComponent("button.orespawn.biome.replace_with", selected.replacementTarget);
+ addButton(OreSpawnScreenLayout.explainedButton(this, font, x, y + 35,
+ paneWidth, 20, replacement, button -> chooseReplacement(selected),
+ "tooltip.orespawn.biome.no_retrogen"));
+ if (selected.replacementTarget != null) {
+ addButton(OreSpawnScreenLayout.explainedButton(this, font, x, y + 59,
+ paneWidth, 20,
+ new TranslatableComponent("button.orespawn.biome.leave_original"),
+ button -> { session.leaveBiomeOriginal(dimension, selected.id); clearMessage(); rebuild(); },
+ "tooltip.orespawn.biome.no_retrogen"));
+ }
+ detailMessageLines = wrapMessage(message, paneWidth - 6);
+ detailMessageTop = detailMessageTop(compact, selected.replacementTarget != null);
+ int ruleTop = detailRuleTop(compact, selected.replacementTarget != null,
+ detailMessageLines.size());
+ detailRuleTop = ruleTop;
+ int controlsTop = controlsTop(height, ruleTop);
+ int ruleHeight = Math.max(16, controlsTop - ruleTop - 4);
+ List placements = selected.placements;
+ ruleList = addButton(new CompactScrollList(this, x, ruleTop, paneWidth, ruleHeight) {
+ @Override protected int size() { return placements.size(); }
+ @Override protected String rowText(int index) {
+ Placement rule = placements.get(index);
+ return (rule.enabled ? "[x] " : "[ ] ") + rule.paletteId + " #" + (rule.order + 1)
+ + (rule.effectiveSurface ? " *" : "");
+ }
+ @Override protected int rowColor(int index) { return placements.get(index).enabled ? 0xFFFFFF : 0x888888; }
+ @Override protected void onRowPressed(int index) {
+ Placement rule = placements.get(index);
+ minecraft.setScreen(new BiomePlacementScreen(BiomeWorldMaterialsScreen.this,
+ session, dimension, rule.paletteId, selected.id));
+ }
+ @Override protected List rowTooltip(int index) { return placementTooltip(placements.get(index)); }
+ });
+ ruleList.setFirstIndex(ruleScroll);
+ int half = (paneWidth - 4) / 2;
+ addButton(OreSpawnScreenLayout.button(this, font, x, controlsTop, half, 20,
+ new TranslatableComponent("button.orespawn.biome.palettes", snapshot.palettes(dimension).size()),
+ button -> minecraft.setScreen(new BiomePaletteScreen(this, session, dimension))));
+ addButton(OreSpawnScreenLayout.button(this, font, x + half + 4, controlsTop, half, 20,
+ new TranslatableComponent(resetArmed ? "button.orespawn.biome.confirm_reset"
+ : "button.orespawn.biome.reset_selected"), button -> resetSelected(selected)));
+ addButton(OreSpawnScreenLayout.button(this, font, x, controlsTop + 24, paneWidth, 20,
+ new TranslatableComponent("button.orespawn.geome_influences"),
+ button -> minecraft.setScreen(new GeomeBiomeScreen(this, session))));
+ }
+
+ private void chooseReplacement(BiomeEntry selected) {
+ captureScroll();
+ minecraft.setScreen(new BiomePickerScreen(this, session, target -> {
+ try {
+ session.replaceBiome(dimension, selected.id, target);
+ clearMessage();
+ } catch (IllegalArgumentException failure) {
+ message = failure.getMessage();
+ }
+ minecraft.setScreen(this);
+ }));
}
- private void setPalette(String key, String value) {
- session.biomePalette(dimension, true).addProperty(key, value);
+ private void resetSelected(BiomeEntry selected) {
+ if (!resetArmed) { resetArmed = true; rebuild(); return; }
+ session.resetBiome(dimension, selected.id);
+ resetArmed = false; clearMessage(); rebuild();
}
- private Component dimensionName(String value) {
- return new TextComponent(value);
+ private void cycleDimension(Snapshot snapshot) {
+ dimension = nextDimension(snapshot.dimensions, dimension);
+ selectedId = null; compactDetail = false; biomeScroll = 0; ruleScroll = 0; clearMessage(); rebuild();
}
- private Component modeName(String value) {
- return new TranslatableComponent("value.orespawn.biome_mode." + value);
+ private void clearMessage() {
+ message = null;
}
- private Component scopeName(String value) {
- return new TranslatableComponent("value.orespawn.biome_scope." + value);
+ private List wrapMessage(String text, int maximumWidth) {
+ if (text == null || text.isEmpty()) return java.util.Collections.emptyList();
+ int width = Math.max(40, maximumWidth);
+ List wrapped = new ArrayList<>();
+ String remaining = text.trim();
+ while (!remaining.isEmpty() && wrapped.size() < 2) {
+ String line = font.plainSubstrByWidth(remaining, width);
+ if (line.length() == remaining.length()) {
+ wrapped.add(line);
+ break;
+ }
+ int space = line.lastIndexOf(' ');
+ if (space > 0) line = line.substring(0, space);
+ wrapped.add(line);
+ remaining = remaining.substring(line.length()).trim();
+ }
+ if (!remaining.isEmpty() && !wrapped.isEmpty()) {
+ int last = wrapped.size() - 1;
+ wrapped.set(last, OreSpawnScreenLayout.fit(font,
+ new TextComponent(wrapped.get(last) + "..."), width).getString());
+ }
+ return wrapped;
}
- private Component regionName(String value) {
- return new TranslatableComponent("value.orespawn.preset." + value);
+ static String nextDimension(Iterable available, String current) {
+ List dimensions = new ArrayList<>();
+ for (String candidate : available) dimensions.add(candidate);
+ if (dimensions.isEmpty()) return "minecraft:overworld";
+ int index = dimensions.indexOf(current);
+ return dimensions.get((Math.max(-1, index) + 1) % dimensions.size());
}
- private void rebuildWidgets() {
- clearWidgets();
- init();
+ private static BiomeEntry find(List entries, String id) {
+ if (id != null) for (BiomeEntry entry : entries) if (id.equals(entry.id)) return entry;
+ return null;
}
- @Override
- public void onClose() {
- minecraft.setScreen(parent);
+ static Component dimensionName(String dimension) {
+ switch (dimension) {
+ case "minecraft:overworld": return new TranslatableComponent("value.orespawn.dimension.overworld");
+ case "minecraft:the_nether": return new TranslatableComponent("value.orespawn.dimension.the_nether");
+ case "minecraft:the_end": return new TranslatableComponent("value.orespawn.dimension.the_end");
+ default: return new TextComponent(dimension);
+ }
}
+ private static Component dimensionSelectorLabel(String dimension) {
+ return new TextComponent("< " + dimensionName(dimension).getString() + " >");
+ }
+
+ private static String statusMarker(Status status) {
+ switch (status) {
+ case USER_REPLACED: return "R";
+ case LAYERED: return "!";
+ case DISABLED: return "-";
+ case MISSING: return "?";
+ case PROVIDER_MANAGED: return "+";
+ default: return " ";
+ }
+ }
+
+ private static int statusColor(Status status) {
+ switch (status) {
+ case MISSING: return 0xFF5555;
+ case USER_REPLACED: return 0x55FFFF;
+ case LAYERED: return 0xFFFF55;
+ case DISABLED: return 0xAAAAAA;
+ case PROVIDER_MANAGED: return 0x55FF55;
+ default: return 0xFFFFFF;
+ }
+ }
+
+ private static List biomeTooltip(BiomeEntry entry) {
+ List lines = new ArrayList<>();
+ lines.add(entry.name + " (" + entry.id + ")");
+ lines.add(entry.ownerName + " " + entry.ownerVersion);
+ lines.add(I18n.get("value.orespawn.biome.status." + entry.status.name().toLowerCase(java.util.Locale.ROOT)));
+ lines.add(I18n.get("label.orespawn.biome.rule_count", entry.placements.size()));
+ if (entry.replacementTarget != null) lines.add(I18n.get("label.orespawn.biome.replaced_by", entry.replacementTarget));
+ return lines;
+ }
+
+ private static List placementTooltip(Placement rule) {
+ List lines = new ArrayList<>();
+ lines.add(rule.paletteId);
+ lines.add(I18n.get("label.orespawn.biome.palette_owner_order", rule.owner, rule.order + 1));
+ if (rule.hasSurface) lines.add(I18n.get(rule.effectiveSurface
+ ? "label.orespawn.biome.surface_effective" : "label.orespawn.biome.surface_overridden"));
+ return lines;
+ }
+
+ private void captureScroll() {
+ if (biomeList != null) biomeScroll = biomeList.firstIndex();
+ if (ruleList != null) ruleScroll = ruleList.firstIndex();
+ }
+
+ private void rebuild() { captureScroll(); clearWidgets(); init(); }
+
+ @Override public void onClose() { minecraft.setScreen(parent); }
+
@Override
- public void render(PoseStack poseStack, int mouseX, int mouseY, float partialTick) {
- renderBackground(poseStack);
- drawCenteredString(poseStack, font, title, width / 2, 14, 0xFFFFFF);
- super.render(poseStack, mouseX, mouseY, partialTick);
- OreSpawnScreenLayout.renderExplanations(this, poseStack, mouseX, mouseY);
+ public void render(int mouseX, int mouseY, float partialTick) {
+ renderBackground();
+ drawCenteredString(font, title, width / 2, 9, 0xFFFFFF);
+ BiomeEntry selected = find(session.biomeDirectory().entries(dimension, true), selectedId);
+ if (selected != null && (width >= TWO_PANE_MINIMUM || compactDetail)) {
+ int contentWidth = contentWidth(width);
+ int x = width < TWO_PANE_MINIMUM ? (width - contentWidth) / 2
+ : (width - contentWidth) / 2 + leftPaneWidth(width) + 10;
+ int y = detailTop(width < TWO_PANE_MINIMUM);
+ drawString(font, new TextComponent(selected.name), x + 3, y, 0xFFFFFF);
+ drawString(font, new TextComponent(selected.id), x + 3, y + 11, 0xAAAAAA);
+ drawString(font, new TranslatableComponent("value.orespawn.biome.status."
+ + selected.status.name().toLowerCase(java.util.Locale.ROOT)), x + 3, y + 22,
+ statusColor(selected.status));
+ for (int line = 0; line < detailMessageLines.size(); line++) {
+ drawString(font, new TextComponent(detailMessageLines.get(line)),
+ detailX + 3, detailMessageTop + (line * 10), 0xFF5555);
+ }
+ drawString(font, new TranslatableComponent("label.orespawn.biome.placement_rules"),
+ detailX + 3, detailRuleTop - 10, 0xCCCCCC);
+ }
+ super.render(mouseX, mouseY, partialTick);
+ OreSpawnScreenLayout.renderExplanations(this, mouseX, mouseY);
+ }
+
+ static int contentWidth(int width) { return Math.min(520, Math.max(300, width - 20)); }
+ static int leftPaneWidth(int width) { return Math.max(132, (contentWidth(width) * 43) / 100); }
+ static int listHeight(int height) { return Math.max(32, materialsButtonY(height) - CONTENT_TOP - 4); }
+ static int materialsButtonY(int height) { return height - 52; }
+ static int detailTop(boolean compact) { return compact ? 34 : 24; }
+ static int detailRuleTop(boolean compact, boolean hasReplacement) {
+ return detailTop(compact) + (hasReplacement ? 95 : 71);
+ }
+
+ static int detailMessageTop(boolean compact, boolean hasReplacement) {
+ return detailRuleTop(compact, hasReplacement) - 12;
}
- private static String string(JsonObject root, String key, String fallback) {
- return root.has(key) ? root.get(key).getAsString() : fallback;
+ static int detailRuleTop(boolean compact, boolean hasReplacement, int messageLines) {
+ return detailRuleTop(compact, hasReplacement) + (Math.max(0, Math.min(2, messageLines)) * 10);
}
- private static boolean bool(JsonObject root, String key, boolean fallback) {
- return root.has(key) ? root.get(key).getAsBoolean() : fallback;
+ static int controlsTop(int height, int ruleTop) {
+ return Math.max(ruleTop + 34, height - 76);
}
}
diff --git a/src/main/java/zone/moddev/mc/orespawn/client/Button.java b/src/main/java/zone/moddev/mc/orespawn/client/Button.java
new file mode 100644
index 00000000..81e90a32
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/orespawn/client/Button.java
@@ -0,0 +1,70 @@
+package zone.moddev.mc.orespawn.client;
+
+import com.mojang.blaze3d.vertex.PoseStack;
+
+import net.minecraft.client.gui.GuiComponent;
+import net.minecraft.client.gui.Font;
+import net.minecraft.network.chat.Component;
+import net.minecraft.network.chat.TextComponent;
+
+/** Small bridge for editor controls drawn by Minecraft's pose-stack API. */
+class Button extends net.minecraft.client.gui.components.Button {
+ private final Tooltip tooltip;
+ private PoseStack currentPoseStack;
+
+ Button(int x, int y, int width, int height, String message, OnPress onPress) {
+ this(x, y, width, height, new TextComponent(message), onPress, null);
+ }
+
+ Button(int x, int y, int width, int height, Component message, OnPress onPress) {
+ this(x, y, width, height, message, onPress, null);
+ }
+
+ Button(int x, int y, int width, int height, String message, OnPress onPress,
+ Tooltip tooltip) {
+ this(x, y, width, height, new TextComponent(message), onPress, tooltip);
+ }
+
+ Button(int x, int y, int width, int height, Component message, OnPress onPress,
+ Tooltip tooltip) {
+ super(x, y, width, height, message, onPress);
+ this.tooltip = tooltip;
+ }
+
+ @Override
+ public void render(PoseStack poseStack, int mouseX, int mouseY, float partialTick) {
+ currentPoseStack = poseStack;
+ try {
+ render(mouseX, mouseY, partialTick);
+ } finally {
+ currentPoseStack = null;
+ }
+ }
+
+ public void render(int mouseX, int mouseY, float partialTick) {
+ super.render(currentPoseStack, mouseX, mouseY, partialTick);
+ }
+
+ @Override
+ public void renderButton(PoseStack poseStack, int mouseX, int mouseY, float partialTick) {
+ super.renderButton(poseStack, mouseX, mouseY, partialTick);
+ if (tooltip != null && isHovered()) tooltip.render(this, mouseX, mouseY);
+ }
+
+ protected final PoseStack currentPoseStack() {
+ return currentPoseStack;
+ }
+
+ protected final void fill(int x1, int y1, int x2, int y2, int color) {
+ GuiComponent.fill(currentPoseStack, x1, y1, x2, y2, color);
+ }
+
+ protected final void drawCenteredString(Font font, String text,
+ int x, int y, int color) {
+ GuiComponent.drawCenteredString(currentPoseStack, font, text, x, y, color);
+ }
+
+ interface Tooltip {
+ void render(Button button, int mouseX, int mouseY);
+ }
+}
diff --git a/src/main/java/zone/moddev/mc/orespawn/client/CogButton.java b/src/main/java/zone/moddev/mc/orespawn/client/CogButton.java
new file mode 100644
index 00000000..3aa1a7fe
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/orespawn/client/CogButton.java
@@ -0,0 +1,28 @@
+package zone.moddev.mc.orespawn.client;
+
+import com.mojang.blaze3d.vertex.PoseStack;
+import net.minecraft.client.gui.GuiComponent;
+
+/** Compact settings button used in the Mods directory. */
+final class CogButton extends Button {
+ CogButton(int x, int y, OnPress onPress, Tooltip tooltip) {
+ super(x, y, 22, 20, "", onPress, tooltip);
+ }
+
+ @Override
+ public void render(int mouseX, int mouseY, float partialTick) {
+ super.render(mouseX, mouseY, partialTick);
+ if (!visible) return;
+ drawCog(currentPoseStack(), x + 11, y + 10, active);
+ }
+
+ static void drawCog(PoseStack poseStack, int centerX, int centerY, boolean active) {
+ int color = active ? 0xFFE0E0E0 : 0xFF777777;
+ GuiComponent.fill(poseStack, centerX - 4, centerY - 4, centerX + 5, centerY + 5, color);
+ GuiComponent.fill(poseStack, centerX - 1, centerY - 6, centerX + 2, centerY - 4, color);
+ GuiComponent.fill(poseStack, centerX - 1, centerY + 5, centerX + 2, centerY + 7, color);
+ GuiComponent.fill(poseStack, centerX - 6, centerY - 1, centerX - 4, centerY + 2, color);
+ GuiComponent.fill(poseStack, centerX + 5, centerY - 1, centerX + 7, centerY + 2, color);
+ GuiComponent.fill(poseStack, centerX - 2, centerY - 2, centerX + 3, centerY + 3, 0xFF303030);
+ }
+}
diff --git a/src/main/java/zone/moddev/mc/orespawn/client/CompactScrollList.java b/src/main/java/zone/moddev/mc/orespawn/client/CompactScrollList.java
new file mode 100644
index 00000000..91a6f27c
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/orespawn/client/CompactScrollList.java
@@ -0,0 +1,294 @@
+package zone.moddev.mc.orespawn.client;
+
+import java.util.Collections;
+import java.util.List;
+
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.gui.Font;
+
+/**
+ * Compact text list with independent row and scrollbar input.
+ * It deliberately avoids GuiSlot, whose legacy coordinate handling assumes a
+ * full-width screen and is unsuitable for two adjacent panes.
+ */
+abstract class CompactScrollList extends Button {
+ static final int DEFAULT_ROW_HEIGHT = 16;
+ private static final int SCROLLBAR_WIDTH = 10;
+ private static final int ARROW_HEIGHT = 9;
+ private static final int MIN_THUMB_HEIGHT = 8;
+
+ private final OreSpawnScreen owner;
+ private final int rowHeight;
+ private int firstIndex;
+ private boolean dragging;
+ private int dragOffset;
+ private int hoveredIndex = -1;
+ private boolean hoveredAction;
+ private boolean pointerInside;
+
+ CompactScrollList(OreSpawnScreen owner, int x, int y, int width, int height) {
+ this(owner, x, y, width, height, DEFAULT_ROW_HEIGHT);
+ }
+
+ CompactScrollList(OreSpawnScreen owner, int x, int y, int width, int height, int rowHeight) {
+ super(x, y, width, height, "", null);
+ this.owner = owner;
+ this.rowHeight = Math.max(12, rowHeight);
+ }
+
+ protected abstract int size();
+
+ protected abstract String rowText(int index);
+
+ protected void onRowPressed(int index) { }
+
+ protected boolean rowEnabled(int index) {
+ return true;
+ }
+
+ protected boolean rowSelected(int index) {
+ return false;
+ }
+
+ protected int rowColor(int index) {
+ return rowEnabled(index) ? 0xFFFFFF : 0x777777;
+ }
+
+ protected List rowTooltip(int index) {
+ return Collections.singletonList(rowText(index));
+ }
+
+ protected int rowActionWidth(int index) {
+ return 0;
+ }
+
+ protected void drawRowAction(Minecraft minecraft, int index, int x, int y,
+ int width, int height, boolean hovered) { }
+
+ protected void onRowActionPressed(int index) { }
+
+ protected List rowActionTooltip(int index) {
+ return rowTooltip(index);
+ }
+
+ @Override
+ public void render(int mouseX, int mouseY, float partialTicks) {
+ if (!visible) return;
+ Minecraft minecraft = Minecraft.getInstance();
+ clampFirstIndex();
+ pointerInside = contains(mouseX, mouseY);
+
+ fill(x, y, x + width, y + height, 0x76000000);
+ hoveredIndex = rowAt(mouseX, mouseY);
+ hoveredAction = hoveredIndex >= 0 && isInRowAction(hoveredIndex, mouseX);
+ Font font = minecraft.font;
+ int rows = visibleRows();
+ int contentRight = contentRight();
+ for (int row = 0; row < rows; row++) {
+ int index = firstIndex + row;
+ if (index >= size()) break;
+ int rowY = y + (row * rowHeight);
+ boolean hovered = index == hoveredIndex;
+ if (rowSelected(index)) {
+ fill(x + 1, rowY, contentRight, rowY + rowHeight - 1, 0xA052648C);
+ } else if (hovered) {
+ fill(x + 1, rowY, contentRight, rowY + rowHeight - 1, 0x80404040);
+ }
+ int actionWidth = Math.max(0, rowActionWidth(index));
+ int textWidth = Math.max(0, contentRight - x - actionWidth - 8);
+ String text = fit(font, rowText(index), textWidth);
+ font.drawShadow(currentPoseStack(), text, x + 4,
+ rowY + Math.max(2, (rowHeight - 8) / 2), rowColor(index));
+ if (actionWidth > 0) {
+ int actionX = contentRight - actionWidth;
+ drawRowAction(minecraft, index, actionX, rowY,
+ actionWidth, rowHeight - 1, hovered && mouseX >= actionX);
+ }
+ }
+ drawScrollbar(minecraft, mouseX, mouseY);
+ }
+
+ @Override
+ public boolean mouseClicked(double mouseX, double mouseY, int button) {
+ if (button != 0 || !visible || !active || !contains((int) mouseX, (int) mouseY)) return false;
+ int x = (int) mouseX;
+ int y = (int) mouseY;
+ if (x >= contentRight()) {
+ pressScrollbar(y);
+ return true;
+ }
+ int index = rowAt(x, y);
+ if (index < 0) return true;
+ if (isInRowAction(index, x)) onRowActionPressed(index);
+ else if (rowEnabled(index)) onRowPressed(index);
+ return true;
+ }
+
+ @Override
+ public boolean mouseReleased(double mouseX, double mouseY, int button) {
+ dragging = false;
+ return button == 0 && contains((int) mouseX, (int) mouseY);
+ }
+
+ @Override
+ public boolean mouseDragged(double mouseX, double mouseY, int button,
+ double deltaX, double deltaY) {
+ if (!dragging || button != 0) return false;
+ dragTo((int) mouseY - dragOffset);
+ return true;
+ }
+
+ void renderTooltip(int mouseX, int mouseY) {
+ if (!visible || hoveredIndex < 0 || !contains(mouseX, mouseY)) return;
+ List lines = hoveredAction ? rowActionTooltip(hoveredIndex)
+ : rowTooltip(hoveredIndex);
+ if (lines != null && !lines.isEmpty()) owner.renderStringTooltip(lines, mouseX, mouseY);
+ }
+
+ boolean mouseWheel(int mouseX, int mouseY, int delta) {
+ if (!visible || delta == 0 || !contains(mouseX, mouseY)) return false;
+ scrollBy(delta > 0 ? -3 : 3);
+ return true;
+ }
+
+ @Override
+ public boolean mouseScrolled(double mouseX, double mouseY, double delta) {
+ if (!visible || !active || !contains((int) mouseX, (int) mouseY) || delta == 0.0D) return false;
+ scrollBy(delta > 0.0D ? -3 : 3);
+ return true;
+ }
+
+ void scrollBy(int rows) {
+ setFirstIndex(firstIndex + rows);
+ }
+
+ int firstIndex() {
+ return firstIndex;
+ }
+
+ void setFirstIndex(int index) {
+ firstIndex = Math.max(0, Math.min(index, maxFirst(size(), visibleRows())));
+ }
+
+ void ensureVisible(int index) {
+ if (index < firstIndex) setFirstIndex(index);
+ else if (index >= firstIndex + visibleRows()) setFirstIndex(index - visibleRows() + 1);
+ }
+
+ int visibleRows() {
+ return visibleRows(height, rowHeight);
+ }
+
+ int rowY(int index) {
+ return index < firstIndex || index >= firstIndex + visibleRows()
+ ? -1 : y + ((index - firstIndex) * rowHeight);
+ }
+
+ int contentRight() {
+ return x + width - SCROLLBAR_WIDTH;
+ }
+
+ private boolean contains(int mouseX, int mouseY) {
+ return mouseX >= x && mouseX < x + width
+ && mouseY >= y && mouseY < y + height;
+ }
+
+ private int rowAt(int mouseX, int mouseY) {
+ if (!contains(mouseX, mouseY) || mouseX >= contentRight()) return -1;
+ int index = firstIndex + ((mouseY - y) / rowHeight);
+ return index >= 0 && index < size() ? index : -1;
+ }
+
+ private boolean isInRowAction(int index, int mouseX) {
+ int actionWidth = Math.max(0, rowActionWidth(index));
+ return actionWidth > 0 && mouseX >= contentRight() - actionWidth;
+ }
+
+ private void pressScrollbar(int mouseY) {
+ if (mouseY < y + ARROW_HEIGHT) {
+ scrollBy(-1);
+ return;
+ }
+ if (mouseY >= y + height - ARROW_HEIGHT) {
+ scrollBy(1);
+ return;
+ }
+ int thumbY = thumbY();
+ int thumbHeight = thumbHeight(size(), visibleRows(), trackHeight());
+ if (mouseY >= thumbY && mouseY < thumbY + thumbHeight) {
+ dragging = true;
+ dragOffset = mouseY - thumbY;
+ } else {
+ scrollBy(mouseY < thumbY ? -visibleRows() : visibleRows());
+ }
+ }
+
+ private void dragTo(int thumbTop) {
+ int maximum = maxFirst(size(), visibleRows());
+ int track = trackHeight();
+ int thumb = thumbHeight(size(), visibleRows(), track);
+ int travel = Math.max(1, track - thumb);
+ int relative = Math.max(0, Math.min(travel, thumbTop - (y + ARROW_HEIGHT)));
+ setFirstIndex(firstFromThumb(relative, travel, maximum));
+ }
+
+ private int trackHeight() {
+ return Math.max(1, height - (ARROW_HEIGHT * 2));
+ }
+
+ private int thumbY() {
+ int maximum = maxFirst(size(), visibleRows());
+ int track = trackHeight();
+ int thumb = thumbHeight(size(), visibleRows(), track);
+ int travel = Math.max(0, track - thumb);
+ return y + ARROW_HEIGHT + (maximum == 0 ? 0
+ : Math.round((float) firstIndex * travel / maximum));
+ }
+
+ private void drawScrollbar(Minecraft minecraft, int mouseX, int mouseY) {
+ int left = contentRight();
+ int right = x + width;
+ fill(left, y, right, y + height, 0xB0202020);
+ Font font = minecraft.font;
+ drawCenteredString(currentPoseStack(), font, "^", left + (SCROLLBAR_WIDTH / 2), y, 0xDDDDDD);
+ drawCenteredString(currentPoseStack(), font, "v", left + (SCROLLBAR_WIDTH / 2),
+ y + height - ARROW_HEIGHT, 0xDDDDDD);
+ int thumbY = thumbY();
+ int thumbHeight = thumbHeight(size(), visibleRows(), trackHeight());
+ fill(left + 1, thumbY, right - 1, thumbY + thumbHeight,
+ mouseX >= left && mouseX < right && mouseY >= thumbY
+ && mouseY < thumbY + thumbHeight ? 0xFFE0E0E0 : 0xFF909090);
+ }
+
+ private void clampFirstIndex() {
+ setFirstIndex(firstIndex);
+ }
+
+ private static String fit(Font font, String text, int width) {
+ if (width <= 0) return "";
+ if (font.width(text) <= width) return text;
+ String suffix = "...";
+ return font.plainSubstrByWidth(text,
+ Math.max(0, width - font.width(suffix))) + suffix;
+ }
+
+ static int visibleRows(int height, int rowHeight) {
+ return Math.max(1, Math.max(1, height) / Math.max(1, rowHeight));
+ }
+
+ static int maxFirst(int entries, int visibleRows) {
+ return Math.max(0, Math.max(0, entries) - Math.max(1, visibleRows));
+ }
+
+ static int thumbHeight(int entries, int visibleRows, int trackHeight) {
+ if (entries <= visibleRows) return Math.max(1, trackHeight);
+ return Math.max(MIN_THUMB_HEIGHT,
+ Math.min(trackHeight, (trackHeight * Math.max(1, visibleRows)) / Math.max(1, entries)));
+ }
+
+ static int firstFromThumb(int relative, int travel, int maximum) {
+ if (travel <= 0 || maximum <= 0) return 0;
+ return Math.max(0, Math.min(maximum,
+ Math.round((float) relative * maximum / travel)));
+ }
+}
diff --git a/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java b/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java
index 98bb7137..4fab7c7e 100644
--- a/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java
+++ b/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java
@@ -5,7 +5,9 @@
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
+import java.util.HashMap;
import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
@@ -17,6 +19,7 @@
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
+import com.google.gson.JsonPrimitive;
import zone.moddev.mc.orespawn.worldgen.OreHeightDistribution;
import zone.moddev.mc.orespawn.worldgen.OrePattern;
import zone.moddev.mc.orespawn.worldgen.GeomeConfig;
@@ -24,6 +27,8 @@
import zone.moddev.mc.orespawn.init.OreSpawnPatterns;
import zone.moddev.mc.orespawn.api.OreDimensionSelector;
import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfile;
+import zone.moddev.mc.orespawn.integration.WorldgenIntegrationManager;
+import zone.moddev.mc.orespawn.integration.WorldgenIntegrationManager.BiomeProviderDefaultsSnapshot;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.core.BlockPos;
@@ -34,6 +39,7 @@
import net.minecraft.world.level.block.LiquidBlock;
import net.minecraft.world.item.Items;
import net.minecraftforge.registries.ForgeRegistries;
+import zone.moddev.mc.orespawn.util.FluidBlocks;
/** Mutable client-side copy used until the Create World settings are accepted. */
final class GeologyEditorSession {
@@ -58,6 +64,8 @@ enum MaterialTab {
private final WorldGeologyProfile originalProfile;
private final JsonObject original;
private final JsonObject root;
+ private JsonObject oreSourceOriginal;
+ private final BiomeProviderDefaultsSnapshot biomeProviderDefaults;
private final Set availableDimensionIds = new TreeSet<>();
GeologyEditorSession(WorldGeologyProfile profile) {
@@ -65,11 +73,19 @@ enum MaterialTab {
}
GeologyEditorSession(WorldGeologyProfile profile, Iterable dimensions) {
+ this(profile, dimensions, WorldgenIntegrationManager.biomeProviderDefaults());
+ }
+
+ GeologyEditorSession(WorldGeologyProfile profile, Iterable dimensions,
+ BiomeProviderDefaultsSnapshot defaults) {
originalProfile = profile;
+ biomeProviderDefaults = defaults;
original = profile.rootCopy();
root = profile.rootCopy();
normalizeRegistrySections(original);
normalizeRegistrySections(root);
+ oreSourceOriginal = profile.rootCopy();
+ normalizeRegistrySections(oreSourceOriginal);
rememberDimension("minecraft:overworld");
rememberDimension("minecraft:the_nether");
rememberDimension("minecraft:the_end");
@@ -92,6 +108,44 @@ void applyProfile(WorldGeologyProfile profile) {
normalizeRegistrySections(root);
}
+ void setManageVanillaOres(boolean enabled) {
+ WorldGeologyProfile current = profile();
+ JsonObject refreshed = current.rootCopy();
+ refreshed.addProperty("manage_vanilla_ores", enabled);
+ applyProfile(WorldGeologyProfile.fromGlobalConfig(refreshed,
+ current.geologyMode(), current.placeFluidDeposits()));
+ synchronizeVanillaPlacementSources(enabled);
+ }
+
+ private void synchronizeVanillaPlacementSources(boolean enabled) {
+ JsonObject ores = section("ores");
+ for (Entry entry : section("ore_source_policies").entrySet()) {
+ if (!entry.getValue().isJsonObject()) continue;
+ JsonObject policy = entry.getValue().getAsJsonObject();
+ JsonObject placements = objectEntry(policy, "placement_sources");
+ if (!policy.has("candidates") || !policy.get("candidates").isJsonArray()) continue;
+ for (JsonElement element : policy.getAsJsonArray("candidates")) {
+ if (!element.isJsonObject()) continue;
+ JsonObject candidate = element.getAsJsonObject();
+ String sourceId = string(candidate, "source_id", "");
+ if (!ores.has(sourceId) || !ores.get(sourceId).isJsonObject()
+ || !bool(ores.getAsJsonObject(sourceId), "native_generation", false)) continue;
+ String channel = string(candidate, "placement_channel", "orespawn:standard");
+ if (enabled && bool(candidate, "loaded", false)
+ && bool(candidate, "placement_active", false)
+ && !bool(candidate, "external", false)
+ && !bool(candidate, "enrichment", false)
+ && !placements.has(channel)) {
+ placements.addProperty(channel, sourceId);
+ } else if (!enabled && placements.has(channel)
+ && sourceId.equals(string(placements, channel, ""))) {
+ placements.remove(channel);
+ }
+ }
+ refreshOreSourceStatus(policy);
+ }
+ }
+
JsonObject section(String key) {
return object(root, key);
}
@@ -122,6 +176,13 @@ List availableDimensionIds() {
if (validResource(id)) result.add(new ResourceLocation(id).toString());
}
}
+ for (String sectionName : Arrays.asList("biome_palettes", "dimension_materials")) {
+ for (Entry entry : section(sectionName).entrySet()) {
+ if (!entry.getValue().isJsonObject()) continue;
+ String id = string(entry.getValue().getAsJsonObject(), "dimension", "");
+ if (validResource(id)) result.add(new ResourceLocation(id).toString());
+ }
+ }
List ordered = new ArrayList<>();
for (String vanilla : Arrays.asList("minecraft:overworld", "minecraft:the_nether", "minecraft:the_end")) {
ordered.add(vanilla);
@@ -346,6 +407,775 @@ void disableOrRemoveOre(String id) {
}
}
+ List oreSourceGroups() {
+ List result = new ArrayList<>();
+ for (Entry entry : section("ore_source_policies").entrySet()) {
+ if (!entry.getValue().isJsonObject()) continue;
+ JsonObject policy = entry.getValue().getAsJsonObject();
+ String material = string(policy, "material", "");
+ String domain = string(policy, "domain", "");
+ if (!validResource(material) || !validResource(domain)) continue;
+ JsonObject groupDefinitions = section("ore_material_groups");
+ JsonObject groupDefinition = groupDefinitions.has(material)
+ && groupDefinitions.get(material).isJsonObject()
+ ? groupDefinitions.getAsJsonObject(material) : new JsonObject();
+ String displayName = string(groupDefinition, "display_name", displayName(material));
+ List groupDictionary = stringList(groupDefinition, "block_tag_entries");
+ boolean curated = "orespawn:sulfur".equals(material) || "orespawn:aluminum".equals(material);
+ List candidates = new ArrayList<>();
+ if (policy.has("candidates") && policy.get("candidates").isJsonArray()) {
+ for (JsonElement element : policy.getAsJsonArray("candidates")) {
+ if (!element.isJsonObject()) continue;
+ JsonObject candidate = element.getAsJsonObject();
+ String sourceId = string(candidate, "source_id", "");
+ String channel = string(candidate, "placement_channel", "orespawn:standard");
+ String registryId = string(candidate, "registry_id", "");
+ if (sourceId.isEmpty() || !validResource(channel) || !validResource(registryId)) continue;
+ List dictionary = new ArrayList<>();
+ if (candidate.has("block_tags") && candidate.get("block_tags").isJsonArray()) {
+ for (JsonElement name : candidate.getAsJsonArray("block_tags")) {
+ try { dictionary.add(name.getAsString()); }
+ catch (RuntimeException ignored) { }
+ }
+ }
+ candidates.add(new OreSourceCandidate(sourceId,
+ string(candidate, "owner", ""), string(candidate, "owner_name", ""),
+ string(candidate, "owner_version", ""), registryId,
+ integer(candidate, "metadata", 0), channel, dictionary,
+ bool(candidate, "loaded", false), bool(candidate, "placement_active", true),
+ bool(candidate, "external", false),
+ bool(candidate, "enrichment", false),
+ bool(candidate, "review_required", false)));
+ }
+ }
+ Collections.sort(candidates, (left, right) -> {
+ int owner = left.owner.compareTo(right.owner);
+ return owner != 0 ? owner : left.sourceId.compareTo(right.sourceId);
+ });
+ JsonObject viewPolicy = JsonCopies.copy(policy);
+ refreshOreSourceStatus(viewPolicy);
+ OreSourceGroup group = new OreSourceGroup(entry.getKey(), material, domain,
+ displayName, groupDictionary, curated,
+ string(policy, "mode", "keep_separate"), outputMode(policy),
+ string(viewPolicy, "status", "review_required"),
+ candidates, decimalMap(policy, "outputs"), stringMap(policy, "placement_sources"));
+ if (!bool(policy, "dormant", false)) result.add(group);
+ }
+ Collections.sort(result, (left, right) -> {
+ int name = left.displayName.compareToIgnoreCase(right.displayName);
+ if (name != 0) return name;
+ int material = left.material.compareTo(right.material);
+ return material != 0 ? material : left.domain.compareTo(right.domain);
+ });
+ return Collections.unmodifiableList(result);
+ }
+
+ String addOreMaterialGroup() {
+ JsonObject groups = section("ore_material_groups");
+ JsonObject policies = section("ore_source_policies");
+ String base = "orespawn:custom/custom_group";
+ String id = base;
+ for (int suffix = 2; groups.has(id)
+ || policies.has(id + "|minecraft:overworld"); suffix++) id = base + '_' + suffix;
+ JsonObject definition = new JsonObject();
+ definition.addProperty("display_name", "Custom Group");
+ definition.add("block_tag_entries", new JsonArray());
+ groups.add(id, definition);
+ String key = id + "|minecraft:overworld";
+ JsonObject policy = objectEntry(policies, key);
+ policy.remove("dormant");
+ policy.addProperty("material", id);
+ policy.addProperty("domain", "minecraft:overworld");
+ policy.addProperty("mode", "keep_separate");
+ policy.addProperty("output_mode", "custom");
+ policy.addProperty("status", "separate");
+ policy.addProperty("review_required", false);
+ policy.add("outputs", new JsonObject());
+ policy.add("placement_sources", new JsonObject());
+ policy.add("candidates", new JsonArray());
+ return key;
+ }
+
+ void renameOreMaterialGroup(String material, String displayName) {
+ if (!validResource(material)) return;
+ String value = displayName == null ? "" : displayName.trim();
+ if (value.isEmpty()) return;
+ objectEntry(section("ore_material_groups"), material)
+ .addProperty("display_name", value.length() > 64 ? value.substring(0, 64) : value);
+ }
+
+ String blockTagOwner(String alias) {
+ for (Entry entry : section("ore_material_groups").entrySet()) {
+ if (!entry.getValue().isJsonObject()) continue;
+ if (stringList(entry.getValue().getAsJsonObject(), "block_tag_entries").contains(alias)) {
+ return entry.getKey();
+ }
+ }
+ return null;
+ }
+
+ boolean vanillaOreManagementRequiredForAliasMove(String material, String alias) {
+ if (bool(root, "manage_vanilla_ores", false)) return false;
+ String owner = blockTagOwner(alias);
+ if (owner == null || owner.equals(material)) return false;
+ JsonObject ores = section("ores");
+ for (OreSourceGroup group : oreSourceGroups()) {
+ if (!owner.equals(group.material)) continue;
+ for (OreSourceCandidate candidate : group.candidates) {
+ if (!candidate.loaded || candidate.external || !candidate.blockTag.contains(alias)
+ || !ores.has(candidate.sourceId) || !ores.get(candidate.sourceId).isJsonObject()) continue;
+ if (bool(ores.getAsJsonObject(candidate.sourceId), "native_generation", false)) return true;
+ }
+ }
+ return false;
+ }
+
+ boolean addOreMaterialAlias(String material, String alias, boolean moveExisting) {
+ if (!validResource(material) || !validBlockTagName(alias)) return false;
+ String owner = blockTagOwner(alias);
+ if (owner != null && !owner.equals(material)) {
+ if (!moveExisting) return false;
+ if (vanillaOreManagementRequiredForAliasMove(material, alias)) return false;
+ removeOreMaterialAlias(owner, alias);
+ }
+ JsonObject definition = objectEntry(section("ore_material_groups"), material);
+ List names = stringList(definition, "block_tag_entries");
+ if (!names.contains(alias)) names.add(alias);
+ Collections.sort(names);
+ definition.add("block_tag_entries", strings(names));
+ rebuildOreSourcePolicies();
+ return true;
+ }
+
+ void removeOreMaterialAlias(String material, String alias) {
+ if (!validResource(material)) return;
+ JsonObject definition = objectEntry(section("ore_material_groups"), material);
+ List names = stringList(definition, "block_tag_entries");
+ if (names.remove(alias)) {
+ definition.add("block_tag_entries", strings(names));
+ rebuildOreSourcePolicies();
+ }
+ }
+
+ void resetOreMaterialGroup(String material) {
+ if ("orespawn:sulfur".equals(material)) {
+ setOreMaterialGroup(material, "Sulfur",
+ Arrays.asList("forge:ores/sulfur", "forge:ores/sulphur"));
+ } else if ("orespawn:aluminum".equals(material)) {
+ setOreMaterialGroup(material, "Aluminum",
+ Arrays.asList("forge:ores/aluminum", "forge:ores/aluminium"));
+ }
+ rebuildOreSourcePolicies();
+ }
+
+ boolean deleteEmptyOreMaterialGroup(String material) {
+ if (!isCustomMaterial(material)) return false;
+ JsonObject groups = section("ore_material_groups");
+ if (!groups.has(material) || !groups.get(material).isJsonObject()
+ || !stringList(groups.getAsJsonObject(material), "block_tag_entries").isEmpty()) {
+ return false;
+ }
+ groups.remove(material);
+ markMaterialPoliciesDormant(material);
+ rebuildOreSourcePolicies();
+ return true;
+ }
+
+ boolean dissolveOreMaterialGroup(String material) {
+ if (!isCustomMaterial(material)) return false;
+ JsonObject groups = section("ore_material_groups");
+ if (!groups.has(material) || !groups.get(material).isJsonObject()) return false;
+ List aliases = stringList(groups.getAsJsonObject(material), "block_tag_entries");
+ if (aliases.isEmpty()) return false;
+ groups.remove(material);
+ for (String alias : aliases) {
+ String restoredMaterial = inferredMaterialForAlias(alias);
+ JsonObject definition = objectEntry(groups, restoredMaterial);
+ if (!definition.has("display_name")) {
+ definition.addProperty("display_name", displayName(restoredMaterial));
+ }
+ List restoredAliases = stringList(definition, "block_tag_entries");
+ if (!restoredAliases.contains(alias)) restoredAliases.add(alias);
+ Collections.sort(restoredAliases);
+ definition.add("block_tag_entries", strings(restoredAliases));
+ }
+ markMaterialPoliciesDormant(material);
+ rebuildOreSourcePolicies();
+ return true;
+ }
+
+ private void markMaterialPoliciesDormant(String material) {
+ for (Entry entry : section("ore_source_policies").entrySet()) {
+ if (entry.getValue().isJsonObject()
+ && material.equals(string(entry.getValue().getAsJsonObject(), "material", ""))) {
+ entry.getValue().getAsJsonObject().addProperty("dormant", true);
+ }
+ }
+ }
+
+ boolean oreMaterialGroupsChanged() {
+ return !sectionCopy(original, "ore_material_groups").equals(sectionCopy(root, "ore_material_groups"));
+ }
+
+ JsonObject oreMaterialGroupsCopy() {
+ return sectionCopy(root, "ore_material_groups");
+ }
+
+ void resetOreSourcesToDefaults() {
+ WorldGeologyProfile current = profile();
+ JsonObject reset = current.rootCopy();
+ reset.remove("ore_material_groups");
+ reset.remove("ore_source_policies");
+ WorldGeologyProfile defaults = WorldGeologyProfile.fromGlobalConfig(reset,
+ current.geologyMode(), current.placeFluidDeposits());
+ applyProfile(defaults);
+ oreSourceOriginal = defaults.rootCopy();
+ normalizeRegistrySections(oreSourceOriginal);
+ }
+
+ private void setOreMaterialGroup(String material, String displayName, List aliases) {
+ JsonObject definition = objectEntry(section("ore_material_groups"), material);
+ definition.addProperty("display_name", displayName);
+ definition.add("block_tag_entries", strings(aliases));
+ }
+
+ private void rebuildOreSourcePolicies() {
+ JsonObject policies = section("ore_source_policies");
+ Map> previousMembers = new LinkedHashMap<>();
+ Map candidates = new LinkedHashMap<>();
+ for (Entry entry : policies.entrySet()) {
+ if (!entry.getValue().isJsonObject()) continue;
+ JsonObject policy = entry.getValue().getAsJsonObject();
+ Set members = new LinkedHashSet<>();
+ if (!policy.has("candidates") || !policy.get("candidates").isJsonArray()) continue;
+ boolean hasCandidates = policy.getAsJsonArray("candidates").size() > 0;
+ for (JsonElement element : policy.getAsJsonArray("candidates")) {
+ if (!element.isJsonObject()) continue;
+ JsonObject candidate = JsonCopies.copy(element.getAsJsonObject());
+ String identity = candidateIdentity(candidate, string(policy, "domain", ""));
+ members.add(identity);
+ candidates.put(identity, candidate);
+ }
+ previousMembers.put(entry.getKey(), members);
+ String material = string(policy, "material", "");
+ if (!hasCandidates && section("ore_material_groups").has(material)) {
+ policy.remove("dormant");
+ } else {
+ policy.addProperty("dormant", true);
+ }
+ }
+ Map> regrouped = new LinkedHashMap<>();
+ for (JsonObject candidate : candidates.values()) {
+ String material = string(candidate, "material", "");
+ if (!bool(candidate, "material_declared", false)) {
+ material = materialForAliases(stringList(candidate, "block_tags"));
+ if (!material.isEmpty()) candidate.addProperty("material", material);
+ }
+ String domain = string(candidate, "domain", "minecraft:overworld");
+ if (!validResource(material) || !validResource(domain)) continue;
+ regrouped.computeIfAbsent(material + '|' + domain, ignored -> new ArrayList<>()).add(candidate);
+ }
+ for (Entry> entry : regrouped.entrySet()) {
+ int split = entry.getKey().indexOf('|');
+ String material = entry.getKey().substring(0, split);
+ String domain = entry.getKey().substring(split + 1);
+ JsonObject policy = policies.has(entry.getKey()) && policies.get(entry.getKey()).isJsonObject()
+ ? policies.getAsJsonObject(entry.getKey()) : new JsonObject();
+ policy.remove("dormant");
+ policy.addProperty("material", material);
+ policy.addProperty("domain", domain);
+ if (!policy.has("mode")) policy.addProperty("mode", "keep_separate");
+ if (!policy.has("output_mode")) policy.addProperty("output_mode", "custom");
+ JsonArray array = new JsonArray();
+ Set currentMembers = new LinkedHashSet<>();
+ for (JsonObject candidate : entry.getValue()) {
+ array.add(candidate);
+ currentMembers.add(candidateIdentity(candidate, domain));
+ }
+ policy.add("candidates", array);
+ reconcileOreSourceOutputs(policy);
+ reconcileOreSourcePlacements(policy);
+ Set previous = previousMembers.get(entry.getKey());
+ boolean membershipChanged = previous == null || !previous.equals(currentMembers);
+ if (membershipChanged) {
+ policy.addProperty("review_required", outputCandidateJson(policy).size() > 1);
+ }
+ refreshOreSourceStatus(policy);
+ policies.add(entry.getKey(), policy);
+ }
+ }
+
+ private static void reconcileOreSourceOutputs(JsonObject policy) {
+ List candidates = outputCandidateJson(policy);
+ Set candidateIds = allOutputCandidateIds(policy);
+ JsonObject outputs = objectEntry(policy, "outputs");
+ for (String sourceId : new ArrayList<>(JsonCopies.keys(outputs))) {
+ if (!candidateIds.contains(sourceId)) outputs.remove(sourceId);
+ }
+ String mode = string(policy, "mode", "keep_separate");
+ String outputMode = outputMode(policy);
+ if ("keep_separate".equals(mode) || "balanced".equals(outputMode)) {
+ for (JsonObject candidate : candidates) {
+ String sourceId = string(candidate, "source_id", "");
+ if (!sourceId.isEmpty() && bool(candidate, "loaded", false)) {
+ outputs.addProperty(sourceId, 1.0D);
+ }
+ }
+ } else if ("single".equals(outputMode)) {
+ String selected = "";
+ for (String sourceId : JsonCopies.keys(outputs)) {
+ if (candidateIds.contains(sourceId)) {
+ selected = sourceId;
+ break;
+ }
+ }
+ if (selected.isEmpty()) {
+ selected = candidates.isEmpty() ? "" : string(candidates.get(0), "source_id", "");
+ }
+ outputs.entrySet().clear();
+ if (!selected.isEmpty()) outputs.addProperty(selected, 1.0D);
+ }
+ }
+
+ private static void reconcileOreSourcePlacements(JsonObject policy) {
+ Map> retainedChoices = new LinkedHashMap<>();
+ Map> loadedChoices = new LinkedHashMap<>();
+ if (policy.has("candidates") && policy.get("candidates").isJsonArray()) {
+ for (JsonElement element : policy.getAsJsonArray("candidates")) {
+ if (!element.isJsonObject()) continue;
+ JsonObject candidate = element.getAsJsonObject();
+ if (bool(candidate, "external", false) || bool(candidate, "enrichment", false)) continue;
+ String channel = string(candidate, "placement_channel", "orespawn:standard");
+ String sourceId = string(candidate, "source_id", "");
+ if (!sourceId.isEmpty()) {
+ retainedChoices.computeIfAbsent(channel, ignored -> new LinkedHashSet<>()).add(sourceId);
+ }
+ if (bool(candidate, "loaded", false) && bool(candidate, "placement_active", false)) {
+ loadedChoices.computeIfAbsent(channel, ignored -> new ArrayList<>()).add(candidate);
+ }
+ }
+ }
+ JsonObject placements = objectEntry(policy, "placement_sources");
+ for (String channel : new ArrayList<>(JsonCopies.keys(placements))) {
+ String selected = string(placements, channel, "");
+ if (!retainedChoices.getOrDefault(channel, Collections.emptySet()).contains(selected)) {
+ placements.remove(channel);
+ }
+ }
+ for (Entry> entry : loadedChoices.entrySet()) {
+ if (!placements.has(entry.getKey()) && !entry.getValue().isEmpty()) {
+ placements.addProperty(entry.getKey(), string(entry.getValue().get(0), "source_id", ""));
+ }
+ }
+ }
+
+ private static Set allOutputCandidateIds(JsonObject policy) {
+ Set result = new LinkedHashSet<>();
+ if (policy.has("candidates") && policy.get("candidates").isJsonArray()) {
+ for (JsonElement element : policy.getAsJsonArray("candidates")) {
+ if (!element.isJsonObject()) continue;
+ JsonObject candidate = element.getAsJsonObject();
+ if (bool(candidate, "enrichment", false)) continue;
+ String sourceId = string(candidate, "source_id", "");
+ if (!sourceId.isEmpty()) result.add(sourceId);
+ }
+ }
+ return result;
+ }
+
+ private static String candidateIdentity(JsonObject candidate, String fallbackDomain) {
+ return string(candidate, "source_id", "") + '|'
+ + string(candidate, "placement_channel", "orespawn:standard") + '|'
+ + string(candidate, "domain", fallbackDomain);
+ }
+
+ private String materialForAliases(List aliases) {
+ Set materials = new LinkedHashSet<>();
+ for (String alias : aliases) {
+ String owner = blockTagOwner(alias);
+ if (owner != null) materials.add(owner);
+ else if (validBlockTagName(alias)) {
+ materials.add("orespawn:" + oreTagMaterial(alias));
+ }
+ }
+ return materials.size() == 1 ? materials.iterator().next()
+ : materials.size() > 1 ? provisionalMaterialForAliases(aliases) : "";
+ }
+
+ private static String provisionalMaterialForAliases(List aliases) {
+ Set unique = new LinkedHashSet<>();
+ for (String alias : aliases) if (validBlockTagName(alias)) unique.add(alias);
+ List exact = new ArrayList<>(unique);
+ Collections.sort(exact);
+ String token = exact.isEmpty() ? "unknown" : oreTagMaterial(exact.get(0));
+ long hash = 0xcbf29ce484222325L;
+ for (String name : exact) {
+ for (int index = 0; index < name.length(); index++) {
+ hash ^= name.charAt(index);
+ hash *= 0x100000001b3L;
+ }
+ hash ^= 0xffL;
+ hash *= 0x100000001b3L;
+ }
+ String value = Long.toHexString(hash);
+ StringBuilder padded = new StringBuilder(16);
+ for (int index = value.length(); index < 16; index++) padded.append('0');
+ return "orespawn:review/" + token + '-' + padded.append(value).toString();
+ }
+
+ private static boolean isCustomMaterial(String material) {
+ return material != null && material.startsWith("orespawn:custom/") && validResource(material);
+ }
+
+ private static String inferredMaterialForAlias(String alias) {
+ if ("forge:ores/sulfur".equals(alias) || "forge:ores/sulphur".equals(alias)) return "orespawn:sulfur";
+ if ("forge:ores/aluminum".equals(alias) || "forge:ores/aluminium".equals(alias)) return "orespawn:aluminum";
+ return "orespawn:" + oreTagMaterial(alias);
+ }
+
+ void setOreSourceMode(String key, boolean consolidated) {
+ JsonObject policy = objectEntry(section("ore_source_policies"), key);
+ policy.addProperty("mode", consolidated ? "consolidated" : "keep_separate");
+ policy.addProperty("review_required", false);
+ refreshOreSourceStatus(policy);
+ }
+
+ void acceptOreSourcePolicy(String key) {
+ JsonObject policy = objectEntry(section("ore_source_policies"), key);
+ policy.addProperty("review_required", false);
+ refreshOreSourceStatus(policy);
+ }
+
+ void restoreOreSourceOriginalMode(String key) {
+ JsonObject policy = objectEntry(section("ore_source_policies"), key);
+ JsonObject originalPolicies = object(oreSourceOriginal, "ore_source_policies");
+ JsonObject originalPolicy = originalPolicies.has(key) && originalPolicies.get(key).isJsonObject()
+ ? originalPolicies.getAsJsonObject(key) : null;
+ policy.addProperty("mode", "keep_separate");
+ if (originalPolicy != null) {
+ policy.addProperty("output_mode", outputMode(originalPolicy));
+ policy.add("outputs", sectionCopy(originalPolicy, "outputs"));
+ policy.add("placement_sources", sectionCopy(originalPolicy, "placement_sources"));
+ } else {
+ JsonObject outputs = new JsonObject();
+ JsonObject placements = new JsonObject();
+ if (policy.has("candidates") && policy.get("candidates").isJsonArray()) {
+ for (JsonElement element : policy.getAsJsonArray("candidates")) {
+ if (!element.isJsonObject()) continue;
+ JsonObject candidate = element.getAsJsonObject();
+ if (!bool(candidate, "loaded", false)
+ || !bool(candidate, "placement_active", false)
+ || bool(candidate, "external", false)
+ || bool(candidate, "enrichment", false)) continue;
+ String sourceId = string(candidate, "source_id", "");
+ String channel = string(candidate, "placement_channel", "orespawn:standard");
+ if (!sourceId.isEmpty()) outputs.addProperty(sourceId, 1.0D);
+ if (!sourceId.isEmpty() && !placements.has(channel)) {
+ placements.addProperty(channel, sourceId);
+ }
+ }
+ }
+ policy.addProperty("output_mode", "custom");
+ policy.add("outputs", outputs);
+ policy.add("placement_sources", placements);
+ }
+ policy.addProperty("review_required", false);
+ refreshOreSourceStatus(policy);
+ }
+
+ void setOreSourceOutputMode(String key, String outputMode) {
+ if (!"balanced".equals(outputMode) && !"single".equals(outputMode)
+ && !"custom".equals(outputMode)) return;
+ JsonObject policy = objectEntry(section("ore_source_policies"), key);
+ if (!hasManagedPlacementSource(policy)) return;
+ policy.addProperty("mode", "consolidated");
+ policy.addProperty("output_mode", outputMode);
+ JsonObject outputs = objectEntry(policy, "outputs");
+ if ("balanced".equals(outputMode)) {
+ outputs.entrySet().clear();
+ for (JsonObject candidate : outputCandidateJson(policy)) {
+ if (bool(candidate, "external", false)) continue;
+ String sourceId = string(candidate, "source_id", "");
+ if (!sourceId.isEmpty()) outputs.addProperty(sourceId, 1.0D);
+ }
+ } else if ("single".equals(outputMode)) {
+ String selected = preferredSingleOutput(policy, outputs);
+ if (!loadedOutput(policy, selected)) {
+ List candidates = outputCandidateJson(policy);
+ selected = candidates.isEmpty() ? "" : string(candidates.get(0), "source_id", "");
+ }
+ outputs.entrySet().clear();
+ if (!selected.isEmpty()) outputs.addProperty(selected, 1.0D);
+ }
+ policy.addProperty("review_required", false);
+ refreshOreSourceStatus(policy);
+ }
+
+ private static String preferredSingleOutput(JsonObject policy, JsonObject outputs) {
+ JsonObject placements = objectEntry(policy, "placement_sources");
+ for (Entry entry : placements.entrySet()) {
+ try {
+ String sourceId = entry.getValue().getAsString();
+ if (outputs.has(sourceId) && loadedOutput(policy, sourceId)) return sourceId;
+ } catch (RuntimeException ignored) { }
+ }
+ return outputs.entrySet().isEmpty() ? "" : outputs.entrySet().iterator().next().getKey();
+ }
+
+ void setOreSourcePlacement(String key, String channel, String sourceId) {
+ if (!validResource(channel)) return;
+ JsonObject policy = objectEntry(section("ore_source_policies"), key);
+ objectEntry(policy, "placement_sources").addProperty(channel, sourceId);
+ refreshOreSourceStatus(policy);
+ }
+
+ void setOreSourceOutput(String key, String sourceId, boolean enabled, double weight) {
+ JsonObject policy = objectEntry(section("ore_source_policies"), key);
+ JsonObject outputs = objectEntry(policy, "outputs");
+ String outputMode = outputMode(policy);
+ if (!enabled) outputs.remove(sourceId);
+ else if ("single".equals(outputMode)) {
+ outputs.entrySet().clear();
+ outputs.addProperty(sourceId, 1.0D);
+ } else outputs.addProperty(sourceId, "balanced".equals(outputMode) ? 1.0D
+ : Math.max(0.001D, Math.min(1000000.0D, weight)));
+ refreshOreSourceStatus(policy);
+ }
+
+ private static List outputCandidateJson(JsonObject policy) {
+ Map unique = new LinkedHashMap<>();
+ if (policy.has("candidates") && policy.get("candidates").isJsonArray()) {
+ for (JsonElement element : policy.getAsJsonArray("candidates")) {
+ if (!element.isJsonObject()) continue;
+ JsonObject candidate = element.getAsJsonObject();
+ if (!bool(candidate, "loaded", false) || bool(candidate, "enrichment", false)) continue;
+ String identity = string(candidate, "registry_id", "") + '|'
+ + integer(candidate, "metadata", 0);
+ JsonObject previous = unique.get(identity);
+ if (previous == null || (!bool(previous, "placement_active", false)
+ && bool(candidate, "placement_active", false))
+ || (bool(previous, "external", false) && !bool(candidate, "external", false))) {
+ unique.put(identity, candidate);
+ }
+ }
+ }
+ return new ArrayList<>(unique.values());
+ }
+
+ private static boolean loadedOutput(JsonObject policy, String sourceId) {
+ if (sourceId == null || sourceId.isEmpty()) return false;
+ for (JsonObject candidate : outputCandidateJson(policy)) {
+ if (sourceId.equals(string(candidate, "source_id", ""))) return true;
+ }
+ return false;
+ }
+
+ private static boolean hasManagedPlacementSource(JsonObject policy) {
+ if (!policy.has("candidates") || !policy.get("candidates").isJsonArray()) return false;
+ for (JsonElement element : policy.getAsJsonArray("candidates")) {
+ if (!element.isJsonObject()) continue;
+ JsonObject candidate = element.getAsJsonObject();
+ if (bool(candidate, "loaded", false) && bool(candidate, "placement_active", false)
+ && !bool(candidate, "external", false)
+ && !bool(candidate, "enrichment", false)) return true;
+ }
+ return false;
+ }
+
+ private static void refreshOreSourceStatus(JsonObject policy) {
+ Set missing = new HashSet<>();
+ Set inactivePlacements = new HashSet<>();
+ boolean external = false;
+ boolean review = bool(policy, "review_required", false);
+ if (policy.has("candidates") && policy.get("candidates").isJsonArray()) {
+ for (JsonElement element : policy.getAsJsonArray("candidates")) {
+ if (!element.isJsonObject()) continue;
+ JsonObject candidate = element.getAsJsonObject();
+ String sourceId = string(candidate, "source_id", "");
+ if (!bool(candidate, "loaded", false)) missing.add(sourceId);
+ if (!bool(candidate, "placement_active", false)) inactivePlacements.add(sourceId);
+ external |= bool(candidate, "external", false) && bool(candidate, "loaded", false);
+ }
+ }
+ Map outputs = decimalMap(policy, "outputs");
+ Map placements = stringMap(policy, "placement_sources");
+ if ("consolidated".equals(string(policy, "mode", "keep_separate"))
+ && (outputs.isEmpty() || placements.isEmpty())) {
+ policy.addProperty("status", "missing_source");
+ return;
+ }
+ for (String id : outputs.keySet()) {
+ if (missing.contains(id)) { policy.addProperty("status", "missing_source"); return; }
+ }
+ for (String id : placements.values()) {
+ if (missing.contains(id) || inactivePlacements.contains(id)) {
+ policy.addProperty("status", "missing_source"); return;
+ }
+ }
+ policy.addProperty("status", external ? "external_generation" : review ? "review_required"
+ : "consolidated".equals(string(policy, "mode", "keep_separate"))
+ ? "consolidated" : "separate");
+ }
+
+ private static Map decimalMap(JsonObject parent, String key) {
+ if (!parent.has(key) || !parent.get(key).isJsonObject()) return Collections.emptyMap();
+ Map result = new LinkedHashMap<>();
+ for (Entry entry : parent.getAsJsonObject(key).entrySet()) {
+ try {
+ double value = entry.getValue().getAsDouble();
+ if (Double.isFinite(value) && value > 0.0D) result.put(entry.getKey(), value);
+ } catch (RuntimeException ignored) { }
+ }
+ return Collections.unmodifiableMap(result);
+ }
+
+ private static Map stringMap(JsonObject parent, String key) {
+ if (!parent.has(key) || !parent.get(key).isJsonObject()) return Collections.emptyMap();
+ Map result = new LinkedHashMap<>();
+ for (Entry entry : parent.getAsJsonObject(key).entrySet()) {
+ try { result.put(entry.getKey(), entry.getValue().getAsString()); }
+ catch (RuntimeException ignored) { }
+ }
+ return Collections.unmodifiableMap(result);
+ }
+
+ static final class OreSourceGroup {
+ final String key;
+ final String material;
+ final String domain;
+ final String displayName;
+ final List blockTagEntries;
+ final boolean curated;
+ final String mode;
+ final String outputMode;
+ final String status;
+ final List candidates;
+ final Map outputs;
+ final Map placements;
+
+ OreSourceGroup(String key, String material, String domain, String displayName,
+ List blockTagEntries, boolean curated, String mode, String outputMode, String status,
+ List candidates, Map outputs,
+ Map placements) {
+ this.key = key;
+ this.material = material;
+ this.domain = domain;
+ this.displayName = displayName;
+ this.blockTagEntries = Collections.unmodifiableList(new ArrayList<>(blockTagEntries));
+ this.curated = curated;
+ this.mode = mode;
+ this.outputMode = outputMode;
+ this.status = status;
+ this.candidates = Collections.unmodifiableList(new ArrayList<>(candidates));
+ this.outputs = outputs;
+ this.placements = placements;
+ }
+
+ List channels() {
+ Set