From 674676aa08ed90d178064758b02b39e341aadf04 Mon Sep 17 00:00:00 2001
From: JohnBraham
Date: Wed, 23 Sep 2026 20:08:08 +0100
Subject: [PATCH 1/6] Port OreSpawn 4.1 extension and provider contracts
---
README.md | 7 +-
build.gradle | 2 +
docs/DEVELOPER_GUIDE.md | 51 ++++-
docs/PLAYER_GUIDE.md | 11 +-
docs/PROVIDERS.md | 9 +-
docs/schemas/orespawn-provider.schema.json | 1 +
docs/schemas/profile-common.schema.json | 5 +
.../orespawn/testmod/SurfaceProbeTestMod.java | 34 +++-
.../orespawn/client/ClientProbeTestMod.java | 62 ++++++
.../orespawn/compat/LegacyOs3Bridge.java | 24 +++
.../mc/orespawn/api/GeologySampler.java | 3 +-
.../mc/orespawn/api/OreGenerationContext.java | 37 ++++
.../mc/orespawn/api/OrePlacementContext.java | 6 +-
.../moddev/mc/orespawn/api/OreSpawnApi.java | 29 ++-
.../orespawn/api/OreSpawnGeologySampler.java | 10 +-
.../mc/orespawn/api/WorldgenProvider.java | 57 +++++-
.../api/client/WorldSettingsExtension.java | 32 +++
.../WorldSettingsExtensionRegistry.java | 79 ++++++++
.../client/WorldSettingsScreenFactory.java | 12 ++
.../mc/orespawn/client/ClientSetup.java | 1 +
.../moddev/mc/orespawn/client/CogButton.java | 37 ++++
.../client/OreSpawnModDirectoryModel.java | 123 ++++++++++++
.../orespawn/client/OreSpawnModsScreen.java | 189 ++++++++++++++++++
.../client/OreSpawnWorldSettingsScreen.java | 21 +-
.../WorldSettingsExtensionNavigation.java | 39 ++++
.../WorldgenIntegrationManager.java | 132 +++++++++++-
.../worldgen/LegacyConfigMigrator.java | 11 +
.../worldgen/OreSpawnOreGeneration.java | 158 +++++++++++++--
.../worldgen/OreSpawnWorldGenerator.java | 8 +-
.../worldgen/WorldGeologyProfileManager.java | 5 +-
.../resources/assets/orespawn/lang/de_au.lang | 23 +++
.../resources/assets/orespawn/lang/de_de.lang | 23 +++
.../resources/assets/orespawn/lang/en_ca.lang | 23 +++
.../resources/assets/orespawn/lang/en_en.lang | 23 +++
.../resources/assets/orespawn/lang/en_gb.lang | 23 +++
.../resources/assets/orespawn/lang/en_pt.lang | 23 +++
.../resources/assets/orespawn/lang/es_es.lang | 23 +++
.../resources/assets/orespawn/lang/es_mx.lang | 23 +++
.../resources/assets/orespawn/lang/fr_ca.lang | 23 +++
.../resources/assets/orespawn/lang/fr_fr.lang | 23 +++
.../resources/assets/orespawn/lang/ja_jp.lang | 23 +++
.../resources/assets/orespawn/lang/ko_kr.lang | 23 +++
.../resources/assets/orespawn/lang/pt_br.lang | 23 +++
.../resources/assets/orespawn/lang/ru_ru.lang | 23 +++
.../resources/assets/orespawn/lang/zh_cn.lang | 23 +++
.../assets/orespawn/textures/gui/cog.png | Bin 0 -> 321 bytes
.../mc/orespawn/LocalizationParityTest.java | 2 +-
.../mc/orespawn/TooltipAlignmentTest.java | 1 +
.../api/GeologySamplerSourceContractTest.java | 21 ++
.../api/OreGenerationContextTest.java | 47 +++++
.../mc/orespawn/api/WorldgenProviderTest.java | 39 ++++
.../WorldSettingsExtensionRegistryTest.java | 124 ++++++++++++
.../client/OreSpawnModDirectoryModelTest.java | 111 ++++++++++
.../OreSpawnModsScreenContractTest.java | 76 +++++++
.../client/OreSpawnScreenLayoutTest.java | 25 ++-
.../WorldgenIntegrationManagerTest.java | 44 ++++
.../BackgroundGenerationScaleTest.java | 55 +++++
.../BiomeSurfaceFeatureOrderTest.java | 2 +-
.../worldgen/OreSpawnOreGenerationTest.java | 12 ++
59 files changed, 2037 insertions(+), 62 deletions(-)
create mode 100644 src/main/java/zone/moddev/mc/orespawn/api/OreGenerationContext.java
create mode 100644 src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtension.java
create mode 100644 src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistry.java
create mode 100644 src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsScreenFactory.java
create mode 100644 src/main/java/zone/moddev/mc/orespawn/client/CogButton.java
create mode 100644 src/main/java/zone/moddev/mc/orespawn/client/OreSpawnModDirectoryModel.java
create mode 100644 src/main/java/zone/moddev/mc/orespawn/client/OreSpawnModsScreen.java
create mode 100644 src/main/java/zone/moddev/mc/orespawn/client/WorldSettingsExtensionNavigation.java
create mode 100644 src/main/resources/assets/orespawn/textures/gui/cog.png
create mode 100644 src/test/java/zone/moddev/mc/orespawn/api/GeologySamplerSourceContractTest.java
create mode 100644 src/test/java/zone/moddev/mc/orespawn/api/OreGenerationContextTest.java
create mode 100644 src/test/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistryTest.java
create mode 100644 src/test/java/zone/moddev/mc/orespawn/client/OreSpawnModDirectoryModelTest.java
create mode 100644 src/test/java/zone/moddev/mc/orespawn/client/OreSpawnModsScreenContractTest.java
create mode 100644 src/test/java/zone/moddev/mc/orespawn/worldgen/BackgroundGenerationScaleTest.java
diff --git a/README.md b/README.md
index fb31d985..70e860bd 100644
--- a/README.md
+++ b/README.md
@@ -44,7 +44,12 @@ TerraBlender.
When a provider exposes world settings, use **OreSpawn...** on the Create World
screen. **Recommended Defaults** restores the settings supplied by the
-installed mods and pack. The in-game **Help & Guide** explains the controls.
+installed mods and pack. **Mods** opens a directory of every loaded mod that
+OreSpawn recognizes through a current provider, legacy compatibility path, or
+client configuration hook. It shows each mod's version, integration lineage,
+provider revision and status; its cog opens an add-on-owned configuration
+screen when one is available. The in-game **Help & Guide** explains the other
+controls.
Important files:
diff --git a/build.gradle b/build.gradle
index e620098b..b1a0a9ce 100644
--- a/build.gradle
+++ b/build.gradle
@@ -899,6 +899,8 @@ task clientIntegrationProcess(type: JavaExec, dependsOn: prepareClientIntegratio
Properties result = new Properties(); marker.withInputStream { result.load(it) }
if (result.getProperty('reload_rendered') != 'true'
|| result.getProperty('world_settings_opened') != 'true'
+ || result.getProperty('mods_directory_rendered') != 'true'
+ || result.getProperty('directory_stack_validated') != 'true'
|| result.getProperty('long_editor_roundtrip') != 'true'
|| Integer.parseInt(result.getProperty('editor_routes', '0')) < 5) {
throw new GradleException("Client integration result is incomplete: ${marker}")
diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md
index 3e027a43..67cf662a 100644
--- a/docs/DEVELOPER_GUIDE.md
+++ b/docs/DEVELOPER_GUIDE.md
@@ -13,6 +13,9 @@
| Add or place biomes without a framework dependency | Provider schema 4 biome palette |
| Replace surfaces, aquifers, snow, or ice | Provider schema 4 dimension materials |
| Inspect active geology at runtime | `GeologyProfileView` and `GeologySampler` |
+| Build a deterministic region-scale ore pattern | `OreGenerationContext` |
+| Replace or blend background ore generation | `backgroundGenerationScale` |
+| Add an add-on settings screen | Client-only `WorldSettingsExtensionRegistry` |
Strata are optional. If no enabled terrain dimension has eligible rocks,
OreSpawn skips terrain replacement and all formation/geome settings are inert.
@@ -119,6 +122,50 @@ needs different settings; the explicit rule overrides the selector there.
Ore dimension builders support the same exact-ID and biome-dictionary include
and exclude filters as provider JSON and fluid-deposit builders.
+## Region-scale custom patterns
+
+OreSpawn 4.1 supplies compiled patterns with `OreGenerationContext`, a subtype
+of the original placement context. Its world seed, dimension ID and current
+chunk coordinates let an add-on derive a stable region/deposit identity that
+does not depend on chunk generation order. `geologySampler()` provides the
+active OreSpawn geology where one is configured.
+
+Render only the part of a deterministic body that intersects `chunkX()` and
+`chunkZ()`. All reads and writes must still pass through `inside(...)`,
+`isFluid(...)` and `tryPlace(...)`; Forge 1.10 deliberately restricts those
+operations to the current chunk. Perform definition parsing and expensive
+setup in the registered pattern compiler, not its placement callback.
+
+## Add-on world settings
+
+Register one optional client configuration screen during client
+initialization. The preferred form identifies the owning mod directly:
+
+```java
+WorldSettingsExtensionRegistry.registerConfigScreen(
+ "examplemod",
+ parent -> new ExampleDepositSettingsScreen(parent));
+```
+
+The existing form remains available to already-compiled add-ons, and its
+resource namespace becomes the owner:
+
+```java
+WorldSettingsExtensionRegistry.register(
+ new ResourceLocation("examplemod", "deposit_settings"),
+ "button.examplemod.deposit_settings",
+ parent -> new ExampleDepositSettingsScreen(parent));
+```
+
+The registry is client-only. OreSpawn lists the loaded mod once in its
+paginated **Mods** directory, renders a packaged cog, and supplies that
+directory as the parent. The extension owns its complete screen and should
+return to that parent from Done; OreSpawn redirects inherited vanilla Escape
+there as well. OreSpawn synchronizes its pending editor session before opening
+the directory. Only one configuration screen may be
+registered per owning mod, so two resource paths in the same namespace are a
+deterministic duplicate rather than extra main-screen rows.
+
## Pack Override Quick Start
Copy `examples/examplemod-orespawn.json` to:
@@ -174,7 +221,9 @@ See `CONFIGURATION.md` and the JSON Schemas for every field and numeric range.
Provider files and API definitions freeze before generation. OreSpawn resolves
registry IDs, tags, dimensions, geomes, aliases, and block states while baking.
The generation loop must not contain provider callbacks, config reads, registry
-lookups, strings, logging, reflection, or avoidable allocation.
+lookups, strings, logging, reflection, or avoidable allocation. Compiled custom
+patterns are the intentional extension point; they receive only baked settings,
+the allocation-free placement context and cached generation identity/sampler.
Biome filters retain their exact registry IDs. Minecraft 1.12.2 uses a static
Forge-backed biome registry, so generation carries those stable IDs alongside
diff --git a/docs/PLAYER_GUIDE.md b/docs/PLAYER_GUIDE.md
index fd1eb521..b0890ba3 100644
--- a/docs/PLAYER_GUIDE.md
+++ b/docs/PLAYER_GUIDE.md
@@ -9,8 +9,11 @@ For a normal game:
1. Open **OreSpawn...** while creating the world.
2. Choose **Recommended Defaults** unless you want to customise geology.
-3. Open **Help & Guide** for a plain-language tour of the controls.
-4. Press **Done**, then create the world normally.
+3. Open **Mods** to see which installed mods OreSpawn recognized and whether
+ their provider is active. A lit cog opens that mod's own settings; a dim cog
+ explains that the mod did not supply a screen.
+4. Open **Help & Guide** for a plain-language tour of the controls.
+5. Press **Done**, then create the world normally.
Hover over unfamiliar controls for a short explanation. The same explanations
are collected in **Help & Guide**, so a setting can be learned either while
@@ -43,6 +46,10 @@ safely instead of breaking world creation.
region.
- **Manage Vanilla Ores** lets OreSpawn replace vanilla ore features with the
configured OreSpawn rules. Leave it off to keep normal Minecraft placement.
+- **Mods** opens a paginated directory of loaded OreSpawn integrations. It
+ reports the Forge mod version, native OS4 or legacy OS1-OS3 lineage, provider
+ schema/revision and current status. Historical profile owners that are not
+ loaded are not shown.
- **Fluid Deposits** appears after strata are enabled or when a mod or pack
supplies a rule. Press **Add** to choose water, lava, or a fluid block from an
installed mod. These are covered underground deposits, not exposed vanilla
diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md
index 62e136cb..e7a72f2f 100644
--- a/docs/PROVIDERS.md
+++ b/docs/PROVIDERS.md
@@ -101,9 +101,12 @@ Only suppress a provider mod's native ore generation when
`OreSpawnApi.isOreTakeoverActive(modid)` returns true. `PENDING` means discovery
has not frozen. `INACTIVE` is the fail-safe and native generation must remain.
-Existing worlds merge newly introduced provider rule IDs but do not overwrite
-world edits. Disabled and unassigned rules remain tombstones; removed provider
-rules remain in the self-contained snapshot.
+Existing worlds normally merge newly introduced provider rule IDs but do not
+overwrite world edits. A Java provider may opt out with
+`mergeNewEntriesIntoExistingWorlds(false)` when its rules capture structural
+add-on configuration that must begin only in newly created worlds. Disabled and
+unassigned rules remain tombstones; removed provider rules remain in the
+self-contained snapshot.
Biome providers can add Forge biomes normally, then declare where those biomes
belong through `biome_palettes`. The overlay wraps the dimension's existing
diff --git a/docs/schemas/orespawn-provider.schema.json b/docs/schemas/orespawn-provider.schema.json
index 83f67f7b..8fa0f191 100644
--- a/docs/schemas/orespawn-provider.schema.json
+++ b/docs/schemas/orespawn-provider.schema.json
@@ -7,6 +7,7 @@
"schema_version": { "enum": [1, 2, 3, 4] },
"provider_modid": { "type": "string", "pattern": "^[a-z][a-z0-9_.-]+$" },
"provider_revision": { "type": "integer", "minimum": 1 },
+ "merge_new_entries_into_existing_worlds": { "type": "boolean" },
"rocks": { "type": "object", "additionalProperties": { "$ref": "profile-common.schema.json#/$defs/rock" } },
"ores": { "type": "object", "additionalProperties": { "$ref": "profile-common.schema.json#/$defs/ore" } },
"fluid_deposits": { "type": "object", "additionalProperties": { "$ref": "profile-common.schema.json#/$defs/fluidDeposit" } },
diff --git a/docs/schemas/profile-common.schema.json b/docs/schemas/profile-common.schema.json
index 87cefe57..d643ded5 100644
--- a/docs/schemas/profile-common.schema.json
+++ b/docs/schemas/profile-common.schema.json
@@ -148,6 +148,11 @@
"minimum": 0.0,
"maximum": 1.0
},
+ "background_generation_scale": {
+ "type": "number",
+ "minimum": 0.0,
+ "maximum": 1.0
+ },
"spread": { "type": "integer", "minimum": 0, "maximum": 64 },
"vertical_spread": { "type": "integer", "minimum": 0, "maximum": 64 },
"node_size": { "type": "integer", "minimum": 1, "maximum": 32 },
diff --git a/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java b/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java
index 4e4b8a24..b7776826 100644
--- a/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java
+++ b/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java
@@ -13,6 +13,7 @@
import java.util.Random;
import java.util.Set;
import java.util.LinkedHashSet;
+import java.util.concurrent.atomic.AtomicInteger;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
@@ -26,6 +27,8 @@
import zone.moddev.mc.orespawn.api.BiomeRegionSize;
import zone.moddev.mc.orespawn.api.BiomeReplacementScope;
import zone.moddev.mc.orespawn.api.GeologyFamily;
+import zone.moddev.mc.orespawn.api.GeologyColumn;
+import zone.moddev.mc.orespawn.api.OreGenerationContext;
import zone.moddev.mc.orespawn.api.OrePatternType;
import zone.moddev.mc.orespawn.api.OreSpawnApi;
import zone.moddev.mc.orespawn.api.OreSpawnBiomes;
@@ -119,6 +122,7 @@ public final class SurfaceProbeTestMod {
private static final String RAW_CHEST_ITEM_NAME = "surfaceprobe raw block entity sentinel";
private static final Block WEATHER_SNOW_REPLACEMENT = Blocks.WOOL;
private static final Block WEATHER_ICE_REPLACEMENT = Blocks.PACKED_ICE;
+ private static final AtomicInteger EXTENDED_CONTEXT_INVOCATIONS = new AtomicInteger();
private static final ResourceLocation[] BUILT_IN_GEOMES = {
new ResourceLocation("orespawn", "stable_craton"),
new ResourceLocation("orespawn", "mountain_belt"),
@@ -131,7 +135,27 @@ public final class SurfaceProbeTestMod {
};
private static final OrePatternType EXTERNAL_PATTERN = OrePatternType.create(
- StandardPatternSettings.CODEC, settings -> context -> false)
+ StandardPatternSettings.CODEC, settings -> context -> {
+ if (!(context instanceof OreGenerationContext)) {
+ throw new IllegalStateException("External pattern did not receive OreGenerationContext");
+ }
+ OreGenerationContext generation = (OreGenerationContext) context;
+ if (generation.worldSeed() != 0L || !OVERWORLD.equals(generation.dimension())
+ || generation.chunkX() != (generation.originX() >> 4)
+ || generation.chunkZ() != (generation.originZ() >> 4)) {
+ throw new IllegalStateException("External pattern received unstable generation identity");
+ }
+ if (!generation.geologySampler().isPresent()) {
+ throw new IllegalStateException("Overworld pattern did not receive its geology sampler");
+ }
+ GeologyColumn column = generation.geologySampler().get().sampleColumn(
+ generation.originX(), generation.originZ(), 64);
+ if (!generation.dimension().equals(column.dimension())) {
+ throw new IllegalStateException("Context and sampler dimensions disagree");
+ }
+ EXTENDED_CONTEXT_INVOCATIONS.incrementAndGet();
+ return false;
+ })
.setRegistryName(MODID, "external_probe");
private final BiomeRegistrar registrar = OreSpawnBiomes.registrar(MODID);
@@ -215,6 +239,11 @@ public void init(FMLInitializationEvent event) {
provider.fluidDeposit(new ResourceLocation(MODID, "fluid_deposit/dynamic_tick_probe"),
id(DEPOSIT_FLUID), deposit -> deposit.dimension(OVERWORLD,
dimension -> dimension.hostBlock(id(Blocks.STONE))));
+ provider.ore(new ResourceLocation(MODID, "ore/generation_context_probe"),
+ id(Blocks.COAL_ORE), ore -> ore.retrogen(true).dimension(OVERWORLD,
+ dimension -> dimension.yRange(16, 48).attempts(1.0D).quantity(1)
+ .pattern(new ResourceLocation(MODID, "external_probe"), new JsonObject())
+ .hostBlock(id(Blocks.STONE))));
// This stable palette id makes seed zero select both fixture biomes on
// opposite sides of the 1,024-block Tiny-region boundary.
addPalette(provider, "end_palette_1", END, false);
@@ -332,6 +361,9 @@ public void serverStarted(FMLServerStartedEvent event) {
if (overworld == null || overworld.getSeed() != 0L) {
throw new IllegalStateException("surfaceprobe requires seed token zsjpxah (hash zero)");
}
+ if ("fresh".equals(phase) && EXTENDED_CONTEXT_INVOCATIONS.get() <= 0) {
+ throw new IllegalStateException("External generation context probe did not run");
+ }
Path marker = worldRoot(server).resolve(MARKER_NAME);
Properties previous = "reload".equals(phase) ? read(marker) : null;
if ("fresh".equals(phase) && Files.exists(marker)) {
diff --git a/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java
index 1587abf0..d47da8db 100644
--- a/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java
+++ b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java
@@ -3,6 +3,9 @@
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Properties;
@@ -45,6 +48,9 @@ public final class ClientProbeTestMod {
private int reloadWorldFrames;
private int editorFrames;
private boolean worldSettingsOpened;
+ private boolean modsDirectoryRendered;
+ private boolean directoryStackValidated =
+ System.getProperty("clientprobe.expectedMods", "").trim().isEmpty();
private boolean longEditorRoundTrip;
@Mod.EventHandler
@@ -64,6 +70,7 @@ public void onScreenInitialized(GuiScreenEvent.InitGuiEvent.Post event) {
@SubscribeEvent
public void onScreenDrawn(GuiScreenEvent.DrawScreenEvent.Post event) {
if (event.getGui() instanceof OreSpawnScreen) editorFrames++;
+ if (event.getGui() instanceof OreSpawnModsScreen) modsDirectoryRendered = true;
}
@SubscribeEvent
@@ -100,6 +107,7 @@ public void onClientTick(TickEvent.ClientTickEvent event) {
if (minecraft.currentScreen instanceof OreSpawnWorldSettingsScreen && editorFrames >= 2) {
worldSettingsOpened = true;
validateCaptions((OreSpawnWorldSettingsScreen) minecraft.currentScreen);
+ validateExpectedDirectory(minecraft.currentScreen);
validateLongEditorRoundTrip(minecraft, minecraft.currentScreen);
nextState(3);
}
@@ -171,6 +179,57 @@ public void onClientTick(TickEvent.ClientTickEvent event) {
}
}
+ private void validateExpectedDirectory(GuiScreen root) {
+ String configured = System.getProperty("clientprobe.expectedMods", "").trim();
+ if (configured.isEmpty()) return;
+ List expected = Arrays.asList(configured.split(","));
+ List entries = OreSpawnModDirectoryModel.snapshot();
+ List actual = new ArrayList<>();
+ for (OreSpawnModDirectoryModel.Entry entry : entries) actual.add(entry.modId);
+ if (!expected.equals(actual)) {
+ throw new IllegalStateException("Unexpected OreSpawn Mods directory order: expected "
+ + expected + " but found " + actual);
+ }
+ for (OreSpawnModDirectoryModel.Entry entry : entries) {
+ if (entry.version == null || entry.version.trim().isEmpty() || "?".equals(entry.version)) {
+ throw new IllegalStateException("Missing Forge mod version for " + entry.modId);
+ }
+ if ("basemetals".equals(entry.modId)) {
+ if (entry.nativeOs4() || entry.legacyLineages().isEmpty()) {
+ throw new IllegalStateException("Base Metals did not retain its legacy lineage");
+ }
+ } else if (!entry.nativeOs4() || entry.schemaVersion() != 4
+ || entry.providerRevision() < 1) {
+ throw new IllegalStateException("Missing native OS4 schema/revision for " + entry.modId);
+ }
+ boolean shouldConfigure = "realisticdeposits".equals(entry.modId);
+ if (entry.configurable() != shouldConfigure) {
+ throw new IllegalStateException("Unexpected cog availability for " + entry.modId);
+ }
+ if (shouldConfigure) {
+ OreSpawnModsScreen directory = new OreSpawnModsScreen(root, entries);
+ GuiScreen child = entry.extension.createScreen(directory);
+ if (child == null || !child.getClass().getName().endsWith("RealisticDepositsConfigScreen")) {
+ throw new IllegalStateException("Realistic Deposits did not create its config screen");
+ }
+ try {
+ Minecraft minecraft = Minecraft.getMinecraft();
+ WorldSettingsExtensionNavigation.open(directory, child);
+ Method escape = GuiScreen.class.getDeclaredMethod("keyTyped", char.class, int.class);
+ escape.setAccessible(true);
+ escape.invoke(child, '\0', org.lwjgl.input.Keyboard.KEY_ESCAPE);
+ if (minecraft.currentScreen != directory) {
+ throw new IllegalStateException("Add-on Escape did not return to the Mods directory");
+ }
+ minecraft.displayGuiScreen(root);
+ } catch (ReflectiveOperationException failure) {
+ throw new IllegalStateException("Could not exercise add-on Escape", failure);
+ }
+ }
+ }
+ directoryStackValidated = true;
+ }
+
private Button nextNavigationButton(OreSpawnWorldSettingsScreen root) {
for (GuiButton widget : root.buttons) {
if (!(widget instanceof Button) || widget instanceof CycleButton) continue;
@@ -186,6 +245,7 @@ private Button nextNavigationButton(OreSpawnWorldSettingsScreen root) {
private static void validateCaptions(OreSpawnScreen screen) {
for (GuiButton widget : screen.buttons) {
+ if (widget instanceof CogButton) continue;
String caption = TextFormatting.getTextWithoutFormattingCodes(widget.displayString);
if (caption == null || caption.trim().isEmpty()
|| caption.contains("options.generic_value")
@@ -322,6 +382,8 @@ private static void stopIntegratedServer(Minecraft minecraft) {
private void writeMarker() throws IOException {
Properties values = new Properties();
values.setProperty("world_settings_opened", Boolean.toString(worldSettingsOpened));
+ values.setProperty("mods_directory_rendered", Boolean.toString(modsDirectoryRendered));
+ values.setProperty("directory_stack_validated", Boolean.toString(directoryStackValidated));
values.setProperty("long_editor_roundtrip", Boolean.toString(longEditorRoundTrip));
values.setProperty("editor_routes", Integer.toString(editorRoutes.size()));
values.setProperty("editor_classes", editorRoutes.toString());
diff --git a/src/main/java/com/mcmoddev/orespawn/compat/LegacyOs3Bridge.java b/src/main/java/com/mcmoddev/orespawn/compat/LegacyOs3Bridge.java
index 14f4b9d3..1011021d 100644
--- a/src/main/java/com/mcmoddev/orespawn/compat/LegacyOs3Bridge.java
+++ b/src/main/java/com/mcmoddev/orespawn/compat/LegacyOs3Bridge.java
@@ -85,6 +85,7 @@
import net.minecraftforge.registries.IForgeRegistryEntry;
import net.minecraftforge.registries.IForgeRegistry;
import net.minecraftforge.registries.RegistryBuilder;
+import zone.moddev.mc.orespawn.integration.WorldgenIntegrationManager;
import zone.moddev.mc.orespawn.worldgen.LegacyOs3ProfileMigration;
/**
@@ -284,6 +285,7 @@ private static void migrateConfigDirectory(Path configDirectory) {
LOGGER.error("Could not migrate OS3 global world-generation settings", failure);
}
Map programmaticSources = API.programmaticSources();
+ recordLegacyLineages(legacyDirectory, programmaticSources);
if (Files.isRegularFile(bridgeMarker)) {
LOGGER.info("OS3 provider migration already completed; retaining migrated files unchanged");
return;
@@ -350,6 +352,28 @@ private static void migrateConfigDirectory(Path configDirectory) {
}
}
+ private static void recordLegacyLineages(Path legacyDirectory,
+ Map programmaticSources) {
+ for (String owner : programmaticSources.keySet()) {
+ WorldgenIntegrationManager.recordLegacyLineage(owner, 3);
+ }
+ for (String owner : API.embedded.keySet()) {
+ WorldgenIntegrationManager.recordLegacyLineage(owner, 3);
+ }
+ if (!Files.isDirectory(legacyDirectory)) return;
+ try (DirectoryStream files = Files.newDirectoryStream(legacyDirectory, "*.json")) {
+ for (Path file : files) {
+ String owner = file.getFileName().toString().replaceFirst("\\.json$", "");
+ if (validModId(owner)) {
+ WorldgenIntegrationManager.recordLegacyLineage(owner, 3);
+ }
+ }
+ } catch (IOException failure) {
+ REPORT.add("lineage_scan_failed=" + legacyDirectory + ":"
+ + failure.getClass().getSimpleName());
+ }
+ }
+
private static void mergeLegacySource(Map sources, String owner, JsonObject incoming) {
JsonObject target = sources.computeIfAbsent(owner, ignored -> new JsonObject());
target.addProperty("version", text(incoming, "version", "2.0"));
diff --git a/src/main/java/zone/moddev/mc/orespawn/api/GeologySampler.java b/src/main/java/zone/moddev/mc/orespawn/api/GeologySampler.java
index f029d69f..a3d81856 100644
--- a/src/main/java/zone/moddev/mc/orespawn/api/GeologySampler.java
+++ b/src/main/java/zone/moddev/mc/orespawn/api/GeologySampler.java
@@ -4,7 +4,8 @@
public interface GeologySampler {
/**
* Classifies one column. The returned column reuses that biome/geome
- * classification for all subsequent Y queries.
+ * classification for all subsequent Y queries. Sampling is read-only and
+ * does not load or generate the chunk containing the requested column.
*/
GeologyColumn sampleColumn(int blockX, int blockZ, int surfaceY);
}
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..a73da872
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/orespawn/api/OreGenerationContext.java
@@ -0,0 +1,37 @@
+package zone.moddev.mc.orespawn.api;
+
+import java.util.Optional;
+
+import net.minecraft.util.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 {
+ /** 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/OrePlacementContext.java b/src/main/java/zone/moddev/mc/orespawn/api/OrePlacementContext.java
index 0b8f98ec..cc9e52df 100644
--- a/src/main/java/zone/moddev/mc/orespawn/api/OrePlacementContext.java
+++ b/src/main/java/zone/moddev/mc/orespawn/api/OrePlacementContext.java
@@ -4,7 +4,11 @@
import net.minecraftforge.fluids.Fluid;
-/** Allocation-free view supplied to a compiled ore pattern for one attempt. */
+/**
+ * Allocation-free view supplied to a compiled ore pattern for one attempt.
+ * OreSpawn 4.1 contexts also implement {@link OreGenerationContext}; this base
+ * interface remains unchanged for binary compatibility with existing patterns.
+ */
public interface OrePlacementContext {
Random random();
diff --git a/src/main/java/zone/moddev/mc/orespawn/api/OreSpawnApi.java b/src/main/java/zone/moddev/mc/orespawn/api/OreSpawnApi.java
index b3b88361..066295bc 100644
--- a/src/main/java/zone/moddev/mc/orespawn/api/OreSpawnApi.java
+++ b/src/main/java/zone/moddev/mc/orespawn/api/OreSpawnApi.java
@@ -2,19 +2,21 @@
import java.util.Optional;
-import zone.moddev.mc.orespawn.OreSpawn;
import zone.moddev.mc.orespawn.integration.WorldgenIntegrationManager;
+import zone.moddev.mc.orespawn.worldgen.BakedGeomeConfig;
import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfileManager;
import zone.moddev.mc.orespawn.worldgen.GeomeConfig;
import zone.moddev.mc.orespawn.worldgen.WorldIds;
import net.minecraft.server.MinecraftServer;
+import net.minecraft.util.ResourceLocation;
import net.minecraft.world.WorldServer;
/** Entry point for OreSpawn API version 1. */
public final class OreSpawnApi {
public static final int API_VERSION = 1;
public static final String IMC_WORLDGEN_PROVIDER = "worldgen_provider_v1";
+ private static final ResourceLocation OVERWORLD = new ResourceLocation("minecraft", "overworld");
private OreSpawnApi() {
}
@@ -46,10 +48,31 @@ public static Optional getActiveProfile(MinecraftServer serv
}
public static Optional createSampler(WorldServer level) {
- if (level == null || WorldGeologyProfileManager.activeServer() != level.getMinecraftServer()
- || GeomeConfig.baked(WorldIds.dimension(level)) == null) {
+ if (!isActiveLevel(level) || samplerConfig(level) == null) {
return Optional.empty();
}
return Optional.of(OreSpawnGeologySampler.create(level));
}
+
+ static BakedGeomeConfig samplerConfig(WorldServer level) {
+ if (level == null) {
+ return null;
+ }
+ ResourceLocation dimension = WorldIds.dimension(level);
+ return OVERWORLD.equals(dimension) ? GeomeConfig.baked() : GeomeConfig.baked(dimension);
+ }
+
+ static boolean isActiveLevel(WorldServer level) {
+ MinecraftServer active = WorldGeologyProfileManager.activeServer();
+ if (level == null || active == null) {
+ return false;
+ }
+ if (active == level.getMinecraftServer()) {
+ return true;
+ }
+ // Forge can populate the initial spawn before WorldServer has exposed
+ // its server through getMinecraftServer(). The server's dimension table is
+ // authoritative at that point and still rejects stale or foreign worlds.
+ return active.getWorld(level.provider.getDimension()) == level;
+ }
}
diff --git a/src/main/java/zone/moddev/mc/orespawn/api/OreSpawnGeologySampler.java b/src/main/java/zone/moddev/mc/orespawn/api/OreSpawnGeologySampler.java
index 5ba48d47..47579873 100644
--- a/src/main/java/zone/moddev/mc/orespawn/api/OreSpawnGeologySampler.java
+++ b/src/main/java/zone/moddev/mc/orespawn/api/OreSpawnGeologySampler.java
@@ -6,7 +6,6 @@
import zone.moddev.mc.orespawn.OreSpawnConfig.GeologyMode;
import zone.moddev.mc.orespawn.worldgen.BakedGeomeConfig;
import zone.moddev.mc.orespawn.worldgen.Geology;
-import zone.moddev.mc.orespawn.worldgen.GeomeConfig;
import zone.moddev.mc.orespawn.worldgen.GeomeGeology;
import zone.moddev.mc.orespawn.worldgen.RockFamily;
import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfile;
@@ -33,7 +32,7 @@ final class OreSpawnGeologySampler implements GeologySampler {
private OreSpawnGeologySampler(WorldServer level) {
this.level = level;
dimension = WorldIds.dimension(level);
- config = GeomeConfig.baked(dimension);
+ config = OreSpawnApi.samplerConfig(level);
WorldGeologyProfile profile = WorldGeologyProfileManager.activeProfile();
mode = profile.geologyMode();
if (mode == GeologyMode.LEGACY) {
@@ -47,7 +46,7 @@ private OreSpawnGeologySampler(WorldServer level) {
}
static GeologySampler create(WorldServer level) {
- if (level == null || WorldGeologyProfileManager.activeServer() != level.getMinecraftServer()) {
+ if (!OreSpawnApi.isActiveLevel(level)) {
throw new IllegalStateException("The level is not part of OreSpawn's active server");
}
return new OreSpawnGeologySampler(level);
@@ -56,7 +55,10 @@ static GeologySampler create(WorldServer level) {
@Override
public GeologyColumn sampleColumn(int blockX, int blockZ, int surfaceY) {
BlockPos position = new BlockPos(blockX, surfaceY, blockZ);
- Biome biome = level.getBiome(position);
+ // Query the dimension's biome provider directly. World#getBiome may obtain a
+ // chunk and therefore load or generate neighbouring chunks when a large
+ // extension pattern samples a body crossing chunk boundaries.
+ Biome biome = level.provider.getBiomeProvider().getBiome(position);
ResourceLocation biomeId = WorldIds.biome(biome);
if (biomeId == null) biomeId = new ResourceLocation("orespawn", "unregistered_biome");
if (mode == GeologyMode.LEGACY) {
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 2866eb4f..291d84db 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.OptionalDouble;
import java.util.Set;
import java.util.function.Consumer;
@@ -27,11 +28,14 @@
public final class WorldgenProvider {
private final String modId;
private final int revision;
+ private final boolean mergeNewEntriesIntoExistingWorlds;
private final JsonObject definition;
- private WorldgenProvider(String modId, int revision, JsonObject definition) {
+ private WorldgenProvider(String modId, int revision, boolean mergeNewEntriesIntoExistingWorlds,
+ JsonObject definition) {
this.modId = modId;
this.revision = revision;
+ this.mergeNewEntriesIntoExistingWorlds = mergeNewEntriesIntoExistingWorlds;
this.definition = JsonCopies.copy(definition);
}
@@ -47,6 +51,15 @@ public int revision() {
return revision;
}
+ /**
+ * Whether provider entries introduced after a world was created may be merged
+ * into that world's saved profile. Providers default to the historical
+ * merge-enabled behaviour.
+ */
+ public boolean mergeNewEntriesIntoExistingWorlds() {
+ return mergeNewEntriesIntoExistingWorlds;
+ }
+
/** Returns a defensive JSON representation matching provider schema 4. */
public JsonObject toJson() {
return JsonCopies.copy(definition);
@@ -55,6 +68,7 @@ public JsonObject toJson() {
public static final class Builder {
private final String modId;
private final int revision;
+ private boolean mergeNewEntriesIntoExistingWorlds = true;
private final LinkedHashMap rocks = new LinkedHashMap<>();
private final LinkedHashMap ores = new LinkedHashMap<>();
private final LinkedHashMap fluidDeposits =
@@ -77,6 +91,16 @@ private Builder(String modId, int revision) {
this.revision = revision;
}
+ /**
+ * Controls whether newly declared entries are added to already-created world
+ * profiles. Set this to false when definitions capture structural add-on
+ * configuration that must only take effect for new worlds.
+ */
+ public Builder mergeNewEntriesIntoExistingWorlds(boolean value) {
+ mergeNewEntriesIntoExistingWorlds = value;
+ return this;
+ }
+
public Builder rock(RockDefinition rock) {
putUnique(rocks, rock.id(), rock, "rock");
return this;
@@ -214,6 +238,8 @@ public WorldgenProvider build() {
root.addProperty("schema_version", 4);
root.addProperty("provider_modid", modId);
root.addProperty("provider_revision", revision);
+ root.addProperty("merge_new_entries_into_existing_worlds",
+ mergeNewEntriesIntoExistingWorlds);
root.add("rocks", object(rocks));
root.add("ores", object(ores));
root.add("fluid_deposits", object(fluidDeposits));
@@ -223,7 +249,7 @@ public WorldgenProvider build() {
root.add("biome_palettes", object(biomePalettes));
root.add("dimension_materials", object(dimensionMaterials));
root.add("templates", object(templates));
- return new WorldgenProvider(modId, revision, root);
+ return new WorldgenProvider(modId, revision, mergeNewEntriesIntoExistingWorlds, root);
}
private void requireOwned(Collection ids, String type) {
@@ -536,6 +562,7 @@ public static final class OreDimensionDefinition implements JsonDefinition {
private final Set excludedBiomeDictionary;
private final Map hostBlockWeights;
private final Map hostTagWeights;
+ private final Double backgroundGenerationScale;
private OreDimensionDefinition(Builder builder) {
dimension = builder.dimension;
@@ -564,6 +591,7 @@ private OreDimensionDefinition(Builder builder) {
new LinkedHashSet<>(builder.excludedBiomeDictionary));
hostBlockWeights = immutableMap(builder.hostBlockWeights);
hostTagWeights = immutableMap(builder.hostTagWeights);
+ backgroundGenerationScale = builder.backgroundGenerationScale;
}
public static Builder builder(ResourceLocation dimension) { return new Builder(dimension); }
@@ -594,6 +622,15 @@ private OreDimensionDefinition(Builder builder) {
public Set excludedBiomeDictionary() { return excludedBiomeDictionary; }
public Map hostBlockWeights() { return hostBlockWeights; }
public Map hostTagWeights() { return hostTagWeights; }
+ /**
+ * Optional scale applied to other OreSpawn-managed generation and vanilla
+ * generation for this rule's primary output in this dimension. The rule
+ * declaring the controller remains unscaled.
+ */
+ public OptionalDouble backgroundGenerationScale() {
+ return backgroundGenerationScale == null ? OptionalDouble.empty()
+ : OptionalDouble.of(backgroundGenerationScale.doubleValue());
+ }
@Override
public JsonObject toJson() {
@@ -631,6 +668,9 @@ public JsonObject toJson() {
json.add("excluded_biome_ids", ids(excludedBiomeIds));
json.add("biome_dictionary", strings(biomeDictionary));
json.add("excluded_biome_dictionary", strings(excludedBiomeDictionary));
+ if (backgroundGenerationScale != null) {
+ json.addProperty("background_generation_scale", backgroundGenerationScale);
+ }
return json;
}
@@ -660,6 +700,7 @@ public static final class Builder {
private final Set excludedBiomeDictionary = new LinkedHashSet<>();
private final Map hostBlockWeights = new LinkedHashMap<>();
private final Map hostTagWeights = new LinkedHashMap<>();
+ private Double backgroundGenerationScale;
private Builder(ResourceLocation dimension) { this.dimension = Objects.requireNonNull(dimension, "dimension"); }
public Builder enabled(boolean value) { enabled = value; return this; }
@@ -694,6 +735,14 @@ public Builder pattern(ResourceLocation type, JsonObject settings) {
public Builder excludeBiomeDictionary(String value) {
excludedBiomeDictionary.add(nonBlank(value)); return this;
}
+ /**
+ * Scales background generation for the same primary resource and dimension.
+ * The declaring rule is the controller and is not itself scaled.
+ */
+ public Builder backgroundGenerationScale(double value) {
+ backgroundGenerationScale = value;
+ return this;
+ }
public Builder hostBlock(ResourceLocation value, double weight) {
hostBlocks.add(value);
hostBlockWeights.put(value, replacementWeight(weight));
@@ -711,6 +760,10 @@ public OreDimensionDefinition build() {
|| minQuantity < 1 || minQuantity > maxQuantity || maxQuantity > 64
|| !Double.isFinite(discardChanceOnAirExposure)
|| discardChanceOnAirExposure < 0.0D || discardChanceOnAirExposure > 1.0D
+ || (backgroundGenerationScale != null
+ && (!Double.isFinite(backgroundGenerationScale)
+ || backgroundGenerationScale < 0.0D
+ || backgroundGenerationScale > 1.0D))
|| spread < 0 || spread > 64 || verticalSpread < 0 || verticalSpread > 64
|| nodeSize < 1 || nodeSize > 32) {
throw new IllegalStateException("Invalid ore placement values for " + dimension);
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..b632a033
--- /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.GuiScreen;
+import net.minecraft.util.ResourceLocation;
+import net.minecraftforge.fml.relauncher.Side;
+import net.minecraftforge.fml.relauncher.SideOnly;
+
+/** Immutable registered action displayed by OreSpawn's world-settings screen. */
+@SideOnly(Side.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 GuiScreen createScreen(GuiScreen 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..a1df9d13
--- /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.util.ResourceLocation;
+import net.minecraftforge.fml.relauncher.Side;
+import net.minecraftforge.fml.relauncher.SideOnly;
+
+/** Client-only registry for add-on screens in OreSpawn's Mods directory. */
+@SideOnly(Side.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..4ee3841b
--- /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.GuiScreen;
+import net.minecraftforge.fml.relauncher.Side;
+import net.minecraftforge.fml.relauncher.SideOnly;
+
+/** Creates an add-on configuration screen whose Done action returns to OreSpawn's mod directory. */
+@FunctionalInterface
+@SideOnly(Side.CLIENT)
+public interface WorldSettingsScreenFactory {
+ GuiScreen create(GuiScreen parent);
+}
diff --git a/src/main/java/zone/moddev/mc/orespawn/client/ClientSetup.java b/src/main/java/zone/moddev/mc/orespawn/client/ClientSetup.java
index 83ea4337..5dc50511 100644
--- a/src/main/java/zone/moddev/mc/orespawn/client/ClientSetup.java
+++ b/src/main/java/zone/moddev/mc/orespawn/client/ClientSetup.java
@@ -16,5 +16,6 @@ public static synchronized void initialize() {
// class is registered as an instance. Both world-creation hooks are static,
// so the class object is required for the OreSpawn button to be installed.
MinecraftForge.EVENT_BUS.register(WorldCreationScreenHandler.class);
+ MinecraftForge.EVENT_BUS.register(WorldSettingsExtensionNavigation.class);
}
}
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..e03fb1df
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/orespawn/client/CogButton.java
@@ -0,0 +1,37 @@
+package zone.moddev.mc.orespawn.client;
+
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.renderer.GlStateManager;
+import net.minecraft.util.ResourceLocation;
+
+/** Small target-native icon button used by the OreSpawn Mods directory. */
+final class CogButton extends Button {
+ private static final ResourceLocation COG =
+ new ResourceLocation("orespawn", "textures/gui/cog.png");
+
+ CogButton(int x, int y, IPressable onPress, Tooltip tooltip) {
+ super(x, y, 22, 20, "", onPress, tooltip);
+ }
+
+ @Override
+ public void drawButton(Minecraft minecraft, int mouseX, int mouseY, float partialTicks) {
+ super.drawButton(minecraft, mouseX, mouseY, partialTicks);
+ if (!visible) return;
+ minecraft.getTextureManager().bindTexture(COG);
+ if (enabled) GlStateManager.color(1.0F, 1.0F, 1.0F, 1.0F);
+ else GlStateManager.color(0.45F, 0.45F, 0.45F, 0.8F);
+ GlStateManager.pushMatrix();
+ GlStateManager.translate(x + 11.0F, y + 10.0F, 0.0F);
+ GlStateManager.scale(1.5F, 1.5F, 1.0F);
+ drawModalRectWithCustomSizedTexture(-8, -8,
+ 0.0F, 0.0F, 16, 16, 16.0F, 16.0F);
+ if (enabled) {
+ // The source pixels deliberately retain soft edges. A second pass makes
+ // the enabled icon read clearly against the legacy button texture.
+ drawModalRectWithCustomSizedTexture(-8, -8,
+ 0.0F, 0.0F, 16, 16, 16.0F, 16.0F);
+ }
+ GlStateManager.popMatrix();
+ GlStateManager.color(1.0F, 1.0F, 1.0F, 1.0F);
+ }
+}
diff --git a/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnModDirectoryModel.java b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnModDirectoryModel.java
new file mode 100644
index 00000000..fac14c93
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnModDirectoryModel.java
@@ -0,0 +1,123 @@
+package zone.moddev.mc.orespawn.client;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import zone.moddev.mc.orespawn.OreSpawn;
+import zone.moddev.mc.orespawn.api.ProviderStatus;
+import zone.moddev.mc.orespawn.api.client.WorldSettingsExtension;
+import zone.moddev.mc.orespawn.api.client.WorldSettingsExtensionRegistry;
+import zone.moddev.mc.orespawn.integration.WorldgenIntegrationManager;
+import zone.moddev.mc.orespawn.integration.WorldgenIntegrationManager.ProviderIntegrationInfo;
+
+import net.minecraftforge.fml.common.Loader;
+import net.minecraftforge.fml.common.ModContainer;
+
+/** Builds the immutable, loaded-only model rendered by the OreSpawn Mods screen. */
+final class OreSpawnModDirectoryModel {
+ private OreSpawnModDirectoryModel() {
+ }
+
+ static List snapshot() {
+ List loaded = new ArrayList<>();
+ for (ModContainer mod : Loader.instance().getActiveModList()) {
+ loaded.add(new LoadedMod(mod.getModId(), mod.getName(), mod.getVersion()));
+ }
+ return assemble(loaded, WorldgenIntegrationManager.providerIntegrations(),
+ WorldSettingsExtensionRegistry.extensions());
+ }
+
+ static List assemble(List loadedMods,
+ List providers, List extensions) {
+ Map loaded = new LinkedHashMap<>();
+ for (LoadedMod mod : loadedMods) {
+ if (mod != null && mod.modId != null) loaded.put(mod.modId, mod);
+ }
+ Map integrations = new LinkedHashMap<>();
+ for (ProviderIntegrationInfo provider : providers) {
+ if (isVisible(provider.modId(), loaded)) {
+ integrations.computeIfAbsent(provider.modId(), ignored -> new MutableEntry())
+ .provider = provider;
+ }
+ }
+ for (WorldSettingsExtension extension : extensions) {
+ String modId = extension.id().getNamespace();
+ if (isVisible(modId, loaded)) {
+ integrations.computeIfAbsent(modId, ignored -> new MutableEntry())
+ .extension = extension;
+ }
+ }
+
+ List result = new ArrayList<>();
+ for (Map.Entry integration : integrations.entrySet()) {
+ LoadedMod mod = loaded.get(integration.getKey());
+ result.add(new Entry(mod.modId, nonBlank(mod.displayName, mod.modId),
+ nonBlank(mod.version, "?"), integration.getValue().provider,
+ integration.getValue().extension));
+ }
+ result.sort(Comparator.comparing((Entry entry) -> entry.displayName.toLowerCase(Locale.ROOT))
+ .thenComparing(entry -> entry.modId));
+ return Collections.unmodifiableList(result);
+ }
+
+ private static boolean isVisible(String modId, Map loaded) {
+ return modId != null && !OreSpawn.MODID.equals(modId) && loaded.containsKey(modId);
+ }
+
+ private static String nonBlank(String value, String fallback) {
+ return value == null || value.trim().isEmpty() ? fallback : value;
+ }
+
+ static final class LoadedMod {
+ final String modId;
+ final String displayName;
+ final String version;
+
+ LoadedMod(String modId, String displayName, String version) {
+ this.modId = modId;
+ this.displayName = displayName;
+ this.version = version;
+ }
+ }
+
+ static final class Entry {
+ final String modId;
+ final String displayName;
+ final String version;
+ final ProviderIntegrationInfo provider;
+ final WorldSettingsExtension extension;
+
+ Entry(String modId, String displayName, String version,
+ ProviderIntegrationInfo provider, WorldSettingsExtension extension) {
+ this.modId = modId;
+ this.displayName = displayName;
+ this.version = version;
+ this.provider = provider;
+ this.extension = extension;
+ }
+
+ boolean configurable() { return extension != null; }
+ boolean nativeOs4() { return provider != null && provider.nativeOs4(); }
+ int schemaVersion() { return provider == null ? -1 : provider.schemaVersion(); }
+ int providerRevision() { return provider == null ? -1 : provider.providerRevision(); }
+ List legacyLineages() {
+ return provider == null ? Collections.emptyList() : provider.legacyLineages();
+ }
+ String statusKey() {
+ if (provider == null) return "config_only";
+ if (provider.rejected()) return "rejected";
+ ProviderStatus status = provider.status();
+ return status == null ? "inactive" : status.name().toLowerCase(Locale.ROOT);
+ }
+ }
+
+ private static final class MutableEntry {
+ ProviderIntegrationInfo provider;
+ WorldSettingsExtension extension;
+ }
+}
diff --git a/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnModsScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnModsScreen.java
new file mode 100644
index 00000000..67d80c8c
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnModsScreen.java
@@ -0,0 +1,189 @@
+package zone.moddev.mc.orespawn.client;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import zone.moddev.mc.orespawn.client.OreSpawnModDirectoryModel.Entry;
+
+import net.minecraft.client.gui.GuiScreen;
+import net.minecraft.client.resources.I18n;
+import net.minecraft.util.text.TextComponentString;
+import net.minecraft.util.text.TextComponentTranslation;
+
+/** Paginated directory of loaded mods known to OreSpawn. */
+final class OreSpawnModsScreen extends OreSpawnScreen {
+ private static final int ROW_HEIGHT = 42;
+ private final GuiScreen parent;
+ private final List entries;
+ private int page;
+ private String error;
+ private int contentLeft;
+ private int contentWidth;
+ private int listTop;
+ private int pageSize;
+
+ OreSpawnModsScreen(GuiScreen parent) {
+ this(parent, OreSpawnModDirectoryModel.snapshot());
+ }
+
+ OreSpawnModsScreen(GuiScreen parent, List entries) {
+ super(new TextComponentTranslation("screen.orespawn.mods"));
+ this.parent = parent;
+ this.entries = Collections.unmodifiableList(new ArrayList<>(entries));
+ }
+
+ @Override
+ protected void init() {
+ contentWidth = Math.min(390, Math.max(280, width - 24));
+ contentLeft = (width - contentWidth) / 2;
+ listTop = 38;
+ int controlsY = height - 52;
+ pageSize = pageSize(height);
+ int pageCount = pageCount(entries.size(), pageSize);
+ page = Math.max(0, Math.min(page, pageCount - 1));
+ int start = page * pageSize;
+ for (int index = 0; index < pageSize && start + index < entries.size(); index++) {
+ Entry entry = entries.get(start + index);
+ CogButton cog = addButton(new CogButton(contentLeft + contentWidth - 26,
+ listTop + index * ROW_HEIGHT + 9, button -> open(entry),
+ (button, mouseX, mouseY) -> renderStringTooltip(tooltip(entry), mouseX, mouseY)));
+ cog.enabled = entry.configurable();
+ }
+
+ Button previous = addButton(new Button(contentLeft, controlsY, 80, 20,
+ new TextComponentTranslation("button.orespawn.previous"),
+ button -> { page--; rebuildWidgets(); }));
+ Button next = addButton(new Button(contentLeft + contentWidth - 80, controlsY, 80, 20,
+ new TextComponentTranslation("button.orespawn.next"),
+ button -> { page++; rebuildWidgets(); }));
+ previous.enabled = page > 0;
+ next.enabled = page + 1 < pageCount;
+ addButton(new Button(width / 2 - 75, height - 28, 150, 20,
+ DialogTexts.GUI_DONE, button -> onClose()));
+ }
+
+ private void open(Entry entry) {
+ if (!entry.configurable()) return;
+ try {
+ GuiScreen screen = entry.extension.createScreen(this);
+ if (screen == null) {
+ error = I18n.format("error.orespawn.mod.screen_missing", entry.modId);
+ return;
+ }
+ WorldSettingsExtensionNavigation.open(this, screen);
+ } catch (RuntimeException failure) {
+ error = I18n.format("error.orespawn.mod.screen_failed", entry.modId,
+ failure.getClass().getSimpleName());
+ }
+ }
+
+ private List tooltip(Entry entry) {
+ List lines = new ArrayList<>();
+ lines.add(I18n.format(entry.configurable()
+ ? "tooltip.orespawn.mod.configure" : "tooltip.orespawn.mod.unavailable",
+ entry.displayName));
+ lines.add(I18n.format("label.orespawn.mod.id_version", entry.modId, entry.version));
+ lines.add(integration(entry));
+ for (Integer lineage : entry.legacyLineages()) {
+ if (!entry.nativeOs4() && lineage.intValue() == primaryLegacy(entry)) continue;
+ lines.add(I18n.format("label.orespawn.mod.additional_lineage", lineage));
+ }
+ lines.add(I18n.format("label.orespawn.mod.status",
+ I18n.format("status.orespawn.mod." + entry.statusKey())));
+ return lines;
+ }
+
+ private String integration(Entry entry) {
+ if (entry.nativeOs4()) {
+ if (entry.schemaVersion() > 0 && entry.providerRevision() > 0) {
+ return I18n.format("label.orespawn.mod.os4_version",
+ entry.schemaVersion(), entry.providerRevision());
+ }
+ return I18n.format("label.orespawn.mod.os4");
+ }
+ int legacy = primaryLegacy(entry);
+ if (legacy > 0) {
+ if (entry.schemaVersion() > 0 && entry.providerRevision() > 0) {
+ return I18n.format("label.orespawn.mod.legacy_version", legacy,
+ entry.schemaVersion(), entry.providerRevision());
+ }
+ return I18n.format("label.orespawn.mod.legacy", legacy);
+ }
+ return I18n.format("label.orespawn.mod.config_only");
+ }
+
+ private static int primaryLegacy(Entry entry) {
+ List lineages = entry.legacyLineages();
+ return lineages.isEmpty() ? 0 : lineages.get(lineages.size() - 1);
+ }
+
+ private void rebuildWidgets() {
+ buttons.clear();
+ children.clear();
+ init();
+ }
+
+ static int pageSize(int height) {
+ return Math.max(1, ((height - 52) - 38) / ROW_HEIGHT);
+ }
+
+ static int pageCount(int entries, int pageSize) {
+ return Math.max(1, (Math.max(0, entries) + Math.max(1, pageSize) - 1)
+ / Math.max(1, pageSize));
+ }
+
+ @Override
+ public void onClose() {
+ minecraft.displayGuiScreen(parent);
+ }
+
+ @Override
+ public void render(int mouseX, int mouseY, float partialTick) {
+ renderBackground();
+ drawCenteredString(font, title, width / 2, 14, 0xFFFFFF);
+ if (entries.isEmpty()) {
+ drawCenteredString(font, new TextComponentTranslation("label.orespawn.mod.none"),
+ width / 2, height / 2 - 6, 0xA0A0A0);
+ }
+ int start = page * pageSize;
+ for (int index = 0; index < pageSize && start + index < entries.size(); index++) {
+ drawEntry(entries.get(start + index), listTop + index * ROW_HEIGHT);
+ }
+ int pages = pageCount(entries.size(), pageSize);
+ drawCenteredString(font, new TextComponentString((page + 1) + " / " + pages),
+ width / 2, height - 46, 0xA0A0A0);
+ if (error != null) {
+ drawCenteredString(font, new TextComponentString(error), width / 2, 27, 0xFF5555);
+ }
+ super.render(mouseX, mouseY, partialTick);
+ }
+
+ private void drawEntry(Entry entry, int y) {
+ drawRect(contentLeft, y, contentLeft + contentWidth, y + 38, 0x70000000);
+ int textWidth = contentWidth - 36;
+ drawString(font, new TextComponentString(trim(entry.displayName, textWidth)),
+ contentLeft + 6, y + 3, 0xFFFFFF);
+ drawString(font, new TextComponentString(trim(
+ I18n.format("label.orespawn.mod.id_version", entry.modId, entry.version), textWidth)),
+ contentLeft + 6, y + 14, 0xA0A0A0);
+ String details = I18n.format("label.orespawn.mod.integration_status", integration(entry),
+ I18n.format("status.orespawn.mod." + entry.statusKey()));
+ drawString(font, new TextComponentString(trim(details, textWidth)),
+ contentLeft + 6, y + 25, statusColor(entry.statusKey()));
+ }
+
+ private String trim(String value, int maximumWidth) {
+ if (font.getStringWidth(value) <= maximumWidth) return value;
+ String suffix = "...";
+ int width = maximumWidth - font.getStringWidth(suffix);
+ return font.trimStringToWidth(value, Math.max(0, width)) + suffix;
+ }
+
+ private static int statusColor(String status) {
+ if ("active".equals(status)) return 0x55FF55;
+ if ("pending".equals(status)) return 0xFFFF55;
+ if ("rejected".equals(status)) return 0xFF5555;
+ return 0xA0A0A0;
+ }
+}
diff --git a/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnWorldSettingsScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnWorldSettingsScreen.java
index 4e540796..8f599ccb 100644
--- a/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnWorldSettingsScreen.java
+++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnWorldSettingsScreen.java
@@ -106,8 +106,11 @@ columnWidth, BUTTON_HEIGHT, fluidEditorLabel(), button -> openFluidDeposits(),
contentWidth, BUTTON_HEIGHT,
new TextComponentTranslation("button.orespawn.biomes_world_materials"),
button -> openBiomeWorldMaterials(), "tooltip.orespawn.main.biomes_materials"));
- addButton(OreSpawnScreenLayout.button(this, font, left, top + (row * rowIndex),
- contentWidth, BUTTON_HEIGHT, new TextComponentTranslation("button.orespawn.help"),
+ addButton(OreSpawnScreenLayout.explainedButton(this, font, left, top + (row * rowIndex),
+ columnWidth, BUTTON_HEIGHT, new TextComponentTranslation("button.orespawn.mods"),
+ button -> openMods(), "tooltip.orespawn.main.mods"));
+ addButton(OreSpawnScreenLayout.button(this, font, right, top + (row * rowIndex++),
+ columnWidth, BUTTON_HEIGHT, new TextComponentTranslation("button.orespawn.help"),
button -> openHelp()));
} else {
geologyModeButton = OreSpawnScreenLayout.explain(this,
@@ -150,12 +153,15 @@ columnWidth, BUTTON_HEIGHT, new TextComponentTranslation("button.orespawn.materi
addButton(OreSpawnScreenLayout.explainedButton(this, font, left, top + (row * rowIndex),
columnWidth, BUTTON_HEIGHT, new TextComponentTranslation("button.orespawn.advanced"),
button -> openAdvanced(), "tooltip.orespawn.main.advanced"));
+ addButton(OreSpawnScreenLayout.explainedButton(this, font, right, top + (row * rowIndex++),
+ columnWidth, BUTTON_HEIGHT, fluidEditorLabel(), button -> openFluidDeposits(),
+ "tooltip.orespawn.main.fluid_editor"));
+ addButton(OreSpawnScreenLayout.explainedButton(this, font, left, top + (row * rowIndex),
+ columnWidth, BUTTON_HEIGHT, new TextComponentTranslation("button.orespawn.mods"),
+ button -> openMods(), "tooltip.orespawn.main.mods"));
addButton(OreSpawnScreenLayout.button(this, font, right, top + (row * rowIndex++),
columnWidth, BUTTON_HEIGHT, new TextComponentTranslation("button.orespawn.help"),
button -> openHelp()));
- addButton(OreSpawnScreenLayout.explainedButton(this, font, left, top + (row * rowIndex),
- contentWidth, BUTTON_HEIGHT, fluidEditorLabel(), button -> openFluidDeposits(),
- "tooltip.orespawn.main.fluid_editor"));
}
addButton(OreSpawnScreenLayout.button(this, font, left, this.height - 28, columnWidth, BUTTON_HEIGHT,
DialogTexts.GUI_DONE, button -> saveAndClose()));
@@ -322,6 +328,11 @@ private void openHelp() {
minecraft.displayGuiScreen(new OreSpawnGuideScreen(this));
}
+ private void openMods() {
+ syncSession();
+ minecraft.displayGuiScreen(new OreSpawnModsScreen(this));
+ }
+
@Override
public void onClose() {
minecraft.displayGuiScreen(parent);
diff --git a/src/main/java/zone/moddev/mc/orespawn/client/WorldSettingsExtensionNavigation.java b/src/main/java/zone/moddev/mc/orespawn/client/WorldSettingsExtensionNavigation.java
new file mode 100644
index 00000000..ffb6032d
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/orespawn/client/WorldSettingsExtensionNavigation.java
@@ -0,0 +1,39 @@
+package zone.moddev.mc.orespawn.client;
+
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.gui.GuiMainMenu;
+import net.minecraft.client.gui.GuiScreen;
+import net.minecraftforge.client.event.GuiOpenEvent;
+import net.minecraftforge.fml.common.eventhandler.SubscribeEvent;
+
+/** Keeps an add-on's inherited Escape action inside OreSpawn's screen stack. */
+public final class WorldSettingsExtensionNavigation {
+ private static GuiScreen directory;
+ private static GuiScreen extensionScreen;
+
+ private WorldSettingsExtensionNavigation() {
+ }
+
+ static synchronized void open(GuiScreen parentDirectory, GuiScreen screen) {
+ directory = parentDirectory;
+ extensionScreen = screen;
+ Minecraft.getMinecraft().displayGuiScreen(screen);
+ }
+
+ @SubscribeEvent
+ public static synchronized void onScreenOpen(GuiOpenEvent event) {
+ if (extensionScreen == null) return;
+ // This is the initial transition from the directory to the add-on.
+ if (event.getGui() == extensionScreen) return;
+ Minecraft minecraft = Minecraft.getMinecraft();
+ boolean inheritedEscape = event.getGui() == null
+ || (minecraft.world == null && event.getGui() instanceof GuiMainMenu);
+ if (minecraft.currentScreen == extensionScreen && inheritedEscape) {
+ event.setGui(directory);
+ }
+ // Done already names the directory as its destination. Other non-null
+ // transitions belong to the add-on and are deliberately left untouched.
+ directory = null;
+ extensionScreen = null;
+ }
+}
diff --git a/src/main/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManager.java b/src/main/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManager.java
index b4cfa6ea..aa9b2b8e 100644
--- a/src/main/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManager.java
+++ b/src/main/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManager.java
@@ -67,6 +67,7 @@ public final class WorldgenIntegrationManager {
private static final Map FILE_PROVIDERS = new LinkedHashMap<>();
private static final Map ACTIVE_PROVIDERS = new LinkedHashMap<>();
private static final Map TEMPLATES = new LinkedHashMap<>();
+ private static final Map> LEGACY_LINEAGES = new LinkedHashMap<>();
private static final Set INVALID_PROVIDERS = new HashSet<>();
private static final Set FILE_PROVIDER_IDS = new HashSet<>();
private static boolean initialized;
@@ -165,15 +166,77 @@ public static synchronized Set activeProviderIds() {
return Collections.unmodifiableSet(new LinkedHashSet<>(ACTIVE_PROVIDERS.keySet()));
}
+ /** Records a loaded legacy integration at the point where its source is identified. */
+ public static synchronized void recordLegacyLineage(String providerModId, int orespawnGeneration) {
+ if (providerModId == null || !MOD_ID.matcher(providerModId).matches()
+ || orespawnGeneration < 1 || orespawnGeneration > 3) {
+ return;
+ }
+ LEGACY_LINEAGES.computeIfAbsent(providerModId, ignored -> new LinkedHashSet<>())
+ .add(orespawnGeneration);
+ }
+
+ /** Returns immutable provider metadata for the client-side mod directory. */
+ public static synchronized List providerIntegrations() {
+ Set providerIds = new LinkedHashSet<>();
+ providerIds.addAll(API_PROVIDERS.keySet());
+ providerIds.addAll(RESOURCE_PROVIDERS.keySet());
+ providerIds.addAll(FILE_PROVIDER_IDS);
+ providerIds.addAll(ACTIVE_PROVIDERS.keySet());
+ providerIds.addAll(INVALID_PROVIDERS);
+ providerIds.addAll(LEGACY_LINEAGES.keySet());
+ List sorted = new ArrayList<>(providerIds);
+ Collections.sort(sorted);
+ List result = new ArrayList<>();
+ for (String providerId : sorted) {
+ JsonObject root = FILE_PROVIDERS.get(providerId);
+ if (root == null) root = RESOURCE_PROVIDERS.get(providerId);
+ ProviderDefinition active = ACTIVE_PROVIDERS.get(providerId);
+ if (root == null && active != null) root = active.root;
+ WorldgenProvider api = API_PROVIDERS.get(providerId);
+ List legacy = new ArrayList<>(LEGACY_LINEAGES.getOrDefault(
+ providerId, Collections.emptySet()));
+ Collections.sort(legacy);
+ boolean hasApiOrResource = api != null
+ || RESOURCE_PROVIDERS.containsKey(providerId);
+ boolean nativeOs4 = hasApiOrResource
+ || (FILE_PROVIDER_IDS.contains(providerId) && legacy.isEmpty());
+ boolean hasProvider = root != null || api != null
+ || RESOURCE_PROVIDERS.containsKey(providerId)
+ || FILE_PROVIDER_IDS.contains(providerId);
+ result.add(new ProviderIntegrationInfo(providerId, nativeOs4, hasProvider,
+ root == null ? (api == null ? -1 : 4) : integer(root, "schema_version", -1),
+ root == null ? (api == null ? -1 : api.revision())
+ : integer(root, "provider_revision", -1),
+ getProviderStatus(providerId), INVALID_PROVIDERS.contains(providerId), legacy));
+ }
+ return Collections.unmodifiableList(result);
+ }
+
/** Merge new provider-owned defaults without overwriting pack or world values. */
public static synchronized boolean mergeProviderDefinitions(JsonObject target) {
+ return mergeProviderDefinitions(target, false);
+ }
+
+ /**
+ * Merge provider definitions into an already-created world's saved profile.
+ * Providers may opt out so structural configuration is captured only when a
+ * new world is created.
+ */
+ public static synchronized boolean mergeProviderDefinitionsIntoExistingWorld(JsonObject target) {
+ return mergeProviderDefinitions(target, true);
+ }
+
+ private static boolean mergeProviderDefinitions(JsonObject target, boolean existingWorld) {
JsonObject manifests = object(target, "providers");
JsonObject legacyOreManifests = object(target, "ore_providers");
boolean changed = false;
for (ProviderDefinition provider : ACTIVE_PROVIDERS.values()) {
JsonObject manifest = object(manifests, provider.modId);
- if (!bool(manifest, "profile_defaults_applied", false)
+ boolean mergeNewEntries = !existingWorld
+ || bool(provider.root, "merge_new_entries_into_existing_worlds", true);
+ if (mergeNewEntries && !bool(manifest, "profile_defaults_applied", false)
&& provider.root.has("profile_defaults")
&& provider.root.get("profile_defaults").isJsonObject()) {
mergeOverlay(target, provider.root.getAsJsonObject("profile_defaults"));
@@ -193,7 +256,7 @@ public static synchronized boolean mergeProviderDefinitions(JsonObject target) {
for (Entry entry : providerSection.entrySet()) {
String id = entry.getKey();
- if (!targetSection.has(id) && !known.contains(id)) {
+ if (mergeNewEntries && !targetSection.has(id) && !known.contains(id)) {
JsonObject value = JsonCopies.copy(entry.getValue().getAsJsonObject());
if (!"biome_rules".equals(sectionName)) {
value.addProperty("source_provider", provider.modId);
@@ -201,7 +264,9 @@ public static synchronized boolean mergeProviderDefinitions(JsonObject target) {
targetSection.add(id, value);
changed = true;
}
- known.add(id);
+ if (targetSection.has(id)) {
+ known.add(id);
+ }
if (targetSection.has(id) && targetSection.get(id).isJsonObject()) {
targetSection.getAsJsonObject(id).remove("orphaned_provider");
}
@@ -410,14 +475,7 @@ private static void rebuildActiveProviders() {
if (INVALID_PROVIDERS.contains(providerId)) {
continue;
}
- JsonObject root = FILE_PROVIDERS.get(providerId);
- if (root == null) {
- root = RESOURCE_PROVIDERS.get(providerId);
- }
- if (root == null) {
- WorldgenProvider apiProvider = API_PROVIDERS.get(providerId);
- root = apiProvider == null ? null : apiProvider.toJson();
- }
+ JsonObject root = selectedProviderRoot(providerId);
if (root == null || !Loader.isModLoaded(providerId)) {
continue;
}
@@ -435,6 +493,16 @@ private static void rebuildActiveProviders() {
}
}
+ private static JsonObject selectedProviderRoot(String providerId) {
+ JsonObject root = FILE_PROVIDERS.get(providerId);
+ if (root == null) root = RESOURCE_PROVIDERS.get(providerId);
+ if (root == null) {
+ WorldgenProvider apiProvider = API_PROVIDERS.get(providerId);
+ root = apiProvider == null ? null : apiProvider.toJson();
+ }
+ return root;
+ }
+
static void validateProvider(String providerId, JsonObject root) {
int schema = integer(root, "schema_version", -1);
if (schema != 1 && schema != 2 && schema != 3 && schema != 4) {
@@ -446,6 +514,11 @@ static void validateProvider(String providerId, JsonObject root) {
if (integer(root, "provider_revision", -1) < 1) {
throw new JsonSyntaxException("provider_revision must be at least 1");
}
+ if (root.has("merge_new_entries_into_existing_worlds")
+ && (!root.get("merge_new_entries_into_existing_worlds").isJsonPrimitive()
+ || !root.getAsJsonPrimitive("merge_new_entries_into_existing_worlds").isBoolean())) {
+ throw new JsonSyntaxException("merge_new_entries_into_existing_worlds must be boolean");
+ }
if (schema == 1) {
for (String section : new String[] { "rocks", "geomes", "biome_rules",
"terrain_dimensions", "fluid_deposits", "biome_palettes",
@@ -570,12 +643,15 @@ private static void validateOreRule(String idText, Entry en
int maxY = integer(rule, "max_y", Integer.MIN_VALUE);
double frequency = decimal(rule, "frequency", -1.0D);
double discardChance = decimal(rule, "discard_chance_on_air_exposure", 0.0D);
+ double backgroundScale = decimal(rule, "background_generation_scale", 1.0D);
int[] quantities = validateQuantityRange(rule);
int minQuantity = quantities[0];
int maxQuantity = quantities[1];
if (minY < -2048 || maxY > 2048 || minY > maxY || frequency < 0.0D
|| frequency > 64.0D || !Double.isFinite(discardChance)
|| discardChance < 0.0D || discardChance > 1.0D
+ || !Double.isFinite(backgroundScale) || backgroundScale < 0.0D
+ || backgroundScale > 1.0D
|| minQuantity < 1 || minQuantity > maxQuantity || maxQuantity > 64) {
throw new JsonSyntaxException("invalid ore placement for " + idText + " in " + entry.getKey());
}
@@ -1051,6 +1127,40 @@ JsonObject section(String name) {
}
}
+ /** Immutable internal-facing metadata used to describe one OreSpawn integration. */
+ public static final class ProviderIntegrationInfo {
+ private final String modId;
+ private final boolean nativeOs4;
+ private final boolean provider;
+ private final int schemaVersion;
+ private final int providerRevision;
+ private final ProviderStatus status;
+ private final boolean rejected;
+ private final List legacyLineages;
+
+ public ProviderIntegrationInfo(String modId, boolean nativeOs4, boolean provider,
+ int schemaVersion, int providerRevision, ProviderStatus status,
+ boolean rejected, List legacyLineages) {
+ this.modId = modId;
+ this.nativeOs4 = nativeOs4;
+ this.provider = provider;
+ this.schemaVersion = schemaVersion;
+ this.providerRevision = providerRevision;
+ this.status = status;
+ this.rejected = rejected;
+ this.legacyLineages = Collections.unmodifiableList(new ArrayList<>(legacyLineages));
+ }
+
+ public String modId() { return modId; }
+ public boolean nativeOs4() { return nativeOs4; }
+ public boolean hasProvider() { return provider; }
+ public int schemaVersion() { return schemaVersion; }
+ public int providerRevision() { return providerRevision; }
+ public ProviderStatus status() { return status; }
+ public boolean rejected() { return rejected; }
+ public List legacyLineages() { return legacyLineages; }
+ }
+
public static final class TemplateDefinition {
private final ResourceLocation id;
private final String nameKey;
diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java
index 580fc1b8..81dc9b38 100644
--- a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java
+++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java
@@ -45,6 +45,7 @@ static JsonObject migrateIfNeeded(Path target, JsonObject defaults) {
static JsonObject migrateIfNeeded(Path target, JsonObject defaults,
BiFunction> providerRules) {
+ recordLegacyOwners(target.getParent());
if (Files.exists(target)) return null;
Path config = target.getParent();
Path mineralogy = config.resolve("mineralogy-geomes.json");
@@ -101,6 +102,16 @@ static JsonObject migrateIfNeeded(Path target, JsonObject defaults,
return migrated;
}
+ private static void recordLegacyOwners(Path config) {
+ if (config == null) return;
+ for (Path path : legacyFiles(config)) {
+ JsonObject root = readObject(path);
+ if (root == null || !"2.0".equals(string(root, "version", ""))) continue;
+ String owner = safe(path.getFileName().toString().replaceFirst("\\.json$", ""));
+ WorldgenIntegrationManager.recordLegacyLineage(owner, 2);
+ }
+ }
+
private static String migratedRuleId(String owner, String legacyName, JsonObject converted,
JsonObject existingRules,
BiFunction> providerRules, List report) {
diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGeneration.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGeneration.java
index b674eab5..f0ca501d 100644
--- a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGeneration.java
+++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGeneration.java
@@ -9,6 +9,7 @@
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
+import java.util.Optional;
import java.util.Random;
import java.util.Set;
import java.util.function.Supplier;
@@ -18,8 +19,10 @@
import com.google.gson.JsonObject;
import zone.moddev.mc.orespawn.OreSpawn;
import zone.moddev.mc.orespawn.api.CompiledOrePattern;
+import zone.moddev.mc.orespawn.api.GeologySampler;
import zone.moddev.mc.orespawn.api.OreDimensionSelector;
-import zone.moddev.mc.orespawn.api.OrePlacementContext;
+import zone.moddev.mc.orespawn.api.OreGenerationContext;
+import zone.moddev.mc.orespawn.api.OreSpawnApi;
import zone.moddev.mc.orespawn.init.OreSpawnPatterns;
import net.minecraft.util.math.BlockPos;
@@ -56,8 +59,11 @@ public final class OreSpawnOreGeneration {
private static volatile Map oresByDimension = EMPTY_DIMENSIONS;
private static volatile Map> vanillaTakeoverOutputs = Collections.emptyMap();
+ private static volatile Map> backgroundGenerationScales =
+ Collections.emptyMap();
private static volatile BakedOre[] selectorOres = NO_ORES;
private static volatile Set selectorVanillaTakeoverOutputs = Collections.emptySet();
+ private static volatile Map selectorBackgroundGenerationScales = Collections.emptyMap();
private static volatile BakedGeomeConfig geomeConfig;
private static volatile GeomeGeology classifier;
private static volatile long classifierSeed = Long.MIN_VALUE;
@@ -76,8 +82,10 @@ public static void refreshWorldConfig() {
BakedOres baked = bakeOres(WorldGeologyProfileManager.activeProfile().rootCopy(), geomeConfig);
oresByDimension = baked.byDimension;
vanillaTakeoverOutputs = baked.vanillaOutputs;
+ backgroundGenerationScales = baked.backgroundGenerationScales;
selectorOres = baked.selectorOres;
selectorVanillaTakeoverOutputs = baked.selectorVanillaOutputs;
+ selectorBackgroundGenerationScales = baked.selectorBackgroundGenerationScales;
synchronized (CLASSIFIER_LOCK) {
classifier = null;
classifierSeed = Long.MIN_VALUE;
@@ -91,6 +99,28 @@ static boolean takesOverVanillaOre(ResourceLocation dimension, Block output) {
return !WorldgenBenchmark.isVanillaBaseline() && outputs != null && outputs.contains(output);
}
+ static boolean allowsVanillaOre(ResourceLocation dimension, Block output, long worldSeed,
+ int chunkX, int chunkZ) {
+ if (takesOverVanillaOre(dimension, output)) return false;
+ double scale = backgroundScale(dimension, output);
+ if (scale >= 1.0D) return true;
+ if (scale <= 0.0D) return false;
+ ResourceLocation outputId = ForgeRegistries.BLOCKS.getKey(output);
+ return passesBackgroundScale(dimension, outputId, worldSeed, chunkX, chunkZ, scale);
+ }
+
+ static boolean passesBackgroundScale(ResourceLocation dimension, ResourceLocation outputId,
+ long worldSeed, int chunkX, int chunkZ, double scale) {
+ if (scale >= 1.0D) return true;
+ if (scale <= 0.0D) return false;
+ long identity = ((long) dimension.toString().hashCode() << 32)
+ ^ (outputId == null ? 0L : outputId.toString().hashCode() & 0xFFFFFFFFL);
+ long chunk = ((long) chunkX & 0xFFFFFFFFL) | (((long) chunkZ & 0xFFFFFFFFL) << 32);
+ long value = mix(worldSeed, chunk, identity);
+ double sample = (double) (value >>> 11) * 0x1.0p-53;
+ return sample < scale;
+ }
+
static boolean hasManagedOres(ResourceLocation dimension) {
return oresForDimension(dimension).length != 0;
}
@@ -99,7 +129,8 @@ static boolean needsVanillaOreGate(ResourceLocation dimension) {
if (WorldGeologyProfileManager.activeProfile().suppressAllOreFeatures()) return true;
Set outputs = vanillaTakeoverOutputs.get(dimension);
if (outputs == null && selectorAllows(dimension)) outputs = selectorVanillaTakeoverOutputs;
- return outputs != null && !outputs.isEmpty();
+ Map scales = scalesForDimension(dimension);
+ return (outputs != null && !outputs.isEmpty()) || !scales.isEmpty();
}
boolean generate(World world, Chunk chunk, Random random) {
@@ -113,7 +144,8 @@ boolean generate(World world, Chunk chunk, Random random) {
setCenter(scratch.cursor, chunk);
Biome biome = world.getBiome(scratch.cursor);
ResourceLocation biomeId = WorldIds.biome(biome);
- boolean changed = generateChunk(world, chunk, biome, biomeId, dimension,
+ WorldServer level = world instanceof WorldServer ? (WorldServer) world : null;
+ boolean changed = generateChunk(level, world, chunk, biome, biomeId, dimension,
world.getSeed(), random, ores, false, scratch);
OreRetrogenManager.markGenerated(dimension, chunk.getPos());
return changed;
@@ -129,7 +161,7 @@ static boolean retrogen(WorldServer level, Chunk chunk) {
setCenter(scratch.cursor, chunk);
Biome biome = level.getBiome(scratch.cursor);
ResourceLocation biomeId = WorldIds.biome(biome);
- return generateChunk(null, chunk, biome, biomeId, dimension, level.getSeed(),
+ return generateChunk(level, null, chunk, biome, biomeId, dimension, level.getSeed(),
new Random(seed), ores, true, scratch);
}
@@ -138,13 +170,14 @@ private static void setCenter(BlockPos.MutableBlockPos cursor, Chunk chunk) {
cursor.setPos(chunkPos.getXStart() + 8, 0, chunkPos.getZStart() + 8);
}
- private static boolean generateChunk(World world, Chunk chunk, Biome biome,
+ private static boolean generateChunk(WorldServer level, World world, Chunk chunk, Biome biome,
ResourceLocation biomeId,
ResourceLocation dimension, long worldSeed, Random random, BakedOre[] ores,
boolean retrogenOnly, GenerationScratch scratch) {
ChunkPos chunkPos = chunk.getPos();
int centerX = chunkPos.getXStart() + 8;
int centerZ = chunkPos.getZStart() + 8;
+ Optional geologySampler = scratch.geologySampler(level, dimension);
int geome = -1;
if (WorldIds.OVERWORLD.equals(dimension)) {
geome = classifier(worldSeed).classifyColumn(biome, biomeId, centerX, centerZ,
@@ -157,13 +190,15 @@ private static boolean generateChunk(World world, Chunk chunk, Biome biome,
if (!ore.acceptsBiome(biome, biomeId)) {
continue;
}
- double frequency = ore.frequency;
+ double frequency = scaledManagedFrequency(ore.frequency, ore.backgroundController,
+ backgroundScale(dimension, ore.output.getBlock()));
if (geome >= 0) {
frequency *= ore.geomeWeights[geome];
}
int attempts = attemptsForFrequency(random, frequency);
for (int attempt = 0; attempt < attempts; attempt++) {
- changed |= placeAttempt(world, chunk, random, ore, geome, scratch);
+ changed |= placeAttempt(world, chunk, random, ore, geome, dimension,
+ worldSeed, geologySampler, scratch);
}
}
if (changed) {
@@ -182,7 +217,8 @@ private static long mix(long seed, long chunk, long revision) {
}
private static boolean placeAttempt(World world, Chunk chunk, Random random,
- BakedOre ore, int geome,
+ BakedOre ore, int geome, ResourceLocation dimension, long worldSeed,
+ Optional geologySampler,
GenerationScratch scratch) {
int minY = Math.max(ore.minY, 0);
int maxY = Math.min(ore.maxY, 256 - 1);
@@ -193,8 +229,8 @@ private static boolean placeAttempt(World world, Chunk chunk, Random random,
int y = ore.heightDistribution.sample(random, minY, maxY);
int z = chunk.getPos().getZStart() + random.nextInt(16);
int quantity = sampleQuantity(random, ore.minQuantity, ore.maxQuantity);
- scratch.patternContext.initialize(world, chunk, random, ore, geome, x, y, z, minY, maxY,
- quantity);
+ scratch.patternContext.initialize(world, chunk, random, ore, geome, dimension, worldSeed,
+ geologySampler, x, y, z, minY, maxY, quantity);
return ore.pattern.place(scratch.patternContext);
}
@@ -218,6 +254,17 @@ private static BakedOre[] oresForDimension(ResourceLocation dimension) {
return exact != null ? exact : selectorAllows(dimension) ? selectorOres : NO_ORES;
}
+ private static Map scalesForDimension(ResourceLocation dimension) {
+ Map exact = backgroundGenerationScales.get(dimension);
+ return exact != null ? exact
+ : selectorAllows(dimension) ? selectorBackgroundGenerationScales : Collections.emptyMap();
+ }
+
+ private static double backgroundScale(ResourceLocation dimension, Block output) {
+ Double scale = scalesForDimension(dimension).get(output);
+ return scale == null ? 1.0D : scale.doubleValue();
+ }
+
private static boolean insideChunk(Chunk chunk, int x, int y, int z) {
return insideChunk(chunk.getPos(), x, y, z);
}
@@ -317,26 +364,37 @@ private static BakedOres bakeOres(JsonObject profile, BakedGeomeConfig config) {
Map result = new HashMap<>();
Map> vanillaOutputs = new HashMap<>();
+ Map> generationScales = new HashMap<>();
for (ResourceLocation dimension : explicitDimensions) {
List combined = new ArrayList<>();
Set suppressed = Collections.newSetFromMap(new IdentityHashMap());
+ Map scales = new IdentityHashMap<>();
for (BakedOreRule rule : rules) {
BakedOre selected = selectRule(rule.explicit, rule.explicitDimensions,
rule.selector, dimension);
if (selected != null) {
combined.add(selected);
if (rule.suppressVanilla) suppressed.add(rule.output);
+ if (selected.backgroundController) {
+ mergeBackgroundScale(scales, rule.output, selected.backgroundGenerationScale);
+ }
}
}
result.put(dimension, combined.toArray(new BakedOre[combined.size()]));
vanillaOutputs.put(dimension, Collections.unmodifiableSet(suppressed));
+ generationScales.put(dimension, Collections.unmodifiableMap(scales));
}
List selectorList = new ArrayList<>();
Set selectorOutputs = Collections.newSetFromMap(new IdentityHashMap());
+ Map selectorScales = new IdentityHashMap<>();
for (BakedOreRule rule : rules) {
if (rule.selector == null) continue;
selectorList.add(rule.selector);
if (rule.suppressVanilla) selectorOutputs.add(rule.output);
+ if (rule.selector.backgroundController) {
+ mergeBackgroundScale(selectorScales, rule.output,
+ rule.selector.backgroundGenerationScale);
+ }
}
BakedOre[] selectorResult = selectorList.toArray(new BakedOre[selectorList.size()]);
LOGGER.info("Baked {} OreSpawn-managed ore definitions across {} dimensions",
@@ -346,8 +404,10 @@ private static BakedOres bakeOres(JsonObject profile, BakedGeomeConfig config) {
immutableVanillaOutputs.put(entry.getKey(), Collections.unmodifiableSet(entry.getValue()));
}
return new BakedOres(Collections.unmodifiableMap(result),
- Collections.unmodifiableMap(immutableVanillaOutputs), selectorResult,
- Collections.unmodifiableSet(selectorOutputs));
+ Collections.unmodifiableMap(immutableVanillaOutputs),
+ Collections.unmodifiableMap(generationScales), selectorResult,
+ Collections.unmodifiableSet(selectorOutputs),
+ Collections.unmodifiableMap(selectorScales));
}
private static void reportBakeProblem(String message, Object... arguments) {
@@ -362,6 +422,17 @@ private static void reportBakeProblem(String message, Object... arguments) {
}
}
+ static double scaledManagedFrequency(double frequency, boolean controller, double scale) {
+ return controller ? frequency : frequency * scale;
+ }
+
+ static void mergeBackgroundScale(Map scales, Block output, double scale) {
+ Double previous = scales.get(output);
+ if (previous == null || scale < previous.doubleValue()) {
+ scales.put(output, scale);
+ }
+ }
+
private static JsonObject objectOrEmpty(JsonObject root, String key) {
return root.has(key) && root.get(key).isJsonObject() ? root.getAsJsonObject(key) : new JsonObject();
}
@@ -415,6 +486,10 @@ private static BakedOre bakeOre(IBlockState output, IBlockState deepOutput, int
int nodeSize = boundedInteger(json, "node_size", 4, 1, 32);
double discardChanceOnAirExposure = Math.max(0.0D, Math.min(1.0D,
decimal(json, "discard_chance_on_air_exposure", 0.0D)));
+ boolean backgroundController = json.has("background_generation_scale");
+ double backgroundGenerationScale = backgroundController
+ ? Math.max(0.0D, Math.min(1.0D,
+ decimal(json, "background_generation_scale", 1.0D))) : 1.0D;
double[] geomeWeights = new double[config.geomeCount()];
java.util.Arrays.fill(geomeWeights, 1.0D);
@@ -436,7 +511,8 @@ private static BakedOre bakeOre(IBlockState output, IBlockState deepOutput, int
pattern, heightDistribution, discardChanceOnAirExposure,
spread, verticalSpread, nodeSize,
hostBlocks, hostStates, familyMask, geomeWeights, includedBiomeIds, excludedBiomeIds,
- includedDictionaryBiomes, excludedDictionaryBiomes, retrogen);
+ includedDictionaryBiomes, excludedDictionaryBiomes, retrogen,
+ backgroundController, backgroundGenerationScale);
}
static boolean hasHostTargets(Map blocks,
@@ -650,6 +726,8 @@ private static final class BakedOre {
final Set includedDictionaryBiomes;
final Set excludedDictionaryBiomes;
final boolean retrogen;
+ final boolean backgroundController;
+ final double backgroundGenerationScale;
BakedOre(IBlockState output, IBlockState deepOutput, int deepOutputMaxY, BakedOutput[] outputs,
int minY, int maxY, double frequency, int minQuantity, int maxQuantity,
@@ -660,7 +738,8 @@ private static final class BakedOre {
int familyMask, double[] geomeWeights,
Set includedBiomeIds, Set excludedBiomeIds,
Set includedDictionaryBiomes, Set excludedDictionaryBiomes,
- boolean retrogen) {
+ boolean retrogen, boolean backgroundController,
+ double backgroundGenerationScale) {
this.output = output;
this.deepOutput = deepOutput;
this.deepOutputMaxY = deepOutputMaxY;
@@ -685,6 +764,8 @@ private static final class BakedOre {
this.includedDictionaryBiomes = includedDictionaryBiomes;
this.excludedDictionaryBiomes = excludedDictionaryBiomes;
this.retrogen = retrogen;
+ this.backgroundController = backgroundController;
+ this.backgroundGenerationScale = backgroundGenerationScale;
}
IBlockState outputAt(int y, Random random) {
@@ -754,20 +835,26 @@ boolean acceptsY(int y) {
private static final class BakedOres {
static final BakedOres EMPTY = new BakedOres(EMPTY_DIMENSIONS, Collections.emptyMap(),
- NO_ORES, Collections.emptySet());
+ Collections.emptyMap(), NO_ORES, Collections.emptySet(), Collections.emptyMap());
final Map byDimension;
final Map> vanillaOutputs;
+ final Map> backgroundGenerationScales;
final BakedOre[] selectorOres;
final Set selectorVanillaOutputs;
+ final Map selectorBackgroundGenerationScales;
BakedOres(Map byDimension,
- Map> vanillaOutputs, BakedOre[] selectorOres,
- Set selectorVanillaOutputs) {
+ Map> vanillaOutputs,
+ Map> backgroundGenerationScales,
+ BakedOre[] selectorOres, Set selectorVanillaOutputs,
+ Map selectorBackgroundGenerationScales) {
this.byDimension = byDimension;
this.vanillaOutputs = vanillaOutputs;
+ this.backgroundGenerationScales = backgroundGenerationScales;
this.selectorOres = selectorOres;
this.selectorVanillaOutputs = selectorVanillaOutputs;
+ this.selectorBackgroundGenerationScales = selectorBackgroundGenerationScales;
}
}
@@ -775,6 +862,10 @@ private static final class GenerationScratch {
final BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos();
final PatternContext patternContext = new PatternContext(cursor);
private double[] geomeValues = new double[0];
+ private WorldServer samplerLevel;
+ private ResourceLocation samplerDimension;
+ private BakedGeomeConfig samplerConfig;
+ private Optional geologySampler = Optional.empty();
double[] geomeValues(int count) {
if (geomeValues.length != count) {
@@ -782,9 +873,22 @@ private static final class GenerationScratch {
}
return geomeValues;
}
+
+ Optional geologySampler(WorldServer level, ResourceLocation dimension) {
+ BakedGeomeConfig activeConfig = WorldIds.OVERWORLD.equals(dimension)
+ ? GeomeConfig.baked() : GeomeConfig.baked(dimension);
+ if (level != samplerLevel || !dimension.equals(samplerDimension)
+ || activeConfig != samplerConfig) {
+ samplerLevel = level;
+ samplerDimension = dimension;
+ samplerConfig = activeConfig;
+ geologySampler = level == null ? Optional.empty() : OreSpawnApi.createSampler(level);
+ }
+ return geologySampler;
+ }
}
- private static final class PatternContext implements OrePlacementContext {
+ private static final class PatternContext implements OreGenerationContext {
private final BlockPos.MutableBlockPos cursor;
private final BlockPos.MutableBlockPos airCursor = new BlockPos.MutableBlockPos();
private World world;
@@ -792,6 +896,11 @@ private static final class PatternContext implements OrePlacementContext {
private Random random;
private BakedOre ore;
private int geome;
+ private ResourceLocation dimension;
+ private long worldSeed;
+ private int chunkX;
+ private int chunkZ;
+ private Optional geologySampler = Optional.empty();
private int originX;
private int originY;
private int originZ;
@@ -804,12 +913,20 @@ private static final class PatternContext implements OrePlacementContext {
}
void initialize(World world, Chunk chunk, Random random, BakedOre ore, int geome,
+ ResourceLocation dimension, long worldSeed,
+ Optional geologySampler,
int originX, int originY, int originZ, int minY, int maxY, int quantity) {
this.world = world;
this.chunk = chunk;
this.random = random;
this.ore = ore;
this.geome = geome;
+ this.dimension = dimension;
+ this.worldSeed = worldSeed;
+ ChunkPos chunkPos = chunk.getPos();
+ this.chunkX = chunkPos.x;
+ this.chunkZ = chunkPos.z;
+ this.geologySampler = geologySampler;
this.originX = originX;
this.originY = originY;
this.originZ = originZ;
@@ -828,6 +945,11 @@ void initialize(World world, Chunk chunk, Random random, BakedOre ore, int geome
@Override public int spread() { return ore.spread; }
@Override public int verticalSpread() { return ore.verticalSpread; }
@Override public int nodeSize() { return ore.nodeSize; }
+ @Override public long worldSeed() { return worldSeed; }
+ @Override public ResourceLocation dimension() { return dimension; }
+ @Override public int chunkX() { return chunkX; }
+ @Override public int chunkZ() { return chunkZ; }
+ @Override public Optional geologySampler() { return geologySampler; }
@Override
public boolean inside(int x, int y, int z) {
diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnWorldGenerator.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnWorldGenerator.java
index 8f073795..a02f7ff4 100644
--- a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnWorldGenerator.java
+++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnWorldGenerator.java
@@ -65,8 +65,12 @@ public void filterVanillaOre(OreGenEvent.GenerateMinable event) {
return;
}
Block output = vanillaOutput(event.getType());
- if (output != null && OreSpawnOreGeneration.takesOverVanillaOre(dimension, output)) {
- event.setResult(Event.Result.DENY);
+ if (output != null) {
+ ChunkPos chunk = new ChunkPos(event.getPos());
+ if (!OreSpawnOreGeneration.allowsVanillaOre(dimension, output,
+ event.getWorld().getSeed(), chunk.x, chunk.z)) {
+ event.setResult(Event.Result.DENY);
+ }
}
}
diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileManager.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileManager.java
index 5a40998c..dee38dac 100644
--- a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileManager.java
+++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileManager.java
@@ -18,7 +18,6 @@
import com.google.gson.JsonSyntaxException;
import zone.moddev.mc.orespawn.OreSpawnConfig;
import zone.moddev.mc.orespawn.OreSpawnConfig.GeologyMode;
-import zone.moddev.mc.orespawn.api.OreSpawnOreIntegration;
import zone.moddev.mc.orespawn.integration.WorldgenIntegrationManager;
import net.minecraft.server.MinecraftServer;
@@ -105,7 +104,7 @@ public static synchronized boolean reloadActiveProfile() {
.resolve("serverconfig").resolve(PROFILE_FILE_NAME);
WorldGeologyProfile profile = readProfile(profilePath, globalProfile());
JsonObject merged = profile.rootCopy();
- if (OreSpawnOreIntegration.mergeProviderOres(merged)) {
+ if (WorldgenIntegrationManager.mergeProviderDefinitionsIntoExistingWorld(merged)) {
profile = profile.withRoot(merged);
writeProfile(profilePath, profile);
}
@@ -127,7 +126,7 @@ public static void onServerAboutToStart(FMLServerAboutToStartEvent event) {
profile = readProfile(profilePath, fallback);
JsonObject merged = profile.rootCopy();
String beforeMerge = merged.toString();
- OreSpawnOreIntegration.mergeProviderOres(merged);
+ WorldgenIntegrationManager.mergeProviderDefinitionsIntoExistingWorld(merged);
if (!beforeMerge.equals(merged.toString())) {
profile = profile.withRoot(merged);
writeProfile(profilePath, profile);
diff --git a/src/main/resources/assets/orespawn/lang/de_au.lang b/src/main/resources/assets/orespawn/lang/de_au.lang
index f97d6a1f..186f27f7 100644
--- a/src/main/resources/assets/orespawn/lang/de_au.lang
+++ b/src/main/resources/assets/orespawn/lang/de_au.lang
@@ -355,3 +355,26 @@ guide.orespawn.materials.title=Weltmaterialien
guide.orespawn.materials.1=Weltmaterialien ändern Hintergrundmaterialien in einer ganzen Dimension. Verwende Flüssigkeitsvorkommen, wenn du nur gelegentliche unterirdische Taschen möchtest.
guide.orespawn.materials.2=Aquiferflüssigkeit ändert die normale Flüssigkeit unter dem Meeresspiegel. Tiefer Aquifer verwendet unterhalb der gewählten Y-Höhe eine zweite Flüssigkeit.
guide.orespawn.materials.3=Schnee und Eis ersetzen Vanilla-Schnee und gewöhnliches Eis nahe der Oberfläche. Nicht gesetzt lässt das normale Minecraft-Material unverändert.
+button.orespawn.mods=Mod-Verzeichnis
+button.orespawn.mod.configure=Konfigurieren
+tooltip.orespawn.main.mods=Geladene Mods anzeigen, die in OreSpawn integriert sind.
+screen.orespawn.mods=OreSpawn-Mods
+tooltip.orespawn.mod.configure=Konfiguration von %s öffnen.
+tooltip.orespawn.mod.unavailable=%s stellt keinen mit OreSpawn verknüpften Konfigurationsbildschirm bereit.
+label.orespawn.mod.id_version=%s - Mod-Version %s
+label.orespawn.mod.additional_lineage=Auch über OreSpawn-%s-Kompatibilität erkannt.
+label.orespawn.mod.status=Zustand: %s
+label.orespawn.mod.os4_version=Nativer OS4-Anbieter - Schema %s, Anbieterrevision %s
+label.orespawn.mod.os4=Nativer OS4-Anbieter
+label.orespawn.mod.legacy_version=OreSpawn-%s-Kompatibilität -> OS4-Schema %s, Anbieterrevision %s
+label.orespawn.mod.legacy=OreSpawn-%s-Kompatibilität -> OS4
+label.orespawn.mod.config_only=Client-Konfigurationsintegration
+label.orespawn.mod.none=Keine geladenen OreSpawn-Integrationen gefunden.
+label.orespawn.mod.integration_status=%s | %s
+status.orespawn.mod.active=Aktiv
+status.orespawn.mod.pending=Ausstehend
+status.orespawn.mod.inactive=Inaktiv
+status.orespawn.mod.rejected=Abgelehnt
+status.orespawn.mod.config_only=Nur Konfigurationsbildschirm
+error.orespawn.mod.screen_missing=%s hat keinen Konfigurationsbildschirm erstellt.
+error.orespawn.mod.screen_failed=Die Konfiguration von %s konnte nicht geöffnet werden (%s).
diff --git a/src/main/resources/assets/orespawn/lang/de_de.lang b/src/main/resources/assets/orespawn/lang/de_de.lang
index f97d6a1f..186f27f7 100644
--- a/src/main/resources/assets/orespawn/lang/de_de.lang
+++ b/src/main/resources/assets/orespawn/lang/de_de.lang
@@ -355,3 +355,26 @@ guide.orespawn.materials.title=Weltmaterialien
guide.orespawn.materials.1=Weltmaterialien ändern Hintergrundmaterialien in einer ganzen Dimension. Verwende Flüssigkeitsvorkommen, wenn du nur gelegentliche unterirdische Taschen möchtest.
guide.orespawn.materials.2=Aquiferflüssigkeit ändert die normale Flüssigkeit unter dem Meeresspiegel. Tiefer Aquifer verwendet unterhalb der gewählten Y-Höhe eine zweite Flüssigkeit.
guide.orespawn.materials.3=Schnee und Eis ersetzen Vanilla-Schnee und gewöhnliches Eis nahe der Oberfläche. Nicht gesetzt lässt das normale Minecraft-Material unverändert.
+button.orespawn.mods=Mod-Verzeichnis
+button.orespawn.mod.configure=Konfigurieren
+tooltip.orespawn.main.mods=Geladene Mods anzeigen, die in OreSpawn integriert sind.
+screen.orespawn.mods=OreSpawn-Mods
+tooltip.orespawn.mod.configure=Konfiguration von %s öffnen.
+tooltip.orespawn.mod.unavailable=%s stellt keinen mit OreSpawn verknüpften Konfigurationsbildschirm bereit.
+label.orespawn.mod.id_version=%s - Mod-Version %s
+label.orespawn.mod.additional_lineage=Auch über OreSpawn-%s-Kompatibilität erkannt.
+label.orespawn.mod.status=Zustand: %s
+label.orespawn.mod.os4_version=Nativer OS4-Anbieter - Schema %s, Anbieterrevision %s
+label.orespawn.mod.os4=Nativer OS4-Anbieter
+label.orespawn.mod.legacy_version=OreSpawn-%s-Kompatibilität -> OS4-Schema %s, Anbieterrevision %s
+label.orespawn.mod.legacy=OreSpawn-%s-Kompatibilität -> OS4
+label.orespawn.mod.config_only=Client-Konfigurationsintegration
+label.orespawn.mod.none=Keine geladenen OreSpawn-Integrationen gefunden.
+label.orespawn.mod.integration_status=%s | %s
+status.orespawn.mod.active=Aktiv
+status.orespawn.mod.pending=Ausstehend
+status.orespawn.mod.inactive=Inaktiv
+status.orespawn.mod.rejected=Abgelehnt
+status.orespawn.mod.config_only=Nur Konfigurationsbildschirm
+error.orespawn.mod.screen_missing=%s hat keinen Konfigurationsbildschirm erstellt.
+error.orespawn.mod.screen_failed=Die Konfiguration von %s konnte nicht geöffnet werden (%s).
diff --git a/src/main/resources/assets/orespawn/lang/en_ca.lang b/src/main/resources/assets/orespawn/lang/en_ca.lang
index a49564e4..747c6806 100644
--- a/src/main/resources/assets/orespawn/lang/en_ca.lang
+++ b/src/main/resources/assets/orespawn/lang/en_ca.lang
@@ -355,3 +355,26 @@ guide.orespawn.materials.title=World Materials
guide.orespawn.materials.1=World Materials changes background materials across a whole dimension. Use Fluid Deposits instead when you only want occasional underground pockets.
guide.orespawn.materials.2=Aquifer Fluid changes the normal fluid used below sea level. Deep Aquifer uses a second fluid below the chosen Y level.
guide.orespawn.materials.3=Snow and Ice replace vanilla snow and ordinary ice near the surface. Not set leaves Minecraft's normal material unchanged.
+button.orespawn.mods=Mods
+button.orespawn.mod.configure=Configure
+tooltip.orespawn.main.mods=View loaded mods integrated with OreSpawn.
+screen.orespawn.mods=OreSpawn Mods
+tooltip.orespawn.mod.configure=Open %s configuration.
+tooltip.orespawn.mod.unavailable=%s does not provide an OreSpawn-linked configuration screen.
+label.orespawn.mod.id_version=%s - Mod version %s
+label.orespawn.mod.additional_lineage=Also detected through OreSpawn %s compatibility.
+label.orespawn.mod.status=Status: %s
+label.orespawn.mod.os4_version=Native OS4 - schema %s, provider revision %s
+label.orespawn.mod.os4=Native OS4 provider
+label.orespawn.mod.legacy_version=OreSpawn %s compatibility -> OS4 schema %s, provider revision %s
+label.orespawn.mod.legacy=OreSpawn %s compatibility -> OS4
+label.orespawn.mod.config_only=Client configuration integration
+label.orespawn.mod.none=No loaded OreSpawn integrations were found.
+label.orespawn.mod.integration_status=%s - %s
+status.orespawn.mod.active=Active
+status.orespawn.mod.pending=Pending
+status.orespawn.mod.inactive=Inactive
+status.orespawn.mod.rejected=Rejected
+status.orespawn.mod.config_only=Configuration screen only
+error.orespawn.mod.screen_missing=%s did not create a configuration screen.
+error.orespawn.mod.screen_failed=Could not open %s configuration (%s).
diff --git a/src/main/resources/assets/orespawn/lang/en_en.lang b/src/main/resources/assets/orespawn/lang/en_en.lang
index a49564e4..747c6806 100644
--- a/src/main/resources/assets/orespawn/lang/en_en.lang
+++ b/src/main/resources/assets/orespawn/lang/en_en.lang
@@ -355,3 +355,26 @@ guide.orespawn.materials.title=World Materials
guide.orespawn.materials.1=World Materials changes background materials across a whole dimension. Use Fluid Deposits instead when you only want occasional underground pockets.
guide.orespawn.materials.2=Aquifer Fluid changes the normal fluid used below sea level. Deep Aquifer uses a second fluid below the chosen Y level.
guide.orespawn.materials.3=Snow and Ice replace vanilla snow and ordinary ice near the surface. Not set leaves Minecraft's normal material unchanged.
+button.orespawn.mods=Mods
+button.orespawn.mod.configure=Configure
+tooltip.orespawn.main.mods=View loaded mods integrated with OreSpawn.
+screen.orespawn.mods=OreSpawn Mods
+tooltip.orespawn.mod.configure=Open %s configuration.
+tooltip.orespawn.mod.unavailable=%s does not provide an OreSpawn-linked configuration screen.
+label.orespawn.mod.id_version=%s - Mod version %s
+label.orespawn.mod.additional_lineage=Also detected through OreSpawn %s compatibility.
+label.orespawn.mod.status=Status: %s
+label.orespawn.mod.os4_version=Native OS4 - schema %s, provider revision %s
+label.orespawn.mod.os4=Native OS4 provider
+label.orespawn.mod.legacy_version=OreSpawn %s compatibility -> OS4 schema %s, provider revision %s
+label.orespawn.mod.legacy=OreSpawn %s compatibility -> OS4
+label.orespawn.mod.config_only=Client configuration integration
+label.orespawn.mod.none=No loaded OreSpawn integrations were found.
+label.orespawn.mod.integration_status=%s - %s
+status.orespawn.mod.active=Active
+status.orespawn.mod.pending=Pending
+status.orespawn.mod.inactive=Inactive
+status.orespawn.mod.rejected=Rejected
+status.orespawn.mod.config_only=Configuration screen only
+error.orespawn.mod.screen_missing=%s did not create a configuration screen.
+error.orespawn.mod.screen_failed=Could not open %s configuration (%s).
diff --git a/src/main/resources/assets/orespawn/lang/en_gb.lang b/src/main/resources/assets/orespawn/lang/en_gb.lang
index a49564e4..747c6806 100644
--- a/src/main/resources/assets/orespawn/lang/en_gb.lang
+++ b/src/main/resources/assets/orespawn/lang/en_gb.lang
@@ -355,3 +355,26 @@ guide.orespawn.materials.title=World Materials
guide.orespawn.materials.1=World Materials changes background materials across a whole dimension. Use Fluid Deposits instead when you only want occasional underground pockets.
guide.orespawn.materials.2=Aquifer Fluid changes the normal fluid used below sea level. Deep Aquifer uses a second fluid below the chosen Y level.
guide.orespawn.materials.3=Snow and Ice replace vanilla snow and ordinary ice near the surface. Not set leaves Minecraft's normal material unchanged.
+button.orespawn.mods=Mods
+button.orespawn.mod.configure=Configure
+tooltip.orespawn.main.mods=View loaded mods integrated with OreSpawn.
+screen.orespawn.mods=OreSpawn Mods
+tooltip.orespawn.mod.configure=Open %s configuration.
+tooltip.orespawn.mod.unavailable=%s does not provide an OreSpawn-linked configuration screen.
+label.orespawn.mod.id_version=%s - Mod version %s
+label.orespawn.mod.additional_lineage=Also detected through OreSpawn %s compatibility.
+label.orespawn.mod.status=Status: %s
+label.orespawn.mod.os4_version=Native OS4 - schema %s, provider revision %s
+label.orespawn.mod.os4=Native OS4 provider
+label.orespawn.mod.legacy_version=OreSpawn %s compatibility -> OS4 schema %s, provider revision %s
+label.orespawn.mod.legacy=OreSpawn %s compatibility -> OS4
+label.orespawn.mod.config_only=Client configuration integration
+label.orespawn.mod.none=No loaded OreSpawn integrations were found.
+label.orespawn.mod.integration_status=%s - %s
+status.orespawn.mod.active=Active
+status.orespawn.mod.pending=Pending
+status.orespawn.mod.inactive=Inactive
+status.orespawn.mod.rejected=Rejected
+status.orespawn.mod.config_only=Configuration screen only
+error.orespawn.mod.screen_missing=%s did not create a configuration screen.
+error.orespawn.mod.screen_failed=Could not open %s configuration (%s).
diff --git a/src/main/resources/assets/orespawn/lang/en_pt.lang b/src/main/resources/assets/orespawn/lang/en_pt.lang
index a49564e4..747c6806 100644
--- a/src/main/resources/assets/orespawn/lang/en_pt.lang
+++ b/src/main/resources/assets/orespawn/lang/en_pt.lang
@@ -355,3 +355,26 @@ guide.orespawn.materials.title=World Materials
guide.orespawn.materials.1=World Materials changes background materials across a whole dimension. Use Fluid Deposits instead when you only want occasional underground pockets.
guide.orespawn.materials.2=Aquifer Fluid changes the normal fluid used below sea level. Deep Aquifer uses a second fluid below the chosen Y level.
guide.orespawn.materials.3=Snow and Ice replace vanilla snow and ordinary ice near the surface. Not set leaves Minecraft's normal material unchanged.
+button.orespawn.mods=Mods
+button.orespawn.mod.configure=Configure
+tooltip.orespawn.main.mods=View loaded mods integrated with OreSpawn.
+screen.orespawn.mods=OreSpawn Mods
+tooltip.orespawn.mod.configure=Open %s configuration.
+tooltip.orespawn.mod.unavailable=%s does not provide an OreSpawn-linked configuration screen.
+label.orespawn.mod.id_version=%s - Mod version %s
+label.orespawn.mod.additional_lineage=Also detected through OreSpawn %s compatibility.
+label.orespawn.mod.status=Status: %s
+label.orespawn.mod.os4_version=Native OS4 - schema %s, provider revision %s
+label.orespawn.mod.os4=Native OS4 provider
+label.orespawn.mod.legacy_version=OreSpawn %s compatibility -> OS4 schema %s, provider revision %s
+label.orespawn.mod.legacy=OreSpawn %s compatibility -> OS4
+label.orespawn.mod.config_only=Client configuration integration
+label.orespawn.mod.none=No loaded OreSpawn integrations were found.
+label.orespawn.mod.integration_status=%s - %s
+status.orespawn.mod.active=Active
+status.orespawn.mod.pending=Pending
+status.orespawn.mod.inactive=Inactive
+status.orespawn.mod.rejected=Rejected
+status.orespawn.mod.config_only=Configuration screen only
+error.orespawn.mod.screen_missing=%s did not create a configuration screen.
+error.orespawn.mod.screen_failed=Could not open %s configuration (%s).
diff --git a/src/main/resources/assets/orespawn/lang/es_es.lang b/src/main/resources/assets/orespawn/lang/es_es.lang
index 31019987..bbda7584 100644
--- a/src/main/resources/assets/orespawn/lang/es_es.lang
+++ b/src/main/resources/assets/orespawn/lang/es_es.lang
@@ -355,3 +355,26 @@ guide.orespawn.materials.title=Materiales del mundo
guide.orespawn.materials.1=Materiales del mundo cambia materiales de fondo en toda una dimensión. Usa Depósitos de fluidos si solo quieres bolsas subterráneas ocasionales.
guide.orespawn.materials.2=Fluido de acuífero cambia el fluido normal bajo el nivel del mar. Acuífero profundo usa un segundo fluido por debajo del nivel Y elegido.
guide.orespawn.materials.3=Nieve y Hielo sustituyen la nieve vanilla y el hielo normal cerca de la superficie. Sin configurar conserva el material normal de Minecraft.
+button.orespawn.mods=Directorio de mods
+button.orespawn.mod.configure=Configurar
+tooltip.orespawn.main.mods=Ver los mods cargados integrados con OreSpawn.
+screen.orespawn.mods=Mods de OreSpawn
+tooltip.orespawn.mod.configure=Abrir la configuración de %s.
+tooltip.orespawn.mod.unavailable=%s no proporciona una pantalla de configuración vinculada con OreSpawn.
+label.orespawn.mod.id_version=%s - Versión del mod %s
+label.orespawn.mod.additional_lineage=También detectado mediante compatibilidad con OreSpawn %s.
+label.orespawn.mod.status=Estado: %s
+label.orespawn.mod.os4_version=Proveedor OS4 nativo - esquema %s, revisión del proveedor %s
+label.orespawn.mod.os4=Proveedor OS4 nativo
+label.orespawn.mod.legacy_version=Compatibilidad con OreSpawn %s -> esquema OS4 %s, revisión del proveedor %s
+label.orespawn.mod.legacy=Compatibilidad con OreSpawn %s -> OS4
+label.orespawn.mod.config_only=Integración de configuración del cliente
+label.orespawn.mod.none=No se encontraron integraciones de OreSpawn cargadas.
+label.orespawn.mod.integration_status=%s | %s
+status.orespawn.mod.active=Activo
+status.orespawn.mod.pending=Pendiente
+status.orespawn.mod.inactive=Inactivo
+status.orespawn.mod.rejected=Rechazado
+status.orespawn.mod.config_only=Solo pantalla de configuración
+error.orespawn.mod.screen_missing=%s no creó una pantalla de configuración.
+error.orespawn.mod.screen_failed=No se pudo abrir la configuración de %s (%s).
diff --git a/src/main/resources/assets/orespawn/lang/es_mx.lang b/src/main/resources/assets/orespawn/lang/es_mx.lang
index 31019987..bbda7584 100644
--- a/src/main/resources/assets/orespawn/lang/es_mx.lang
+++ b/src/main/resources/assets/orespawn/lang/es_mx.lang
@@ -355,3 +355,26 @@ guide.orespawn.materials.title=Materiales del mundo
guide.orespawn.materials.1=Materiales del mundo cambia materiales de fondo en toda una dimensión. Usa Depósitos de fluidos si solo quieres bolsas subterráneas ocasionales.
guide.orespawn.materials.2=Fluido de acuífero cambia el fluido normal bajo el nivel del mar. Acuífero profundo usa un segundo fluido por debajo del nivel Y elegido.
guide.orespawn.materials.3=Nieve y Hielo sustituyen la nieve vanilla y el hielo normal cerca de la superficie. Sin configurar conserva el material normal de Minecraft.
+button.orespawn.mods=Directorio de mods
+button.orespawn.mod.configure=Configurar
+tooltip.orespawn.main.mods=Ver los mods cargados integrados con OreSpawn.
+screen.orespawn.mods=Mods de OreSpawn
+tooltip.orespawn.mod.configure=Abrir la configuración de %s.
+tooltip.orespawn.mod.unavailable=%s no proporciona una pantalla de configuración vinculada con OreSpawn.
+label.orespawn.mod.id_version=%s - Versión del mod %s
+label.orespawn.mod.additional_lineage=También detectado mediante compatibilidad con OreSpawn %s.
+label.orespawn.mod.status=Estado: %s
+label.orespawn.mod.os4_version=Proveedor OS4 nativo - esquema %s, revisión del proveedor %s
+label.orespawn.mod.os4=Proveedor OS4 nativo
+label.orespawn.mod.legacy_version=Compatibilidad con OreSpawn %s -> esquema OS4 %s, revisión del proveedor %s
+label.orespawn.mod.legacy=Compatibilidad con OreSpawn %s -> OS4
+label.orespawn.mod.config_only=Integración de configuración del cliente
+label.orespawn.mod.none=No se encontraron integraciones de OreSpawn cargadas.
+label.orespawn.mod.integration_status=%s | %s
+status.orespawn.mod.active=Activo
+status.orespawn.mod.pending=Pendiente
+status.orespawn.mod.inactive=Inactivo
+status.orespawn.mod.rejected=Rechazado
+status.orespawn.mod.config_only=Solo pantalla de configuración
+error.orespawn.mod.screen_missing=%s no creó una pantalla de configuración.
+error.orespawn.mod.screen_failed=No se pudo abrir la configuración de %s (%s).
diff --git a/src/main/resources/assets/orespawn/lang/fr_ca.lang b/src/main/resources/assets/orespawn/lang/fr_ca.lang
index 4274c6ef..5d8fa4b5 100644
--- a/src/main/resources/assets/orespawn/lang/fr_ca.lang
+++ b/src/main/resources/assets/orespawn/lang/fr_ca.lang
@@ -355,3 +355,26 @@ guide.orespawn.materials.title=Matériaux du monde
guide.orespawn.materials.1=Matériaux du monde change les matériaux de fond dans toute une dimension. Utilisez Gisements de fluides pour de simples poches souterraines occasionnelles.
guide.orespawn.materials.2=Fluide d'aquifère change le fluide normal sous le niveau de la mer. Aquifère profond utilise un second fluide sous le niveau Y choisi.
guide.orespawn.materials.3=Neige et Glace remplacent la neige vanilla et la glace ordinaire près de la surface. Non défini conserve le matériau normal de Minecraft.
+button.orespawn.mods=Répertoire des mods
+button.orespawn.mod.configure=Configurer
+tooltip.orespawn.main.mods=Afficher les mods chargés intégrés à OreSpawn.
+screen.orespawn.mods=Mods OreSpawn
+tooltip.orespawn.mod.configure=Ouvrir la configuration de %s.
+tooltip.orespawn.mod.unavailable=%s ne fournit pas d'écran de configuration lié à OreSpawn.
+label.orespawn.mod.id_version=%s - Version du mod %s
+label.orespawn.mod.additional_lineage=Également détecté via la compatibilité OreSpawn %s.
+label.orespawn.mod.status=État : %s
+label.orespawn.mod.os4_version=Fournisseur OS4 natif - schéma %s, révision du fournisseur %s
+label.orespawn.mod.os4=Fournisseur OS4 natif
+label.orespawn.mod.legacy_version=Compatibilité OreSpawn %s -> schéma OS4 %s, révision du fournisseur %s
+label.orespawn.mod.legacy=Compatibilité OreSpawn %s -> OS4
+label.orespawn.mod.config_only=Intégration de configuration client
+label.orespawn.mod.none=Aucune intégration OreSpawn chargée n'a été trouvée.
+label.orespawn.mod.integration_status=%s | %s
+status.orespawn.mod.active=Actif
+status.orespawn.mod.pending=En attente
+status.orespawn.mod.inactive=Inactif
+status.orespawn.mod.rejected=Rejeté
+status.orespawn.mod.config_only=Écran de configuration uniquement
+error.orespawn.mod.screen_missing=%s n'a pas créé d'écran de configuration.
+error.orespawn.mod.screen_failed=Impossible d'ouvrir la configuration de %s (%s).
diff --git a/src/main/resources/assets/orespawn/lang/fr_fr.lang b/src/main/resources/assets/orespawn/lang/fr_fr.lang
index 4274c6ef..5d8fa4b5 100644
--- a/src/main/resources/assets/orespawn/lang/fr_fr.lang
+++ b/src/main/resources/assets/orespawn/lang/fr_fr.lang
@@ -355,3 +355,26 @@ guide.orespawn.materials.title=Matériaux du monde
guide.orespawn.materials.1=Matériaux du monde change les matériaux de fond dans toute une dimension. Utilisez Gisements de fluides pour de simples poches souterraines occasionnelles.
guide.orespawn.materials.2=Fluide d'aquifère change le fluide normal sous le niveau de la mer. Aquifère profond utilise un second fluide sous le niveau Y choisi.
guide.orespawn.materials.3=Neige et Glace remplacent la neige vanilla et la glace ordinaire près de la surface. Non défini conserve le matériau normal de Minecraft.
+button.orespawn.mods=Répertoire des mods
+button.orespawn.mod.configure=Configurer
+tooltip.orespawn.main.mods=Afficher les mods chargés intégrés à OreSpawn.
+screen.orespawn.mods=Mods OreSpawn
+tooltip.orespawn.mod.configure=Ouvrir la configuration de %s.
+tooltip.orespawn.mod.unavailable=%s ne fournit pas d'écran de configuration lié à OreSpawn.
+label.orespawn.mod.id_version=%s - Version du mod %s
+label.orespawn.mod.additional_lineage=Également détecté via la compatibilité OreSpawn %s.
+label.orespawn.mod.status=État : %s
+label.orespawn.mod.os4_version=Fournisseur OS4 natif - schéma %s, révision du fournisseur %s
+label.orespawn.mod.os4=Fournisseur OS4 natif
+label.orespawn.mod.legacy_version=Compatibilité OreSpawn %s -> schéma OS4 %s, révision du fournisseur %s
+label.orespawn.mod.legacy=Compatibilité OreSpawn %s -> OS4
+label.orespawn.mod.config_only=Intégration de configuration client
+label.orespawn.mod.none=Aucune intégration OreSpawn chargée n'a été trouvée.
+label.orespawn.mod.integration_status=%s | %s
+status.orespawn.mod.active=Actif
+status.orespawn.mod.pending=En attente
+status.orespawn.mod.inactive=Inactif
+status.orespawn.mod.rejected=Rejeté
+status.orespawn.mod.config_only=Écran de configuration uniquement
+error.orespawn.mod.screen_missing=%s n'a pas créé d'écran de configuration.
+error.orespawn.mod.screen_failed=Impossible d'ouvrir la configuration de %s (%s).
diff --git a/src/main/resources/assets/orespawn/lang/ja_jp.lang b/src/main/resources/assets/orespawn/lang/ja_jp.lang
index 244b73ed..1900748e 100644
--- a/src/main/resources/assets/orespawn/lang/ja_jp.lang
+++ b/src/main/resources/assets/orespawn/lang/ja_jp.lang
@@ -355,3 +355,26 @@ guide.orespawn.materials.title=ワールド素材
guide.orespawn.materials.1=ワールド素材はディメンション全体の基本素材を変更します。ときどき現れる地下ポケットだけが欲しい場合は流体鉱床を使います。
guide.orespawn.materials.2=帯水層の流体は海面より下で使う通常の流体を変更します。深部帯水層は選んだYより下で2種類目の流体を使います。
guide.orespawn.materials.3=雪と氷は地表付近のバニラの雪と通常の氷を置き換えます。「未設定」ならMinecraft標準の素材を変えません。
+button.orespawn.mods=MOD一覧
+button.orespawn.mod.configure=設定
+tooltip.orespawn.main.mods=OreSpawnと連携している読み込み済みMODを表示します。
+screen.orespawn.mods=OreSpawn MOD
+tooltip.orespawn.mod.configure=%sの設定を開きます。
+tooltip.orespawn.mod.unavailable=%sにはOreSpawn連携設定画面がありません。
+label.orespawn.mod.id_version=%s - MODバージョン %s
+label.orespawn.mod.additional_lineage=OreSpawn %s互換としても検出されました。
+label.orespawn.mod.status=状態:%s
+label.orespawn.mod.os4_version=ネイティブOS4 - スキーマ%s、プロバイダーリビジョン%s
+label.orespawn.mod.os4=ネイティブOS4プロバイダー
+label.orespawn.mod.legacy_version=OreSpawn %s互換 -> OS4スキーマ%s、プロバイダーリビジョン%s
+label.orespawn.mod.legacy=OreSpawn %s互換 -> OS4
+label.orespawn.mod.config_only=クライアント設定連携
+label.orespawn.mod.none=読み込み済みのOreSpawn連携は見つかりませんでした。
+label.orespawn.mod.integration_status=%s|%s
+status.orespawn.mod.active=有効
+status.orespawn.mod.pending=保留中
+status.orespawn.mod.inactive=無効
+status.orespawn.mod.rejected=拒否
+status.orespawn.mod.config_only=設定画面のみ
+error.orespawn.mod.screen_missing=%sは設定画面を作成しませんでした。
+error.orespawn.mod.screen_failed=%sの設定を開けませんでした(%s)。
diff --git a/src/main/resources/assets/orespawn/lang/ko_kr.lang b/src/main/resources/assets/orespawn/lang/ko_kr.lang
index 16dda8bd..828c4fab 100644
--- a/src/main/resources/assets/orespawn/lang/ko_kr.lang
+++ b/src/main/resources/assets/orespawn/lang/ko_kr.lang
@@ -355,3 +355,26 @@ guide.orespawn.materials.title=월드 재료
guide.orespawn.materials.1=월드 재료는 차원 전체의 기본 재료를 바꿉니다. 가끔 나타나는 지하 주머니만 원한다면 유체 광상을 사용하세요.
guide.orespawn.materials.2=대수층 유체는 해수면 아래에서 쓰는 기본 유체를 바꿉니다. 깊은 대수층은 선택한 Y 아래에서 두 번째 유체를 사용합니다.
guide.orespawn.materials.3=눈과 얼음은 지표 근처의 바닐라 눈과 일반 얼음을 바꿉니다. 설정 안 함은 Minecraft 기본 재료를 그대로 둡니다.
+button.orespawn.mods=모드 목록
+button.orespawn.mod.configure=설정
+tooltip.orespawn.main.mods=OreSpawn과 연동된 불러온 모드를 표시합니다.
+screen.orespawn.mods=OreSpawn 모드
+tooltip.orespawn.mod.configure=%s 설정을 엽니다.
+tooltip.orespawn.mod.unavailable=%s 모드는 OreSpawn 연동 설정 화면을 제공하지 않습니다.
+label.orespawn.mod.id_version=%s - 모드 버전 %s
+label.orespawn.mod.additional_lineage=OreSpawn %s 호환으로도 감지되었습니다.
+label.orespawn.mod.status=상태: %s
+label.orespawn.mod.os4_version=기본 OS4 공급자 - 스키마 %s, 공급자 리비전 %s
+label.orespawn.mod.os4=기본 OS4 공급자
+label.orespawn.mod.legacy_version=OreSpawn %s 호환 -> OS4 스키마 %s, 공급자 리비전 %s
+label.orespawn.mod.legacy=OreSpawn %s 호환 -> OS4
+label.orespawn.mod.config_only=클라이언트 설정 연동
+label.orespawn.mod.none=불러온 OreSpawn 연동 모드를 찾지 못했습니다.
+label.orespawn.mod.integration_status=%s | %s
+status.orespawn.mod.active=활성
+status.orespawn.mod.pending=대기 중
+status.orespawn.mod.inactive=비활성
+status.orespawn.mod.rejected=거부됨
+status.orespawn.mod.config_only=설정 화면만 제공
+error.orespawn.mod.screen_missing=%s 모드가 설정 화면을 만들지 않았습니다.
+error.orespawn.mod.screen_failed=%s 설정을 열 수 없습니다(%s).
diff --git a/src/main/resources/assets/orespawn/lang/pt_br.lang b/src/main/resources/assets/orespawn/lang/pt_br.lang
index 0d06b1a7..a748ada1 100644
--- a/src/main/resources/assets/orespawn/lang/pt_br.lang
+++ b/src/main/resources/assets/orespawn/lang/pt_br.lang
@@ -355,3 +355,26 @@ guide.orespawn.materials.title=Materiais do mundo
guide.orespawn.materials.1=Materiais do mundo altera materiais de fundo em uma dimensão inteira. Use Depósitos de fluidos quando quiser apenas bolsas subterrâneas ocasionais.
guide.orespawn.materials.2=Fluido do aquífero altera o fluido normal abaixo do nível do mar. Aquífero profundo usa um segundo fluido abaixo do nível Y escolhido.
guide.orespawn.materials.3=Neve e Gelo substituem a neve vanilla e o gelo comum perto da superfície. Não definido mantém o material normal do Minecraft.
+button.orespawn.mods=Diretório de mods
+button.orespawn.mod.configure=Configurar
+tooltip.orespawn.main.mods=Ver os mods carregados integrados ao OreSpawn.
+screen.orespawn.mods=Mods do OreSpawn
+tooltip.orespawn.mod.configure=Abrir a configuração de %s.
+tooltip.orespawn.mod.unavailable=%s não fornece uma tela de configuração vinculada ao OreSpawn.
+label.orespawn.mod.id_version=%s - Versão do mod %s
+label.orespawn.mod.additional_lineage=Também detectado pela compatibilidade com OreSpawn %s.
+label.orespawn.mod.status=Situação: %s
+label.orespawn.mod.os4_version=Provedor OS4 nativo - esquema %s, revisão do provedor %s
+label.orespawn.mod.os4=Provedor OS4 nativo
+label.orespawn.mod.legacy_version=Compatibilidade com OreSpawn %s -> esquema OS4 %s, revisão do provedor %s
+label.orespawn.mod.legacy=Compatibilidade com OreSpawn %s -> OS4
+label.orespawn.mod.config_only=Integração de configuração do cliente
+label.orespawn.mod.none=Nenhuma integração carregada do OreSpawn foi encontrada.
+label.orespawn.mod.integration_status=%s | %s
+status.orespawn.mod.active=Ativo
+status.orespawn.mod.pending=Pendente
+status.orespawn.mod.inactive=Inativo
+status.orespawn.mod.rejected=Rejeitado
+status.orespawn.mod.config_only=Apenas tela de configuração
+error.orespawn.mod.screen_missing=%s não criou uma tela de configuração.
+error.orespawn.mod.screen_failed=Não foi possível abrir a configuração de %s (%s).
diff --git a/src/main/resources/assets/orespawn/lang/ru_ru.lang b/src/main/resources/assets/orespawn/lang/ru_ru.lang
index b564fcf7..0cf74c12 100644
--- a/src/main/resources/assets/orespawn/lang/ru_ru.lang
+++ b/src/main/resources/assets/orespawn/lang/ru_ru.lang
@@ -355,3 +355,26 @@ guide.orespawn.materials.title=Материалы мира
guide.orespawn.materials.1=Материалы мира меняют фоновые материалы во всём измерении. Для редких подземных карманов используйте Месторождения жидкостей.
guide.orespawn.materials.2=Жидкость водоносного слоя меняет обычную жидкость ниже уровня моря. Глубокий слой использует вторую жидкость ниже выбранной Y.
guide.orespawn.materials.3=Снег и лёд заменяют ванильный снег и обычный лёд у поверхности. Не задано сохраняет обычный материал Minecraft.
+button.orespawn.mods=Каталог модов
+button.orespawn.mod.configure=Настроить
+tooltip.orespawn.main.mods=Показать загруженные моды, интегрированные с OreSpawn.
+screen.orespawn.mods=Моды OreSpawn
+tooltip.orespawn.mod.configure=Открыть настройки %s.
+tooltip.orespawn.mod.unavailable=%s не предоставляет экран настроек для OreSpawn.
+label.orespawn.mod.id_version=%s - Версия мода %s
+label.orespawn.mod.additional_lineage=Также обнаружена совместимость с OreSpawn %s.
+label.orespawn.mod.status=Состояние: %s
+label.orespawn.mod.os4_version=Встроенный поставщик OS4 - схема %s, ревизия поставщика %s
+label.orespawn.mod.os4=Встроенный поставщик OS4
+label.orespawn.mod.legacy_version=Совместимость с OreSpawn %s -> схема OS4 %s, ревизия поставщика %s
+label.orespawn.mod.legacy=Совместимость с OreSpawn %s -> OS4
+label.orespawn.mod.config_only=Интеграция клиентских настроек
+label.orespawn.mod.none=Загруженные интеграции OreSpawn не найдены.
+label.orespawn.mod.integration_status=%s | %s
+status.orespawn.mod.active=Активно
+status.orespawn.mod.pending=Ожидание
+status.orespawn.mod.inactive=Неактивно
+status.orespawn.mod.rejected=Отклонено
+status.orespawn.mod.config_only=Только экран настроек
+error.orespawn.mod.screen_missing=%s не создал экран настроек.
+error.orespawn.mod.screen_failed=Не удалось открыть настройки %s (%s).
diff --git a/src/main/resources/assets/orespawn/lang/zh_cn.lang b/src/main/resources/assets/orespawn/lang/zh_cn.lang
index 1c555f4d..7b0515e4 100644
--- a/src/main/resources/assets/orespawn/lang/zh_cn.lang
+++ b/src/main/resources/assets/orespawn/lang/zh_cn.lang
@@ -355,3 +355,26 @@ guide.orespawn.materials.title=世界材料
guide.orespawn.materials.1=世界材料会改变整个维度的基础材料。若只想要偶尔出现的地下囊体,请使用流体矿床。
guide.orespawn.materials.2=含水层流体会改变海平面以下使用的普通流体。深层含水层会在所选Y值以下使用第二种流体。
guide.orespawn.materials.3=雪和冰会替换地表附近的原版雪与普通冰。“未设置”会保留Minecraft的普通材料。
+button.orespawn.mods=模组目录
+button.orespawn.mod.configure=配置
+tooltip.orespawn.main.mods=查看已加载并与OreSpawn集成的模组。
+screen.orespawn.mods=OreSpawn模组
+tooltip.orespawn.mod.configure=打开%s的配置。
+tooltip.orespawn.mod.unavailable=%s未提供与OreSpawn关联的配置界面。
+label.orespawn.mod.id_version=%s - 模组版本%s
+label.orespawn.mod.additional_lineage=同时检测到OreSpawn %s兼容方式。
+label.orespawn.mod.status=状态:%s
+label.orespawn.mod.os4_version=原生OS4提供方 - 架构%s,提供方修订%s
+label.orespawn.mod.os4=原生OS4提供方
+label.orespawn.mod.legacy_version=OreSpawn %s兼容 -> OS4架构%s,提供方修订%s
+label.orespawn.mod.legacy=OreSpawn %s兼容 -> OS4
+label.orespawn.mod.config_only=客户端配置集成
+label.orespawn.mod.none=未找到已加载的OreSpawn集成。
+label.orespawn.mod.integration_status=%s|%s
+status.orespawn.mod.active=已启用
+status.orespawn.mod.pending=待处理
+status.orespawn.mod.inactive=未启用
+status.orespawn.mod.rejected=已拒绝
+status.orespawn.mod.config_only=仅配置界面
+error.orespawn.mod.screen_missing=%s未创建配置界面。
+error.orespawn.mod.screen_failed=无法打开%s的配置(%s)。
diff --git a/src/main/resources/assets/orespawn/textures/gui/cog.png b/src/main/resources/assets/orespawn/textures/gui/cog.png
new file mode 100644
index 0000000000000000000000000000000000000000..e056ab0e803a1aa2de3fcc38e232ecbc6dc0daa6
GIT binary patch
literal 321
zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`jKx9jP7LeL$-D$|SkfJR9T^xl
z_H+M9WCij$3p^r=85sBugD~Uq{1quc!E2r_jv*HQZ!b7qD>N_CNk{_+IteYxkprPI-#7&inN*a9*mq
zSEf$OiaQTCXqnBPAy5>wTTN=|l=YuwA|^SQ%s-s9lchy~ox!&^eoE@g_e=H|Pit8B
z+P&v*@+pZN*FtxWpIQHvxSaJBo=iJpn9y_n`st^8Jc1>5zB_Bs^ZwmIOZ`{zuQgwp
zE^}m{Naic|mq
literal 0
HcmV?d00001
diff --git a/src/test/java/zone/moddev/mc/orespawn/LocalizationParityTest.java b/src/test/java/zone/moddev/mc/orespawn/LocalizationParityTest.java
index 4da19d47..4c3dfac4 100644
--- a/src/test/java/zone/moddev/mc/orespawn/LocalizationParityTest.java
+++ b/src/test/java/zone/moddev/mc/orespawn/LocalizationParityTest.java
@@ -84,7 +84,7 @@ class LocalizationParityTest {
void everyLocaleMatchesEnglishKeysAndFormatting() throws Exception {
JsonObject english = read(LANG_DIR.resolve("en_us.lang"));
Set englishKeys = JsonCopies.keys(english);
- assertEquals(357, englishKeys.size(),
+ assertEquals(380, englishKeys.size(),
"The target locale contract changed; review every shipped translation");
Set localeFiles = new HashSet<>();
Set observedIntentionalEnglishValues = new HashSet<>();
diff --git a/src/test/java/zone/moddev/mc/orespawn/TooltipAlignmentTest.java b/src/test/java/zone/moddev/mc/orespawn/TooltipAlignmentTest.java
index dc586cd2..11d69f8f 100644
--- a/src/test/java/zone/moddev/mc/orespawn/TooltipAlignmentTest.java
+++ b/src/test/java/zone/moddev/mc/orespawn/TooltipAlignmentTest.java
@@ -111,6 +111,7 @@ class TooltipAlignmentTest {
"tooltip.orespawn.main.biomes_materials",
"tooltip.orespawn.main.advanced",
"tooltip.orespawn.main.fluid_editor",
+ "tooltip.orespawn.main.mods",
"tooltip.orespawn.manage_vanilla_ores",
"tooltip.orespawn.fluid_deposits",
"tooltip.orespawn.advanced.formations",
diff --git a/src/test/java/zone/moddev/mc/orespawn/api/GeologySamplerSourceContractTest.java b/src/test/java/zone/moddev/mc/orespawn/api/GeologySamplerSourceContractTest.java
new file mode 100644
index 00000000..699eb3a7
--- /dev/null
+++ b/src/test/java/zone/moddev/mc/orespawn/api/GeologySamplerSourceContractTest.java
@@ -0,0 +1,21 @@
+package zone.moddev.mc.orespawn.api;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+
+import org.junit.jupiter.api.Test;
+
+class GeologySamplerSourceContractTest {
+ @Test
+ void samplingUsesTheBiomeProviderWithoutLoadingAChunk() throws Exception {
+ String source = new String(Files.readAllBytes(Paths.get(
+ "src/main/java/zone/moddev/mc/orespawn/api/OreSpawnGeologySampler.java")),
+ StandardCharsets.UTF_8);
+ assertTrue(source.contains("level.provider.getBiomeProvider().getBiome(position)"));
+ assertFalse(source.contains("level.getBiome(position)"));
+ }
+}
diff --git a/src/test/java/zone/moddev/mc/orespawn/api/OreGenerationContextTest.java b/src/test/java/zone/moddev/mc/orespawn/api/OreGenerationContextTest.java
new file mode 100644
index 00000000..6399b435
--- /dev/null
+++ b/src/test/java/zone/moddev/mc/orespawn/api/OreGenerationContextTest.java
@@ -0,0 +1,47 @@
+package zone.moddev.mc.orespawn.api;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.lang.reflect.Method;
+import java.util.Arrays;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.junit.jupiter.api.Test;
+
+import net.minecraft.util.ResourceLocation;
+
+class OreGenerationContextTest {
+ @Test
+ void extendsTheOriginalPlacementContractWithoutChangingItsAbi() {
+ assertTrue(OrePlacementContext.class.isAssignableFrom(OreGenerationContext.class));
+ Set baseMethods = Arrays.stream(OrePlacementContext.class.getDeclaredMethods())
+ .map(Method::getName).collect(Collectors.toSet());
+ assertFalse(baseMethods.contains("worldSeed"));
+ assertFalse(baseMethods.contains("dimension"));
+ assertFalse(baseMethods.contains("chunkX"));
+ assertFalse(baseMethods.contains("chunkZ"));
+ assertFalse(baseMethods.contains("geologySampler"));
+ }
+
+ @Test
+ void exposesTheStableRegionDepositIdentityContract() throws Exception {
+ assertEquals(long.class, OreGenerationContext.class.getMethod("worldSeed").getReturnType());
+ assertEquals(ResourceLocation.class,
+ OreGenerationContext.class.getMethod("dimension").getReturnType());
+ assertEquals(int.class, OreGenerationContext.class.getMethod("chunkX").getReturnType());
+ assertEquals(int.class, OreGenerationContext.class.getMethod("chunkZ").getReturnType());
+ assertEquals(Optional.class,
+ OreGenerationContext.class.getMethod("geologySampler").getReturnType());
+ }
+
+ @Test
+ void oreSpawnPatternContextImplementsTheExtendedContract() throws Exception {
+ Class> implementation = Class.forName(
+ "zone.moddev.mc.orespawn.worldgen.OreSpawnOreGeneration$PatternContext");
+ assertTrue(OreGenerationContext.class.isAssignableFrom(implementation));
+ }
+}
diff --git a/src/test/java/zone/moddev/mc/orespawn/api/WorldgenProviderTest.java b/src/test/java/zone/moddev/mc/orespawn/api/WorldgenProviderTest.java
index 6ce03837..623dc6aa 100644
--- a/src/test/java/zone/moddev/mc/orespawn/api/WorldgenProviderTest.java
+++ b/src/test/java/zone/moddev/mc/orespawn/api/WorldgenProviderTest.java
@@ -193,6 +193,45 @@ void serializesCodecBackedPatternWithoutLeakingMutableSettings() {
assertEquals(12, pattern.getAsJsonObject("settings").get("radius").getAsInt());
}
+ @Test
+ void serializesBackgroundControllerAndExistingWorldMergePolicy() {
+ WorldgenProvider.OreDimensionDefinition placement = WorldgenProvider.OreDimensionDefinition
+ .builder(id("minecraft:overworld"))
+ .hostBlock(id("minecraft:stone"))
+ .backgroundGenerationScale(0.25D)
+ .build();
+ WorldgenProvider provider = WorldgenProvider.builder("examplemod", 1)
+ .mergeNewEntriesIntoExistingWorlds(false)
+ .ore(id("examplemod:iron_controller"), id("minecraft:iron_ore"), ore -> ore
+ .retrogen(false).dimension(placement))
+ .build();
+
+ assertFalse(provider.mergeNewEntriesIntoExistingWorlds());
+ assertEquals(0.25D, placement.backgroundGenerationScale().getAsDouble());
+ JsonObject json = provider.toJson();
+ assertFalse(json.get("merge_new_entries_into_existing_worlds").getAsBoolean());
+ assertEquals(0.25D, json.getAsJsonObject("ores")
+ .getAsJsonObject("examplemod:iron_controller")
+ .getAsJsonObject("dimensions").getAsJsonObject("minecraft:overworld")
+ .get("background_generation_scale").getAsDouble());
+ }
+
+ @Test
+ void backgroundControllerDefaultsAreNonControllingAndBoundsAreValidated() {
+ WorldgenProvider.OreDimensionDefinition ordinary = WorldgenProvider.OreDimensionDefinition
+ .builder(id("minecraft:overworld")).hostBlock(id("minecraft:stone")).build();
+ assertFalse(ordinary.backgroundGenerationScale().isPresent());
+ assertTrue(WorldgenProvider.builder("examplemod", 1)
+ .ore(id("examplemod:ordinary"), id("minecraft:iron_ore"), ore -> ore.dimension(ordinary))
+ .build().mergeNewEntriesIntoExistingWorlds());
+ assertThrows(IllegalStateException.class, () -> WorldgenProvider.OreDimensionDefinition
+ .builder(id("minecraft:overworld")).hostBlock(id("minecraft:stone"))
+ .backgroundGenerationScale(-0.01D).build());
+ assertThrows(IllegalStateException.class, () -> WorldgenProvider.OreDimensionDefinition
+ .builder(id("minecraft:overworld")).hostBlock(id("minecraft:stone"))
+ .backgroundGenerationScale(1.01D).build());
+ }
+
@Test
void rejectsDefinitionsOutsideProviderNamespace() {
assertThrows(IllegalStateException.class, () -> WorldgenProvider.builder("examplemod", 1)
diff --git a/src/test/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistryTest.java b/src/test/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistryTest.java
new file mode 100644
index 00000000..e8e93ba0
--- /dev/null
+++ b/src/test/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistryTest.java
@@ -0,0 +1,124 @@
+package zone.moddev.mc.orespawn.api.client;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.junit.jupiter.api.Test;
+
+import net.minecraft.client.gui.GuiScreen;
+import net.minecraft.util.ResourceLocation;
+
+class WorldSettingsExtensionRegistryTest {
+ @Test
+ void preservesRegistrationOrderAndReturnsAnImmutableSnapshot() {
+ WorldSettingsExtension first = register("order_first", "settings", parent -> new GuiScreen() { });
+ WorldSettingsExtension second = register("order_second", "settings", parent -> new GuiScreen() { });
+ List extensions = WorldSettingsExtensionRegistry.extensions();
+
+ assertTrue(extensions.indexOf(first) < extensions.indexOf(second));
+ assertThrows(UnsupportedOperationException.class, () -> extensions.add(first));
+ }
+
+ @Test
+ void rejectsDuplicateOwnershipAndInvalidRegistrations() {
+ ResourceLocation id = id("duplicate_owner", "first");
+ WorldSettingsExtensionRegistry.register(id, "button.realisticdeposits.settings",
+ parent -> new GuiScreen() { });
+ assertThrows(IllegalStateException.class, () -> WorldSettingsExtensionRegistry.register(
+ id("duplicate_owner", "second"),
+ "button.realisticdeposits.other", parent -> new GuiScreen() { }));
+ assertThrows(IllegalArgumentException.class, () -> WorldSettingsExtensionRegistry.register(
+ id("blank_key", "settings"), " ", parent -> new GuiScreen() { }));
+ assertThrows(IllegalArgumentException.class, () ->
+ WorldSettingsExtensionRegistry.registerConfigScreen("Bad Mod", parent -> parent));
+ assertThrows(NullPointerException.class, () ->
+ WorldSettingsExtensionRegistry.registerConfigScreen("null_factory", null));
+ }
+
+ @Test
+ void preferredRegistrationOwnsOneConventionalScreenPerMod() {
+ WorldSettingsExtension extension = WorldSettingsExtensionRegistry.registerConfigScreen(
+ "preferred_form", parent -> parent);
+
+ assertEquals("preferred_form", extension.id().getNamespace());
+ assertEquals(new ResourceLocation("preferred_form", "configuration"), extension.id());
+ assertEquals("button.orespawn.mod.configure", extension.buttonTranslationKey());
+ assertThrows(IllegalStateException.class, () -> WorldSettingsExtensionRegistry.register(
+ id("preferred_form", "legacy_form"), "button.example.settings", parent -> parent));
+ }
+
+ @Test
+ void passesTheCurrentOreSpawnScreenToTheFactory() {
+ AtomicReference receivedParent = new AtomicReference<>();
+ GuiScreen expectedParent = new GuiScreen() { };
+ GuiScreen expectedChild = new GuiScreen() { };
+ WorldSettingsExtension extension = register("parent_handoff", "settings", parent -> {
+ receivedParent.set(parent);
+ return expectedChild;
+ });
+
+ assertSame(expectedChild, extension.createScreen(expectedParent));
+ assertSame(expectedParent, receivedParent.get());
+ assertEquals("button.realisticdeposits.settings", extension.buttonTranslationKey());
+ }
+
+ @Test
+ void screenResultsMayBeNullOrThrowForTheDirectoryToHandle() {
+ GuiScreen parent = new GuiScreen() { };
+ WorldSettingsExtension missing = register("missing_screen", "settings", ignored -> null);
+ WorldSettingsExtension failing = register("throwing_screen", "settings", ignored -> {
+ throw new IllegalArgumentException("fixture");
+ });
+
+ assertSame(null, missing.createScreen(parent));
+ assertThrows(IllegalArgumentException.class, () -> failing.createScreen(parent));
+ }
+
+ @Test
+ void oldAndPreferredJvmDescriptorsRemainAvailable() throws Exception {
+ assertNotNull(WorldSettingsExtensionRegistry.class.getDeclaredMethod("register",
+ ResourceLocation.class, String.class, WorldSettingsScreenFactory.class));
+ assertNotNull(WorldSettingsExtensionRegistry.class.getDeclaredMethod("registerConfigScreen",
+ String.class, WorldSettingsScreenFactory.class));
+ }
+
+ @Test
+ void mainScreenUsesOneBoundedDirectoryEntryPointInsteadOfExtensionRows() throws Exception {
+ String source = new String(Files.readAllBytes(Paths.get("src", "main", "java", "zone",
+ "moddev", "mc", "orespawn", "client", "OreSpawnWorldSettingsScreen.java")),
+ StandardCharsets.UTF_8);
+ assertTrue(!source.contains("WorldSettingsExtensionRegistry.extensions()"));
+ assertEquals(2, occurrences(source, "button.orespawn.mods"),
+ "Each mutually exclusive main-screen layout owns exactly one Mods button");
+ assertTrue(source.contains("new TextComponentTranslation(\"button.orespawn.mods\")"));
+ assertTrue(source.contains("new OreSpawnModsScreen(this)"));
+ assertTrue(source.contains("syncSession();"));
+ }
+
+ private static int occurrences(String source, String value) {
+ int count = 0;
+ for (int index = 0; (index = source.indexOf(value, index)) >= 0; index += value.length()) {
+ count++;
+ }
+ return count;
+ }
+
+ private static WorldSettingsExtension register(String modId, String path,
+ WorldSettingsScreenFactory factory) {
+ return WorldSettingsExtensionRegistry.register(id(modId, path),
+ "button.realisticdeposits.settings", factory);
+ }
+
+ private static ResourceLocation id(String modId, String path) {
+ return new ResourceLocation(modId, path);
+ }
+}
diff --git a/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnModDirectoryModelTest.java b/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnModDirectoryModelTest.java
new file mode 100644
index 00000000..06f080cc
--- /dev/null
+++ b/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnModDirectoryModelTest.java
@@ -0,0 +1,111 @@
+package zone.moddev.mc.orespawn.client;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import net.minecraft.client.gui.GuiScreen;
+import net.minecraft.util.ResourceLocation;
+import zone.moddev.mc.orespawn.api.ProviderStatus;
+import zone.moddev.mc.orespawn.api.client.WorldSettingsExtension;
+import zone.moddev.mc.orespawn.api.client.WorldSettingsExtensionRegistry;
+import zone.moddev.mc.orespawn.client.OreSpawnModDirectoryModel.Entry;
+import zone.moddev.mc.orespawn.client.OreSpawnModDirectoryModel.LoadedMod;
+import zone.moddev.mc.orespawn.integration.WorldgenIntegrationManager.ProviderIntegrationInfo;
+
+class OreSpawnModDirectoryModelTest {
+ @Test
+ void discoversLoadedNativeLegacyUiOnlyInactiveAndRejectedIntegrations() {
+ WorldSettingsExtension realisticScreen = WorldSettingsExtensionRegistry.register(
+ new ResourceLocation("directory_realistic", "settings"),
+ "button.realisticdeposits.settings", parent -> new GuiScreen() { });
+ WorldSettingsExtension uiOnlyScreen = WorldSettingsExtensionRegistry.registerConfigScreen(
+ "directory_ui_only", parent -> new GuiScreen() { });
+
+ List loaded = Arrays.asList(
+ new LoadedMod("orespawn", "MMD OreSpawn", "4.1.0.110021"),
+ new LoadedMod("directory_realistic", "Realistic Deposits", "0.1.0.110021"),
+ new LoadedMod("directory_base", "Base Metals", "2.5.0-beta5"),
+ new LoadedMod("directory_mineralogy", "Mineralogy", "6.0.2.110021"),
+ new LoadedMod("directory_ui_only", "Client Tweaks", "1.2.3"),
+ new LoadedMod("directory_inactive", "Inactive Rocks", "2.0"),
+ new LoadedMod("directory_rejected", "Rejected Ores", "3.0"));
+ List providers = Arrays.asList(
+ provider("directory_mineralogy", true, true, 4, 3,
+ ProviderStatus.ACTIVE, false),
+ provider("directory_base", false, true, 4, 2,
+ ProviderStatus.ACTIVE, false, 3),
+ provider("directory_realistic", true, true, 4, 1,
+ ProviderStatus.ACTIVE, false, 3),
+ provider("directory_inactive", true, true, 4, 1,
+ ProviderStatus.INACTIVE, false),
+ provider("directory_rejected", true, true, -1, -1,
+ ProviderStatus.INACTIVE, true),
+ provider("directory_unloaded", true, true, 4, 9,
+ ProviderStatus.ACTIVE, false),
+ provider("orespawn", true, true, 4, 1,
+ ProviderStatus.ACTIVE, false));
+
+ List result = OreSpawnModDirectoryModel.assemble(loaded, providers,
+ Arrays.asList(realisticScreen, uiOnlyScreen));
+
+ assertEquals(Arrays.asList("directory_base", "directory_ui_only", "directory_inactive",
+ "directory_mineralogy", "directory_realistic", "directory_rejected"),
+ result.stream().map(entry -> entry.modId).collect(java.util.stream.Collectors.toList()));
+ assertEquals(6, result.size());
+ assertEquals("config_only", find(result, "directory_ui_only").statusKey());
+ assertEquals("inactive", find(result, "directory_inactive").statusKey());
+ assertEquals("rejected", find(result, "directory_rejected").statusKey());
+ assertFalse(find(result, "directory_base").nativeOs4());
+ assertEquals(Collections.singletonList(3), find(result, "directory_base").legacyLineages());
+ assertTrue(find(result, "directory_realistic").nativeOs4());
+ assertTrue(find(result, "directory_realistic").configurable());
+ assertThrows(UnsupportedOperationException.class, () -> result.clear());
+ }
+
+ @Test
+ void deduplicatesByModIdAndSortsEqualNamesByModId() {
+ List loaded = Arrays.asList(
+ new LoadedMod("directory_sort_b", "Same Name", "1"),
+ new LoadedMod("directory_sort_a", "Same Name", "2"));
+ List providers = Arrays.asList(
+ provider("directory_sort_b", false, true, 4, 1, ProviderStatus.ACTIVE, false, 1, 2, 3),
+ provider("directory_sort_a", true, true, 4, 2, ProviderStatus.PENDING, false));
+
+ List result = OreSpawnModDirectoryModel.assemble(loaded, providers,
+ Collections.emptyList());
+
+ assertEquals("directory_sort_a", result.get(0).modId);
+ assertEquals("directory_sort_b", result.get(1).modId);
+ assertEquals(Arrays.asList(1, 2, 3), result.get(1).legacyLineages());
+ assertThrows(UnsupportedOperationException.class,
+ () -> result.get(1).legacyLineages().add(2));
+ }
+
+ @Test
+ void paginationRemainsBoundedAtMinimumResolution() {
+ assertEquals(3, OreSpawnModsScreen.pageSize(240));
+ assertEquals(1, OreSpawnModsScreen.pageCount(0, 3));
+ assertEquals(1, OreSpawnModsScreen.pageCount(3, 3));
+ assertEquals(2, OreSpawnModsScreen.pageCount(4, 3));
+ }
+
+ private static Entry find(List entries, String modId) {
+ return entries.stream().filter(entry -> modId.equals(entry.modId)).findFirst().orElseThrow(
+ () -> new AssertionError("Missing " + modId));
+ }
+
+ private static ProviderIntegrationInfo provider(String modId, boolean nativeOs4,
+ boolean hasProvider, int schema, int revision, ProviderStatus status,
+ boolean rejected, Integer... lineages) {
+ return new ProviderIntegrationInfo(modId, nativeOs4, hasProvider, schema, revision,
+ status, rejected, Arrays.asList(lineages));
+ }
+}
diff --git a/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnModsScreenContractTest.java b/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnModsScreenContractTest.java
new file mode 100644
index 00000000..85f57147
--- /dev/null
+++ b/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnModsScreenContractTest.java
@@ -0,0 +1,76 @@
+package zone.moddev.mc.orespawn.client;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.awt.image.BufferedImage;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+import javax.imageio.ImageIO;
+
+import org.junit.jupiter.api.Test;
+
+class OreSpawnModsScreenContractTest {
+ private static final Path CLIENT = Paths.get("src", "main", "java", "zone", "moddev", "mc",
+ "orespawn", "client");
+
+ @Test
+ void packagedCogIsARealSixteenPixelPngRatherThanAFontGlyph() throws Exception {
+ Path cog = Paths.get("src", "main", "resources", "assets", "orespawn", "textures", "gui",
+ "cog.png");
+ byte[] bytes = Files.readAllBytes(cog);
+ assertArrayEquals(new byte[] {(byte) 0x89, 0x50, 0x4e, 0x47},
+ java.util.Arrays.copyOf(bytes, 4));
+ BufferedImage image = ImageIO.read(cog.toFile());
+ assertEquals(16, image.getWidth());
+ assertEquals(16, image.getHeight());
+ String source = read(CLIENT.resolve("CogButton.java"));
+ assertTrue(source.contains("textures/gui/cog.png"));
+ assertFalse(source.contains("\\u2699"));
+ assertTrue(source.contains("GlStateManager.scale(1.5F, 1.5F, 1.0F)"));
+ assertTrue(source.contains("if (enabled)"));
+ }
+
+ @Test
+ void directoryOwnsPaginationClippingTooltipsAndFailureContainment() throws Exception {
+ String source = read(CLIENT.resolve("OreSpawnModsScreen.java"));
+ assertTrue(source.contains("pageCount(entries.size(), pageSize)"));
+ assertTrue(source.contains("font.trimStringToWidth"));
+ assertTrue(source.contains("cog.enabled = entry.configurable()"));
+ assertTrue(source.contains("tooltip.orespawn.mod.unavailable"));
+ assertTrue(source.contains("entry.extension.createScreen(this)"));
+ assertTrue(source.contains("if (screen == null)"));
+ assertTrue(source.contains("catch (RuntimeException failure)"));
+ assertTrue(source.contains("minecraft.displayGuiScreen(parent)"));
+ assertTrue(source.contains("WorldSettingsExtensionNavigation.open(this, screen)"));
+ String navigation = read(CLIENT.resolve("WorldSettingsExtensionNavigation.java"));
+ assertTrue(navigation.contains("event.getGui() == null"));
+ assertTrue(navigation.contains("event.getGui() instanceof GuiMainMenu"));
+ assertTrue(navigation.contains("event.setGui(directory)"));
+ }
+
+ @Test
+ void discoveryConsumesRecordedBoundariesRatherThanScanningProfilesInTheGui() throws Exception {
+ String model = read(CLIENT.resolve("OreSpawnModDirectoryModel.java"));
+ String legacyThree = read(Paths.get("src", "main", "java", "com", "mcmoddev", "orespawn",
+ "compat", "LegacyOs3Bridge.java"));
+ String legacyTwo = read(Paths.get("src", "main", "java", "zone", "moddev", "mc", "orespawn",
+ "worldgen", "LegacyConfigMigrator.java"));
+ assertTrue(model.contains("Loader.instance().getActiveModList()"));
+ assertTrue(model.contains("mod.getName()"));
+ assertTrue(model.contains("mod.getVersion()"));
+ assertFalse(model.contains("Files."));
+ assertFalse(model.contains("JsonParser"));
+ assertTrue(legacyThree.contains("recordLegacyLineage(owner, 3)"));
+ assertTrue(legacyTwo.contains("recordLegacyLineage(owner, 2)"));
+ }
+
+ private static String read(Path path) throws Exception {
+ return new String(Files.readAllBytes(path), StandardCharsets.UTF_8);
+ }
+}
diff --git a/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java b/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java
index b26253d6..91ba2f25 100644
--- a/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java
+++ b/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java
@@ -26,7 +26,7 @@ void everyConcreteScreenClearsThePreviousFrameBeforeDrawingWidgets() throws Exce
.sorted()
.collect(Collectors.toList());
}
- assertEquals(24, screens.size(), "Review this render-order gate when screens are added or removed");
+ assertEquals(25, screens.size(), "Review this render-order gate when screens are added or removed");
for (Path screen : screens) {
String source = new String(Files.readAllBytes(screen), StandardCharsets.UTF_8);
int render = source.indexOf(
@@ -60,9 +60,30 @@ void compactOrePlacementRowsStayAboveFooterAtMinimumTestHeight() {
assertCompactOrePlacementClearsFooter(240);
}
+ @Test
+ void advancedAndFluidEditorsShareTheRecoveredTerrainRow() throws Exception {
+ Path screen = Paths.get("src", "main", "java", "zone", "moddev", "mc",
+ "orespawn", "client", "OreSpawnWorldSettingsScreen.java");
+ String source = new String(Files.readAllBytes(screen), StandardCharsets.UTF_8);
+ int advanced = source.indexOf("new TextComponentTranslation(\"button.orespawn.advanced\")");
+ int advancedCall = source.lastIndexOf("addButton", advanced);
+ int fluid = source.indexOf("fluidEditorLabel()", advanced);
+ int fluidCall = source.lastIndexOf("addButton", fluid);
+ assertTrue(advanced > 0 && fluid > advanced);
+ assertTrue(source.substring(advancedCall, advanced)
+ .contains("font, left, top + (row * rowIndex)"));
+ assertTrue(source.substring(fluidCall, fluid)
+ .contains("font, right, top + (row * rowIndex++)"));
+ }
+
private static void assertRowsClearFooter(int height) {
+ int rows = 8;
+ int top = OreSpawnScreenLayout.mainTop(height);
+ int available = OreSpawnScreenLayout.footerY(height) - top - 20 - 4;
+ int spacing = Math.min(OreSpawnScreenLayout.mainRowSpacing(height),
+ Math.max(20, available / (rows - 1)));
int lastRowBottom = OreSpawnScreenLayout.mainTop(height)
- + (OreSpawnScreenLayout.mainRowSpacing(height) * 7) + 20;
+ + (spacing * (rows - 1)) + 20;
assertTrue(lastRowBottom < OreSpawnScreenLayout.footerY(height));
}
diff --git a/src/test/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManagerTest.java b/src/test/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManagerTest.java
index 5502a191..fc97e870 100644
--- a/src/test/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManagerTest.java
+++ b/src/test/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManagerTest.java
@@ -7,7 +7,10 @@
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.util.HashMap;
+import java.util.LinkedHashMap;
import java.util.Map;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Field;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
@@ -112,6 +115,47 @@ void validatesBiomePaletteAndDimensionMaterialsOnlyInSchemaFour() {
() -> WorldgenIntegrationManager.validateProvider("examplemod", solidFluid));
}
+ @Test
+ @SuppressWarnings({ "rawtypes", "unchecked" })
+ void providerCanDeferNewEntriesForExistingWorldsWithoutChangingNewWorldDefaults()
+ throws Exception {
+ JsonObject root = new JsonObject();
+ root.addProperty("schema_version", 4);
+ root.addProperty("provider_modid", "realisticdeposits");
+ root.addProperty("provider_revision", 1);
+ root.addProperty("merge_new_entries_into_existing_worlds", false);
+ JsonObject ores = new JsonObject();
+ ores.add("realisticdeposits:deposit/minecraft/iron_ore", new JsonObject());
+ root.add("ores", ores);
+
+ Class> definitionType = Class.forName(
+ "zone.moddev.mc.orespawn.integration.WorldgenIntegrationManager$ProviderDefinition");
+ Constructor> constructor = definitionType.getDeclaredConstructor(
+ String.class, int.class, JsonObject.class);
+ constructor.setAccessible(true);
+ Object provider = constructor.newInstance("realisticdeposits", 1, root);
+ Field activeField = WorldgenIntegrationManager.class.getDeclaredField("ACTIVE_PROVIDERS");
+ activeField.setAccessible(true);
+ Map active = (Map) activeField.get(null);
+ Map saved = new LinkedHashMap(active);
+ try {
+ active.clear();
+ active.put("realisticdeposits", provider);
+ JsonObject existingWorld = new JsonObject();
+ WorldgenIntegrationManager.mergeProviderDefinitionsIntoExistingWorld(existingWorld);
+ assertFalse(existingWorld.getAsJsonObject("ores")
+ .has("realisticdeposits:deposit/minecraft/iron_ore"));
+
+ JsonObject newWorld = new JsonObject();
+ WorldgenIntegrationManager.mergeProviderDefinitions(newWorld);
+ assertTrue(newWorld.getAsJsonObject("ores")
+ .has("realisticdeposits:deposit/minecraft/iron_ore"));
+ } finally {
+ active.clear();
+ active.putAll(saved);
+ }
+ }
+
private static JsonObject provider(String block, boolean withHost, int schema) {
JsonObject root = new JsonObject();
root.addProperty("schema_version", schema);
diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/BackgroundGenerationScaleTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/BackgroundGenerationScaleTest.java
new file mode 100644
index 00000000..3fcf00ab
--- /dev/null
+++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/BackgroundGenerationScaleTest.java
@@ -0,0 +1,55 @@
+package zone.moddev.mc.orespawn.worldgen;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.IdentityHashMap;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+import net.minecraft.block.Block;
+import net.minecraft.block.material.Material;
+import net.minecraft.util.ResourceLocation;
+
+class BackgroundGenerationScaleTest {
+ private static final ResourceLocation OVERWORLD = new ResourceLocation("minecraft", "overworld");
+ private static final ResourceLocation IRON = new ResourceLocation("minecraft", "iron_ore");
+
+ @Test
+ void controllerRuleRemainsUnscaledWhileBackgroundRulesAreScaled() {
+ assertEquals(8.0D, OreSpawnOreGeneration.scaledManagedFrequency(8.0D, true, 0.25D));
+ assertEquals(2.0D, OreSpawnOreGeneration.scaledManagedFrequency(8.0D, false, 0.25D));
+ }
+
+ @Test
+ void lowestControllerScaleWinsForTheSamePrimaryResource() {
+ Map scales = new IdentityHashMap<>();
+ Block output = new Block(Material.ROCK);
+ OreSpawnOreGeneration.mergeBackgroundScale(scales, output, 0.75D);
+ OreSpawnOreGeneration.mergeBackgroundScale(scales, output, 0.25D);
+ OreSpawnOreGeneration.mergeBackgroundScale(scales, output, 0.5D);
+ assertEquals(0.25D, scales.get(output).doubleValue());
+ }
+
+ @Test
+ void vanillaScaleGateIsStableByWorldDimensionChunkAndResource() {
+ assertFalse(OreSpawnOreGeneration.passesBackgroundScale(
+ OVERWORLD, IRON, 42L, -3, 7, 0.0D));
+ assertTrue(OreSpawnOreGeneration.passesBackgroundScale(
+ OVERWORLD, IRON, 42L, -3, 7, 1.0D));
+ boolean first = OreSpawnOreGeneration.passesBackgroundScale(
+ OVERWORLD, IRON, 42L, -3, 7, 0.25D);
+ assertEquals(first, OreSpawnOreGeneration.passesBackgroundScale(
+ OVERWORLD, IRON, 42L, -3, 7, 0.25D));
+
+ int accepted = 0;
+ for (int chunk = -5000; chunk < 5000; chunk++) {
+ if (OreSpawnOreGeneration.passesBackgroundScale(
+ OVERWORLD, IRON, 42L, chunk, -chunk, 0.25D)) accepted++;
+ }
+ assertTrue(accepted > 2200 && accepted < 2800,
+ "quarter-scale gate should retain approximately one quarter of chunks: " + accepted);
+ }
+}
diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/BiomeSurfaceFeatureOrderTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/BiomeSurfaceFeatureOrderTest.java
index d7123dec..1934c567 100644
--- a/src/test/java/zone/moddev/mc/orespawn/worldgen/BiomeSurfaceFeatureOrderTest.java
+++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/BiomeSurfaceFeatureOrderTest.java
@@ -26,7 +26,7 @@ void coordinatorRunsSurfacesBeforeManagedOresAndLeavesStaticBiomeListsAlone() th
assertTrue(source.contains("@SubscribeEvent(priority = EventPriority.LOWEST)\r\n\tpublic void filterVanillaOre")
|| source.contains("@SubscribeEvent(priority = EventPriority.LOWEST)\n\tpublic void filterVanillaOre"),
"vanilla-ore suppression must make its decision at Forge's final standard event priority");
- assertTrue(source.contains("OreSpawnOreGeneration.takesOverVanillaOre(dimension, output)"));
+ assertTrue(source.contains("OreSpawnOreGeneration.allowsVanillaOre(dimension, output"));
assertTrue(source.contains("event.setResult(Event.Result.DENY)"));
assertTrue(source.contains("earlyComplete.add(key)") && source.contains("oreComplete.add(key)"),
"terrain and ore callbacks must be independently deduplicated");
diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGenerationTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGenerationTest.java
index 02faa51e..749f336f 100644
--- a/src/test/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGenerationTest.java
+++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGenerationTest.java
@@ -56,6 +56,18 @@ void chunkBoundsIncludeAllFourEdgesAndRejectEveryNeighbour() {
assertFalse(OreSpawnOreGeneration.insideChunk(chunk, minX, 256, minZ));
}
+ @Test
+ void normalGenerationAndRetrogenShareStableExtendedIdentityInputs() throws Exception {
+ String source = new String(Files.readAllBytes(Paths.get("src", "main", "java", "zone",
+ "moddev", "mc", "orespawn", "worldgen", "OreSpawnOreGeneration.java")),
+ StandardCharsets.UTF_8);
+ assertTrue(source.contains("generateChunk(level, world, chunk"));
+ assertTrue(source.contains("generateChunk(level, null, chunk"));
+ assertTrue(source.contains("dimension, worldSeed,\n\t\t\t\tgeologySampler"));
+ assertTrue(source.contains("this.chunkX = chunkPos.x"));
+ assertTrue(source.contains("this.chunkZ = chunkPos.z"));
+ }
+
@Test
void fixedQuantityDoesNotConsumeRandomState() {
CountingRandom random = new CountingRandom(0);
From 26dc8a74c2ed3d55503020c68911e028bcab0833 Mon Sep 17 00:00:00 2001
From: JohnBraham
Date: Wed, 23 Sep 2026 20:21:01 +0100
Subject: [PATCH 2/6] Port Ore Sources arbitration and editor
---
build.gradle | 23 +-
docs/CONFIGURATION.md | 114 ++-
docs/DEVELOPER_GUIDE.md | 22 +-
docs/MIGRATION.md | 15 +-
docs/PLAYER_GUIDE.md | 55 ++
docs/PROVIDERS.md | 45 +-
docs/examples/examplemod-orespawn.json | 4 +-
docs/examples/orespawn-global.json | 13 +-
docs/examples/orespawn-world.json | 13 +-
docs/schemas/orespawn-global.schema.json | 2 +-
docs/schemas/orespawn-provider.schema.json | 2 +-
docs/schemas/orespawn-world.schema.json | 2 +-
docs/schemas/profile-common.schema.json | 55 +-
.../client/OreSourceEditorProbeBridge.java | 116 +++
.../orespawn/testmod/SurfaceProbeTestMod.java | 369 ++++++-
.../orespawn/client/ClientProbeTestMod.java | 411 +++++++-
.../mc/orespawn/api/OreGenerationContext.java | 9 +
.../mc/orespawn/api/WorldgenProvider.java | 26 +-
.../mc/orespawn/client/CompactScrollList.java | 293 ++++++
.../orespawn/client/GeologyEditorSession.java | 898 ++++++++++++++++++
.../client/GeologyMaterialsScreen.java | 12 +-
.../client/OreSourceGroupSettingsScreen.java | 394 ++++++++
.../orespawn/client/OreSourceListScreen.java | 517 ++++++++++
.../mc/orespawn/client/OreSpawnScreen.java | 13 +-
.../orespawn/client/OreSpawnScreenLayout.java | 4 +-
.../client/OreSpawnWorldSettingsScreen.java | 11 +-
.../mc/orespawn/client/TextFieldWidget.java | 11 +
.../WorldgenIntegrationManager.java | 24 +-
.../mc/orespawn/worldgen/GeomeConfig.java | 37 +-
.../orespawn/worldgen/OreMaterialGroups.java | 251 +++++
.../orespawn/worldgen/OreSourcePolicies.java | 797 ++++++++++++++++
.../worldgen/OreSpawnOreGeneration.java | 397 +++++++-
.../worldgen/WorldGeologyProfile.java | 21 +-
.../worldgen/WorldGeologyProfileManager.java | 24 +-
.../resources/assets/orespawn/lang/de_au.lang | 92 ++
.../resources/assets/orespawn/lang/de_de.lang | 92 ++
.../resources/assets/orespawn/lang/en_ca.lang | 92 ++
.../resources/assets/orespawn/lang/en_en.lang | 92 ++
.../resources/assets/orespawn/lang/en_gb.lang | 92 ++
.../resources/assets/orespawn/lang/en_pt.lang | 92 ++
.../resources/assets/orespawn/lang/en_us.lang | 119 ++-
.../resources/assets/orespawn/lang/es_es.lang | 92 ++
.../resources/assets/orespawn/lang/es_mx.lang | 92 ++
.../resources/assets/orespawn/lang/fr_ca.lang | 92 ++
.../resources/assets/orespawn/lang/fr_fr.lang | 92 ++
.../resources/assets/orespawn/lang/ja_jp.lang | 92 ++
.../resources/assets/orespawn/lang/ko_kr.lang | 92 ++
.../resources/assets/orespawn/lang/pt_br.lang | 92 ++
.../resources/assets/orespawn/lang/ru_ru.lang | 92 ++
.../resources/assets/orespawn/lang/zh_cn.lang | 92 ++
.../mc/orespawn/LocalizationParityTest.java | 2 +-
.../api/OreGenerationContextTest.java | 9 +
.../mc/orespawn/api/WorldgenProviderTest.java | 26 +-
.../client/CompactScrollListTest.java | 61 ++
.../client/GeologyEditorSessionTest.java | 544 ++++++++++-
.../client/OreSourcePlacementScreenTest.java | 140 +++
.../client/OreSpawnScreenLayoutTest.java | 126 ++-
.../WorldgenIntegrationManagerTest.java | 38 +
.../BackgroundGenerationScaleTest.java | 11 +
.../worldgen/OreSourcePoliciesTest.java | 449 +++++++++
.../worldgen/OreSpawnOreGenerationTest.java | 121 ++-
.../worldgen/WorldGeologyProfileTest.java | 101 ++
62 files changed, 8011 insertions(+), 116 deletions(-)
create mode 100644 src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/client/OreSourceEditorProbeBridge.java
create mode 100644 src/main/java/zone/moddev/mc/orespawn/client/CompactScrollList.java
create mode 100644 src/main/java/zone/moddev/mc/orespawn/client/OreSourceGroupSettingsScreen.java
create mode 100644 src/main/java/zone/moddev/mc/orespawn/client/OreSourceListScreen.java
create mode 100644 src/main/java/zone/moddev/mc/orespawn/worldgen/OreMaterialGroups.java
create mode 100644 src/main/java/zone/moddev/mc/orespawn/worldgen/OreSourcePolicies.java
create mode 100644 src/test/java/zone/moddev/mc/orespawn/client/CompactScrollListTest.java
create mode 100644 src/test/java/zone/moddev/mc/orespawn/client/OreSourcePlacementScreenTest.java
create mode 100644 src/test/java/zone/moddev/mc/orespawn/worldgen/OreSourcePoliciesTest.java
diff --git a/build.gradle b/build.gradle
index b1a0a9ce..7235ae7d 100644
--- a/build.gradle
+++ b/build.gradle
@@ -558,6 +558,9 @@ task surfaceIntegrationTest(dependsOn: surfaceIntegrationReloadProcess) {
if (result.getProperty('reload_verified') != 'true') {
throw new GradleException("Surface integration reload was not verified: ${marker}")
}
+ if (result.getProperty('ore_sources_reset_verified') != 'true') {
+ throw new GradleException("Ore-source editor/profile/generation integration was not verified: ${marker}")
+ }
if (!(result.getProperty('dynamic_fluid_placements') ?: '0').isInteger()
|| result.getProperty('dynamic_fluid_placements').toInteger() <= 0) {
throw new GradleException("Dynamic fluid-deposit probe did not place any blocks: ${marker}")
@@ -901,11 +904,13 @@ task clientIntegrationProcess(type: JavaExec, dependsOn: prepareClientIntegratio
|| result.getProperty('world_settings_opened') != 'true'
|| result.getProperty('mods_directory_rendered') != 'true'
|| result.getProperty('directory_stack_validated') != 'true'
+ || result.getProperty('ore_sources_validated') != 'true'
+ || result.getProperty('ore_sources_layout_validated') != 'true'
|| result.getProperty('long_editor_roundtrip') != 'true'
|| Integer.parseInt(result.getProperty('editor_routes', '0')) < 5) {
throw new GradleException("Client integration result is incomplete: ${marker}")
}
- assertRuntimeLogsClean(clientIntegrationRunDirectory, 'client integration fresh/reload phases')
+ assertRuntimeLogsClean(clientIntegrationRunDirectory, 'client integration fresh/reload phases')
}
}
@@ -1374,14 +1379,14 @@ tasks.register('verifyReleaseConfiguration') {
throw new GradleException('OreSpawn API major must remain 1')
}
if (!file('src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeConfig.java')
- .getText('UTF-8').contains('SCHEMA_VERSION = 6')
+ .getText('UTF-8').contains('SCHEMA_VERSION = 8')
|| !file('src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java')
- .getText('UTF-8').contains('SCHEMA_VERSION = 5')) {
- throw new GradleException('Global/world schema versions must remain 6/5')
+ .getText('UTF-8').contains('SCHEMA_VERSION = 7')) {
+ throw new GradleException('Global/world schema versions must remain 8/7')
}
def providerSchema = new JsonSlurper().parse(file('docs/schemas/orespawn-provider.schema.json'))
- if (!(providerSchema.properties.schema_version.enum as List).contains(4)) {
- throw new GradleException('Provider schema must continue to support schema version 4')
+ if (!(providerSchema.properties.schema_version.enum as List).containsAll([4, 5])) {
+ throw new GradleException('Provider schema must support schema versions 4 and 5')
}
}
}
@@ -1426,7 +1431,7 @@ tasks.register('verifyReleaseArtifacts') {
['src/test/', 'src/biomeIntegrationTest/', 'src/migrationIntegrationTest/',
'src/clientIntegrationTest/', 'src/os1AbiFixture/', 'src/os3AbiFixture/',
'agent-notes/', 'surfaceprobe', 'migrationprobe', 'clientprobe',
- 'codec-conflict/', 'LegacyApiConsumer',
+ 'codec-conflict/', 'LegacyApiConsumer', 'OreSourceEditorProbeBridge',
'org/junit/', 'org/mockito/', 'net/bytebuddy/'].each { forbidden ->
if (candidateZip.entries().any { it.name.contains(forbidden) }) {
throw new GradleException(
@@ -1897,8 +1902,8 @@ tasks.register('verifyEclipseProductionClasspath') {
'src/test', 'bin/test', 'build/classes/java/test',
'biomeIntegrationTest', 'migrationIntegrationTest', 'clientIntegrationTest',
'os1AbiFixture', 'os3AbiFixture', 'surfaceprobe', 'migrationprobe', 'clientprobe',
- 'junit-', 'opentest4j-', 'junit-platform-',
- 'Mineralogy-1.10.2-3.3.8.26.jar', 'Mineralogy-1.12.2-3.8.0.53.jar'
+ 'OreSourceEditorProbeBridge', 'junit-', 'opentest4j-', 'junit-platform-',
+ 'Mineralogy-1.10.2-3.3.8.26.jar', 'Mineralogy-1.12.2-3.8.0.53.jar'
]
launchFiles.each { launch ->
String contents = launch.getText('UTF-8').replace('\\', '/')
diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md
index 3f2a101e..353c49e2 100644
--- a/docs/CONFIGURATION.md
+++ b/docs/CONFIGURATION.md
@@ -4,13 +4,14 @@ OreSpawn uses three JSON contracts:
| File | Schema | Purpose |
|---|---:|---|
-| `config/orespawn-worldgen.json` | 6 | Installed-pack defaults for new worlds |
-| `/serverconfig/orespawn-worldgen.json` | 5 | Self-contained snapshot for one world |
-| `config/-orespawn.json` | 4 | Optional authoritative provider override |
+| `config/orespawn-worldgen.json` | 8 | Installed-pack defaults for new worlds |
+| `/serverconfig/orespawn-worldgen.json` | 7 | Self-contained snapshot for one world |
+| `config/-orespawn.json` | 5 | Optional authoritative provider override |
-A provider may package schema 4 at `assets//orespawn/provider.json`.
-Legacy provider schemas 1-3 remain accepted. Fluid deposits require schema 3;
-biome palettes and dimension materials require schema 4.
+A provider may package schema 5 at `assets//orespawn/provider.json`.
+Legacy provider schemas 1-4 remain accepted. Fluid deposits require schema 3;
+biome palettes and dimension materials require schema 4; ore `material` and
+`placement_channel` declarations require schema 5.
The profile for a new world is merged in this order: passive OreSpawn defaults,
packaged or API providers, provider override files, the global configuration,
@@ -21,7 +22,7 @@ world. Restart after editing JSON by hand.
| Field | Values | Meaning |
|---|---|---|
-| `schema_version` | Contract-specific integer | Global 6, world 5, provider 4 |
+| `schema_version` | Contract-specific integer | Global 8, world 7, provider 5 |
| `geology_mode` | `geome`, `legacy` | Sky/geome engine or Cyano legacy engine |
| `place_fluid_deposits` | boolean | Master switch for configured fluid-deposit rules |
| `manage_vanilla_ores` | boolean | Lets OreSpawn suppress and replace claimed vanilla ore features |
@@ -36,6 +37,8 @@ world. Restart after editing JSON by hand.
| `biome_palettes` | object keyed by provider-owned rule ID | Optional native-biome overlays and surfaces |
| `dimension_materials` | object keyed by provider-owned rule ID | Aquifer fluid, snow, and ice substitutions |
| `ores` | object keyed by rule ID | Ore outputs and per-dimension placement |
+| `ore_material_groups` | object keyed by stable group ID | Friendly material names and exact Ore Dictionary aliases |
+| `ore_source_policies` | object keyed by material and dimension/domain | Persisted output and placement arbitration choices |
| `fluid_deposits` | object keyed by rule ID | Provider-owned fluids and per-dimension placement |
| `retrogen` | object | Bounded ore retrogen controls |
| `flat_bedrock` | object | Opt-in flat bedrock controls |
@@ -162,8 +165,11 @@ See `BIOMES.md` for complete examples and practical guidance.
## Ore Fields
An ore has `enabled`, one output `block` or weighted `outputs`, and at least one
-entry in `dimensions` or `dimension_selectors`. Optional fields include `native_generation`,
-`suppress_vanilla`, `retrogen`, `deep_output`, and `deep_output_max_y`.
+entry in `dimensions` or `dimension_selectors`. Optional fields include
+`material`, `native_generation`, `suppress_vanilla`, `retrogen`, `deep_output`,
+and `deep_output_max_y`. Schema 5 `material` is a canonical material ID such as
+`orespawn:sulfur`; it lets several rules share outputs without sharing placement
+budgets.
Each enabled ore dimension uses:
@@ -174,6 +180,7 @@ Each enabled ore dimension uses:
| `quantity` | 1-64 | Fixed block budget for each attempt |
| `min_quantity`, `max_quantity` | 1-64 | Inclusive random block-budget range; both fields are required |
| `pattern` | pattern name or codec object | Deposit shape |
+| `placement_channel` | registry ID | Independent placement engine; built-ins default to `orespawn:standard`, custom patterns to their pattern-type ID |
| `height_distribution` | one of four values | Vertical probability curve |
| `discard_chance_on_air_exposure` | 0-1 | Chance to omit candidates touching cave air |
| `spread` | 0-64 | Horizontal pattern reach |
@@ -204,6 +211,95 @@ The selector `orespawn:all_except_nether_end` covers every dimension except
the vanilla Nether and End. Explicit rules in `dimensions` override selector
rules for the same ore and dimension, including explicit disabled rules.
+## Ore Source Policies
+
+OreSpawn builds one immutable source catalog while providers and the Forge Ore
+Dictionary are being baked. Explicit `material` declarations win. Otherwise,
+only exact Ore Dictionary names of the form `oreX` are inferred; curated
+spelling aliases include sulfur/sulphur and aluminum/aluminium. Niter and
+Saltpeter remain distinct, and ambiguous entries are marked **Review required**
+instead of being guessed. Registry, dictionary and policy work never runs in a
+chunk-generation loop.
+
+Each `ore_material_groups` entry has a persistent registry-style group ID, a
+`display_name`, and `ore_dictionary_entries`. Entries must be exact `oreX`
+names and may belong to only one group. Renaming a group does not change its
+ID. OreSpawn supplies Sulfur (`oreSulfur`, `oreSulphur`) and Aluminum
+(`oreAluminum`, `oreAluminium`) groups; Niter and Saltpeter deliberately remain
+separate. The UI can create custom groups and confirm moving an alias from
+another group. An empty custom group can be deleted immediately; a populated
+custom group requires a confirmed dissolution that returns each alias to its
+deterministic inferred or curated group. Automatically discovered groups
+cannot be deleted because they represent the loaded Ore Dictionary. Curated
+Sulfur and Aluminum groups instead offer Reset Defaults.
+
+Each `ore_source_policies` key combines the group ID with an exact dimension or
+existing dimension-selector ID. `mode` is `consolidated` or `keep_separate`.
+Consolidated policies also store `output_mode` as `balanced`, `single`, or
+`custom`, selected output rule IDs with positive weights, and one active
+placement-source rule per placement channel. Balanced uses equal weights,
+Single has exactly one output, and Custom accepts any non-empty positively
+weighted subset. The UI calls `keep_separate` **Keep Original**. Returning to
+Keep Original restores the output weights and placement-source selections from
+the profile snapshot taken when the editor was opened. If Keep Original itself
+still needs review, **Accept** leaves those restored values unchanged and only
+marks the policy reviewed. Like every Ore Sources edit, it is persisted only by
+the main editor's **Done** action.
+
+**Reset All** is a confirmed pending action. It removes custom material groups
+and source-policy choices, restores the built-in aliases, and rediscovers the
+inferred groups from the ores currently loaded. It does not rewrite generated
+chunks. The reset reaches the world profile and future-world defaults only when
+the main editor's **Done** action succeeds; its **Cancel** action discards it.
+
+The Ore Sources screen keeps compact scrollable group and output lists visible
+together. Its default view hides harmless one-alias, one-output entries; **Show
+All** reveals them. Red groups need attention, yellow groups have a saved rule,
+and green entries need no action. A saved consolidated rule or an explicitly
+accepted Keep Original policy therefore clears a multi-output group's attention
+state without hiding the resolved group. Use a
+group's cog for its friendly name, Ore Dictionary aliases and **Placement
+Rules**. Output selection controls which registered block represents
+the material. Placement Rules separately control the managed rule that supplies
+frequency, shape, depth and host restrictions for each channel. `orespawn:standard`
+is shown as **Standard veins**; exact custom-channel IDs remain available in
+tooltips. A sole active managed source is read-only, multiple active managed
+sources are selectable, and independent external generators are never offered
+as placement owners. Each row shows the selected source and the available
+choice count. It cycles only in a consolidated mode; under Keep Original it is
+informational because the original rules remain independent. The compact help
+and row tooltips explain fixed, selectable, missing and inactive states without
+reducing the alias-list height.
+
+Native vanilla ore rules are active placement sources only when
+`manage_vanilla_ores` is enabled. Changing that option refreshes the pending
+Ore Sources snapshot before the editor opens. While it is disabled, Group
+Settings refuses to move a native vanilla ore's Ore Dictionary alias into
+another material group and explains that vanilla management must be enabled
+first.
+
+Consolidation runs the chosen placement rule's pattern, hosts, filters, height
+and frequency once; selected candidates contribute output bundles only. An
+inactive managed provider may therefore supply an output block without adding
+a placement budget. Output choice is stable for a whole ordinary vein, and
+custom patterns can supply a stable body identity so one deposit uses one
+source across chunk boundaries. External Ore Dictionary members can be chosen
+as outputs, but their independent native generators remain uncontrolled.
+
+New worlds consolidate only reviewed high-confidence MMD catalog conflicts and
+start those groups in Balanced mode with all eligible outputs selected.
+Upgraded worlds initialize discovered conflicts as `keep_separate`, preserving
+their historical placement frequency and outputs until changed through **Ore
+Sources...**. Policy edits affect newly generated chunks only and never cause
+retrogen. Missing selected candidates remain in the profile; remaining outputs
+are reweighted, while a channel with no remaining selected placement source is
+disabled and reported rather than silently reassigned.
+
+DenseMetals entries are shown as enrichment rather than interchangeable output.
+BaseSciences has no ordinary ore-source role. An Ore Dictionary member whose
+generator is not controlled by OreSpawn is reported as external generation and
+is never suppressed, so total abundance may still be increased by that mod.
+
## Fluid Deposits, Retrogen, And Bedrock
Each `fluid_deposits` entry has a stable provider-namespaced rule ID, an
diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md
index 67cf662a..097f2d7e 100644
--- a/docs/DEVELOPER_GUIDE.md
+++ b/docs/DEVELOPER_GUIDE.md
@@ -15,6 +15,7 @@
| Inspect active geology at runtime | `GeologyProfileView` and `GeologySampler` |
| Build a deterministic region-scale ore pattern | `OreGenerationContext` |
| Replace or blend background ore generation | `backgroundGenerationScale` |
+| Share equivalent ore outputs without multiplying placement | Provider schema 5 `material` and `placement_channel` |
| Add an add-on settings screen | Client-only `WorldSettingsExtensionRegistry` |
Strata are optional. If no enabled terrain dimension has eligible rocks,
@@ -24,7 +25,7 @@ blocks or tags.
## Provider JSON Quick Start
-Put a schema-4 file in your mod jar at:
+Put a schema-5 file in your mod jar at:
```text
src/main/resources/assets/examplemod/orespawn/provider.json
@@ -36,12 +37,13 @@ stone without enabling strata:
```json
{
- "schema_version": 4,
+ "schema_version": 5,
"provider_modid": "examplemod",
"provider_revision": 1,
"ores": {
"examplemod:ore/tin": {
"block": "examplemod:tin_ore",
+ "material": "orespawn:tin",
"enabled": true,
"source_mod": "examplemod",
"dimensions": {
@@ -53,6 +55,7 @@ stone without enabling strata:
"min_quantity": 4,
"max_quantity": 11,
"pattern": "vein",
+ "placement_channel": "orespawn:standard",
"height_distribution": "triangle",
"host_tags": ["forge:stone"]
}
@@ -93,6 +96,7 @@ public void init(FMLInitializationEvent event) {
ResourceLocation tin = new ResourceLocation("examplemod", "tin_ore");
WorldgenProvider provider = WorldgenProvider.builder("examplemod", 1)
.ore(tin, ore -> ore
+ .material(new ResourceLocation("orespawn", "tin"))
.retrogen(false)
.dimensionSelector(OreDimensionSelector.ALL_EXCEPT_NETHER_AND_END,
placement -> placement
@@ -100,6 +104,7 @@ public void init(FMLInitializationEvent event) {
.attempts(6.0)
.quantityRange(4, 11)
.pattern(OrePattern.VEIN)
+ .placementChannel(new ResourceLocation("orespawn", "standard"))
.heightDistribution(OreHeightDistribution.TRIANGLE)
.biome(new ResourceLocation("minecraft", "plains"))
.biomeDictionary("FOREST")
@@ -122,6 +127,13 @@ needs different settings; the explicit rule overrides the selector there.
Ore dimension builders support the same exact-ID and biome-dictionary include
and exclude filters as provider JSON and fluid-deposit builders.
+Give equivalent ores the same canonical `.material(...)`. A placement channel
+identifies an independent generation engine: built-in patterns default to
+`orespawn:standard`, while a custom pattern defaults to its pattern-type ID.
+OreSpawn can then consolidate one placement budget while retaining weighted
+whole-vein output choice. Do not use a block ID as a material merely because
+the names happen to match; use a stable semantic material ID.
+
## Region-scale custom patterns
OreSpawn 4.1 supplies compiled patterns with `OreGenerationContext`, a subtype
@@ -136,6 +148,12 @@ Render only the part of a deterministic body that intersects `chunkX()` and
operations to the current chunk. Perform definition parsing and expensive
setup in the registered pattern compiler, not its placement callback.
+When placing a multi-chunk body, call
+`tryPlace(x, y, z, stableBodyIdentity)`. The overload is binary-compatible with
+existing patterns and makes OreSpawn select one output source for the entire
+body. The identity must be stable for the deposit and independent of chunk
+generation order.
+
## Add-on world settings
Register one optional client configuration screen during client
diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md
index 5685866b..e32f78b6 100644
--- a/docs/MIGRATION.md
+++ b/docs/MIGRATION.md
@@ -109,17 +109,28 @@ replacement, retrogen, forced retrogen, flat bedrock, and bedrock thickness.
Unknown numeric dimensions and obsolete block states are reported instead of
guessed. Review `config/orespawn-migration/migration-report.txt` after import.
-Global schemas 1-5 and world schemas 1-4 are upgraded in memory and persisted
+Global schemas 1-7 and world schemas 1-6 are upgraded in memory and persisted
where safe. A schema-1 world held only mode/oil/formation choices and is
overlaid on the effective installed-pack profile. A schema-2 world is already
a full snapshot and preserves its geology. Existing terrain is not rewritten.
-Provider schemas 1-3 remain readable. Schema 4 adds `biome_palettes`,
+Provider schemas 1-4 remain readable. Schema 4 adds `biome_palettes`,
`dimension_materials`, and automatic fresh-world template metadata. Older
profiles receive empty biome/material sections, so migration cannot change
their terrain or biome output. Auto-selected templates are never applied to an
existing world profile during migration.
+Provider schema 5 adds optional canonical ore `material` and per-rule
+`placement_channel` fields. Global schema 8 and world schema 7 persist
+`ore_material_groups`, each policy's `output_mode`, and the existing
+`ore_source_policies`. Existing consolidated policies infer Single, Balanced,
+or Custom from their selected outputs and weights. A new world may consolidate
+only reviewed MMD conflicts and begins those groups Balanced; every discovered
+conflict in an upgraded world is initialized as `keep_separate` (shown as Keep
+Original), preserving prior placement frequency, ordering and output behaviour.
+Missing selected sources and dormant policies remain recorded. Policy changes
+affect new chunks only and do not add retrogen.
+
The old `place_crude_oil` and singleton `oil` fields migrate to
`place_fluid_deposits` and a provider-owned rule. A valid block becomes
`:fluid_deposit/`; `minecraft:air` creates no rule. Mineralogy
diff --git a/docs/PLAYER_GUIDE.md b/docs/PLAYER_GUIDE.md
index b0890ba3..d3af9e75 100644
--- a/docs/PLAYER_GUIDE.md
+++ b/docs/PLAYER_GUIDE.md
@@ -46,6 +46,42 @@ safely instead of breaking world creation.
region.
- **Manage Vanilla Ores** lets OreSpawn replace vanilla ore features with the
configured OreSpawn rules. Leave it off to keep normal Minecraft placement.
+- **Ore Sources** opens the material-group editor beside **Add Block**. Compact
+ scrolling lists keep relevant groups on the left and the deduplicated union
+ of installed ore blocks represented by their aliases on the right. Ordinary
+ one-alias, one-output entries are hidden until **Show All** is selected. Red
+ groups need attention, yellow groups have a saved rule and green entries need
+ no action:
+ **Balanced** shares them equally, **Single** uses one, **Custom** enables a
+ weighted subset, and **Keep Original** preserves every old placement rule and
+ restores the output and placement selections present when the editor opened.
+ Groups needing attention sort first. Saving a consolidated rule clears that
+ attention state. To keep an unchanged red Keep Original policy, press
+ **Accept**; it marks the group reviewed without changing its outputs or
+ placement rules. It is saved only when the main editor's **Done** is used.
+ **Reset All** asks for confirmation, removes pending custom groups and source
+ choices, and rebuilds the built-in and inferred groups from the currently
+ loaded ores. Main-editor **Cancel** still discards that reset.
+ The cog beside a group opens its friendly
+ name, Ore Dictionary aliases and **Placement Rules**. Outputs decide which
+ block is used; Placement Rules decide the frequency, shape, depth and host
+ restrictions for each managed channel. A single managed source is read-only;
+ a row with multiple sources shows the choice count and can be clicked to
+ cycle the source after Balanced, Single or Custom is selected. Under Keep
+ Original the row is informational because every rule remains independent.
+ Hover the Placement Rules help or
+ a rule row to see whether it is fixed, selectable, missing, or inactive under
+ Keep Original.
+ Empty custom groups can be deleted immediately. A populated custom group can
+ be dissolved after a second confirmation, returning all aliases to their
+ inferred or curated groups. Automatically discovered groups cannot be
+ deleted, while the curated Sulfur and Aluminum groups can be reset.
+ Turning **Manage Vanilla Ores** on makes native vanilla rules available here
+ immediately. When it is off, OreSpawn will not let a vanilla ore alias be
+ moved into another group; the screen tells you to enable vanilla management
+ first.
+ Independent external generators
+ are explained but never offered as controllable placement sources.
- **Mods** opens a paginated directory of loaded OreSpawn integrations. It
reports the Forge mod version, native OS4 or legacy OS1-OS3 lineage, provider
schema/revision and current status. Historical profile owners that are not
@@ -80,6 +116,25 @@ whether a deposit is compact, vein-like, clustered, cloud-like, or below a
fluid. Hosts decide which blocks, tags, or configured rock families it may
replace.
+When two or more OreSpawn-managed rules represent one material, open **Ore
+Sources...**. The list reports the material, dimension, candidate count and
+whether it is Consolidated, Separate, Review required, Missing source, or also
+has External generation. Open a material to choose one placement source for
+each independent channel and enable one or more weighted output sources.
+
+Consolidated mode uses one placement budget, so three sulfur blocks do not mean
+three times as much sulfur. Each vein uses one selected output instead of a
+salt-and-pepper mixture. Custom region-scale deposit engines remain on their
+own placement channel and can keep one output across every chunk of a deposit.
+External native generators are explained but never disabled by OreSpawn.
+
+New worlds automatically consolidate only reviewed high-confidence MMD
+conflicts. Existing worlds start with conflicts Separate so their established
+frequency remains unchanged. Missing selected mods stay visible and are
+restored automatically if reinstalled. These edits stay pending until the main
+OreSpawn editor's **Done** is pressed; its **Cancel** discards them. Changes
+affect only newly generated chunks and never retrogen existing terrain.
+
Removing a rock from generation does not unregister its block or recipes. It
only prevents that rock appearing in newly generated terrain.
diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md
index e7a72f2f..42d00337 100644
--- a/docs/PROVIDERS.md
+++ b/docs/PROVIDERS.md
@@ -6,12 +6,13 @@ Provider mods may contribute through Forge IMC, a packaged resource at
present malformed override leaves that provider inactive instead of silently
falling back.
-Provider schema 4 supports `profile_defaults`, `rocks`, `ores`,
+Provider schema 5 supports `profile_defaults`, `rocks`, `ores`,
`fluid_deposits`, `geomes`, `biome_rules`, `terrain_dimensions`, and
`templates`, plus `biome_palettes` and `dimension_materials`. Each file requires a
matching `provider_modid`, a positive `provider_revision`, and at least one
-contribution. Legacy schemas 1-3 remain accepted; schema 3 introduced fluid
-deposits and schema 4 introduces biome and world-material controls.
+contribution. Legacy schemas 1-4 remain accepted; schema 3 introduced fluid
+deposits, schema 4 introduced biome and world-material controls, and schema 5
+adds canonical ore materials and independent placement channels.
An ore-only provider does not need rocks, geomes, or terrain dimensions. Give
each ore explicit host blocks or tags and OreSpawn will leave vanilla terrain,
@@ -26,6 +27,7 @@ ownership keys, not necessarily block IDs. Set `block` for one output or
```json
"examplemod:ore/tin": {
"block": "examplemod:tin_ore",
+ "material": "orespawn:tin",
"outputs": [
{ "block": "examplemod:tin_ore", "weight": 90 },
{ "block": "examplemod:rich_tin_ore", "weight": 10 }
@@ -40,12 +42,20 @@ ownership keys, not necessarily block IDs. Set `block` for one output or
"min_quantity": 4,
"max_quantity": 11,
"pattern": "default",
+ "placement_channel": "orespawn:standard",
"host_tags": ["forge:stone"]
}
}
}
```
+`material` identifies what an ore represents; it is not an output registry ID.
+Rules for one material and dimension share selectable output sources, while
+`placement_channel` keeps ordinary veins independent from custom deposit
+engines. Built-in patterns default to `orespawn:standard`; custom patterns
+default to their registered pattern-type ID. A schema-5 provider should declare
+these fields explicitly when it expects Ore Sources arbitration.
+
Fluid-deposit IDs also use the provider namespace. Their `block` may belong to
any installed mod, but it must be a real fluid block. Every enabled dimension
needs hosts and can independently set depth, attempts, lobe geometry, cover,
@@ -101,6 +111,35 @@ Only suppress a provider mod's native ore generation when
`OreSpawnApi.isOreTakeoverActive(modid)` returns true. `PENDING` means discovery
has not frozen. `INACTIVE` is the fail-safe and native generation must remain.
+## Reviewed MMD Ore Sources
+
+OreSpawn ships a deliberately narrow compatibility catalog. BaseMetals,
+ModernMetals, BaseGems, BaseMinerals, FantasyMetals and Advantage-family ores
+may participate as ordinary interchangeable outputs when their material is
+unambiguous. NetherMetals and EndMetals stay in their respective dimension
+domains. DenseMetals is enrichment, not an interchangeable output, and
+BaseSciences has no ordinary ore-source role.
+
+Reviewed fresh-world conflicts start Balanced with all eligible output blocks.
+Mineralogy, then BaseMinerals, then ElectricAdvantage is the initial placement
+and Single-mode priority for sulfur; BaseMinerals, then ElectricAdvantage is the
+corresponding lithium priority. No other conflict is automatically consolidated
+without an explicit catalog row. Exact Ore Dictionary `oreX` inference recognizes the curated
+sulfur/sulphur and aluminum/aluminium aliases but deliberately keeps Niter and
+Saltpeter distinct. Multiple unrelated entries are marked for review rather
+than fuzzy-matched from block names.
+
+Provider definitions that are disabled for placement remain managed
+output-only candidates. This permits a sibling mod's block to participate in a
+Balanced, Single, or Custom material group without contributing a second
+frequency budget. Duplicate rule or alias references to the same registry ID
+and metadata are displayed once.
+
+Ore Dictionary membership does not prove that OreSpawn controls a mod's native
+generator. Such candidates are displayed as external generation and are never
+disabled. Providers should keep native generation enabled until takeover is
+active, then let their OreSpawn rules participate normally.
+
Existing worlds normally merge newly introduced provider rule IDs but do not
overwrite world edits. A Java provider may opt out with
`mergeNewEntriesIntoExistingWorlds(false)` when its rules capture structural
diff --git a/docs/examples/examplemod-orespawn.json b/docs/examples/examplemod-orespawn.json
index d850d2ef..6590127c 100644
--- a/docs/examples/examplemod-orespawn.json
+++ b/docs/examples/examplemod-orespawn.json
@@ -1,5 +1,5 @@
{
- "schema_version": 4,
+ "schema_version": 5,
"provider_modid": "examplemod",
"provider_revision": 1,
"rocks": {
@@ -32,6 +32,7 @@
"ores": {
"examplemod:ore/tin": {
"block": "examplemod:tin_ore",
+ "material": "orespawn:tin",
"outputs": [
{ "block": "examplemod:tin_ore", "weight": 90 },
{ "block": "examplemod:rich_tin_ore", "weight": 10, "min_y": 0, "max_y": 24 }
@@ -44,6 +45,7 @@
"min_y": 0,
"max_y": 96,
"frequency": 6.0,
+ "placement_channel": "orespawn:standard",
"quantity": 8,
"pattern": {
"type": "orespawn:clusters",
diff --git a/docs/examples/orespawn-global.json b/docs/examples/orespawn-global.json
index cabcfee1..a6b9f2d9 100644
--- a/docs/examples/orespawn-global.json
+++ b/docs/examples/orespawn-global.json
@@ -1,5 +1,5 @@
{
- "schema_version": 6,
+ "schema_version": 8,
"geology_mode": "geome",
"place_fluid_deposits": true,
"manage_vanilla_ores": false,
@@ -32,6 +32,17 @@
"biome_palettes": {},
"dimension_materials": {},
"ores": {},
+ "ore_material_groups": {
+ "orespawn:sulfur": {
+ "display_name": "Sulfur",
+ "ore_dictionary_entries": ["oreSulfur", "oreSulphur"]
+ },
+ "orespawn:aluminum": {
+ "display_name": "Aluminum",
+ "ore_dictionary_entries": ["oreAluminum", "oreAluminium"]
+ }
+ },
+ "ore_source_policies": {},
"fluid_deposits": {},
"retrogen": {
"enabled": false,
diff --git a/docs/examples/orespawn-world.json b/docs/examples/orespawn-world.json
index 93f51e30..f23002ca 100644
--- a/docs/examples/orespawn-world.json
+++ b/docs/examples/orespawn-world.json
@@ -1,5 +1,5 @@
{
- "schema_version": 5,
+ "schema_version": 7,
"geology_mode": "geome",
"place_fluid_deposits": true,
"manage_vanilla_ores": false,
@@ -70,6 +70,17 @@
"biome_palettes": {},
"dimension_materials": {},
"ores": {},
+ "ore_material_groups": {
+ "orespawn:sulfur": {
+ "display_name": "Sulfur",
+ "ore_dictionary_entries": ["oreSulfur", "oreSulphur"]
+ },
+ "orespawn:aluminum": {
+ "display_name": "Aluminum",
+ "ore_dictionary_entries": ["oreAluminum", "oreAluminium"]
+ }
+ },
+ "ore_source_policies": {},
"fluid_deposits": {},
"retrogen": {
"enabled": false,
diff --git a/docs/schemas/orespawn-global.schema.json b/docs/schemas/orespawn-global.schema.json
index e20e5774..49022e13 100644
--- a/docs/schemas/orespawn-global.schema.json
+++ b/docs/schemas/orespawn-global.schema.json
@@ -6,7 +6,7 @@
{
"type": "object",
"required": ["schema_version"],
- "properties": { "schema_version": { "const": 6 } }
+ "properties": { "schema_version": { "const": 8 } }
}
]
}
diff --git a/docs/schemas/orespawn-provider.schema.json b/docs/schemas/orespawn-provider.schema.json
index 8fa0f191..41739283 100644
--- a/docs/schemas/orespawn-provider.schema.json
+++ b/docs/schemas/orespawn-provider.schema.json
@@ -4,7 +4,7 @@
"type": "object",
"required": ["schema_version", "provider_modid", "provider_revision"],
"properties": {
- "schema_version": { "enum": [1, 2, 3, 4] },
+ "schema_version": { "enum": [1, 2, 3, 4, 5] },
"provider_modid": { "type": "string", "pattern": "^[a-z][a-z0-9_.-]+$" },
"provider_revision": { "type": "integer", "minimum": 1 },
"merge_new_entries_into_existing_worlds": { "type": "boolean" },
diff --git a/docs/schemas/orespawn-world.schema.json b/docs/schemas/orespawn-world.schema.json
index f23b7970..497cc4a0 100644
--- a/docs/schemas/orespawn-world.schema.json
+++ b/docs/schemas/orespawn-world.schema.json
@@ -6,7 +6,7 @@
{
"type": "object",
"required": ["schema_version"],
- "properties": { "schema_version": { "const": 5 } }
+ "properties": { "schema_version": { "const": 7 } }
}
]
}
diff --git a/docs/schemas/profile-common.schema.json b/docs/schemas/profile-common.schema.json
index d643ded5..9584dad2 100644
--- a/docs/schemas/profile-common.schema.json
+++ b/docs/schemas/profile-common.schema.json
@@ -2,7 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://mcmoddev.com/orespawn/schemas/profile-common.schema.json",
"type": "object",
- "required": ["formations", "rocks", "geomes", "biomes", "terrain_dimensions", "biome_palettes", "dimension_materials", "ores", "fluid_deposits"],
+ "required": ["formations", "rocks", "geomes", "biomes", "terrain_dimensions", "biome_palettes", "dimension_materials", "ores", "ore_material_groups", "ore_source_policies", "fluid_deposits"],
"properties": {
"geology_mode": { "enum": ["geome", "legacy"] },
"place_fluid_deposits": { "type": "boolean" },
@@ -19,6 +19,8 @@
"biome_palettes": { "type": "object", "additionalProperties": { "$ref": "#/$defs/biomePalette" } },
"dimension_materials": { "type": "object", "additionalProperties": { "$ref": "#/$defs/dimensionMaterials" } },
"ores": { "type": "object", "additionalProperties": { "$ref": "#/$defs/ore" } },
+ "ore_material_groups": { "type": "object", "propertyNames": { "pattern": "^[a-z0-9_.-]+:[a-z0-9_./-]+$" }, "additionalProperties": { "$ref": "#/$defs/oreMaterialGroup" } },
+ "ore_source_policies": { "type": "object", "additionalProperties": { "$ref": "#/$defs/oreSourcePolicy" } },
"fluid_deposits": { "type": "object", "additionalProperties": { "$ref": "#/$defs/fluidDeposit" } },
"worldgen_aliases": { "type": "object", "additionalProperties": { "$ref": "#/$defs/registryId" } },
"providers": { "type": "object" },
@@ -153,6 +155,7 @@
"minimum": 0.0,
"maximum": 1.0
},
+ "placement_channel": { "$ref": "#/$defs/registryId" },
"spread": { "type": "integer", "minimum": 0, "maximum": 64 },
"vertical_spread": { "type": "integer", "minimum": 0, "maximum": 64 },
"node_size": { "type": "integer", "minimum": 1, "maximum": 32 },
@@ -189,6 +192,7 @@
"required": ["enabled"],
"properties": {
"block": { "$ref": "#/$defs/registryId" },
+ "material": { "$ref": "#/$defs/registryId" },
"metadata": { "$ref": "#/$defs/metadata" },
"enabled": { "type": "boolean" },
"source_mod": { "type": "string" },
@@ -228,6 +232,55 @@
],
"additionalProperties": true
},
+ "oreSourceCandidate": {
+ "type": "object",
+ "required": ["source_id", "owner", "registry_id", "material", "domain", "placement_channel", "loaded", "external"],
+ "properties": {
+ "source_id": { "type": "string", "minLength": 1 },
+ "owner": { "type": "string", "minLength": 1 },
+ "owner_name": { "type": "string" },
+ "owner_version": { "type": "string" },
+ "registry_id": { "$ref": "#/$defs/registryId" },
+ "metadata": { "$ref": "#/$defs/metadata" },
+ "material": { "$ref": "#/$defs/registryId" },
+ "domain": { "$ref": "#/$defs/registryId" },
+ "placement_channel": { "$ref": "#/$defs/registryId" },
+ "ore_dictionary": { "type": "array", "uniqueItems": true, "items": { "type": "string", "minLength": 1 } },
+ "loaded": { "type": "boolean" },
+ "placement_active": { "type": "boolean" },
+ "external": { "type": "boolean" },
+ "enrichment": { "type": "boolean" },
+ "review_required": { "type": "boolean" },
+ "material_declared": { "type": "boolean" }
+ },
+ "additionalProperties": false
+ },
+ "oreSourcePolicy": {
+ "type": "object",
+ "required": ["material", "domain", "mode", "output_mode", "outputs", "placement_sources", "candidates"],
+ "properties": {
+ "material": { "$ref": "#/$defs/registryId" },
+ "domain": { "$ref": "#/$defs/registryId" },
+ "mode": { "enum": ["consolidated", "keep_separate"] },
+ "output_mode": { "enum": ["balanced", "single", "custom"] },
+ "outputs": { "type": "object", "additionalProperties": { "type": "number", "exclusiveMinimum": 0 } },
+ "placement_sources": { "type": "object", "propertyNames": { "pattern": "^[a-z0-9_.-]+:[a-z0-9_./-]+$" }, "additionalProperties": { "type": "string", "minLength": 1 } },
+ "candidates": { "type": "array", "items": { "$ref": "#/$defs/oreSourceCandidate" } },
+ "review_required": { "type": "boolean" },
+ "status": { "enum": ["consolidated", "separate", "review_required", "missing_source", "external_generation"] },
+ "dormant": { "type": "boolean" }
+ },
+ "additionalProperties": false
+ },
+ "oreMaterialGroup": {
+ "type": "object",
+ "required": ["display_name", "ore_dictionary_entries"],
+ "properties": {
+ "display_name": { "type": "string", "minLength": 1, "maxLength": 64 },
+ "ore_dictionary_entries": { "type": "array", "uniqueItems": true, "items": { "type": "string", "pattern": "^ore[A-Z][A-Za-z0-9_]*$", "maxLength": 128 } }
+ },
+ "additionalProperties": false
+ },
"fluidDepositDimension": {
"type": "object",
"required": ["enabled", "min_y", "max_y", "frequency", "min_radius", "max_radius", "min_vertical_radius", "max_vertical_radius", "max_lobes", "min_solid_cover"],
diff --git a/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/client/OreSourceEditorProbeBridge.java b/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/client/OreSourceEditorProbeBridge.java
new file mode 100644
index 00000000..5fc41e6f
--- /dev/null
+++ b/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/client/OreSourceEditorProbeBridge.java
@@ -0,0 +1,116 @@
+package zone.moddev.mc.orespawn.client;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import com.google.gson.JsonObject;
+
+import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfile;
+
+/** Test-fixture bridge that drives the real pending editor model on a server. */
+public final class OreSourceEditorProbeBridge {
+ private static final String DOMAIN = "minecraft:overworld";
+ private static final String CHANNEL = "orespawn:standard";
+
+ private OreSourceEditorProbeBridge() { }
+
+ public static JsonObject configure(JsonObject root) {
+ WorldGeologyProfile profile = WorldGeologyProfile.fromJson(root,
+ WorldGeologyProfile.recommended(false));
+ GeologyEditorSession session = new GeologyEditorSession(profile);
+
+ configureBalanced(session, "surfaceprobe:balanced", "surfaceprobe:balanced_a");
+ configureSingle(session, "surfaceprobe:single", "surfaceprobe:single_b",
+ "surfaceprobe:single_a");
+ configureCustom(session, "surfaceprobe:custom", "surfaceprobe:custom_a",
+ "surfaceprobe:custom_b");
+ configureBalanced(session, "surfaceprobe:body", "surfaceprobe:body_a");
+
+ GeologyEditorSession.OreSourceGroup original = group(session, "surfaceprobe:original");
+ session.acceptOreSourcePolicy(original.key);
+ assertGroup(group(session, "surfaceprobe:original"), "keep_separate", 2,
+ map("surfaceprobe:original_a", 1.0D, "surfaceprobe:original_b", 1.0D));
+ return session.profile().rootCopy();
+ }
+
+ public static JsonObject reset(JsonObject root) {
+ WorldGeologyProfile profile = WorldGeologyProfile.fromJson(root,
+ WorldGeologyProfile.recommended(false));
+ GeologyEditorSession session = new GeologyEditorSession(profile);
+ session.resetOreSourcesToDefaults();
+ for (String material : new String[] { "surfaceprobe:balanced", "surfaceprobe:single",
+ "surfaceprobe:custom", "surfaceprobe:original", "surfaceprobe:body" }) {
+ GeologyEditorSession.OreSourceGroup group = group(session, material);
+ if (!"keep_separate".equals(group.mode) || group.outputCandidates().size() != 2) {
+ throw new IllegalStateException("Reset All did not restore Keep Original for "
+ + material + ": mode=" + group.mode + ", candidates="
+ + group.outputCandidates().size());
+ }
+ }
+ return session.profile().rootCopy();
+ }
+
+ private static void configureBalanced(GeologyEditorSession session, String material,
+ String placement) {
+ GeologyEditorSession.OreSourceGroup group = group(session, material);
+ session.setOreSourceOutputMode(group.key, "balanced");
+ session.setOreSourcePlacement(group.key, CHANNEL, placement);
+ assertGroup(group(session, material), "consolidated", 2,
+ map(material + "_a", 1.0D, material + "_b", 1.0D));
+ }
+
+ private static void configureSingle(GeologyEditorSession session, String material,
+ String selected, String placement) {
+ GeologyEditorSession.OreSourceGroup group = group(session, material);
+ session.setOreSourceOutputMode(group.key, "single");
+ session.setOreSourceOutput(group.key, selected, true, 1.0D);
+ session.setOreSourcePlacement(group.key, CHANNEL, placement);
+ assertGroup(group(session, material), "consolidated", 1,
+ map(selected, 1.0D));
+ }
+
+ private static void configureCustom(GeologyEditorSession session, String material,
+ String first, String second) {
+ GeologyEditorSession.OreSourceGroup group = group(session, material);
+ session.setOreSourceOutputMode(group.key, "custom");
+ session.setOreSourceOutput(group.key, first, true, 1.0D);
+ session.setOreSourceOutput(group.key, second, true, 3.0D);
+ session.setOreSourcePlacement(group.key, CHANNEL, first);
+ assertGroup(group(session, material), "consolidated", 2,
+ map(first, 1.0D, second, 3.0D));
+ }
+
+ private static void assertGroup(GeologyEditorSession.OreSourceGroup group, String mode,
+ int outputCount, Map outputs) {
+ if (!mode.equals(group.mode) || group.outputs.size() != outputCount
+ || !outputs.equals(group.outputs)) {
+ throw new IllegalStateException("Unexpected editor policy for " + group.material
+ + ": mode=" + group.mode + ", outputs=" + group.outputs);
+ }
+ if ("consolidated".equals(mode)
+ && !group.placements.containsKey(CHANNEL)) {
+ throw new IllegalStateException("Missing Standard veins placement for " + group.material);
+ }
+ }
+
+ private static GeologyEditorSession.OreSourceGroup group(GeologyEditorSession session,
+ String material) {
+ for (GeologyEditorSession.OreSourceGroup group : session.oreSourceGroups()) {
+ if (material.equals(group.material) && DOMAIN.equals(group.domain)) return group;
+ }
+ throw new IllegalStateException("Missing editor material group " + material);
+ }
+
+ private static Map map(String first, double firstWeight,
+ String second, double secondWeight) {
+ Map result = map(first, firstWeight);
+ result.put(second, Double.valueOf(secondWeight));
+ return result;
+ }
+
+ private static Map map(String source, double weight) {
+ Map result = new LinkedHashMap<>();
+ result.put(source, Double.valueOf(weight));
+ return result;
+ }
+}
diff --git a/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java b/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java
index b7776826..3f404af6 100644
--- a/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java
+++ b/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java
@@ -5,9 +5,16 @@
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
import java.util.LinkedHashMap;
+import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Random;
@@ -22,6 +29,7 @@
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
+import com.google.gson.JsonPrimitive;
import zone.moddev.mc.orespawn.api.BiomePlacementMode;
import zone.moddev.mc.orespawn.api.BiomeRegionSize;
@@ -40,6 +48,7 @@
import zone.moddev.mc.orespawn.api.WorldgenProvider;
import zone.moddev.mc.orespawn.api.WorldgenProvider.BiomeSurfaceDefinition;
import zone.moddev.mc.orespawn.api.WorldgenProvider.TerrainDimensionDefinition;
+import zone.moddev.mc.orespawn.client.OreSourceEditorProbeBridge;
import zone.moddev.mc.orespawn.worldgen.SurfaceProbeSpringBridge;
import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfileManager;
@@ -93,7 +102,16 @@ public final class SurfaceProbeTestMod {
private static final ResourceLocation BIOME_B = new ResourceLocation(MODID, "surface_b");
private static final ResourceLocation PROBE_GEOME = new ResourceLocation(MODID, "exact_biome");
private static final ResourceLocation SPRING_ROCK = new ResourceLocation(MODID, "rock/spring_host");
+ private static final ResourceLocation STANDARD_CHANNEL = new ResourceLocation("orespawn", "standard");
+ private static final long BODY_IDENTITY = 0x51A7F00D1234ABCDL;
+ private static final int ORE_MIN_CHUNK = 80;
+ private static final int ORE_MAX_CHUNK = 84;
+ private static final int RESET_MIN_CHUNK = 88;
+ private static final int RESET_MAX_CHUNK = 92;
private static final ProbeLiquid DEPOSIT_FLUID = new ProbeLiquid();
+ private static final Map SOURCE_BLOCKS = sourceBlocks();
+ private static final Map SOURCE_PATTERNS = sourcePatterns();
+ private static final List ORE_INVOCATIONS = new ArrayList<>();
private static final BlockPos SPRING_POS = new BlockPos(1128, 32, 1128);
private static final IBlockState[] NATURAL_SOURCES = {
Blocks.DIRT.getStateFromMeta(0), Blocks.GRASS.getDefaultState(),
@@ -158,6 +176,67 @@ public final class SurfaceProbeTestMod {
})
.setRegistryName(MODID, "external_probe");
+ private static Map sourceBlocks() {
+ Map result = new LinkedHashMap<>();
+ for (String group : Arrays.asList("balanced", "single", "custom", "original", "body")) {
+ result.put(group, new Block[] {
+ probeBlock(group + "_a"), probeBlock(group + "_b")
+ });
+ }
+ return result;
+ }
+
+ private static Block probeBlock(String name) {
+ return new Block(net.minecraft.block.material.Material.ROCK).setRegistryName(MODID, name)
+ .setTranslationKey(MODID + "." + name);
+ }
+
+ private static Map sourcePatterns() {
+ Map result = new LinkedHashMap<>();
+ for (String group : Arrays.asList("balanced", "single", "custom", "original", "body")) {
+ result.put(group, sourcePattern(group, "body".equals(group)));
+ }
+ return result;
+ }
+
+ private static OrePatternType sourcePattern(String group, boolean bodyIdentity) {
+ return OrePatternType.create(StandardPatternSettings.CODEC, settings -> context -> {
+ if (!(context instanceof OreGenerationContext)) {
+ throw new IllegalStateException("Ore source probe did not receive OreGenerationContext");
+ }
+ OreGenerationContext generation = (OreGenerationContext) context;
+ List placed = new ArrayList<>();
+ int height = generation.maxY() - generation.minY() + 1;
+ boolean changed = false;
+ for (int index = 0; index < generation.quantity(); index++) {
+ int y = generation.minY() + Math.floorMod(
+ generation.originY() - generation.minY() + index, height);
+ BlockPos position = new BlockPos(generation.originX(), y, generation.originZ());
+ boolean accepted = bodyIdentity
+ ? generation.tryPlace(position.getX(), position.getY(), position.getZ(), BODY_IDENTITY)
+ : generation.tryPlace(position.getX(), position.getY(), position.getZ());
+ if (accepted) placed.add(position);
+ changed |= accepted;
+ }
+ if (insideOreAudit(generation.chunkX(), generation.chunkZ())) {
+ synchronized (ORE_INVOCATIONS) {
+ ORE_INVOCATIONS.add(new OreInvocation(group, generation.chunkX(),
+ generation.chunkZ(), placed));
+ }
+ }
+ return changed;
+ }).setRegistryName(MODID, group + "_source_probe");
+ }
+
+ private static boolean insideOreAudit(int chunkX, int chunkZ) {
+ return insideSquare(chunkX, chunkZ, ORE_MIN_CHUNK, ORE_MAX_CHUNK)
+ || insideSquare(chunkX, chunkZ, RESET_MIN_CHUNK, RESET_MAX_CHUNK);
+ }
+
+ private static boolean insideSquare(int chunkX, int chunkZ, int minimum, int maximum) {
+ return chunkX >= minimum && chunkX <= maximum && chunkZ >= minimum && chunkZ <= maximum;
+ }
+
private final BiomeRegistrar registrar = OreSpawnBiomes.registrar(MODID);
private final BiomeReference surfaceA = OreSpawnBiomes.blankAndRegister(registrar,
"surface_a", properties -> configure(properties, 1.35F, 0.15F));
@@ -187,11 +266,13 @@ public void registerBiomes(RegistryEvent.Register event) {
@SubscribeEvent
public void registerBlocks(RegistryEvent.Register event) {
event.getRegistry().register(DEPOSIT_FLUID);
+ for (Block[] blocks : SOURCE_BLOCKS.values()) event.getRegistry().registerAll(blocks);
}
@SubscribeEvent
public void registerPatterns(RegistryEvent.Register event) {
event.getRegistry().register(EXTERNAL_PATTERN);
+ event.getRegistry().registerAll(SOURCE_PATTERNS.values().toArray(new OrePatternType[0]));
}
@EventHandler
@@ -244,6 +325,7 @@ public void init(FMLInitializationEvent event) {
dimension -> dimension.yRange(16, 48).attempts(1.0D).quantity(1)
.pattern(new ResourceLocation(MODID, "external_probe"), new JsonObject())
.hostBlock(id(Blocks.STONE))));
+ addOreSourceRules(provider);
// This stable palette id makes seed zero select both fixture biomes on
// opposite sides of the 1,024-block Tiny-region boundary.
addPalette(provider, "end_palette_1", END, false);
@@ -256,6 +338,26 @@ public void init(FMLInitializationEvent event) {
}
}
+ private static void addOreSourceRules(WorldgenProvider.Builder provider) {
+ int minimumY = 8;
+ for (Map.Entry entry : SOURCE_BLOCKS.entrySet()) {
+ String group = entry.getKey();
+ ResourceLocation material = new ResourceLocation(MODID, group);
+ ResourceLocation pattern = SOURCE_PATTERNS.get(group).getRegistryName();
+ for (int index = 0; index < entry.getValue().length; index++) {
+ String suffix = index == 0 ? "a" : "b";
+ ResourceLocation rule = new ResourceLocation(MODID, group + "_" + suffix);
+ Block output = entry.getValue()[index];
+ int bandMin = minimumY;
+ provider.ore(rule, id(output), ore -> ore.material(material).retrogen(false)
+ .dimension(OVERWORLD, dimension -> dimension.yRange(bandMin, bandMin + 7)
+ .attempts(1.0D).quantity(3).pattern(pattern, new JsonObject())
+ .placementChannel(STANDARD_CHANNEL).hostBlock(id(Blocks.STONE))));
+ }
+ minimumY += 12;
+ }
+ }
+
private static void addGeology(WorldgenProvider.Builder provider) {
provider.geome(PROBE_GEOME, geome -> geome.baseWeight(0.0D)
.familyWeight(GeologyFamily.SEDIMENTARY, 1.0D));
@@ -288,6 +390,7 @@ private static void addGeology(WorldgenProvider.Builder provider) {
@EventHandler
public void serverAboutToStart(FMLServerAboutToStartEvent event) {
+ String phase = System.getProperty(PHASE_PROPERTY, "").trim();
Path profile = worldRoot(event.getServer()).resolve("serverconfig")
.resolve("orespawn-worldgen.json");
JsonObject root;
@@ -302,8 +405,10 @@ public void serverAboutToStart(FMLServerAboutToStartEvent event) {
JsonObject end = terrain.getAsJsonObject(END.toString());
JsonArray hosts = end.getAsJsonArray("host_blocks");
for (Block block : Arrays.asList(Blocks.AIR, Blocks.WATER, Blocks.BEDROCK, Blocks.CHEST)) {
- hosts.add(id(block).toString());
+ addUnique(hosts, id(block).toString());
}
+ if ("fresh".equals(phase)) root = OreSourceEditorProbeBridge.configure(root);
+ assertPersistedOreSourceModes(root, false);
try (BufferedWriter writer = Files.newBufferedWriter(profile)) {
new GsonBuilder().setPrettyPrinting().create().toJson(root, writer);
}
@@ -315,6 +420,13 @@ public void serverAboutToStart(FMLServerAboutToStartEvent event) {
}
}
+ private static void addUnique(JsonArray values, String value) {
+ for (int index = 0; index < values.size(); index++) {
+ if (value.equals(values.get(index).getAsString())) return;
+ }
+ values.add(new JsonPrimitive(value));
+ }
+
private static void addPalette(WorldgenProvider.Builder provider, String name,
ResourceLocation dimension, boolean ceiling) {
BiomeSurfaceDefinition a = surface(Blocks.EMERALD_BLOCK, Blocks.QUARTZ_BLOCK,
@@ -370,6 +482,14 @@ public void serverStarted(FMLServerStartedEvent event) {
throw new IllegalStateException("Fresh surfaceprobe retained an old marker");
}
Map results = new LinkedHashMap<>();
+ OreSourceAudit oreSources;
+ if ("fresh".equals(phase)) {
+ clearOreInvocations();
+ generateSquare(overworld, ORE_MIN_CHUNK, ORE_MAX_CHUNK, false);
+ oreSources = auditOreSources(overworld, ORE_MIN_CHUNK, ORE_MAX_CHUNK, false);
+ } else {
+ oreSources = scanOreSources(overworld, ORE_MIN_CHUNK, ORE_MAX_CHUNK);
+ }
results.put("end", audit(requireWorld(server, 1), false));
results.put("nether", audit(requireWorld(server, -1), true));
ResourceLocation spring = auditSpring(overworld, phase);
@@ -382,6 +502,9 @@ public void serverStarted(FMLServerStartedEvent event) {
+ dynamicFluidPlacements);
}
Properties current = properties(overworld.getSeed(), results, spring);
+ oreSources.write(current, "ore_sources.");
+ current.setProperty("ore_sources.profile_sha256", sha256(worldRoot(server)
+ .resolve("serverconfig").resolve("orespawn-worldgen.json")));
current.setProperty("dynamic_fluid_placements", "fresh".equals(phase)
? Integer.toString(dynamicFluidPlacements)
: previous.getProperty("dynamic_fluid_placements"));
@@ -394,6 +517,13 @@ public void serverStarted(FMLServerStartedEvent event) {
+ previous.getProperty(key) + " but found " + current.getProperty(key));
}
}
+ resetOreSources(server);
+ clearOreInvocations();
+ generateSquare(overworld, RESET_MIN_CHUNK, RESET_MAX_CHUNK, true);
+ OreSourceAudit reset = auditOreSources(overworld,
+ RESET_MIN_CHUNK, RESET_MAX_CHUNK, true);
+ reset.write(previous, "ore_sources.reset.");
+ previous.setProperty("ore_sources_reset_verified", "true");
previous.setProperty("reload_verified", "true");
write(marker, previous);
}
@@ -402,6 +532,206 @@ public void serverStarted(FMLServerStartedEvent event) {
server.initiateShutdown();
}
+ private static void assertPersistedOreSourceModes(JsonObject root, boolean reset) {
+ JsonObject policies = root.getAsJsonObject("ore_source_policies");
+ if (policies == null) throw new IllegalStateException("Missing persisted ore-source policies");
+ for (String group : SOURCE_BLOCKS.keySet()) {
+ String key = MODID + ":" + group + "|minecraft:overworld";
+ if (!policies.has(key) || !policies.get(key).isJsonObject()) {
+ throw new IllegalStateException("Missing persisted ore-source policy " + key);
+ }
+ JsonObject policy = policies.getAsJsonObject(key);
+ String expected = reset || "original".equals(group) ? "keep_separate" : "consolidated";
+ if (!expected.equals(policy.get("mode").getAsString())) {
+ throw new IllegalStateException("Unexpected persisted mode for " + group + ": " + policy);
+ }
+ if (!reset && "single".equals(group)) {
+ JsonObject outputs = policy.getAsJsonObject("outputs");
+ if (outputs.entrySet().size() != 1 || !outputs.has(MODID + ":single_b")) {
+ throw new IllegalStateException("Single output did not survive persistence: " + outputs);
+ }
+ }
+ if (!reset && "custom".equals(group)) {
+ JsonObject outputs = policy.getAsJsonObject("outputs");
+ if (outputs.get(MODID + ":custom_a").getAsDouble() != 1.0D
+ || outputs.get(MODID + ":custom_b").getAsDouble() != 3.0D) {
+ throw new IllegalStateException("Custom weights did not survive persistence: " + outputs);
+ }
+ }
+ }
+ }
+
+ private static void resetOreSources(MinecraftServer server) {
+ Path profile = worldRoot(server).resolve("serverconfig").resolve("orespawn-worldgen.json");
+ JsonObject reset = OreSourceEditorProbeBridge.reset(
+ WorldGeologyProfileManager.activeProfile().rootCopy());
+ assertPersistedOreSourceModes(reset, true);
+ try (BufferedWriter writer = Files.newBufferedWriter(profile)) {
+ new GsonBuilder().setPrettyPrinting().create().toJson(reset, writer);
+ } catch (IOException exception) {
+ throw new IllegalStateException("Could not persist Reset All ore-source profile", exception);
+ }
+ if (!WorldGeologyProfileManager.reloadActiveProfile()) {
+ throw new IllegalStateException("Could not reload Reset All ore-source profile");
+ }
+ assertPersistedOreSourceModes(WorldGeologyProfileManager.activeProfile().rootCopy(), true);
+ }
+
+ private static void generateSquare(WorldServer world, int minimum, int maximum, boolean reverse) {
+ if (reverse) {
+ for (int z = maximum + 1; z >= minimum - 1; z--) {
+ for (int x = maximum + 1; x >= minimum - 1; x--) world.getChunkProvider().provideChunk(x, z);
+ }
+ } else {
+ for (int z = minimum - 1; z <= maximum + 1; z++) {
+ for (int x = minimum - 1; x <= maximum + 1; x++) world.getChunkProvider().provideChunk(x, z);
+ }
+ }
+ }
+
+ private static void clearOreInvocations() {
+ synchronized (ORE_INVOCATIONS) { ORE_INVOCATIONS.clear(); }
+ }
+
+ private static OreSourceAudit auditOreSources(WorldServer world, int minimum, int maximum,
+ boolean reset) {
+ Map> byGroup = new HashMap<>();
+ synchronized (ORE_INVOCATIONS) {
+ for (OreInvocation invocation : ORE_INVOCATIONS) {
+ if (insideSquare(invocation.chunkX, invocation.chunkZ, minimum, maximum)) {
+ byGroup.computeIfAbsent(invocation.group, ignored -> new ArrayList<>()).add(invocation);
+ }
+ }
+ }
+ int chunks = (maximum - minimum + 1) * (maximum - minimum + 1);
+ for (String group : SOURCE_BLOCKS.keySet()) {
+ List invocations = byGroup.getOrDefault(group, Collections.emptyList());
+ int expected = reset || "original".equals(group) ? chunks * 2 : chunks;
+ if (invocations.size() != expected) {
+ throw new IllegalStateException("Expected " + expected + " placement budgets for "
+ + group + " but recorded " + invocations.size());
+ }
+ Set observed = new LinkedHashSet<>();
+ Map selectedInvocations = new HashMap<>();
+ int nonEmpty = 0;
+ for (OreInvocation invocation : invocations) {
+ Block invocationOutput = null;
+ for (BlockPos position : invocation.positions) {
+ Block output = world.getBlockState(position).getBlock();
+ if (!contains(SOURCE_BLOCKS.get(group), output)) {
+ throw new IllegalStateException("Unexpected output " + output + " for " + group
+ + " at " + position);
+ }
+ if (invocationOutput != null && invocationOutput != output) {
+ throw new IllegalStateException("One ore body mixed outputs for " + group
+ + " in chunk " + invocation.chunkX + "," + invocation.chunkZ);
+ }
+ invocationOutput = output;
+ observed.add(output);
+ }
+ if (invocationOutput != null) {
+ nonEmpty++;
+ selectedInvocations.put(invocationOutput,
+ selectedInvocations.getOrDefault(invocationOutput, 0) + 1);
+ }
+ }
+ if (nonEmpty == 0) {
+ throw new IllegalStateException("Too few successful ore-source attempts for " + group
+ + ": " + nonEmpty + "/" + invocations.size());
+ }
+ Block[] outputs = SOURCE_BLOCKS.get(group);
+ if (!reset && "single".equals(group)
+ && (!observed.equals(Collections.singleton(outputs[1])))) {
+ throw new IllegalStateException("Single mode placed unexpected outputs: " + observed);
+ }
+ if (!reset && "body".equals(group) && observed.size() != 1) {
+ throw new IllegalStateException("Stable body identity did not retain one output: " + observed);
+ }
+ if ((reset || (!"single".equals(group) && !"body".equals(group))) && observed.size() != 2) {
+ throw new IllegalStateException(group + " did not exercise both selected outputs: " + observed);
+ }
+ if (!reset && "custom".equals(group)
+ && selectedInvocations.getOrDefault(outputs[1], 0)
+ <= selectedInvocations.getOrDefault(outputs[0], 0)) {
+ throw new IllegalStateException("Custom 1:3 weights were not reflected in deterministic "
+ + "whole-vein selections: " + selectedInvocations);
+ }
+ }
+ if (!reset) assertBodyIdentity(world, byGroup.get("body"));
+ return scanOreSources(world, minimum, maximum);
+ }
+
+ private static void assertBodyIdentity(WorldServer world, List invocations) {
+ Block selected = null;
+ for (OreInvocation invocation : invocations) {
+ for (BlockPos position : invocation.positions) {
+ Block output = world.getBlockState(position).getBlock();
+ if (selected == null) selected = output;
+ else if (selected != output) {
+ throw new IllegalStateException("Stable body identity selected different outputs across chunks");
+ }
+ }
+ }
+ if (selected == null) throw new IllegalStateException("Stable body identity placed no output");
+ }
+
+ private static boolean contains(Block[] values, Block value) {
+ for (Block candidate : values) if (candidate == value) return true;
+ return false;
+ }
+
+ private static OreSourceAudit scanOreSources(WorldServer world, int minimum, int maximum) {
+ List positions = new ArrayList<>();
+ Map counts = new LinkedHashMap<>();
+ BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos();
+ for (int chunkZ = minimum; chunkZ <= maximum; chunkZ++) {
+ for (int chunkX = minimum; chunkX <= maximum; chunkX++) {
+ Chunk chunk = world.getChunkProvider().provideChunk(chunkX, chunkZ);
+ for (int localZ = 0; localZ < 16; localZ++) {
+ for (int localX = 0; localX < 16; localX++) {
+ for (int y = 0; y <= 80; y++) {
+ int x = (chunkX << 4) + localX;
+ int z = (chunkZ << 4) + localZ;
+ Block block = chunk.getBlockState(cursor.setPos(x, y, z)).getBlock();
+ String identity = sourceIdentity(block);
+ if (identity == null) continue;
+ positions.add(identity + "@" + x + "," + y + "," + z);
+ counts.put(identity, Integer.valueOf(counts.getOrDefault(identity, 0) + 1));
+ }
+ }
+ }
+ }
+ }
+ positions.sort(Comparator.naturalOrder());
+ if (positions.isEmpty()) throw new IllegalStateException("Ore-source audit found no probe blocks");
+ return new OreSourceAudit(hash(String.join("\n", positions).getBytes(StandardCharsets.UTF_8)), counts);
+ }
+
+ private static String sourceIdentity(Block block) {
+ for (Map.Entry entry : SOURCE_BLOCKS.entrySet()) {
+ for (int index = 0; index < entry.getValue().length; index++) {
+ if (entry.getValue()[index] == block) return entry.getKey() + (index == 0 ? "_a" : "_b");
+ }
+ }
+ return null;
+ }
+
+ private static String sha256(Path path) {
+ try { return hash(Files.readAllBytes(path)); }
+ catch (IOException exception) { throw new IllegalStateException("Could not hash " + path, exception); }
+ }
+
+ private static String hash(byte[] bytes) {
+ try {
+ byte[] digest = MessageDigest.getInstance("SHA-256").digest(bytes);
+ StringBuilder result = new StringBuilder();
+ for (byte value : digest) result.append(String.format("%02X", value & 0xFF));
+ return result.toString();
+ } catch (NoSuchAlgorithmException exception) {
+ throw new IllegalStateException(exception);
+ }
+ }
+
private static WorldServer requireWorld(MinecraftServer server, int dimension) {
WorldServer world = server.getWorld(dimension);
if (world == null) {
@@ -853,6 +1183,43 @@ public void decorate(World world, Random random, Biome biome, BlockPos pos) {
}
}
+ private static final class OreInvocation {
+ final String group;
+ final int chunkX, chunkZ;
+ final List positions;
+
+ OreInvocation(String group, int chunkX, int chunkZ, List positions) {
+ this.group = group;
+ this.chunkX = chunkX;
+ this.chunkZ = chunkZ;
+ this.positions = Collections.unmodifiableList(new ArrayList<>(positions));
+ }
+ }
+
+ private static final class OreSourceAudit {
+ final String coordinateHash;
+ final Map counts;
+
+ OreSourceAudit(String coordinateHash, Map counts) {
+ this.coordinateHash = coordinateHash;
+ this.counts = Collections.unmodifiableMap(new LinkedHashMap<>(counts));
+ }
+
+ void write(Properties properties, String prefix) {
+ properties.setProperty(prefix + "coordinate_sha256", coordinateHash);
+ int total = 0;
+ for (String group : SOURCE_BLOCKS.keySet()) {
+ for (String suffix : Arrays.asList("a", "b")) {
+ String identity = group + "_" + suffix;
+ int count = counts.getOrDefault(identity, Integer.valueOf(0)).intValue();
+ properties.setProperty(prefix + identity, Integer.toString(count));
+ total += count;
+ }
+ }
+ properties.setProperty(prefix + "total", Integer.toString(total));
+ }
+ }
+
private static final class Audit {
final long dry, wet, filler, geology, ceiling, roof;
final int biomeA, biomeB, edges, sentinels;
diff --git a/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java
index d47da8db..8329d20e 100644
--- a/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java
+++ b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java
@@ -3,6 +3,7 @@
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
+import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
@@ -20,6 +21,7 @@
import net.minecraft.client.gui.GuiCreateWorld;
import net.minecraft.client.gui.GuiMainMenu;
import net.minecraft.client.gui.GuiScreen;
+import net.minecraft.client.resources.I18n;
import net.minecraft.util.text.TextFormatting;
import net.minecraft.world.GameType;
import net.minecraft.world.WorldSettings;
@@ -31,6 +33,7 @@
import net.minecraftforge.fml.common.event.FMLInitializationEvent;
import net.minecraftforge.fml.common.eventhandler.SubscribeEvent;
import net.minecraftforge.fml.common.gameevent.TickEvent;
+import zone.moddev.mc.orespawn.util.JsonCopies;
import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfile;
/** Build-only client probe. It is compiled and packaged outside every release artifact. */
@@ -51,6 +54,10 @@ public final class ClientProbeTestMod {
private boolean modsDirectoryRendered;
private boolean directoryStackValidated =
System.getProperty("clientprobe.expectedMods", "").trim().isEmpty();
+ private boolean oreSourcesValidated =
+ System.getProperty("clientprobe.expectedMods", "").trim().isEmpty();
+ private boolean oreSourcesLayoutValidated =
+ System.getProperty("clientprobe.expectedMods", "").trim().isEmpty();
private boolean longEditorRoundTrip;
@Mod.EventHandler
@@ -154,7 +161,10 @@ public void onClientTick(TickEvent.ClientTickEvent event) {
}
break;
case 7:
- if (minecraft.world == null && !minecraft.isIntegratedServerRunning() && stateTicks >= 20) {
+ if (stateTicks >= 20) {
+ if (minecraft.world != null || minecraft.isIntegratedServerRunning()) {
+ minecraft.loadWorld(null);
+ }
minecraft.launchIntegratedServer(WORLD_DIRECTORY, "OreSpawn Client Smoke",
new WorldSettings(0L, GameType.CREATIVE, false, false, WorldType.DEFAULT));
nextState(8);
@@ -198,9 +208,15 @@ private void validateExpectedDirectory(GuiScreen root) {
if (entry.nativeOs4() || entry.legacyLineages().isEmpty()) {
throw new IllegalStateException("Base Metals did not retain its legacy lineage");
}
- } else if (!entry.nativeOs4() || entry.schemaVersion() != 4
+ } else if ("mineralogy".equals(entry.modId)) {
+ if (!entry.nativeOs4() || entry.schemaVersion() != 4 || entry.providerRevision() != 3) {
+ throw new IllegalStateException("Missing Mineralogy OS4 schema 4/provider revision 3: schema="
+ + entry.schemaVersion() + ", revision=" + entry.providerRevision());
+ }
+ } else if (!entry.nativeOs4() || entry.schemaVersion() != 5
|| entry.providerRevision() < 1) {
- throw new IllegalStateException("Missing native OS4 schema/revision for " + entry.modId);
+ throw new IllegalStateException("Missing native OS4 schema 5/revision for " + entry.modId
+ + ": schema=" + entry.schemaVersion() + ", revision=" + entry.providerRevision());
}
boolean shouldConfigure = "realisticdeposits".equals(entry.modId);
if (entry.configurable() != shouldConfigure) {
@@ -227,9 +243,386 @@ private void validateExpectedDirectory(GuiScreen root) {
}
}
}
+ if (expected.contains("mineralogy") && expected.contains("electricadvantage")) {
+ validateExpectedOreSources((OreSpawnWorldSettingsScreen) root);
+ }
directoryStackValidated = true;
}
+ private void validateExpectedOreSources(OreSpawnWorldSettingsScreen root) {
+ GeologyEditorSession session = editorSession(root);
+ GeologyEditorSession.OreSourceGroup sulfur = null;
+ for (GeologyEditorSession.OreSourceGroup group : session.oreSourceGroups()) {
+ if ("orespawn:sulfur".equals(group.material)
+ && "minecraft:overworld".equals(group.domain)) sulfur = group;
+ }
+ if (sulfur == null) throw new IllegalStateException("Missing curated Sulfur material group");
+ if (!"Sulfur".equals(sulfur.displayName)
+ || !sulfur.oreDictionaryEntries.contains("oreSulfur")
+ || !sulfur.oreDictionaryEntries.contains("oreSulphur")) {
+ throw new IllegalStateException("Sulfur aliases were not grouped correctly: "
+ + sulfur.oreDictionaryEntries);
+ }
+ if (!"consolidated".equals(sulfur.mode) || !"balanced".equals(sulfur.outputMode)) {
+ throw new IllegalStateException("Fresh Sulfur policy was not Balanced: mode="
+ + sulfur.mode + ", outputMode=" + sulfur.outputMode);
+ }
+ if (sulfur.needsAttention() || OreSourceListScreen.groupRowColor(sulfur) != 0xFFFF55) {
+ throw new IllegalStateException("Resolved Sulfur policy was not classified yellow: status="
+ + sulfur.status + ", attention=" + sulfur.needsAttention());
+ }
+ boolean mineralogy = false;
+ boolean electricOutputOnly = false;
+ for (GeologyEditorSession.OreSourceCandidate candidate : sulfur.outputCandidates()) {
+ if ("mineralogy:sulfur_ore".equals(candidate.registryId)) {
+ mineralogy = candidate.loaded && candidate.active && !candidate.external;
+ }
+ if ("electricadvantage:sulfur_ore".equals(candidate.registryId)) {
+ electricOutputOnly = candidate.loaded && !candidate.active && !candidate.external;
+ }
+ if (("mineralogy:sulfur_ore".equals(candidate.registryId)
+ || "electricadvantage:sulfur_ore".equals(candidate.registryId))
+ && !sulfur.outputs.containsKey(candidate.sourceId)) {
+ throw new IllegalStateException("Balanced Sulfur output is not selected: "
+ + candidate.sourceId);
+ }
+ }
+ if (!mineralogy || !electricOutputOnly) {
+ throw new IllegalStateException("Sulfur outputs were not classified correctly: mineralogy="
+ + mineralogy + ", electricOutputOnly=" + electricOutputOnly);
+ }
+ validateOreSourcesSingleScreen(root, session);
+ oreSourcesValidated = true;
+ }
+
+ private void validateOreSourcesSingleScreen(GuiScreen parent, GeologyEditorSession session) {
+ Minecraft minecraft = Minecraft.getMinecraft();
+ OreSourceListScreen screen = new OreSourceListScreen(parent, session);
+ screen.setWorldAndResolution(minecraft, 426, 265);
+ int contentWidth = OreSourceListScreen.contentWidth(426);
+ int left = (426 - contentWidth) / 2;
+ int right = left + OreSourceListScreen.leftPaneWidth(426) + 6;
+ boolean groupList = false;
+ boolean outputList = false;
+ boolean inlineAdministration = false;
+ boolean back = false;
+ boolean showAll = false;
+ int showAllWidth = 0;
+ for (GuiButton widget : screen.buttons) {
+ String caption = TextFormatting.getTextWithoutFormattingCodes(widget.displayString);
+ if (widget instanceof CompactScrollList && widget.x < right) groupList = true;
+ if (widget instanceof CompactScrollList && widget.x >= right) outputList = true;
+ if (widget instanceof TextFieldWidget && widget.x < right) inlineAdministration = true;
+ if ("Back".equals(caption)) back = true;
+ if (I18n.format("button.orespawn.show_all").equals(caption)) {
+ showAll = true;
+ showAllWidth = widget.width;
+ }
+ }
+ if (!groupList || !outputList || inlineAdministration || back || !showAll
+ || showAllWidth >= OreSourceListScreen.leftPaneWidth(426) / 2) {
+ throw new IllegalStateException("Ore Sources was not one compact two-pane screen: groups="
+ + groupList + ", outputs=" + outputList + ", inlineAdministration="
+ + inlineAdministration + ", back=" + back + ", showAll=" + showAll
+ + ", showAllWidth=" + showAllWidth
+ + ", left=" + left + ", right=" + right);
+ }
+
+ GeologyEditorSession.OreSourceGroup sulfurGroup = null;
+ for (GeologyEditorSession.OreSourceGroup group : session.oreSourceGroups()) {
+ if ("orespawn:sulfur".equals(group.material)) sulfurGroup = group;
+ }
+ if (sulfurGroup == null) throw new IllegalStateException("Sulfur group disappeared before settings test");
+ OreSourceGroupSettingsScreen settings = new OreSourceGroupSettingsScreen(
+ screen, session, sulfurGroup.key);
+ settings.setWorldAndResolution(minecraft, 426, 265);
+ int compactLists = 0;
+ boolean sulfurName = false;
+ int lowestListBottom = 0;
+ int aliasRows = 0;
+ int placementRows = 0;
+ for (GuiButton widget : settings.buttons) {
+ if (widget instanceof CompactScrollList) {
+ compactLists++;
+ lowestListBottom = Math.max(lowestListBottom, widget.y + widget.height);
+ if (widget.y < 100) aliasRows = CompactScrollList.visibleRows(widget.height, 16);
+ else placementRows = CompactScrollList.visibleRows(widget.height, 16);
+ }
+ if (widget instanceof TextFieldWidget
+ && "Sulfur".equals(((TextFieldWidget) widget).getValue())) sulfurName = true;
+ }
+ if (compactLists != 2 || !sulfurName || aliasRows < 5 || placementRows < 3
+ || lowestListBottom > 233
+ || !OreSourceGroupSettingsScreen.placementChannels(sulfurGroup)
+ .contains("orespawn:standard")) {
+ throw new IllegalStateException("Group Settings did not expose aliases and placement rules: lists="
+ + compactLists + ", name=" + sulfurName + ", aliasRows=" + aliasRows
+ + ", placementRows=" + placementRows + ", listBottom=" + lowestListBottom + ", channels="
+ + OreSourceGroupSettingsScreen.placementChannels(sulfurGroup));
+ }
+ validateKeepOriginalAcceptance(minecraft, parent, session, sulfurGroup.key);
+ validateStaleExternalClassification(minecraft, parent, session, sulfurGroup.key);
+ validateCustomGroupReassignmentLayout(minecraft, parent, session);
+ oreSourcesLayoutValidated = true;
+ }
+
+ private void validateCustomGroupReassignmentLayout(Minecraft minecraft, GuiScreen parent,
+ GeologyEditorSession source) {
+ GeologyEditorSession customSession = new GeologyEditorSession(
+ WorldGeologyProfile.fromJson(source.profile().rootCopy(), source.profile()));
+ customSession.setManageVanillaOres(false);
+ String customKey = customSession.addOreMaterialGroup();
+ String material = customKey.substring(0, customKey.indexOf('|'));
+ customSession.renameOreMaterialGroup(material, "Precious Stones");
+ String originalOwner = customSession.oreDictionaryOwner("oreDiamond");
+ if (originalOwner == null || material.equals(originalOwner)) {
+ throw new IllegalStateException("Could not establish the oreDiamond reassignment control");
+ }
+ OreSourceGroupSettingsScreen settings = new OreSourceGroupSettingsScreen(
+ parent, customSession, customKey);
+ settings.setWorldAndResolution(minecraft, 426, 265);
+ TextFieldWidget alias = null;
+ Button add = null;
+ for (GuiButton widget : settings.buttons) {
+ if (widget instanceof TextFieldWidget
+ && ((TextFieldWidget) widget).getValue().isEmpty()) alias = (TextFieldWidget) widget;
+ String caption = TextFormatting.getTextWithoutFormattingCodes(widget.displayString);
+ if (widget instanceof Button && "+".equals(caption)) add = (Button) widget;
+ }
+ if (alias == null || add == null) {
+ throw new IllegalStateException("Custom Group Settings did not expose alias reassignment controls");
+ }
+ alias.setValue("oreDiamond");
+ add.press();
+
+ Button move = null;
+ Button done = null;
+ for (GuiButton widget : settings.buttons) {
+ String caption = TextFormatting.getTextWithoutFormattingCodes(widget.displayString);
+ if (widget instanceof Button
+ && I18n.format("button.orespawn.ore_source.move").equals(caption)) move = (Button) widget;
+ if (widget instanceof Button && I18n.format("gui.done").equals(caption)) done = (Button) widget;
+ }
+ String message;
+ try {
+ Field error = OreSourceGroupSettingsScreen.class.getDeclaredField("error");
+ error.setAccessible(true);
+ message = (String) error.get(settings);
+ } catch (ReflectiveOperationException exception) {
+ throw new IllegalStateException("Could not inspect Group Settings validation state", exception);
+ }
+ int contentWidth = 406;
+ int expectedDoneX = 10 + contentWidth
+ - OreSourceGroupSettingsScreen.doneButtonWidth(contentWidth);
+ if (move != null || !I18n.format("error.orespawn.ore_source.manage_vanilla_first").equals(message)
+ || done == null
+ || done.x != expectedDoneX || done.y != 237
+ || OreSourceGroupSettingsScreen.validationMessageWidth(contentWidth) <= 0) {
+ throw new IllegalStateException("Disabled vanilla management did not block reassignment: move="
+ + (move != null) + ", message=" + message + ", done="
+ + (done == null ? "missing" : done.x + "," + done.y));
+ }
+ if (!originalOwner.equals(customSession.oreDictionaryOwner("oreDiamond"))) {
+ throw new IllegalStateException("Blocked vanilla alias move changed oreDiamond ownership");
+ }
+
+ customSession.setManageVanillaOres(true);
+ settings = new OreSourceGroupSettingsScreen(parent, customSession, customKey);
+ settings.setWorldAndResolution(minecraft, 426, 265);
+ alias = null;
+ add = null;
+ for (GuiButton widget : settings.buttons) {
+ if (widget instanceof TextFieldWidget
+ && ((TextFieldWidget) widget).getValue().isEmpty()) alias = (TextFieldWidget) widget;
+ String caption = TextFormatting.getTextWithoutFormattingCodes(widget.displayString);
+ if (widget instanceof Button && "+".equals(caption)) add = (Button) widget;
+ }
+ if (alias == null || add == null) {
+ throw new IllegalStateException("Managed vanilla alias controls did not reopen");
+ }
+ alias.setValue("oreDiamond");
+ add.press();
+ move = button(settings, I18n.format("button.orespawn.ore_source.move"));
+ if (move == null) {
+ throw new IllegalStateException("Managed vanilla alias move did not request confirmation");
+ }
+ move.press();
+ if (!material.equals(customSession.oreDictionaryOwner("oreDiamond"))) {
+ throw new IllegalStateException("Move did not confirm oreDiamond reassignment");
+ }
+ if (!customSession.addOreMaterialAlias(material, "oreEmerald", true)) {
+ throw new IllegalStateException("Could not construct the managed Precious Stones group");
+ }
+ GeologyEditorSession.OreSourceGroup customGroup = null;
+ for (GeologyEditorSession.OreSourceGroup group : customSession.oreSourceGroups()) {
+ if (customKey.equals(group.key)) customGroup = group;
+ }
+ if (customGroup == null || !customGroup.hasManagedPlacementSource()
+ || !customGroup.needsReview() || customGroup.outputCandidates().size() != 2
+ || customGroup.outputs.size() != 2
+ || OreSourceListScreen.placementCandidates(
+ customGroup, "orespawn:standard").size() != 2) {
+ throw new IllegalStateException("Managed Precious Stones review was not constructed");
+ }
+ OreSourceListScreen outputs = new OreSourceListScreen(parent, customSession);
+ outputs.setWorldAndResolution(minecraft, 426, 265);
+ Button mode = button(outputs, I18n.format("option.orespawn.ore_source.output_mode",
+ I18n.format("mode.orespawn.ore_source.keep_original")));
+ Button accept = button(outputs, I18n.format("button.orespawn.ore_source.accept"));
+ if (mode == null || accept == null) {
+ throw new IllegalStateException("Managed vanilla review did not retain Keep Original and Accept");
+ }
+ OreSourceListScreen minimum = new OreSourceListScreen(parent, customSession);
+ minimum.setWorldAndResolution(minecraft, 320, 240);
+ if (button(minimum, I18n.format("button.orespawn.ore_source.accept")) == null) {
+ throw new IllegalStateException("Accept was not visible at the minimum supported resolution");
+ }
+ mode.press();
+ GeologyEditorSession.OreSourceGroup after = null;
+ for (GeologyEditorSession.OreSourceGroup group : customSession.oreSourceGroups()) {
+ if (customKey.equals(group.key)) after = group;
+ }
+ String outputError;
+ try {
+ Field field = OreSourceListScreen.class.getDeclaredField("error");
+ field.setAccessible(true);
+ outputError = (String) field.get(outputs);
+ } catch (ReflectiveOperationException exception) {
+ throw new IllegalStateException("Could not inspect Ore Sources validation state", exception);
+ }
+ if (after == null || !"consolidated".equals(after.mode) || after.needsAttention()
+ || after.outputs.size() != 2 || after.placements.size() != 1
+ || OreSourceListScreen.placementCandidates(
+ after, "orespawn:standard").size() != 2
+ || outputError != null
+ || button(outputs, I18n.format("button.orespawn.ore_source.accept")) != null) {
+ throw new IllegalStateException("Managed vanilla group did not enter Balanced mode: error="
+ + outputError + ", mode=" + (after == null ? "missing" : after.mode));
+ }
+
+ OreSourceGroupSettingsScreen dissolve = new OreSourceGroupSettingsScreen(
+ parent, customSession, customKey);
+ dissolve.setWorldAndResolution(minecraft, 426, 265);
+ Button dissolveButton = button(dissolve,
+ I18n.format("button.orespawn.ore_source.dissolve_group"));
+ if (dissolveButton == null) {
+ throw new IllegalStateException("Populated custom group did not expose Dissolve Group");
+ }
+ dissolveButton.press();
+ Button confirm = button(dissolve,
+ I18n.format("button.orespawn.ore_source.confirm_dissolve"));
+ if (confirm == null || !material.equals(customSession.oreDictionaryOwner("oreDiamond"))) {
+ throw new IllegalStateException("Dissolve Group did not require a second confirmation");
+ }
+ confirm.press();
+ if (!"orespawn:diamond".equals(customSession.oreDictionaryOwner("oreDiamond"))
+ || !"orespawn:emerald".equals(customSession.oreDictionaryOwner("oreEmerald"))) {
+ throw new IllegalStateException("Confirmed dissolution did not restore inferred groups");
+ }
+ }
+
+ private static Button button(OreSpawnScreen screen, String caption) {
+ for (GuiButton widget : screen.buttons) {
+ String text = TextFormatting.getTextWithoutFormattingCodes(widget.displayString);
+ if (widget instanceof Button && caption.equals(text)) return (Button) widget;
+ }
+ return null;
+ }
+
+ private void validateKeepOriginalAcceptance(Minecraft minecraft, GuiScreen parent,
+ GeologyEditorSession source, String key) {
+ JsonObject root = source.profile().rootCopy();
+ JsonObject policy = root.getAsJsonObject("ore_source_policies").getAsJsonObject(key);
+ policy.addProperty("mode", "keep_separate");
+ policy.addProperty("review_required", true);
+ policy.addProperty("status", "review_required");
+ JsonObject onlyPolicy = new JsonObject();
+ onlyPolicy.add(key, policy);
+ root.add("ore_source_policies", onlyPolicy);
+ GeologyEditorSession reviewSession = new GeologyEditorSession(
+ WorldGeologyProfile.fromJson(root, source.profile()));
+ GeologyEditorSession.OreSourceGroup before = null;
+ for (GeologyEditorSession.OreSourceGroup group : reviewSession.oreSourceGroups()) {
+ if (key.equals(group.key)) before = group;
+ }
+ if (before == null || !before.needsReview()) {
+ throw new IllegalStateException("Could not construct pending Keep Original review");
+ }
+ OreSourceListScreen review = new OreSourceListScreen(parent, reviewSession);
+ review.setWorldAndResolution(minecraft, 426, 265);
+ Button accept = null;
+ for (GuiButton widget : review.buttons) {
+ String caption = TextFormatting.getTextWithoutFormattingCodes(widget.displayString);
+ if (widget instanceof Button
+ && I18n.format("button.orespawn.ore_source.accept").equals(caption)) {
+ accept = (Button) widget;
+ }
+ }
+ if (accept == null) throw new IllegalStateException("Pending Keep Original did not show Accept");
+ accept.press();
+ GeologyEditorSession.OreSourceGroup after = null;
+ for (GeologyEditorSession.OreSourceGroup group : reviewSession.oreSourceGroups()) {
+ if (key.equals(group.key)) after = group;
+ }
+ if (after == null || after.needsReview() || after.needsAttention()
+ || !before.mode.equals(after.mode) || !before.outputs.equals(after.outputs)
+ || !before.placements.equals(after.placements)) {
+ throw new IllegalStateException("Accept changed Keep Original instead of only clearing review");
+ }
+ }
+
+ private void validateStaleExternalClassification(Minecraft minecraft, GuiScreen parent,
+ GeologyEditorSession source, String key) {
+ JsonObject root = source.profile().rootCopy();
+ JsonObject policy = root.getAsJsonObject("ore_source_policies").getAsJsonObject(key);
+ JsonObject managed = null;
+ for (com.google.gson.JsonElement element : policy.getAsJsonArray("candidates")) {
+ JsonObject candidate = element.getAsJsonObject();
+ if ("electricadvantage:sulfur_ore".equals(candidate.get("registry_id").getAsString())
+ && !candidate.get("external").getAsBoolean()) managed = candidate;
+ }
+ if (managed == null) throw new IllegalStateException("Missing managed Electric Advantage output");
+ JsonObject external = JsonCopies.copy(managed);
+ external.addProperty("source_id", "external/electricadvantage:sulfur_ore/0");
+ external.addProperty("loaded", false);
+ external.addProperty("placement_active", false);
+ external.addProperty("external", true);
+ policy.getAsJsonArray("candidates").add(external);
+ policy.addProperty("mode", "keep_separate");
+ policy.addProperty("output_mode", "single");
+ policy.addProperty("review_required", false);
+ policy.addProperty("status", "external_generation");
+ JsonObject onlyPolicy = new JsonObject();
+ onlyPolicy.add(key, policy);
+ root.add("ore_source_policies", onlyPolicy);
+ GeologyEditorSession staleSession = new GeologyEditorSession(
+ WorldGeologyProfile.fromJson(root, source.profile()));
+ GeologyEditorSession.OreSourceGroup group = staleSession.oreSourceGroups().get(0);
+ if (group.needsAttention() || !"separate".equals(group.status)
+ || OreSourceListScreen.groupRowColor(group) != 0xFFFF55) {
+ throw new IllegalStateException("Stale missing external duplicate remained red: status="
+ + group.status + ", attention=" + group.needsAttention());
+ }
+ OreSourceListScreen screen = new OreSourceListScreen(parent, staleSession);
+ screen.setWorldAndResolution(minecraft, 426, 265);
+ for (GuiButton widget : screen.buttons) {
+ String caption = TextFormatting.getTextWithoutFormattingCodes(widget.displayString);
+ if (I18n.format("button.orespawn.ore_source.accept").equals(caption)) {
+ throw new IllegalStateException("Resolved stale external duplicate still requested acceptance");
+ }
+ }
+ }
+
+ private static GeologyEditorSession editorSession(OreSpawnWorldSettingsScreen root) {
+ try {
+ Field field = OreSpawnWorldSettingsScreen.class.getDeclaredField("session");
+ field.setAccessible(true);
+ return (GeologyEditorSession) field.get(root);
+ } catch (ReflectiveOperationException failure) {
+ throw new IllegalStateException("Could not inspect the active Ore Sources editor session", failure);
+ }
+ }
+
private Button nextNavigationButton(OreSpawnWorldSettingsScreen root) {
for (GuiButton widget : root.buttons) {
if (!(widget instanceof Button) || widget instanceof CycleButton) continue;
@@ -245,7 +638,7 @@ private Button nextNavigationButton(OreSpawnWorldSettingsScreen root) {
private static void validateCaptions(OreSpawnScreen screen) {
for (GuiButton widget : screen.buttons) {
- if (widget instanceof CogButton) continue;
+ if (widget instanceof CogButton || widget instanceof CompactScrollList) continue;
String caption = TextFormatting.getTextWithoutFormattingCodes(widget.displayString);
if (caption == null || caption.trim().isEmpty()
|| caption.contains("options.generic_value")
@@ -370,12 +763,10 @@ private static void pressDone(OreSpawnScreen screen) {
}
private static void stopIntegratedServer(Minecraft minecraft) {
- // Match GuiIngameMenu's target-native disconnect path. loadWorld(null)
- // coordinates the integrated-server save/stop; installing the replacement
- // screen in the same tick prevents EntityRenderer from seeing no world and
- // no screen between frames.
+ // Ask the integrated server to stop while retaining the client world until
+ // the server and its queued play packets have drained. Clearing the world in
+ // this tick races Forge 1.10 packet tasks against a null client world.
if (minecraft.world != null) minecraft.world.sendQuittingDisconnectingPacket();
- minecraft.loadWorld(null);
minecraft.displayGuiScreen(new GuiMainMenu());
}
@@ -384,6 +775,8 @@ private void writeMarker() throws IOException {
values.setProperty("world_settings_opened", Boolean.toString(worldSettingsOpened));
values.setProperty("mods_directory_rendered", Boolean.toString(modsDirectoryRendered));
values.setProperty("directory_stack_validated", Boolean.toString(directoryStackValidated));
+ values.setProperty("ore_sources_validated", Boolean.toString(oreSourcesValidated));
+ values.setProperty("ore_sources_layout_validated", Boolean.toString(oreSourcesLayoutValidated));
values.setProperty("long_editor_roundtrip", Boolean.toString(longEditorRoundTrip));
values.setProperty("editor_routes", Integer.toString(editorRoutes.size()));
values.setProperty("editor_classes", editorRoutes.toString());
diff --git a/src/main/java/zone/moddev/mc/orespawn/api/OreGenerationContext.java b/src/main/java/zone/moddev/mc/orespawn/api/OreGenerationContext.java
index a73da872..72a6080b 100644
--- a/src/main/java/zone/moddev/mc/orespawn/api/OreGenerationContext.java
+++ b/src/main/java/zone/moddev/mc/orespawn/api/OreGenerationContext.java
@@ -16,6 +16,15 @@
* 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();
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 291d84db..e5e648a7 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.OptionalDouble;
import java.util.Set;
import java.util.function.Consumer;
@@ -60,7 +61,7 @@ public boolean mergeNewEntriesIntoExistingWorlds() {
return mergeNewEntriesIntoExistingWorlds;
}
- /** 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);
}
@@ -235,7 +236,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.addProperty("merge_new_entries_into_existing_worlds",
@@ -375,6 +376,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;
@@ -388,6 +390,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;
@@ -403,6 +406,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; }
+ /** Stable semantic 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; }
@@ -414,6 +419,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);
@@ -439,6 +445,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;
@@ -455,6 +462,8 @@ private Builder(ResourceLocation id, ResourceLocation block) {
this.block = Objects.requireNonNull(block, "block");
}
public Builder enabled(boolean value) { enabled = value; return this; }
+ /** Declares the semantic material used for source arbitration. */
+ 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; }
@@ -546,6 +555,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;
@@ -574,6 +584,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;
@@ -606,6 +619,8 @@ private OreDimensionDefinition(Builder builder) {
public int maxQuantity() { return maxQuantity; }
public OrePattern pattern() { return pattern; }
public ResourceLocation patternType() { return patternType; }
+ /** Stable channel whose placement budget is arbitrated independently. */
+ public ResourceLocation placementChannel() { return placementChannel; }
public JsonObject patternSettings() { return JsonCopies.copy(patternSettings); }
public OreHeightDistribution heightDistribution() { return heightDistribution; }
public double discardChanceOnAirExposure() { return discardChanceOnAirExposure; }
@@ -653,6 +668,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);
@@ -684,6 +700,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;
@@ -721,6 +738,11 @@ public Builder pattern(ResourceLocation type, JsonObject settings) {
patternSettings = JsonCopies.copy(Objects.requireNonNull(settings, "settings"));
return this;
}
+ /** Overrides the placement-budget channel for this dimension rule. */
+ 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/client/CompactScrollList.java b/src/main/java/zone/moddev/mc/orespawn/client/CompactScrollList.java
new file mode 100644
index 00000000..00a37682
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/orespawn/client/CompactScrollList.java
@@ -0,0 +1,293 @@
+package zone.moddev.mc.orespawn.client;
+
+import java.util.Collections;
+import java.util.List;
+
+import org.lwjgl.input.Mouse;
+
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.gui.FontRenderer;
+
+/**
+ * Small Forge 1.10-native 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 int pendingIndex = -1;
+ private boolean pendingAction;
+
+ 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 drawButton(Minecraft minecraft, int mouseX, int mouseY, float partialTicks) {
+ if (!visible) return;
+ clampFirstIndex();
+ if (dragging) {
+ if (Mouse.isButtonDown(0)) dragTo(mouseY - dragOffset);
+ else dragging = false;
+ }
+
+ drawRect(x, y, x + width, y + height, 0x76000000);
+ hoveredIndex = rowAt(mouseX, mouseY);
+ hoveredAction = hoveredIndex >= 0 && isInRowAction(hoveredIndex, mouseX);
+ FontRenderer font = minecraft.fontRenderer;
+ 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)) {
+ drawRect(x + 1, rowY, contentRight, rowY + rowHeight - 1, 0xA052648C);
+ } else if (hovered) {
+ drawRect(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.drawStringWithShadow(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 mousePressed(Minecraft minecraft, int mouseX, int mouseY) {
+ if (!visible || !enabled || !contains(mouseX, mouseY)) return false;
+ pendingIndex = -1;
+ if (mouseX >= contentRight()) {
+ pressScrollbar(mouseY);
+ return true;
+ }
+ int index = rowAt(mouseX, mouseY);
+ if (index < 0) return true;
+ pendingAction = isInRowAction(index, mouseX);
+ if (pendingAction || rowEnabled(index)) pendingIndex = index;
+ return true;
+ }
+
+ @Override
+ void press() {
+ int index = pendingIndex;
+ pendingIndex = -1;
+ if (index < 0 || index >= size()) return;
+ if (pendingAction) onRowActionPressed(index);
+ else onRowPressed(index);
+ }
+
+ @Override
+ public void mouseReleased(int mouseX, int mouseY) {
+ dragging = false;
+ super.mouseReleased(mouseX, mouseY);
+ }
+
+ @Override
+ 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;
+ }
+
+ 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;
+ drawRect(left, y, right, y + height, 0xB0202020);
+ FontRenderer font = minecraft.fontRenderer;
+ drawCenteredString(font, "^", left + (SCROLLBAR_WIDTH / 2), y, 0xDDDDDD);
+ drawCenteredString(font, "v", left + (SCROLLBAR_WIDTH / 2),
+ y + height - ARROW_HEIGHT, 0xDDDDDD);
+ int thumbY = thumbY();
+ int thumbHeight = thumbHeight(size(), visibleRows(), trackHeight());
+ drawRect(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(FontRenderer font, String text, int width) {
+ if (width <= 0) return "";
+ if (font.getStringWidth(text) <= width) return text;
+ String suffix = "...";
+ return font.trimStringToWidth(text,
+ Math.max(0, width - font.getStringWidth(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 4b7bb69c..0032cd6c 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;
@@ -58,6 +61,7 @@ enum MaterialTab {
private final WorldGeologyProfile originalProfile;
private final JsonObject original;
private final JsonObject root;
+ private JsonObject oreSourceOriginal;
private final Set availableDimensionIds = new TreeSet<>();
GeologyEditorSession(WorldGeologyProfile profile) {
@@ -70,6 +74,8 @@ enum MaterialTab {
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 +98,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);
}
@@ -344,6 +388,755 @@ 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, "ore_dictionary_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("ore_dictionary") && candidate.get("ore_dictionary").isJsonArray()) {
+ for (JsonElement name : candidate.getAsJsonArray("ore_dictionary")) {
+ 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("ore_dictionary_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 oreDictionaryOwner(String alias) {
+ for (Entry entry : section("ore_material_groups").entrySet()) {
+ if (!entry.getValue().isJsonObject()) continue;
+ if (stringList(entry.getValue().getAsJsonObject(), "ore_dictionary_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 = oreDictionaryOwner(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.oreDictionary.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) || !validOreDictionaryName(alias)) return false;
+ String owner = oreDictionaryOwner(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, "ore_dictionary_entries");
+ if (!names.contains(alias)) names.add(alias);
+ Collections.sort(names);
+ definition.add("ore_dictionary_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, "ore_dictionary_entries");
+ if (names.remove(alias)) {
+ definition.add("ore_dictionary_entries", strings(names));
+ rebuildOreSourcePolicies();
+ }
+ }
+
+ void resetOreMaterialGroup(String material) {
+ if ("orespawn:sulfur".equals(material)) {
+ setOreMaterialGroup(material, "Sulfur", Arrays.asList("oreSulfur", "oreSulphur"));
+ } else if ("orespawn:aluminum".equals(material)) {
+ setOreMaterialGroup(material, "Aluminum", Arrays.asList("oreAluminum", "oreAluminium"));
+ }
+ 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), "ore_dictionary_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), "ore_dictionary_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, "ore_dictionary_entries");
+ if (!restoredAliases.contains(alias)) restoredAliases.add(alias);
+ Collections.sort(restoredAliases);
+ definition.add("ore_dictionary_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("ore_dictionary_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, "ore_dictionary"));
+ 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 = oreDictionaryOwner(alias);
+ if (owner != null) materials.add(owner);
+ else if (validOreDictionaryName(alias)) {
+ materials.add("orespawn:" + alias.substring(3).toLowerCase(Locale.ROOT));
+ }
+ }
+ return materials.size() == 1 ? materials.iterator().next()
+ : materials.size() > 1 ? "orespawn:review_required" : "";
+ }
+
+ private static boolean isCustomMaterial(String material) {
+ return material != null && material.startsWith("orespawn:custom/") && validResource(material);
+ }
+
+ private static String inferredMaterialForAlias(String alias) {
+ if ("oreSulfur".equals(alias) || "oreSulphur".equals(alias)) return "orespawn:sulfur";
+ if ("oreAluminum".equals(alias) || "oreAluminium".equals(alias)) return "orespawn:aluminum";
+ return "orespawn:" + alias.substring(3).toLowerCase(Locale.ROOT);
+ }
+
+ 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)) {
+ if (outputs.entrySet().isEmpty()) {
+ for (JsonObject candidate : outputCandidateJson(policy)) {
+ outputs.addProperty(string(candidate, "source_id", ""), 1.0D);
+ }
+ } else {
+ for (String sourceId : new ArrayList<>(JsonCopies.keys(outputs))) {
+ 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 oreDictionaryEntries;
+ 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 oreDictionaryEntries, 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.oreDictionaryEntries = Collections.unmodifiableList(new ArrayList<>(oreDictionaryEntries));
+ 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 result = new TreeSet<>();
+ for (OreSourceCandidate candidate : candidates) {
+ if (!candidate.external && candidate.active) result.add(candidate.channel);
+ }
+ return Collections.unmodifiableList(new ArrayList<>(result));
+ }
+
+ List outputCandidates() {
+ Map unique = new LinkedHashMap<>();
+ for (OreSourceCandidate candidate : candidates) {
+ if (candidate.enrichment) continue;
+ String identity = candidate.registryId + '|' + candidate.metadata;
+ OreSourceCandidate previous = unique.get(identity);
+ if (previous == null || (!previous.active && candidate.active)
+ || (previous.external && !candidate.external)) unique.put(identity, candidate);
+ }
+ return Collections.unmodifiableList(new ArrayList<>(unique.values()));
+ }
+
+ boolean needsAttention() {
+ return needsReview() || "missing_source".equals(status)
+ || "external_generation".equals(status) || outputCandidates().isEmpty();
+ }
+
+ boolean needsReview() {
+ return "review_required".equals(status);
+ }
+
+ boolean hasManagedPlacementSource() {
+ for (OreSourceCandidate candidate : candidates) {
+ if (candidate.loaded && candidate.active && !candidate.external
+ && !candidate.enrichment) return true;
+ }
+ return false;
+ }
+
+ boolean isRoutineSingleSource() {
+ return !needsAttention() && oreDictionaryEntries.size() == 1
+ && outputCandidates().size() == 1;
+ }
+
+ boolean isCustom() {
+ return isCustomMaterial(material);
+ }
+
+ boolean canDeleteEmpty() {
+ return isCustom() && oreDictionaryEntries.isEmpty();
+ }
+
+ boolean canDissolve() {
+ return isCustom() && !oreDictionaryEntries.isEmpty();
+ }
+
+ boolean isRedundantSeparatePolicy() {
+ if (!"keep_separate".equals(mode) || candidates.isEmpty()) return false;
+ Set outputs = new HashSet<>();
+ Map> ownersByChannel = new HashMap<>();
+ for (OreSourceCandidate candidate : candidates) {
+ if (!candidate.loaded || candidate.external || candidate.enrichment
+ || candidate.reviewRequired) return false;
+ outputs.add(candidate.registryId + '|' + candidate.metadata);
+ ownersByChannel.computeIfAbsent(candidate.channel, ignored -> new HashSet<>())
+ .add(candidate.owner);
+ }
+ if (outputs.size() > 1) return false;
+ for (Set owners : ownersByChannel.values()) if (owners.size() > 1) return false;
+ return true;
+ }
+ }
+
+ static final class OreSourceCandidate {
+ final String sourceId;
+ final String owner;
+ final String ownerName;
+ final String ownerVersion;
+ final String registryId;
+ final int metadata;
+ final String channel;
+ final List oreDictionary;
+ final boolean loaded;
+ final boolean active;
+ final boolean external;
+ final boolean enrichment;
+ final boolean reviewRequired;
+
+ OreSourceCandidate(String sourceId, String owner, String ownerName, String ownerVersion,
+ String registryId, int metadata, String channel, List oreDictionary,
+ boolean loaded, boolean active, boolean external, boolean enrichment, boolean reviewRequired) {
+ this.sourceId = sourceId;
+ this.owner = owner;
+ this.ownerName = ownerName;
+ this.ownerVersion = ownerVersion;
+ this.registryId = registryId;
+ this.metadata = metadata;
+ this.channel = channel;
+ this.oreDictionary = Collections.unmodifiableList(new ArrayList<>(oreDictionary));
+ this.loaded = loaded;
+ this.active = active;
+ this.external = external;
+ this.enrichment = enrichment;
+ this.reviewRequired = reviewRequired;
+ }
+ }
+
void resetEntry(String section, String id) {
JsonObject originalSection = object(original, section);
if (originalSection.has(id)) {
@@ -822,6 +1615,8 @@ List validate() {
validateGeomeWeights(errors, entry.getKey(), rule.get("geomes"), geomes);
}
}
+ validateOreMaterialGroups(errors);
+ validateOreSourcePolicies(errors);
validateRuleGeomes(errors, section("biomes"), geomes, "biome");
validateRuleGeomes(errors, section("biome_dictionary"), geomes, "biome type");
for (Entry entry : section("terrain_dimensions").entrySet()) {
@@ -949,6 +1744,62 @@ > decimal(placement, "max_downfall", 1.0D)) {
return errors;
}
+ private void validateOreMaterialGroups(List errors) {
+ Set aliases = new HashSet<>();
+ for (Entry entry : section("ore_material_groups").entrySet()) {
+ if (!validResource(entry.getKey()) || !entry.getValue().isJsonObject()) {
+ errors.add("Invalid ore material group: " + entry.getKey());
+ continue;
+ }
+ JsonObject group = entry.getValue().getAsJsonObject();
+ if (string(group, "display_name", "").trim().isEmpty()) {
+ errors.add("Ore material group has no display name: " + entry.getKey());
+ }
+ for (String alias : stringList(group, "ore_dictionary_entries")) {
+ if (!validOreDictionaryName(alias)) {
+ errors.add("Invalid Ore Dictionary entry in " + entry.getKey() + ": " + alias);
+ } else if (!aliases.add(alias)) {
+ errors.add("Ore Dictionary entry belongs to more than one material group: " + alias);
+ }
+ }
+ }
+ }
+
+ private void validateOreSourcePolicies(List errors) {
+ for (Entry entry : section("ore_source_policies").entrySet()) {
+ if (!entry.getValue().isJsonObject()) {
+ errors.add("Invalid ore source policy: " + entry.getKey());
+ continue;
+ }
+ JsonObject policy = entry.getValue().getAsJsonObject();
+ if (bool(policy, "dormant", false)
+ || !"consolidated".equals(string(policy, "mode", "keep_separate"))) continue;
+ JsonObject outputs = policy.has("outputs") && policy.get("outputs").isJsonObject()
+ ? policy.getAsJsonObject("outputs") : new JsonObject();
+ JsonObject placements = policy.has("placement_sources")
+ && policy.get("placement_sources").isJsonObject()
+ ? policy.getAsJsonObject("placement_sources") : new JsonObject();
+ String outputMode = outputMode(policy);
+ if (outputs.entrySet().isEmpty()) {
+ errors.add("Consolidated ore source policy has no selected outputs: " + entry.getKey());
+ }
+ if (placements.entrySet().isEmpty()) {
+ errors.add("Consolidated ore source policy has no placement source: " + entry.getKey());
+ }
+ if ("single".equals(outputMode) && outputs.entrySet().size() != 1) {
+ errors.add("Single-output ore source policy must select exactly one output: " + entry.getKey());
+ }
+ for (Entry output : outputs.entrySet()) {
+ try {
+ double value = output.getValue().getAsDouble();
+ if (!Double.isFinite(value) || value <= 0.0D) throw new NumberFormatException();
+ } catch (RuntimeException invalid) {
+ errors.add("Ore source output weight must be positive: " + entry.getKey());
+ }
+ }
+ }
+ }
+
private static void validateOrePattern(JsonObject rule) {
if (!OreSpawnPatterns.isBuiltIn(rule)) {
OreSpawnPatterns.decode(rule);
@@ -1179,6 +2030,53 @@ private static boolean validResource(String id) {
}
}
+ private static boolean validOreDictionaryName(String name) {
+ return name != null && name.length() > 3 && name.length() <= 128
+ && name.startsWith("ore") && Character.isUpperCase(name.charAt(3))
+ && name.matches("[A-Za-z0-9_]+");
+ }
+
+ private static JsonObject sectionCopy(JsonObject parent, String key) {
+ return parent.has(key) && parent.get(key).isJsonObject()
+ ? JsonCopies.copy(parent.getAsJsonObject(key)) : new JsonObject();
+ }
+
+ private static List stringList(JsonObject parent, String key) {
+ List result = new ArrayList<>();
+ if (parent.has(key) && parent.get(key).isJsonArray()) {
+ for (JsonElement element : parent.getAsJsonArray(key)) {
+ try {
+ String value = element.getAsString();
+ if (!result.contains(value)) result.add(value);
+ } catch (RuntimeException ignored) { }
+ }
+ }
+ return result;
+ }
+
+ private static JsonArray strings(Iterable values) {
+ JsonArray result = new JsonArray();
+ for (String value : values) result.add(new JsonPrimitive(value));
+ return result;
+ }
+
+ private static String displayName(String id) {
+ int split = id.indexOf(':');
+ String value = (split < 0 ? id : id.substring(split + 1)).replace('_', ' ').replace('/', ' ');
+ return value.isEmpty() ? value : Character.toUpperCase(value.charAt(0)) + value.substring(1);
+ }
+
+ private static String outputMode(JsonObject policy) {
+ String configured = string(policy, "output_mode", "");
+ if ("balanced".equals(configured) || "single".equals(configured)
+ || "custom".equals(configured)) return configured;
+ Map outputs = decimalMap(policy, "outputs");
+ if (outputs.size() <= 1) return "single";
+ double first = outputs.values().iterator().next();
+ for (double value : outputs.values()) if (Double.compare(first, value) != 0) return "custom";
+ return "balanced";
+ }
+
private static boolean validGeomeId(String id) {
if (id == null || id.isEmpty()) return false;
if (id.indexOf(':') < 0) return id.matches("[a-z0-9_.-]+");
diff --git a/src/main/java/zone/moddev/mc/orespawn/client/GeologyMaterialsScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/GeologyMaterialsScreen.java
index 47c28c42..4a5a8870 100644
--- a/src/main/java/zone/moddev/mc/orespawn/client/GeologyMaterialsScreen.java
+++ b/src/main/java/zone/moddev/mc/orespawn/client/GeologyMaterialsScreen.java
@@ -100,9 +100,17 @@ protected void init() {
previous.enabled = page > 0;
next.enabled = page + 1 < pageCount;
- int addWidth = Math.min(150, contentWidth - 105);
+ int addWidth = tab == MaterialTab.ORES ? Math.max(50, (contentWidth - 105) / 2)
+ : Math.min(150, contentWidth - 105);
+ int addX = contentLeft + contentWidth - addWidth;
+ Button sources = addButton(OreSpawnScreenLayout.button(this, font,
+ addX - addWidth - 5, controlsY, addWidth, 20,
+ new TextComponentTranslation("button.orespawn.ore_sources"),
+ button -> minecraft.displayGuiScreen(new OreSourceListScreen(this, session))));
+ sources.visible = tab == MaterialTab.ORES;
+ OreSpawnScreenLayout.explain(this, sources, "tooltip.orespawn.ore_sources");
Button add = addButton(OreSpawnScreenLayout.button(this, font,
- contentLeft + contentWidth - addWidth, controlsY, addWidth, 20,
+ addX, controlsY, addWidth, 20,
new TextComponentTranslation("button.orespawn.add_block"), button -> openBlockPicker()));
add.visible = tab != MaterialTab.UNASSIGNED;
OreSpawnScreenLayout.explain(this, add, "tooltip.orespawn.material.add_block");
diff --git a/src/main/java/zone/moddev/mc/orespawn/client/OreSourceGroupSettingsScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceGroupSettingsScreen.java
new file mode 100644
index 00000000..11438f0c
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceGroupSettingsScreen.java
@@ -0,0 +1,394 @@
+package zone.moddev.mc.orespawn.client;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.TreeSet;
+
+import org.lwjgl.input.Mouse;
+
+import zone.moddev.mc.orespawn.client.GeologyEditorSession.OreSourceCandidate;
+import zone.moddev.mc.orespawn.client.GeologyEditorSession.OreSourceGroup;
+
+import net.minecraft.client.gui.GuiScreen;
+import net.minecraft.client.resources.I18n;
+import net.minecraft.util.text.TextComponentString;
+import net.minecraft.util.text.TextComponentTranslation;
+
+/** Infrequent group metadata and placement ownership controls opened by the group cog. */
+final class OreSourceGroupSettingsScreen extends OreSpawnScreen {
+ private static final int MARGIN = 10;
+ private static final int NAME_TOP = 24;
+ private static final int ALIAS_LABEL_TOP = 48;
+ private static final int ALIAS_TOP = 58;
+ private final GuiScreen parent;
+ private final GeologyEditorSession session;
+ private final String groupKey;
+ private String pendingAliasMove;
+ private String pendingAliasValue;
+ private boolean pendingDissolve;
+ private String error;
+ private int aliasScroll;
+ private int placementScroll;
+ private int contentX;
+ private int contentWidth;
+ private int aliasListHeight;
+ private int addAliasY;
+ private int placementLabelY;
+ private int placementListY;
+ private TextFieldWidget nameField;
+ private TextFieldWidget aliasField;
+ private CompactScrollList aliasList;
+ private CompactScrollList placementList;
+
+ OreSourceGroupSettingsScreen(GuiScreen parent, GeologyEditorSession session, String groupKey) {
+ super(new TextComponentTranslation("screen.orespawn.ore_source.group_settings"));
+ this.parent = parent;
+ this.session = session;
+ this.groupKey = groupKey;
+ }
+
+ @Override
+ protected void init() {
+ OreSpawnScreenLayout.beginHelp(this);
+ OreSourceGroup group = selectedGroup();
+ if (group == null) {
+ minecraft.displayGuiScreen(parent);
+ return;
+ }
+ contentWidth = Math.max(240, Math.min(600, width - (MARGIN * 2)));
+ contentX = (width - contentWidth) / 2;
+ boolean hasGroupAction = group.curated || group.canDeleteEmpty() || group.canDissolve();
+ String groupActionKey = group.curated ? "button.orespawn.reset"
+ : group.canDissolve() ? (pendingDissolve
+ ? "button.orespawn.ore_source.confirm_dissolve"
+ : "button.orespawn.ore_source.dissolve_group")
+ : "button.orespawn.ore_source.delete_group";
+ int actionWidth = hasGroupAction
+ ? Math.min(120, Math.max(55, font.getStringWidth(I18n.format(groupActionKey)) + 10)) : 0;
+ int nameLabelWidth = font.getStringWidth(
+ I18n.format("option.orespawn.ore_source.group_name")) + 8;
+
+ nameField = addButton(new TextFieldWidget(font, contentX + nameLabelWidth, NAME_TOP,
+ contentWidth - nameLabelWidth - actionWidth - (hasGroupAction ? 5 : 0), 20,
+ new TextComponentTranslation("option.orespawn.ore_source.group_name")));
+ nameField.setMaxLength(64);
+ nameField.setValue(group.displayName);
+ if (hasGroupAction) {
+ Button groupAction = addButton(OreSpawnScreenLayout.button(this, font,
+ contentX + contentWidth - actionWidth, NAME_TOP, actionWidth, 20,
+ new TextComponentTranslation(groupActionKey), button -> handleGroupAction(group)));
+ OreSpawnScreenLayout.explain(this, groupAction, group.curated
+ ? "tooltip.orespawn.ore_source.reset_group"
+ : group.canDissolve() ? "tooltip.orespawn.ore_source.dissolve_group"
+ : "tooltip.orespawn.ore_source.delete_group");
+ }
+
+ aliasListHeight = aliasListHeight(height);
+ final List aliases = group.oreDictionaryEntries;
+ aliasList = addButton(new CompactScrollList(this, contentX, ALIAS_TOP,
+ contentWidth, aliasListHeight) {
+ @Override protected int size() { return aliases.size(); }
+ @Override protected String rowText(int index) { return aliases.get(index); }
+ @Override protected List rowTooltip(int index) {
+ return java.util.Collections.singletonList(aliases.get(index));
+ }
+ @Override protected int rowActionWidth(int index) { return 18; }
+ @Override protected void drawRowAction(net.minecraft.client.Minecraft minecraft,
+ int index, int x, int y, int actionWidth, int rowHeight, boolean hovered) {
+ if (hovered) drawRect(x, y, x + actionWidth, y + rowHeight, 0x805F5F5F);
+ drawCenteredString(minecraft.fontRenderer, "-", x + (actionWidth / 2), y + 3, 0xFFFFFF);
+ }
+ @Override protected void onRowActionPressed(int index) {
+ syncName();
+ session.removeOreMaterialAlias(group.material, aliases.get(index));
+ pendingAliasMove = null;
+ pendingAliasValue = null;
+ pendingDissolve = false;
+ error = null;
+ captureScroll();
+ rebuild();
+ }
+ @Override protected List rowActionTooltip(int index) {
+ return java.util.Collections.singletonList(
+ I18n.format("tooltip.orespawn.ore_source.remove_alias", aliases.get(index)));
+ }
+ });
+ aliasList.setFirstIndex(aliasScroll);
+
+ addAliasY = ALIAS_TOP + aliasListHeight + 4;
+ aliasField = addButton(new TextFieldWidget(font, contentX, addAliasY,
+ contentWidth - 46, 20,
+ new TextComponentTranslation("option.orespawn.ore_source.alias")));
+ aliasField.setMaxLength(128);
+ if (pendingAliasMove != null) {
+ int split = pendingAliasMove.indexOf('|');
+ aliasField.setValue(split < 0 ? pendingAliasMove : pendingAliasMove.substring(0, split));
+ } else if (pendingAliasValue != null) {
+ aliasField.setValue(pendingAliasValue);
+ }
+ Button add = addButton(new Button(contentX + contentWidth - 41, addAliasY, 41, 20,
+ new TextComponentString(pendingAliasMove == null ? "+"
+ : I18n.format("button.orespawn.ore_source.move")), button -> addAlias(group)));
+ OreSpawnScreenLayout.explain(this, add, "tooltip.orespawn.ore_source.alias");
+
+ final List channels = placementChannels(group);
+ placementLabelY = addAliasY + 27;
+ int helpX = contentX + font.getStringWidth(
+ I18n.format("label.orespawn.ore_source.placement_rules")) + 8;
+ addButton(new Button(helpX, placementLabelY - 4, 16, 16,
+ new TextComponentString("?"), button -> { }, (button, mouseX, mouseY) -> {
+ List lines = new ArrayList<>();
+ lines.add(I18n.format("tooltip.orespawn.ore_source.placement_rules"));
+ lines.add(placementStatus(group, channels));
+ lines.add(I18n.format("tooltip.orespawn.ore_source.external_excluded"));
+ renderStringTooltip(lines, mouseX, mouseY);
+ }));
+ placementListY = placementLabelY + 14;
+ placementList = addButton(new CompactScrollList(this, contentX, placementListY,
+ contentWidth, Math.max(16, height - 32 - placementListY)) {
+ @Override protected int size() { return channels.size(); }
+ @Override protected String rowText(int index) {
+ String channel = channels.get(index);
+ List choices = OreSourceListScreen.placementCandidates(group, channel);
+ String selected = group.placements.get(channel);
+ int choice = OreSourceListScreen.indexOf(choices, selected);
+ String source = choice < 0
+ ? I18n.format("label.orespawn.ore_source.missing")
+ : OreSourceListScreen.candidateLabel(choices.get(choice));
+ return channelLabel(channel) + (placementSelectable(group, channel) ? " > " : " = ")
+ + placementSourceLabel(source, choices.size());
+ }
+ @Override protected boolean rowEnabled(int index) {
+ String channel = channels.get(index);
+ return placementSelectable(group, channel);
+ }
+ @Override protected int rowColor(int index) {
+ String channel = channels.get(index);
+ List choices = OreSourceListScreen.placementCandidates(group, channel);
+ return choices.isEmpty() ? 0x777777
+ : placementSelectable(group, channel) ? 0xFFFFFF : 0xC0C0C0;
+ }
+ @Override protected void onRowPressed(int index) {
+ String channel = channels.get(index);
+ List choices = OreSourceListScreen.placementCandidates(group, channel);
+ if (choices.size() <= 1) return;
+ int selected = OreSourceListScreen.indexOf(choices, group.placements.get(channel));
+ OreSourceCandidate next = choices.get((selected + 1 + choices.size()) % choices.size());
+ syncName();
+ session.setOreSourcePlacement(group.key, channel, next.sourceId);
+ captureScroll();
+ rebuild();
+ }
+ @Override protected List rowTooltip(int index) {
+ String channel = channels.get(index);
+ List choices = OreSourceListScreen.placementCandidates(group, channel);
+ List lines = new ArrayList<>();
+ lines.add(channelLabel(channel) + " (" + channel + ")");
+ lines.add(I18n.format("tooltip.orespawn.ore_source.placement_rules"));
+ if ("keep_separate".equals(group.mode)) {
+ lines.add(I18n.format("tooltip.orespawn.ore_source.placement_requires_consolidated"));
+ } else if (choices.isEmpty()) {
+ lines.add(I18n.format("tooltip.orespawn.ore_source.placement_missing"));
+ } else if (choices.size() == 1) {
+ lines.add(I18n.format("tooltip.orespawn.ore_source.placement_readonly"));
+ } else {
+ lines.add(I18n.format("tooltip.orespawn.ore_source.placement_select"));
+ }
+ lines.add(I18n.format("tooltip.orespawn.ore_source.external_excluded"));
+ return lines;
+ }
+ });
+ placementList.setFirstIndex(placementScroll);
+
+ int doneWidth = doneButtonWidth(contentWidth);
+ addButton(new Button(contentX + contentWidth - doneWidth, height - 28, doneWidth, 20,
+ DialogTexts.GUI_DONE, button -> onClose()));
+ }
+
+ private void handleGroupAction(OreSourceGroup group) {
+ syncName();
+ pendingAliasMove = null;
+ pendingAliasValue = null;
+ if (group.curated) {
+ session.resetOreMaterialGroup(group.material);
+ pendingDissolve = false;
+ error = null;
+ rebuild();
+ return;
+ }
+ if (group.canDeleteEmpty()) {
+ if (session.deleteEmptyOreMaterialGroup(group.material)) minecraft.displayGuiScreen(parent);
+ return;
+ }
+ if (!group.canDissolve()) return;
+ if (!pendingDissolve) {
+ pendingDissolve = true;
+ error = I18n.format("error.orespawn.ore_source.confirm_dissolve");
+ rebuild();
+ return;
+ }
+ if (session.dissolveOreMaterialGroup(group.material)) minecraft.displayGuiScreen(parent);
+ }
+
+ private void addAlias(OreSourceGroup group) {
+ syncName();
+ String alias = aliasField == null ? "" : aliasField.getValue().trim();
+ String owner = session.oreDictionaryOwner(alias);
+ String move = owner == null || owner.equals(group.material) ? null : alias + '|' + owner;
+ if (move != null && session.vanillaOreManagementRequiredForAliasMove(group.material, alias)) {
+ pendingDissolve = false;
+ pendingAliasMove = null;
+ pendingAliasValue = alias;
+ error = I18n.format("error.orespawn.ore_source.manage_vanilla_first");
+ rebuild();
+ return;
+ }
+ if (move != null && !move.equals(pendingAliasMove)) {
+ pendingDissolve = false;
+ pendingAliasMove = move;
+ pendingAliasValue = alias;
+ error = I18n.format("error.orespawn.ore_source.alias_owned",
+ OreSourceListScreen.display(owner));
+ rebuild();
+ return;
+ }
+ if (!session.addOreMaterialAlias(group.material, alias, move != null)) {
+ error = I18n.format("error.orespawn.ore_source.alias");
+ return;
+ }
+ pendingAliasMove = null;
+ pendingAliasValue = null;
+ pendingDissolve = false;
+ error = null;
+ rebuild();
+ }
+
+ private OreSourceGroup selectedGroup() {
+ for (OreSourceGroup group : session.oreSourceGroups()) {
+ if (groupKey.equals(group.key)) return group;
+ }
+ return null;
+ }
+
+ private void syncName() {
+ OreSourceGroup group = selectedGroup();
+ if (group != null && nameField != null) {
+ session.renameOreMaterialGroup(group.material, nameField.getValue());
+ }
+ }
+
+ private void captureScroll() {
+ if (aliasList != null) aliasScroll = aliasList.firstIndex();
+ if (placementList != null) placementScroll = placementList.firstIndex();
+ }
+
+ private void rebuild() {
+ buttons.clear();
+ children.clear();
+ init();
+ }
+
+ static int aliasListHeight(int height) {
+ return height >= 260 ? 80 : 64;
+ }
+
+ static int doneButtonWidth(int contentWidth) {
+ return Math.min(120, Math.max(80, contentWidth / 4));
+ }
+
+ static int validationMessageWidth(int contentWidth) {
+ return Math.max(40, contentWidth - doneButtonWidth(contentWidth) - 8);
+ }
+
+ static List placementChannels(OreSourceGroup group) {
+ Set channels = new TreeSet<>(group.channels());
+ channels.addAll(group.placements.keySet());
+ return new ArrayList<>(channels);
+ }
+
+ static String channelLabel(String channel) {
+ return "orespawn:standard".equals(channel)
+ ? I18n.format("label.orespawn.ore_source.standard_veins")
+ : OreSourceListScreen.display(channel);
+ }
+
+ static String placementSourceLabel(String source, int choices) {
+ return choices > 1
+ ? I18n.format("label.orespawn.ore_source.placement_choice_count", source, choices)
+ : source;
+ }
+
+ static boolean placementSelectable(OreSourceGroup group, String channel) {
+ return !"keep_separate".equals(group.mode)
+ && OreSourceListScreen.placementCandidates(group, channel).size() > 1;
+ }
+
+ static String placementStatus(OreSourceGroup group, List channels) {
+ if (channels.isEmpty()) return I18n.format("label.orespawn.ore_source.no_placement_rules");
+ if ("keep_separate".equals(group.mode)) {
+ return I18n.format("tooltip.orespawn.ore_source.placement_requires_consolidated");
+ }
+ boolean selectable = false;
+ for (String channel : channels) {
+ int choices = OreSourceListScreen.placementCandidates(group, channel).size();
+ if (choices == 0) return I18n.format("tooltip.orespawn.ore_source.placement_missing");
+ selectable |= choices > 1;
+ }
+ return I18n.format(selectable ? "tooltip.orespawn.ore_source.placement_select"
+ : "tooltip.orespawn.ore_source.placement_readonly");
+ }
+
+ @Override
+ public void handleMouseInput() throws IOException {
+ int mouseX = Mouse.getEventX() * width / minecraft.displayWidth;
+ int mouseY = height - Mouse.getEventY() * height / minecraft.displayHeight - 1;
+ int wheel = Mouse.getEventDWheel();
+ if (wheel != 0) {
+ if (aliasList != null) aliasList.mouseWheel(mouseX, mouseY, wheel);
+ if (placementList != null) placementList.mouseWheel(mouseX, mouseY, wheel);
+ captureScroll();
+ }
+ super.handleMouseInput();
+ }
+
+ @Override
+ public void onClose() {
+ syncName();
+ captureScroll();
+ minecraft.displayGuiScreen(parent);
+ }
+
+ @Override
+ public void render(int mouseX, int mouseY, float partialTick) {
+ renderBackground();
+ drawCenteredString(font, title, width / 2, 8, 0xFFFFFF);
+ drawString(font, new TextComponentTranslation("option.orespawn.ore_source.group_name"),
+ contentX + 2, NAME_TOP + 6, 0xA0A0A0);
+ drawString(font, new TextComponentTranslation("label.orespawn.ore_source.aliases"),
+ contentX + 2, ALIAS_LABEL_TOP, 0xA0A0A0);
+ drawString(font, new TextComponentTranslation("label.orespawn.ore_source.placement_rules"),
+ contentX + 2, placementLabelY, 0xFFFFFF);
+ OreSourceGroup group = selectedGroup();
+ if (group != null && group.oreDictionaryEntries.isEmpty()) {
+ drawCenteredString(font, new TextComponentTranslation("label.orespawn.ore_source.no_aliases"),
+ contentX + (contentWidth / 2), ALIAS_TOP + 4, 0x777777);
+ }
+ super.render(mouseX, mouseY, partialTick);
+ if (error != null) {
+ int messageWidth = validationMessageWidth(contentWidth);
+ TextComponentString message = new TextComponentString(error);
+ drawString(font, new TextComponentString(OreSpawnScreenLayout.fit(font,
+ message, messageWidth)), contentX + 2, height - 22, 0xFF5555);
+ }
+ OreSpawnScreenLayout.renderExplanations(this, mouseX, mouseY);
+ if (error != null) {
+ int messageWidth = validationMessageWidth(contentWidth);
+ if (mouseX >= contentX && mouseX < contentX + messageWidth
+ && mouseY >= height - 26 && mouseY < height - 8) {
+ renderStringTooltip(java.util.Collections.singletonList(error), mouseX, mouseY);
+ }
+ }
+ }
+}
diff --git a/src/main/java/zone/moddev/mc/orespawn/client/OreSourceListScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceListScreen.java
new file mode 100644
index 00000000..281cb6e8
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceListScreen.java
@@ -0,0 +1,517 @@
+package zone.moddev.mc.orespawn.client;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.lwjgl.input.Mouse;
+
+import zone.moddev.mc.orespawn.client.GeologyEditorSession.OreSourceCandidate;
+import zone.moddev.mc.orespawn.client.GeologyEditorSession.OreSourceGroup;
+
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.gui.GuiScreen;
+import net.minecraft.client.renderer.GlStateManager;
+import net.minecraft.client.resources.I18n;
+import net.minecraft.util.ResourceLocation;
+import net.minecraft.util.text.TextComponentString;
+import net.minecraft.util.text.TextComponentTranslation;
+
+/** Two-pane material-group editor backed by compact independently scrolling lists. */
+final class OreSourceListScreen extends OreSpawnScreen {
+ private static final ResourceLocation COG =
+ new ResourceLocation("orespawn", "textures/gui/cog.png");
+ private static final int HORIZONTAL_MARGIN = 10;
+ private static final int PANE_GAP = 6;
+ private static final int CONTENT_TOP = 24;
+ private final GuiScreen parent;
+ private final GeologyEditorSession session;
+ private final Map weights = new LinkedHashMap<>();
+ private String selectedKey;
+ private String error;
+ private boolean showAll;
+ private boolean resetArmed;
+ private int groupScroll;
+ private int outputScroll;
+ private int leftPaneX;
+ private int leftPaneWidth;
+ private int rightPaneX;
+ private int rightPaneWidth;
+ private int paneBottom;
+ private CompactScrollList groupList;
+ private CompactScrollList outputList;
+ private List visibleCandidates = java.util.Collections.emptyList();
+
+ OreSourceListScreen(GuiScreen parent, GeologyEditorSession session) {
+ super(new TextComponentTranslation("screen.orespawn.ore_sources"));
+ this.parent = parent;
+ this.session = session;
+ }
+
+ @Override
+ protected void init() {
+ OreSpawnScreenLayout.beginHelp(this);
+ weights.clear();
+ List groups = orderedGroups();
+ if (selected(groups) == null && !groups.isEmpty()) selectedKey = groups.get(0).key;
+
+ int contentWidth = contentWidth(width);
+ leftPaneX = (width - contentWidth) / 2;
+ leftPaneWidth = leftPaneWidth(width);
+ rightPaneX = leftPaneX + leftPaneWidth + PANE_GAP;
+ rightPaneWidth = contentWidth - leftPaneWidth - PANE_GAP;
+ paneBottom = height - 32;
+
+ initGroups(groups);
+ initOutputs(selected(groups));
+ String resetLabel = I18n.format(resetArmed
+ ? "button.orespawn.ore_source.confirm_reset_all"
+ : "button.orespawn.ore_source.reset_all");
+ int resetWidth = Math.min(leftPaneWidth, Math.max(54, font.getStringWidth(resetLabel) + 10));
+ Button reset = addButton(OreSpawnScreenLayout.button(this, font,
+ leftPaneX, height - 28, resetWidth, 20,
+ new TextComponentString(resetLabel), button -> resetAll()));
+ OreSpawnScreenLayout.explain(this, reset, "tooltip.orespawn.ore_source.reset_all");
+ int doneWidth = Math.min(150, rightPaneWidth);
+ addButton(new Button(rightPaneX + rightPaneWidth - doneWidth, height - 28, doneWidth, 20,
+ DialogTexts.GUI_DONE, button -> onClose()));
+ }
+
+ private void resetAll() {
+ if (!resetArmed) {
+ resetArmed = true;
+ error = null;
+ rebuild();
+ return;
+ }
+ session.resetOreSourcesToDefaults();
+ selectedKey = null;
+ groupScroll = 0;
+ outputScroll = 0;
+ showAll = false;
+ resetArmed = false;
+ error = null;
+ rebuild();
+ }
+
+ private void initGroups(final List groups) {
+ int addX = leftPaneX + font.getStringWidth(I18n.format("label.orespawn.ore_source.groups")) + 8;
+ Button add = addButton(new Button(addX, 4, 16, 16,
+ new TextComponentString("+"), button -> {
+ if (!syncWeights()) return;
+ captureScroll();
+ selectedKey = session.addOreMaterialGroup();
+ outputScroll = 0;
+ error = null;
+ rebuild();
+ }));
+ OreSpawnScreenLayout.explain(this, add, "button.orespawn.ore_source.add_group");
+ int showX = addX + 20;
+ String showLabel = I18n.format(showAll
+ ? "button.orespawn.ore_source.hide_single"
+ : "button.orespawn.show_all");
+ int showWidth = compactFilterWidth(leftPaneX + leftPaneWidth - showX,
+ font.getStringWidth(showLabel));
+ Button show = addButton(OreSpawnScreenLayout.button(this, font,
+ showX, 4, showWidth, 16,
+ new TextComponentString(showLabel), button -> {
+ if (!syncWeights()) return;
+ captureScroll();
+ showAll = !showAll;
+ groupScroll = 0;
+ error = null;
+ rebuild();
+ }));
+ OreSpawnScreenLayout.explain(this, show, "tooltip.orespawn.ore_source.group_colours");
+
+ groupList = addButton(new CompactScrollList(this, leftPaneX, CONTENT_TOP,
+ leftPaneWidth, paneBottom - CONTENT_TOP) {
+ @Override protected int size() { return groups.size(); }
+
+ @Override protected String rowText(int index) {
+ OreSourceGroup group = groups.get(index);
+ return group.displayName + " - " + display(group.domain);
+ }
+
+ @Override protected int rowColor(int index) {
+ return groupRowColor(groups.get(index));
+ }
+
+ @Override protected boolean rowSelected(int index) {
+ return groups.get(index).key.equals(selectedKey);
+ }
+
+ @Override protected void onRowPressed(int index) {
+ if (!syncWeights()) return;
+ captureScroll();
+ selectedKey = groups.get(index).key;
+ outputScroll = 0;
+ error = null;
+ rebuild();
+ }
+
+ @Override protected List rowTooltip(int index) {
+ return groupTooltip(groups.get(index));
+ }
+
+ @Override protected int rowActionWidth(int index) { return 16; }
+
+ @Override protected void drawRowAction(Minecraft minecraft, int index,
+ int x, int y, int actionWidth, int rowHeight, boolean hovered) {
+ drawCompactCog(minecraft, x, y, actionWidth, rowHeight, hovered);
+ }
+
+ @Override protected void onRowActionPressed(int index) {
+ if (!syncWeights()) return;
+ captureScroll();
+ selectedKey = groups.get(index).key;
+ minecraft.displayGuiScreen(new OreSourceGroupSettingsScreen(
+ OreSourceListScreen.this, session, selectedKey));
+ }
+
+ @Override protected List rowActionTooltip(int index) {
+ return java.util.Collections.singletonList(
+ I18n.format("tooltip.orespawn.ore_source.group_settings"));
+ }
+ });
+ groupList.setFirstIndex(groupScroll);
+ int selected = groupIndexOf(groups, selectedKey);
+ if (selected >= 0) groupList.ensureVisible(selected);
+ }
+
+ private void initOutputs(final OreSourceGroup group) {
+ if (group == null) {
+ visibleCandidates = java.util.Collections.emptyList();
+ return;
+ }
+ String mode = "keep_separate".equals(group.mode) ? "keep_original" : group.outputMode;
+ String acceptLabel = I18n.format("button.orespawn.ore_source.accept");
+ int acceptWidth = group.needsReview()
+ ? Math.min(Math.max(42, font.getStringWidth(acceptLabel) + 10), rightPaneWidth / 3)
+ : 0;
+ int modeWidth = rightPaneWidth - (acceptWidth == 0 ? 0 : acceptWidth + 4);
+ Button modeButton = addButton(OreSpawnScreenLayout.button(this, font,
+ rightPaneX, CONTENT_TOP, modeWidth, 20,
+ new TextComponentTranslation("option.orespawn.ore_source.output_mode",
+ new TextComponentTranslation("mode.orespawn.ore_source." + mode).getFormattedText()),
+ button -> {
+ if (!syncWeights()) return;
+ cycleMode(group);
+ outputScroll = 0;
+ rebuild();
+ }));
+ OreSpawnScreenLayout.explain(this, modeButton, "tooltip.orespawn.ore_source.output_mode");
+ if (acceptWidth > 0) {
+ Button accept = addButton(OreSpawnScreenLayout.button(this, font,
+ rightPaneX + modeWidth + 4, CONTENT_TOP, acceptWidth, 20,
+ new TextComponentString(acceptLabel), button -> {
+ if (!syncWeights()) return;
+ captureScroll();
+ session.acceptOreSourcePolicy(group.key);
+ error = null;
+ rebuild();
+ }));
+ OreSpawnScreenLayout.explain(this, accept, "tooltip.orespawn.ore_source.accept");
+ }
+
+ visibleCandidates = group.outputCandidates();
+ final List candidates = visibleCandidates;
+ outputList = addButton(new CompactScrollList(this, rightPaneX, CONTENT_TOP + 24,
+ rightPaneWidth, paneBottom - CONTENT_TOP - 24) {
+ @Override protected int size() { return candidates.size(); }
+
+ @Override protected String rowText(int index) {
+ OreSourceCandidate candidate = candidates.get(index);
+ boolean selected = group.outputs.containsKey(candidate.sourceId);
+ boolean single = "single".equals(group.outputMode)
+ && !"keep_separate".equals(group.mode);
+ String marker = single ? (selected ? "(*) " : "( ) ")
+ : (selected ? "[x] " : "[ ] ");
+ return marker + candidateLabel(candidate);
+ }
+
+ @Override protected boolean rowEnabled(int index) {
+ OreSourceCandidate candidate = candidates.get(index);
+ return candidate.loaded && !candidate.enrichment
+ && !"keep_separate".equals(group.mode);
+ }
+
+ @Override protected boolean rowSelected(int index) {
+ return group.outputs.containsKey(candidates.get(index).sourceId);
+ }
+
+ @Override protected void onRowPressed(int index) {
+ if (!syncWeights()) return;
+ OreSourceCandidate candidate = candidates.get(index);
+ boolean single = "single".equals(group.outputMode)
+ && !"keep_separate".equals(group.mode);
+ session.setOreSourceOutput(group.key, candidate.sourceId,
+ single || !group.outputs.containsKey(candidate.sourceId),
+ group.outputs.containsKey(candidate.sourceId)
+ ? group.outputs.get(candidate.sourceId) : 1.0D);
+ captureScroll();
+ rebuild();
+ }
+
+ @Override protected List rowTooltip(int index) {
+ return candidateTooltip(candidates.get(index));
+ }
+
+ @Override protected int rowActionWidth(int index) {
+ OreSourceCandidate candidate = candidates.get(index);
+ return "custom".equals(group.outputMode)
+ && !"keep_separate".equals(group.mode)
+ && group.outputs.containsKey(candidate.sourceId) ? 48 : 0;
+ }
+ });
+ outputList.setFirstIndex(outputScroll);
+
+ if ("custom".equals(group.outputMode) && !"keep_separate".equals(group.mode)) {
+ for (OreSourceCandidate candidate : candidates) {
+ if (!group.outputs.containsKey(candidate.sourceId)) continue;
+ TextFieldWidget weight = addButton(new TextFieldWidget(font, 0, 0, 42, 14,
+ new TextComponentTranslation("option.orespawn.ore_source.weight")));
+ weight.setMaxLength(12);
+ weight.setValue(formatWeight(group.outputs.get(candidate.sourceId)));
+ weight.enabled = candidate.loaded && !candidate.enrichment;
+ weight.visible = false;
+ weights.put(candidate.sourceId, weight);
+ }
+ }
+ }
+
+ private void updateWeightBounds() {
+ if (outputList == null) return;
+ for (int index = 0; index < visibleCandidates.size(); index++) {
+ OreSourceCandidate candidate = visibleCandidates.get(index);
+ TextFieldWidget weight = weights.get(candidate.sourceId);
+ if (weight == null) continue;
+ int y = outputList.rowY(index);
+ weight.visible = y >= 0;
+ if (weight.visible) weight.setBounds(outputList.contentRight() - 45, y + 1, 42, 14);
+ }
+ }
+
+ private void cycleMode(OreSourceGroup group) {
+ resetArmed = false;
+ if (!group.hasManagedPlacementSource()) {
+ if (!"keep_separate".equals(group.mode)) session.restoreOreSourceOriginalMode(group.key);
+ error = I18n.format("label.orespawn.ore_source.no_placement_rules");
+ return;
+ }
+ error = null;
+ if ("keep_separate".equals(group.mode)) session.setOreSourceOutputMode(group.key, "balanced");
+ else if ("balanced".equals(group.outputMode)) session.setOreSourceOutputMode(group.key, "single");
+ else if ("single".equals(group.outputMode)) session.setOreSourceOutputMode(group.key, "custom");
+ else session.restoreOreSourceOriginalMode(group.key);
+ }
+
+ private boolean syncWeights() {
+ OreSourceGroup group = selected(orderedGroups());
+ if (group == null || !"custom".equals(group.outputMode)) return true;
+ for (Map.Entry entry : weights.entrySet()) {
+ if (!group.outputs.containsKey(entry.getKey())) continue;
+ try {
+ double value = Double.parseDouble(entry.getValue().getValue().trim());
+ if (!Double.isFinite(value) || value <= 0.0D) throw new NumberFormatException();
+ session.setOreSourceOutput(group.key, entry.getKey(), true, value);
+ } catch (NumberFormatException invalid) {
+ error = I18n.format("error.orespawn.ore_source.weight");
+ return false;
+ }
+ }
+ error = null;
+ return true;
+ }
+
+ private void captureScroll() {
+ resetArmed = false;
+ if (groupList != null) groupScroll = groupList.firstIndex();
+ if (outputList != null) outputScroll = outputList.firstIndex();
+ }
+
+ private List orderedGroups() {
+ List result = new ArrayList<>();
+ for (OreSourceGroup group : session.oreSourceGroups()) {
+ if (showAll || showByDefault(group)) result.add(group);
+ }
+ result.sort((left, right) -> {
+ int leftRank = groupStatusRank(left);
+ int rightRank = groupStatusRank(right);
+ if (leftRank != rightRank) return Integer.compare(leftRank, rightRank);
+ 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 result;
+ }
+
+ static boolean showByDefault(OreSourceGroup group) {
+ return !group.isRoutineSingleSource();
+ }
+
+ static int groupRowColor(OreSourceGroup group) {
+ return group.needsAttention() ? 0xFF5555
+ : group.isRoutineSingleSource() ? 0x55FF55 : 0xFFFF55;
+ }
+
+ private static int groupStatusRank(OreSourceGroup group) {
+ return group.needsAttention() ? 0 : group.isRoutineSingleSource() ? 2 : 1;
+ }
+
+ private OreSourceGroup selected(List groups) {
+ if (selectedKey == null) return null;
+ for (OreSourceGroup group : groups) if (selectedKey.equals(group.key)) return group;
+ return null;
+ }
+
+ static List placementCandidates(OreSourceGroup group, String channel) {
+ List result = new ArrayList<>();
+ for (OreSourceCandidate candidate : group.candidates) {
+ if (candidate.loaded && candidate.active && !candidate.external && !candidate.enrichment
+ && channel.equals(candidate.channel)) result.add(candidate);
+ }
+ return result;
+ }
+
+ static int indexOf(List candidates, String sourceId) {
+ for (int index = 0; index < candidates.size(); index++) {
+ if (candidates.get(index).sourceId.equals(sourceId)) return index;
+ }
+ return -1;
+ }
+
+ static int groupIndexOf(List groups, String key) {
+ for (int index = 0; index < groups.size(); index++) {
+ if (groups.get(index).key.equals(key)) return index;
+ }
+ return -1;
+ }
+
+ static String candidateLabel(OreSourceCandidate candidate) {
+ String owner = candidate.ownerName.isEmpty() ? candidate.owner : candidate.ownerName;
+ String state = candidate.external ? I18n.format("status.orespawn.ore_source.external_short")
+ : !candidate.loaded ? I18n.format("status.orespawn.ore_source.missing_short")
+ : !candidate.active ? I18n.format("status.orespawn.ore_source.output_only") : "";
+ return owner + " - " + candidate.registryId + (state.isEmpty() ? "" : " (" + state + ")");
+ }
+
+ static List candidateTooltip(OreSourceCandidate candidate) {
+ List result = new ArrayList<>();
+ result.add(I18n.format("label.orespawn.ore_source.owner", candidate.owner,
+ candidate.ownerVersion.isEmpty() ? "?" : candidate.ownerVersion));
+ result.add(I18n.format("label.orespawn.ore_source.registry", candidate.registryId, candidate.metadata));
+ result.add(I18n.format("label.orespawn.ore_source.dictionary",
+ candidate.oreDictionary.isEmpty() ? "-" : String.join(", ", candidate.oreDictionary)));
+ result.add(I18n.format(candidate.external ? "tooltip.orespawn.ore_source.external"
+ : !candidate.loaded ? "tooltip.orespawn.ore_source.missing"
+ : !candidate.active ? "tooltip.orespawn.ore_source.output_only"
+ : "tooltip.orespawn.ore_source.controlled"));
+ return result;
+ }
+
+ static List groupTooltip(OreSourceGroup group) {
+ List result = new ArrayList<>();
+ result.add(group.material);
+ result.add(I18n.format("label.orespawn.ore_source.summary", group.outputCandidates().size(),
+ I18n.format("status.orespawn.ore_source." + group.status)));
+ result.add(group.oreDictionaryEntries.isEmpty() ? "-"
+ : String.join(", ", group.oreDictionaryEntries));
+ result.add(I18n.format("tooltip.orespawn.ore_source.group_colours"));
+ return result;
+ }
+
+ static String formatWeight(Double weight) {
+ if (weight == null) return "1";
+ long whole = weight.longValue();
+ return weight.doubleValue() == whole ? Long.toString(whole) : Double.toString(weight.doubleValue());
+ }
+
+ static String display(String id) {
+ int split = id.indexOf(':');
+ String value = (split < 0 ? id : id.substring(split + 1)).replace('_', ' ').replace('/', ' ');
+ return value.isEmpty() ? value : Character.toUpperCase(value.charAt(0)) + value.substring(1);
+ }
+
+ private static void drawCompactCog(Minecraft minecraft, int x, int y,
+ int width, int height, boolean hovered) {
+ if (hovered) drawRect(x, y, x + width, y + height, 0x805F5F5F);
+ minecraft.getTextureManager().bindTexture(COG);
+ GlStateManager.color(1.0F, 1.0F, 1.0F, 1.0F);
+ GlStateManager.pushMatrix();
+ GlStateManager.translate(x + (width / 2.0F), y + (height / 2.0F), 0.0F);
+ GlStateManager.scale(0.75F, 0.75F, 1.0F);
+ drawModalRectWithCustomSizedTexture(-8, -8, 0.0F, 0.0F, 16, 16, 16.0F, 16.0F);
+ drawModalRectWithCustomSizedTexture(-8, -8, 0.0F, 0.0F, 16, 16, 16.0F, 16.0F);
+ GlStateManager.popMatrix();
+ GlStateManager.color(1.0F, 1.0F, 1.0F, 1.0F);
+ }
+
+ private void rebuild() {
+ buttons.clear();
+ children.clear();
+ init();
+ }
+
+ static int contentWidth(int width) {
+ return Math.max(240, Math.min(700, width - (HORIZONTAL_MARGIN * 2)));
+ }
+
+ static int leftPaneWidth(int width) {
+ int content = contentWidth(width);
+ return Math.max(104, Math.min(280, (content * 43) / 100));
+ }
+
+ static int listHeight(int height) {
+ return Math.max(32, (height - 32) - CONTENT_TOP);
+ }
+
+ static int compactFilterWidth(int availableWidth, int labelWidth) {
+ return Math.min(Math.max(38, labelWidth + 10), Math.max(16, availableWidth));
+ }
+
+ @Override
+ public void handleMouseInput() throws IOException {
+ int mouseX = Mouse.getEventX() * width / minecraft.displayWidth;
+ int mouseY = height - Mouse.getEventY() * height / minecraft.displayHeight - 1;
+ int wheel = Mouse.getEventDWheel();
+ if (wheel != 0) {
+ if (groupList != null) groupList.mouseWheel(mouseX, mouseY, wheel);
+ if (outputList != null) outputList.mouseWheel(mouseX, mouseY, wheel);
+ captureScroll();
+ }
+ super.handleMouseInput();
+ }
+
+ @Override
+ public void onClose() {
+ captureScroll();
+ if (syncWeights()) minecraft.displayGuiScreen(parent);
+ }
+
+ @Override
+ public void render(int mouseX, int mouseY, float partialTick) {
+ renderBackground();
+ drawRect(leftPaneX - 2, CONTENT_TOP - 2,
+ leftPaneX + leftPaneWidth + 2, paneBottom + 2, 0x70000000);
+ drawRect(rightPaneX - 2, CONTENT_TOP - 2,
+ rightPaneX + rightPaneWidth + 2, paneBottom + 2, 0x70000000);
+ drawCenteredString(font, title, width / 2, 8, 0xFFFFFF);
+ drawString(font, new TextComponentTranslation("label.orespawn.ore_source.groups"),
+ leftPaneX + 2, 8, 0xFFFFFF);
+ if (error != null) {
+ drawCenteredString(font, new TextComponentString(OreSpawnScreenLayout.fit(font,
+ new TextComponentString(error), width - 24)), width / 2, height - 39, 0xFF5555);
+ }
+ if (selected(orderedGroups()) == null) {
+ drawCenteredString(font, new TextComponentTranslation("label.orespawn.ore_source.none"),
+ leftPaneX + (leftPaneWidth / 2), CONTENT_TOP + 10, 0xA0A0A0);
+ }
+ updateWeightBounds();
+ super.render(mouseX, mouseY, partialTick);
+ OreSpawnScreenLayout.renderExplanations(this, mouseX, mouseY);
+ }
+}
diff --git a/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnScreen.java
index 065a27e1..7348838a 100644
--- a/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnScreen.java
+++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnScreen.java
@@ -98,6 +98,17 @@ protected final void renderComponentTooltip(List extends ITextComponent> lines
}
final void renderStringTooltip(List lines, int mouseX, int mouseY) {
- drawHoveringText(lines, mouseX, mouseY, fontRenderer);
+ List wrapped = new ArrayList<>();
+ int maximumWidth = tooltipWidth(width, mouseX);
+ for (String line : lines) {
+ if (line == null || line.isEmpty()) wrapped.add("");
+ else wrapped.addAll(fontRenderer.listFormattedStringToWidth(line, maximumWidth));
+ }
+ drawHoveringText(wrapped, mouseX, mouseY, fontRenderer);
+ }
+
+ static int tooltipWidth(int screenWidth, int mouseX) {
+ int room = Math.max(mouseX - 24, screenWidth - mouseX - 24);
+ return Math.max(80, Math.min(260, Math.min(screenWidth - 32, room)));
}
}
diff --git a/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayout.java b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayout.java
index 6c88fad0..b3c27bed 100644
--- a/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayout.java
+++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayout.java
@@ -103,12 +103,10 @@ static void renderExplanations(OreSpawnScreen screen, int mouseX, int mouseY) {
&& mouseX < explanation.widget.x + explanation.widget.width
&& mouseY >= explanation.widget.y
&& mouseY < explanation.widget.y + explanation.widget.height) {
- FontRenderer font = Minecraft.getMinecraft().fontRenderer;
String text = explanation.translation
? new TextComponentTranslation(explanation.text).getFormattedText()
: explanation.text;
- screen.renderStringTooltip(font.listFormattedStringToWidth(text,
- Math.max(180, Math.min(310, screen.width - 20))), mouseX, mouseY);
+ screen.renderStringTooltip(java.util.Collections.singletonList(text), mouseX, mouseY);
return;
}
}
diff --git a/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnWorldSettingsScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnWorldSettingsScreen.java
index 8f599ccb..0e0ff0e5 100644
--- a/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnWorldSettingsScreen.java
+++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnWorldSettingsScreen.java
@@ -8,6 +8,7 @@
import zone.moddev.mc.orespawn.OreSpawnConfig.GeologyMode;
import zone.moddev.mc.orespawn.worldgen.FormationSettings.Preset;
+import zone.moddev.mc.orespawn.worldgen.GeomeConfig;
import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfile;
import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfileManager;
import zone.moddev.mc.orespawn.integration.WorldgenIntegrationManager;
@@ -280,6 +281,11 @@ private void saveAndClose() {
validationError = new net.minecraft.util.text.TextComponentString(errors.get(0));
return;
}
+ if (session.oreMaterialGroupsChanged()
+ && !GeomeConfig.persistOreMaterialGroups(session.oreMaterialGroupsCopy())) {
+ validationError = new TextComponentTranslation("error.orespawn.ore_source.defaults_write");
+ return;
+ }
WorldGeologyProfileManager.setPendingNewWorldProfile(session.profile());
minecraft.displayGuiScreen(parent);
}
@@ -288,9 +294,8 @@ private void syncSession() {
WorldGeologyProfile selected = session.profile().withSelection(
geologyMode, horizontalSize, verticalThickness, waviness,
edgeIrregularity, formationContinuity, placeFluidDeposits);
- com.google.gson.JsonObject root = selected.rootCopy();
- root.addProperty("manage_vanilla_ores", manageVanillaOres);
- session.applyProfile(selected.withRoot(root));
+ session.applyProfile(selected);
+ session.setManageVanillaOres(manageVanillaOres);
}
private void openMaterials() {
diff --git a/src/main/java/zone/moddev/mc/orespawn/client/TextFieldWidget.java b/src/main/java/zone/moddev/mc/orespawn/client/TextFieldWidget.java
index d8171988..1f1c676c 100644
--- a/src/main/java/zone/moddev/mc/orespawn/client/TextFieldWidget.java
+++ b/src/main/java/zone/moddev/mc/orespawn/client/TextFieldWidget.java
@@ -48,6 +48,17 @@ void setMaxLength(int length) {
field.setMaxStringLength(length);
}
+ void setBounds(int x, int y, int width, int height) {
+ this.x = x;
+ this.y = y;
+ this.width = width;
+ this.height = height;
+ field.x = x;
+ field.y = y;
+ field.width = width;
+ field.height = height;
+ }
+
void func_212954_a(Consumer responder) {
field.setGuiResponder(new net.minecraft.client.gui.GuiPageButtonList.GuiResponder() {
@Override public void setEntryValue(int id, boolean value) { }
diff --git a/src/main/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManager.java b/src/main/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManager.java
index aa9b2b8e..aa4faa30 100644
--- a/src/main/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManager.java
+++ b/src/main/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManager.java
@@ -205,7 +205,7 @@ public static synchronized List providerIntegrations()
|| RESOURCE_PROVIDERS.containsKey(providerId)
|| FILE_PROVIDER_IDS.contains(providerId);
result.add(new ProviderIntegrationInfo(providerId, nativeOs4, hasProvider,
- root == null ? (api == null ? -1 : 4) : integer(root, "schema_version", -1),
+ root == null ? (api == null ? -1 : 5) : integer(root, "schema_version", -1),
root == null ? (api == null ? -1 : api.revision())
: integer(root, "provider_revision", -1),
getProviderStatus(providerId), INVALID_PROVIDERS.contains(providerId), legacy));
@@ -505,7 +505,7 @@ private static JsonObject selectedProviderRoot(String providerId) {
static void validateProvider(String providerId, JsonObject root) {
int schema = integer(root, "schema_version", -1);
- if (schema != 1 && schema != 2 && schema != 3 && schema != 4) {
+ if (schema != 1 && schema != 2 && schema != 3 && schema != 4 && schema != 5) {
throw new JsonSyntaxException("unsupported schema_version");
}
if (!providerId.equals(string(root, "provider_modid", ""))) {
@@ -554,7 +554,7 @@ static void validateProvider(String providerId, JsonObject root) {
if ("rocks".equals(section)) {
validateRock(entry.getKey(), entry.getValue().getAsJsonObject());
} else if ("ores".equals(section)) {
- validateOre(entry.getKey(), entry.getValue().getAsJsonObject());
+ validateOre(entry.getKey(), entry.getValue().getAsJsonObject(), schema);
} else if ("geomes".equals(section)) {
validateGeome(entry.getKey(), entry.getValue().getAsJsonObject());
} else if ("biome_rules".equals(section)) {
@@ -609,7 +609,11 @@ private static void validateRock(String idText, JsonObject rock) {
validateWeights(optionalObject(rock, "geomes"));
}
- private static void validateOre(String idText, JsonObject ore) {
+ private static void validateOre(String idText, JsonObject ore, int schema) {
+ if (schema < 5 && ore.has("material")) {
+ throw new JsonSyntaxException("ore material requires provider schema 5");
+ }
+ if (ore.has("material")) new ResourceLocation(string(ore, "material", ""));
String blockId = string(ore, "block", idText);
Block output = block(blockId);
if (output == null || output == Blocks.AIR) {
@@ -625,19 +629,25 @@ private static void validateOre(String idText, JsonObject ore) {
}
for (Entry entry : dimensions.entrySet()) {
new ResourceLocation(entry.getKey());
- validateOreRule(idText, entry);
+ validateOreRule(idText, entry, schema);
}
for (Entry entry : selectors.entrySet()) {
OreDimensionSelector.fromId(new ResourceLocation(entry.getKey()));
- validateOreRule(idText, entry);
+ validateOreRule(idText, entry, schema);
}
}
- private static void validateOreRule(String idText, Entry entry) {
+ private static void validateOreRule(String idText, Entry entry, int schema) {
if (!entry.getValue().isJsonObject()) {
throw new JsonSyntaxException("ore dimension is not an object: " + entry.getKey());
}
JsonObject rule = entry.getValue().getAsJsonObject();
+ if (schema < 5 && rule.has("placement_channel")) {
+ throw new JsonSyntaxException("ore placement_channel requires provider schema 5");
+ }
+ if (rule.has("placement_channel")) {
+ new ResourceLocation(string(rule, "placement_channel", ""));
+ }
if (!bool(rule, "enabled", true)) return;
int minY = integer(rule, "min_y", Integer.MIN_VALUE);
int maxY = integer(rule, "max_y", Integer.MIN_VALUE);
diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeConfig.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeConfig.java
index 5d2a348f..95316031 100644
--- a/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeConfig.java
+++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeConfig.java
@@ -62,6 +62,10 @@ public final class GeomeConfig {
private static final Path CONFIG_V3_BACKUP_PATH = Paths.get("config", "orespawn-worldgen.v3.bak");
private static final Path CONFIG_V4_BACKUP_PATH = Paths.get("config", "orespawn-worldgen.v4.bak");
private static final Path CONFIG_V5_BACKUP_PATH = Paths.get("config", "orespawn-worldgen.v5.bak");
+ private static final Path CONFIG_V6_BACKUP_PATH = Paths.get("config", "orespawn-worldgen.v6.bak");
+ private static final Path CONFIG_V7_BACKUP_PATH = Paths.get("config", "orespawn-worldgen.v7.bak");
+ private static final Path MATERIAL_GROUPS_BACKUP_PATH = Paths.get("config",
+ "orespawn-worldgen.pre-material-groups.bak");
private static final Path BIOME_DEFAULTS_BACKUP_PATH = Paths.get("config",
"orespawn-worldgen.pre-biome-revision-3.bak");
private static final Path WORLDGEN_ALIAS_DEFAULTS_BACKUP_PATH = Paths.get("config",
@@ -71,7 +75,7 @@ public final class GeomeConfig {
private static final Path CONFIG_TEMP_PATH = Paths.get("config", "orespawn-worldgen.json.tmp");
private static final Path PROVIDER_DEFAULTS_BACKUP_PATH = Paths.get("config",
"orespawn-worldgen.pre-provider-defaults.bak");
- public static final int SCHEMA_VERSION = 6;
+ public static final int SCHEMA_VERSION = 8;
private static final int BIOME_DEFAULTS_REVISION = 3;
private static final int WORLDGEN_ALIAS_DEFAULTS_REVISION = 1;
private static final int ORE_DEFAULTS_REVISION = 10;
@@ -97,6 +101,7 @@ public static synchronized BakedGeomeConfig bake() {
JsonObject root = loadConfig();
boolean changed = OreSpawnOreIntegration.mergeProviderOres(root);
changed |= FluidDepositMigration.normalize(root);
+ changed |= OreSourcePolicies.initialize(root, false);
if (changed) {
writeProviderMerge(root);
}
@@ -162,6 +167,23 @@ public static WorldGeologyProfile globalBaseProfile() {
return globalBaseProfile;
}
+ /** Persists only the reusable material-group defaults accepted by the world editor. */
+ public static synchronized boolean persistOreMaterialGroups(JsonObject groups) {
+ JsonObject updated = globalBaseConfigSnapshot();
+ updated.add(OreMaterialGroups.SECTION, JsonCopies.copy(groups));
+ OreMaterialGroups.initialize(updated);
+ updated.addProperty("schema_version", SCHEMA_VERSION);
+ if (!writeUpdatedConfig(updated, MATERIAL_GROUPS_BACKUP_PATH)) return false;
+ globalBaseConfigRoot = JsonCopies.copy(updated);
+ globalBaseProfile = WorldGeologyProfile.fromGlobalConfig(updated,
+ OreSpawnConfig.geologyMode(), OreSpawnConfig.placeCrudeOil());
+ JsonObject effective = applyFreshWorldTemplate(updated);
+ globalConfigRoot = JsonCopies.copy(effective);
+ globalProfile = WorldGeologyProfile.fromGlobalConfig(effective,
+ OreSpawnConfig.geologyMode(), OreSpawnConfig.placeCrudeOil());
+ return true;
+ }
+
static boolean hasTerrainReplacement(ResourceLocation dimension) {
return terrainDimension(dimension) != null;
}
@@ -194,13 +216,15 @@ private static JsonObject loadConfig() {
int schemaVersion = getInt(root, "schema_version", 1);
if (schemaVersion < SCHEMA_VERSION) {
JsonObject migrated = schemaVersion <= 1 ? migrateV1(root) : JsonCopies.copy(root);
- migrated = migrateToV6(migrated, defaults);
+ migrated = migrateToV8(migrated, defaults);
writeMigratedConfig(migrated,
schemaVersion <= 1 ? CONFIG_BACKUP_PATH
: schemaVersion == 2 ? CONFIG_V2_BACKUP_PATH
: schemaVersion == 3 ? CONFIG_V3_BACKUP_PATH
: schemaVersion == 4 ? CONFIG_V4_BACKUP_PATH
- : CONFIG_V5_BACKUP_PATH);
+ : schemaVersion == 5 ? CONFIG_V5_BACKUP_PATH
+ : schemaVersion == 6 ? CONFIG_V6_BACKUP_PATH
+ : CONFIG_V7_BACKUP_PATH);
return migrated;
}
if (schemaVersion > SCHEMA_VERSION) {
@@ -663,14 +687,15 @@ private static JsonObject migrateV1(JsonObject original) {
return refreshBiomeDefaults(migrated, defaultConfig());
}
- private static JsonObject migrateToV6(JsonObject original, JsonObject defaults) {
+ private static JsonObject migrateToV8(JsonObject original, JsonObject defaults) {
JsonObject migrated = getInt(original, "biome_defaults_revision", 0) < BIOME_DEFAULTS_REVISION
? refreshBiomeDefaults(original, defaults) : JsonCopies.copy(original);
FluidDepositMigration.normalize(migrated);
for (String key : new String[] { "geology_mode", "place_fluid_deposits", "fluid_deposits",
"manage_vanilla_ores", "ore_defaults_revision", "cyano", "ores",
"ore_providers", "providers", "worldgen_aliases", "default_template",
- "terrain_dimensions", "biome_palettes", "dimension_materials" }) {
+ "terrain_dimensions", "biome_palettes", "dimension_materials",
+ OreMaterialGroups.SECTION, OreSourcePolicies.SECTION }) {
if (!migrated.has(key)) {
migrated.add(key, JsonCopies.copy(defaults.get(key)));
}
@@ -1414,6 +1439,8 @@ private static JsonObject defaultConfig() {
root.add("cyano", defaultCyanoConfig());
root.add("fluid_deposits", new JsonObject());
root.add("ores", defaultOreConfig());
+ root.add(OreMaterialGroups.SECTION, OreMaterialGroups.defaults());
+ root.add(OreSourcePolicies.SECTION, new JsonObject());
root.add("ore_providers", new JsonObject());
root.add("providers", new JsonObject());
root.add("terrain_dimensions", new JsonObject());
diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreMaterialGroups.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreMaterialGroups.java
new file mode 100644
index 00000000..f1541794
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreMaterialGroups.java
@@ -0,0 +1,251 @@
+package zone.moddev.mc.orespawn.worldgen;
+
+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.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonPrimitive;
+
+import net.minecraft.util.ResourceLocation;
+import zone.moddev.mc.orespawn.util.JsonCopies;
+
+/** Stable material names and exact Ore Dictionary aliases used by source arbitration. */
+final class OreMaterialGroups {
+ static final String SECTION = "ore_material_groups";
+ static final ResourceLocation REVIEW = new ResourceLocation("orespawn", "review_required");
+ private static final ResourceLocation SULFUR = new ResourceLocation("orespawn", "sulfur");
+ private static final ResourceLocation ALUMINUM = new ResourceLocation("orespawn", "aluminum");
+
+ private OreMaterialGroups() { }
+
+ static boolean initialize(JsonObject root) {
+ JsonObject before = root.has(SECTION) && root.get(SECTION).isJsonObject()
+ ? JsonCopies.copy(root.getAsJsonObject(SECTION)) : new JsonObject();
+ JsonObject normalized = normalize(before);
+ mergeCurated(normalized, SULFUR, "Sulfur", "oreSulfur", "oreSulphur");
+ mergeCurated(normalized, ALUMINUM, "Aluminum", "oreAluminum", "oreAluminium");
+ deduplicateAliases(normalized);
+ root.add(SECTION, normalized);
+ return !before.toString().equals(normalized.toString());
+ }
+
+ static JsonObject defaults() {
+ JsonObject root = new JsonObject();
+ mergeCurated(root, SULFUR, "Sulfur", "oreSulfur", "oreSulphur");
+ mergeCurated(root, ALUMINUM, "Aluminum", "oreAluminum", "oreAluminium");
+ return root;
+ }
+
+ static Inference infer(JsonObject root, Iterable oreNames) {
+ Map owners = aliases(root);
+ Set materials = new LinkedHashSet<>();
+ List exact = new ArrayList<>();
+ for (String name : oreNames) {
+ if (!validOreName(name)) continue;
+ exact.add(name);
+ ResourceLocation material = owners.get(name);
+ if (material == null) {
+ String token = name.substring(3).toLowerCase(Locale.ROOT);
+ try { material = new ResourceLocation("orespawn", token); }
+ catch (RuntimeException invalid) { material = REVIEW; }
+ }
+ materials.add(material);
+ }
+ Collections.sort(exact);
+ if (materials.size() == 1) {
+ ResourceLocation material = materials.iterator().next();
+ return new Inference(material, REVIEW.equals(material), exact);
+ }
+ return new Inference(materials.size() > 1 ? REVIEW : null, materials.size() > 1, exact);
+ }
+
+ static List definitions(JsonObject root) {
+ JsonObject groups = root.has(SECTION) && root.get(SECTION).isJsonObject()
+ ? root.getAsJsonObject(SECTION) : defaults();
+ List result = new ArrayList<>();
+ for (Entry entry : groups.entrySet()) {
+ ResourceLocation id = resource(entry.getKey());
+ if (id == null || !entry.getValue().isJsonObject()) continue;
+ JsonObject value = entry.getValue().getAsJsonObject();
+ List names = new ArrayList<>();
+ if (value.has("ore_dictionary_entries") && value.get("ore_dictionary_entries").isJsonArray()) {
+ for (JsonElement element : value.getAsJsonArray("ore_dictionary_entries")) {
+ try {
+ String name = element.getAsString();
+ if (validOreName(name) && !names.contains(name)) names.add(name);
+ } catch (RuntimeException ignored) { }
+ }
+ }
+ Collections.sort(names);
+ String display = string(value, "display_name", humanize(id.getPath()));
+ result.add(new Definition(id, display, names, curated(id)));
+ }
+ result.sort(Comparator.comparing((Definition value) -> value.displayName.toLowerCase(Locale.ROOT))
+ .thenComparing(value -> value.id.toString()));
+ return Collections.unmodifiableList(result);
+ }
+
+ static Definition definition(JsonObject root, ResourceLocation id) {
+ for (Definition definition : definitions(root)) if (definition.id.equals(id)) return definition;
+ return new Definition(id, humanize(id.getPath()), Collections.emptyList(), curated(id));
+ }
+
+ static boolean ensureDefinition(JsonObject root, ResourceLocation id, Iterable oreNames) {
+ JsonObject groups = root.has(SECTION) && root.get(SECTION).isJsonObject()
+ ? root.getAsJsonObject(SECTION) : defaults();
+ boolean changed = !root.has(SECTION) || !root.get(SECTION).isJsonObject();
+ JsonObject value;
+ if (groups.has(id.toString()) && groups.get(id.toString()).isJsonObject()) {
+ value = groups.getAsJsonObject(id.toString());
+ } else {
+ value = new JsonObject();
+ value.addProperty("display_name", humanize(id.getPath()));
+ value.add("ore_dictionary_entries", new JsonArray());
+ groups.add(id.toString(), value);
+ changed = true;
+ }
+ JsonArray aliases = value.has("ore_dictionary_entries")
+ && value.get("ore_dictionary_entries").isJsonArray()
+ ? value.getAsJsonArray("ore_dictionary_entries") : new JsonArray();
+ if (!value.has("ore_dictionary_entries") || !value.get("ore_dictionary_entries").isJsonArray()) {
+ value.add("ore_dictionary_entries", aliases);
+ changed = true;
+ }
+ Set present = new LinkedHashSet<>();
+ for (JsonElement alias : aliases) {
+ try { present.add(alias.getAsString()); } catch (RuntimeException ignored) { }
+ }
+ Map owners = aliases(root);
+ for (String name : oreNames) {
+ if (!validOreName(name) || present.contains(name)) continue;
+ ResourceLocation owner = owners.get(name);
+ if (owner != null && !owner.equals(id)) continue;
+ aliases.add(new JsonPrimitive(name));
+ present.add(name);
+ changed = true;
+ }
+ root.add(SECTION, groups);
+ return changed;
+ }
+
+ static boolean validOreName(String name) {
+ return name != null && name.length() > 3 && name.length() <= 128
+ && name.startsWith("ore") && Character.isUpperCase(name.charAt(3))
+ && name.matches("[A-Za-z0-9_]+") ;
+ }
+
+ static boolean curated(ResourceLocation id) {
+ return SULFUR.equals(id) || ALUMINUM.equals(id);
+ }
+
+ private static JsonObject normalize(JsonObject groups) {
+ JsonObject result = new JsonObject();
+ for (Entry entry : groups.entrySet()) {
+ ResourceLocation id = resource(entry.getKey());
+ if (id == null || !entry.getValue().isJsonObject()) continue;
+ JsonObject source = entry.getValue().getAsJsonObject();
+ JsonObject value = new JsonObject();
+ value.addProperty("display_name", string(source, "display_name", humanize(id.getPath())));
+ JsonArray names = new JsonArray();
+ Set seen = new LinkedHashSet<>();
+ if (source.has("ore_dictionary_entries") && source.get("ore_dictionary_entries").isJsonArray()) {
+ for (JsonElement element : source.getAsJsonArray("ore_dictionary_entries")) {
+ try {
+ String name = element.getAsString();
+ if (validOreName(name) && seen.add(name)) names.add(new JsonPrimitive(name));
+ } catch (RuntimeException ignored) { }
+ }
+ }
+ value.add("ore_dictionary_entries", names);
+ result.add(id.toString(), value);
+ }
+ return result;
+ }
+
+ private static void mergeCurated(JsonObject groups, ResourceLocation id, String display, String... aliases) {
+ if (groups.has(id.toString()) && groups.get(id.toString()).isJsonObject()) return;
+ JsonObject value = new JsonObject();
+ value.addProperty("display_name", display);
+ JsonArray names = new JsonArray();
+ for (String alias : aliases) names.add(new JsonPrimitive(alias));
+ value.add("ore_dictionary_entries", names);
+ groups.add(id.toString(), value);
+ }
+
+ private static void deduplicateAliases(JsonObject groups) {
+ Set claimed = new LinkedHashSet<>();
+ for (Entry entry : groups.entrySet()) {
+ if (!entry.getValue().isJsonObject()) continue;
+ JsonObject value = entry.getValue().getAsJsonObject();
+ JsonArray unique = new JsonArray();
+ if (value.has("ore_dictionary_entries") && value.get("ore_dictionary_entries").isJsonArray()) {
+ for (JsonElement element : value.getAsJsonArray("ore_dictionary_entries")) {
+ try {
+ String name = element.getAsString();
+ if (validOreName(name) && claimed.add(name)) unique.add(new JsonPrimitive(name));
+ } catch (RuntimeException ignored) { }
+ }
+ }
+ value.add("ore_dictionary_entries", unique);
+ }
+ }
+
+ private static Map aliases(JsonObject root) {
+ Map result = new LinkedHashMap<>();
+ for (Definition definition : definitions(root)) {
+ for (String name : definition.oreDictionaryEntries) result.putIfAbsent(name, definition.id);
+ }
+ return result;
+ }
+
+ private static ResourceLocation resource(String value) {
+ try { return value == null || value.trim().isEmpty() ? null : new ResourceLocation(value); }
+ catch (RuntimeException ignored) { return null; }
+ }
+
+ private static String string(JsonObject root, String key, String fallback) {
+ try { return root.has(key) ? root.get(key).getAsString() : fallback; }
+ catch (RuntimeException ignored) { return fallback; }
+ }
+
+ private static String humanize(String path) {
+ String value = path.replace('_', ' ').replace('/', ' ');
+ return value.isEmpty() ? value : Character.toUpperCase(value.charAt(0)) + value.substring(1);
+ }
+
+ static final class Definition {
+ final ResourceLocation id;
+ final String displayName;
+ final List oreDictionaryEntries;
+ final boolean curated;
+
+ Definition(ResourceLocation id, String displayName, List oreDictionaryEntries, boolean curated) {
+ this.id = id;
+ this.displayName = displayName;
+ this.oreDictionaryEntries = Collections.unmodifiableList(new ArrayList<>(oreDictionaryEntries));
+ this.curated = curated;
+ }
+ }
+
+ static final class Inference {
+ final ResourceLocation material;
+ final boolean reviewRequired;
+ final List names;
+
+ Inference(ResourceLocation material, boolean reviewRequired, List names) {
+ this.material = material;
+ this.reviewRequired = reviewRequired;
+ this.names = Collections.unmodifiableList(new ArrayList<>(names));
+ }
+ }
+}
diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSourcePolicies.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSourcePolicies.java
new file mode 100644
index 00000000..0e3f4941
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSourcePolicies.java
@@ -0,0 +1,797 @@
+package zone.moddev.mc.orespawn.worldgen;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+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.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonPrimitive;
+import zone.moddev.mc.orespawn.util.JsonCopies;
+
+import net.minecraft.block.Block;
+import net.minecraft.init.Blocks;
+import net.minecraft.item.ItemStack;
+import net.minecraft.util.ResourceLocation;
+import net.minecraftforge.fml.common.Loader;
+import net.minecraftforge.fml.common.ModContainer;
+import net.minecraftforge.fml.common.registry.ForgeRegistries;
+import net.minecraftforge.oredict.OreDictionary;
+
+/** Bake-time discovery and persistence for equivalent managed ore sources. */
+final class OreSourcePolicies {
+ static final ResourceLocation STANDARD = new ResourceLocation("orespawn", "standard");
+ static final String SECTION = "ore_source_policies";
+ private static final String MODE_CONSOLIDATED = "consolidated";
+ private static final String MODE_SEPARATE = "keep_separate";
+ private static final String OUTPUT_BALANCED = "balanced";
+ private static final String OUTPUT_SINGLE = "single";
+ private static final String OUTPUT_CUSTOM = "custom";
+ private static final ResourceLocation REVIEW = OreMaterialGroups.REVIEW;
+ private static final Map> PRIORITIES = priorities();
+ private static final Set ORDINARY_MMD = Collections.unmodifiableSet(new LinkedHashSet<>(Arrays.asList(
+ "basemetals", "modernmetals", "basegems", "baseminerals", "fantasymetals",
+ "electricadvantage", "steamadvantage", "poweradvantage", "mineralogy")));
+
+ private OreSourcePolicies() { }
+
+ /**
+ * Discovers currently available sources and creates only missing policies.
+ * Existing selections are never discarded, which lets missing mods return.
+ */
+ static boolean initialize(JsonObject root, boolean existingWorld) {
+ boolean changed = OreMaterialGroups.initialize(root);
+ boolean hadPolicies = root.has(SECTION) && root.get(SECTION).isJsonObject();
+ JsonObject policies = object(root, SECTION);
+ Map groups = discover(root);
+ for (Group group : groups.values()) {
+ Set names = new LinkedHashSet<>();
+ for (Candidate candidate : group.candidates) names.addAll(candidate.oreNames);
+ changed |= OreMaterialGroups.ensureDefinition(root, group.material, names);
+ }
+ changed |= !hadPolicies;
+ Set refreshedKeys = new LinkedHashSet<>();
+ for (Group group : groups.values()) {
+ String key = key(group.material, group.domain);
+ JsonObject previous = policies.has(key) && policies.get(key).isJsonObject()
+ ? policies.getAsJsonObject(key) : null;
+ JsonObject refreshed = previous == null
+ ? createPolicy(group, existingWorld) : refreshPolicy(previous, group);
+ refreshed.remove("dormant");
+ if (previous == null || !previous.toString().equals(refreshed.toString())) {
+ policies.add(key, refreshed);
+ changed = true;
+ }
+ refreshedKeys.add(key);
+ }
+ for (Entry entry : new ArrayList<>(policies.entrySet())) {
+ if (refreshedKeys.contains(entry.getKey()) || !entry.getValue().isJsonObject()) continue;
+ JsonObject previous = entry.getValue().getAsJsonObject();
+ ResourceLocation material = resource(string(previous, "material", ""));
+ ResourceLocation domain = resource(string(previous, "domain", ""));
+ if (material == null || domain == null) continue;
+ Group group = groups.get(key(material, domain));
+ if (group == null) group = new Group(material, domain);
+ JsonObject refreshed = refreshPolicy(previous, group);
+ if (candidatesWereReassigned(root, previous, material)) {
+ refreshed.addProperty("dormant", true);
+ }
+ if (!previous.toString().equals(refreshed.toString())) {
+ policies.add(entry.getKey(), refreshed);
+ changed = true;
+ }
+ }
+ root.add(SECTION, policies);
+ return changed;
+ }
+
+ /**
+ * A retained policy is missing from discovery either because its mod is absent or
+ * because the user moved its inferred aliases into another material group. Missing
+ * mods must remain visible; reassigned policies must remain stored but dormant.
+ */
+ private static boolean candidatesWereReassigned(JsonObject root, JsonObject policy,
+ ResourceLocation material) {
+ if (!policy.has("candidates") || !policy.get("candidates").isJsonArray()) return false;
+ boolean foundInferredCandidate = false;
+ for (JsonElement element : policy.getAsJsonArray("candidates")) {
+ if (!element.isJsonObject()) continue;
+ JsonObject candidate = element.getAsJsonObject();
+ if (bool(candidate, "material_declared", false)) return false;
+ List names = strings(candidate.get("ore_dictionary"));
+ if (names.isEmpty()) return false;
+ OreMaterialGroups.Inference inferred = OreMaterialGroups.infer(root, names);
+ if (inferred.material == null || material.equals(inferred.material)) return false;
+ foundInferredCandidate = true;
+ }
+ return foundInferredCandidate;
+ }
+
+ static Snapshot snapshot(JsonObject root) {
+ Map discovered = discover(root);
+ JsonObject policies = object(root, SECTION);
+ List views = new ArrayList<>();
+ Set seen = new LinkedHashSet<>();
+ for (Entry entry : policies.entrySet()) {
+ if (!entry.getValue().isJsonObject()) continue;
+ JsonObject policy = entry.getValue().getAsJsonObject();
+ if (bool(policy, "dormant", false)) continue;
+ ResourceLocation material = resource(string(policy, "material", ""));
+ ResourceLocation domain = resource(string(policy, "domain", ""));
+ if (material == null || domain == null) continue;
+ Group current = discovered.get(key(material, domain));
+ List candidates = readCandidates(policy, current);
+ String mode = string(policy, "mode", MODE_SEPARATE);
+ OreMaterialGroups.Definition definition = OreMaterialGroups.definition(root, material);
+ views.add(new GroupView(entry.getKey(), material, domain, definition.displayName,
+ definition.oreDictionaryEntries, definition.curated,
+ mode, outputMode(policy), status(policy, candidates), candidates,
+ decimalMap(policy, "outputs"), stringMap(policy, "placement_sources")));
+ seen.add(entry.getKey());
+ }
+ for (Group group : discovered.values()) {
+ String key = key(group.material, group.domain);
+ if (seen.contains(key)) continue;
+ JsonObject policy = createPolicy(group, true);
+ OreMaterialGroups.Definition definition = OreMaterialGroups.definition(root, group.material);
+ views.add(new GroupView(key, group.material, group.domain, definition.displayName,
+ definition.oreDictionaryEntries, definition.curated, MODE_SEPARATE, OUTPUT_CUSTOM,
+ status(policy, group.candidates), immutableCandidates(group.candidates),
+ decimalMap(policy, "outputs"), stringMap(policy, "placement_sources")));
+ }
+ views.sort(Comparator.comparing((GroupView view) -> view.material.toString())
+ .thenComparing(view -> view.domain.toString()));
+ return new Snapshot(views);
+ }
+
+ static ResourceLocation material(JsonObject ore, ItemStack output) {
+ ResourceLocation explicit = resource(string(ore, "material", ""));
+ if (explicit != null) return explicit;
+ return inferMaterial(oreNames(output)).material;
+ }
+
+ static ResourceLocation material(JsonObject root, JsonObject ore, ItemStack output) {
+ ResourceLocation explicit = resource(string(ore, "material", ""));
+ if (explicit != null) return explicit;
+ return inferMaterial(root, oreNames(output)).material;
+ }
+
+ static ResourceLocation placementChannel(JsonObject rule) {
+ ResourceLocation explicit = resource(string(rule, "placement_channel", ""));
+ if (explicit != null) return explicit;
+ JsonObject pattern = rule.has("pattern") && rule.get("pattern").isJsonObject()
+ ? rule.getAsJsonObject("pattern") : null;
+ if (pattern != null) {
+ ResourceLocation type = resource(string(pattern, "type", ""));
+ if (type != null) return type;
+ }
+ String legacy = string(rule, "pattern", "");
+ ResourceLocation type = legacy.indexOf(':') >= 0 ? resource(legacy) : null;
+ return type != null ? type : STANDARD;
+ }
+
+ static Inference inferMaterial(Iterable oreNames) {
+ JsonObject root = new JsonObject();
+ root.add(OreMaterialGroups.SECTION, OreMaterialGroups.defaults());
+ return inferMaterial(root, oreNames);
+ }
+
+ static Inference inferMaterial(JsonObject root, Iterable oreNames) {
+ OreMaterialGroups.Inference inferred = OreMaterialGroups.infer(root, oreNames);
+ return new Inference(inferred.material, inferred.reviewRequired, inferred.names);
+ }
+
+ static String key(ResourceLocation material, ResourceLocation domain) {
+ return material.toString() + "|" + domain.toString();
+ }
+
+ private static Map discover(JsonObject root) {
+ Map groups = new LinkedHashMap<>();
+ boolean manageVanillaOres = bool(root, "manage_vanilla_ores", false);
+ JsonObject ores = object(root, "ores");
+ for (Entry oreEntry : ores.entrySet()) {
+ if (!oreEntry.getValue().isJsonObject()) continue;
+ JsonObject ore = oreEntry.getValue().getAsJsonObject();
+ boolean oreActive = bool(ore, "enabled", true)
+ && (!bool(ore, "native_generation", false) || manageVanillaOres);
+ ResourceLocation blockId = resource(string(ore, "block", oreEntry.getKey()));
+ Block block = blockId == null ? null : ForgeRegistries.BLOCKS.getValue(blockId);
+ if (block == null || block == Blocks.AIR) continue;
+ int metadata = integer(ore, "metadata", 0);
+ ItemStack stack = new ItemStack(block, 1, metadata);
+ Inference inferred = inferMaterial(root, oreNames(stack));
+ ResourceLocation material = resource(string(ore, "material", ""));
+ boolean materialDeclared = material != null;
+ if (material == null) material = inferred.material;
+ if (material == null) continue;
+ String owner = owner(ore, blockId);
+ Role role = role(owner);
+ if (role == Role.NONE) continue;
+ for (Entry dimension : object(ore, "dimensions").entrySet()) {
+ addConfigured(groups, oreEntry.getKey(), owner, blockId, metadata, material,
+ resource(dimension.getKey()), dimension.getValue(), inferred, role,
+ oreActive, materialDeclared);
+ }
+ for (Entry selector : object(ore, "dimension_selectors").entrySet()) {
+ addConfigured(groups, oreEntry.getKey(), owner, blockId, metadata, material,
+ resource(selector.getKey()), selector.getValue(), inferred, role,
+ oreActive, materialDeclared);
+ }
+ }
+ addExternalDictionaryCandidates(groups, root);
+ return groups;
+ }
+
+ private static void addConfigured(Map groups, String sourceId, String owner,
+ ResourceLocation blockId, int metadata, ResourceLocation material, ResourceLocation domain,
+ JsonElement ruleElement, Inference inferred, Role role, boolean oreActive,
+ boolean materialDeclared) {
+ if (domain == null || !ruleElement.isJsonObject()) return;
+ if (role == Role.NETHER && !"minecraft:the_nether".equals(domain.toString())) return;
+ if (role == Role.END && !"minecraft:the_end".equals(domain.toString())) return;
+ JsonObject rule = ruleElement.getAsJsonObject();
+ boolean active = oreActive && bool(rule, "enabled", true);
+ Candidate candidate = new Candidate(sourceId, owner, modName(owner), modVersion(owner), blockId,
+ metadata, material, domain, placementChannel(rule), inferred.names, true, active,
+ false, role == Role.ENRICHMENT, inferred.reviewRequired, materialDeclared);
+ group(groups, material, domain).add(candidate);
+ }
+
+ private static void addExternalDictionaryCandidates(Map groups, JsonObject root) {
+ if (groups.isEmpty()) return;
+ Map> namesByMaterial = new HashMap<>();
+ for (String name : safeOreNames()) {
+ Inference inference = inferMaterial(root, Collections.singletonList(name));
+ if (inference.material != null && !inference.reviewRequired) {
+ namesByMaterial.computeIfAbsent(inference.material, ignored -> new ArrayList<>()).add(name);
+ }
+ }
+ for (Group group : groups.values()) {
+ List names = namesByMaterial.get(group.material);
+ if (names == null) continue;
+ for (String oreName : names) {
+ for (ItemStack stack : safeOres(oreName)) {
+ Block block = stack == null ? null : Block.getBlockFromItem(stack.getItem());
+ ResourceLocation id = block == null ? null : ForgeRegistries.BLOCKS.getKey(block);
+ if (id == null || block == Blocks.AIR || group.hasBlock(id, stack.getMetadata())) continue;
+ String owner = id.getNamespace();
+ Role role = role(owner);
+ if (role == Role.NONE || role == Role.ENRICHMENT) continue;
+ if (role == Role.NETHER && !"minecraft:the_nether".equals(group.domain.toString())) continue;
+ if (role == Role.END && !"minecraft:the_end".equals(group.domain.toString())) continue;
+ String sourceId = "external/" + id.toString() + "/" + stack.getMetadata();
+ group.add(new Candidate(sourceId, owner, modName(owner), modVersion(owner), id,
+ stack.getMetadata(), group.material, group.domain, STANDARD,
+ Collections.singletonList(oreName), loaded(owner), false, true, false, false,
+ false));
+ }
+ }
+ }
+ }
+
+ private static JsonObject createPolicy(Group group, boolean existingWorld) {
+ JsonObject policy = new JsonObject();
+ policy.addProperty("material", group.material.toString());
+ policy.addProperty("domain", group.domain.toString());
+ Candidate preferred = preferred(group);
+ boolean automatic = !existingWorld && preferred != null && highConfidence(group);
+ policy.addProperty("mode", automatic ? MODE_CONSOLIDATED : MODE_SEPARATE);
+ policy.addProperty("output_mode", automatic ? OUTPUT_BALANCED : OUTPUT_CUSTOM);
+ JsonObject outputs = new JsonObject();
+ for (Candidate candidate : group.outputs()) {
+ if (!automatic && candidate.external) continue;
+ outputs.addProperty(candidate.sourceId, 1.0D);
+ }
+ policy.add("outputs", outputs);
+ JsonObject placements = new JsonObject();
+ for (ResourceLocation channel : group.channels()) {
+ Candidate selected = preferred(group, channel);
+ if (selected != null) placements.addProperty(channel.toString(), selected.sourceId);
+ }
+ policy.add("placement_sources", placements);
+ policy.addProperty("review_required", !automatic && group.reviewRequired());
+ policy.addProperty("status", automatic ? "consolidated"
+ : group.hasExternal() ? "external_generation"
+ : group.reviewRequired() ? "review_required" : "separate");
+ policy.add("candidates", candidates(group.candidates));
+ return policy;
+ }
+
+ private static JsonObject refreshPolicy(JsonObject previous, Group group) {
+ JsonObject policy = JsonCopies.copy(previous);
+ policy.addProperty("material", group.material.toString());
+ policy.addProperty("domain", group.domain.toString());
+ if (!MODE_CONSOLIDATED.equals(string(policy, "mode", MODE_SEPARATE))) {
+ policy.addProperty("mode", MODE_SEPARATE);
+ }
+ policy.addProperty("output_mode", outputMode(policy));
+ if (!policy.has("outputs") || !policy.get("outputs").isJsonObject()) {
+ JsonObject outputs = new JsonObject();
+ for (Candidate candidate : group.outputs()) if (!candidate.external) {
+ outputs.addProperty(candidate.sourceId, 1.0D);
+ }
+ policy.add("outputs", outputs);
+ }
+ if (!policy.has("placement_sources") || !policy.get("placement_sources").isJsonObject()) {
+ policy.add("placement_sources", new JsonObject());
+ }
+ List merged = readCandidates(previous, group);
+ policy.add("candidates", candidates(merged));
+ String status = status(policy, merged);
+ policy.addProperty("status", status);
+ policy.addProperty("review_required", "review_required".equals(status)
+ || bool(previous, "review_required", false));
+ return policy;
+ }
+
+ private static String outputMode(JsonObject policy) {
+ String configured = string(policy, "output_mode", "");
+ if (OUTPUT_BALANCED.equals(configured) || OUTPUT_SINGLE.equals(configured)
+ || OUTPUT_CUSTOM.equals(configured)) return configured;
+ Map outputs = decimalMap(policy, "outputs");
+ if (outputs.size() <= 1) return OUTPUT_SINGLE;
+ double first = outputs.values().iterator().next();
+ for (double value : outputs.values()) {
+ if (Double.compare(first, value) != 0) return OUTPUT_CUSTOM;
+ }
+ return OUTPUT_BALANCED;
+ }
+
+ private static String status(JsonObject policy, List candidates) {
+ Map current = new HashMap<>();
+ boolean external = false;
+ boolean review = false;
+ for (Candidate candidate : candidates) {
+ current.put(candidate.sourceId, candidate);
+ external |= candidate.external && candidate.loaded;
+ review |= candidate.reviewRequired;
+ }
+ Map outputs = stringMap(policy, "outputs");
+ Map placements = stringMap(policy, "placement_sources");
+ if (MODE_CONSOLIDATED.equals(string(policy, "mode", MODE_SEPARATE))
+ && (outputs.isEmpty() || placements.isEmpty())) return "missing_source";
+ for (String selected : outputs.keySet()) {
+ Candidate candidate = current.get(selected);
+ if (candidate == null || !candidate.loaded) return "missing_source";
+ }
+ for (String selected : placements.values()) {
+ Candidate candidate = current.get(selected);
+ if (candidate == null || !candidate.loaded || !candidate.active) return "missing_source";
+ }
+ if (external) return "external_generation";
+ if (review || bool(policy, "review_required", false)) return "review_required";
+ return MODE_CONSOLIDATED.equals(string(policy, "mode", MODE_SEPARATE))
+ ? "consolidated" : "separate";
+ }
+
+ private static List readCandidates(JsonObject policy, Group current) {
+ Map merged = new LinkedHashMap<>();
+ if (policy.has("candidates") && policy.get("candidates").isJsonArray()) {
+ for (JsonElement element : policy.getAsJsonArray("candidates")) {
+ Candidate candidate = candidate(element, false);
+ if (candidate != null) merged.put(candidate.identity(), candidate);
+ }
+ }
+ if (current != null) {
+ for (Candidate candidate : current.candidates) merged.put(candidate.identity(), candidate);
+ }
+ List result = new ArrayList<>();
+ Set currentIds = new LinkedHashSet<>();
+ if (current != null) for (Candidate candidate : current.candidates) currentIds.add(candidate.identity());
+ for (Candidate candidate : merged.values()) {
+ result.add(currentIds.contains(candidate.identity()) ? candidate : candidate.missing());
+ }
+ result.sort(CANDIDATE_ORDER);
+ return Collections.unmodifiableList(result);
+ }
+
+ private static JsonArray candidates(List values) {
+ JsonArray result = new JsonArray();
+ List sorted = new ArrayList<>(values);
+ sorted.sort(CANDIDATE_ORDER);
+ for (Candidate candidate : sorted) result.add(candidate.toJson());
+ return result;
+ }
+
+ private static List immutableCandidates(List values) {
+ List copy = new ArrayList<>(values);
+ copy.sort(CANDIDATE_ORDER);
+ return Collections.unmodifiableList(copy);
+ }
+
+ private static final Comparator CANDIDATE_ORDER = Comparator
+ .comparing((Candidate candidate) -> candidate.owner)
+ .thenComparing(candidate -> candidate.sourceId)
+ .thenComparing(candidate -> candidate.channel.toString());
+
+ private static Candidate candidate(JsonElement element, boolean defaultLoaded) {
+ if (element == null || !element.isJsonObject()) return null;
+ JsonObject json = element.getAsJsonObject();
+ ResourceLocation block = resource(string(json, "registry_id", ""));
+ ResourceLocation material = resource(string(json, "material", ""));
+ ResourceLocation domain = resource(string(json, "domain", ""));
+ ResourceLocation channel = resource(string(json, "placement_channel", ""));
+ if (block == null || material == null || domain == null || channel == null) return null;
+ List names = strings(json.get("ore_dictionary"));
+ return new Candidate(string(json, "source_id", ""), string(json, "owner", block.getNamespace()),
+ string(json, "owner_name", ""), string(json, "owner_version", ""), block,
+ integer(json, "metadata", 0), material, domain, channel, names,
+ bool(json, "loaded", defaultLoaded), bool(json, "placement_active", defaultLoaded),
+ bool(json, "external", false), bool(json, "enrichment", false),
+ bool(json, "review_required", false), bool(json, "material_declared", false));
+ }
+
+ private static boolean highConfidence(Group group) {
+ List priority = PRIORITIES.get(group.material.getPath());
+ if (priority == null || group.managed().size() < 2 || group.hasExternal()) return false;
+ for (Candidate candidate : group.managed()) if (!priority.contains(candidate.owner)) return false;
+ return true;
+ }
+
+ private static Candidate preferred(Group group) {
+ List priority = PRIORITIES.get(group.material.getPath());
+ if (priority != null) {
+ for (String owner : priority) {
+ for (Candidate candidate : group.configured()) if (owner.equals(candidate.owner)) return candidate;
+ }
+ }
+ return group.configured().isEmpty() ? null : group.configured().get(0);
+ }
+
+ private static Candidate preferred(Group group, ResourceLocation channel) {
+ Candidate preferred = preferred(group);
+ if (preferred != null && channel.equals(preferred.channel)) return preferred;
+ for (Candidate candidate : group.configured()) if (channel.equals(candidate.channel)) return candidate;
+ return null;
+ }
+
+ private static Map> priorities() {
+ Map> result = new HashMap<>();
+ result.put("sulfur", Collections.unmodifiableList(Arrays.asList(
+ "mineralogy", "baseminerals", "electricadvantage")));
+ result.put("lithium", Collections.unmodifiableList(Arrays.asList(
+ "baseminerals", "electricadvantage")));
+ return Collections.unmodifiableMap(result);
+ }
+
+ private static Role role(String owner) {
+ if ("densemetals".equals(owner)) return Role.ENRICHMENT;
+ if ("basesciences".equals(owner)) return Role.NONE;
+ if ("nethermetals".equals(owner)) return Role.NETHER;
+ if ("endmetals".equals(owner)) return Role.END;
+ if (ORDINARY_MMD.contains(owner) || "minecraft".equals(owner) || "orespawn".equals(owner)) {
+ return Role.ORDINARY;
+ }
+ return Role.ORDINARY;
+ }
+
+ private static Group group(Map groups, ResourceLocation material, ResourceLocation domain) {
+ String key = key(material, domain);
+ return groups.computeIfAbsent(key, ignored -> new Group(material, domain));
+ }
+
+ private static String owner(JsonObject ore, ResourceLocation block) {
+ String owner = string(ore, "source_provider", string(ore, "source_mod", block.getNamespace()));
+ return owner.trim().toLowerCase(Locale.ROOT);
+ }
+
+ private static boolean loaded(String owner) {
+ if ("minecraft".equals(owner) || "orespawn".equals(owner)) return true;
+ try { return Loader.isModLoaded(owner); }
+ catch (Throwable ignored) { return false; }
+ }
+
+ private static String modName(String owner) {
+ ModContainer container = container(owner);
+ return container == null ? owner : container.getName();
+ }
+
+ private static String modVersion(String owner) {
+ ModContainer container = container(owner);
+ return container == null ? "" : container.getVersion();
+ }
+
+ private static ModContainer container(String owner) {
+ try { return Loader.instance().getIndexedModList().get(owner); }
+ catch (Throwable ignored) { return null; }
+ }
+
+ private static List oreNames(ItemStack stack) {
+ List result = new ArrayList<>();
+ try {
+ for (int id : OreDictionary.getOreIDs(stack)) result.add(OreDictionary.getOreName(id));
+ } catch (RuntimeException ignored) { }
+ return result;
+ }
+
+ private static String[] safeOreNames() {
+ try { return OreDictionary.getOreNames(); }
+ catch (RuntimeException ignored) { return new String[0]; }
+ }
+
+ private static List safeOres(String name) {
+ try { return OreDictionary.getOres(name, false); }
+ catch (RuntimeException ignored) { return Collections.emptyList(); }
+ }
+
+ private static JsonObject object(JsonObject root, String key) {
+ if (root.has(key) && root.get(key).isJsonObject()) return root.getAsJsonObject(key);
+ JsonObject result = new JsonObject();
+ root.add(key, result);
+ return result;
+ }
+
+ private static ResourceLocation resource(String value) {
+ try { return value == null || value.trim().isEmpty() ? null : new ResourceLocation(value); }
+ catch (RuntimeException ignored) { return null; }
+ }
+
+ private static Map stringMap(JsonObject root, String key) {
+ if (!root.has(key) || !root.get(key).isJsonObject()) return Collections.emptyMap();
+ Map result = new LinkedHashMap<>();
+ for (Entry entry : root.getAsJsonObject(key).entrySet()) {
+ try { result.put(entry.getKey(), entry.getValue().getAsString()); }
+ catch (RuntimeException ignored) { }
+ }
+ return Collections.unmodifiableMap(result);
+ }
+
+ private static Map decimalMap(JsonObject root, String key) {
+ if (!root.has(key) || !root.get(key).isJsonObject()) return Collections.emptyMap();
+ Map result = new LinkedHashMap<>();
+ for (Entry entry : root.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 List strings(JsonElement element) {
+ if (element == null || !element.isJsonArray()) return Collections.emptyList();
+ List result = new ArrayList<>();
+ for (JsonElement value : element.getAsJsonArray()) {
+ try { result.add(value.getAsString()); }
+ catch (RuntimeException ignored) { }
+ }
+ return Collections.unmodifiableList(result);
+ }
+
+ private static String string(JsonObject root, String key, String fallback) {
+ try { return root.has(key) ? root.get(key).getAsString() : fallback; }
+ catch (RuntimeException ignored) { return fallback; }
+ }
+
+ private static boolean bool(JsonObject root, String key, boolean fallback) {
+ try { return root.has(key) ? root.get(key).getAsBoolean() : fallback; }
+ catch (RuntimeException ignored) { return fallback; }
+ }
+
+ private static int integer(JsonObject root, String key, int fallback) {
+ try { return root.has(key) ? root.get(key).getAsInt() : fallback; }
+ catch (RuntimeException ignored) { return fallback; }
+ }
+
+ private enum Role { ORDINARY, NETHER, END, ENRICHMENT, NONE }
+
+ static final class Inference {
+ final ResourceLocation material;
+ final boolean reviewRequired;
+ final List names;
+
+ Inference(ResourceLocation material, boolean reviewRequired, List names) {
+ this.material = material;
+ this.reviewRequired = reviewRequired;
+ this.names = Collections.unmodifiableList(new ArrayList<>(names));
+ }
+ }
+
+ static final class Snapshot {
+ private final List groups;
+
+ Snapshot(List groups) {
+ this.groups = Collections.unmodifiableList(new ArrayList<>(groups));
+ }
+
+ List groups() { return groups; }
+ }
+
+ static final class GroupView {
+ final String key;
+ final ResourceLocation material;
+ final ResourceLocation domain;
+ final String displayName;
+ final List oreDictionaryEntries;
+ final boolean curated;
+ final String mode;
+ final String outputMode;
+ final String status;
+ final List candidates;
+ final Map outputs;
+ final Map placements;
+
+ GroupView(String key, ResourceLocation material, ResourceLocation domain,
+ String displayName, List oreDictionaryEntries, 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.oreDictionaryEntries = Collections.unmodifiableList(new ArrayList<>(oreDictionaryEntries));
+ this.curated = curated;
+ this.mode = mode;
+ this.outputMode = outputMode;
+ this.status = status;
+ this.candidates = Collections.unmodifiableList(new ArrayList<>(candidates));
+ this.outputs = Collections.unmodifiableMap(new LinkedHashMap<>(outputs));
+ this.placements = Collections.unmodifiableMap(new LinkedHashMap<>(placements));
+ }
+ }
+
+ static final class Candidate {
+ final String sourceId;
+ final String owner;
+ final String ownerName;
+ final String ownerVersion;
+ final ResourceLocation block;
+ final int metadata;
+ final ResourceLocation material;
+ final ResourceLocation domain;
+ final ResourceLocation channel;
+ final List oreNames;
+ final boolean loaded;
+ final boolean active;
+ final boolean external;
+ final boolean enrichment;
+ final boolean reviewRequired;
+ final boolean materialDeclared;
+
+ Candidate(String sourceId, String owner, String ownerName, String ownerVersion,
+ ResourceLocation block, int metadata, ResourceLocation material, ResourceLocation domain,
+ ResourceLocation channel, List oreNames, boolean loaded, boolean active,
+ boolean external, boolean enrichment, boolean reviewRequired, boolean materialDeclared) {
+ this.sourceId = sourceId;
+ this.owner = owner;
+ this.ownerName = ownerName;
+ this.ownerVersion = ownerVersion;
+ this.block = block;
+ this.metadata = metadata;
+ this.material = material;
+ this.domain = domain;
+ this.channel = channel;
+ this.oreNames = Collections.unmodifiableList(new ArrayList<>(oreNames));
+ this.loaded = loaded;
+ this.active = active;
+ this.external = external;
+ this.enrichment = enrichment;
+ this.reviewRequired = reviewRequired;
+ this.materialDeclared = materialDeclared;
+ }
+
+ String identity() { return sourceId + '|' + channel.toString(); }
+
+ String outputIdentity() { return block.toString() + '|' + metadata; }
+
+ Candidate missing() {
+ return new Candidate(sourceId, owner, ownerName, ownerVersion, block, metadata, material,
+ domain, channel, oreNames, false, false, external, enrichment, reviewRequired,
+ materialDeclared);
+ }
+
+ JsonObject toJson() {
+ JsonObject json = new JsonObject();
+ json.addProperty("source_id", sourceId);
+ json.addProperty("owner", owner);
+ json.addProperty("owner_name", ownerName);
+ json.addProperty("owner_version", ownerVersion);
+ json.addProperty("registry_id", block.toString());
+ json.addProperty("metadata", metadata);
+ json.addProperty("material", material.toString());
+ json.addProperty("domain", domain.toString());
+ json.addProperty("placement_channel", channel.toString());
+ JsonArray dictionary = new JsonArray();
+ for (String name : oreNames) dictionary.add(new JsonPrimitive(name));
+ json.add("ore_dictionary", dictionary);
+ json.addProperty("loaded", loaded);
+ json.addProperty("placement_active", active);
+ json.addProperty("external", external);
+ json.addProperty("enrichment", enrichment);
+ json.addProperty("review_required", reviewRequired);
+ json.addProperty("material_declared", materialDeclared);
+ return json;
+ }
+ }
+
+ private static final class Group {
+ final ResourceLocation material;
+ final ResourceLocation domain;
+ final List candidates = new ArrayList<>();
+
+ Group(ResourceLocation material, ResourceLocation domain) {
+ this.material = material;
+ this.domain = domain;
+ }
+
+ void add(Candidate candidate) {
+ for (Candidate value : candidates) if (value.identity().equals(candidate.identity())) return;
+ candidates.add(candidate);
+ }
+
+ boolean hasBlock(ResourceLocation block, int metadata) {
+ for (Candidate value : candidates) {
+ if (value.block.equals(block) && value.metadata == metadata) return true;
+ }
+ return false;
+ }
+
+ List managed() {
+ List result = new ArrayList<>();
+ for (Candidate value : candidates) if (!value.external && !value.enrichment) result.add(value);
+ result.sort(CANDIDATE_ORDER);
+ return result;
+ }
+
+ List configured() {
+ List result = new ArrayList<>();
+ for (Candidate value : managed()) if (value.active) result.add(value);
+ return result;
+ }
+
+ List outputs() {
+ Map unique = new LinkedHashMap<>();
+ for (Candidate value : candidates) {
+ if (value.enrichment || !value.loaded) continue;
+ String key = value.outputIdentity();
+ Candidate previous = unique.get(key);
+ if (previous == null || (!previous.active && value.active)
+ || (previous.external && !value.external)) unique.put(key, value);
+ }
+ List result = new ArrayList<>(unique.values());
+ result.sort(CANDIDATE_ORDER);
+ return result;
+ }
+
+ Set channels() {
+ Set result = new LinkedHashSet<>();
+ for (Candidate value : configured()) result.add(value.channel);
+ return result;
+ }
+
+ boolean hasExternal() {
+ for (Candidate value : candidates) if (value.external) return true;
+ return false;
+ }
+
+ boolean reviewRequired() {
+ for (Candidate value : candidates) if (value.reviewRequired) return true;
+ return hasManagedConflict() && !highConfidence(this);
+ }
+
+ private boolean hasManagedConflict() {
+ List managed = managed();
+ Set outputs = new LinkedHashSet<>();
+ Map> ownersByChannel = new LinkedHashMap<>();
+ for (Candidate value : managed) {
+ outputs.add(value.outputIdentity());
+ if (value.active) ownersByChannel.computeIfAbsent(value.channel,
+ ignored -> new LinkedHashSet<>()).add(value.owner);
+ }
+ if (outputs.size() > 1) return true;
+ for (Set owners : ownersByChannel.values()) if (owners.size() > 1) return true;
+ return false;
+ }
+
+ }
+}
diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGeneration.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGeneration.java
index f0ca501d..b2695053 100644
--- a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGeneration.java
+++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGeneration.java
@@ -61,9 +61,13 @@ public final class OreSpawnOreGeneration {
private static volatile Map> vanillaTakeoverOutputs = Collections.emptyMap();
private static volatile Map> backgroundGenerationScales =
Collections.emptyMap();
+ private static volatile Map> materialBackgroundGenerationScales =
+ Collections.emptyMap();
private static volatile BakedOre[] selectorOres = NO_ORES;
private static volatile Set selectorVanillaTakeoverOutputs = Collections.emptySet();
private static volatile Map selectorBackgroundGenerationScales = Collections.emptyMap();
+ private static volatile Map selectorMaterialBackgroundGenerationScales =
+ Collections.emptyMap();
private static volatile BakedGeomeConfig geomeConfig;
private static volatile GeomeGeology classifier;
private static volatile long classifierSeed = Long.MIN_VALUE;
@@ -83,9 +87,11 @@ public static void refreshWorldConfig() {
oresByDimension = baked.byDimension;
vanillaTakeoverOutputs = baked.vanillaOutputs;
backgroundGenerationScales = baked.backgroundGenerationScales;
+ materialBackgroundGenerationScales = baked.materialBackgroundGenerationScales;
selectorOres = baked.selectorOres;
selectorVanillaTakeoverOutputs = baked.selectorVanillaOutputs;
selectorBackgroundGenerationScales = baked.selectorBackgroundGenerationScales;
+ selectorMaterialBackgroundGenerationScales = baked.selectorMaterialBackgroundGenerationScales;
synchronized (CLASSIFIER_LOCK) {
classifier = null;
classifierSeed = Long.MIN_VALUE;
@@ -190,15 +196,18 @@ private static boolean generateChunk(WorldServer level, World world, Chunk chunk
if (!ore.acceptsBiome(biome, biomeId)) {
continue;
}
- double frequency = scaledManagedFrequency(ore.frequency, ore.backgroundController,
+ ResourceLocation scalingMaterial = ore.materialDeclared ? ore.material : null;
+ double scale = channelBackgroundScale(scalingMaterial, ore.channel,
+ materialBackgroundScale(dimension, scalingMaterial),
backgroundScale(dimension, ore.output.getBlock()));
+ double frequency = scaledManagedFrequency(ore.frequency, ore.backgroundController, scale);
if (geome >= 0) {
frequency *= ore.geomeWeights[geome];
}
int attempts = attemptsForFrequency(random, frequency);
for (int attempt = 0; attempt < attempts; attempt++) {
changed |= placeAttempt(world, chunk, random, ore, geome, dimension,
- worldSeed, geologySampler, scratch);
+ worldSeed, attempt, geologySampler, scratch);
}
}
if (changed) {
@@ -216,8 +225,23 @@ private static long mix(long seed, long chunk, long revision) {
return value ^ (value >>> 31);
}
+ static long ordinaryOutputIdentity(ChunkPos chunk, int attemptIndex,
+ ResourceLocation ruleId, ResourceLocation channel) {
+ return mix(chunkKey(chunk), attemptIndex,
+ ((long) ruleId.hashCode() << 32) ^ (channel.hashCode() & 0xFFFFFFFFL));
+ }
+
+ static double outputSelectionSample(long worldSeed, ResourceLocation dimension,
+ ResourceLocation material, ResourceLocation channel, long outputIdentity) {
+ long value = mix(worldSeed, outputIdentity,
+ ((long) material.hashCode() << 32)
+ ^ (channel.hashCode() & 0xFFFFFFFFL) ^ dimension.hashCode());
+ return (double) (value >>> 11) * 0x1.0p-53;
+ }
+
private static boolean placeAttempt(World world, Chunk chunk, Random random,
BakedOre ore, int geome, ResourceLocation dimension, long worldSeed,
+ int attemptIndex,
Optional geologySampler,
GenerationScratch scratch) {
int minY = Math.max(ore.minY, 0);
@@ -230,7 +254,7 @@ private static boolean placeAttempt(World world, Chunk chunk, Random random,
int z = chunk.getPos().getZStart() + random.nextInt(16);
int quantity = sampleQuantity(random, ore.minQuantity, ore.maxQuantity);
scratch.patternContext.initialize(world, chunk, random, ore, geome, dimension, worldSeed,
- geologySampler, x, y, z, minY, maxY, quantity);
+ attemptIndex, geologySampler, x, y, z, minY, maxY, quantity);
return ore.pattern.place(scratch.patternContext);
}
@@ -265,6 +289,14 @@ private static double backgroundScale(ResourceLocation dimension, Block output)
return scale == null ? 1.0D : scale.doubleValue();
}
+ private static double materialBackgroundScale(ResourceLocation dimension, ResourceLocation material) {
+ if (material == null) return 1.0D;
+ Map exact = materialBackgroundGenerationScales.get(dimension);
+ if (exact == null && selectorAllows(dimension)) exact = selectorMaterialBackgroundGenerationScales;
+ Double scale = exact == null ? null : exact.get(material);
+ return scale == null ? 1.0D : scale.doubleValue();
+ }
+
private static boolean insideChunk(Chunk chunk, int x, int y, int z) {
return insideChunk(chunk.getPos(), x, y, z);
}
@@ -282,21 +314,17 @@ private static BakedOres bakeOres(JsonObject profile, BakedGeomeConfig config) {
boolean manageVanillaOres = bool(profile, "manage_vanilla_ores", false);
Map> resolvedTags = new HashMap<>();
List rules = new ArrayList<>();
+ Map outputBundles = new LinkedHashMap<>();
Set explicitDimensions = new HashSet<>();
for (Entry oreEntry : profile.getAsJsonObject("ores").entrySet()) {
if (!oreEntry.getValue().isJsonObject()) {
continue;
}
JsonObject oreJson = oreEntry.getValue().getAsJsonObject();
- if (!bool(oreJson, "enabled", true)) {
- continue;
- }
+ boolean oreEnabled = bool(oreJson, "enabled", true);
boolean nativeGeneration = bool(oreJson, "native_generation", false);
boolean retrogen = bool(oreJson, "retrogen", true);
boolean suppressVanilla = nativeGeneration || bool(oreJson, "suppress_vanilla", false);
- if (nativeGeneration && !manageVanillaOres) {
- continue;
- }
ResourceLocation oreId = resource(string(oreJson, "block", oreEntry.getKey()));
Block output = oreId == null ? null : ForgeRegistries.BLOCKS.getValue(oreId);
JsonObject dimensions = objectOrEmpty(oreJson, "dimensions");
@@ -314,7 +342,16 @@ private static BakedOres bakeOres(JsonObject profile, BakedGeomeConfig config) {
}
int deepOutputMaxY = integer(oreJson, "deep_output_max_y", -1);
BakedOutput[] outputs = bakeOutputs(oreJson, outputState);
- BakedOreRule rule = new BakedOreRule(output, manageVanillaOres && suppressVanilla);
+ ResourceLocation ruleId = resource(oreEntry.getKey());
+ if (ruleId == null) ruleId = oreId;
+ outputBundles.put(ruleId.toString(), new BakedOutputBundle(
+ outputState, deepOutput, deepOutputMaxY, outputs));
+ if (!oreEnabled || (nativeGeneration && !manageVanillaOres)) continue;
+ ResourceLocation material = OreSourcePolicies.material(profile, oreJson,
+ new ItemStack(output, 1, integer(oreJson, "metadata", 0)));
+ boolean materialDeclared = resource(string(oreJson, "material", "")) != null;
+ BakedOreRule rule = new BakedOreRule(ruleId, output, material, materialDeclared,
+ manageVanillaOres && suppressVanilla);
for (Entry dimensionEntry : dimensions.entrySet()) {
ResourceLocation dimensionId = resource(dimensionEntry.getKey());
@@ -332,8 +369,9 @@ private static BakedOres bakeOres(JsonObject profile, BakedGeomeConfig config) {
if (!bool(dimension, "enabled", true)) {
continue;
}
- BakedOre baked = bakeOre(outputState, deepOutput, deepOutputMaxY, outputs,
- dimension, config, resolvedTags, retrogen);
+ BakedOre baked = bakeOre(ruleId, material, materialDeclared, dimensionId,
+ outputState, deepOutput,
+ deepOutputMaxY, outputs, dimension, config, resolvedTags, retrogen);
if (baked != null) {
rule.explicit.put(dimensionId, baked);
} else {
@@ -352,8 +390,9 @@ private static BakedOres bakeOres(JsonObject profile, BakedGeomeConfig config) {
if (!selectorEntry.getValue().isJsonObject()) continue;
JsonObject selector = selectorEntry.getValue().getAsJsonObject();
if (!bool(selector, "enabled", true)) continue;
- rule.selector = bakeOre(outputState, deepOutput, deepOutputMaxY, outputs,
- selector, config, resolvedTags, retrogen);
+ rule.selector = bakeOre(ruleId, material, materialDeclared, selectorId,
+ outputState, deepOutput,
+ deepOutputMaxY, outputs, selector, config, resolvedTags, retrogen);
if (rule.selector == null) {
reportBakeProblem("Ignoring invalid selector rule for OreSpawn-managed ore '{}'",
oreEntry.getKey());
@@ -365,10 +404,12 @@ private static BakedOres bakeOres(JsonObject profile, BakedGeomeConfig config) {
Map result = new HashMap<>();
Map> vanillaOutputs = new HashMap<>();
Map> generationScales = new HashMap<>();
+ Map> materialScales = new HashMap<>();
for (ResourceLocation dimension : explicitDimensions) {
List combined = new ArrayList<>();
Set suppressed = Collections.newSetFromMap(new IdentityHashMap());
Map scales = new IdentityHashMap<>();
+ Map semanticScales = new HashMap<>();
for (BakedOreRule rule : rules) {
BakedOre selected = selectRule(rule.explicit, rule.explicitDimensions,
rule.selector, dimension);
@@ -377,16 +418,21 @@ private static BakedOres bakeOres(JsonObject profile, BakedGeomeConfig config) {
if (rule.suppressVanilla) suppressed.add(rule.output);
if (selected.backgroundController) {
mergeBackgroundScale(scales, rule.output, selected.backgroundGenerationScale);
+ if (rule.materialDeclared) mergeBackgroundScale(semanticScales,
+ rule.material, selected.backgroundGenerationScale);
}
}
}
+ combined = arbitrate(profile, combined, outputBundles);
result.put(dimension, combined.toArray(new BakedOre[combined.size()]));
vanillaOutputs.put(dimension, Collections.unmodifiableSet(suppressed));
generationScales.put(dimension, Collections.unmodifiableMap(scales));
+ materialScales.put(dimension, Collections.unmodifiableMap(semanticScales));
}
List selectorList = new ArrayList<>();
Set selectorOutputs = Collections.newSetFromMap(new IdentityHashMap