From c0eecfa8d84e34c76762f8f46f41b0c2bf7047e3 Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Thu, 10 Sep 2026 17:05:18 +0100 Subject: [PATCH 01/31] Add Realistic Deposits extension APIs --- .../orespawn/testmod/SurfaceProbeTestMod.java | 34 ++++++++- .../mc/orespawn/api/OreGenerationContext.java | 37 ++++++++++ .../mc/orespawn/api/OrePlacementContext.java | 14 ++-- .../moddev/mc/orespawn/api/OreSpawnApi.java | 29 +++++++- .../orespawn/api/OreSpawnGeologySampler.java | 5 +- .../api/client/WorldSettingsExtension.java | 32 ++++++++ .../WorldSettingsExtensionRegistry.java | 49 ++++++++++++ .../client/WorldSettingsScreenFactory.java | 12 +++ .../client/OreSpawnWorldSettingsScreen.java | 26 ++++++- .../worldgen/OreSpawnOreGeneration.java | 60 ++++++++++++--- .../api/OreGenerationContextTest.java | 47 ++++++++++++ .../WorldSettingsExtensionRegistryTest.java | 74 +++++++++++++++++++ .../worldgen/OreSpawnOreGenerationTest.java | 12 +++ 13 files changed, 407 insertions(+), 24 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/test/java/zone/moddev/mc/orespawn/api/OreGenerationContextTest.java create mode 100644 src/test/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistryTest.java 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 32c4bb63..eef6b699 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; @@ -27,6 +28,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; @@ -120,6 +123,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"), @@ -132,7 +136,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); @@ -216,6 +240,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); @@ -333,6 +362,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/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..e0f17838 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(); @@ -19,10 +23,10 @@ public interface OrePlacementContext { int nodeSize(); /** - * Returns whether this attempt may inspect or replace the position. During initial - * generation this includes Minecraft's already-loaded writable worldgen region, so - * deposits can cross chunk borders. Retrogen deliberately limits it to the chunk - * being updated. + * Returns whether this attempt may inspect or replace the position. Forge 1.10 + * limits both ordinary generation and retrogen to the current chunk. A large + * deterministic pattern can still render the intersecting slice independently in + * every chunk. */ boolean inside(int x, int y, int z); boolean isFluid(int x, int y, int z, Fluid fluid); 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..3c452b5d 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 1.10 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.worldServerForDimension(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..c53d1bd5 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); 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..66d7ba51 --- /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 the current OreSpawn screen 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..a7cbf0ef --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistry.java @@ -0,0 +1,49 @@ +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 net.minecraft.util.ResourceLocation; +import net.minecraftforge.fml.relauncher.Side; +import net.minecraftforge.fml.relauncher.SideOnly; + +/** Client-only registry for add-on actions in OreSpawn's world-settings screen. */ +@SideOnly(Side.CLIENT) +public final class WorldSettingsExtensionRegistry { + private static final Map EXTENSIONS = + new LinkedHashMap<>(); + + private WorldSettingsExtensionRegistry() { + } + + /** + * Registers one translated world-settings action in deterministic registration + * order. 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"); + Objects.requireNonNull(screenFactory, "screenFactory"); + if (buttonTranslationKey == null || buttonTranslationKey.trim().isEmpty()) { + throw new IllegalArgumentException("buttonTranslationKey cannot be blank"); + } + if (EXTENSIONS.containsKey(id)) { + throw new IllegalStateException("Duplicate OreSpawn world-settings extension: " + id); + } + WorldSettingsExtension extension = new WorldSettingsExtension(id, + buttonTranslationKey, screenFactory); + EXTENSIONS.put(id, extension); + return extension; + } + + /** 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..dcf2a199 --- /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. */ +@FunctionalInterface +@SideOnly(Side.CLIENT) +public interface WorldSettingsScreenFactory { + GuiScreen create(GuiScreen parent); +} 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..9ab2e809 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnWorldSettingsScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnWorldSettingsScreen.java @@ -7,6 +7,8 @@ import java.util.Objects; import zone.moddev.mc.orespawn.OreSpawnConfig.GeologyMode; +import zone.moddev.mc.orespawn.api.client.WorldSettingsExtension; +import zone.moddev.mc.orespawn.api.client.WorldSettingsExtensionRegistry; import zone.moddev.mc.orespawn.worldgen.FormationSettings.Preset; import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfile; import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfileManager; @@ -69,9 +71,10 @@ protected void init() { int right = left + columnWidth + 5; boolean terrain = session.hasTerrainRules(); boolean fluids = !session.fluidDepositIds().isEmpty(); + List extensions = WorldSettingsExtensionRegistry.extensions(); int top = OreSpawnScreenLayout.mainTop(this.height); int rows = (templateChoices.size() > 1 ? 1 : 0) + (terrain ? 8 - : 5 + (fluids ? 1 : 0)); + : 5 + (fluids ? 1 : 0)) + extensions.size(); int available = OreSpawnScreenLayout.footerY(height) - top - BUTTON_HEIGHT - 4; int row = Math.min(OreSpawnScreenLayout.mainRowSpacing(this.height), rows <= 1 ? 24 : Math.max(BUTTON_HEIGHT, available / (rows - 1))); @@ -106,7 +109,7 @@ 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), + addButton(OreSpawnScreenLayout.button(this, font, left, top + (row * rowIndex++), contentWidth, BUTTON_HEIGHT, new TextComponentTranslation("button.orespawn.help"), button -> openHelp())); } else { @@ -153,10 +156,16 @@ columnWidth, BUTTON_HEIGHT, new TextComponentTranslation("button.orespawn.advanc 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), + addButton(OreSpawnScreenLayout.explainedButton(this, font, left, top + (row * rowIndex++), contentWidth, BUTTON_HEIGHT, fluidEditorLabel(), button -> openFluidDeposits(), "tooltip.orespawn.main.fluid_editor")); } + for (WorldSettingsExtension extension : extensions) { + addButton(OreSpawnScreenLayout.button(this, font, left, top + (row * rowIndex++), + contentWidth, BUTTON_HEIGHT, + new TextComponentTranslation(extension.buttonTranslationKey()), + button -> openExtension(extension))); + } addButton(OreSpawnScreenLayout.button(this, font, left, this.height - 28, columnWidth, BUTTON_HEIGHT, DialogTexts.GUI_DONE, button -> saveAndClose())); addButton(OreSpawnScreenLayout.button(this, font, right, this.height - 28, columnWidth, BUTTON_HEIGHT, @@ -322,6 +331,17 @@ private void openHelp() { minecraft.displayGuiScreen(new OreSpawnGuideScreen(this)); } + private void openExtension(WorldSettingsExtension extension) { + syncSession(); + GuiScreen screen = extension.createScreen(this); + if (screen == null) { + validationError = new TextComponentString( + "World-settings extension did not create a screen: " + extension.id()); + return; + } + minecraft.displayGuiScreen(screen); + } + @Override public void onClose() { minecraft.displayGuiScreen(parent); 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 819cbfbb..ecc4cc85 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; @@ -112,7 +115,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, ChunkAccessCompat.position(chunk)); return changed; @@ -128,7 +132,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); } @@ -137,13 +141,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 = ChunkAccessCompat.position(chunk); 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, @@ -162,7 +167,8 @@ private static boolean generateChunk(World world, Chunk chunk, Biome biome, } 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) { @@ -181,7 +187,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); @@ -192,8 +199,8 @@ private static boolean placeAttempt(World world, Chunk chunk, Random random, int y = ore.heightDistribution.sample(random, minY, maxY); int z = ChunkAccessCompat.position(chunk).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); } @@ -774,6 +781,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) { @@ -781,9 +792,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; @@ -791,6 +815,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; @@ -803,12 +832,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 = ChunkAccessCompat.position(chunk); + this.chunkX = chunkPos.chunkXPos; + this.chunkZ = chunkPos.chunkZPos; + this.geologySampler = geologySampler; this.originX = originX; this.originY = originY; this.originZ = originZ; @@ -827,6 +864,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/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/client/WorldSettingsExtensionRegistryTest.java b/src/test/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistryTest.java new file mode 100644 index 00000000..55d090e9 --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistryTest.java @@ -0,0 +1,74 @@ +package zone.moddev.mc.orespawn.api.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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("ordered_first", parent -> new GuiScreen() { }); + WorldSettingsExtension second = register("ordered_second", parent -> new GuiScreen() { }); + List extensions = WorldSettingsExtensionRegistry.extensions(); + + assertTrue(extensions.indexOf(first) < extensions.indexOf(second)); + assertThrows(UnsupportedOperationException.class, () -> extensions.add(first)); + } + + @Test + void rejectsDuplicateIdsAndBlankTranslationKeys() { + ResourceLocation id = id("duplicate"); + WorldSettingsExtensionRegistry.register(id, "button.realisticdeposits.settings", + parent -> new GuiScreen() { }); + assertThrows(IllegalStateException.class, () -> WorldSettingsExtensionRegistry.register(id, + "button.realisticdeposits.other", parent -> new GuiScreen() { })); + assertThrows(IllegalArgumentException.class, () -> WorldSettingsExtensionRegistry.register( + id("blank"), " ", parent -> new GuiScreen() { })); + } + + @Test + void passesTheCurrentOreSpawnScreenToTheFactory() { + AtomicReference receivedParent = new AtomicReference<>(); + GuiScreen expectedParent = new GuiScreen() { }; + GuiScreen expectedChild = new GuiScreen() { }; + WorldSettingsExtension extension = register("parent", parent -> { + receivedParent.set(parent); + return expectedChild; + }); + + assertSame(expectedChild, extension.createScreen(expectedParent)); + assertSame(expectedParent, receivedParent.get()); + assertEquals("button.realisticdeposits.settings", extension.buttonTranslationKey()); + } + + @Test + void worldSettingsScreenOwnsExtensionButtonPlacementAndNavigation() 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()")); + assertTrue(source.contains("new TextComponentTranslation(extension.buttonTranslationKey())")); + assertTrue(source.contains("extension.createScreen(this)")); + } + + private static WorldSettingsExtension register(String path, WorldSettingsScreenFactory factory) { + return WorldSettingsExtensionRegistry.register(id(path), + "button.realisticdeposits.settings", factory); + } + + private static ResourceLocation id(String path) { + return new ResourceLocation("realisticdeposits", path); + } +} 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 a14854d1..428e8240 100644 --- a/src/test/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGenerationTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGenerationTest.java @@ -66,6 +66,18 @@ void managedWritesDoNotLeakReusablePositionsIntoForgeTickData() throws Exception assertFalse(source.contains("world.setBlockState(cursor, output, GENERATION_WRITE_FLAGS)")); } + @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.chunkXPos")); + assertTrue(source.contains("this.chunkZ = chunkPos.chunkZPos")); + } + @Test void fixedQuantityDoesNotConsumeRandomState() { CountingRandom random = new CountingRandom(0); From c46a9533b882434bb5ad54beb608aa96a168739c Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Thu, 10 Sep 2026 17:05:30 +0100 Subject: [PATCH 02/31] Prepare OreSpawn 4.1.0.110021 --- .github/workflows/ci.yml | 6 +-- CHANGELOG.txt | 12 +++++ README.md | 6 ++- build.gradle | 8 ++-- docs/API.md | 44 ++++++++++++++++++- docs/DEVELOPER_GUIDE.md | 36 ++++++++++++++- docs/VERSIONS.md | 2 +- gradle.properties | 2 +- .../orespawn/compat/LegacyOs3Bridge.java | 2 +- src/main/java/cyano/orespawn/OreSpawn.java | 2 +- .../zone/moddev/mc/orespawn/OreSpawn.java | 2 +- .../LegacyMineralogyProfileMigration.java | 2 +- .../worldgen/LegacyOs3ProfileMigration.java | 2 +- 13 files changed, 109 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d030b92..8b541302 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -126,9 +126,9 @@ jobs: if-no-files-found: error retention-days: 30 path: | - build/libs/OreSpawn-4.0.16.110021.jar - build/libs/OreSpawn-4.0.16.110021-sources.jar - build/libs/OreSpawn-4.0.16.110021-javadoc.jar + build/libs/OreSpawn-4.1.0.110021.jar + build/libs/OreSpawn-4.1.0.110021-sources.jar + build/libs/OreSpawn-4.1.0.110021-javadoc.jar build/release/SHA256SUMS CHANGELOG.txt diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 238baa2d..64980315 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,15 @@ +Version 4.1.0.110021 + +* Add an extended, binary-compatible ore generation context exposing stable + world seed, dimension and current-chunk identity to compiled pattern add-ons. +* Expose the active geology sampler to patterns when the generated dimension + has OreSpawn geology, including identical identity during supported retrogen. +* Add a deterministic client-only registry for translated add-on actions in + OreSpawn's world-settings screen, with parent-screen handoff and duplicate-ID + rejection. +* Preserve API major 1, schemas 4/6/5, existing pattern binaries, profiles, + migration behaviour and generated chunks. + Version 4.0.16.110021 * Adopt the shared 4.0.16 release identity. Forge 1.10 has neither diff --git a/README.md b/README.md index c4419edb..6a3f8330 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,10 @@ It gives mods and modpacks one place to configure ores, deposit shapes, optional rock strata and geomes, provider-owned underground fluid deposits, biome palettes and world materials, flat bedrock, and bounded ore retrogen. -This branch builds target-qualified version `4.0.16.110021`: the OreSpawn 4.0.16 -feature set for Minecraft 1.10.2 and Forge. See the +This branch builds target-qualified version `4.1.0.110021`: the OreSpawn 4.1.0 +feature set for Minecraft 1.10.2 and Forge. It adds stable generation identity +and a client world-settings extension point for add-ons such as Realistic +Deposits while retaining API major 1. See the [versioning policy](docs/VERSIONS.md) for the encoding and release convention. Its deprecated compatibility layer imports OreSpawn 1 and OreSpawn 3 diff --git a/build.gradle b/build.gradle index 3bf50c08..528639e2 100644 --- a/build.gradle +++ b/build.gradle @@ -960,7 +960,7 @@ tasks.register('verifyReleaseConfiguration') { description = 'Validates the target-qualified release, API, schemas, reports, and publishing identity.' doLast { - if (project.mod_version != '4.0.16.110021' + if (project.mod_version != '4.1.0.110021' || project.mod_group != expectedMavenGroup) { throw new GradleException("Unexpected OreSpawn release version: ${project.mod_version}") } @@ -988,8 +988,8 @@ tasks.register('verifyReleaseConfiguration') { 'src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyOs3ProfileMigration.java', 'README.md', 'CHANGELOG.txt' ].each { path -> - if (!file(path).getText('UTF-8').contains('4.0.16.110021')) { - throw new GradleException("Authoritative release location does not contain 4.0.16.110021: ${path}") + if (!file(path).getText('UTF-8').contains('4.1.0.110021')) { + throw new GradleException("Authoritative release location does not contain 4.1.0.110021: ${path}") } } if (!file('docs/API.md').getText('UTF-8').contains('orespawn@[4.0.6,5.0.0)')) { @@ -1071,6 +1071,8 @@ tasks.register('verifyReleaseArtifacts') { 'mcmod.info', 'META-INF/accesstransformer.cfg', 'zone/moddev/mc/orespawn/api/OreSpawnApi.class', + 'zone/moddev/mc/orespawn/api/OreGenerationContext.class', + 'zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistry.class', 'META-INF/orespawn/docs/VERSIONS.md', 'META-INF/orespawn/docs/schemas/orespawn-provider.schema.json', 'META-INF/orespawn/docs/schemas/orespawn-global.schema.json', diff --git a/docs/API.md b/docs/API.md index 5b21d46c..98b9fd85 100644 --- a/docs/API.md +++ b/docs/API.md @@ -152,7 +152,29 @@ OreSpawnApi.createSampler(server.overworld()).ifPresent(sampler -> { `sampleColumn` performs one biome/geome classification and reuses it for every Y query. Sampling is read-only and is intended for gameplay decisions, diagnostics, and compatible generation outside OreSpawn's block loops. -Callbacks inside OreSpawn generation loops are intentionally unsupported. + +OreSpawn 4.1 adds `OreGenerationContext` as a binary-compatible subtype of the +original `OrePlacementContext`. Every context supplied by OreSpawn implements +the extended type. A custom compiled pattern can obtain stable region-scale +identity without retaining a world object: + +```java +if (!(context instanceof OreGenerationContext)) return false; +OreGenerationContext generation = (OreGenerationContext) context; +long seed = generation.worldSeed(); +ResourceLocation dimension = generation.dimension(); +int chunkX = generation.chunkX(); +int chunkZ = generation.chunkZ(); +Optional geology = generation.geologySampler(); +``` + +The world seed, dimension and chunk coordinates are identical for ordinary +generation and supported retrogen. `geologySampler()` is empty when that +dimension has no active OreSpawn geology configuration. It otherwise returns +the already-prepared, allocation-light sampler for the active world. Large +patterns must independently render only the slice intersecting the current +chunk and continue to use `inside(...)` and `tryPlace(...)` for safe writes. +Do not retain the sampler or placement context beyond the current call. Forge 12 custom-pattern mods attach a generic `RegistryEvent.Register` listener to their mod event bus and @@ -163,5 +185,25 @@ register named values into `OreSpawnPatternRegistry.REGISTRY_NAME`. An baking the profile; only the compiled placement function runs during generation. +## Client world-settings extensions + +An add-on may contribute a translated action to OreSpawn's world-settings +screen without depending on OreSpawn implementation classes. Register from +client initialization only: + +```java +WorldSettingsExtensionRegistry.register( + new ResourceLocation("examplemod", "deposit_settings"), + "button.examplemod.deposit_settings", + parent -> new ExampleDepositSettingsScreen(parent)); +``` + +OreSpawn owns layout and navigation and passes its current screen as the +factory parent, so the add-on's Done action can return safely. Entries appear +in deterministic registration order. Duplicate IDs, blank translation keys +and null factories are rejected. The translation belongs in the add-on's own +language resources. These types are client-only and must not be loaded from a +dedicated-server initialization path. + `OreSpawnOreIntegration` remains as a deprecated facade for early ore-provider integrations. New code should use `OreSpawnApi`. diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md index 43f94896..0de6b9d3 100644 --- a/docs/DEVELOPER_GUIDE.md +++ b/docs/DEVELOPER_GUIDE.md @@ -13,6 +13,8 @@ | 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` | +| 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 +121,36 @@ 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 optional client configuration screens during client initialization: + +```java +WorldSettingsExtensionRegistry.register( + new ResourceLocation("examplemod", "deposit_settings"), + "button.examplemod.deposit_settings", + parent -> new ExampleDepositSettingsScreen(parent)); +``` + +The registry is client-only. OreSpawn renders the translated button, controls +its placement, and supplies the current OreSpawn screen as the parent. The +extension owns its own screen and translations and must return to that parent +from Done. Duplicate identifiers are rejected deterministically. + ## Pack Override Quick Start Copy `examples/examplemod-orespawn.json` to: @@ -174,7 +206,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.10.2 uses a static Forge-backed biome registry, so generation carries those stable IDs alongside diff --git a/docs/VERSIONS.md b/docs/VERSIONS.md index 09f03e1a..2069a178 100644 --- a/docs/VERSIONS.md +++ b/docs/VERSIONS.md @@ -51,7 +51,7 @@ Examples: | Minecraft | Loader | Target | Example full version | | --- | --- | ---: | --- | -| 1.10.2 | Forge | `110021` | `4.0.16.110021` | +| 1.10.2 | Forge | `110021` | `4.1.0.110021` | | 1.12.2 | Forge | `112021` | `4.0.6.112021` | | 1.13.2 | Forge | `113021` | `4.0.6.113021` | | 1.20.6 | Forge | `120061` | `4.0.6.120061` | diff --git a/gradle.properties b/gradle.properties index aacbd2ac..e4ae0128 100644 --- a/gradle.properties +++ b/gradle.properties @@ -26,7 +26,7 @@ curseforge_project_id=245586 mod_id=orespawn mod_name=MMD OreSpawn mod_license=LGPL-2.1 -mod_version=4.0.16.110021 +mod_version=4.1.0.110021 mod_group=zone.moddev.mc.orespawn mod_authors=SkyBlade1978, dshadowwolf, the MMD Team mod_description=Configurable, provider-driven terrain, ore, and deposit generation. diff --git a/src/main/java/com/mcmoddev/orespawn/compat/LegacyOs3Bridge.java b/src/main/java/com/mcmoddev/orespawn/compat/LegacyOs3Bridge.java index 4e1093fd..7c7369ff 100644 --- a/src/main/java/com/mcmoddev/orespawn/compat/LegacyOs3Bridge.java +++ b/src/main/java/com/mcmoddev/orespawn/compat/LegacyOs3Bridge.java @@ -985,7 +985,7 @@ private static void writeHumanUpgradeReport(Path destination) throws IOException } } List lines = new ArrayList<>(); - lines.add("OreSpawn 4.0.16.110021 Upgrade Report"); + lines.add("OreSpawn 4.1.0.110021 Upgrade Report"); lines.add("================================"); lines.add(""); lines.add("RESULT: Legacy OreSpawn configuration was consumed and translated for OS4."); diff --git a/src/main/java/cyano/orespawn/OreSpawn.java b/src/main/java/cyano/orespawn/OreSpawn.java index b150c79f..18d9485d 100644 --- a/src/main/java/cyano/orespawn/OreSpawn.java +++ b/src/main/java/cyano/orespawn/OreSpawn.java @@ -19,7 +19,7 @@ public class OreSpawn { public static OreSpawn INSTANCE; public static final String MODID = "orespawn"; public static final String NAME = "OreSpawn"; - public static final String VERSION = "4.0.16.110021"; + public static final String VERSION = "4.1.0.110021"; public static final List oreSpawnConfigFiles = new ArrayList<>(); public static final List additionalStoneBlocks = new ArrayList<>(); public static boolean disableVanillaOreGen; diff --git a/src/main/java/zone/moddev/mc/orespawn/OreSpawn.java b/src/main/java/zone/moddev/mc/orespawn/OreSpawn.java index 761ada97..1c91df6f 100644 --- a/src/main/java/zone/moddev/mc/orespawn/OreSpawn.java +++ b/src/main/java/zone/moddev/mc/orespawn/OreSpawn.java @@ -52,7 +52,7 @@ public class OreSpawn { public static final String MODID = "orespawn"; public static final String NAME = "OreSpawn"; - public static final String VERSION = "4.0.16.110021"; + public static final String VERSION = "4.1.0.110021"; private static final Logger LOGGER = LogManager.getLogger(); diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java index 413115bc..145648cd 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java @@ -125,7 +125,7 @@ private static void writeUpgradeReport(Path worldRoot, Path configDirectory, } List lines = new ArrayList<>(); - lines.add("OreSpawn 4.0.16.110021 Upgrade Report"); + lines.add("OreSpawn 4.1.0.110021 Upgrade Report"); lines.add("================================"); lines.add(""); lines.add("RESULT: Existing Mineralogy " + mineralogyVersion diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyOs3ProfileMigration.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyOs3ProfileMigration.java index b36f5c09..f1113d92 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyOs3ProfileMigration.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyOs3ProfileMigration.java @@ -65,7 +65,7 @@ private static void writeInitialUpgradeReport(Path configDirectory, boolean forceRetrogen, boolean flatBedrock, boolean retrogenBedrock, int bedrockLayers) throws IOException { String newline = System.lineSeparator(); - String text = "OreSpawn 4.0.16.110021 Upgrade Report" + newline + String text = "OreSpawn 4.1.0.110021 Upgrade Report" + newline + "================================" + newline + newline + "RESULT: Legacy OreSpawn settings were imported into the OS4 profile." + newline + "- Manage vanilla ores: " + manageVanilla + newline From b7c59a6c77f24bd8826b153e9370c7b274cee691 Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Fri, 11 Sep 2026 09:56:10 +0100 Subject: [PATCH 03/31] Complete Realistic Deposits extension contracts --- .../mc/orespawn/api/GeologySampler.java | 3 +- .../orespawn/api/OreSpawnGeologySampler.java | 5 +- .../mc/orespawn/api/WorldgenProvider.java | 57 ++++++++++- .../WorldgenIntegrationManager.java | 31 +++++- .../worldgen/OreSpawnOreGeneration.java | 98 +++++++++++++++++-- .../worldgen/OreSpawnWorldGenerator.java | 8 +- .../worldgen/WorldGeologyProfileManager.java | 5 +- .../api/GeologySamplerSourceContractTest.java | 21 ++++ .../mc/orespawn/api/WorldgenProviderTest.java | 39 ++++++++ .../WorldgenIntegrationManagerTest.java | 44 +++++++++ .../BackgroundGenerationScaleTest.java | 55 +++++++++++ .../BiomeSurfaceFeatureOrderTest.java | 2 +- 12 files changed, 346 insertions(+), 22 deletions(-) create mode 100644 src/test/java/zone/moddev/mc/orespawn/api/GeologySamplerSourceContractTest.java create mode 100644 src/test/java/zone/moddev/mc/orespawn/worldgen/BackgroundGenerationScaleTest.java 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/OreSpawnGeologySampler.java b/src/main/java/zone/moddev/mc/orespawn/api/OreSpawnGeologySampler.java index c53d1bd5..47579873 100644 --- a/src/main/java/zone/moddev/mc/orespawn/api/OreSpawnGeologySampler.java +++ b/src/main/java/zone/moddev/mc/orespawn/api/OreSpawnGeologySampler.java @@ -55,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 736885ca..584a3b94 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; @@ -28,11 +29,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); } @@ -48,6 +52,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); @@ -56,6 +69,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 = @@ -78,6 +92,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; @@ -215,6 +239,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)); @@ -224,7 +250,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) { @@ -537,6 +563,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; @@ -565,6 +592,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); } @@ -595,6 +623,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() { @@ -632,6 +669,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; } @@ -661,6 +701,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; } @@ -695,6 +736,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)); @@ -712,6 +761,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/integration/WorldgenIntegrationManager.java b/src/main/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManager.java index 0dde25e6..6f5e9a94 100644 --- a/src/main/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManager.java +++ b/src/main/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManager.java @@ -168,13 +168,28 @@ public static synchronized Set activeProviderIds() { /** 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")); @@ -194,7 +209,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); @@ -202,7 +217,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"); } @@ -447,6 +464,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", @@ -571,12 +593,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()); } 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 ecc4cc85..fee7b6a5 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGeneration.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGeneration.java @@ -58,8 +58,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; @@ -78,8 +81,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; @@ -93,6 +98,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; } @@ -101,7 +128,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) { @@ -161,7 +189,8 @@ private static boolean generateChunk(WorldServer level, World world, Chunk chunk 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]; } @@ -224,6 +253,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(ChunkAccessCompat.position(chunk), x, y, z); } @@ -323,26 +363,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", @@ -352,8 +403,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) { @@ -368,6 +421,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(); } @@ -421,6 +485,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); @@ -442,7 +510,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, @@ -656,6 +725,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, @@ -666,7 +737,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; @@ -691,6 +763,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) { @@ -760,20 +834,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; } } 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 bd234d47..bbd7de17 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.chunkXPos, chunk.chunkZPos)) { + 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 597e9384..02139ebe 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/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/WorldgenProviderTest.java b/src/test/java/zone/moddev/mc/orespawn/api/WorldgenProviderTest.java index c4bc6d13..68f57d10 100644 --- a/src/test/java/zone/moddev/mc/orespawn/api/WorldgenProviderTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/api/WorldgenProviderTest.java @@ -194,6 +194,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/integration/WorldgenIntegrationManagerTest.java b/src/test/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManagerTest.java index d9f3929d..14976eab 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; @@ -113,6 +116,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 3a869dc6..7a82fa46 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"); From 60beadd10c02f70e125d9e6a3998cc9ef637c0ee Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Fri, 11 Sep 2026 09:56:19 +0100 Subject: [PATCH 04/31] Document OreSpawn 4.1 extension policies --- CHANGELOG.txt | 6 ++++++ README.md | 7 ++++--- docs/API.md | 19 +++++++++++++++++-- docs/DEVELOPER_GUIDE.md | 1 + docs/PROVIDERS.md | 9 ++++++--- docs/schemas/orespawn-provider.schema.json | 1 + docs/schemas/profile-common.schema.json | 5 +++++ 7 files changed, 40 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 64980315..a2ead528 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -7,6 +7,12 @@ Version 4.1.0.110021 * Add a deterministic client-only registry for translated add-on actions in OreSpawn's world-settings screen, with parent-screen handoff and duplicate-ID rejection. +* Add optional per-resource background-generation scaling for controller + patterns, including deterministic vanilla gating and lowest-controller wins. +* Let structural providers keep newly introduced entries out of existing world + profiles while retaining the historical merge behavior by default. +* Guarantee geology column sampling does not load or generate neighbouring + chunks. * Preserve API major 1, schemas 4/6/5, existing pattern binaries, profiles, migration behaviour and generated chunks. diff --git a/README.md b/README.md index 6a3f8330..65a86449 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,10 @@ rock strata and geomes, provider-owned underground fluid deposits, biome palettes and world materials, flat bedrock, and bounded ore retrogen. This branch builds target-qualified version `4.1.0.110021`: the OreSpawn 4.1.0 -feature set for Minecraft 1.10.2 and Forge. It adds stable generation identity -and a client world-settings extension point for add-ons such as Realistic -Deposits while retaining API major 1. See the +feature set for Minecraft 1.10.2 and Forge. It adds stable generation identity, +non-loading geology sampling, per-resource background control, existing-world +provider merge policy and a client world-settings extension point for add-ons +such as Realistic Deposits while retaining API major 1. See the [versioning policy](docs/VERSIONS.md) for the encoding and release convention. Its deprecated compatibility layer imports OreSpawn 1 and OreSpawn 3 diff --git a/docs/API.md b/docs/API.md index 98b9fd85..5bbec638 100644 --- a/docs/API.md +++ b/docs/API.md @@ -150,8 +150,9 @@ OreSpawnApi.createSampler(server.overworld()).ifPresent(sampler -> { ``` `sampleColumn` performs one biome/geome classification and reuses it for every -Y query. Sampling is read-only and is intended for gameplay decisions, -diagnostics, and compatible generation outside OreSpawn's block loops. +Y query. Sampling is read-only, does not load or generate the requested chunk, +and is intended for gameplay decisions, diagnostics, and compatible generation +outside OreSpawn's block loops. OreSpawn 4.1 adds `OreGenerationContext` as a binary-compatible subtype of the original `OrePlacementContext`. Every context supplied by OreSpawn implements @@ -176,6 +177,20 @@ patterns must independently render only the slice intersecting the current chunk and continue to use `inside(...)` and `tryPlace(...)` for safe writes. Do not retain the sampler or placement context beyond the current call. +Large-deposit add-ons may make one OreSpawn rule the controller for a resource +in a dimension. Set `.backgroundGenerationScale(value)` on that rule to scale +other OreSpawn-managed rules and vanilla generation for the same primary +output. The controller rule remains unscaled, omitted values mean `1.0`, zero +fully suppresses background generation, and the lowest value wins when several +controllers target the same resource. Vanilla decisions use a stable hash of +world, dimension, chunk and resource rather than mutable event order. + +Providers whose entries capture structural configuration can call +`.mergeNewEntriesIntoExistingWorlds(false)`. New worlds still receive the +complete provider, while an existing saved world profile does not silently gain +definitions introduced after that world was created. The historical default is +`true`. + Forge 12 custom-pattern mods attach a generic `RegistryEvent.Register` listener to their mod event bus and register named values into `OreSpawnPatternRegistry.REGISTRY_NAME`. An diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md index 0de6b9d3..24004910 100644 --- a/docs/DEVELOPER_GUIDE.md +++ b/docs/DEVELOPER_GUIDE.md @@ -14,6 +14,7 @@ | 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, diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 2bf28838..533803b3 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 }, From 7c4a7c8d956cdffb69b5091a7aed25806c9200bf Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Sat, 12 Sep 2026 15:48:13 +0100 Subject: [PATCH 05/31] Add OreSpawn mod integration directory --- CHANGELOG.txt | 11 +- README.md | 7 +- build.gradle | 2 + docs/API.md | 30 ++- docs/DEVELOPER_GUIDE.md | 24 ++- docs/PLAYER_GUIDE.md | 11 +- .../orespawn/client/ClientProbeTestMod.java | 62 ++++++ .../orespawn/compat/LegacyOs3Bridge.java | 31 +++ .../api/client/WorldSettingsExtension.java | 2 +- .../WorldSettingsExtensionRegistry.java | 50 ++++- .../client/WorldSettingsScreenFactory.java | 2 +- .../mc/orespawn/client/ClientSetup.java | 1 + .../moddev/mc/orespawn/client/CogButton.java | 27 +++ .../client/OreSpawnModDirectoryModel.java | 123 ++++++++++++ .../orespawn/client/OreSpawnModsScreen.java | 189 ++++++++++++++++++ .../client/OreSpawnWorldSettingsScreen.java | 43 ++-- .../WorldSettingsExtensionNavigation.java | 39 ++++ .../WorldgenIntegrationManager.java | 101 +++++++++- .../worldgen/LegacyConfigMigrator.java | 11 + .../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/en_US.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 + .../WorldSettingsExtensionRegistryTest.java | 80 ++++++-- .../client/OreSpawnModDirectoryModelTest.java | 111 ++++++++++ .../OreSpawnModsScreenContractTest.java | 75 +++++++ .../client/OreSpawnScreenLayoutTest.java | 9 +- 42 files changed, 1328 insertions(+), 84 deletions(-) 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/client/OreSpawnModDirectoryModelTest.java create mode 100644 src/test/java/zone/moddev/mc/orespawn/client/OreSpawnModsScreenContractTest.java diff --git a/CHANGELOG.txt b/CHANGELOG.txt index a2ead528..a745dbba 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -4,9 +4,14 @@ Version 4.1.0.110021 world seed, dimension and current-chunk identity to compiled pattern add-ons. * Expose the active geology sampler to patterns when the generated dimension has OreSpawn geology, including identical identity during supported retrogen. -* Add a deterministic client-only registry for translated add-on actions in - OreSpawn's world-settings screen, with parent-screen handoff and duplicate-ID - rejection. +* Replace per-add-on world-settings buttons with one fixed Mods directory that + lists loaded native OS4, legacy OS1-OS3 and UI-only integrations with their + mod version, provider lineage, revision and status. +* Add a packaged cog action for add-on-owned configuration screens, including + pagination, disabled-state explanations, safe failure handling and a return + path through the Mods directory. +* Preserve the original three-argument client registration descriptor while + adding one-screen-per-mod `registerConfigScreen` registration. * Add optional per-resource background-generation scaling for controller patterns, including deterministic vanilla gating and lowest-controller wins. * Let structural providers keep newly introduced entries out of existing world diff --git a/README.md b/README.md index 65a86449..81738d29 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,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 528639e2..99b03906 100644 --- a/build.gradle +++ b/build.gradle @@ -673,6 +673,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/API.md b/docs/API.md index 5bbec638..ed2a10c6 100644 --- a/docs/API.md +++ b/docs/API.md @@ -202,9 +202,18 @@ generation. ## Client world-settings extensions -An add-on may contribute a translated action to OreSpawn's world-settings -screen without depending on OreSpawn implementation classes. Register from -client initialization only: +An add-on may contribute one configuration screen to OreSpawn's **Mods** +directory without depending on OreSpawn implementation classes. Prefer the +owning-mod registration form during client initialization: + +```java +WorldSettingsExtensionRegistry.registerConfigScreen( + "examplemod", + parent -> new ExampleDepositSettingsScreen(parent)); +``` + +The original three-argument form remains binary compatible. Its resource +namespace is treated as the owning mod ID: ```java WorldSettingsExtensionRegistry.register( @@ -213,12 +222,15 @@ WorldSettingsExtensionRegistry.register( parent -> new ExampleDepositSettingsScreen(parent)); ``` -OreSpawn owns layout and navigation and passes its current screen as the -factory parent, so the add-on's Done action can return safely. Entries appear -in deterministic registration order. Duplicate IDs, blank translation keys -and null factories are rejected. The translation belongs in the add-on's own -language resources. These types are client-only and must not be loaded from a -dedicated-server initialization path. +OreSpawn owns directory layout and passes the Mods directory as the factory +parent. Add-ons should use that parent for Done; OreSpawn also redirects the +inherited vanilla Escape action there before the player returns to the main +geology screen. Exactly one screen may be owned by +each mod; duplicate ownership, blank legacy translation keys and null factories +are rejected deterministically. The legacy translation key and extension +accessors remain available to existing binaries, although the directory now +uses its packaged cog action. These types are client-only and must not be +loaded from a dedicated-server initialization path. `OreSpawnOreIntegration` remains as a deprecated facade for early ore-provider integrations. New code should use `OreSpawnApi`. diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md index 24004910..8fbf88eb 100644 --- a/docs/DEVELOPER_GUIDE.md +++ b/docs/DEVELOPER_GUIDE.md @@ -138,7 +138,17 @@ setup in the registered pattern compiler, not its placement callback. ## Add-on world settings -Register optional client configuration screens during client initialization: +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( @@ -147,10 +157,14 @@ WorldSettingsExtensionRegistry.register( parent -> new ExampleDepositSettingsScreen(parent)); ``` -The registry is client-only. OreSpawn renders the translated button, controls -its placement, and supplies the current OreSpawn screen as the parent. The -extension owns its own screen and translations and must return to that parent -from Done. Duplicate identifiers are rejected deterministically. +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 diff --git a/docs/PLAYER_GUIDE.md b/docs/PLAYER_GUIDE.md index 2bd031c2..c0682804 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/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java index 1a9003a6..5fac2bf7 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); } @@ -174,6 +182,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; @@ -189,6 +248,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") @@ -325,6 +385,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 7c7369ff..b5a60e59 100644 --- a/src/main/java/com/mcmoddev/orespawn/compat/LegacyOs3Bridge.java +++ b/src/main/java/com/mcmoddev/orespawn/compat/LegacyOs3Bridge.java @@ -84,6 +84,7 @@ import net.minecraftforge.common.DimensionManager; import net.minecraftforge.oredict.OreDictionary; import net.minecraft.item.ItemStack; +import zone.moddev.mc.orespawn.integration.WorldgenIntegrationManager; import zone.moddev.mc.orespawn.worldgen.LegacyOs3ProfileMigration; /** @@ -315,6 +316,7 @@ private static void migrateConfigDirectory(Path configDirectory) { LOGGER.error("Could not migrate OS3 global world-generation settings", failure); } Map programmaticSources = API.programmaticSources(); + recordLegacyLineages(legacyDirectory, os1Directory, programmaticSources); if (Files.isRegularFile(bridgeMarker)) { LOGGER.info("OS3 provider migration already completed; retaining migrated files unchanged"); return; @@ -406,6 +408,35 @@ private static void migrateConfigDirectory(Path configDirectory) { } } + private static void recordLegacyLineages(Path legacyDirectory, Path os1Directory, + Map programmaticSources) { + for (String owner : OS1_PROGRAMMATIC.keySet()) { + WorldgenIntegrationManager.recordLegacyLineage(owner, 1); + } + for (String owner : programmaticSources.keySet()) { + WorldgenIntegrationManager.recordLegacyLineage(owner, 3); + } + for (String owner : API.embedded.keySet()) { + WorldgenIntegrationManager.recordLegacyLineage(owner, 3); + } + recordLegacyFiles(legacyDirectory, 3); + recordLegacyFiles(os1Directory, 1); + } + + private static void recordLegacyFiles(Path directory, int generation) { + if (!Files.isDirectory(directory)) return; + try (DirectoryStream files = Files.newDirectoryStream(directory, "*.json")) { + for (Path file : files) { + String owner = file.getFileName().toString().replaceFirst("\\.json$", ""); + if (validModId(owner)) { + WorldgenIntegrationManager.recordLegacyLineage(owner, generation); + } + } + } catch (IOException failure) { + REPORT.add("lineage_scan_failed=" + directory + ":" + failure.getClass().getSimpleName()); + } + } + static JsonObject translateOs1ForTests(String owner, JsonObject source) { return convertOs1Source(owner, source); } 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 index 66d7ba51..b632a033 100644 --- a/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtension.java +++ b/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtension.java @@ -25,7 +25,7 @@ public final class WorldSettingsExtension { public ResourceLocation id() { return id; } public String buttonTranslationKey() { return buttonTranslationKey; } - /** Creates the extension screen with the current OreSpawn screen as its parent. */ + /** 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 index a7cbf0ef..c55b9936 100644 --- a/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistry.java +++ b/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistry.java @@ -6,42 +6,72 @@ 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 actions in OreSpawn's world-settings screen. */ +/** Client-only registry for add-on screens in OreSpawn's Mods directory. */ @SideOnly(Side.CLIENT) public final class WorldSettingsExtensionRegistry { - private static final Map EXTENSIONS = + 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 one translated world-settings action in deterministic registration - * order. Registration must occur during client initialization. + * 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"); - Objects.requireNonNull(screenFactory, "screenFactory"); if (buttonTranslationKey == null || buttonTranslationKey.trim().isEmpty()) { throw new IllegalArgumentException("buttonTranslationKey cannot be blank"); } - if (EXTENSIONS.containsKey(id)) { - throw new IllegalStateException("Duplicate OreSpawn world-settings extension: " + id); + String modId = id.getResourceDomain(); + 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(id, extension); + 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 index dcf2a199..4ee3841b 100644 --- a/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsScreenFactory.java +++ b/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsScreenFactory.java @@ -4,7 +4,7 @@ import net.minecraftforge.fml.relauncher.Side; import net.minecraftforge.fml.relauncher.SideOnly; -/** Creates an add-on configuration screen whose Done action returns to OreSpawn. */ +/** Creates an add-on configuration screen whose Done action returns to OreSpawn's mod directory. */ @FunctionalInterface @SideOnly(Side.CLIENT) public interface WorldSettingsScreenFactory { 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 e3d0f494..3d391969 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..250319fb --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/CogButton.java @@ -0,0 +1,27 @@ +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) { + super.drawButton(minecraft, mouseX, mouseY); + 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); + drawModalRectWithCustomSizedTexture(xPosition + 3, yPosition + 2, + 0.0F, 0.0F, 16, 16, 16.0F, 16.0F); + 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..f47767a3 --- /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().getResourceDomain(); + 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 9ab2e809..89268289 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnWorldSettingsScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnWorldSettingsScreen.java @@ -7,8 +7,6 @@ import java.util.Objects; import zone.moddev.mc.orespawn.OreSpawnConfig.GeologyMode; -import zone.moddev.mc.orespawn.api.client.WorldSettingsExtension; -import zone.moddev.mc.orespawn.api.client.WorldSettingsExtensionRegistry; import zone.moddev.mc.orespawn.worldgen.FormationSettings.Preset; import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfile; import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfileManager; @@ -71,10 +69,9 @@ protected void init() { int right = left + columnWidth + 5; boolean terrain = session.hasTerrainRules(); boolean fluids = !session.fluidDepositIds().isEmpty(); - List extensions = WorldSettingsExtensionRegistry.extensions(); int top = OreSpawnScreenLayout.mainTop(this.height); - int rows = (templateChoices.size() > 1 ? 1 : 0) + (terrain ? 8 - : 5 + (fluids ? 1 : 0)) + extensions.size(); + int rows = (templateChoices.size() > 1 ? 1 : 0) + (terrain ? 9 + : 5 + (fluids ? 1 : 0)); int available = OreSpawnScreenLayout.footerY(height) - top - BUTTON_HEIGHT - 4; int row = Math.min(OreSpawnScreenLayout.mainRowSpacing(this.height), rows <= 1 ? 24 : Math.max(BUTTON_HEIGHT, available / (rows - 1))); @@ -109,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,21 +150,18 @@ columnWidth, BUTTON_HEIGHT, new TextComponentTranslation("button.orespawn.materi columnWidth, BUTTON_HEIGHT, new TextComponentTranslation("button.orespawn.biomes_world_materials"), button -> openBiomeWorldMaterials(), "tooltip.orespawn.main.biomes_materials")); - addButton(OreSpawnScreenLayout.explainedButton(this, font, left, top + (row * rowIndex), - columnWidth, BUTTON_HEIGHT, new TextComponentTranslation("button.orespawn.advanced"), + addButton(OreSpawnScreenLayout.explainedButton(this, font, left, top + (row * rowIndex++), + contentWidth, BUTTON_HEIGHT, new TextComponentTranslation("button.orespawn.advanced"), button -> openAdvanced(), "tooltip.orespawn.main.advanced")); - 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")); - } - for (WorldSettingsExtension extension : extensions) { - addButton(OreSpawnScreenLayout.button(this, font, left, top + (row * rowIndex++), - contentWidth, BUTTON_HEIGHT, - new TextComponentTranslation(extension.buttonTranslationKey()), - button -> openExtension(extension))); + 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.button(this, font, left, this.height - 28, columnWidth, BUTTON_HEIGHT, DialogTexts.GUI_DONE, button -> saveAndClose())); @@ -331,15 +328,9 @@ private void openHelp() { minecraft.displayGuiScreen(new OreSpawnGuideScreen(this)); } - private void openExtension(WorldSettingsExtension extension) { + private void openMods() { syncSession(); - GuiScreen screen = extension.createScreen(this); - if (screen == null) { - validationError = new TextComponentString( - "World-settings extension did not create a screen: " + extension.id()); - return; - } - minecraft.displayGuiScreen(screen); + minecraft.displayGuiScreen(new OreSpawnModsScreen(this)); } @Override 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 6f5e9a94..316d592a 100644 --- a/src/main/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManager.java +++ b/src/main/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManager.java @@ -68,6 +68,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; @@ -166,6 +167,53 @@ 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); @@ -428,14 +476,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; } @@ -453,6 +494,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) { @@ -1077,6 +1128,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 953e0516..aab3b5d4 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java @@ -46,6 +46,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"); @@ -102,6 +103,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/resources/assets/orespawn/lang/de_AU.lang b/src/main/resources/assets/orespawn/lang/de_AU.lang index 28319a7a..63b71fed 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 28319a7a..63b71fed 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 ca45e95f..e1fe984d 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 ca45e95f..e1fe984d 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 ca45e95f..e1fe984d 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 ca45e95f..e1fe984d 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/en_US.lang b/src/main/resources/assets/orespawn/lang/en_US.lang index 847f14dc..ad792079 100644 --- a/src/main/resources/assets/orespawn/lang/en_US.lang +++ b/src/main/resources/assets/orespawn/lang/en_US.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 c314efd0..4e6ca25e 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 c314efd0..4e6ca25e 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 e6401a63..632f79fc 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 e6401a63..632f79fc 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 14410380..0309bfc8 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 4160ffb7..925602ac 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 e8daf02f..c242bc55 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 4c19ffe7..0b1def7c 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 b57e863f..10f23d61 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 3c95d137..117fd804 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 b0d95cfc..4a6e7c86 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/client/WorldSettingsExtensionRegistryTest.java b/src/test/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistryTest.java index 55d090e9..2b5c078a 100644 --- a/src/test/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistryTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistryTest.java @@ -1,6 +1,7 @@ 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; @@ -19,8 +20,8 @@ class WorldSettingsExtensionRegistryTest { @Test void preservesRegistrationOrderAndReturnsAnImmutableSnapshot() { - WorldSettingsExtension first = register("ordered_first", parent -> new GuiScreen() { }); - WorldSettingsExtension second = register("ordered_second", parent -> new GuiScreen() { }); + 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)); @@ -28,14 +29,31 @@ void preservesRegistrationOrderAndReturnsAnImmutableSnapshot() { } @Test - void rejectsDuplicateIdsAndBlankTranslationKeys() { - ResourceLocation id = id("duplicate"); + void rejectsDuplicateOwnershipAndInvalidRegistrations() { + ResourceLocation id = id("duplicate_owner", "first"); WorldSettingsExtensionRegistry.register(id, "button.realisticdeposits.settings", parent -> new GuiScreen() { }); - assertThrows(IllegalStateException.class, () -> WorldSettingsExtensionRegistry.register(id, + assertThrows(IllegalStateException.class, () -> WorldSettingsExtensionRegistry.register( + id("duplicate_owner", "second"), "button.realisticdeposits.other", parent -> new GuiScreen() { })); assertThrows(IllegalArgumentException.class, () -> WorldSettingsExtensionRegistry.register( - id("blank"), " ", parent -> new GuiScreen() { })); + 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().getResourceDomain()); + 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 @@ -43,7 +61,7 @@ void passesTheCurrentOreSpawnScreenToTheFactory() { AtomicReference receivedParent = new AtomicReference<>(); GuiScreen expectedParent = new GuiScreen() { }; GuiScreen expectedChild = new GuiScreen() { }; - WorldSettingsExtension extension = register("parent", parent -> { + WorldSettingsExtension extension = register("parent_handoff", "settings", parent -> { receivedParent.set(parent); return expectedChild; }); @@ -54,21 +72,53 @@ void passesTheCurrentOreSpawnScreenToTheFactory() { } @Test - void worldSettingsScreenOwnsExtensionButtonPlacementAndNavigation() throws Exception { + 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()")); - assertTrue(source.contains("new TextComponentTranslation(extension.buttonTranslationKey())")); - assertTrue(source.contains("extension.createScreen(this)")); + 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 path, WorldSettingsScreenFactory factory) { - return WorldSettingsExtensionRegistry.register(id(path), + 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 path) { - return new ResourceLocation("realisticdeposits", path); + 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..234543cb --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnModsScreenContractTest.java @@ -0,0 +1,75 @@ +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")); + } + + @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, 1)")); + 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 7fea8b86..4bc51ee3 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( @@ -61,8 +61,13 @@ void compactOrePlacementRowsStayAboveFooterAtMinimumTestHeight() { } private static void assertRowsClearFooter(int height) { + int rows = 9; + 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)); } From 6275b4f3c64d8c25d4e818ffb304b2ae5a772ac3 Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Mon, 14 Sep 2026 13:47:09 +0100 Subject: [PATCH 06/31] Polish OreSpawn mod directory layout --- .../moddev/mc/orespawn/client/CogButton.java | 12 +++++++++++- .../client/OreSpawnWorldSettingsScreen.java | 10 +++++----- .../client/OreSpawnModsScreenContractTest.java | 2 ++ .../client/OreSpawnScreenLayoutTest.java | 18 +++++++++++++++++- 4 files changed, 35 insertions(+), 7 deletions(-) diff --git a/src/main/java/zone/moddev/mc/orespawn/client/CogButton.java b/src/main/java/zone/moddev/mc/orespawn/client/CogButton.java index 250319fb..bd6c25b5 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/CogButton.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/CogButton.java @@ -20,8 +20,18 @@ public void drawButton(Minecraft minecraft, int mouseX, int mouseY) { 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); - drawModalRectWithCustomSizedTexture(xPosition + 3, yPosition + 2, + GlStateManager.pushMatrix(); + GlStateManager.translate(xPosition + 11.0F, yPosition + 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/OreSpawnWorldSettingsScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnWorldSettingsScreen.java index 89268289..8f599ccb 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnWorldSettingsScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnWorldSettingsScreen.java @@ -70,7 +70,7 @@ protected void init() { boolean terrain = session.hasTerrainRules(); boolean fluids = !session.fluidDepositIds().isEmpty(); int top = OreSpawnScreenLayout.mainTop(this.height); - int rows = (templateChoices.size() > 1 ? 1 : 0) + (terrain ? 9 + int rows = (templateChoices.size() > 1 ? 1 : 0) + (terrain ? 8 : 5 + (fluids ? 1 : 0)); int available = OreSpawnScreenLayout.footerY(height) - top - BUTTON_HEIGHT - 4; int row = Math.min(OreSpawnScreenLayout.mainRowSpacing(this.height), @@ -150,11 +150,11 @@ columnWidth, BUTTON_HEIGHT, new TextComponentTranslation("button.orespawn.materi columnWidth, BUTTON_HEIGHT, new TextComponentTranslation("button.orespawn.biomes_world_materials"), button -> openBiomeWorldMaterials(), "tooltip.orespawn.main.biomes_materials")); - addButton(OreSpawnScreenLayout.explainedButton(this, font, left, top + (row * rowIndex++), - contentWidth, BUTTON_HEIGHT, new TextComponentTranslation("button.orespawn.advanced"), + 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, left, top + (row * rowIndex++), - contentWidth, BUTTON_HEIGHT, fluidEditorLabel(), button -> openFluidDeposits(), + 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"), diff --git a/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnModsScreenContractTest.java b/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnModsScreenContractTest.java index 234543cb..b0187b1f 100644 --- a/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnModsScreenContractTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnModsScreenContractTest.java @@ -32,6 +32,8 @@ void packagedCogIsARealSixteenPixelPngRatherThanAFontGlyph() throws Exception { 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 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 4bc51ee3..04a02059 100644 --- a/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java @@ -60,8 +60,24 @@ 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 = 9; + int rows = 8; int top = OreSpawnScreenLayout.mainTop(height); int available = OreSpawnScreenLayout.footerY(height) - top - 20 - 4; int spacing = Math.min(OreSpawnScreenLayout.mainRowSpacing(height), From e0850d3e3de12ca6321352217b801004f8740aa4 Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Mon, 14 Sep 2026 15:59:11 +0100 Subject: [PATCH 07/31] Add material source arbitration contracts --- docs/examples/examplemod-orespawn.json | 4 +- docs/schemas/orespawn-provider.schema.json | 2 +- .../mc/orespawn/api/OreGenerationContext.java | 9 +++++ .../mc/orespawn/api/WorldgenProvider.java | 26 ++++++++++++- .../WorldgenIntegrationManager.java | 24 ++++++++---- .../api/OreGenerationContextTest.java | 9 +++++ .../mc/orespawn/api/WorldgenProviderTest.java | 26 ++++++++++++- .../WorldgenIntegrationManagerTest.java | 37 +++++++++++++++++++ 8 files changed, 124 insertions(+), 13 deletions(-) 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/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/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 584a3b94..a62bf209 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; @@ -61,7 +62,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); } @@ -236,7 +237,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", @@ -376,6 +377,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; @@ -389,6 +391,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; @@ -404,6 +407,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; } @@ -415,6 +420,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); @@ -440,6 +446,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; @@ -456,6 +463,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; } @@ -547,6 +556,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; @@ -575,6 +585,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; @@ -607,6 +620,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; } @@ -654,6 +669,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); @@ -685,6 +701,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; @@ -722,6 +739,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/integration/WorldgenIntegrationManager.java b/src/main/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManager.java index 316d592a..72046d43 100644 --- a/src/main/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManager.java +++ b/src/main/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManager.java @@ -206,7 +206,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)); @@ -506,7 +506,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", ""))) { @@ -555,7 +555,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)) { @@ -610,7 +610,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) { @@ -626,19 +630,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/test/java/zone/moddev/mc/orespawn/api/OreGenerationContextTest.java b/src/test/java/zone/moddev/mc/orespawn/api/OreGenerationContextTest.java index 6399b435..e7ff2f18 100644 --- a/src/test/java/zone/moddev/mc/orespawn/api/OreGenerationContextTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/api/OreGenerationContextTest.java @@ -25,6 +25,13 @@ void extendsTheOriginalPlacementContractWithoutChangingItsAbi() { assertFalse(baseMethods.contains("chunkX")); assertFalse(baseMethods.contains("chunkZ")); assertFalse(baseMethods.contains("geologySampler")); + assertEquals(boolean.class, method(OrePlacementContext.class, "tryPlace", + int.class, int.class, int.class).getReturnType()); + } + + private static Method method(Class owner, String name, Class... parameters) { + try { return owner.getMethod(name, parameters); } + catch (ReflectiveOperationException missing) { throw new AssertionError(missing); } } @Test @@ -36,6 +43,8 @@ void exposesTheStableRegionDepositIdentityContract() throws Exception { assertEquals(int.class, OreGenerationContext.class.getMethod("chunkZ").getReturnType()); assertEquals(Optional.class, OreGenerationContext.class.getMethod("geologySampler").getReturnType()); + assertTrue(OreGenerationContext.class.getMethod("tryPlace", + int.class, int.class, int.class, long.class).isDefault()); } @Test 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 68f57d10..611e6074 100644 --- a/src/test/java/zone/moddev/mc/orespawn/api/WorldgenProviderTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/api/WorldgenProviderTest.java @@ -37,18 +37,20 @@ void terrainHostContractRetainsLegacyNaturalSourceOrder() { } @Test - void serializesTypedSchemaFourProvider() { + void serializesTypedSchemaFiveProvider() { ResourceLocation overworld = id("minecraft:overworld"); WorldgenProvider provider = WorldgenProvider.builder("examplemod", 4) .rock(id("examplemod:slate"), GeologyFamily.METAMORPHIC, rock -> rock .depth(20, 36).weight(1.25D).oreReplaceable(true)) .ore(id("examplemod:tin_ore"), ore -> ore + .material(id("orespawn:tin")) .output(id("examplemod:tin_ore"), 9.0D) .output(id("examplemod:rich_tin_ore"), 1.0D, -64, 24) .suppressVanilla(true).retrogen(false) .dimension(overworld, dimension -> dimension .yRange(-16, 96).attempts(6.5D).quantity(8) .pattern(OrePattern.CLUSTER) + .placementChannel(id("examplemod:ordinary_veins")) .heightDistribution(OreHeightDistribution.BOTTOM_TRIANGLE) .discardChanceOnAirExposure(0.75D) .hostFamily(GeologyFamily.METAMORPHIC) @@ -58,19 +60,22 @@ void serializesTypedSchemaFourProvider() { .build(); JsonObject json = provider.toJson(); - assertEquals(4, json.get("schema_version").getAsInt()); + assertEquals(5, json.get("schema_version").getAsInt()); assertEquals("examplemod", json.get("provider_modid").getAsString()); assertTrue(json.getAsJsonObject("rocks").has("examplemod:rock/examplemod/slate")); assertEquals("examplemod:slate", json.getAsJsonObject("rocks") .getAsJsonObject("examplemod:rock/examplemod/slate").get("block").getAsString()); assertTrue(json.getAsJsonObject("ores").has("examplemod:ore/examplemod/tin_ore")); JsonObject ore = json.getAsJsonObject("ores").getAsJsonObject("examplemod:ore/examplemod/tin_ore"); + assertEquals("orespawn:tin", ore.get("material").getAsString()); assertEquals(2, ore.getAsJsonArray("outputs").size()); assertFalse(ore.get("retrogen").getAsBoolean()); assertEquals(0.75D, ore.getAsJsonObject("dimensions").getAsJsonObject("minecraft:overworld") .getAsJsonArray("host_blocks").get(0).getAsJsonObject().get("weight").getAsDouble()); assertEquals("bottom_triangle", ore.getAsJsonObject("dimensions") .getAsJsonObject("minecraft:overworld").get("height_distribution").getAsString()); + assertEquals("examplemod:ordinary_veins", ore.getAsJsonObject("dimensions") + .getAsJsonObject("minecraft:overworld").get("placement_channel").getAsString()); assertEquals(0.75D, ore.getAsJsonObject("dimensions") .getAsJsonObject("minecraft:overworld") .get("discard_chance_on_air_exposure").getAsDouble()); @@ -81,6 +86,23 @@ void serializesTypedSchemaFourProvider() { .has("examplemod:rock/examplemod/slate")); } + @Test + void derivesStablePlacementChannelsAndExposesImmutableMaterial() { + WorldgenProvider.OreDimensionDefinition standard = WorldgenProvider.OreDimensionDefinition + .builder(id("minecraft:overworld")).hostBlock(id("minecraft:stone")).build(); + WorldgenProvider.OreDimensionDefinition custom = WorldgenProvider.OreDimensionDefinition + .builder(id("minecraft:overworld")) + .pattern(id("realisticdeposits:stratiform"), new JsonObject()) + .hostBlock(id("minecraft:stone")).build(); + WorldgenProvider.OreDefinition ore = WorldgenProvider.OreDefinition + .builder(id("examplemod:iron"), id("examplemod:iron_ore")) + .material(id("orespawn:iron")).dimension(standard).build(); + + assertEquals(id("orespawn:iron"), ore.material().get()); + assertEquals(id("orespawn:standard"), standard.placementChannel()); + assertEquals(id("realisticdeposits:stratiform"), custom.placementChannel()); + } + @Test void serializesCompleteDeclarativeProviderSurface() { ResourceLocation dimension = id("minecraft:the_end"); 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 14976eab..a5f4b991 100644 --- a/src/test/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManagerTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManagerTest.java @@ -116,6 +116,14 @@ void validatesBiomePaletteAndDimensionMaterialsOnlyInSchemaFour() { () -> WorldgenIntegrationManager.validateProvider("examplemod", solidFluid)); } + @Test + void sourceArbitrationFieldsRequireProviderSchemaFive() { + assertDoesNotThrow(() -> WorldgenIntegrationManager.validateProvider( + "examplemod", oreSourceProvider(5))); + assertThrows(JsonSyntaxException.class, () -> WorldgenIntegrationManager.validateProvider( + "examplemod", oreSourceProvider(4))); + } + @Test @SuppressWarnings({ "rawtypes", "unchecked" }) void providerCanDeferNewEntriesForExistingWorldsWithoutChangingNewWorldDefaults() @@ -227,4 +235,33 @@ private static JsonObject biomeProvider(int schema, String fluid) { root.add("dimension_materials", materials); return root; } + + private static JsonObject oreSourceProvider(int schema) { + JsonObject root = new JsonObject(); + root.addProperty("schema_version", schema); + root.addProperty("provider_modid", "examplemod"); + root.addProperty("provider_revision", 1); + JsonObject rule = new JsonObject(); + // This test exercises only the schema gate. Pattern decoding is covered by + // the provider validation tests that install the OreSpawn pattern registry. + rule.addProperty("enabled", false); + rule.addProperty("min_y", 0); + rule.addProperty("max_y", 64); + rule.addProperty("frequency", 1.0D); + rule.addProperty("quantity", 8); + rule.addProperty("placement_channel", "orespawn:standard"); + JsonArray hosts = new JsonArray(); + hosts.add(new JsonPrimitive("minecraft:stone")); + rule.add("host_blocks", hosts); + JsonObject ore = new JsonObject(); + ore.addProperty("block", "minecraft:iron_ore"); + ore.addProperty("material", "orespawn:iron"); + JsonObject dimensions = new JsonObject(); + dimensions.add("minecraft:overworld", rule); + ore.add("dimensions", dimensions); + JsonObject ores = new JsonObject(); + ores.add("examplemod:iron", ore); + root.add("ores", ores); + return root; + } } From 33f00840339663c79f579058eb79e2a1b211123b Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Mon, 14 Sep 2026 15:59:20 +0100 Subject: [PATCH 08/31] Resolve duplicate ore sources deterministically --- build.gradle | 10 +- docs/examples/orespawn-global.json | 3 +- docs/examples/orespawn-world.json | 3 +- docs/schemas/orespawn-global.schema.json | 2 +- docs/schemas/orespawn-world.schema.json | 2 +- docs/schemas/profile-common.schema.json | 41 +- .../mc/orespawn/worldgen/GeomeConfig.java | 15 +- .../orespawn/worldgen/OreSourcePolicies.java | 700 ++++++++++++++++++ .../worldgen/OreSpawnOreGeneration.java | 325 +++++++- .../worldgen/WorldGeologyProfile.java | 17 +- .../worldgen/WorldGeologyProfileManager.java | 24 +- .../BackgroundGenerationScaleTest.java | 11 + .../worldgen/OreSourcePoliciesTest.java | 254 +++++++ .../worldgen/OreSpawnOreGenerationTest.java | 121 ++- .../worldgen/WorldGeologyProfileTest.java | 78 ++ 15 files changed, 1564 insertions(+), 42 deletions(-) create mode 100644 src/main/java/zone/moddev/mc/orespawn/worldgen/OreSourcePolicies.java create mode 100644 src/test/java/zone/moddev/mc/orespawn/worldgen/OreSourcePoliciesTest.java diff --git a/build.gradle b/build.gradle index 99b03906..2e1acd2a 100644 --- a/build.gradle +++ b/build.gradle @@ -1002,14 +1002,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 = 7') || !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 = 6')) { + throw new GradleException('Global/world schema versions must remain 7/6') } 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') } } } diff --git a/docs/examples/orespawn-global.json b/docs/examples/orespawn-global.json index cabcfee1..dd3740a5 100644 --- a/docs/examples/orespawn-global.json +++ b/docs/examples/orespawn-global.json @@ -1,5 +1,5 @@ { - "schema_version": 6, + "schema_version": 7, "geology_mode": "geome", "place_fluid_deposits": true, "manage_vanilla_ores": false, @@ -32,6 +32,7 @@ "biome_palettes": {}, "dimension_materials": {}, "ores": {}, + "ore_source_policies": {}, "fluid_deposits": {}, "retrogen": { "enabled": false, diff --git a/docs/examples/orespawn-world.json b/docs/examples/orespawn-world.json index 93f51e30..ca099c74 100644 --- a/docs/examples/orespawn-world.json +++ b/docs/examples/orespawn-world.json @@ -1,5 +1,5 @@ { - "schema_version": 5, + "schema_version": 6, "geology_mode": "geome", "place_fluid_deposits": true, "manage_vanilla_ores": false, @@ -70,6 +70,7 @@ "biome_palettes": {}, "dimension_materials": {}, "ores": {}, + "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..df82afa9 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": 7 } } } ] } diff --git a/docs/schemas/orespawn-world.schema.json b/docs/schemas/orespawn-world.schema.json index f23b7970..aebd4bde 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": 6 } } } ] } diff --git a/docs/schemas/profile-common.schema.json b/docs/schemas/profile-common.schema.json index d643ded5..9e3827e9 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_source_policies", "fluid_deposits"], "properties": { "geology_mode": { "enum": ["geome", "legacy"] }, "place_fluid_deposits": { "type": "boolean" }, @@ -19,6 +19,7 @@ "biome_palettes": { "type": "object", "additionalProperties": { "$ref": "#/$defs/biomePalette" } }, "dimension_materials": { "type": "object", "additionalProperties": { "$ref": "#/$defs/dimensionMaterials" } }, "ores": { "type": "object", "additionalProperties": { "$ref": "#/$defs/ore" } }, + "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 +154,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 +191,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 +231,42 @@ ], "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" }, + "external": { "type": "boolean" }, + "enrichment": { "type": "boolean" }, + "review_required": { "type": "boolean" } + }, + "additionalProperties": false + }, + "oreSourcePolicy": { + "type": "object", + "required": ["material", "domain", "mode", "outputs", "placement_sources", "candidates"], + "properties": { + "material": { "$ref": "#/$defs/registryId" }, + "domain": { "$ref": "#/$defs/registryId" }, + "mode": { "enum": ["consolidated", "keep_separate"] }, + "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"] } + }, + "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/main/java/zone/moddev/mc/orespawn/worldgen/GeomeConfig.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeConfig.java index 02642706..76f786be 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeConfig.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeConfig.java @@ -63,6 +63,7 @@ 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 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", @@ -72,7 +73,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 = 7; 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; @@ -98,6 +99,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); } @@ -195,13 +197,14 @@ 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 = migrateToV7(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 + : CONFIG_V6_BACKUP_PATH); return migrated; } if (schemaVersion > SCHEMA_VERSION) { @@ -664,14 +667,15 @@ private static JsonObject migrateV1(JsonObject original) { return refreshBiomeDefaults(migrated, defaultConfig()); } - private static JsonObject migrateToV6(JsonObject original, JsonObject defaults) { + private static JsonObject migrateToV7(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", + OreSourcePolicies.SECTION }) { if (!migrated.has(key)) { migrated.add(key, JsonCopies.copy(defaults.get(key))); } @@ -1415,6 +1419,7 @@ private static JsonObject defaultConfig() { root.add("cyano", defaultCyanoConfig()); root.add("fluid_deposits", new JsonObject()); root.add("ores", defaultOreConfig()); + 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/OreSourcePolicies.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSourcePolicies.java new file mode 100644 index 00000000..89292d3c --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSourcePolicies.java @@ -0,0 +1,700 @@ +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 ResourceLocation REVIEW = new ResourceLocation("orespawn", "review_required"); + 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 hadPolicies = root.has(SECTION) && root.get(SECTION).isJsonObject(); + JsonObject policies = object(root, SECTION); + Map groups = discover(root); + boolean changed = !hadPolicies; + Set refreshedKeys = new LinkedHashSet<>(); + for (Group group : groups.values()) { + if (!group.needsPolicy()) continue; + 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); + 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 (!previous.toString().equals(refreshed.toString())) { + policies.add(entry.getKey(), refreshed); + changed = true; + } + } + root.add(SECTION, policies); + return changed; + } + + 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(); + 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); + views.add(new GroupView(entry.getKey(), material, domain, + string(policy, "mode", MODE_SEPARATE), 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) || !group.needsPolicy()) continue; + JsonObject policy = createPolicy(group, true); + views.add(new GroupView(key, group.material, group.domain, MODE_SEPARATE, + 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 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) { + Set materials = new LinkedHashSet<>(); + List exact = new ArrayList<>(); + for (String name : oreNames) { + if (name == null || name.length() <= 3 || !name.startsWith("ore") + || !Character.isUpperCase(name.charAt(3))) continue; + exact.add(name); + String token = name.substring(3).toLowerCase(Locale.ROOT); + if ("sulphur".equals(token)) token = "sulfur"; + if ("aluminium".equals(token)) token = "aluminum"; + materials.add(token); + } + Collections.sort(exact); + if (materials.size() == 1) { + String token = materials.iterator().next(); + if (!token.matches("[a-z0-9_./-]+")) return new Inference(REVIEW, true, exact); + try { + return new Inference(new ResourceLocation("orespawn", token), false, exact); + } catch (RuntimeException invalidDictionaryName) { + return new Inference(REVIEW, true, exact); + } + } + return new Inference(materials.size() > 1 ? REVIEW : null, materials.size() > 1, exact); + } + + static String key(ResourceLocation material, ResourceLocation domain) { + return material.toString() + "|" + domain.toString(); + } + + private static Map discover(JsonObject root) { + Map groups = new LinkedHashMap<>(); + JsonObject ores = object(root, "ores"); + for (Entry oreEntry : ores.entrySet()) { + if (!oreEntry.getValue().isJsonObject()) continue; + JsonObject ore = oreEntry.getValue().getAsJsonObject(); + if (!bool(ore, "enabled", true)) continue; + 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(oreNames(stack)); + ResourceLocation material = resource(string(ore, "material", "")); + 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); + } + for (Entry selector : object(ore, "dimension_selectors").entrySet()) { + addConfigured(groups, oreEntry.getKey(), owner, blockId, metadata, material, + resource(selector.getKey()), selector.getValue(), inferred, role); + } + } + addExternalDictionaryCandidates(groups); + 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) { + if (domain == null || !ruleElement.isJsonObject() + || !bool(ruleElement.getAsJsonObject(), "enabled", true)) 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(); + Candidate candidate = new Candidate(sourceId, owner, modName(owner), modVersion(owner), blockId, + metadata, material, domain, placementChannel(rule), inferred.names, true, false, + role == Role.ENRICHMENT, inferred.reviewRequired); + group(groups, material, domain).add(candidate); + } + + private static void addExternalDictionaryCandidates(Map groups) { + if (groups.isEmpty()) return; + Map> namesByMaterial = new HashMap<>(); + for (String name : safeOreNames()) { + Inference inference = inferMaterial(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.getResourceDomain(); + 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), true, 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); + JsonObject outputs = new JsonObject(); + if (automatic) outputs.addProperty(preferred.sourceId, 1.0D); + else for (Candidate candidate : group.configured()) 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); + } + if (!policy.has("outputs") || !policy.get("outputs").isJsonObject()) { + JsonObject outputs = new JsonObject(); + for (Candidate candidate : group.configured()) 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 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; + 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) 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.getResourceDomain()), + string(json, "owner_name", ""), string(json, "owner_version", ""), block, + integer(json, "metadata", 0), material, domain, channel, names, + bool(json, "loaded", defaultLoaded), bool(json, "external", false), + bool(json, "enrichment", false), bool(json, "review_required", false)); + } + + private static boolean highConfidence(Group group) { + List priority = PRIORITIES.get(group.material.getResourcePath()); + if (priority == null || group.configured().size() < 2 || group.hasExternal()) return false; + for (Candidate candidate : group.configured()) if (!priority.contains(candidate.owner)) return false; + return true; + } + + private static Candidate preferred(Group group) { + List priority = PRIORITIES.get(group.material.getResourcePath()); + 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.getResourceDomain())); + 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 mode; + final String status; + final List candidates; + final Map outputs; + final Map placements; + + GroupView(String key, ResourceLocation material, ResourceLocation domain, String mode, + String status, List candidates, Map outputs, + Map placements) { + this.key = key; + this.material = material; + this.domain = domain; + this.mode = mode; + 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 external; + final boolean enrichment; + final boolean reviewRequired; + + Candidate(String sourceId, String owner, String ownerName, String ownerVersion, + ResourceLocation block, int metadata, ResourceLocation material, ResourceLocation domain, + ResourceLocation channel, List oreNames, boolean loaded, boolean external, + boolean enrichment, boolean reviewRequired) { + 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.external = external; + this.enrichment = enrichment; + this.reviewRequired = reviewRequired; + } + + String identity() { return sourceId + '|' + channel.toString(); } + + Candidate missing() { + return new Candidate(sourceId, owner, ownerName, ownerVersion, block, metadata, material, + domain, channel, oreNames, false, external, enrichment, reviewRequired); + } + + 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("external", external); + json.addProperty("enrichment", enrichment); + json.addProperty("review_required", reviewRequired); + 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 configured() { + List result = new ArrayList<>(); + for (Candidate value : candidates) if (!value.external && !value.enrichment) result.add(value); + 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 configured().size() > 1 && !highConfidence(this); + } + + boolean needsPolicy() { + return configured().size() > 1 || hasExternal() || hasEnrichment() + || reviewRequiredCandidate(); + } + + private boolean hasEnrichment() { + for (Candidate value : candidates) if (value.enrichment) return true; + return false; + } + + private boolean reviewRequiredCandidate() { + for (Candidate value : candidates) if (value.reviewRequired) 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 fee7b6a5..053e1809 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGeneration.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGeneration.java @@ -60,9 +60,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; @@ -82,9 +86,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; @@ -189,15 +195,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) { @@ -215,8 +224,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); @@ -229,7 +253,7 @@ private static boolean placeAttempt(World world, Chunk chunk, Random random, int z = ChunkAccessCompat.position(chunk).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); } @@ -264,6 +288,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(ChunkAccessCompat.position(chunk), x, y, z); } @@ -313,7 +345,13 @@ 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; + ResourceLocation material = OreSourcePolicies.material(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()); @@ -331,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 { @@ -351,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()); @@ -364,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); @@ -376,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); 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()); Map selectorScales = new IdentityHashMap<>(); + Map selectorMaterialScales = new HashMap<>(); for (BakedOreRule rule : rules) { if (rule.selector == null) continue; selectorList.add(rule.selector); @@ -393,8 +440,11 @@ private static BakedOres bakeOres(JsonObject profile, BakedGeomeConfig config) { if (rule.selector.backgroundController) { mergeBackgroundScale(selectorScales, rule.output, rule.selector.backgroundGenerationScale); + if (rule.materialDeclared) mergeBackgroundScale(selectorMaterialScales, + rule.material, rule.selector.backgroundGenerationScale); } } + selectorList = arbitrate(profile, selectorList); BakedOre[] selectorResult = selectorList.toArray(new BakedOre[selectorList.size()]); LOGGER.info("Baked {} OreSpawn-managed ore definitions across {} dimensions", rules.size(), result.size()); @@ -404,9 +454,127 @@ private static BakedOres bakeOres(JsonObject profile, BakedGeomeConfig config) { } return new BakedOres(Collections.unmodifiableMap(result), Collections.unmodifiableMap(immutableVanillaOutputs), - Collections.unmodifiableMap(generationScales), selectorResult, + Collections.unmodifiableMap(generationScales), + Collections.unmodifiableMap(materialScales), selectorResult, Collections.unmodifiableSet(selectorOutputs), - Collections.unmodifiableMap(selectorScales)); + Collections.unmodifiableMap(selectorScales), + Collections.unmodifiableMap(selectorMaterialScales)); + } + + private static List arbitrate(JsonObject profile, List original) { + if (!profile.has(OreSourcePolicies.SECTION) + || !profile.get(OreSourcePolicies.SECTION).isJsonObject()) return original; + JsonObject policies = profile.getAsJsonObject(OreSourcePolicies.SECTION); + Map> groups = new LinkedHashMap<>(); + for (BakedOre ore : original) { + if (ore.material != null) groups.computeIfAbsent( + OreSourcePolicies.key(ore.material, ore.policyDomain), + ignored -> new ArrayList<>()).add(ore); + } + Map> replacements = new HashMap<>(); + for (Entry> entry : groups.entrySet()) { + BakedOre first = entry.getValue().get(0); + String key = entry.getKey(); + if (!policies.has(key) || !policies.get(key).isJsonObject()) continue; + JsonObject policy = policies.getAsJsonObject(key); + if (!"consolidated".equals(string(policy, "mode", "keep_separate"))) continue; + Map weights = positiveWeights(objectOrEmpty(policy, "outputs")); + List sources = new ArrayList<>(); + Set sourceIds = new HashSet<>(); + for (BakedOre candidate : entry.getValue()) { + Double weight = weights.get(candidate.ruleId.toString()); + if (weight != null && sourceIds.add(candidate.ruleId)) { + sources.add(new BakedSource(candidate, weight.doubleValue())); + } + } + if (sources.isEmpty()) { + replacements.put(key, Collections.emptyList()); + continue; + } + Map available = new LinkedHashMap<>(); + for (BakedOre candidate : entry.getValue()) available.put(candidate.ruleId, candidate.channel); + Map placements = selectedPlacementRules( + profile, first.material, first.policyDomain, available); + List selected = new ArrayList<>(); + for (Entry placement : placements.entrySet()) { + ResourceLocation channel = placement.getKey(); + ResourceLocation source = placement.getValue(); + for (BakedOre candidate : entry.getValue()) { + if (channel.equals(candidate.channel) && source.equals(candidate.ruleId)) { + selected.add(candidate.withSources(sources)); + break; + } + } + } + replacements.put(key, selected); + } + if (replacements.isEmpty()) return original; + List result = new ArrayList<>(); + Set emitted = new HashSet<>(); + for (BakedOre ore : original) { + String groupKey = ore.material == null ? null + : OreSourcePolicies.key(ore.material, ore.policyDomain); + List replacement = groupKey == null ? null : replacements.get(groupKey); + if (replacement == null) result.add(ore); + else if (emitted.add(groupKey)) result.addAll(replacement); + } + return result; + } + + /** Returns null for the behaviour-preserving separate mode. */ + static Map selectedPlacementRules(JsonObject profile, + ResourceLocation material, ResourceLocation domain, + Map availableSourceChannels) { + if (!profile.has(OreSourcePolicies.SECTION) + || !profile.get(OreSourcePolicies.SECTION).isJsonObject()) return null; + JsonObject policies = profile.getAsJsonObject(OreSourcePolicies.SECTION); + String key = OreSourcePolicies.key(material, domain); + if (!policies.has(key) || !policies.get(key).isJsonObject()) return null; + JsonObject policy = policies.getAsJsonObject(key); + if (!"consolidated".equals(string(policy, "mode", "keep_separate"))) return null; + Map result = new LinkedHashMap<>(); + for (Entry placement : objectOrEmpty( + policy, "placement_sources").entrySet()) { + ResourceLocation channel = resource(placement.getKey()); + ResourceLocation source; + try { source = resource(placement.getValue().getAsString()); } + catch (RuntimeException invalid) { continue; } + if (channel != null && source != null && channel.equals(availableSourceChannels.get(source))) { + result.put(channel, source); + } + } + return Collections.unmodifiableMap(result); + } + + static int weightedIndex(double sample, double... weights) { + double total = 0.0D; + for (double weight : weights) if (Double.isFinite(weight) && weight > 0.0D) total += weight; + if (total <= 0.0D) return -1; + double choice = weightedChoice(sample, total); + int last = -1; + for (int index = 0; index < weights.length; index++) { + double weight = weights[index]; + if (!Double.isFinite(weight) || weight <= 0.0D) continue; + last = index; + choice -= weight; + if (choice < 0.0D) return index; + } + return last; + } + + private static double weightedChoice(double sample, double total) { + return Math.max(0.0D, Math.min(Math.nextDown(1.0D), sample)) * total; + } + + private static Map positiveWeights(JsonObject values) { + Map result = new LinkedHashMap<>(); + for (Entry entry : values.entrySet()) { + try { + double value = entry.getValue().getAsDouble(); + if (Double.isFinite(value) && value > 0.0D) result.put(entry.getKey(), value); + } catch (RuntimeException ignored) { } + } + return result; } private static void reportBakeProblem(String message, Object... arguments) { @@ -425,18 +593,24 @@ static double scaledManagedFrequency(double frequency, boolean controller, doubl return controller ? frequency : frequency * scale; } - static void mergeBackgroundScale(Map scales, Block output, double scale) { + static double channelBackgroundScale(ResourceLocation material, ResourceLocation channel, + double materialScale, double legacyBlockScale) { + if (material == null) return legacyBlockScale; + return OreSourcePolicies.STANDARD.equals(channel) ? materialScale : 1.0D; + } + + static void mergeBackgroundScale(Map scales, T output, double scale) { Double previous = scales.get(output); - if (previous == null || scale < previous.doubleValue()) { - scales.put(output, scale); - } + 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(); } - private static BakedOre bakeOre(IBlockState output, IBlockState deepOutput, int deepOutputMaxY, + private static BakedOre bakeOre(ResourceLocation ruleId, ResourceLocation material, + boolean materialDeclared, ResourceLocation policyDomain, + IBlockState output, IBlockState deepOutput, int deepOutputMaxY, BakedOutput[] outputs, JsonObject json, BakedGeomeConfig config, Map> resolvedTags, boolean retrogen) { @@ -505,7 +679,9 @@ private static BakedOre bakeOre(IBlockState output, IBlockState deepOutput, int Set includedDictionaryBiomes = resolveBiomeDictionary(json, "biome_dictionary"); Set excludedDictionaryBiomes = resolveBiomeDictionary(json, "excluded_biome_dictionary"); - return new BakedOre(output, deepOutput, deepOutputMaxY, outputs, + return new BakedOre(ruleId, material, materialDeclared, policyDomain, + OreSourcePolicies.placementChannel(json), + output, deepOutput, deepOutputMaxY, outputs, minY, maxY, Math.min(64.0D, frequency), minQuantity, maxQuantity, pattern, heightDistribution, discardChanceOnAirExposure, spread, verticalSpread, nodeSize, @@ -701,6 +877,11 @@ private static IBlockState state(Block block, int metadata) { } private static final class BakedOre { + final ResourceLocation ruleId; + final ResourceLocation material; + final boolean materialDeclared; + final ResourceLocation policyDomain; + final ResourceLocation channel; final IBlockState output; final IBlockState deepOutput; final int deepOutputMaxY; @@ -727,8 +908,11 @@ private static final class BakedOre { final boolean retrogen; final boolean backgroundController; final double backgroundGenerationScale; + final BakedSource[] arbitrationSources; - BakedOre(IBlockState output, IBlockState deepOutput, int deepOutputMaxY, BakedOutput[] outputs, + BakedOre(ResourceLocation ruleId, ResourceLocation material, boolean materialDeclared, + ResourceLocation policyDomain, ResourceLocation channel, + IBlockState output, IBlockState deepOutput, int deepOutputMaxY, BakedOutput[] outputs, int minY, int maxY, double frequency, int minQuantity, int maxQuantity, CompiledOrePattern pattern, OreHeightDistribution heightDistribution, double discardChanceOnAirExposure, @@ -739,6 +923,33 @@ private static final class BakedOre { Set includedDictionaryBiomes, Set excludedDictionaryBiomes, boolean retrogen, boolean backgroundController, double backgroundGenerationScale) { + this(ruleId, material, materialDeclared, policyDomain, channel, + output, deepOutput, deepOutputMaxY, outputs, + minY, maxY, frequency, minQuantity, maxQuantity, pattern, heightDistribution, + discardChanceOnAirExposure, spread, verticalSpread, nodeSize, hostBlocks, hostStates, + familyMask, geomeWeights, includedBiomeIds, excludedBiomeIds, + includedDictionaryBiomes, excludedDictionaryBiomes, retrogen, backgroundController, + backgroundGenerationScale, new BakedSource[0]); + } + + private BakedOre(ResourceLocation ruleId, ResourceLocation material, boolean materialDeclared, + ResourceLocation policyDomain, ResourceLocation channel, + IBlockState output, IBlockState deepOutput, int deepOutputMaxY, BakedOutput[] outputs, + int minY, int maxY, double frequency, int minQuantity, int maxQuantity, + CompiledOrePattern pattern, OreHeightDistribution heightDistribution, + double discardChanceOnAirExposure, + int spread, int verticalSpread, int nodeSize, + Map hostBlocks, Map hostStates, + int familyMask, double[] geomeWeights, + Set includedBiomeIds, Set excludedBiomeIds, + Set includedDictionaryBiomes, Set excludedDictionaryBiomes, + boolean retrogen, boolean backgroundController, + double backgroundGenerationScale, BakedSource[] arbitrationSources) { + this.ruleId = ruleId; + this.material = material; + this.materialDeclared = materialDeclared; + this.policyDomain = policyDomain; + this.channel = channel; this.output = output; this.deepOutput = deepOutput; this.deepOutputMaxY = deepOutputMaxY; @@ -765,6 +976,30 @@ private static final class BakedOre { this.retrogen = retrogen; this.backgroundController = backgroundController; this.backgroundGenerationScale = backgroundGenerationScale; + this.arbitrationSources = arbitrationSources; + } + + BakedOre withSources(List sources) { + return new BakedOre(ruleId, material, materialDeclared, policyDomain, channel, + output, deepOutput, deepOutputMaxY, outputs, + minY, maxY, frequency, minQuantity, maxQuantity, pattern, heightDistribution, + discardChanceOnAirExposure, spread, verticalSpread, nodeSize, hostBlocks, hostStates, + familyMask, geomeWeights, includedBiomeIds, excludedBiomeIds, + includedDictionaryBiomes, excludedDictionaryBiomes, retrogen, backgroundController, + backgroundGenerationScale, sources.toArray(new BakedSource[sources.size()])); + } + + BakedOre outputSource(long worldSeed, ResourceLocation dimension, long identity) { + if (arbitrationSources.length == 0) return this; + double total = 0.0D; + for (BakedSource source : arbitrationSources) total += source.weight; + double choice = weightedChoice(outputSelectionSample( + worldSeed, dimension, material, channel, identity), total); + for (BakedSource source : arbitrationSources) { + choice -= source.weight; + if (choice < 0.0D) return source.ore; + } + return arbitrationSources[arbitrationSources.length - 1].ore; } IBlockState outputAt(int y, Random random) { @@ -802,18 +1037,36 @@ boolean acceptsBiome(Biome biome, ResourceLocation biomeId) { } private static final class BakedOreRule { + final ResourceLocation ruleId; final Block output; + final ResourceLocation material; + final boolean materialDeclared; final boolean suppressVanilla; final Map explicit = new HashMap<>(); final Set explicitDimensions = new HashSet<>(); BakedOre selector; - BakedOreRule(Block output, boolean suppressVanilla) { + BakedOreRule(ResourceLocation ruleId, Block output, ResourceLocation material, + boolean materialDeclared, + boolean suppressVanilla) { + this.ruleId = ruleId; this.output = output; + this.material = material; + this.materialDeclared = materialDeclared; this.suppressVanilla = suppressVanilla; } } + private static final class BakedSource { + final BakedOre ore; + final double weight; + + BakedSource(BakedOre ore, double weight) { + this.ore = ore; + this.weight = weight; + } + } + private static final class BakedOutput { final IBlockState state; final double weight; @@ -834,26 +1087,33 @@ boolean acceptsY(int y) { private static final class BakedOres { static final BakedOres EMPTY = new BakedOres(EMPTY_DIMENSIONS, Collections.emptyMap(), - Collections.emptyMap(), NO_ORES, Collections.emptySet(), Collections.emptyMap()); + Collections.emptyMap(), Collections.emptyMap(), NO_ORES, Collections.emptySet(), + Collections.emptyMap(), Collections.emptyMap()); final Map byDimension; final Map> vanillaOutputs; final Map> backgroundGenerationScales; + final Map> materialBackgroundGenerationScales; final BakedOre[] selectorOres; final Set selectorVanillaOutputs; final Map selectorBackgroundGenerationScales; + final Map selectorMaterialBackgroundGenerationScales; BakedOres(Map byDimension, Map> vanillaOutputs, Map> backgroundGenerationScales, + Map> materialBackgroundGenerationScales, BakedOre[] selectorOres, Set selectorVanillaOutputs, - Map selectorBackgroundGenerationScales) { + Map selectorBackgroundGenerationScales, + Map selectorMaterialBackgroundGenerationScales) { this.byDimension = byDimension; this.vanillaOutputs = vanillaOutputs; this.backgroundGenerationScales = backgroundGenerationScales; + this.materialBackgroundGenerationScales = materialBackgroundGenerationScales; this.selectorOres = selectorOres; this.selectorVanillaOutputs = selectorVanillaOutputs; this.selectorBackgroundGenerationScales = selectorBackgroundGenerationScales; + this.selectorMaterialBackgroundGenerationScales = selectorMaterialBackgroundGenerationScales; } } @@ -906,6 +1166,10 @@ private static final class PatternContext implements OreGenerationContext { private int minY; private int maxY; private int quantity; + private BakedOre ordinaryOutput; + private BakedOre identifiedOutput; + private long identifiedOutputIdentity; + private boolean hasIdentifiedOutput; PatternContext(BlockPos.MutableBlockPos cursor) { this.cursor = cursor; @@ -913,6 +1177,7 @@ private static final class PatternContext implements OreGenerationContext { void initialize(World world, Chunk chunk, Random random, BakedOre ore, int geome, ResourceLocation dimension, long worldSeed, + int attemptIndex, Optional geologySampler, int originX, int originY, int originZ, int minY, int maxY, int quantity) { this.world = world; @@ -932,6 +1197,10 @@ void initialize(World world, Chunk chunk, Random random, BakedOre ore, int geome this.minY = minY; this.maxY = maxY; this.quantity = quantity; + long attemptIdentity = ordinaryOutputIdentity(chunkPos, attemptIndex, ore.ruleId, ore.channel); + this.ordinaryOutput = ore.outputSource(worldSeed, dimension, attemptIdentity); + this.identifiedOutput = null; + this.hasIdentifiedOutput = false; } @Override public Random random() { return random; } @@ -973,6 +1242,20 @@ public boolean isFluid(int x, int y, int z, Fluid fluid) { @Override public boolean tryPlace(int x, int y, int z) { + return tryPlaceWithOutput(x, y, z, ordinaryOutput); + } + + @Override + public boolean tryPlace(int x, int y, int z, long outputIdentity) { + if (!hasIdentifiedOutput || identifiedOutputIdentity != outputIdentity) { + identifiedOutputIdentity = outputIdentity; + identifiedOutput = ore.outputSource(worldSeed, dimension, outputIdentity); + hasIdentifiedOutput = true; + } + return tryPlaceWithOutput(x, y, z, identifiedOutput); + } + + private boolean tryPlaceWithOutput(int x, int y, int z, BakedOre outputSource) { if (!inside(x, y, z)) return false; cursor.setPos(x, y, z); IBlockState existing = world == null ? chunk.getBlockState(cursor) : world.getBlockState(cursor); @@ -982,7 +1265,7 @@ public boolean tryPlace(int x, int y, int z) { && isAdjacentToAir(x, y, z)) { return false; } - IBlockState output = ore.outputAt(y, random); + IBlockState output = outputSource.outputAt(y, random); if (world == null) chunk.setBlockState(cursor, output); else world.setBlockState(cursor.toImmutable(), output, GENERATION_WRITE_FLAGS); return true; diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java index 35be9a40..c958b094 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java @@ -22,7 +22,7 @@ /** A complete, self-contained snapshot of the geology settings for one world. */ public final class WorldGeologyProfile { - public static final int SCHEMA_VERSION = 5; + public static final int SCHEMA_VERSION = 6; private static final Logger LOGGER = LogManager.getLogger(); @@ -39,6 +39,10 @@ public final class WorldGeologyProfile { private WorldGeologyProfile(JsonObject root, GeologyMode fallbackMode, boolean fallbackFluidDeposits) { this.root = JsonCopies.copy(root); FluidDepositMigration.normalize(this.root); + if (!this.root.has(OreSourcePolicies.SECTION) + || !this.root.get(OreSourcePolicies.SECTION).isJsonObject()) { + this.root.add(OreSourcePolicies.SECTION, new JsonObject()); + } this.root.addProperty("schema_version", SCHEMA_VERSION); geologyMode = enumValue(this.root, "geology_mode", GeologyMode.class, fallbackMode); placeFluidDeposits = booleanValue(this.root, "place_fluid_deposits", fallbackFluidDeposits); @@ -67,6 +71,7 @@ public static WorldGeologyProfile recommended(boolean placeFluidDeposits) { public static WorldGeologyProfile fromGlobalConfig(JsonObject globalRoot, GeologyMode geologyMode, boolean placeFluidDeposits) { JsonObject root = JsonCopies.copy(globalRoot); + OreSourcePolicies.initialize(root, false); if (!root.has("geology_mode")) { root.addProperty("geology_mode", geologyMode.name().toLowerCase(Locale.ROOT)); } @@ -81,7 +86,7 @@ public static WorldGeologyProfile fromJson(JsonObject json, WorldGeologyProfile if (schema >= SCHEMA_VERSION) { return new WorldGeologyProfile(json, fallback.geologyMode, fallback.placeFluidDeposits); } - if (schema == 2 || schema == 3 || schema == 4) { + if (schema >= 2) { JsonObject migrated = JsonCopies.copy(json); for (String key : new String[] { "terrain_dimensions", "providers", "biome_palettes", "dimension_materials" }) { @@ -89,6 +94,7 @@ public static WorldGeologyProfile fromJson(JsonObject json, WorldGeologyProfile migrated.add(key, JsonCopies.copy(fallback.root.get(key))); } } + OreSourcePolicies.initialize(migrated, true); return new WorldGeologyProfile(migrated, fallback.geologyMode, fallback.placeFluidDeposits); } @@ -98,6 +104,10 @@ public static WorldGeologyProfile fromJson(JsonObject json, WorldGeologyProfile copyIfPresent(json, migrated, "geology_mode"); copyIfPresent(json, migrated, "place_crude_oil"); copyIfPresent(json, migrated, "formations"); + // A schema-one file belongs to an existing world. Do not inherit a fresh + // install's automatic consolidation choices from the fallback snapshot. + migrated.remove(OreSourcePolicies.SECTION); + OreSourcePolicies.initialize(migrated, true); return new WorldGeologyProfile(migrated, fallback.geologyMode, fallback.placeFluidDeposits); } @@ -260,7 +270,8 @@ public int generationRevision() { return configured; } int hash = 17; - for (String key : new String[] { "ores", "fluid_deposits", "flat_bedrock", "providers" }) { + for (String key : new String[] { "ores", "ore_source_policies", "fluid_deposits", + "flat_bedrock", "providers" }) { if (root.has(key)) { hash = (31 * hash) + root.get(key).toString().hashCode(); } 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 02139ebe..94cd5b18 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileManager.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileManager.java @@ -104,7 +104,9 @@ public static synchronized boolean reloadActiveProfile() { .resolve("serverconfig").resolve(PROFILE_FILE_NAME); WorldGeologyProfile profile = readProfile(profilePath, globalProfile()); JsonObject merged = profile.rootCopy(); - if (WorldgenIntegrationManager.mergeProviderDefinitionsIntoExistingWorld(merged)) { + boolean changed = WorldgenIntegrationManager.mergeProviderDefinitionsIntoExistingWorld(merged); + changed |= OreSourcePolicies.initialize(merged, true); + if (changed) { profile = profile.withRoot(merged); writeProfile(profilePath, profile); } @@ -127,6 +129,7 @@ public static void onServerAboutToStart(FMLServerAboutToStartEvent event) { JsonObject merged = profile.rootCopy(); String beforeMerge = merged.toString(); WorldgenIntegrationManager.mergeProviderDefinitionsIntoExistingWorld(merged); + OreSourcePolicies.initialize(merged, true); if (!beforeMerge.equals(merged.toString())) { profile = profile.withRoot(merged); writeProfile(profilePath, profile); @@ -157,6 +160,7 @@ public static void onServerAboutToStart(FMLServerAboutToStartEvent event) { profile = fallback.copy(); source = "installed-pack fresh-world"; } + profile = initializeNewWorldPolicies(profile, generatedWorld); writeProfile(profilePath, profile); LOGGER.info("Created OreSpawn world geology profile '{}' from {} settings", profilePath, source); @@ -171,6 +175,17 @@ public static void onServerAboutToStart(FMLServerAboutToStartEvent event) { profile.enabledFluidDepositCount(), profile.fluidDepositCount()); } + static WorldGeologyProfile initializeNewWorldPolicies(WorldGeologyProfile profile, + boolean generatedWorld) { + JsonObject initialized = profile.rootCopy(); + // A generated world without an OS4 profile is an upgrade. Its installed-pack + // fallback may already contain fresh-world consolidation defaults, which must + // not alter the world's historical ore frequency implicitly. + if (generatedWorld) initialized.remove(OreSourcePolicies.SECTION); + OreSourcePolicies.initialize(initialized, generatedWorld); + return profile.withRoot(initialized); + } + public static void onServerStopped(FMLServerStoppedEvent event) { activeServer = null; activeProfile = null; @@ -264,8 +279,10 @@ static WorldGeologyProfile readProfile(Path path, WorldGeologyProfile fallback) if (oreDefaultsRefreshed) { root = GeomeConfig.refreshWorldOreDefaults(root); } + boolean sourcePoliciesRefreshed = OreSourcePolicies.initialize(root, true); WorldGeologyProfile profile = WorldGeologyProfile.fromJson(root, fallback); - if (schema < WorldGeologyProfile.SCHEMA_VERSION || oreDefaultsRefreshed) { + if (schema < WorldGeologyProfile.SCHEMA_VERSION || oreDefaultsRefreshed + || sourcePoliciesRefreshed) { boolean canWrite = schema >= WorldGeologyProfile.SCHEMA_VERSION || preserveBeforeSchemaMigration(path, schema); canWrite &= !oreDefaultsRefreshed || preserveBeforeOreDefaultsRefresh(path); @@ -278,6 +295,9 @@ static WorldGeologyProfile readProfile(Path path, WorldGeologyProfile fallback) LOGGER.info("Updated untouched managed-ore rules in world geology profile '{}' to revision {}", path, GeomeConfig.oreDefaultsRevision()); } + if (sourcePoliciesRefreshed) { + LOGGER.info("Refreshed OreSpawn ore-source policies in world geology profile '{}'", path); + } } else if (oreDefaultsRefreshed) { LOGGER.warn("Could not persist updated OreSpawn ore defaults for world profile '{}'; " + "using refreshed settings in memory", path); diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/BackgroundGenerationScaleTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/BackgroundGenerationScaleTest.java index 3fcf00ab..1d94048b 100644 --- a/src/test/java/zone/moddev/mc/orespawn/worldgen/BackgroundGenerationScaleTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/BackgroundGenerationScaleTest.java @@ -23,6 +23,17 @@ void controllerRuleRemainsUnscaledWhileBackgroundRulesAreScaled() { assertEquals(2.0D, OreSpawnOreGeneration.scaledManagedFrequency(8.0D, false, 0.25D)); } + @Test + void declaredMaterialsScaleOnlyTheStandardPlacementChannel() { + ResourceLocation material = new ResourceLocation("orespawn:iron"); + assertEquals(0.15D, OreSpawnOreGeneration.channelBackgroundScale(material, + new ResourceLocation("orespawn:standard"), 0.15D, 0.75D)); + assertEquals(1.0D, OreSpawnOreGeneration.channelBackgroundScale(material, + new ResourceLocation("realisticdeposits:district"), 0.15D, 0.75D)); + assertEquals(0.75D, OreSpawnOreGeneration.channelBackgroundScale(null, + new ResourceLocation("orespawn:standard"), 0.15D, 0.75D)); + } + @Test void lowestControllerScaleWinsForTheSamePrimaryResource() { Map scales = new IdentityHashMap<>(); diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/OreSourcePoliciesTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/OreSourcePoliciesTest.java new file mode 100644 index 00000000..79d4a6c9 --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/OreSourcePoliciesTest.java @@ -0,0 +1,254 @@ +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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; +import java.util.Collections; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import zone.moddev.mc.orespawn.test.Forge12TestBootstrap; + +class OreSourcePoliciesTest { + @BeforeAll + static void bootstrapVanilla() { + Forge12TestBootstrap.registerVanilla(); + } + + @Test + void exactOreDictionaryNamesAndCuratedSpellingsProduceCanonicalMaterials() { + OreSourcePolicies.Inference sulfur = OreSourcePolicies.inferMaterial( + Arrays.asList("oreSulphur", "oreSulfur", "dustSulfur")); + OreSourcePolicies.Inference aluminum = OreSourcePolicies.inferMaterial( + Arrays.asList("oreAluminium", "oreAluminum")); + + assertEquals("orespawn:sulfur", sulfur.material.toString()); + assertEquals(Arrays.asList("oreSulfur", "oreSulphur"), sulfur.names); + assertFalse(sulfur.reviewRequired); + assertEquals("orespawn:aluminum", aluminum.material.toString()); + } + + @Test + void niterAndSaltpeterRemainDistinctAndAmbiguousMembershipRequiresReview() { + assertEquals("orespawn:niter", OreSourcePolicies.inferMaterial( + Collections.singletonList("oreNiter")).material.toString()); + assertEquals("orespawn:saltpeter", OreSourcePolicies.inferMaterial( + Collections.singletonList("oreSaltpeter")).material.toString()); + OreSourcePolicies.Inference ambiguous = OreSourcePolicies.inferMaterial( + Arrays.asList("oreNiter", "oreSaltpeter")); + assertEquals("orespawn:review_required", ambiguous.material.toString()); + assertTrue(ambiguous.reviewRequired); + assertEquals(null, OreSourcePolicies.inferMaterial( + Arrays.asList("dustSulfur", "crushedSulfur")).material); + OreSourcePolicies.Inference invalid = OreSourcePolicies.inferMaterial( + Collections.singletonList("oreBad Path")); + assertEquals("orespawn:review_required", invalid.material.toString()); + assertTrue(invalid.reviewRequired); + } + + @Test + void freshCataloguedMmdConflictConsolidatesUsingReviewedPriority() { + JsonObject root = rootWithSulfurConflict(); + assertTrue(OreSourcePolicies.initialize(root, false)); + JsonObject policy = policy(root); + + assertEquals("consolidated", policy.get("mode").getAsString()); + assertEquals("consolidated", policy.get("status").getAsString()); + assertEquals(1, policy.getAsJsonObject("outputs").entrySet().size()); + assertTrue(policy.getAsJsonObject("outputs").has("mineralogy:sulfur")); + assertEquals("mineralogy:sulfur", policy.getAsJsonObject("placement_sources") + .get("orespawn:standard").getAsString()); + } + + @Test + void upgradedWorldConflictPreservesEverySourceUntilUserChooses() { + JsonObject root = rootWithSulfurConflict(); + OreSourcePolicies.initialize(root, true); + JsonObject policy = policy(root); + + assertEquals("keep_separate", policy.get("mode").getAsString()); + assertEquals(2, policy.getAsJsonObject("outputs").entrySet().size()); + } + + @Test + void thirdPartyConflictIsNotAutomaticallyConsolidated() { + JsonObject root = root(); + addOre(root, "otherone:sulfur", "otherone", "minecraft:iron_ore", "minecraft:overworld"); + addOre(root, "othertwo:sulfur", "othertwo", "minecraft:gold_ore", "minecraft:overworld"); + OreSourcePolicies.initialize(root, false); + + JsonObject policy = policy(root); + assertEquals("keep_separate", policy.get("mode").getAsString()); + assertEquals("review_required", policy.get("status").getAsString()); + } + + @Test + void missingSelectionsRemainPersistedAndSnapshotsAreImmutable() { + JsonObject root = rootWithSulfurConflict(); + OreSourcePolicies.initialize(root, false); + root.getAsJsonObject("ores").remove("mineralogy:sulfur"); + OreSourcePolicies.initialize(root, false); + + JsonObject policy = policy(root); + assertTrue(policy.getAsJsonObject("outputs").has("mineralogy:sulfur")); + assertEquals("missing_source", policy.get("status").getAsString()); + boolean missing = false; + for (JsonElement element : policy.getAsJsonArray("candidates")) { + JsonObject candidate = element.getAsJsonObject(); + if ("mineralogy:sulfur".equals(candidate.get("source_id").getAsString())) { + missing = !candidate.get("loaded").getAsBoolean(); + } + } + assertTrue(missing); + OreSourcePolicies.Snapshot snapshot = OreSourcePolicies.snapshot(root); + assertThrows(UnsupportedOperationException.class, () -> snapshot.groups().clear()); + assertThrows(UnsupportedOperationException.class, + () -> snapshot.groups().get(0).outputs.put("replacement", 2.0D)); + + addOre(root, "mineralogy:sulfur", "mineralogy", "minecraft:iron_ore", + "minecraft:overworld"); + OreSourcePolicies.initialize(root, false); + for (JsonElement element : policy(root).getAsJsonArray("candidates")) { + JsonObject candidate = element.getAsJsonObject(); + if ("mineralogy:sulfur".equals(candidate.get("source_id").getAsString())) { + assertTrue(candidate.get("loaded").getAsBoolean()); + } + } + } + + @Test + void lithiumUsesItsReviewedMmdPriority() { + JsonObject root = root(); + addOre(root, "electricadvantage:lithium", "electricadvantage", + "minecraft:gold_ore", "orespawn:lithium", "minecraft:overworld", null); + addOre(root, "baseminerals:lithium", "baseminerals", + "minecraft:iron_ore", "orespawn:lithium", "minecraft:overworld", null); + OreSourcePolicies.initialize(root, false); + JsonObject policy = policy(root); + assertEquals("consolidated", policy.get("mode").getAsString()); + assertTrue(policy.getAsJsonObject("outputs").has("baseminerals:lithium")); + } + + @Test + void placementChannelsStayIndependentWhileOutputsRemainMaterialWide() { + JsonObject root = rootWithSulfurConflict(); + addOre(root, "electricadvantage:sulfur_district", "electricadvantage", + "minecraft:coal_ore", "orespawn:sulfur", "minecraft:overworld", + "realisticdeposits:district"); + OreSourcePolicies.initialize(root, false); + JsonObject policy = policy(root); + assertEquals(2, policy.getAsJsonObject("placement_sources").entrySet().size()); + assertEquals("mineralogy:sulfur", policy.getAsJsonObject("placement_sources") + .get("orespawn:standard").getAsString()); + assertEquals("electricadvantage:sulfur_district", policy.getAsJsonObject("placement_sources") + .get("realisticdeposits:district").getAsString()); + assertEquals(1, policy.getAsJsonObject("outputs").entrySet().size()); + } + + @Test + void catalogExcludesScienceAndSeparatesDenseEnrichment() { + JsonObject root = root(); + addOre(root, "mineralogy:sulfur", "mineralogy", "minecraft:iron_ore", + "minecraft:overworld"); + addOre(root, "densemetals:dense_sulfur", "densemetals", "minecraft:gold_ore", + "minecraft:overworld"); + addOre(root, "basesciences:sulfur", "basesciences", "minecraft:coal_ore", + "minecraft:overworld"); + OreSourcePolicies.initialize(root, false); + JsonObject policy = policy(root); + assertEquals("keep_separate", policy.get("mode").getAsString()); + assertEquals(2, policy.getAsJsonArray("candidates").size()); + assertFalse(policy.getAsJsonObject("outputs").has("densemetals:dense_sulfur")); + boolean enrichment = false; + for (JsonElement element : policy.getAsJsonArray("candidates")) { + JsonObject candidate = element.getAsJsonObject(); + enrichment |= "densemetals:dense_sulfur".equals( + candidate.get("source_id").getAsString()) + && candidate.get("enrichment").getAsBoolean(); + } + assertTrue(enrichment); + } + + @Test + void netherAndEndCatalogCandidatesCannotLeakIntoOtherDomains() { + JsonObject root = root(); + addOre(root, "nethermetals:sulfur", "nethermetals", "minecraft:gold_ore", + "orespawn:sulfur", "minecraft:overworld", null); + addOre(root, "endmetals:sulfur", "endmetals", "minecraft:coal_ore", + "orespawn:sulfur", "minecraft:overworld", null); + OreSourcePolicies.initialize(root, false); + assertTrue(root.getAsJsonObject(OreSourcePolicies.SECTION).entrySet().isEmpty()); + + addOre(root, "nethermetals:sulfur", "nethermetals", "minecraft:gold_ore", + "orespawn:sulfur", "minecraft:the_nether", null); + addOre(root, "baseminerals:sulfur", "baseminerals", "minecraft:iron_ore", + "orespawn:sulfur", "minecraft:the_nether", null); + OreSourcePolicies.initialize(root, false); + JsonObject policy = policy(root); + assertEquals("minecraft:the_nether", policy.get("domain").getAsString()); + } + + @Test + void customPatternsDefaultToTheirOwnPlacementChannel() { + JsonObject custom = new JsonObject(); + JsonObject pattern = new JsonObject(); + pattern.addProperty("type", "realisticdeposits:district"); + pattern.add("settings", new JsonObject()); + custom.add("pattern", pattern); + assertEquals("realisticdeposits:district", + OreSourcePolicies.placementChannel(custom).toString()); + pattern.addProperty("type", "orespawn:registered_custom"); + assertEquals("orespawn:registered_custom", + OreSourcePolicies.placementChannel(custom).toString()); + + JsonObject builtIn = new JsonObject(); + builtIn.addProperty("pattern", "vein"); + assertEquals("orespawn:standard", OreSourcePolicies.placementChannel(builtIn).toString()); + } + + private static JsonObject rootWithSulfurConflict() { + JsonObject root = root(); + addOre(root, "mineralogy:sulfur", "mineralogy", "minecraft:iron_ore", "minecraft:overworld"); + addOre(root, "baseminerals:sulfur", "baseminerals", "minecraft:gold_ore", "minecraft:overworld"); + return root; + } + + private static JsonObject root() { + JsonObject root = new JsonObject(); + root.add("ores", new JsonObject()); + return root; + } + + private static void addOre(JsonObject root, String id, String owner, String block, String dimension) { + addOre(root, id, owner, block, "orespawn:sulfur", dimension, null); + } + + private static void addOre(JsonObject root, String id, String owner, String block, + String material, String dimension, String channel) { + JsonObject ore = new JsonObject(); + ore.addProperty("enabled", true); + ore.addProperty("block", block); + ore.addProperty("material", material); + ore.addProperty("source_provider", owner); + JsonObject dimensions = new JsonObject(); + JsonObject rule = new JsonObject(); + rule.addProperty("enabled", true); + rule.addProperty("pattern", "vein"); + if (channel != null) rule.addProperty("placement_channel", channel); + dimensions.add(dimension, rule); + ore.add("dimensions", dimensions); + root.getAsJsonObject("ores").add(id, ore); + } + + private static JsonObject policy(JsonObject root) { + JsonObject policies = root.getAsJsonObject(OreSourcePolicies.SECTION); + assertEquals(1, policies.entrySet().size()); + return policies.entrySet().iterator().next().getValue().getAsJsonObject(); + } +} 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 428e8240..45d9187a 100644 --- a/src/test/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGenerationTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGenerationTest.java @@ -7,12 +7,15 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Paths; +import java.util.Collections; import java.util.Random; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.Map; import java.util.Set; +import com.google.gson.JsonObject; import org.junit.jupiter.api.Test; import net.minecraft.util.ResourceLocation; @@ -73,11 +76,127 @@ void normalGenerationAndRetrogenShareStableExtendedIdentityInputs() throws Excep 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("dimension, worldSeed,\n\t\t\t\tattemptIndex, geologySampler")); assertTrue(source.contains("this.chunkX = chunkPos.chunkXPos")); assertTrue(source.contains("this.chunkZ = chunkPos.chunkZPos")); } + @Test + void wholeBodyOutputSelectionIgnoresChunkOrder() { + ResourceLocation material = new ResourceLocation("orespawn:sulfur"); + ResourceLocation channel = new ResourceLocation("realisticdeposits:district"); + long body = 0x1234ABCD5678EF90L; + double first = OreSpawnOreGeneration.outputSelectionSample(42L, WorldIds.OVERWORLD, + material, channel, body); + double fromAnotherChunk = OreSpawnOreGeneration.outputSelectionSample(42L, WorldIds.OVERWORLD, + material, channel, body); + assertEquals(first, fromAnotherChunk); + } + + @Test + void ordinaryAttemptsIncludeChunkRuleChannelAndAttemptIdentity() { + ResourceLocation rule = new ResourceLocation("examplemod:sulfur"); + ResourceLocation channel = new ResourceLocation("orespawn:standard"); + long first = OreSpawnOreGeneration.ordinaryOutputIdentity(new ChunkPos(1, 2), 0, rule, channel); + assertEquals(first, OreSpawnOreGeneration.ordinaryOutputIdentity( + new ChunkPos(1, 2), 0, rule, channel)); + assertFalse(first == OreSpawnOreGeneration.ordinaryOutputIdentity( + new ChunkPos(2, 1), 0, rule, channel)); + assertFalse(first == OreSpawnOreGeneration.ordinaryOutputIdentity( + new ChunkPos(1, 2), 1, rule, channel)); + } + + @Test + void placementChannelsAreArbitratedIndependentlyWithoutFallback() { + ResourceLocation material = new ResourceLocation("orespawn:iron"); + ResourceLocation standard = new ResourceLocation("orespawn:standard"); + ResourceLocation district = new ResourceLocation("realisticdeposits:district"); + ResourceLocation ordinary = new ResourceLocation("basemetals:iron"); + ResourceLocation large = new ResourceLocation("realisticdeposits:iron_district"); + JsonObject root = new JsonObject(); + JsonObject policies = new JsonObject(); + JsonObject policy = new JsonObject(); + policy.addProperty("mode", "consolidated"); + JsonObject placements = new JsonObject(); + placements.addProperty(standard.toString(), ordinary.toString()); + placements.addProperty(district.toString(), large.toString()); + placements.addProperty("missing:channel", "missing:rule"); + policy.add("placement_sources", placements); + policies.add(OreSourcePolicies.key(material, WorldIds.OVERWORLD), policy); + root.add(OreSourcePolicies.SECTION, policies); + Map available = new LinkedHashMap<>(); + available.put(ordinary, standard); + available.put(large, district); + + Map selected = OreSpawnOreGeneration + .selectedPlacementRules(root, material, WorldIds.OVERWORLD, available); + assertEquals(2, selected.size()); + assertEquals(ordinary, selected.get(standard)); + assertEquals(large, selected.get(district)); + assertFalse(selected.containsKey(new ResourceLocation("missing:channel"))); + } + + @Test + void selectorPoliciesRemainKeyedByTheirSelectorDomain() { + ResourceLocation material = new ResourceLocation("orespawn:copper"); + ResourceLocation standard = new ResourceLocation("orespawn:standard"); + ResourceLocation source = new ResourceLocation("basemetals:copper"); + ResourceLocation selector = new ResourceLocation("orespawn:all_except_nether_end"); + JsonObject root = new JsonObject(); + JsonObject policies = new JsonObject(); + JsonObject policy = new JsonObject(); + policy.addProperty("mode", "consolidated"); + JsonObject placements = new JsonObject(); + placements.addProperty(standard.toString(), source.toString()); + policy.add("placement_sources", placements); + policies.add(OreSourcePolicies.key(material, selector), policy); + root.add(OreSourcePolicies.SECTION, policies); + Map available = new LinkedHashMap<>(); + available.put(source, standard); + + assertEquals(source, OreSpawnOreGeneration.selectedPlacementRules( + root, material, selector, available).get(standard)); + assertEquals(null, OreSpawnOreGeneration.selectedPlacementRules( + root, material, WorldIds.OVERWORLD, available)); + } + + @Test + void keepSeparatePolicyLeavesEveryPlacementRuleUntouched() { + ResourceLocation material = new ResourceLocation("orespawn:sulfur"); + JsonObject root = new JsonObject(); + JsonObject policies = new JsonObject(); + JsonObject policy = new JsonObject(); + policy.addProperty("mode", "keep_separate"); + policies.add(OreSourcePolicies.key(material, WorldIds.OVERWORLD), policy); + root.add(OreSourcePolicies.SECTION, policies); + + assertEquals(null, OreSpawnOreGeneration.selectedPlacementRules(root, material, + WorldIds.OVERWORLD, Collections.emptyMap())); + } + + @Test + void weightedOutputSelectionUsesPositiveRelativeWeights() { + assertEquals(0, OreSpawnOreGeneration.weightedIndex(0.0D, 1.0D, 3.0D)); + assertEquals(0, OreSpawnOreGeneration.weightedIndex(0.249999D, 1.0D, 3.0D)); + assertEquals(1, OreSpawnOreGeneration.weightedIndex(0.25D, 1.0D, 3.0D)); + assertEquals(1, OreSpawnOreGeneration.weightedIndex(0.999999D, 1.0D, 3.0D)); + assertEquals(-1, OreSpawnOreGeneration.weightedIndex(0.5D, 0.0D, -1.0D)); + } + + @Test + void stableOutputSelectionRespectsConfiguredWeightDistribution() { + ResourceLocation material = new ResourceLocation("orespawn:sulfur"); + ResourceLocation channel = new ResourceLocation("orespawn:standard"); + int first = 0; + for (long identity = 0; identity < 10000; identity++) { + double sample = OreSpawnOreGeneration.outputSelectionSample( + 42L, WorldIds.OVERWORLD, material, channel, identity); + if (OreSpawnOreGeneration.weightedIndex(sample, 1.0D, 3.0D) == 0) first++; + } + assertTrue(first > 2200 && first < 2800, + "one-to-three weights should select the first source about one quarter of the time: " + first); + } + @Test void fixedQuantityDoesNotConsumeRandomState() { CountingRandom random = new CountingRandom(0); diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileTest.java index 286327e7..929b0b78 100644 --- a/src/test/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileTest.java @@ -18,11 +18,18 @@ import zone.moddev.mc.orespawn.OreSpawnConfig.GeologyMode; import zone.moddev.mc.orespawn.worldgen.FormationSettings.Algorithm; import zone.moddev.mc.orespawn.worldgen.FormationSettings.Preset; +import zone.moddev.mc.orespawn.test.Forge12TestBootstrap; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; class WorldGeologyProfileTest { + @BeforeAll + static void bootstrapMinecraft() { + Forge12TestBootstrap.registerVanilla(); + } + @Test void schemaThreeOilMigratesToNamedFluidDeposit() { JsonObject legacy = completeGlobalFixture(); @@ -122,6 +129,52 @@ void schemaOneMigrationPreservesShapeAndSnapshotsGlobalGeology() { assertFalse(result.has("oil")); } + @Test + void freshProfilesConsolidateReviewedConflictsButUpgradesStaySeparate() { + JsonObject global = completeGlobalFixture(); + addSulfurConflict(global); + WorldGeologyProfile fresh = WorldGeologyProfile.fromGlobalConfig( + global, GeologyMode.GEOME, false); + JsonObject freshPolicy = onlyPolicy(fresh.toJson()); + assertEquals("consolidated", freshPolicy.get("mode").getAsString()); + + JsonObject legacy = JsonCopies.copy(global); + legacy.addProperty("schema_version", 5); + legacy.remove(OreSourcePolicies.SECTION); + WorldGeologyProfile upgraded = WorldGeologyProfile.fromJson(legacy, fresh); + JsonObject upgradedPolicy = onlyPolicy(upgraded.toJson()); + assertEquals("keep_separate", upgradedPolicy.get("mode").getAsString()); + assertEquals(2, upgradedPolicy.getAsJsonObject("outputs").entrySet().size()); + } + + @Test + void schemaOneUpgradeDoesNotInheritFreshWorldConsolidation() { + JsonObject global = completeGlobalFixture(); + addSulfurConflict(global); + WorldGeologyProfile fallback = WorldGeologyProfile.fromGlobalConfig( + global, GeologyMode.GEOME, false); + assertEquals("consolidated", onlyPolicy(fallback.toJson()).get("mode").getAsString()); + JsonObject schemaOne = new JsonObject(); + schemaOne.addProperty("schema_version", 1); + schemaOne.addProperty("geology_mode", "geome"); + + WorldGeologyProfile upgraded = WorldGeologyProfile.fromJson(schemaOne, fallback); + assertEquals("keep_separate", onlyPolicy(upgraded.toJson()).get("mode").getAsString()); + } + + @Test + void existingUnprofiledWorldDoesNotInheritFreshWorldConsolidation() { + JsonObject global = completeGlobalFixture(); + addSulfurConflict(global); + WorldGeologyProfile fresh = WorldGeologyProfile.fromGlobalConfig( + global, GeologyMode.GEOME, false); + assertEquals("consolidated", onlyPolicy(fresh.toJson()).get("mode").getAsString()); + + WorldGeologyProfile upgraded = WorldGeologyProfileManager + .initializeNewWorldPolicies(fresh, true); + assertEquals("keep_separate", onlyPolicy(upgraded.toJson()).get("mode").getAsString()); + } + @Test void schemaTwoRoundTripKeepsExternalAndProviderData() { JsonObject root = completeGlobalFixture(); @@ -525,6 +578,31 @@ private static JsonObject oreDefaultsFixture(double frequency) { return objectWith("ores", objectWith("minecraft:coal_ore", coal)); } + private static void addSulfurConflict(JsonObject root) { + JsonObject ores = root.getAsJsonObject("ores"); + addSulfurOre(ores, "mineralogy:sulfur", "mineralogy", "minecraft:iron_ore"); + addSulfurOre(ores, "baseminerals:sulfur", "baseminerals", "minecraft:gold_ore"); + } + + private static void addSulfurOre(JsonObject ores, String id, String owner, String block) { + JsonObject ore = new JsonObject(); + ore.addProperty("enabled", true); + ore.addProperty("block", block); + ore.addProperty("material", "orespawn:sulfur"); + ore.addProperty("source_provider", owner); + JsonObject rule = new JsonObject(); + rule.addProperty("enabled", true); + rule.addProperty("pattern", "vein"); + ore.add("dimensions", objectWith("minecraft:overworld", rule)); + ores.add(id, ore); + } + + private static JsonObject onlyPolicy(JsonObject root) { + JsonObject policies = root.getAsJsonObject(OreSourcePolicies.SECTION); + assertEquals(1, policies.entrySet().size()); + return policies.entrySet().iterator().next().getValue().getAsJsonObject(); + } + private static JsonObject oreRule(int minY, int maxY, double frequency, int quantity, String pattern, String distribution, double discardChance) { JsonObject rule = new JsonObject(); From 0cc3341fb9789a1399432ca82192f3ef726111bf Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Mon, 14 Sep 2026 15:59:28 +0100 Subject: [PATCH 09/31] Add Ore Sources configuration screens --- build.gradle | 3 +- .../orespawn/client/ClientProbeTestMod.java | 10 +- .../orespawn/client/GeologyEditorSession.java | 181 ++++++++++++++ .../client/GeologyMaterialsScreen.java | 12 +- .../client/OreSourceDetailScreen.java | 233 ++++++++++++++++++ .../orespawn/client/OreSourceListScreen.java | 103 ++++++++ .../resources/assets/orespawn/lang/de_AU.lang | 34 +++ .../resources/assets/orespawn/lang/de_DE.lang | 34 +++ .../resources/assets/orespawn/lang/en_CA.lang | 34 +++ .../resources/assets/orespawn/lang/en_EN.lang | 34 +++ .../resources/assets/orespawn/lang/en_GB.lang | 34 +++ .../resources/assets/orespawn/lang/en_PT.lang | 34 +++ .../resources/assets/orespawn/lang/en_US.lang | 34 +++ .../resources/assets/orespawn/lang/es_ES.lang | 34 +++ .../resources/assets/orespawn/lang/es_MX.lang | 34 +++ .../resources/assets/orespawn/lang/fr_CA.lang | 34 +++ .../resources/assets/orespawn/lang/fr_FR.lang | 34 +++ .../resources/assets/orespawn/lang/ja_JP.lang | 34 +++ .../resources/assets/orespawn/lang/ko_KR.lang | 34 +++ .../resources/assets/orespawn/lang/pt_BR.lang | 34 +++ .../resources/assets/orespawn/lang/ru_RU.lang | 34 +++ .../resources/assets/orespawn/lang/zh_CN.lang | 34 +++ .../mc/orespawn/LocalizationParityTest.java | 2 +- .../client/GeologyEditorSessionTest.java | 56 +++++ .../client/OreSpawnScreenLayoutTest.java | 35 ++- 25 files changed, 1172 insertions(+), 7 deletions(-) create mode 100644 src/main/java/zone/moddev/mc/orespawn/client/OreSourceDetailScreen.java create mode 100644 src/main/java/zone/moddev/mc/orespawn/client/OreSourceListScreen.java diff --git a/build.gradle b/build.gradle index 2e1acd2a..7a3bcca2 100644 --- a/build.gradle +++ b/build.gradle @@ -688,7 +688,8 @@ task clientIntegrationProcess(type: JavaExec, dependsOn: prepareClientIntegratio 'The binary patch set is missing', 'FML appears to be missing any signature data', 'Unable to read a class file correctly', - 'problem reading the entry META-INF/versions/11/' + 'problem reading the entry META-INF/versions/11/', + 'Trying to activate the universal filled bucket too late. Call it statically in your Mods class. Mod: Base Metals' ] List unaccountedErrors = latest.readLines('UTF-8').findAll { line -> (line.contains('/ERROR]') || line.contains('/FATAL]')) 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 5fac2bf7..d91b5695 100644 --- a/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java +++ b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java @@ -201,9 +201,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) { 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 3e035bad..c7cd89fb 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java @@ -345,6 +345,187 @@ 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; + 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, "external", false), + bool(candidate, "enrichment", false))); + } + } + Collections.sort(candidates, (left, right) -> { + int owner = left.owner.compareTo(right.owner); + return owner != 0 ? owner : left.sourceId.compareTo(right.sourceId); + }); + result.add(new OreSourceGroup(entry.getKey(), material, domain, + string(policy, "mode", "keep_separate"), string(policy, "status", "review_required"), + candidates, decimalMap(policy, "outputs"), stringMap(policy, "placement_sources"))); + } + Collections.sort(result, (left, right) -> { + int material = left.material.compareTo(right.material); + return material != 0 ? material : left.domain.compareTo(right.domain); + }); + return Collections.unmodifiableList(result); + } + + 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 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"); + if (!enabled) outputs.remove(sourceId); + else outputs.addProperty(sourceId, Math.max(0.001D, Math.min(1000000.0D, weight))); + refreshOreSourceStatus(policy); + } + + private static void refreshOreSourceStatus(JsonObject policy) { + Set missing = 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(); + if (!bool(candidate, "loaded", false)) missing.add(string(candidate, "source_id", "")); + external |= bool(candidate, "external", 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)) { 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 mode; + final String status; + final List candidates; + final Map outputs; + final Map placements; + + OreSourceGroup(String key, String material, String domain, String mode, String status, + List candidates, Map outputs, + Map placements) { + this.key = key; + this.material = material; + this.domain = domain; + this.mode = mode; + 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) result.add(candidate.channel); + return Collections.unmodifiableList(new ArrayList<>(result)); + } + } + + 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 external; + final boolean enrichment; + + OreSourceCandidate(String sourceId, String owner, String ownerName, String ownerVersion, + String registryId, int metadata, String channel, List oreDictionary, + boolean loaded, boolean external, boolean enrichment) { + 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.external = external; + this.enrichment = enrichment; + } + } + void resetEntry(String section, String id) { JsonObject originalSection = object(original, section); if (originalSection.has(id)) { 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/OreSourceDetailScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceDetailScreen.java new file mode 100644 index 00000000..82aba479 --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceDetailScreen.java @@ -0,0 +1,233 @@ +package zone.moddev.mc.orespawn.client; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +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; + +/** Edits one material/domain policy inside the pending world-settings session. */ +final class OreSourceDetailScreen extends OreSpawnScreen { + private final GuiScreen parent; + private final GeologyEditorSession session; + private final String groupKey; + private final Map weights = new LinkedHashMap<>(); + private int channelIndex; + private int page; + private int pageSize; + private String error; + + OreSourceDetailScreen(GuiScreen parent, GeologyEditorSession session, String groupKey) { + super(new TextComponentTranslation("screen.orespawn.ore_source_detail")); + this.parent = parent; + this.session = session; + this.groupKey = groupKey; + } + + @Override + protected void init() { + OreSpawnScreenLayout.beginHelp(this); + weights.clear(); + OreSourceGroup group = group(); + if (group == null) { + addButton(new Button(width / 2 - 75, height - 28, 150, 20, + DialogTexts.GUI_DONE, button -> onClose())); + return; + } + int contentWidth = Math.min(390, Math.max(240, width - 24)); + int left = (width - contentWidth) / 2; + int half = (contentWidth - 5) / 2; + Button mode = addButton(OreSpawnScreenLayout.button(this, font, left, 34, half, 20, + new TextComponentTranslation("option.orespawn.ore_source.mode", + new TextComponentTranslation("mode.orespawn.ore_source." + group.mode).getFormattedText()), + button -> { + syncWeights(); + session.setOreSourceMode(groupKey, !"consolidated".equals(group().mode)); + rebuild(); + })); + OreSpawnScreenLayout.explain(this, mode, "tooltip.orespawn.ore_source.mode"); + + List channels = group.channels(); + if (channels.isEmpty()) channels = java.util.Collections.singletonList("orespawn:standard"); + channelIndex = Math.max(0, Math.min(channelIndex, channels.size() - 1)); + final List channelValues = channels; + Button channel = addButton(OreSpawnScreenLayout.button(this, font, left + half + 5, 34, half, 20, + new TextComponentTranslation("option.orespawn.ore_source.channel", + display(channelValues.get(channelIndex))), button -> { + syncWeights(); + channelIndex = (channelIndex + 1) % channelValues.size(); + rebuild(); + })); + OreSpawnScreenLayout.explain(this, channel, "tooltip.orespawn.ore_source.channel"); + + String selectedChannel = channelValues.get(channelIndex); + List placementCandidates = candidatesForChannel(group, selectedChannel); + String selected = group.placements.get(selectedChannel); + OreSourceCandidate selectedCandidate = candidate(group.candidates, selected); + int selectedIndex = indexOf(placementCandidates, selected); + if (selectedIndex < 0 && !placementCandidates.isEmpty()) selectedIndex = 0; + final int currentSelection = selectedIndex; + Button placement = addButton(OreSpawnScreenLayout.button(this, font, left, 58, contentWidth, 20, + new TextComponentTranslation("option.orespawn.ore_source.placement", + selectedCandidate != null && !selectedCandidate.loaded + ? candidateLabel(selectedCandidate) + : selectedIndex < 0 ? I18n.format("label.orespawn.ore_source.missing") + : candidateLabel(placementCandidates.get(selectedIndex))), button -> { + syncWeights(); + if (!placementCandidates.isEmpty()) { + OreSourceCandidate next = placementCandidates.get((currentSelection + 1) + % placementCandidates.size()); + session.setOreSourcePlacement(groupKey, selectedChannel, next.sourceId); + rebuild(); + } + })); + placement.enabled = !placementCandidates.isEmpty(); + OreSpawnScreenLayout.explain(this, placement, "tooltip.orespawn.ore_source.placement"); + + int listTop = 86; + int controlsY = height - 52; + pageSize = Math.max(1, (controlsY - listTop) / 24); + int pages = OreSourceListScreen.pageCount(group.candidates.size(), pageSize); + page = Math.max(0, Math.min(page, pages - 1)); + int start = page * pageSize; + for (int index = 0; index < pageSize && start + index < group.candidates.size(); index++) { + OreSourceCandidate candidate = group.candidates.get(start + index); + int y = listTop + index * 24; + boolean enabled = group.outputs.containsKey(candidate.sourceId); + Button toggle = addButton(new Button(left, y, contentWidth - 92, 20, + OreSpawnScreenLayout.fit(font, new TextComponentString( + (enabled ? "[x] " : "[ ] ") + candidateLabel(candidate)), contentWidth - 100), + button -> { + syncWeights(); + double current = group().outputs.containsKey(candidate.sourceId) + ? group().outputs.get(candidate.sourceId) : 1.0D; + session.setOreSourceOutput(groupKey, candidate.sourceId, + !group().outputs.containsKey(candidate.sourceId), current); + rebuild(); + }, (button, mouseX, mouseY) -> renderStringTooltip(candidateTooltip(candidate), mouseX, mouseY))); + toggle.enabled = !candidate.external && !candidate.enrichment && candidate.loaded; + TextFieldWidget weight = addButton(new TextFieldWidget(font, left + contentWidth - 87, y, 87, 20, + new TextComponentTranslation("option.orespawn.ore_source.weight"))); + weight.setMaxLength(12); + weight.setValue(formatWeight(group.outputs.get(candidate.sourceId))); + weight.enabled = enabled && !candidate.external && !candidate.enrichment && candidate.loaded; + weights.put(candidate.sourceId, weight); + OreSpawnScreenLayout.explain(this, weight, "tooltip.orespawn.ore_source.weight"); + } + + Button previous = addButton(new Button(left, controlsY, 60, 20, new TextComponentString("<"), + button -> { syncWeights(); page--; rebuild(); })); + Button next = addButton(new Button(left + contentWidth - 60, controlsY, 60, 20, + new TextComponentString(">"), button -> { syncWeights(); page++; rebuild(); })); + previous.enabled = page > 0; + next.enabled = page + 1 < pages; + addButton(new Button(width / 2 - 75, height - 28, 150, 20, + DialogTexts.GUI_DONE, button -> onClose())); + } + + private void syncWeights() { + OreSourceGroup group = group(); + if (group == null) return; + error = null; + 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(groupKey, entry.getKey(), true, value); + } catch (NumberFormatException invalid) { + error = I18n.format("error.orespawn.ore_source.weight"); + } + } + } + + private OreSourceGroup group() { + for (OreSourceGroup value : session.oreSourceGroups()) if (groupKey.equals(value.key)) return value; + return null; + } + + private static List candidatesForChannel(OreSourceGroup group, String channel) { + List result = new ArrayList<>(); + for (OreSourceCandidate candidate : group.candidates) { + if (!candidate.external && !candidate.enrichment && candidate.loaded + && channel.equals(candidate.channel)) result.add(candidate); + } + return result; + } + + private static OreSourceCandidate candidate(List candidates, String sourceId) { + if (sourceId == null) return null; + for (OreSourceCandidate candidate : candidates) { + if (sourceId.equals(candidate.sourceId)) return candidate; + } + return null; + } + + private 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; + } + + private static String candidateLabel(OreSourceCandidate candidate) { + String owner = candidate.ownerName.isEmpty() ? candidate.owner : candidate.ownerName; + return owner + " - " + candidate.registryId + (candidate.loaded ? "" + : " (" + I18n.format("status.orespawn.ore_source.missing_source") + ")"); + } + + private 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.enrichment ? "tooltip.orespawn.ore_source.enrichment" + : candidate.loaded ? "tooltip.orespawn.ore_source.controlled" + : "tooltip.orespawn.ore_source.missing")); + return result; + } + + private 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()); + } + + private static String display(String id) { + int split = id.indexOf(':'); + return (split < 0 ? id : id.substring(split + 1)).replace('_', ' '); + } + + private void rebuild() { buttons.clear(); children.clear(); init(); } + + @Override + public void onClose() { + syncWeights(); + if (error == null) minecraft.displayGuiScreen(parent); + } + + @Override + public void render(int mouseX, int mouseY, float partialTick) { + renderBackground(); + OreSourceGroup group = group(); + drawCenteredString(font, title, width / 2, 8, 0xFFFFFF); + if (group != null) drawCenteredString(font, + new TextComponentString(display(group.material) + " - " + display(group.domain)), + width / 2, 20, 0xA0A0A0); + if (error != null) drawCenteredString(font, new TextComponentString(error), width / 2, + height - 64, 0xFF5555); + super.render(mouseX, mouseY, partialTick); + OreSpawnScreenLayout.renderExplanations(this, 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..ee2582db --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceListScreen.java @@ -0,0 +1,103 @@ +package zone.moddev.mc.orespawn.client; + +import java.util.List; + +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; + +/** Height-aware directory of material/domain source conflicts. */ +final class OreSourceListScreen extends OreSpawnScreen { + private static final int ROW_HEIGHT = 38; + private final GuiScreen parent; + private final GeologyEditorSession session; + private int page; + private int pageSize; + private int contentLeft; + private int contentWidth; + + 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); + List groups = session.oreSourceGroups(); + contentWidth = Math.min(390, Math.max(240, width - 24)); + contentLeft = (width - contentWidth) / 2; + int listTop = 34; + int controlsY = height - 52; + pageSize = Math.max(1, (controlsY - listTop) / ROW_HEIGHT); + int pages = pageCount(groups.size(), pageSize); + page = Math.max(0, Math.min(page, pages - 1)); + int start = page * pageSize; + for (int index = 0; index < pageSize && start + index < groups.size(); index++) { + OreSourceGroup group = groups.get(start + index); + int y = listTop + index * ROW_HEIGHT; + addButton(new Button(contentLeft, y, contentWidth, 34, + OreSpawnScreenLayout.fit(font, new TextComponentString(rowLabel(group)), contentWidth - 8), + button -> minecraft.displayGuiScreen(new OreSourceDetailScreen(this, session, group.key)), + (button, mouseX, mouseY) -> renderStringTooltip(tooltip(group), mouseX, mouseY))); + } + Button previous = addButton(new Button(contentLeft, controlsY, 80, 20, + new TextComponentTranslation("button.orespawn.previous"), + button -> { page--; rebuild(); })); + Button next = addButton(new Button(contentLeft + contentWidth - 80, controlsY, 80, 20, + new TextComponentTranslation("button.orespawn.next"), + button -> { page++; rebuild(); })); + previous.enabled = page > 0; + next.enabled = page + 1 < pages; + addButton(new Button(width / 2 - 75, height - 28, 150, 20, + DialogTexts.GUI_DONE, button -> onClose())); + } + + private String rowLabel(OreSourceGroup group) { + return I18n.format("label.orespawn.ore_source.row", display(group.material), display(group.domain), + group.candidates.size(), I18n.format("status.orespawn.ore_source." + group.status)); + } + + private java.util.List tooltip(OreSourceGroup group) { + java.util.List result = new java.util.ArrayList<>(); + result.add(I18n.format("label.orespawn.ore_source.material", group.material)); + result.add(I18n.format("label.orespawn.ore_source.domain", group.domain)); + result.add(I18n.format("label.orespawn.ore_source.channels", group.channels().size())); + result.add(I18n.format("tooltip.orespawn.ore_source.open")); + return result; + } + + private static String display(String id) { + int split = id.indexOf(':'); + String value = split < 0 ? id : id.substring(split + 1); + return value.replace('_', ' '); + } + + private void rebuild() { + buttons.clear(); children.clear(); init(); + } + + 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, 12, 0xFFFFFF); + List groups = session.oreSourceGroups(); + if (groups.isEmpty()) drawCenteredString(font, + new TextComponentTranslation("label.orespawn.ore_source.none"), width / 2, height / 2, 0xA0A0A0); + drawCenteredString(font, new TextComponentString((page + 1) + " / " + pageCount(groups.size(), pageSize)), + width / 2, height - 46, 0xA0A0A0); + super.render(mouseX, mouseY, partialTick); + OreSpawnScreenLayout.renderExplanations(this, mouseX, mouseY); + } +} diff --git a/src/main/resources/assets/orespawn/lang/de_AU.lang b/src/main/resources/assets/orespawn/lang/de_AU.lang index 63b71fed..25ef8316 100644 --- a/src/main/resources/assets/orespawn/lang/de_AU.lang +++ b/src/main/resources/assets/orespawn/lang/de_AU.lang @@ -378,3 +378,37 @@ 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). +button.orespawn.ore_sources=Erzquellen... +tooltip.orespawn.ore_sources=Gleichwertige Erzausgaben prüfen, ohne die Platzierungshäufigkeit zu vervielfachen. +screen.orespawn.ore_sources=Erzquellen +screen.orespawn.ore_source_detail=Erzquellenrichtlinie +label.orespawn.ore_source.none=Keine gleichwertigen verwalteten Erzquellen gefunden. +label.orespawn.ore_source.row=%s - %s - %s Kandidaten - %s +label.orespawn.ore_source.material=Rohstoff: %s +label.orespawn.ore_source.domain=Dimension/Bereich: %s +label.orespawn.ore_source.channels=Platzierungskanäle: %s +label.orespawn.ore_source.missing=Fehlende Platzierungsquelle +label.orespawn.ore_source.owner=Besitzer: %s, Version %s +label.orespawn.ore_source.registry=Registrierte Ausgabe: %s, Metadaten %s +label.orespawn.ore_source.dictionary=Erzlexikon: %s +option.orespawn.ore_source.mode=Modus: %s +option.orespawn.ore_source.channel=Kanal: %s +option.orespawn.ore_source.placement=Platzierungsquelle: %s +option.orespawn.ore_source.weight=Gewicht +mode.orespawn.ore_source.consolidated=Zusammengeführt +mode.orespawn.ore_source.keep_separate=Getrennt halten +status.orespawn.ore_source.consolidated=Zusammengeführt +status.orespawn.ore_source.separate=Getrennt +status.orespawn.ore_source.review_required=Prüfung erforderlich +status.orespawn.ore_source.missing_source=Quelle fehlt +status.orespawn.ore_source.external_generation=Externe Generierung +tooltip.orespawn.ore_source.open=Diese Materialrichtlinie öffnen. +tooltip.orespawn.ore_source.mode=Zusammengeführt verwendet ein Platzierungsbudget pro Kanal. Getrennt halten bewahrt jede konfigurierte Regel. +tooltip.orespawn.ore_source.channel=Platzierungs-Engines werden unabhängig verwaltet. +tooltip.orespawn.ore_source.placement=Regel auswählen, die Häufigkeit, Form, Wirte, Filter und Höhe dieses Kanals bestimmt. +tooltip.orespawn.ore_source.weight=Positive relative Chance, diese Ausgabe für einen ganzen Erzgang oder Lagerstättenkörper zu wählen. +tooltip.orespawn.ore_source.external=Dieser Mod registriert Generierung außerhalb von OreSpawn; OreSpawn meldet sie, kann sie aber nicht unterdrücken. +tooltip.orespawn.ore_source.controlled=OreSpawn steuert diese konfigurierte Platzierung und Ausgabequelle. +tooltip.orespawn.ore_source.enrichment=Dieser Eintrag ist eine Anreicherung und keine austauschbare Erzquelle. +tooltip.orespawn.ore_source.missing=Diese Quelle ist nicht verfügbar. Die gespeicherte Auswahl wird wiederhergestellt, wenn der Mod zurückkehrt. +error.orespawn.ore_source.weight=Ausgabegewichte müssen positive Zahlen sein. diff --git a/src/main/resources/assets/orespawn/lang/de_DE.lang b/src/main/resources/assets/orespawn/lang/de_DE.lang index 63b71fed..25ef8316 100644 --- a/src/main/resources/assets/orespawn/lang/de_DE.lang +++ b/src/main/resources/assets/orespawn/lang/de_DE.lang @@ -378,3 +378,37 @@ 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). +button.orespawn.ore_sources=Erzquellen... +tooltip.orespawn.ore_sources=Gleichwertige Erzausgaben prüfen, ohne die Platzierungshäufigkeit zu vervielfachen. +screen.orespawn.ore_sources=Erzquellen +screen.orespawn.ore_source_detail=Erzquellenrichtlinie +label.orespawn.ore_source.none=Keine gleichwertigen verwalteten Erzquellen gefunden. +label.orespawn.ore_source.row=%s - %s - %s Kandidaten - %s +label.orespawn.ore_source.material=Rohstoff: %s +label.orespawn.ore_source.domain=Dimension/Bereich: %s +label.orespawn.ore_source.channels=Platzierungskanäle: %s +label.orespawn.ore_source.missing=Fehlende Platzierungsquelle +label.orespawn.ore_source.owner=Besitzer: %s, Version %s +label.orespawn.ore_source.registry=Registrierte Ausgabe: %s, Metadaten %s +label.orespawn.ore_source.dictionary=Erzlexikon: %s +option.orespawn.ore_source.mode=Modus: %s +option.orespawn.ore_source.channel=Kanal: %s +option.orespawn.ore_source.placement=Platzierungsquelle: %s +option.orespawn.ore_source.weight=Gewicht +mode.orespawn.ore_source.consolidated=Zusammengeführt +mode.orespawn.ore_source.keep_separate=Getrennt halten +status.orespawn.ore_source.consolidated=Zusammengeführt +status.orespawn.ore_source.separate=Getrennt +status.orespawn.ore_source.review_required=Prüfung erforderlich +status.orespawn.ore_source.missing_source=Quelle fehlt +status.orespawn.ore_source.external_generation=Externe Generierung +tooltip.orespawn.ore_source.open=Diese Materialrichtlinie öffnen. +tooltip.orespawn.ore_source.mode=Zusammengeführt verwendet ein Platzierungsbudget pro Kanal. Getrennt halten bewahrt jede konfigurierte Regel. +tooltip.orespawn.ore_source.channel=Platzierungs-Engines werden unabhängig verwaltet. +tooltip.orespawn.ore_source.placement=Regel auswählen, die Häufigkeit, Form, Wirte, Filter und Höhe dieses Kanals bestimmt. +tooltip.orespawn.ore_source.weight=Positive relative Chance, diese Ausgabe für einen ganzen Erzgang oder Lagerstättenkörper zu wählen. +tooltip.orespawn.ore_source.external=Dieser Mod registriert Generierung außerhalb von OreSpawn; OreSpawn meldet sie, kann sie aber nicht unterdrücken. +tooltip.orespawn.ore_source.controlled=OreSpawn steuert diese konfigurierte Platzierung und Ausgabequelle. +tooltip.orespawn.ore_source.enrichment=Dieser Eintrag ist eine Anreicherung und keine austauschbare Erzquelle. +tooltip.orespawn.ore_source.missing=Diese Quelle ist nicht verfügbar. Die gespeicherte Auswahl wird wiederhergestellt, wenn der Mod zurückkehrt. +error.orespawn.ore_source.weight=Ausgabegewichte müssen positive Zahlen sein. diff --git a/src/main/resources/assets/orespawn/lang/en_CA.lang b/src/main/resources/assets/orespawn/lang/en_CA.lang index e1fe984d..8afdb8f2 100644 --- a/src/main/resources/assets/orespawn/lang/en_CA.lang +++ b/src/main/resources/assets/orespawn/lang/en_CA.lang @@ -378,3 +378,37 @@ 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). +button.orespawn.ore_sources=Ore Sources... +tooltip.orespawn.ore_sources=Review equivalent ore outputs without multiplying placement frequency. +screen.orespawn.ore_sources=Ore Sources +screen.orespawn.ore_source_detail=Ore Source Policy +label.orespawn.ore_source.none=No equivalent managed ore sources were found. +label.orespawn.ore_source.row=%s - %s - %s candidates - %s +label.orespawn.ore_source.material=Material: %s +label.orespawn.ore_source.domain=Dimension/domain: %s +label.orespawn.ore_source.channels=Placement channels: %s +label.orespawn.ore_source.missing=Missing placement source +label.orespawn.ore_source.owner=Owner: %s, version %s +label.orespawn.ore_source.registry=Registry output: %s, metadata %s +label.orespawn.ore_source.dictionary=Ore Dictionary: %s +option.orespawn.ore_source.mode=Mode: %s +option.orespawn.ore_source.channel=Channel: %s +option.orespawn.ore_source.placement=Placement source: %s +option.orespawn.ore_source.weight=Weight +mode.orespawn.ore_source.consolidated=Consolidated +mode.orespawn.ore_source.keep_separate=Keep Separate +status.orespawn.ore_source.consolidated=Consolidated +status.orespawn.ore_source.separate=Separate +status.orespawn.ore_source.review_required=Review required +status.orespawn.ore_source.missing_source=Missing source +status.orespawn.ore_source.external_generation=External generation +tooltip.orespawn.ore_source.open=Open this material policy. +tooltip.orespawn.ore_source.mode=Consolidated uses one placement budget per channel. Keep Separate preserves every configured rule. +tooltip.orespawn.ore_source.channel=Placement engines are arbitrated independently. +tooltip.orespawn.ore_source.placement=Choose the rule that owns frequency, shape, hosts, filters and height for this channel. +tooltip.orespawn.ore_source.weight=Positive relative chance of selecting this output for a whole vein or identified deposit body. +tooltip.orespawn.ore_source.external=This mod registers generation outside OreSpawn; OreSpawn reports it but cannot suppress it. +tooltip.orespawn.ore_source.controlled=OreSpawn controls this configured placement and output source. +tooltip.orespawn.ore_source.enrichment=This entry is enrichment, not an interchangeable ore source. +tooltip.orespawn.ore_source.missing=This source is unavailable. Its saved selection will be restored if the owning mod returns. +error.orespawn.ore_source.weight=Output weights must be positive numbers. diff --git a/src/main/resources/assets/orespawn/lang/en_EN.lang b/src/main/resources/assets/orespawn/lang/en_EN.lang index e1fe984d..8afdb8f2 100644 --- a/src/main/resources/assets/orespawn/lang/en_EN.lang +++ b/src/main/resources/assets/orespawn/lang/en_EN.lang @@ -378,3 +378,37 @@ 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). +button.orespawn.ore_sources=Ore Sources... +tooltip.orespawn.ore_sources=Review equivalent ore outputs without multiplying placement frequency. +screen.orespawn.ore_sources=Ore Sources +screen.orespawn.ore_source_detail=Ore Source Policy +label.orespawn.ore_source.none=No equivalent managed ore sources were found. +label.orespawn.ore_source.row=%s - %s - %s candidates - %s +label.orespawn.ore_source.material=Material: %s +label.orespawn.ore_source.domain=Dimension/domain: %s +label.orespawn.ore_source.channels=Placement channels: %s +label.orespawn.ore_source.missing=Missing placement source +label.orespawn.ore_source.owner=Owner: %s, version %s +label.orespawn.ore_source.registry=Registry output: %s, metadata %s +label.orespawn.ore_source.dictionary=Ore Dictionary: %s +option.orespawn.ore_source.mode=Mode: %s +option.orespawn.ore_source.channel=Channel: %s +option.orespawn.ore_source.placement=Placement source: %s +option.orespawn.ore_source.weight=Weight +mode.orespawn.ore_source.consolidated=Consolidated +mode.orespawn.ore_source.keep_separate=Keep Separate +status.orespawn.ore_source.consolidated=Consolidated +status.orespawn.ore_source.separate=Separate +status.orespawn.ore_source.review_required=Review required +status.orespawn.ore_source.missing_source=Missing source +status.orespawn.ore_source.external_generation=External generation +tooltip.orespawn.ore_source.open=Open this material policy. +tooltip.orespawn.ore_source.mode=Consolidated uses one placement budget per channel. Keep Separate preserves every configured rule. +tooltip.orespawn.ore_source.channel=Placement engines are arbitrated independently. +tooltip.orespawn.ore_source.placement=Choose the rule that owns frequency, shape, hosts, filters and height for this channel. +tooltip.orespawn.ore_source.weight=Positive relative chance of selecting this output for a whole vein or identified deposit body. +tooltip.orespawn.ore_source.external=This mod registers generation outside OreSpawn; OreSpawn reports it but cannot suppress it. +tooltip.orespawn.ore_source.controlled=OreSpawn controls this configured placement and output source. +tooltip.orespawn.ore_source.enrichment=This entry is enrichment, not an interchangeable ore source. +tooltip.orespawn.ore_source.missing=This source is unavailable. Its saved selection will be restored if the owning mod returns. +error.orespawn.ore_source.weight=Output weights must be positive numbers. diff --git a/src/main/resources/assets/orespawn/lang/en_GB.lang b/src/main/resources/assets/orespawn/lang/en_GB.lang index e1fe984d..8afdb8f2 100644 --- a/src/main/resources/assets/orespawn/lang/en_GB.lang +++ b/src/main/resources/assets/orespawn/lang/en_GB.lang @@ -378,3 +378,37 @@ 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). +button.orespawn.ore_sources=Ore Sources... +tooltip.orespawn.ore_sources=Review equivalent ore outputs without multiplying placement frequency. +screen.orespawn.ore_sources=Ore Sources +screen.orespawn.ore_source_detail=Ore Source Policy +label.orespawn.ore_source.none=No equivalent managed ore sources were found. +label.orespawn.ore_source.row=%s - %s - %s candidates - %s +label.orespawn.ore_source.material=Material: %s +label.orespawn.ore_source.domain=Dimension/domain: %s +label.orespawn.ore_source.channels=Placement channels: %s +label.orespawn.ore_source.missing=Missing placement source +label.orespawn.ore_source.owner=Owner: %s, version %s +label.orespawn.ore_source.registry=Registry output: %s, metadata %s +label.orespawn.ore_source.dictionary=Ore Dictionary: %s +option.orespawn.ore_source.mode=Mode: %s +option.orespawn.ore_source.channel=Channel: %s +option.orespawn.ore_source.placement=Placement source: %s +option.orespawn.ore_source.weight=Weight +mode.orespawn.ore_source.consolidated=Consolidated +mode.orespawn.ore_source.keep_separate=Keep Separate +status.orespawn.ore_source.consolidated=Consolidated +status.orespawn.ore_source.separate=Separate +status.orespawn.ore_source.review_required=Review required +status.orespawn.ore_source.missing_source=Missing source +status.orespawn.ore_source.external_generation=External generation +tooltip.orespawn.ore_source.open=Open this material policy. +tooltip.orespawn.ore_source.mode=Consolidated uses one placement budget per channel. Keep Separate preserves every configured rule. +tooltip.orespawn.ore_source.channel=Placement engines are arbitrated independently. +tooltip.orespawn.ore_source.placement=Choose the rule that owns frequency, shape, hosts, filters and height for this channel. +tooltip.orespawn.ore_source.weight=Positive relative chance of selecting this output for a whole vein or identified deposit body. +tooltip.orespawn.ore_source.external=This mod registers generation outside OreSpawn; OreSpawn reports it but cannot suppress it. +tooltip.orespawn.ore_source.controlled=OreSpawn controls this configured placement and output source. +tooltip.orespawn.ore_source.enrichment=This entry is enrichment, not an interchangeable ore source. +tooltip.orespawn.ore_source.missing=This source is unavailable. Its saved selection will be restored if the owning mod returns. +error.orespawn.ore_source.weight=Output weights must be positive numbers. diff --git a/src/main/resources/assets/orespawn/lang/en_PT.lang b/src/main/resources/assets/orespawn/lang/en_PT.lang index e1fe984d..8afdb8f2 100644 --- a/src/main/resources/assets/orespawn/lang/en_PT.lang +++ b/src/main/resources/assets/orespawn/lang/en_PT.lang @@ -378,3 +378,37 @@ 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). +button.orespawn.ore_sources=Ore Sources... +tooltip.orespawn.ore_sources=Review equivalent ore outputs without multiplying placement frequency. +screen.orespawn.ore_sources=Ore Sources +screen.orespawn.ore_source_detail=Ore Source Policy +label.orespawn.ore_source.none=No equivalent managed ore sources were found. +label.orespawn.ore_source.row=%s - %s - %s candidates - %s +label.orespawn.ore_source.material=Material: %s +label.orespawn.ore_source.domain=Dimension/domain: %s +label.orespawn.ore_source.channels=Placement channels: %s +label.orespawn.ore_source.missing=Missing placement source +label.orespawn.ore_source.owner=Owner: %s, version %s +label.orespawn.ore_source.registry=Registry output: %s, metadata %s +label.orespawn.ore_source.dictionary=Ore Dictionary: %s +option.orespawn.ore_source.mode=Mode: %s +option.orespawn.ore_source.channel=Channel: %s +option.orespawn.ore_source.placement=Placement source: %s +option.orespawn.ore_source.weight=Weight +mode.orespawn.ore_source.consolidated=Consolidated +mode.orespawn.ore_source.keep_separate=Keep Separate +status.orespawn.ore_source.consolidated=Consolidated +status.orespawn.ore_source.separate=Separate +status.orespawn.ore_source.review_required=Review required +status.orespawn.ore_source.missing_source=Missing source +status.orespawn.ore_source.external_generation=External generation +tooltip.orespawn.ore_source.open=Open this material policy. +tooltip.orespawn.ore_source.mode=Consolidated uses one placement budget per channel. Keep Separate preserves every configured rule. +tooltip.orespawn.ore_source.channel=Placement engines are arbitrated independently. +tooltip.orespawn.ore_source.placement=Choose the rule that owns frequency, shape, hosts, filters and height for this channel. +tooltip.orespawn.ore_source.weight=Positive relative chance of selecting this output for a whole vein or identified deposit body. +tooltip.orespawn.ore_source.external=This mod registers generation outside OreSpawn; OreSpawn reports it but cannot suppress it. +tooltip.orespawn.ore_source.controlled=OreSpawn controls this configured placement and output source. +tooltip.orespawn.ore_source.enrichment=This entry is enrichment, not an interchangeable ore source. +tooltip.orespawn.ore_source.missing=This source is unavailable. Its saved selection will be restored if the owning mod returns. +error.orespawn.ore_source.weight=Output weights must be positive numbers. diff --git a/src/main/resources/assets/orespawn/lang/en_US.lang b/src/main/resources/assets/orespawn/lang/en_US.lang index ad792079..97b67a21 100644 --- a/src/main/resources/assets/orespawn/lang/en_US.lang +++ b/src/main/resources/assets/orespawn/lang/en_US.lang @@ -378,3 +378,37 @@ 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). +button.orespawn.ore_sources=Ore Sources... +tooltip.orespawn.ore_sources=Review equivalent ore outputs without multiplying placement frequency. +screen.orespawn.ore_sources=Ore Sources +screen.orespawn.ore_source_detail=Ore Source Policy +label.orespawn.ore_source.none=No equivalent managed ore sources were found. +label.orespawn.ore_source.row=%s - %s - %s candidates - %s +label.orespawn.ore_source.material=Material: %s +label.orespawn.ore_source.domain=Dimension/domain: %s +label.orespawn.ore_source.channels=Placement channels: %s +label.orespawn.ore_source.missing=Missing placement source +label.orespawn.ore_source.owner=Owner: %s, version %s +label.orespawn.ore_source.registry=Registry output: %s, metadata %s +label.orespawn.ore_source.dictionary=Ore Dictionary: %s +option.orespawn.ore_source.mode=Mode: %s +option.orespawn.ore_source.channel=Channel: %s +option.orespawn.ore_source.placement=Placement source: %s +option.orespawn.ore_source.weight=Weight +mode.orespawn.ore_source.consolidated=Consolidated +mode.orespawn.ore_source.keep_separate=Keep Separate +status.orespawn.ore_source.consolidated=Consolidated +status.orespawn.ore_source.separate=Separate +status.orespawn.ore_source.review_required=Review required +status.orespawn.ore_source.missing_source=Missing source +status.orespawn.ore_source.external_generation=External generation +tooltip.orespawn.ore_source.open=Open this material policy. +tooltip.orespawn.ore_source.mode=Consolidated uses one placement budget per channel. Keep Separate preserves every configured rule. +tooltip.orespawn.ore_source.channel=Placement engines are arbitrated independently. +tooltip.orespawn.ore_source.placement=Choose the rule that owns frequency, shape, hosts, filters and height for this channel. +tooltip.orespawn.ore_source.weight=Positive relative chance of selecting this output for a whole vein or identified deposit body. +tooltip.orespawn.ore_source.external=This mod registers generation outside OreSpawn; OreSpawn reports it but cannot suppress it. +tooltip.orespawn.ore_source.controlled=OreSpawn controls this configured placement and output source. +tooltip.orespawn.ore_source.enrichment=This entry is enrichment, not an interchangeable ore source. +tooltip.orespawn.ore_source.missing=This source is unavailable. Its saved selection will be restored if the owning mod returns. +error.orespawn.ore_source.weight=Output weights must be positive numbers. diff --git a/src/main/resources/assets/orespawn/lang/es_ES.lang b/src/main/resources/assets/orespawn/lang/es_ES.lang index 4e6ca25e..f5d37926 100644 --- a/src/main/resources/assets/orespawn/lang/es_ES.lang +++ b/src/main/resources/assets/orespawn/lang/es_ES.lang @@ -378,3 +378,37 @@ 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). +button.orespawn.ore_sources=Fuentes de mineral... +tooltip.orespawn.ore_sources=Revisa salidas de mineral equivalentes sin multiplicar la frecuencia de colocación. +screen.orespawn.ore_sources=Fuentes de mineral +screen.orespawn.ore_source_detail=Política de fuente de mineral +label.orespawn.ore_source.none=No se encontraron fuentes de mineral equivalentes gestionadas. +label.orespawn.ore_source.row=%s - %s - %s candidatos - %s +label.orespawn.ore_source.material=Materia: %s +label.orespawn.ore_source.domain=Dimensión/dominio: %s +label.orespawn.ore_source.channels=Canales de colocación: %s +label.orespawn.ore_source.missing=Falta la fuente de colocación +label.orespawn.ore_source.owner=Propietario: %s, versión %s +label.orespawn.ore_source.registry=Salida registrada: %s, metadatos %s +label.orespawn.ore_source.dictionary=Diccionario de minerales: %s +option.orespawn.ore_source.mode=Modo: %s +option.orespawn.ore_source.channel=Canal: %s +option.orespawn.ore_source.placement=Fuente de colocación: %s +option.orespawn.ore_source.weight=Peso +mode.orespawn.ore_source.consolidated=Consolidado +mode.orespawn.ore_source.keep_separate=Mantener separado +status.orespawn.ore_source.consolidated=Consolidado +status.orespawn.ore_source.separate=Separado +status.orespawn.ore_source.review_required=Revisión necesaria +status.orespawn.ore_source.missing_source=Falta la fuente +status.orespawn.ore_source.external_generation=Generación externa +tooltip.orespawn.ore_source.open=Abre esta política de material. +tooltip.orespawn.ore_source.mode=Consolidado usa un presupuesto de colocación por canal. Mantener separado conserva cada regla configurada. +tooltip.orespawn.ore_source.channel=Los motores de colocación se arbitran de forma independiente. +tooltip.orespawn.ore_source.placement=Elige la regla que controla frecuencia, forma, huéspedes, filtros y altura de este canal. +tooltip.orespawn.ore_source.weight=Probabilidad relativa positiva de elegir esta salida para una veta completa o un depósito identificado. +tooltip.orespawn.ore_source.external=Este mod registra generación fuera de OreSpawn; OreSpawn la muestra, pero no puede impedirla. +tooltip.orespawn.ore_source.controlled=OreSpawn controla esta colocación y fuente de salida configuradas. +tooltip.orespawn.ore_source.enrichment=Esta entrada es enriquecimiento, no una fuente de mineral intercambiable. +tooltip.orespawn.ore_source.missing=Esta fuente no está disponible. La selección guardada se restaurará si vuelve el mod propietario. +error.orespawn.ore_source.weight=Los pesos de salida deben ser números positivos. diff --git a/src/main/resources/assets/orespawn/lang/es_MX.lang b/src/main/resources/assets/orespawn/lang/es_MX.lang index 4e6ca25e..f5d37926 100644 --- a/src/main/resources/assets/orespawn/lang/es_MX.lang +++ b/src/main/resources/assets/orespawn/lang/es_MX.lang @@ -378,3 +378,37 @@ 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). +button.orespawn.ore_sources=Fuentes de mineral... +tooltip.orespawn.ore_sources=Revisa salidas de mineral equivalentes sin multiplicar la frecuencia de colocación. +screen.orespawn.ore_sources=Fuentes de mineral +screen.orespawn.ore_source_detail=Política de fuente de mineral +label.orespawn.ore_source.none=No se encontraron fuentes de mineral equivalentes gestionadas. +label.orespawn.ore_source.row=%s - %s - %s candidatos - %s +label.orespawn.ore_source.material=Materia: %s +label.orespawn.ore_source.domain=Dimensión/dominio: %s +label.orespawn.ore_source.channels=Canales de colocación: %s +label.orespawn.ore_source.missing=Falta la fuente de colocación +label.orespawn.ore_source.owner=Propietario: %s, versión %s +label.orespawn.ore_source.registry=Salida registrada: %s, metadatos %s +label.orespawn.ore_source.dictionary=Diccionario de minerales: %s +option.orespawn.ore_source.mode=Modo: %s +option.orespawn.ore_source.channel=Canal: %s +option.orespawn.ore_source.placement=Fuente de colocación: %s +option.orespawn.ore_source.weight=Peso +mode.orespawn.ore_source.consolidated=Consolidado +mode.orespawn.ore_source.keep_separate=Mantener separado +status.orespawn.ore_source.consolidated=Consolidado +status.orespawn.ore_source.separate=Separado +status.orespawn.ore_source.review_required=Revisión necesaria +status.orespawn.ore_source.missing_source=Falta la fuente +status.orespawn.ore_source.external_generation=Generación externa +tooltip.orespawn.ore_source.open=Abre esta política de material. +tooltip.orespawn.ore_source.mode=Consolidado usa un presupuesto de colocación por canal. Mantener separado conserva cada regla configurada. +tooltip.orespawn.ore_source.channel=Los motores de colocación se arbitran de forma independiente. +tooltip.orespawn.ore_source.placement=Elige la regla que controla frecuencia, forma, huéspedes, filtros y altura de este canal. +tooltip.orespawn.ore_source.weight=Probabilidad relativa positiva de elegir esta salida para una veta completa o un depósito identificado. +tooltip.orespawn.ore_source.external=Este mod registra generación fuera de OreSpawn; OreSpawn la muestra, pero no puede impedirla. +tooltip.orespawn.ore_source.controlled=OreSpawn controla esta colocación y fuente de salida configuradas. +tooltip.orespawn.ore_source.enrichment=Esta entrada es enriquecimiento, no una fuente de mineral intercambiable. +tooltip.orespawn.ore_source.missing=Esta fuente no está disponible. La selección guardada se restaurará si vuelve el mod propietario. +error.orespawn.ore_source.weight=Los pesos de salida deben ser números positivos. diff --git a/src/main/resources/assets/orespawn/lang/fr_CA.lang b/src/main/resources/assets/orespawn/lang/fr_CA.lang index 632f79fc..608bceee 100644 --- a/src/main/resources/assets/orespawn/lang/fr_CA.lang +++ b/src/main/resources/assets/orespawn/lang/fr_CA.lang @@ -378,3 +378,37 @@ 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). +button.orespawn.ore_sources=Sources de minerai... +tooltip.orespawn.ore_sources=Examinez les sorties de minerai équivalentes sans multiplier la fréquence de placement. +screen.orespawn.ore_sources=Sources de minerai +screen.orespawn.ore_source_detail=Politique de source de minerai +label.orespawn.ore_source.none=Aucune source de minerai gérée équivalente n'a été trouvée. +label.orespawn.ore_source.row=%s - %s - %s candidats - %s +label.orespawn.ore_source.material=Matériau : %s +label.orespawn.ore_source.domain=Dimension/domaine : %s +label.orespawn.ore_source.channels=Canaux de placement : %s +label.orespawn.ore_source.missing=Source de placement manquante +label.orespawn.ore_source.owner=Propriétaire : %s, version %s +label.orespawn.ore_source.registry=Sortie enregistrée : %s, métadonnées %s +label.orespawn.ore_source.dictionary=Dictionnaire des minerais : %s +option.orespawn.ore_source.mode=Mode : %s +option.orespawn.ore_source.channel=Canal : %s +option.orespawn.ore_source.placement=Source de placement : %s +option.orespawn.ore_source.weight=Poids +mode.orespawn.ore_source.consolidated=Consolidé +mode.orespawn.ore_source.keep_separate=Garder séparé +status.orespawn.ore_source.consolidated=Consolidé +status.orespawn.ore_source.separate=Séparé +status.orespawn.ore_source.review_required=Révision requise +status.orespawn.ore_source.missing_source=Source manquante +status.orespawn.ore_source.external_generation=Génération externe +tooltip.orespawn.ore_source.open=Ouvrir cette politique de matériau. +tooltip.orespawn.ore_source.mode=Consolidé utilise un budget de placement par canal. Garder séparé conserve chaque règle configurée. +tooltip.orespawn.ore_source.channel=Les moteurs de placement sont arbitrés indépendamment. +tooltip.orespawn.ore_source.placement=Choisissez la règle qui contrôle fréquence, forme, hôtes, filtres et hauteur pour ce canal. +tooltip.orespawn.ore_source.weight=Chance relative positive de choisir cette sortie pour une veine entière ou un gisement identifié. +tooltip.orespawn.ore_source.external=Ce mod enregistre une génération hors d'OreSpawn ; OreSpawn la signale sans pouvoir la supprimer. +tooltip.orespawn.ore_source.controlled=OreSpawn contrôle cette source de placement et de sortie configurée. +tooltip.orespawn.ore_source.enrichment=Cette entrée est un enrichissement, pas une source de minerai interchangeable. +tooltip.orespawn.ore_source.missing=Cette source est indisponible. Le choix enregistré sera restauré si son mod revient. +error.orespawn.ore_source.weight=Les poids de sortie doivent être des nombres positifs. diff --git a/src/main/resources/assets/orespawn/lang/fr_FR.lang b/src/main/resources/assets/orespawn/lang/fr_FR.lang index 632f79fc..608bceee 100644 --- a/src/main/resources/assets/orespawn/lang/fr_FR.lang +++ b/src/main/resources/assets/orespawn/lang/fr_FR.lang @@ -378,3 +378,37 @@ 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). +button.orespawn.ore_sources=Sources de minerai... +tooltip.orespawn.ore_sources=Examinez les sorties de minerai équivalentes sans multiplier la fréquence de placement. +screen.orespawn.ore_sources=Sources de minerai +screen.orespawn.ore_source_detail=Politique de source de minerai +label.orespawn.ore_source.none=Aucune source de minerai gérée équivalente n'a été trouvée. +label.orespawn.ore_source.row=%s - %s - %s candidats - %s +label.orespawn.ore_source.material=Matériau : %s +label.orespawn.ore_source.domain=Dimension/domaine : %s +label.orespawn.ore_source.channels=Canaux de placement : %s +label.orespawn.ore_source.missing=Source de placement manquante +label.orespawn.ore_source.owner=Propriétaire : %s, version %s +label.orespawn.ore_source.registry=Sortie enregistrée : %s, métadonnées %s +label.orespawn.ore_source.dictionary=Dictionnaire des minerais : %s +option.orespawn.ore_source.mode=Mode : %s +option.orespawn.ore_source.channel=Canal : %s +option.orespawn.ore_source.placement=Source de placement : %s +option.orespawn.ore_source.weight=Poids +mode.orespawn.ore_source.consolidated=Consolidé +mode.orespawn.ore_source.keep_separate=Garder séparé +status.orespawn.ore_source.consolidated=Consolidé +status.orespawn.ore_source.separate=Séparé +status.orespawn.ore_source.review_required=Révision requise +status.orespawn.ore_source.missing_source=Source manquante +status.orespawn.ore_source.external_generation=Génération externe +tooltip.orespawn.ore_source.open=Ouvrir cette politique de matériau. +tooltip.orespawn.ore_source.mode=Consolidé utilise un budget de placement par canal. Garder séparé conserve chaque règle configurée. +tooltip.orespawn.ore_source.channel=Les moteurs de placement sont arbitrés indépendamment. +tooltip.orespawn.ore_source.placement=Choisissez la règle qui contrôle fréquence, forme, hôtes, filtres et hauteur pour ce canal. +tooltip.orespawn.ore_source.weight=Chance relative positive de choisir cette sortie pour une veine entière ou un gisement identifié. +tooltip.orespawn.ore_source.external=Ce mod enregistre une génération hors d'OreSpawn ; OreSpawn la signale sans pouvoir la supprimer. +tooltip.orespawn.ore_source.controlled=OreSpawn contrôle cette source de placement et de sortie configurée. +tooltip.orespawn.ore_source.enrichment=Cette entrée est un enrichissement, pas une source de minerai interchangeable. +tooltip.orespawn.ore_source.missing=Cette source est indisponible. Le choix enregistré sera restauré si son mod revient. +error.orespawn.ore_source.weight=Les poids de sortie doivent être des nombres positifs. diff --git a/src/main/resources/assets/orespawn/lang/ja_JP.lang b/src/main/resources/assets/orespawn/lang/ja_JP.lang index 0309bfc8..d4359c43 100644 --- a/src/main/resources/assets/orespawn/lang/ja_JP.lang +++ b/src/main/resources/assets/orespawn/lang/ja_JP.lang @@ -378,3 +378,37 @@ status.orespawn.mod.rejected=拒否 status.orespawn.mod.config_only=設定画面のみ error.orespawn.mod.screen_missing=%sは設定画面を作成しませんでした。 error.orespawn.mod.screen_failed=%sの設定を開けませんでした(%s)。 +button.orespawn.ore_sources=鉱石ソース... +tooltip.orespawn.ore_sources=配置頻度を増やさずに同等の鉱石出力を確認します。 +screen.orespawn.ore_sources=鉱石ソース +screen.orespawn.ore_source_detail=鉱石ソース方針 +label.orespawn.ore_source.none=同等の管理対象鉱石ソースは見つかりませんでした。 +label.orespawn.ore_source.row=%s - %s - 候補 %s 件 - %s +label.orespawn.ore_source.material=素材: %s +label.orespawn.ore_source.domain=ディメンション/領域: %s +label.orespawn.ore_source.channels=配置チャンネル: %s +label.orespawn.ore_source.missing=配置ソースがありません +label.orespawn.ore_source.owner=所有元: %s、バージョン %s +label.orespawn.ore_source.registry=登録出力: %s、メタデータ %s +label.orespawn.ore_source.dictionary=鉱石辞書: %s +option.orespawn.ore_source.mode=モード: %s +option.orespawn.ore_source.channel=チャンネル: %s +option.orespawn.ore_source.placement=配置ソース: %s +option.orespawn.ore_source.weight=重み +mode.orespawn.ore_source.consolidated=統合 +mode.orespawn.ore_source.keep_separate=個別に保持 +status.orespawn.ore_source.consolidated=統合 +status.orespawn.ore_source.separate=個別 +status.orespawn.ore_source.review_required=確認が必要 +status.orespawn.ore_source.missing_source=ソースがありません +status.orespawn.ore_source.external_generation=外部生成 +tooltip.orespawn.ore_source.open=この素材の方針を開きます。 +tooltip.orespawn.ore_source.mode=統合はチャンネルごとに1つの配置枠を使います。個別に保持は設定済みの各ルールを維持します。 +tooltip.orespawn.ore_source.channel=配置エンジンは個別に調整されます。 +tooltip.orespawn.ore_source.placement=このチャンネルの頻度、形状、母岩、フィルター、高さを決めるルールを選びます。 +tooltip.orespawn.ore_source.weight=鉱脈全体または識別済み鉱床全体でこの出力を選ぶ正の相対確率です。 +tooltip.orespawn.ore_source.external=このModはOreSpawn外で生成を登録しています。OreSpawnは表示できますが抑止できません。 +tooltip.orespawn.ore_source.controlled=OreSpawnがこの設定済み配置と出力ソースを制御します。 +tooltip.orespawn.ore_source.enrichment=この項目は富化用であり、交換可能な鉱石ソースではありません。 +tooltip.orespawn.ore_source.missing=このソースは利用できません。所有Modが戻ると保存済み選択が復元されます。 +error.orespawn.ore_source.weight=出力の重みは正の数である必要があります。 diff --git a/src/main/resources/assets/orespawn/lang/ko_KR.lang b/src/main/resources/assets/orespawn/lang/ko_KR.lang index 925602ac..7b668840 100644 --- a/src/main/resources/assets/orespawn/lang/ko_KR.lang +++ b/src/main/resources/assets/orespawn/lang/ko_KR.lang @@ -378,3 +378,37 @@ status.orespawn.mod.rejected=거부됨 status.orespawn.mod.config_only=설정 화면만 제공 error.orespawn.mod.screen_missing=%s 모드가 설정 화면을 만들지 않았습니다. error.orespawn.mod.screen_failed=%s 설정을 열 수 없습니다(%s). +button.orespawn.ore_sources=광석 소스... +tooltip.orespawn.ore_sources=배치 빈도를 늘리지 않고 동등한 광석 출력을 검토합니다. +screen.orespawn.ore_sources=광석 소스 +screen.orespawn.ore_source_detail=광석 소스 정책 +label.orespawn.ore_source.none=동등한 관리 광석 소스를 찾지 못했습니다. +label.orespawn.ore_source.row=%s - %s - 후보 %s개 - %s +label.orespawn.ore_source.material=재료: %s +label.orespawn.ore_source.domain=차원/영역: %s +label.orespawn.ore_source.channels=배치 채널: %s +label.orespawn.ore_source.missing=배치 소스 없음 +label.orespawn.ore_source.owner=소유자: %s, 버전 %s +label.orespawn.ore_source.registry=등록 출력: %s, 메타데이터 %s +label.orespawn.ore_source.dictionary=광석 사전: %s +option.orespawn.ore_source.mode=모드: %s +option.orespawn.ore_source.channel=채널: %s +option.orespawn.ore_source.placement=배치 소스: %s +option.orespawn.ore_source.weight=가중치 +mode.orespawn.ore_source.consolidated=통합 +mode.orespawn.ore_source.keep_separate=분리 유지 +status.orespawn.ore_source.consolidated=통합 +status.orespawn.ore_source.separate=분리 +status.orespawn.ore_source.review_required=검토 필요 +status.orespawn.ore_source.missing_source=소스 없음 +status.orespawn.ore_source.external_generation=외부 생성 +tooltip.orespawn.ore_source.open=이 재료 정책을 엽니다. +tooltip.orespawn.ore_source.mode=통합은 채널마다 하나의 배치 예산을 사용합니다. 분리 유지는 설정된 모든 규칙을 보존합니다. +tooltip.orespawn.ore_source.channel=배치 엔진은 서로 독립적으로 조정됩니다. +tooltip.orespawn.ore_source.placement=이 채널의 빈도, 모양, 모암, 필터 및 높이를 제어하는 규칙을 선택합니다. +tooltip.orespawn.ore_source.weight=전체 광맥 또는 식별된 광상에 이 출력을 선택할 양의 상대 확률입니다. +tooltip.orespawn.ore_source.external=이 Mod는 OreSpawn 밖에서 생성을 등록합니다. OreSpawn은 표시할 수 있지만 억제할 수 없습니다. +tooltip.orespawn.ore_source.controlled=OreSpawn이 이 설정된 배치와 출력 소스를 제어합니다. +tooltip.orespawn.ore_source.enrichment=이 항목은 농축용이며 교환 가능한 광석 소스가 아닙니다. +tooltip.orespawn.ore_source.missing=이 소스를 사용할 수 없습니다. 소유 Mod가 돌아오면 저장된 선택이 복원됩니다. +error.orespawn.ore_source.weight=출력 가중치는 양수여야 합니다. diff --git a/src/main/resources/assets/orespawn/lang/pt_BR.lang b/src/main/resources/assets/orespawn/lang/pt_BR.lang index c242bc55..18b8f311 100644 --- a/src/main/resources/assets/orespawn/lang/pt_BR.lang +++ b/src/main/resources/assets/orespawn/lang/pt_BR.lang @@ -378,3 +378,37 @@ 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). +button.orespawn.ore_sources=Fontes de minério... +tooltip.orespawn.ore_sources=Revise saídas de minério equivalentes sem multiplicar a frequência de colocação. +screen.orespawn.ore_sources=Fontes de minério +screen.orespawn.ore_source_detail=Política de fonte de minério +label.orespawn.ore_source.none=Nenhuma fonte de minério gerenciada equivalente foi encontrada. +label.orespawn.ore_source.row=%s - %s - %s candidatos - %s +label.orespawn.ore_source.material=Matéria-prima: %s +label.orespawn.ore_source.domain=Dimensão/domínio: %s +label.orespawn.ore_source.channels=Canais de colocação: %s +label.orespawn.ore_source.missing=Fonte de colocação ausente +label.orespawn.ore_source.owner=Proprietário: %s, versão %s +label.orespawn.ore_source.registry=Saída registrada: %s, metadados %s +label.orespawn.ore_source.dictionary=Dicionário de minérios: %s +option.orespawn.ore_source.mode=Modo: %s +option.orespawn.ore_source.channel=Canal: %s +option.orespawn.ore_source.placement=Fonte de colocação: %s +option.orespawn.ore_source.weight=Peso +mode.orespawn.ore_source.consolidated=Consolidado +mode.orespawn.ore_source.keep_separate=Manter separado +status.orespawn.ore_source.consolidated=Consolidado +status.orespawn.ore_source.separate=Separado +status.orespawn.ore_source.review_required=Revisão necessária +status.orespawn.ore_source.missing_source=Fonte ausente +status.orespawn.ore_source.external_generation=Geração externa +tooltip.orespawn.ore_source.open=Abrir esta política de material. +tooltip.orespawn.ore_source.mode=Consolidado usa um orçamento de colocação por canal. Manter separado preserva cada regra configurada. +tooltip.orespawn.ore_source.channel=Os mecanismos de colocação são arbitrados separadamente. +tooltip.orespawn.ore_source.placement=Escolha a regra que controla frequência, forma, hospedeiros, filtros e altura neste canal. +tooltip.orespawn.ore_source.weight=Chance relativa positiva de escolher esta saída para um veio inteiro ou corpo de depósito identificado. +tooltip.orespawn.ore_source.external=Este mod registra geração fora do OreSpawn; o OreSpawn a informa, mas não pode impedi-la. +tooltip.orespawn.ore_source.controlled=O OreSpawn controla esta colocação e fonte de saída configuradas. +tooltip.orespawn.ore_source.enrichment=Esta entrada é um enriquecimento, não uma fonte de minério intercambiável. +tooltip.orespawn.ore_source.missing=Esta fonte está indisponível. A seleção salva será restaurada se o mod proprietário retornar. +error.orespawn.ore_source.weight=Os pesos de saída devem ser números positivos. diff --git a/src/main/resources/assets/orespawn/lang/ru_RU.lang b/src/main/resources/assets/orespawn/lang/ru_RU.lang index 0b1def7c..7d009862 100644 --- a/src/main/resources/assets/orespawn/lang/ru_RU.lang +++ b/src/main/resources/assets/orespawn/lang/ru_RU.lang @@ -378,3 +378,37 @@ status.orespawn.mod.rejected=Отклонено status.orespawn.mod.config_only=Только экран настроек error.orespawn.mod.screen_missing=%s не создал экран настроек. error.orespawn.mod.screen_failed=Не удалось открыть настройки %s (%s). +button.orespawn.ore_sources=Источники руд... +tooltip.orespawn.ore_sources=Проверить равнозначные руды без умножения частоты размещения. +screen.orespawn.ore_sources=Источники руд +screen.orespawn.ore_source_detail=Правило источника руды +label.orespawn.ore_source.none=Равнозначные управляемые источники руд не найдены. +label.orespawn.ore_source.row=%s - %s - кандидатов: %s - %s +label.orespawn.ore_source.material=Материал: %s +label.orespawn.ore_source.domain=Измерение/область: %s +label.orespawn.ore_source.channels=Каналы размещения: %s +label.orespawn.ore_source.missing=Нет источника размещения +label.orespawn.ore_source.owner=Владелец: %s, версия %s +label.orespawn.ore_source.registry=Зарегистрированный выход: %s, метаданные %s +label.orespawn.ore_source.dictionary=Словарь руд: %s +option.orespawn.ore_source.mode=Режим: %s +option.orespawn.ore_source.channel=Канал: %s +option.orespawn.ore_source.placement=Источник размещения: %s +option.orespawn.ore_source.weight=Вес +mode.orespawn.ore_source.consolidated=Объединено +mode.orespawn.ore_source.keep_separate=Хранить отдельно +status.orespawn.ore_source.consolidated=Объединено +status.orespawn.ore_source.separate=Раздельно +status.orespawn.ore_source.review_required=Требуется проверка +status.orespawn.ore_source.missing_source=Источник отсутствует +status.orespawn.ore_source.external_generation=Внешняя генерация +tooltip.orespawn.ore_source.open=Открыть правило этого материала. +tooltip.orespawn.ore_source.mode=Объединение использует один бюджет размещения на канал. Раздельный режим сохраняет каждое настроенное правило. +tooltip.orespawn.ore_source.channel=Механизмы размещения регулируются независимо. +tooltip.orespawn.ore_source.placement=Выберите правило частоты, формы, вмещающих пород, фильтров и высоты для этого канала. +tooltip.orespawn.ore_source.weight=Положительный относительный шанс выбрать этот выход для всей жилы или определённого рудного тела. +tooltip.orespawn.ore_source.external=Этот мод регистрирует генерацию вне OreSpawn; OreSpawn показывает её, но не может отключить. +tooltip.orespawn.ore_source.controlled=OreSpawn управляет этим настроенным размещением и источником выхода. +tooltip.orespawn.ore_source.enrichment=Эта запись является обогащением, а не взаимозаменяемым источником руды. +tooltip.orespawn.ore_source.missing=Источник недоступен. Сохранённый выбор восстановится, когда вернётся его мод. +error.orespawn.ore_source.weight=Веса выходов должны быть положительными числами. diff --git a/src/main/resources/assets/orespawn/lang/zh_CN.lang b/src/main/resources/assets/orespawn/lang/zh_CN.lang index 10f23d61..62ec7359 100644 --- a/src/main/resources/assets/orespawn/lang/zh_CN.lang +++ b/src/main/resources/assets/orespawn/lang/zh_CN.lang @@ -378,3 +378,37 @@ status.orespawn.mod.rejected=已拒绝 status.orespawn.mod.config_only=仅配置界面 error.orespawn.mod.screen_missing=%s未创建配置界面。 error.orespawn.mod.screen_failed=无法打开%s的配置(%s)。 +button.orespawn.ore_sources=矿石来源... +tooltip.orespawn.ore_sources=检查等价矿石输出,同时避免生成频率叠加。 +screen.orespawn.ore_sources=矿石来源 +screen.orespawn.ore_source_detail=矿石来源策略 +label.orespawn.ore_source.none=未找到等价的受管理矿石来源。 +label.orespawn.ore_source.row=%s - %s - %s 个候选 - %s +label.orespawn.ore_source.material=材料:%s +label.orespawn.ore_source.domain=维度/范围:%s +label.orespawn.ore_source.channels=放置通道:%s +label.orespawn.ore_source.missing=缺少放置来源 +label.orespawn.ore_source.owner=所有者:%s,版本 %s +label.orespawn.ore_source.registry=注册输出:%s,元数据 %s +label.orespawn.ore_source.dictionary=矿石词典:%s +option.orespawn.ore_source.mode=模式:%s +option.orespawn.ore_source.channel=通道:%s +option.orespawn.ore_source.placement=放置来源:%s +option.orespawn.ore_source.weight=权重 +mode.orespawn.ore_source.consolidated=合并 +mode.orespawn.ore_source.keep_separate=保持独立 +status.orespawn.ore_source.consolidated=已合并 +status.orespawn.ore_source.separate=独立 +status.orespawn.ore_source.review_required=需要检查 +status.orespawn.ore_source.missing_source=来源缺失 +status.orespawn.ore_source.external_generation=外部生成 +tooltip.orespawn.ore_source.open=打开此材料策略。 +tooltip.orespawn.ore_source.mode=合并模式每个通道只使用一份放置预算。保持独立会保留每条已配置规则。 +tooltip.orespawn.ore_source.channel=各放置引擎会被独立协调。 +tooltip.orespawn.ore_source.placement=选择控制此通道频率、形状、宿主、筛选和高度的规则。 +tooltip.orespawn.ore_source.weight=为整条矿脉或已识别矿体选择此输出的正相对概率。 +tooltip.orespawn.ore_source.external=此模组在 OreSpawn 外部注册生成;OreSpawn 可以报告但无法抑制。 +tooltip.orespawn.ore_source.controlled=OreSpawn 控制此已配置的放置与输出来源。 +tooltip.orespawn.ore_source.enrichment=此条目属于富集矿,不是可互换的矿石来源。 +tooltip.orespawn.ore_source.missing=此来源不可用。其所属模组恢复后将自动恢复已保存的选择。 +error.orespawn.ore_source.weight=输出权重必须为正数。 diff --git a/src/test/java/zone/moddev/mc/orespawn/LocalizationParityTest.java b/src/test/java/zone/moddev/mc/orespawn/LocalizationParityTest.java index 117fd804..7e54c49c 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(380, englishKeys.size(), + assertEquals(414, 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/client/GeologyEditorSessionTest.java b/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java index 89579826..0f08c1a4 100644 --- a/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java @@ -2,6 +2,7 @@ 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; @@ -179,4 +180,59 @@ void namespacedGeomesCanBeAddedValidatedAndRoundTripped() { assertFalse(invalidSession.section("geomes").has("bad:upper")); assertTrue(invalidSession.section("geomes").has(geomeId)); } + + @Test + void oreSourceEditsStayInsideThePendingEditorSession() { + WorldGeologyProfile original = profileWithOreSourcePolicy(); + JsonObject before = original.rootCopy(); + GeologyEditorSession session = new GeologyEditorSession(original); + GeologyEditorSession.OreSourceGroup group = session.oreSourceGroups().get(0); + assertEquals("keep_separate", group.mode); + assertThrows(UnsupportedOperationException.class, () -> group.candidates.clear()); + assertThrows(UnsupportedOperationException.class, + () -> group.outputs.put("example:other", 2.0D)); + + session.setOreSourceMode(group.key, true); + session.setOreSourcePlacement(group.key, "orespawn:standard", "mineralogy:sulfur"); + session.setOreSourceOutput(group.key, "mineralogy:sulfur", true, 3.5D); + GeologyEditorSession.OreSourceGroup edited = session.oreSourceGroups().get(0); + assertEquals("consolidated", edited.mode); + assertEquals("mineralogy:sulfur", edited.placements.get("orespawn:standard")); + assertEquals(3.5D, edited.outputs.get("mineralogy:sulfur").doubleValue()); + assertEquals(before, original.rootCopy(), "editing must not persist the profile early"); + assertFalse(before.equals(session.profile().rootCopy())); + } + + private static WorldGeologyProfile profileWithOreSourcePolicy() { + JsonObject root = WorldGeologyProfile.recommended(false).toJson(); + JsonObject policy = new JsonObject(); + policy.addProperty("material", "orespawn:sulfur"); + policy.addProperty("domain", "minecraft:overworld"); + policy.addProperty("mode", "keep_separate"); + policy.addProperty("status", "review_required"); + policy.addProperty("review_required", true); + JsonObject outputs = new JsonObject(); + outputs.addProperty("mineralogy:sulfur", 1.0D); + policy.add("outputs", outputs); + policy.add("placement_sources", new JsonObject()); + JsonObject candidate = new JsonObject(); + candidate.addProperty("source_id", "mineralogy:sulfur"); + candidate.addProperty("owner", "mineralogy"); + candidate.addProperty("owner_name", "Mineralogy"); + candidate.addProperty("owner_version", "6.0.0"); + candidate.addProperty("registry_id", "minecraft:iron_ore"); + candidate.addProperty("metadata", 0); + candidate.addProperty("placement_channel", "orespawn:standard"); + candidate.addProperty("loaded", true); + candidate.addProperty("external", false); + candidate.addProperty("enrichment", false); + candidate.add("ore_dictionary", new JsonArray()); + JsonArray candidates = new JsonArray(); + candidates.add(candidate); + policy.add("candidates", candidates); + JsonObject policies = new JsonObject(); + policies.add("orespawn:sulfur|minecraft:overworld", policy); + root.add("ore_source_policies", policies); + return WorldGeologyProfile.fromJson(root, WorldGeologyProfile.recommended(false)); + } } 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 04a02059..33621a0b 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(25, screens.size(), "Review this render-order gate when screens are added or removed"); + assertEquals(27, 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( @@ -76,6 +76,39 @@ void advancedAndFluidEditorsShareTheRecoveredTerrainRow() throws Exception { .contains("font, right, top + (row * rowIndex++)")); } + @Test + void oreSourcesAndAddBlockShareTheCompactOreFooter() throws Exception { + String source = screenSource("GeologyMaterialsScreen.java"); + int width = source.indexOf("int addWidth = tab == MaterialTab.ORES"); + int sources = source.indexOf("button.orespawn.ore_sources", width); + int add = source.indexOf("button.orespawn.add_block", sources); + assertTrue(width >= 0 && sources > width && add > sources); + assertTrue(source.substring(width, add).contains("addX - addWidth - 5")); + assertTrue(source.substring(width, add).contains("addX, controlsY, addWidth, 20")); + } + + @Test + void oreSourceDirectoryPaginatesClipsAndPreservesParentNavigation() throws Exception { + assertEquals(1, OreSourceListScreen.pageCount(0, 4)); + assertEquals(1, OreSourceListScreen.pageCount(4, 4)); + assertEquals(2, OreSourceListScreen.pageCount(5, 4)); + String list = screenSource("OreSourceListScreen.java"); + String detail = screenSource("OreSourceDetailScreen.java"); + assertTrue(list.contains("pageSize = Math.max(1")); + assertTrue(detail.contains("pageSize = Math.max(1")); + assertTrue(list.contains("OreSpawnScreenLayout.fit")); + assertTrue(detail.contains("OreSpawnScreenLayout.fit")); + assertTrue(list.contains("minecraft.displayGuiScreen(parent)")); + assertTrue(detail.contains("syncWeights();\n\t\tif (error == null) minecraft.displayGuiScreen(parent)")); + assertTrue(detail.contains("toggle.enabled = !candidate.external && !candidate.enrichment")); + } + + private static String screenSource(String name) throws Exception { + Path screen = Paths.get("src", "main", "java", "zone", "moddev", "mc", + "orespawn", "client", name); + return new String(Files.readAllBytes(screen), StandardCharsets.UTF_8); + } + private static void assertRowsClearFooter(int height) { int rows = 8; int top = OreSpawnScreenLayout.mainTop(height); From ba9c7a5cfc8d32c6b0630b9380cc323dbeb56f51 Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Mon, 14 Sep 2026 15:59:35 +0100 Subject: [PATCH 10/31] Document OreSpawn 4.1 ore source policies --- CHANGELOG.txt | 15 +++++++++-- README.md | 13 ++++++++-- docs/API.md | 42 +++++++++++++++++++++++++++++-- docs/CONFIGURATION.md | 56 ++++++++++++++++++++++++++++++++++------- docs/DEVELOPER_GUIDE.md | 22 ++++++++++++++-- docs/MIGRATION.md | 12 +++++++-- docs/PLAYER_GUIDE.md | 22 ++++++++++++++++ docs/PROVIDERS.md | 38 +++++++++++++++++++++++++--- 8 files changed, 198 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.txt b/CHANGELOG.txt index a745dbba..5f537830 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,5 +1,15 @@ Version 4.1.0.110021 +* Add Ore Sources policies that group equivalent configured outputs by material + and dimension while keeping ordinary and custom placement channels independent. +* Consolidate only reviewed high-confidence MMD conflicts for new worlds; + upgraded worlds retain their previous separate placement and output behaviour. +* Select one weighted output for an entire ordinary vein or custom deposit body + using stable world/deposit identity, including safe missing-mod restoration. +* Add paginated Ore Sources list and detail screens for mode, placement-source + and output-weight control without persisting changes until the main editor's Done. +* Add provider schema 5 material and placement-channel declarations, global + schema 7 and world-profile schema 6 while retaining older readable schemas. * Add an extended, binary-compatible ore generation context exposing stable world seed, dimension and current-chunk identity to compiled pattern add-ons. * Expose the active geology sampler to patterns when the generated dimension @@ -18,8 +28,9 @@ Version 4.1.0.110021 profiles while retaining the historical merge behavior by default. * Guarantee geology column sampling does not load or generate neighbouring chunks. -* Preserve API major 1, schemas 4/6/5, existing pattern binaries, profiles, - migration behaviour and generated chunks. +* Preserve API major 1, provider schemas 1-4, existing pattern binaries, + migration behaviour and generated chunks while advancing active schemas to + provider 5, global 7 and world profile 6. Version 4.0.16.110021 diff --git a/README.md b/README.md index 81738d29..ae4f82b2 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,9 @@ palettes and world materials, flat bedrock, and bounded ore retrogen. This branch builds target-qualified version `4.1.0.110021`: the OreSpawn 4.1.0 feature set for Minecraft 1.10.2 and Forge. It adds stable generation identity, non-loading geology sampling, per-resource background control, existing-world -provider merge policy and a client world-settings extension point for add-ons -such as Realistic Deposits while retaining API major 1. See the +provider merge policy, an Ore Sources arbiter for equivalent provider ores and +a client world-settings extension point for add-ons such as Realistic Deposits +while retaining API major 1. See the [versioning policy](docs/VERSIONS.md) for the encoding and release convention. Its deprecated compatibility layer imports OreSpawn 1 and OreSpawn 3 @@ -57,6 +58,14 @@ 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. +On the ORES tab, **Ore Sources...** identifies configured ores that represent +the same material. New worlds consolidate only reviewed, high-confidence MMD +conflicts, so several sulfur providers do not automatically create several +independent sulfur budgets. Existing worlds start in **Keep Separate** mode and +retain their old frequency and output behaviour until their owner explicitly +changes a policy. Third-party generators remain outside OreSpawn's control and +are reported rather than disabled. + Important files: | Location | Purpose | diff --git a/docs/API.md b/docs/API.md index ed2a10c6..95084448 100644 --- a/docs/API.md +++ b/docs/API.md @@ -52,8 +52,9 @@ coverage with `OreDefinition.Builder.dimensionSelector(...)` and `OreDimensionSelector.ALL_EXCEPT_NETHER_AND_END`. Explicit dimensions override that selector and prevent duplicate placement. -The builder emits provider schema 4. Legacy provider schemas 1-3 remain -readable. Schema 4 is required for biome palettes and dimension materials. +The builder emits provider schema 5. Legacy provider schemas 1-4 remain +readable. Schema 4 is required for biome palettes and dimension materials; +schema 5 adds ore material identity and independent placement channels. Provider-owned fluid deposits are declarative and may target several dimensions: @@ -91,6 +92,32 @@ exclusions. These methods work on both explicit `.dimension(...)` rules and `.dimensionSelector(...)` fallbacks; built definitions and their returned filter sets are immutable. +Declare the canonical material separately from the output block when equivalent +ores may be supplied by several mods: + +```java +ResourceLocation sulfur = new ResourceLocation("orespawn", "sulfur"); +ResourceLocation standard = new ResourceLocation("orespawn", "standard"); + +WorldgenProvider provider = WorldgenProvider.builder("examplemod", 2) + .ore(new ResourceLocation("examplemod", "ore/sulfur"), ore -> ore + .material(sulfur) + .dimension(new ResourceLocation("minecraft", "overworld"), placement -> placement + .placementChannel(standard) + .yRange(0, 48) + .attempts(4.0) + .quantity(8) + .pattern(OrePattern.VEIN) + .hostTag(new ResourceLocation("forge", "stone")))) + .build(); +``` + +`OreDefinition.material()` is optional. Built-in patterns default their +`OreDimensionDefinition.placementChannel()` to `orespawn:standard`; a custom +pattern defaults to its registered pattern-type ID. Explicit channels keep an +ordinary vein budget independent from a region-scale deposit budget while the +material group may still share the same selectable outputs. + Create one `BiomeRegistrar` during normal mod construction. It attaches to the calling mod's event bus and defers biome factories until Forge's biome registry event. `OreSpawnBiomes.copyAndRegister` clones a known biome without adding a @@ -177,6 +204,12 @@ patterns must independently render only the slice intersecting the current chunk and continue to use `inside(...)` and `tryPlace(...)` for safe writes. Do not retain the sampler or placement context beyond the current call. +For a body that intersects several chunks, call +`tryPlace(x, y, z, stableBodyIdentity)` instead. This Java 8 default overload +preserves the original three-coordinate method and therefore existing pattern +binaries. OreSpawn uses the supplied identity to choose one output source for +the complete body rather than independently selecting an output per chunk. + Large-deposit add-ons may make one OreSpawn rule the controller for a resource in a dimension. Set `.backgroundGenerationScale(value)` on that rule to scale other OreSpawn-managed rules and vanilla generation for the same primary @@ -185,6 +218,11 @@ fully suppresses background generation, and the lowest value wins when several controllers target the same resource. Vanilla decisions use a stable hash of world, dimension, chunk and resource rather than mutable event order. +When a rule declares `material`, background scaling applies only to that +material's `orespawn:standard` channel; it does not scale another custom +placement engine. Undeclared legacy rules retain the existing block-based +background behaviour. + Providers whose entries capture structural configuration can call `.mergeNewEntriesIntoExistingWorlds(false)`. New worlds still receive the complete provider, while an existing saved world profile does not silently gain diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 2649879a..061e31d8 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` | 7 | Installed-pack defaults for new worlds | +| `/serverconfig/orespawn-worldgen.json` | 6 | 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 7, world 6, 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,7 @@ 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_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 | @@ -161,8 +163,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: @@ -173,6 +178,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 | @@ -203,6 +209,38 @@ 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_source_policies` key combines the canonical material with an exact +dimension or existing dimension-selector ID. A policy stores `mode` as +`consolidated` or `keep_separate`, selected output rule IDs with positive +weights, and one placement-source rule ID per placement channel. Consolidation +runs the chosen placement rule's pattern, hosts, filters, height and frequency +once; selected candidates contribute output bundles only. 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. + +New worlds consolidate only reviewed high-confidence MMD catalog conflicts. +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 8fbf88eb..b435417a 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 1d691406..0e1cc101 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -117,17 +117,25 @@ 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-6 and world schemas 1-5 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 7 and world schema 6 persist +`ore_source_policies`. A new world may consolidate only reviewed MMD conflicts; +every discovered conflict in an upgraded world is initialized as +`keep_separate`, preserving prior placement frequency, ordering and output +behaviour. Missing selected sources 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 c0682804..d0e7c9ee 100644 --- a/docs/PLAYER_GUIDE.md +++ b/docs/PLAYER_GUIDE.md @@ -46,6 +46,9 @@ 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** finds configured ores that represent the same material and + lets you consolidate their placement without losing the installed block + choices. It is available from the ORES tab beside **Add Block**. - **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 +83,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 533803b3..fe7eedd3 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,28 @@ 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. + +The reviewed automatic priorities are Mineralogy, then BaseMinerals, then +ElectricAdvantage for sulfur; and BaseMinerals, then ElectricAdvantage for +lithium. 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. + +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 From 4cbe3b592e2192049dfa91f4a8650a89c0cc42b2 Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Wed, 16 Sep 2026 15:15:20 +0100 Subject: [PATCH 11/31] Redesign Ore Sources around material groups --- CHANGELOG.txt | 18 +- README.md | 17 +- build.gradle | 7 +- docs/API.md | 14 + docs/CONFIGURATION.md | 43 +- docs/MIGRATION.md | 17 +- docs/PLAYER_GUIDE.md | 11 +- docs/PROVIDERS.md | 15 +- docs/examples/orespawn-global.json | 12 +- docs/examples/orespawn-world.json | 12 +- docs/schemas/orespawn-global.schema.json | 2 +- docs/schemas/orespawn-world.schema.json | 2 +- docs/schemas/profile-common.schema.json | 22 +- .../orespawn/client/ClientProbeTestMod.java | 57 +++ .../orespawn/client/GeologyEditorSession.java | 457 +++++++++++++++++- .../client/OreSourceDetailScreen.java | 233 --------- .../orespawn/client/OreSourceListScreen.java | 415 +++++++++++++--- .../client/OreSpawnWorldSettingsScreen.java | 6 + .../mc/orespawn/worldgen/GeomeConfig.java | 32 +- .../orespawn/worldgen/OreMaterialGroups.java | 251 ++++++++++ .../orespawn/worldgen/OreSourcePolicies.java | 207 +++++--- .../worldgen/OreSpawnOreGeneration.java | 118 +++-- .../worldgen/WorldGeologyProfile.java | 8 +- .../resources/assets/orespawn/lang/de_AU.lang | 32 ++ .../resources/assets/orespawn/lang/de_DE.lang | 32 ++ .../resources/assets/orespawn/lang/en_CA.lang | 32 ++ .../resources/assets/orespawn/lang/en_EN.lang | 32 ++ .../resources/assets/orespawn/lang/en_GB.lang | 32 ++ .../resources/assets/orespawn/lang/en_PT.lang | 32 ++ .../resources/assets/orespawn/lang/en_US.lang | 32 ++ .../resources/assets/orespawn/lang/es_ES.lang | 32 ++ .../resources/assets/orespawn/lang/es_MX.lang | 32 ++ .../resources/assets/orespawn/lang/fr_CA.lang | 32 ++ .../resources/assets/orespawn/lang/fr_FR.lang | 32 ++ .../resources/assets/orespawn/lang/ja_JP.lang | 32 ++ .../resources/assets/orespawn/lang/ko_KR.lang | 32 ++ .../resources/assets/orespawn/lang/pt_BR.lang | 32 ++ .../resources/assets/orespawn/lang/ru_RU.lang | 32 ++ .../resources/assets/orespawn/lang/zh_CN.lang | 32 ++ .../mc/orespawn/LocalizationParityTest.java | 2 +- .../client/GeologyEditorSessionTest.java | 123 +++++ .../client/OreSpawnScreenLayoutTest.java | 36 +- .../worldgen/OreSourcePoliciesTest.java | 165 ++++++- .../worldgen/WorldGeologyProfileTest.java | 23 + 44 files changed, 2363 insertions(+), 474 deletions(-) delete mode 100644 src/main/java/zone/moddev/mc/orespawn/client/OreSourceDetailScreen.java create mode 100644 src/main/java/zone/moddev/mc/orespawn/worldgen/OreMaterialGroups.java diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 5f537830..e10ba3e4 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -6,10 +6,20 @@ Version 4.1.0.110021 upgraded worlds retain their previous separate placement and output behaviour. * Select one weighted output for an entire ordinary vein or custom deposit body using stable world/deposit identity, including safe missing-mod restoration. -* Add paginated Ore Sources list and detail screens for mode, placement-source - and output-weight control without persisting changes until the main editor's Done. +* Replace the technical Ore Sources list/detail flow with one responsive + material-group editor for friendly names, exact Ore Dictionary aliases and + selectable ore blocks. It uses side-by-side panes when wide and list/detail + pages at compact resolutions. +* Add Keep Original, Balanced, Single and Custom output modes. Reviewed fresh + MMD conflicts balance every eligible output through one placement budget; + inactive provider definitions remain selectable outputs without contributing + another placement rule. +* Ship curated Sulfur/Sulphur and Aluminum/Aluminium groups, keep Niter and + Saltpeter distinct, deduplicate identical registry states, and allow confirmed + alias reassignment plus stable custom groups. Group edits are saved to the + world and future-world defaults only when the main editor's Done is accepted. * Add provider schema 5 material and placement-channel declarations, global - schema 7 and world-profile schema 6 while retaining older readable schemas. + schema 8 and world-profile schema 7 while retaining older readable schemas. * Add an extended, binary-compatible ore generation context exposing stable world seed, dimension and current-chunk identity to compiled pattern add-ons. * Expose the active geology sampler to patterns when the generated dimension @@ -30,7 +40,7 @@ Version 4.1.0.110021 chunks. * Preserve API major 1, provider schemas 1-4, existing pattern binaries, migration behaviour and generated chunks while advancing active schemas to - provider 5, global 7 and world profile 6. + provider 5, global 8 and world profile 7. Version 4.0.16.110021 diff --git a/README.md b/README.md index ae4f82b2..d2bbfc2f 100644 --- a/README.md +++ b/README.md @@ -58,13 +58,16 @@ 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. -On the ORES tab, **Ore Sources...** identifies configured ores that represent -the same material. New worlds consolidate only reviewed, high-confidence MMD -conflicts, so several sulfur providers do not automatically create several -independent sulfur budgets. Existing worlds start in **Keep Separate** mode and -retain their old frequency and output behaviour until their owner explicitly -changes a policy. Third-party generators remain outside OreSpawn's control and -are reported rather than disabled. +On the ORES tab, **Ore Sources...** opens a material-group editor. Its initial +**Needs Attention** list keeps ordinary one-source materials out of the way; +**All Groups** reveals everything. A group has a friendly name, exact Ore +Dictionary aliases, and the installed ore blocks that may represent it. New +worlds balance all eligible outputs in reviewed MMD conflicts while running one +placement budget, so sulfur/sulphur providers do not multiply abundance. +**Single** chooses one output, **Custom** allows a weighted subset, and **Keep +Original** preserves every active rule. Existing worlds start in Keep Original +until their owner explicitly changes a group. Third-party generators remain +outside OreSpawn's control and are clearly reported rather than disabled. Important files: diff --git a/build.gradle b/build.gradle index 7a3bcca2..ee87396d 100644 --- a/build.gradle +++ b/build.gradle @@ -675,6 +675,7 @@ 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('long_editor_roundtrip') != 'true' || Integer.parseInt(result.getProperty('editor_routes', '0')) < 5) { throw new GradleException("Client integration result is incomplete: ${marker}") @@ -1003,10 +1004,10 @@ 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 = 7') + .getText('UTF-8').contains('SCHEMA_VERSION = 8') || !file('src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java') - .getText('UTF-8').contains('SCHEMA_VERSION = 6')) { - throw new GradleException('Global/world schema versions must remain 7/6') + .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).containsAll([4, 5])) { diff --git a/docs/API.md b/docs/API.md index 95084448..accc84f0 100644 --- a/docs/API.md +++ b/docs/API.md @@ -118,6 +118,20 @@ pattern defaults to its registered pattern-type ID. Explicit channels keep an ordinary vein budget independent from a region-scale deposit budget while the material group may still share the same selectable outputs. +Provider definitions are also the authoritative managed-output inventory. A +disabled provider ore remains available as an output-only candidate, but it +cannot own frequency, shape or placement. This is how a sibling mod can offer +its sulfur block without multiplying sulfur generation. Ore Dictionary-only +third-party blocks may be selected as outputs, but OreSpawn never claims to +disable their independent generators. + +Global schema 8 and world-profile schema 7 persist `ore_material_groups` and +the output mode chosen for each policy. These are profile configuration, not +additional public Java descriptors. Existing provider and pattern binaries +continue to use the same API-major-1 methods. A compiled custom pattern should +pass its stable deposit identity to `tryPlace(x, y, z, outputIdentity)` so the +selected material output remains constant throughout a body and across chunks. + Create one `BiomeRegistrar` during normal mod construction. It attaches to the calling mod's event bus and defers biome factories until Forge's biome registry event. `OreSpawnBiomes.copyAndRegister` clones a known biome without adding a diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 061e31d8..9801d284 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -4,8 +4,8 @@ OreSpawn uses three JSON contracts: | File | Schema | Purpose | |---|---:|---| -| `config/orespawn-worldgen.json` | 7 | Installed-pack defaults for new worlds | -| `/serverconfig/orespawn-worldgen.json` | 6 | Self-contained snapshot for one world | +| `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 5 at `assets//orespawn/provider.json`. @@ -22,7 +22,7 @@ world. Restart after editing JSON by hand. | Field | Values | Meaning | |---|---|---| -| `schema_version` | Contract-specific integer | Global 7, world 6, provider 5 | +| `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 | @@ -37,6 +37,7 @@ 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 | @@ -219,16 +220,32 @@ 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_source_policies` key combines the canonical material with an exact -dimension or existing dimension-selector ID. A policy stores `mode` as -`consolidated` or `keep_separate`, selected output rule IDs with positive -weights, and one placement-source rule ID per placement channel. Consolidation -runs the chosen placement rule's pattern, hosts, filters, height and frequency -once; selected candidates contribute output bundles only. 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. - -New worlds consolidate only reviewed high-confidence MMD catalog conflicts. +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 and delete custom groups, confirm moving an alias +from another group, or reset a curated group to its shipped 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**. + +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 diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index 0e1cc101..c2ee05c7 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -117,7 +117,7 @@ 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-6 and world schemas 1-5 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. @@ -129,12 +129,15 @@ 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 7 and world schema 6 persist -`ore_source_policies`. A new world may consolidate only reviewed MMD conflicts; -every discovered conflict in an upgraded world is initialized as -`keep_separate`, preserving prior placement frequency, ordering and output -behaviour. Missing selected sources remain recorded. Policy changes affect new -chunks only and do not add retrogen. +`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 diff --git a/docs/PLAYER_GUIDE.md b/docs/PLAYER_GUIDE.md index d0e7c9ee..ecbadff1 100644 --- a/docs/PLAYER_GUIDE.md +++ b/docs/PLAYER_GUIDE.md @@ -46,9 +46,14 @@ 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** finds configured ores that represent the same material and - lets you consolidate their placement without losing the installed block - choices. It is available from the ORES tab beside **Add Block**. +- **Ore Sources** opens the material-group editor beside **Add Block**. It + starts on **Needs Attention**; use **All Groups** for harmless single-source + materials. The left side edits the friendly group name and exact Ore + Dictionary aliases. The right side selects installed ore blocks: **Balanced** + shares them equally, **Single** uses one, **Custom** enables a weighted + subset, and **Keep Original** preserves every old placement rule. At narrow + resolutions the same editor uses separate list and selected-group pages. + Advanced placement-channel choices stay collapsed unless needed. - **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 diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index fe7eedd3..46ee066c 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -120,14 +120,21 @@ 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. -The reviewed automatic priorities are Mineralogy, then BaseMinerals, then -ElectricAdvantage for sulfur; and BaseMinerals, then ElectricAdvantage for -lithium. No other conflict is automatically consolidated without an explicit -catalog row. Exact Ore Dictionary `oreX` inference recognizes the curated +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 diff --git a/docs/examples/orespawn-global.json b/docs/examples/orespawn-global.json index dd3740a5..a6b9f2d9 100644 --- a/docs/examples/orespawn-global.json +++ b/docs/examples/orespawn-global.json @@ -1,5 +1,5 @@ { - "schema_version": 7, + "schema_version": 8, "geology_mode": "geome", "place_fluid_deposits": true, "manage_vanilla_ores": false, @@ -32,6 +32,16 @@ "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": { diff --git a/docs/examples/orespawn-world.json b/docs/examples/orespawn-world.json index ca099c74..f23002ca 100644 --- a/docs/examples/orespawn-world.json +++ b/docs/examples/orespawn-world.json @@ -1,5 +1,5 @@ { - "schema_version": 6, + "schema_version": 7, "geology_mode": "geome", "place_fluid_deposits": true, "manage_vanilla_ores": false, @@ -70,6 +70,16 @@ "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": { diff --git a/docs/schemas/orespawn-global.schema.json b/docs/schemas/orespawn-global.schema.json index df82afa9..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": 7 } } + "properties": { "schema_version": { "const": 8 } } } ] } diff --git a/docs/schemas/orespawn-world.schema.json b/docs/schemas/orespawn-world.schema.json index aebd4bde..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": 6 } } + "properties": { "schema_version": { "const": 7 } } } ] } diff --git a/docs/schemas/profile-common.schema.json b/docs/schemas/profile-common.schema.json index 9e3827e9..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", "ore_source_policies", "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,7 @@ "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" } }, @@ -246,24 +247,37 @@ "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" } + "review_required": { "type": "boolean" }, + "material_declared": { "type": "boolean" } }, "additionalProperties": false }, "oreSourcePolicy": { "type": "object", - "required": ["material", "domain", "mode", "outputs", "placement_sources", "candidates"], + "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"] } + "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 }, 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 d91b5695..513d6585 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; @@ -51,6 +52,8 @@ 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 longEditorRoundTrip; @Mod.EventHandler @@ -236,9 +239,62 @@ private void validateExpectedDirectory(GuiScreen root) { } } } + if (expected.contains("mineralogy") && expected.contains("electricadvantage")) { + validateExpectedOreSources((OreSpawnWorldSettingsScreen) root); + } directoryStackValidated = true; } + private void validateExpectedOreSources(OreSpawnWorldSettingsScreen root) { + GeologyEditorSession.OreSourceGroup sulfur = null; + for (GeologyEditorSession.OreSourceGroup group : editorSession(root).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); + } + 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); + } + oreSourcesValidated = true; + } + + 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; @@ -393,6 +449,7 @@ 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("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/client/GeologyEditorSession.java b/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java index c7cd89fb..b0d8bf62 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; @@ -353,6 +355,13 @@ List oreSourceGroups() { 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")) { @@ -373,25 +382,219 @@ List oreSourceGroups() { string(candidate, "owner", ""), string(candidate, "owner_name", ""), string(candidate, "owner_version", ""), registryId, integer(candidate, "metadata", 0), channel, dictionary, - bool(candidate, "loaded", false), bool(candidate, "external", false), - bool(candidate, "enrichment", false))); + 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); }); - result.add(new OreSourceGroup(entry.getKey(), material, domain, - string(policy, "mode", "keep_separate"), string(policy, "status", "review_required"), - candidates, decimalMap(policy, "outputs"), stringMap(policy, "placement_sources"))); + OreSourceGroup group = new OreSourceGroup(entry.getKey(), material, domain, + displayName, groupDictionary, curated, + string(policy, "mode", "keep_separate"), outputMode(policy), + string(policy, "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 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; + 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(); + } + + void deleteOreMaterialGroup(String material) { + if (!validResource(material) || "orespawn:sulfur".equals(material) + || "orespawn:aluminum".equals(material)) return; + section("ore_material_groups").remove(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); + } + } + rebuildOreSourcePolicies(); + } + + boolean oreMaterialGroupsChanged() { + return !sectionCopy(original, "ore_material_groups").equals(sectionCopy(root, "ore_material_groups")); + } + + JsonObject oreMaterialGroupsCopy() { + return sectionCopy(root, "ore_material_groups"); + } + + 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 candidates = new LinkedHashMap<>(); + for (Entry entry : policies.entrySet()) { + if (!entry.getValue().isJsonObject()) continue; + JsonObject policy = entry.getValue().getAsJsonObject(); + 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 = string(candidate, "source_id", "") + '|' + + string(candidate, "placement_channel", "orespawn:standard") + '|' + + string(candidate, "domain", string(policy, "domain", "")); + candidates.put(identity, candidate); + } + 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(); + for (JsonObject candidate : entry.getValue()) array.add(candidate); + policy.add("candidates", array); + JsonObject outputs = objectEntry(policy, "outputs"); + if (outputs.entrySet().isEmpty()) for (JsonObject candidate : outputCandidateJson(policy)) { + if (!bool(candidate, "external", false)) { + outputs.addProperty(string(candidate, "source_id", ""), 1.0D); + } + } + JsonObject placements = objectEntry(policy, "placement_sources"); + for (JsonObject candidate : entry.getValue()) { + if (bool(candidate, "placement_active", false) && !bool(candidate, "external", false)) { + String channel = string(candidate, "placement_channel", "orespawn:standard"); + if (!placements.has(channel)) placements.addProperty(channel, + string(candidate, "source_id", "")); + } + } + policy.addProperty("review_required", outputCandidateJson(policy).size() > 1); + refreshOreSourceStatus(policy); + policies.add(entry.getKey(), policy); + } + } + + 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" : ""; + } + void setOreSourceMode(String key, boolean consolidated) { JsonObject policy = objectEntry(section("ore_source_policies"), key); policy.addProperty("mode", consolidated ? "consolidated" : "keep_separate"); @@ -399,6 +602,47 @@ void setOreSourceMode(String key, boolean consolidated) { 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); + 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); @@ -409,20 +653,56 @@ void setOreSourcePlacement(String key, String channel, String sourceId) { 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 outputs.addProperty(sourceId, Math.max(0.001D, Math.min(1000000.0D, weight))); + 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 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(); - if (!bool(candidate, "loaded", false)) missing.add(string(candidate, "source_id", "")); + 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); } } @@ -437,7 +717,9 @@ private static void refreshOreSourceStatus(JsonObject policy) { if (missing.contains(id)) { policy.addProperty("status", "missing_source"); return; } } for (String id : placements.values()) { - if (missing.contains(id)) { policy.addProperty("status", "missing_source"); return; } + 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")) @@ -470,19 +752,28 @@ 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 mode, String status, + 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; @@ -491,9 +782,44 @@ static final class OreSourceGroup { List channels() { Set result = new TreeSet<>(); - for (OreSourceCandidate candidate : candidates) if (!candidate.external) result.add(candidate.channel); + 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 "review_required".equals(status) || "missing_source".equals(status) + || "external_generation".equals(status) || outputCandidates().size() > 1; + } + + 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 { @@ -506,12 +832,14 @@ static final class OreSourceCandidate { 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 external, boolean enrichment) { + boolean loaded, boolean active, boolean external, boolean enrichment, boolean reviewRequired) { this.sourceId = sourceId; this.owner = owner; this.ownerName = ownerName; @@ -521,8 +849,10 @@ static final class OreSourceCandidate { 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; } } @@ -1004,6 +1334,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()) { @@ -1131,6 +1463,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); @@ -1361,6 +1749,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/OreSourceDetailScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceDetailScreen.java deleted file mode 100644 index 82aba479..00000000 --- a/src/main/java/zone/moddev/mc/orespawn/client/OreSourceDetailScreen.java +++ /dev/null @@ -1,233 +0,0 @@ -package zone.moddev.mc.orespawn.client; - -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -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; - -/** Edits one material/domain policy inside the pending world-settings session. */ -final class OreSourceDetailScreen extends OreSpawnScreen { - private final GuiScreen parent; - private final GeologyEditorSession session; - private final String groupKey; - private final Map weights = new LinkedHashMap<>(); - private int channelIndex; - private int page; - private int pageSize; - private String error; - - OreSourceDetailScreen(GuiScreen parent, GeologyEditorSession session, String groupKey) { - super(new TextComponentTranslation("screen.orespawn.ore_source_detail")); - this.parent = parent; - this.session = session; - this.groupKey = groupKey; - } - - @Override - protected void init() { - OreSpawnScreenLayout.beginHelp(this); - weights.clear(); - OreSourceGroup group = group(); - if (group == null) { - addButton(new Button(width / 2 - 75, height - 28, 150, 20, - DialogTexts.GUI_DONE, button -> onClose())); - return; - } - int contentWidth = Math.min(390, Math.max(240, width - 24)); - int left = (width - contentWidth) / 2; - int half = (contentWidth - 5) / 2; - Button mode = addButton(OreSpawnScreenLayout.button(this, font, left, 34, half, 20, - new TextComponentTranslation("option.orespawn.ore_source.mode", - new TextComponentTranslation("mode.orespawn.ore_source." + group.mode).getFormattedText()), - button -> { - syncWeights(); - session.setOreSourceMode(groupKey, !"consolidated".equals(group().mode)); - rebuild(); - })); - OreSpawnScreenLayout.explain(this, mode, "tooltip.orespawn.ore_source.mode"); - - List channels = group.channels(); - if (channels.isEmpty()) channels = java.util.Collections.singletonList("orespawn:standard"); - channelIndex = Math.max(0, Math.min(channelIndex, channels.size() - 1)); - final List channelValues = channels; - Button channel = addButton(OreSpawnScreenLayout.button(this, font, left + half + 5, 34, half, 20, - new TextComponentTranslation("option.orespawn.ore_source.channel", - display(channelValues.get(channelIndex))), button -> { - syncWeights(); - channelIndex = (channelIndex + 1) % channelValues.size(); - rebuild(); - })); - OreSpawnScreenLayout.explain(this, channel, "tooltip.orespawn.ore_source.channel"); - - String selectedChannel = channelValues.get(channelIndex); - List placementCandidates = candidatesForChannel(group, selectedChannel); - String selected = group.placements.get(selectedChannel); - OreSourceCandidate selectedCandidate = candidate(group.candidates, selected); - int selectedIndex = indexOf(placementCandidates, selected); - if (selectedIndex < 0 && !placementCandidates.isEmpty()) selectedIndex = 0; - final int currentSelection = selectedIndex; - Button placement = addButton(OreSpawnScreenLayout.button(this, font, left, 58, contentWidth, 20, - new TextComponentTranslation("option.orespawn.ore_source.placement", - selectedCandidate != null && !selectedCandidate.loaded - ? candidateLabel(selectedCandidate) - : selectedIndex < 0 ? I18n.format("label.orespawn.ore_source.missing") - : candidateLabel(placementCandidates.get(selectedIndex))), button -> { - syncWeights(); - if (!placementCandidates.isEmpty()) { - OreSourceCandidate next = placementCandidates.get((currentSelection + 1) - % placementCandidates.size()); - session.setOreSourcePlacement(groupKey, selectedChannel, next.sourceId); - rebuild(); - } - })); - placement.enabled = !placementCandidates.isEmpty(); - OreSpawnScreenLayout.explain(this, placement, "tooltip.orespawn.ore_source.placement"); - - int listTop = 86; - int controlsY = height - 52; - pageSize = Math.max(1, (controlsY - listTop) / 24); - int pages = OreSourceListScreen.pageCount(group.candidates.size(), pageSize); - page = Math.max(0, Math.min(page, pages - 1)); - int start = page * pageSize; - for (int index = 0; index < pageSize && start + index < group.candidates.size(); index++) { - OreSourceCandidate candidate = group.candidates.get(start + index); - int y = listTop + index * 24; - boolean enabled = group.outputs.containsKey(candidate.sourceId); - Button toggle = addButton(new Button(left, y, contentWidth - 92, 20, - OreSpawnScreenLayout.fit(font, new TextComponentString( - (enabled ? "[x] " : "[ ] ") + candidateLabel(candidate)), contentWidth - 100), - button -> { - syncWeights(); - double current = group().outputs.containsKey(candidate.sourceId) - ? group().outputs.get(candidate.sourceId) : 1.0D; - session.setOreSourceOutput(groupKey, candidate.sourceId, - !group().outputs.containsKey(candidate.sourceId), current); - rebuild(); - }, (button, mouseX, mouseY) -> renderStringTooltip(candidateTooltip(candidate), mouseX, mouseY))); - toggle.enabled = !candidate.external && !candidate.enrichment && candidate.loaded; - TextFieldWidget weight = addButton(new TextFieldWidget(font, left + contentWidth - 87, y, 87, 20, - new TextComponentTranslation("option.orespawn.ore_source.weight"))); - weight.setMaxLength(12); - weight.setValue(formatWeight(group.outputs.get(candidate.sourceId))); - weight.enabled = enabled && !candidate.external && !candidate.enrichment && candidate.loaded; - weights.put(candidate.sourceId, weight); - OreSpawnScreenLayout.explain(this, weight, "tooltip.orespawn.ore_source.weight"); - } - - Button previous = addButton(new Button(left, controlsY, 60, 20, new TextComponentString("<"), - button -> { syncWeights(); page--; rebuild(); })); - Button next = addButton(new Button(left + contentWidth - 60, controlsY, 60, 20, - new TextComponentString(">"), button -> { syncWeights(); page++; rebuild(); })); - previous.enabled = page > 0; - next.enabled = page + 1 < pages; - addButton(new Button(width / 2 - 75, height - 28, 150, 20, - DialogTexts.GUI_DONE, button -> onClose())); - } - - private void syncWeights() { - OreSourceGroup group = group(); - if (group == null) return; - error = null; - 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(groupKey, entry.getKey(), true, value); - } catch (NumberFormatException invalid) { - error = I18n.format("error.orespawn.ore_source.weight"); - } - } - } - - private OreSourceGroup group() { - for (OreSourceGroup value : session.oreSourceGroups()) if (groupKey.equals(value.key)) return value; - return null; - } - - private static List candidatesForChannel(OreSourceGroup group, String channel) { - List result = new ArrayList<>(); - for (OreSourceCandidate candidate : group.candidates) { - if (!candidate.external && !candidate.enrichment && candidate.loaded - && channel.equals(candidate.channel)) result.add(candidate); - } - return result; - } - - private static OreSourceCandidate candidate(List candidates, String sourceId) { - if (sourceId == null) return null; - for (OreSourceCandidate candidate : candidates) { - if (sourceId.equals(candidate.sourceId)) return candidate; - } - return null; - } - - private 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; - } - - private static String candidateLabel(OreSourceCandidate candidate) { - String owner = candidate.ownerName.isEmpty() ? candidate.owner : candidate.ownerName; - return owner + " - " + candidate.registryId + (candidate.loaded ? "" - : " (" + I18n.format("status.orespawn.ore_source.missing_source") + ")"); - } - - private 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.enrichment ? "tooltip.orespawn.ore_source.enrichment" - : candidate.loaded ? "tooltip.orespawn.ore_source.controlled" - : "tooltip.orespawn.ore_source.missing")); - return result; - } - - private 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()); - } - - private static String display(String id) { - int split = id.indexOf(':'); - return (split < 0 ? id : id.substring(split + 1)).replace('_', ' '); - } - - private void rebuild() { buttons.clear(); children.clear(); init(); } - - @Override - public void onClose() { - syncWeights(); - if (error == null) minecraft.displayGuiScreen(parent); - } - - @Override - public void render(int mouseX, int mouseY, float partialTick) { - renderBackground(); - OreSourceGroup group = group(); - drawCenteredString(font, title, width / 2, 8, 0xFFFFFF); - if (group != null) drawCenteredString(font, - new TextComponentString(display(group.material) + " - " + display(group.domain)), - width / 2, 20, 0xA0A0A0); - if (error != null) drawCenteredString(font, new TextComponentString(error), width / 2, - height - 64, 0xFF5555); - super.render(mouseX, mouseY, partialTick); - OreSpawnScreenLayout.renderExplanations(this, 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 index ee2582db..33036870 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/OreSourceListScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceListScreen.java @@ -1,7 +1,11 @@ package zone.moddev.mc.orespawn.client; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import zone.moddev.mc.orespawn.client.GeologyEditorSession.OreSourceCandidate; import zone.moddev.mc.orespawn.client.GeologyEditorSession.OreSourceGroup; import net.minecraft.client.gui.GuiScreen; @@ -9,15 +13,25 @@ import net.minecraft.util.text.TextComponentString; import net.minecraft.util.text.TextComponentTranslation; -/** Height-aware directory of material/domain source conflicts. */ +/** Responsive master/detail editor for material groups and their output blocks. */ final class OreSourceListScreen extends OreSpawnScreen { - private static final int ROW_HEIGHT = 38; + private static final int WIDE_MINIMUM = 520; + private static final int ROW_HEIGHT = 22; private final GuiScreen parent; private final GeologyEditorSession session; - private int page; - private int pageSize; - private int contentLeft; - private int contentWidth; + private final Map weights = new LinkedHashMap<>(); + private boolean showAll; + private boolean compactDetail; + private boolean advanced; + private int groupPage; + private int outputPage; + private int aliasIndex; + private int channelIndex; + private String selectedKey; + private String pendingAliasMove; + private String error; + private TextFieldWidget nameField; + private TextFieldWidget aliasField; OreSourceListScreen(GuiScreen parent, GeologyEditorSession session) { super(new TextComponentTranslation("screen.orespawn.ore_sources")); @@ -28,75 +42,368 @@ final class OreSourceListScreen extends OreSpawnScreen { @Override protected void init() { OreSpawnScreenLayout.beginHelp(this); - List groups = session.oreSourceGroups(); - contentWidth = Math.min(390, Math.max(240, width - 24)); - contentLeft = (width - contentWidth) / 2; - int listTop = 34; - int controlsY = height - 52; - pageSize = Math.max(1, (controlsY - listTop) / ROW_HEIGHT); - int pages = pageCount(groups.size(), pageSize); - page = Math.max(0, Math.min(page, pages - 1)); - int start = page * pageSize; - for (int index = 0; index < pageSize && start + index < groups.size(); index++) { + weights.clear(); + nameField = null; + aliasField = null; + List groups = visibleGroups(); + if (selected(groups) == null && !groups.isEmpty()) selectedKey = groups.get(0).key; + boolean compact = compact(width); + int footer = height - 28; + if (compact && !compactDetail) { + initDirectory(12, 30, width - 24, footer - 34, groups, true); + } else if (compact) { + initCompactDetail(12, 28, width - 24, footer - 32); + } else { + int contentWidth = Math.min(700, width - 20); + int left = (width - contentWidth) / 2; + int leftWidth = Math.max(190, (contentWidth * 2) / 5); + initDirectory(left, 28, leftWidth, footer - 32, groups, false); + initOutputs(left + leftWidth + 8, 28, contentWidth - leftWidth - 8, footer - 32); + } + if (compact && compactDetail) { + addButton(new Button(12, footer, 92, 20, + new TextComponentTranslation("button.orespawn.back"), button -> { + syncFields(); compactDetail = false; rebuild(false); + })); + addButton(new Button(width - 162, footer, 150, 20, + DialogTexts.GUI_DONE, button -> onClose())); + } else { + addButton(new Button(width / 2 - 75, footer, 150, 20, + DialogTexts.GUI_DONE, button -> onClose())); + } + } + + private void initDirectory(int left, int top, int paneWidth, int bottom, + List groups, boolean compactLayout) { + int half = (paneWidth - 5) / 2; + addButton(OreSpawnScreenLayout.button(this, font, left, top, half, 20, + new TextComponentTranslation(showAll ? "button.orespawn.ore_source.needs_attention" + : "button.orespawn.ore_source.all_groups"), button -> { + showAll = !showAll; groupPage = 0; selectedKey = null; rebuild(true); + })); + addButton(OreSpawnScreenLayout.button(this, font, left + half + 5, top, half, 20, + new TextComponentTranslation("button.orespawn.ore_source.add_group"), button -> { + syncFields(); selectedKey = session.addOreMaterialGroup(); showAll = true; + compactDetail = compact(width); groupPage = 0; rebuild(false); + })); + + int listTop = top + 24; + int editorReserve = compactLayout ? 24 : 94; + int rows = Math.max(1, Math.min(5, (bottom - listTop - editorReserve - 24) / ROW_HEIGHT)); + int pages = pageCount(groups.size(), rows); + groupPage = Math.max(0, Math.min(groupPage, pages - 1)); + int start = groupPage * rows; + for (int index = 0; index < rows && start + index < groups.size(); index++) { OreSourceGroup group = groups.get(start + index); - int y = listTop + index * ROW_HEIGHT; - addButton(new Button(contentLeft, y, contentWidth, 34, - OreSpawnScreenLayout.fit(font, new TextComponentString(rowLabel(group)), contentWidth - 8), - button -> minecraft.displayGuiScreen(new OreSourceDetailScreen(this, session, group.key)), - (button, mouseX, mouseY) -> renderStringTooltip(tooltip(group), mouseX, mouseY))); + int y = listTop + (index * ROW_HEIGHT); + String prefix = group.key.equals(selectedKey) ? "> " : ""; + String label = prefix + group.displayName + " - " + display(group.domain); + Button row = addButton(new Button(left, y, paneWidth, 20, + OreSpawnScreenLayout.fit(font, new TextComponentString(label), paneWidth - 8), button -> { + syncFields(); selectedKey = group.key; compactDetail = compact(width); + outputPage = 0; aliasIndex = 0; error = null; rebuild(false); + }, (button, mouseX, mouseY) -> renderStringTooltip(groupTooltip(group), mouseX, mouseY))); + row.enabled = !group.key.equals(selectedKey) || compactLayout; + } + int navigationY = listTop + (rows * ROW_HEIGHT); + Button previous = addButton(new Button(left, navigationY, 48, 20, + new TextComponentString("<"), button -> { syncFields(); groupPage--; rebuild(false); })); + Button next = addButton(new Button(left + paneWidth - 48, navigationY, 48, 20, + new TextComponentString(">"), button -> { syncFields(); groupPage++; rebuild(false); })); + previous.enabled = groupPage > 0; + next.enabled = groupPage + 1 < pages; + if (!compactLayout) initGroupEditor(left, navigationY + 24, paneWidth, bottom); + } + + private void initCompactDetail(int left, int top, int paneWidth, int bottom) { + OreSourceGroup group = selected(session.oreSourceGroups()); + if (group == null) return; + initGroupEditor(left, top, paneWidth, top + 70); + initOutputs(left, top + 74, paneWidth, bottom); + } + + private void initGroupEditor(int left, int top, int paneWidth, int bottom) { + OreSourceGroup group = selected(session.oreSourceGroups()); + if (group == null || top + 20 > bottom) return; + int actionWidth = 58; + nameField = addButton(new TextFieldWidget(font, left, top, paneWidth - actionWidth - 5, 20, + new TextComponentTranslation("option.orespawn.ore_source.group_name"))); + nameField.setMaxLength(64); + nameField.setValue(group.displayName); + Button groupAction = addButton(OreSpawnScreenLayout.button(this, font, + left + paneWidth - actionWidth, top, actionWidth, 20, + new TextComponentTranslation(group.curated ? "button.orespawn.reset" + : "button.orespawn.ore_source.delete_group"), button -> { + if (group.curated) session.resetOreMaterialGroup(group.material); + else { session.deleteOreMaterialGroup(group.material); selectedKey = null; } + rebuild(false); + })); + OreSpawnScreenLayout.explain(this, groupAction, group.curated + ? "tooltip.orespawn.ore_source.reset_group" : "tooltip.orespawn.ore_source.delete_group"); + if (top + 44 > bottom) return; + List aliases = group.oreDictionaryEntries; + aliasIndex = aliases.isEmpty() ? 0 : Math.max(0, Math.min(aliasIndex, aliases.size() - 1)); + String alias = aliases.isEmpty() ? I18n.format("label.orespawn.ore_source.no_aliases") + : aliases.get(aliasIndex); + Button aliasButton = addButton(new Button(left, top + 24, paneWidth - 46, 20, + OreSpawnScreenLayout.fit(font, new TextComponentString(alias), paneWidth - 54), button -> { + if (!aliases.isEmpty()) { aliasIndex = (aliasIndex + 1) % aliases.size(); rebuild(false); } + })); + aliasButton.enabled = aliases.size() > 1; + Button remove = addButton(new Button(left + paneWidth - 41, top + 24, 41, 20, + new TextComponentString("-"), button -> { + if (!aliases.isEmpty()) session.removeOreMaterialAlias(group.material, aliases.get(aliasIndex)); + aliasIndex = 0; rebuild(false); + })); + remove.enabled = !aliases.isEmpty(); + if (top + 68 > bottom) return; + aliasField = addButton(new TextFieldWidget(font, left, top + 48, paneWidth - 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)); + } + Button add = addButton(new Button(left + paneWidth - 41, top + 48, 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"); + } + + private void initOutputs(int left, int top, int paneWidth, int bottom) { + OreSourceGroup group = selected(session.oreSourceGroups()); + if (group == null) return; + String mode = "keep_separate".equals(group.mode) ? "keep_original" : group.outputMode; + Button modeButton = addButton(OreSpawnScreenLayout.button(this, font, left, top, paneWidth, 20, + new TextComponentTranslation("option.orespawn.ore_source.output_mode", + new TextComponentTranslation("mode.orespawn.ore_source." + mode).getFormattedText()), button -> { + syncFields(); cycleMode(group); rebuild(false); + })); + OreSpawnScreenLayout.explain(this, modeButton, "tooltip.orespawn.ore_source.output_mode"); + + int advancedReserve = advanced ? 48 : 24; + int listTop = top + 24; + List candidates = group.outputCandidates(); + int rows = Math.max(1, (bottom - listTop - advancedReserve - 22) / ROW_HEIGHT); + int pages = pageCount(candidates.size(), rows); + outputPage = Math.max(0, Math.min(outputPage, pages - 1)); + int start = outputPage * rows; + for (int index = 0; index < rows && start + index < candidates.size(); index++) { + OreSourceCandidate candidate = candidates.get(start + index); + int y = listTop + (index * ROW_HEIGHT); + boolean selected = group.outputs.containsKey(candidate.sourceId); + int weightWidth = "custom".equals(group.outputMode) && !"keep_separate".equals(group.mode) ? 58 : 0; + Button toggle = addButton(new Button(left, y, paneWidth - weightWidth - (weightWidth == 0 ? 0 : 5), 20, + OreSpawnScreenLayout.fit(font, new TextComponentString((selected ? "[x] " : "[ ] ") + + candidateLabel(candidate)), paneWidth - weightWidth - 13), button -> { + syncFields(); session.setOreSourceOutput(group.key, candidate.sourceId, + !group.outputs.containsKey(candidate.sourceId), + group.outputs.containsKey(candidate.sourceId) ? group.outputs.get(candidate.sourceId) : 1.0D); + rebuild(false); + }, (button, mouseX, mouseY) -> renderStringTooltip(candidateTooltip(candidate), mouseX, mouseY))); + toggle.enabled = candidate.loaded && !candidate.enrichment && !"keep_separate".equals(group.mode); + if (weightWidth > 0) { + TextFieldWidget weight = addButton(new TextFieldWidget(font, left + paneWidth - weightWidth, + y, weightWidth, 20, new TextComponentTranslation("option.orespawn.ore_source.weight"))); + weight.setMaxLength(12); + weight.setValue(formatWeight(group.outputs.get(candidate.sourceId))); + weight.enabled = selected && candidate.loaded && !candidate.enrichment; + weights.put(candidate.sourceId, weight); + } + } + int navigationY = listTop + (rows * ROW_HEIGHT); + Button previous = addButton(new Button(left, navigationY, 40, 20, + new TextComponentString("<"), button -> { syncFields(); outputPage--; rebuild(false); })); + Button next = addButton(new Button(left + paneWidth - 40, navigationY, 40, 20, + new TextComponentString(">"), button -> { syncFields(); outputPage++; rebuild(false); })); + previous.enabled = outputPage > 0; + next.enabled = outputPage + 1 < pages; + Button advancedButton = addButton(OreSpawnScreenLayout.button(this, font, + left + 45, navigationY, paneWidth - 90, 20, + new TextComponentTranslation(advanced ? "button.orespawn.ore_source.hide_advanced" + : "button.orespawn.ore_source.advanced"), button -> { + syncFields(); advanced = !advanced; rebuild(false); + })); + if (advanced) initAdvanced(group, left, navigationY + 24, paneWidth); + } + + private void initAdvanced(OreSourceGroup group, int left, int y, int paneWidth) { + List channels = group.channels(); + if (channels.isEmpty()) return; + channelIndex = Math.max(0, Math.min(channelIndex, channels.size() - 1)); + String channel = channels.get(channelIndex); + int half = (paneWidth - 5) / 2; + addButton(OreSpawnScreenLayout.button(this, font, left, y, half, 20, + new TextComponentString(display(channel)), button -> { + channelIndex = (channelIndex + 1) % channels.size(); rebuild(false); + })); + List placements = placementCandidates(group, channel); + String selected = group.placements.get(channel); + int index = indexOf(placements, selected); + if (index < 0 && !placements.isEmpty()) index = 0; + final int selectedIndex = index; + Button placement = addButton(OreSpawnScreenLayout.button(this, font, left + half + 5, y, half, 20, + new TextComponentString(index < 0 ? I18n.format("label.orespawn.ore_source.missing") + : candidateLabel(placements.get(index))), button -> { + if (!placements.isEmpty()) { + OreSourceCandidate next = placements.get((selectedIndex + 1) % placements.size()); + session.setOreSourcePlacement(group.key, channel, next.sourceId); rebuild(false); + } + })); + placement.enabled = !placements.isEmpty(); + } + + private void cycleMode(OreSourceGroup group) { + 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.setOreSourceMode(group.key, false); + } + + 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 && !move.equals(pendingAliasMove)) { + pendingAliasMove = move; + error = I18n.format("error.orespawn.ore_source.alias_owned", display(owner)); + rebuild(false); + return; + } + if (!session.addOreMaterialAlias(group.material, alias, move != null)) { + error = I18n.format("error.orespawn.ore_source.alias"); + return; + } + pendingAliasMove = null; + error = null; + aliasIndex = 0; + rebuild(false); + } + + private void syncFields() { + syncName(); + OreSourceGroup group = selected(session.oreSourceGroups()); + if (group == null || !"custom".equals(group.outputMode)) return; + 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"); + } } - Button previous = addButton(new Button(contentLeft, controlsY, 80, 20, - new TextComponentTranslation("button.orespawn.previous"), - button -> { page--; rebuild(); })); - Button next = addButton(new Button(contentLeft + contentWidth - 80, controlsY, 80, 20, - new TextComponentTranslation("button.orespawn.next"), - button -> { page++; rebuild(); })); - previous.enabled = page > 0; - next.enabled = page + 1 < pages; - addButton(new Button(width / 2 - 75, height - 28, 150, 20, - DialogTexts.GUI_DONE, button -> onClose())); - } - - private String rowLabel(OreSourceGroup group) { - return I18n.format("label.orespawn.ore_source.row", display(group.material), display(group.domain), - group.candidates.size(), I18n.format("status.orespawn.ore_source." + group.status)); - } - - private java.util.List tooltip(OreSourceGroup group) { - java.util.List result = new java.util.ArrayList<>(); - result.add(I18n.format("label.orespawn.ore_source.material", group.material)); - result.add(I18n.format("label.orespawn.ore_source.domain", group.domain)); - result.add(I18n.format("label.orespawn.ore_source.channels", group.channels().size())); - result.add(I18n.format("tooltip.orespawn.ore_source.open")); + } + + private void syncName() { + OreSourceGroup group = selected(session.oreSourceGroups()); + if (group != null && nameField != null) session.renameOreMaterialGroup(group.material, nameField.getValue()); + } + + private List visibleGroups() { + List all = session.oreSourceGroups(); + if (showAll) return all; + List result = new ArrayList<>(); + for (OreSourceGroup group : all) if (group.needsAttention()) result.add(group); return result; } + private OreSourceGroup selected(List groups) { + if (selectedKey == null) return null; + for (OreSourceGroup group : groups) if (selectedKey.equals(group.key)) return group; + return null; + } + + private 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; + } + + private 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; + } + + private 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 + ")"); + } + + private 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; + } + + private 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)); + return result; + } + + private 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()); + } + private static String display(String id) { int split = id.indexOf(':'); - String value = split < 0 ? id : id.substring(split + 1); - return value.replace('_', ' '); + String value = (split < 0 ? id : id.substring(split + 1)).replace('_', ' ').replace('/', ' '); + return value.isEmpty() ? value : Character.toUpperCase(value.charAt(0)) + value.substring(1); } - private void rebuild() { - buttons.clear(); children.clear(); init(); + private void rebuild(boolean clearError) { + if (clearError) error = null; + buttons.clear(); + children.clear(); + init(); } + static boolean compact(int width) { return width < WIDE_MINIMUM; } + 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); } + public void onClose() { + syncFields(); + if (error == null) minecraft.displayGuiScreen(parent); + } @Override public void render(int mouseX, int mouseY, float partialTick) { renderBackground(); - drawCenteredString(font, title, width / 2, 12, 0xFFFFFF); - List groups = session.oreSourceGroups(); + drawCenteredString(font, title, width / 2, 8, 0xFFFFFF); + if (error != null) drawCenteredString(font, new TextComponentString( + OreSpawnScreenLayout.fit(font, new TextComponentString(error), width - 24)), width / 2, 19, 0xFF5555); + List groups = visibleGroups(); if (groups.isEmpty()) drawCenteredString(font, - new TextComponentTranslation("label.orespawn.ore_source.none"), width / 2, height / 2, 0xA0A0A0); - drawCenteredString(font, new TextComponentString((page + 1) + " / " + pageCount(groups.size(), pageSize)), - width / 2, height - 46, 0xA0A0A0); + new TextComponentTranslation(showAll ? "label.orespawn.ore_source.none" + : "label.orespawn.ore_source.none_attention"), width / 2, height / 2, 0xA0A0A0); super.render(mouseX, mouseY, partialTick); OreSpawnScreenLayout.renderExplanations(this, mouseX, mouseY); } 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..23432817 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); } 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 76f786be..a906dc4f 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeConfig.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeConfig.java @@ -64,6 +64,9 @@ public final class GeomeConfig { 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", @@ -73,7 +76,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 = 7; + 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; @@ -165,6 +168,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; } @@ -197,14 +217,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 = migrateToV7(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 : schemaVersion == 5 ? CONFIG_V5_BACKUP_PATH - : CONFIG_V6_BACKUP_PATH); + : schemaVersion == 6 ? CONFIG_V6_BACKUP_PATH + : CONFIG_V7_BACKUP_PATH); return migrated; } if (schemaVersion > SCHEMA_VERSION) { @@ -667,7 +688,7 @@ private static JsonObject migrateV1(JsonObject original) { return refreshBiomeDefaults(migrated, defaultConfig()); } - private static JsonObject migrateToV7(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); @@ -675,7 +696,7 @@ private static JsonObject migrateToV7(JsonObject original, JsonObject defaults) "manage_vanilla_ores", "ore_defaults_revision", "cyano", "ores", "ore_providers", "providers", "worldgen_aliases", "default_template", "terrain_dimensions", "biome_palettes", "dimension_materials", - OreSourcePolicies.SECTION }) { + OreMaterialGroups.SECTION, OreSourcePolicies.SECTION }) { if (!migrated.has(key)) { migrated.add(key, JsonCopies.copy(defaults.get(key))); } @@ -1419,6 +1440,7 @@ 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()); 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..cb46674c --- /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.getResourcePath())); + 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.getResourcePath()), 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.getResourcePath())); + 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.getResourcePath()))); + 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 index 89292d3c..d0f2e053 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSourcePolicies.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSourcePolicies.java @@ -34,7 +34,10 @@ final class OreSourcePolicies { 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 ResourceLocation REVIEW = new ResourceLocation("orespawn", "review_required"); + 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", @@ -47,13 +50,18 @@ private OreSourcePolicies() { } * 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); - boolean changed = !hadPolicies; + 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()) { - if (!group.needsPolicy()) continue; String key = key(group.material, group.domain); JsonObject previous = policies.has(key) && policies.get(key).isJsonObject() ? policies.getAsJsonObject(key) : null; @@ -91,21 +99,27 @@ static Snapshot snapshot(JsonObject root) { 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); - views.add(new GroupView(entry.getKey(), material, domain, - string(policy, "mode", MODE_SEPARATE), status(policy, candidates), candidates, + 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) || !group.needsPolicy()) continue; + if (seen.contains(key)) continue; JsonObject policy = createPolicy(group, true); - views.add(new GroupView(key, group.material, group.domain, MODE_SEPARATE, + 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"))); } @@ -120,6 +134,12 @@ static ResourceLocation material(JsonObject ore, ItemStack output) { 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; @@ -135,28 +155,14 @@ static ResourceLocation placementChannel(JsonObject rule) { } static Inference inferMaterial(Iterable oreNames) { - Set materials = new LinkedHashSet<>(); - List exact = new ArrayList<>(); - for (String name : oreNames) { - if (name == null || name.length() <= 3 || !name.startsWith("ore") - || !Character.isUpperCase(name.charAt(3))) continue; - exact.add(name); - String token = name.substring(3).toLowerCase(Locale.ROOT); - if ("sulphur".equals(token)) token = "sulfur"; - if ("aluminium".equals(token)) token = "aluminum"; - materials.add(token); - } - Collections.sort(exact); - if (materials.size() == 1) { - String token = materials.iterator().next(); - if (!token.matches("[a-z0-9_./-]+")) return new Inference(REVIEW, true, exact); - try { - return new Inference(new ResourceLocation("orespawn", token), false, exact); - } catch (RuntimeException invalidDictionaryName) { - return new Inference(REVIEW, true, exact); - } - } - return new Inference(materials.size() > 1 ? REVIEW : null, materials.size() > 1, exact); + 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) { @@ -165,18 +171,21 @@ static String key(ResourceLocation material, ResourceLocation domain) { 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(); - if (!bool(ore, "enabled", true)) continue; + 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(oreNames(stack)); + 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); @@ -184,36 +193,39 @@ private static Map discover(JsonObject root) { 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); + 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); + resource(selector.getKey()), selector.getValue(), inferred, role, + oreActive, materialDeclared); } } - addExternalDictionaryCandidates(groups); + 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) { - if (domain == null || !ruleElement.isJsonObject() - || !bool(ruleElement.getAsJsonObject(), "enabled", true)) return; + 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, false, - role == Role.ENRICHMENT, inferred.reviewRequired); + 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) { + private static void addExternalDictionaryCandidates(Map groups, JsonObject root) { if (groups.isEmpty()) return; Map> namesByMaterial = new HashMap<>(); for (String name : safeOreNames()) { - Inference inference = inferMaterial(Collections.singletonList(name)); + Inference inference = inferMaterial(root, Collections.singletonList(name)); if (inference.material != null && !inference.reviewRequired) { namesByMaterial.computeIfAbsent(inference.material, ignored -> new ArrayList<>()).add(name); } @@ -234,7 +246,8 @@ private static void addExternalDictionaryCandidates(Map groups) { 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), true, false, false)); + Collections.singletonList(oreName), loaded(owner), false, true, false, false, + false)); } } } @@ -247,9 +260,12 @@ private static JsonObject createPolicy(Group group, boolean existingWorld) { 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(); - if (automatic) outputs.addProperty(preferred.sourceId, 1.0D); - else for (Candidate candidate : group.configured()) outputs.addProperty(candidate.sourceId, 1.0D); + 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()) { @@ -272,9 +288,12 @@ private static JsonObject refreshPolicy(JsonObject previous, Group group) { 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.configured()) outputs.addProperty(candidate.sourceId, 1.0D); + 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()) { @@ -289,6 +308,19 @@ private static JsonObject refreshPolicy(JsonObject previous, Group group) { 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; @@ -308,7 +340,7 @@ private static String status(JsonObject policy, List candidates) { } for (String selected : placements.values()) { Candidate candidate = current.get(selected); - if (candidate == null || !candidate.loaded) return "missing_source"; + 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"; @@ -368,14 +400,15 @@ private static Candidate candidate(JsonElement element, boolean defaultLoaded) { return new Candidate(string(json, "source_id", ""), string(json, "owner", block.getResourceDomain()), string(json, "owner_name", ""), string(json, "owner_version", ""), block, integer(json, "metadata", 0), material, domain, channel, names, - bool(json, "loaded", defaultLoaded), bool(json, "external", false), - bool(json, "enrichment", false), bool(json, "review_required", false)); + 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.getResourcePath()); - if (priority == null || group.configured().size() < 2 || group.hasExternal()) return false; - for (Candidate candidate : group.configured()) if (!priority.contains(candidate.owner)) return false; + 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; } @@ -552,19 +585,28 @@ 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 mode, - String status, List candidates, Map outputs, + 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)); @@ -584,14 +626,16 @@ static final class Candidate { 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 external, - boolean enrichment, boolean reviewRequired) { + 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; @@ -603,16 +647,21 @@ static final class Candidate { 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, external, enrichment, reviewRequired); + domain, channel, oreNames, false, false, external, enrichment, reviewRequired, + materialDeclared); } JsonObject toJson() { @@ -630,9 +679,11 @@ JsonObject toJson() { 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; } } @@ -659,13 +710,33 @@ boolean hasBlock(ResourceLocation block, int metadata) { return false; } - List configured() { + 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); @@ -679,22 +750,22 @@ boolean hasExternal() { boolean reviewRequired() { for (Candidate value : candidates) if (value.reviewRequired) return true; - return configured().size() > 1 && !highConfidence(this); - } - - boolean needsPolicy() { - return configured().size() > 1 || hasExternal() || hasEnrichment() - || reviewRequiredCandidate(); + return hasManagedConflict() && !highConfidence(this); } - private boolean hasEnrichment() { - for (Candidate value : candidates) if (value.enrichment) return true; + 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; } - private boolean reviewRequiredCandidate() { - for (Candidate value : candidates) if (value.reviewRequired) 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 053e1809..fe67fa47 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGeneration.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGeneration.java @@ -313,21 +313,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"); @@ -347,7 +343,10 @@ private static BakedOres bakeOres(JsonObject profile, BakedGeomeConfig config) { BakedOutput[] outputs = bakeOutputs(oreJson, outputState); ResourceLocation ruleId = resource(oreEntry.getKey()); if (ruleId == null) ruleId = oreId; - ResourceLocation material = OreSourcePolicies.material(oreJson, + 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, @@ -423,7 +422,7 @@ private static BakedOres bakeOres(JsonObject profile, BakedGeomeConfig config) { } } } - combined = arbitrate(profile, combined); + 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)); @@ -444,7 +443,7 @@ private static BakedOres bakeOres(JsonObject profile, BakedGeomeConfig config) { rule.material, rule.selector.backgroundGenerationScale); } } - selectorList = arbitrate(profile, selectorList); + selectorList = arbitrate(profile, selectorList, outputBundles); BakedOre[] selectorResult = selectorList.toArray(new BakedOre[selectorList.size()]); LOGGER.info("Baked {} OreSpawn-managed ore definitions across {} dimensions", rules.size(), result.size()); @@ -461,7 +460,8 @@ private static BakedOres bakeOres(JsonObject profile, BakedGeomeConfig config) { Collections.unmodifiableMap(selectorMaterialScales)); } - private static List arbitrate(JsonObject profile, List original) { + private static List arbitrate(JsonObject profile, List original, + Map outputBundles) { if (!profile.has(OreSourcePolicies.SECTION) || !profile.get(OreSourcePolicies.SECTION).isJsonObject()) return original; JsonObject policies = profile.getAsJsonObject(OreSourcePolicies.SECTION); @@ -480,11 +480,13 @@ private static List arbitrate(JsonObject profile, List origi if (!"consolidated".equals(string(policy, "mode", "keep_separate"))) continue; Map weights = positiveWeights(objectOrEmpty(policy, "outputs")); List sources = new ArrayList<>(); - Set sourceIds = new HashSet<>(); - for (BakedOre candidate : entry.getValue()) { - Double weight = weights.get(candidate.ruleId.toString()); - if (weight != null && sourceIds.add(candidate.ruleId)) { - sources.add(new BakedSource(candidate, weight.doubleValue())); + Set sourceIds = new HashSet<>(); + for (Entry selectedOutput : weights.entrySet()) { + BakedOutputBundle bundle = outputBundles.get(selectedOutput.getKey()); + if (bundle == null) bundle = candidateBundle(policy, selectedOutput.getKey()); + if (bundle != null && sourceIds.add(selectedOutput.getKey())) { + sources.add(new BakedSource(selectedOutput.getKey(), bundle, + selectedOutput.getValue().doubleValue())); } } if (sources.isEmpty()) { @@ -577,6 +579,25 @@ private static Map positiveWeights(JsonObject values) { return result; } + private static BakedOutputBundle candidateBundle(JsonObject policy, String sourceId) { + if (!policy.has("candidates") || !policy.get("candidates").isJsonArray()) return null; + for (JsonElement element : policy.getAsJsonArray("candidates")) { + if (!element.isJsonObject()) continue; + JsonObject candidate = element.getAsJsonObject(); + if (!sourceId.equals(string(candidate, "source_id", "")) + || !bool(candidate, "loaded", false) + || bool(candidate, "enrichment", false)) continue; + ResourceLocation id = resource(string(candidate, "registry_id", "")); + Block block = id == null ? null : ForgeRegistries.BLOCKS.getValue(id); + if (block == null || block == Blocks.AIR) return null; + IBlockState state = state(block, integer(candidate, "metadata", 0)); + return new BakedOutputBundle(state, state, -1, + new BakedOutput[] { new BakedOutput(state, 1.0D, + Integer.MIN_VALUE, Integer.MAX_VALUE) }); + } + return null; + } + private static void reportBakeProblem(String message, Object... arguments) { // Loading invokes provisional bakes before the server's registries and // data-pack tags are authoritative. Keep diagnostics available at debug @@ -908,6 +929,7 @@ private static final class BakedOre { final boolean retrogen; final boolean backgroundController; final double backgroundGenerationScale; + final BakedOutputBundle outputBundle; final BakedSource[] arbitrationSources; BakedOre(ResourceLocation ruleId, ResourceLocation material, boolean materialDeclared, @@ -976,6 +998,7 @@ private BakedOre(ResourceLocation ruleId, ResourceLocation material, boolean mat this.retrogen = retrogen; this.backgroundController = backgroundController; this.backgroundGenerationScale = backgroundGenerationScale; + this.outputBundle = new BakedOutputBundle(output, deepOutput, deepOutputMaxY, outputs); this.arbitrationSources = arbitrationSources; } @@ -989,31 +1012,17 @@ BakedOre withSources(List sources) { backgroundGenerationScale, sources.toArray(new BakedSource[sources.size()])); } - BakedOre outputSource(long worldSeed, ResourceLocation dimension, long identity) { - if (arbitrationSources.length == 0) return this; + BakedOutputBundle outputSource(long worldSeed, ResourceLocation dimension, long identity) { + if (arbitrationSources.length == 0) return outputBundle; double total = 0.0D; for (BakedSource source : arbitrationSources) total += source.weight; double choice = weightedChoice(outputSelectionSample( worldSeed, dimension, material, channel, identity), total); for (BakedSource source : arbitrationSources) { choice -= source.weight; - if (choice < 0.0D) return source.ore; + if (choice < 0.0D) return source.bundle; } - return arbitrationSources[arbitrationSources.length - 1].ore; - } - - IBlockState outputAt(int y, Random random) { - if (y <= deepOutputMaxY) return deepOutput; - double total = 0.0D; - for (BakedOutput candidate : outputs) if (candidate.acceptsY(y)) total += candidate.weight; - if (total <= 0.0D) return output; - double choice = random.nextDouble() * total; - for (BakedOutput candidate : outputs) { - if (!candidate.acceptsY(y)) continue; - choice -= candidate.weight; - if (choice <= 0.0D) return candidate.state; - } - return output; + return arbitrationSources[arbitrationSources.length - 1].bundle; } boolean accepts(IBlockState state, Random random, BakedGeomeConfig config) { @@ -1058,15 +1067,46 @@ private static final class BakedOreRule { } private static final class BakedSource { - final BakedOre ore; + final String sourceId; + final BakedOutputBundle bundle; final double weight; - BakedSource(BakedOre ore, double weight) { - this.ore = ore; + BakedSource(String sourceId, BakedOutputBundle bundle, double weight) { + this.sourceId = sourceId; + this.bundle = bundle; this.weight = weight; } } + private static final class BakedOutputBundle { + final IBlockState output; + final IBlockState deepOutput; + final int deepOutputMaxY; + final BakedOutput[] outputs; + + BakedOutputBundle(IBlockState output, IBlockState deepOutput, int deepOutputMaxY, + BakedOutput[] outputs) { + this.output = output; + this.deepOutput = deepOutput; + this.deepOutputMaxY = deepOutputMaxY; + this.outputs = outputs; + } + + IBlockState outputAt(int y, Random random) { + if (y <= deepOutputMaxY) return deepOutput; + double total = 0.0D; + for (BakedOutput candidate : outputs) if (candidate.acceptsY(y)) total += candidate.weight; + if (total <= 0.0D) return output; + double choice = random.nextDouble() * total; + for (BakedOutput candidate : outputs) { + if (!candidate.acceptsY(y)) continue; + choice -= candidate.weight; + if (choice <= 0.0D) return candidate.state; + } + return output; + } + } + private static final class BakedOutput { final IBlockState state; final double weight; @@ -1166,8 +1206,8 @@ private static final class PatternContext implements OreGenerationContext { private int minY; private int maxY; private int quantity; - private BakedOre ordinaryOutput; - private BakedOre identifiedOutput; + private BakedOutputBundle ordinaryOutput; + private BakedOutputBundle identifiedOutput; private long identifiedOutputIdentity; private boolean hasIdentifiedOutput; @@ -1255,7 +1295,7 @@ public boolean tryPlace(int x, int y, int z, long outputIdentity) { return tryPlaceWithOutput(x, y, z, identifiedOutput); } - private boolean tryPlaceWithOutput(int x, int y, int z, BakedOre outputSource) { + private boolean tryPlaceWithOutput(int x, int y, int z, BakedOutputBundle outputSource) { if (!inside(x, y, z)) return false; cursor.setPos(x, y, z); IBlockState existing = world == null ? chunk.getBlockState(cursor) : world.getBlockState(cursor); diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java index c958b094..c07a7f2e 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java @@ -22,7 +22,7 @@ /** A complete, self-contained snapshot of the geology settings for one world. */ public final class WorldGeologyProfile { - public static final int SCHEMA_VERSION = 6; + public static final int SCHEMA_VERSION = 7; private static final Logger LOGGER = LogManager.getLogger(); @@ -39,6 +39,7 @@ public final class WorldGeologyProfile { private WorldGeologyProfile(JsonObject root, GeologyMode fallbackMode, boolean fallbackFluidDeposits) { this.root = JsonCopies.copy(root); FluidDepositMigration.normalize(this.root); + OreMaterialGroups.initialize(this.root); if (!this.root.has(OreSourcePolicies.SECTION) || !this.root.get(OreSourcePolicies.SECTION).isJsonObject()) { this.root.add(OreSourcePolicies.SECTION, new JsonObject()); @@ -89,7 +90,7 @@ public static WorldGeologyProfile fromJson(JsonObject json, WorldGeologyProfile if (schema >= 2) { JsonObject migrated = JsonCopies.copy(json); for (String key : new String[] { "terrain_dimensions", "providers", - "biome_palettes", "dimension_materials" }) { + "biome_palettes", "dimension_materials", OreMaterialGroups.SECTION }) { if (!migrated.has(key) && fallback.root.has(key)) { migrated.add(key, JsonCopies.copy(fallback.root.get(key))); } @@ -270,7 +271,8 @@ public int generationRevision() { return configured; } int hash = 17; - for (String key : new String[] { "ores", "ore_source_policies", "fluid_deposits", + for (String key : new String[] { "ores", OreMaterialGroups.SECTION, + "ore_source_policies", "fluid_deposits", "flat_bedrock", "providers" }) { if (root.has(key)) { hash = (31 * hash) + root.get(key).toString().hashCode(); diff --git a/src/main/resources/assets/orespawn/lang/de_AU.lang b/src/main/resources/assets/orespawn/lang/de_AU.lang index 25ef8316..e06d6691 100644 --- a/src/main/resources/assets/orespawn/lang/de_AU.lang +++ b/src/main/resources/assets/orespawn/lang/de_AU.lang @@ -384,6 +384,10 @@ screen.orespawn.ore_sources=Erzquellen screen.orespawn.ore_source_detail=Erzquellenrichtlinie label.orespawn.ore_source.none=Keine gleichwertigen verwalteten Erzquellen gefunden. label.orespawn.ore_source.row=%s - %s - %s Kandidaten - %s +label.orespawn.ore_source.intro=Wähle, wie gleichwertige Erze generiert werden. +label.orespawn.ore_source.summary=%s Kandidaten - %s +button.orespawn.ore_source.review=Prüfen... +button.orespawn.ore_source.configure=Konfigurieren... label.orespawn.ore_source.material=Rohstoff: %s label.orespawn.ore_source.domain=Dimension/Bereich: %s label.orespawn.ore_source.channels=Platzierungskanäle: %s @@ -412,3 +416,31 @@ tooltip.orespawn.ore_source.controlled=OreSpawn steuert diese konfigurierte Plat tooltip.orespawn.ore_source.enrichment=Dieser Eintrag ist eine Anreicherung und keine austauschbare Erzquelle. tooltip.orespawn.ore_source.missing=Diese Quelle ist nicht verfügbar. Die gespeicherte Auswahl wird wiederhergestellt, wenn der Mod zurückkehrt. error.orespawn.ore_source.weight=Ausgabegewichte müssen positive Zahlen sein. +button.orespawn.back=Zurück +button.orespawn.ore_source.needs_attention=Zu prüfen +button.orespawn.ore_source.all_groups=Alle Gruppen +button.orespawn.ore_source.add_group=Gruppe hinzufügen +button.orespawn.ore_source.delete_group=Löschen +button.orespawn.ore_source.move=Verschieben +button.orespawn.ore_source.advanced=Erweitert... +button.orespawn.ore_source.hide_advanced=Erweitert ausblenden +option.orespawn.ore_source.group_name=Gruppenname +option.orespawn.ore_source.alias=Ore-Dictionary-Eintrag +option.orespawn.ore_source.output_mode=Ausgabe: %s +mode.orespawn.ore_source.keep_original=Original beibehalten +mode.orespawn.ore_source.balanced=Ausgewogen +mode.orespawn.ore_source.single=Einzeln +mode.orespawn.ore_source.custom=Benutzerdefiniert +label.orespawn.ore_source.no_aliases=Keine Ore-Dictionary-Einträge +label.orespawn.ore_source.none_attention=Keine Erzgruppen müssen geprüft werden. +status.orespawn.ore_source.external_short=extern +status.orespawn.ore_source.missing_short=fehlt +status.orespawn.ore_source.output_only=nur Ausgabe +tooltip.orespawn.ore_source.output_only=Diese Anbieterausgabe ist verfügbar, aber ihre Platzierungsregel ist inaktiv. +tooltip.orespawn.ore_source.output_mode=Original beibehalten erhält jede Regel. Ausgewogen verteilt gleichmäßig, Einzeln wählt einen Block und Benutzerdefiniert erlaubt gewichtete Auswahlen. +tooltip.orespawn.ore_source.alias=Füge einen exakten Ore-Dictionary-Erznamen hinzu. Drücke erneut Verschieben, um die Neuzuordnung aus einer anderen Gruppe zu bestätigen. +tooltip.orespawn.ore_source.reset_group=Stelle den Namen und die Ore-Dictionary-Einträge dieser kuratierten Gruppe wieder her. +tooltip.orespawn.ore_source.delete_group=Lösche diese benutzerdefinierte Gruppe, ohne erzeugte Chunks neu zu schreiben. +error.orespawn.ore_source.alias=Verwende einen exakten Ore-Dictionary-Erznamen wie oreSulfur. +error.orespawn.ore_source.alias_owned=%s besitzt diesen Eintrag bereits. Drücke Verschieben, um ihn neu zuzuweisen. +error.orespawn.ore_source.defaults_write=Die Standardwerte der Materialgruppen konnten nicht gespeichert werden. diff --git a/src/main/resources/assets/orespawn/lang/de_DE.lang b/src/main/resources/assets/orespawn/lang/de_DE.lang index 25ef8316..e06d6691 100644 --- a/src/main/resources/assets/orespawn/lang/de_DE.lang +++ b/src/main/resources/assets/orespawn/lang/de_DE.lang @@ -384,6 +384,10 @@ screen.orespawn.ore_sources=Erzquellen screen.orespawn.ore_source_detail=Erzquellenrichtlinie label.orespawn.ore_source.none=Keine gleichwertigen verwalteten Erzquellen gefunden. label.orespawn.ore_source.row=%s - %s - %s Kandidaten - %s +label.orespawn.ore_source.intro=Wähle, wie gleichwertige Erze generiert werden. +label.orespawn.ore_source.summary=%s Kandidaten - %s +button.orespawn.ore_source.review=Prüfen... +button.orespawn.ore_source.configure=Konfigurieren... label.orespawn.ore_source.material=Rohstoff: %s label.orespawn.ore_source.domain=Dimension/Bereich: %s label.orespawn.ore_source.channels=Platzierungskanäle: %s @@ -412,3 +416,31 @@ tooltip.orespawn.ore_source.controlled=OreSpawn steuert diese konfigurierte Plat tooltip.orespawn.ore_source.enrichment=Dieser Eintrag ist eine Anreicherung und keine austauschbare Erzquelle. tooltip.orespawn.ore_source.missing=Diese Quelle ist nicht verfügbar. Die gespeicherte Auswahl wird wiederhergestellt, wenn der Mod zurückkehrt. error.orespawn.ore_source.weight=Ausgabegewichte müssen positive Zahlen sein. +button.orespawn.back=Zurück +button.orespawn.ore_source.needs_attention=Zu prüfen +button.orespawn.ore_source.all_groups=Alle Gruppen +button.orespawn.ore_source.add_group=Gruppe hinzufügen +button.orespawn.ore_source.delete_group=Löschen +button.orespawn.ore_source.move=Verschieben +button.orespawn.ore_source.advanced=Erweitert... +button.orespawn.ore_source.hide_advanced=Erweitert ausblenden +option.orespawn.ore_source.group_name=Gruppenname +option.orespawn.ore_source.alias=Ore-Dictionary-Eintrag +option.orespawn.ore_source.output_mode=Ausgabe: %s +mode.orespawn.ore_source.keep_original=Original beibehalten +mode.orespawn.ore_source.balanced=Ausgewogen +mode.orespawn.ore_source.single=Einzeln +mode.orespawn.ore_source.custom=Benutzerdefiniert +label.orespawn.ore_source.no_aliases=Keine Ore-Dictionary-Einträge +label.orespawn.ore_source.none_attention=Keine Erzgruppen müssen geprüft werden. +status.orespawn.ore_source.external_short=extern +status.orespawn.ore_source.missing_short=fehlt +status.orespawn.ore_source.output_only=nur Ausgabe +tooltip.orespawn.ore_source.output_only=Diese Anbieterausgabe ist verfügbar, aber ihre Platzierungsregel ist inaktiv. +tooltip.orespawn.ore_source.output_mode=Original beibehalten erhält jede Regel. Ausgewogen verteilt gleichmäßig, Einzeln wählt einen Block und Benutzerdefiniert erlaubt gewichtete Auswahlen. +tooltip.orespawn.ore_source.alias=Füge einen exakten Ore-Dictionary-Erznamen hinzu. Drücke erneut Verschieben, um die Neuzuordnung aus einer anderen Gruppe zu bestätigen. +tooltip.orespawn.ore_source.reset_group=Stelle den Namen und die Ore-Dictionary-Einträge dieser kuratierten Gruppe wieder her. +tooltip.orespawn.ore_source.delete_group=Lösche diese benutzerdefinierte Gruppe, ohne erzeugte Chunks neu zu schreiben. +error.orespawn.ore_source.alias=Verwende einen exakten Ore-Dictionary-Erznamen wie oreSulfur. +error.orespawn.ore_source.alias_owned=%s besitzt diesen Eintrag bereits. Drücke Verschieben, um ihn neu zuzuweisen. +error.orespawn.ore_source.defaults_write=Die Standardwerte der Materialgruppen konnten nicht gespeichert werden. diff --git a/src/main/resources/assets/orespawn/lang/en_CA.lang b/src/main/resources/assets/orespawn/lang/en_CA.lang index 8afdb8f2..ff720396 100644 --- a/src/main/resources/assets/orespawn/lang/en_CA.lang +++ b/src/main/resources/assets/orespawn/lang/en_CA.lang @@ -384,6 +384,10 @@ screen.orespawn.ore_sources=Ore Sources screen.orespawn.ore_source_detail=Ore Source Policy label.orespawn.ore_source.none=No equivalent managed ore sources were found. label.orespawn.ore_source.row=%s - %s - %s candidates - %s +label.orespawn.ore_source.intro=Choose how equivalent ores generate. +label.orespawn.ore_source.summary=%s candidates - %s +button.orespawn.ore_source.review=Review... +button.orespawn.ore_source.configure=Configure... label.orespawn.ore_source.material=Material: %s label.orespawn.ore_source.domain=Dimension/domain: %s label.orespawn.ore_source.channels=Placement channels: %s @@ -412,3 +416,31 @@ tooltip.orespawn.ore_source.controlled=OreSpawn controls this configured placeme tooltip.orespawn.ore_source.enrichment=This entry is enrichment, not an interchangeable ore source. tooltip.orespawn.ore_source.missing=This source is unavailable. Its saved selection will be restored if the owning mod returns. error.orespawn.ore_source.weight=Output weights must be positive numbers. +button.orespawn.back=Back +button.orespawn.ore_source.needs_attention=Needs Attention +button.orespawn.ore_source.all_groups=All Groups +button.orespawn.ore_source.add_group=Add Group +button.orespawn.ore_source.delete_group=Delete +button.orespawn.ore_source.move=Move +button.orespawn.ore_source.advanced=Advanced... +button.orespawn.ore_source.hide_advanced=Hide Advanced +option.orespawn.ore_source.group_name=Group name +option.orespawn.ore_source.alias=Ore Dictionary entry +option.orespawn.ore_source.output_mode=Output: %s +mode.orespawn.ore_source.keep_original=Keep Original +mode.orespawn.ore_source.balanced=Balanced +mode.orespawn.ore_source.single=Single +mode.orespawn.ore_source.custom=Custom +label.orespawn.ore_source.no_aliases=No Ore Dictionary entries +label.orespawn.ore_source.none_attention=No ore groups need attention. +status.orespawn.ore_source.external_short=external +status.orespawn.ore_source.missing_short=missing +status.orespawn.ore_source.output_only=output only +tooltip.orespawn.ore_source.output_only=This provider output is available, but its placement rule is inactive. +tooltip.orespawn.ore_source.output_mode=Keep Original preserves every rule. Balanced shares equally, Single chooses one block, and Custom enables weighted choices. +tooltip.orespawn.ore_source.alias=Add an exact Ore Dictionary ore name. Press Move again to confirm reassignment from another group. +tooltip.orespawn.ore_source.reset_group=Restore this curated group name and Ore Dictionary entries. +tooltip.orespawn.ore_source.delete_group=Delete this custom group without rewriting generated chunks. +error.orespawn.ore_source.alias=Use an exact Ore Dictionary ore name such as oreSulfur. +error.orespawn.ore_source.alias_owned=%s already owns this entry. Press Move to reassign it. +error.orespawn.ore_source.defaults_write=Could not save material-group defaults. diff --git a/src/main/resources/assets/orespawn/lang/en_EN.lang b/src/main/resources/assets/orespawn/lang/en_EN.lang index 8afdb8f2..ff720396 100644 --- a/src/main/resources/assets/orespawn/lang/en_EN.lang +++ b/src/main/resources/assets/orespawn/lang/en_EN.lang @@ -384,6 +384,10 @@ screen.orespawn.ore_sources=Ore Sources screen.orespawn.ore_source_detail=Ore Source Policy label.orespawn.ore_source.none=No equivalent managed ore sources were found. label.orespawn.ore_source.row=%s - %s - %s candidates - %s +label.orespawn.ore_source.intro=Choose how equivalent ores generate. +label.orespawn.ore_source.summary=%s candidates - %s +button.orespawn.ore_source.review=Review... +button.orespawn.ore_source.configure=Configure... label.orespawn.ore_source.material=Material: %s label.orespawn.ore_source.domain=Dimension/domain: %s label.orespawn.ore_source.channels=Placement channels: %s @@ -412,3 +416,31 @@ tooltip.orespawn.ore_source.controlled=OreSpawn controls this configured placeme tooltip.orespawn.ore_source.enrichment=This entry is enrichment, not an interchangeable ore source. tooltip.orespawn.ore_source.missing=This source is unavailable. Its saved selection will be restored if the owning mod returns. error.orespawn.ore_source.weight=Output weights must be positive numbers. +button.orespawn.back=Back +button.orespawn.ore_source.needs_attention=Needs Attention +button.orespawn.ore_source.all_groups=All Groups +button.orespawn.ore_source.add_group=Add Group +button.orespawn.ore_source.delete_group=Delete +button.orespawn.ore_source.move=Move +button.orespawn.ore_source.advanced=Advanced... +button.orespawn.ore_source.hide_advanced=Hide Advanced +option.orespawn.ore_source.group_name=Group name +option.orespawn.ore_source.alias=Ore Dictionary entry +option.orespawn.ore_source.output_mode=Output: %s +mode.orespawn.ore_source.keep_original=Keep Original +mode.orespawn.ore_source.balanced=Balanced +mode.orespawn.ore_source.single=Single +mode.orespawn.ore_source.custom=Custom +label.orespawn.ore_source.no_aliases=No Ore Dictionary entries +label.orespawn.ore_source.none_attention=No ore groups need attention. +status.orespawn.ore_source.external_short=external +status.orespawn.ore_source.missing_short=missing +status.orespawn.ore_source.output_only=output only +tooltip.orespawn.ore_source.output_only=This provider output is available, but its placement rule is inactive. +tooltip.orespawn.ore_source.output_mode=Keep Original preserves every rule. Balanced shares equally, Single chooses one block, and Custom enables weighted choices. +tooltip.orespawn.ore_source.alias=Add an exact Ore Dictionary ore name. Press Move again to confirm reassignment from another group. +tooltip.orespawn.ore_source.reset_group=Restore this curated group name and Ore Dictionary entries. +tooltip.orespawn.ore_source.delete_group=Delete this custom group without rewriting generated chunks. +error.orespawn.ore_source.alias=Use an exact Ore Dictionary ore name such as oreSulfur. +error.orespawn.ore_source.alias_owned=%s already owns this entry. Press Move to reassign it. +error.orespawn.ore_source.defaults_write=Could not save material-group defaults. diff --git a/src/main/resources/assets/orespawn/lang/en_GB.lang b/src/main/resources/assets/orespawn/lang/en_GB.lang index 8afdb8f2..ff720396 100644 --- a/src/main/resources/assets/orespawn/lang/en_GB.lang +++ b/src/main/resources/assets/orespawn/lang/en_GB.lang @@ -384,6 +384,10 @@ screen.orespawn.ore_sources=Ore Sources screen.orespawn.ore_source_detail=Ore Source Policy label.orespawn.ore_source.none=No equivalent managed ore sources were found. label.orespawn.ore_source.row=%s - %s - %s candidates - %s +label.orespawn.ore_source.intro=Choose how equivalent ores generate. +label.orespawn.ore_source.summary=%s candidates - %s +button.orespawn.ore_source.review=Review... +button.orespawn.ore_source.configure=Configure... label.orespawn.ore_source.material=Material: %s label.orespawn.ore_source.domain=Dimension/domain: %s label.orespawn.ore_source.channels=Placement channels: %s @@ -412,3 +416,31 @@ tooltip.orespawn.ore_source.controlled=OreSpawn controls this configured placeme tooltip.orespawn.ore_source.enrichment=This entry is enrichment, not an interchangeable ore source. tooltip.orespawn.ore_source.missing=This source is unavailable. Its saved selection will be restored if the owning mod returns. error.orespawn.ore_source.weight=Output weights must be positive numbers. +button.orespawn.back=Back +button.orespawn.ore_source.needs_attention=Needs Attention +button.orespawn.ore_source.all_groups=All Groups +button.orespawn.ore_source.add_group=Add Group +button.orespawn.ore_source.delete_group=Delete +button.orespawn.ore_source.move=Move +button.orespawn.ore_source.advanced=Advanced... +button.orespawn.ore_source.hide_advanced=Hide Advanced +option.orespawn.ore_source.group_name=Group name +option.orespawn.ore_source.alias=Ore Dictionary entry +option.orespawn.ore_source.output_mode=Output: %s +mode.orespawn.ore_source.keep_original=Keep Original +mode.orespawn.ore_source.balanced=Balanced +mode.orespawn.ore_source.single=Single +mode.orespawn.ore_source.custom=Custom +label.orespawn.ore_source.no_aliases=No Ore Dictionary entries +label.orespawn.ore_source.none_attention=No ore groups need attention. +status.orespawn.ore_source.external_short=external +status.orespawn.ore_source.missing_short=missing +status.orespawn.ore_source.output_only=output only +tooltip.orespawn.ore_source.output_only=This provider output is available, but its placement rule is inactive. +tooltip.orespawn.ore_source.output_mode=Keep Original preserves every rule. Balanced shares equally, Single chooses one block, and Custom enables weighted choices. +tooltip.orespawn.ore_source.alias=Add an exact Ore Dictionary ore name. Press Move again to confirm reassignment from another group. +tooltip.orespawn.ore_source.reset_group=Restore this curated group name and Ore Dictionary entries. +tooltip.orespawn.ore_source.delete_group=Delete this custom group without rewriting generated chunks. +error.orespawn.ore_source.alias=Use an exact Ore Dictionary ore name such as oreSulfur. +error.orespawn.ore_source.alias_owned=%s already owns this entry. Press Move to reassign it. +error.orespawn.ore_source.defaults_write=Could not save material-group defaults. diff --git a/src/main/resources/assets/orespawn/lang/en_PT.lang b/src/main/resources/assets/orespawn/lang/en_PT.lang index 8afdb8f2..ff720396 100644 --- a/src/main/resources/assets/orespawn/lang/en_PT.lang +++ b/src/main/resources/assets/orespawn/lang/en_PT.lang @@ -384,6 +384,10 @@ screen.orespawn.ore_sources=Ore Sources screen.orespawn.ore_source_detail=Ore Source Policy label.orespawn.ore_source.none=No equivalent managed ore sources were found. label.orespawn.ore_source.row=%s - %s - %s candidates - %s +label.orespawn.ore_source.intro=Choose how equivalent ores generate. +label.orespawn.ore_source.summary=%s candidates - %s +button.orespawn.ore_source.review=Review... +button.orespawn.ore_source.configure=Configure... label.orespawn.ore_source.material=Material: %s label.orespawn.ore_source.domain=Dimension/domain: %s label.orespawn.ore_source.channels=Placement channels: %s @@ -412,3 +416,31 @@ tooltip.orespawn.ore_source.controlled=OreSpawn controls this configured placeme tooltip.orespawn.ore_source.enrichment=This entry is enrichment, not an interchangeable ore source. tooltip.orespawn.ore_source.missing=This source is unavailable. Its saved selection will be restored if the owning mod returns. error.orespawn.ore_source.weight=Output weights must be positive numbers. +button.orespawn.back=Back +button.orespawn.ore_source.needs_attention=Needs Attention +button.orespawn.ore_source.all_groups=All Groups +button.orespawn.ore_source.add_group=Add Group +button.orespawn.ore_source.delete_group=Delete +button.orespawn.ore_source.move=Move +button.orespawn.ore_source.advanced=Advanced... +button.orespawn.ore_source.hide_advanced=Hide Advanced +option.orespawn.ore_source.group_name=Group name +option.orespawn.ore_source.alias=Ore Dictionary entry +option.orespawn.ore_source.output_mode=Output: %s +mode.orespawn.ore_source.keep_original=Keep Original +mode.orespawn.ore_source.balanced=Balanced +mode.orespawn.ore_source.single=Single +mode.orespawn.ore_source.custom=Custom +label.orespawn.ore_source.no_aliases=No Ore Dictionary entries +label.orespawn.ore_source.none_attention=No ore groups need attention. +status.orespawn.ore_source.external_short=external +status.orespawn.ore_source.missing_short=missing +status.orespawn.ore_source.output_only=output only +tooltip.orespawn.ore_source.output_only=This provider output is available, but its placement rule is inactive. +tooltip.orespawn.ore_source.output_mode=Keep Original preserves every rule. Balanced shares equally, Single chooses one block, and Custom enables weighted choices. +tooltip.orespawn.ore_source.alias=Add an exact Ore Dictionary ore name. Press Move again to confirm reassignment from another group. +tooltip.orespawn.ore_source.reset_group=Restore this curated group name and Ore Dictionary entries. +tooltip.orespawn.ore_source.delete_group=Delete this custom group without rewriting generated chunks. +error.orespawn.ore_source.alias=Use an exact Ore Dictionary ore name such as oreSulfur. +error.orespawn.ore_source.alias_owned=%s already owns this entry. Press Move to reassign it. +error.orespawn.ore_source.defaults_write=Could not save material-group defaults. diff --git a/src/main/resources/assets/orespawn/lang/en_US.lang b/src/main/resources/assets/orespawn/lang/en_US.lang index 97b67a21..0f5e18ee 100644 --- a/src/main/resources/assets/orespawn/lang/en_US.lang +++ b/src/main/resources/assets/orespawn/lang/en_US.lang @@ -384,6 +384,10 @@ screen.orespawn.ore_sources=Ore Sources screen.orespawn.ore_source_detail=Ore Source Policy label.orespawn.ore_source.none=No equivalent managed ore sources were found. label.orespawn.ore_source.row=%s - %s - %s candidates - %s +label.orespawn.ore_source.intro=Choose how equivalent ores generate. +label.orespawn.ore_source.summary=%s candidates - %s +button.orespawn.ore_source.review=Review... +button.orespawn.ore_source.configure=Configure... label.orespawn.ore_source.material=Material: %s label.orespawn.ore_source.domain=Dimension/domain: %s label.orespawn.ore_source.channels=Placement channels: %s @@ -412,3 +416,31 @@ tooltip.orespawn.ore_source.controlled=OreSpawn controls this configured placeme tooltip.orespawn.ore_source.enrichment=This entry is enrichment, not an interchangeable ore source. tooltip.orespawn.ore_source.missing=This source is unavailable. Its saved selection will be restored if the owning mod returns. error.orespawn.ore_source.weight=Output weights must be positive numbers. +button.orespawn.back=Back +button.orespawn.ore_source.needs_attention=Needs Attention +button.orespawn.ore_source.all_groups=All Groups +button.orespawn.ore_source.add_group=Add Group +button.orespawn.ore_source.delete_group=Delete +button.orespawn.ore_source.move=Move +button.orespawn.ore_source.advanced=Advanced... +button.orespawn.ore_source.hide_advanced=Hide Advanced +option.orespawn.ore_source.group_name=Group name +option.orespawn.ore_source.alias=Ore Dictionary entry +option.orespawn.ore_source.output_mode=Output: %s +mode.orespawn.ore_source.keep_original=Keep Original +mode.orespawn.ore_source.balanced=Balanced +mode.orespawn.ore_source.single=Single +mode.orespawn.ore_source.custom=Custom +label.orespawn.ore_source.no_aliases=No Ore Dictionary entries +label.orespawn.ore_source.none_attention=No ore groups need attention. +status.orespawn.ore_source.external_short=external +status.orespawn.ore_source.missing_short=missing +status.orespawn.ore_source.output_only=output only +tooltip.orespawn.ore_source.output_only=This provider output is available, but its placement rule is inactive. +tooltip.orespawn.ore_source.output_mode=Keep Original preserves every rule. Balanced shares equally, Single chooses one block, and Custom enables weighted choices. +tooltip.orespawn.ore_source.alias=Add an exact Ore Dictionary ore name. Press Move again to confirm reassignment from another group. +tooltip.orespawn.ore_source.reset_group=Restore this curated group name and Ore Dictionary entries. +tooltip.orespawn.ore_source.delete_group=Delete this custom group without rewriting generated chunks. +error.orespawn.ore_source.alias=Use an exact Ore Dictionary ore name such as oreSulfur. +error.orespawn.ore_source.alias_owned=%s already owns this entry. Press Move to reassign it. +error.orespawn.ore_source.defaults_write=Could not save material-group defaults. diff --git a/src/main/resources/assets/orespawn/lang/es_ES.lang b/src/main/resources/assets/orespawn/lang/es_ES.lang index f5d37926..9b776fbc 100644 --- a/src/main/resources/assets/orespawn/lang/es_ES.lang +++ b/src/main/resources/assets/orespawn/lang/es_ES.lang @@ -384,6 +384,10 @@ screen.orespawn.ore_sources=Fuentes de mineral screen.orespawn.ore_source_detail=Política de fuente de mineral label.orespawn.ore_source.none=No se encontraron fuentes de mineral equivalentes gestionadas. label.orespawn.ore_source.row=%s - %s - %s candidatos - %s +label.orespawn.ore_source.intro=Elige cómo se generan los minerales equivalentes. +label.orespawn.ore_source.summary=%s candidatos - %s +button.orespawn.ore_source.review=Revisar... +button.orespawn.ore_source.configure=Configurar... label.orespawn.ore_source.material=Materia: %s label.orespawn.ore_source.domain=Dimensión/dominio: %s label.orespawn.ore_source.channels=Canales de colocación: %s @@ -412,3 +416,31 @@ tooltip.orespawn.ore_source.controlled=OreSpawn controla esta colocación y fuen tooltip.orespawn.ore_source.enrichment=Esta entrada es enriquecimiento, no una fuente de mineral intercambiable. tooltip.orespawn.ore_source.missing=Esta fuente no está disponible. La selección guardada se restaurará si vuelve el mod propietario. error.orespawn.ore_source.weight=Los pesos de salida deben ser números positivos. +button.orespawn.back=Atrás +button.orespawn.ore_source.needs_attention=Requiere atención +button.orespawn.ore_source.all_groups=Todos los grupos +button.orespawn.ore_source.add_group=Añadir grupo +button.orespawn.ore_source.delete_group=Eliminar +button.orespawn.ore_source.move=Mover +button.orespawn.ore_source.advanced=Avanzado... +button.orespawn.ore_source.hide_advanced=Ocultar avanzado +option.orespawn.ore_source.group_name=Nombre del grupo +option.orespawn.ore_source.alias=Entrada del diccionario de minerales +option.orespawn.ore_source.output_mode=Salida: %s +mode.orespawn.ore_source.keep_original=Mantener original +mode.orespawn.ore_source.balanced=Equilibrado +mode.orespawn.ore_source.single=Único +mode.orespawn.ore_source.custom=Personalizado +label.orespawn.ore_source.no_aliases=Sin entradas del diccionario de minerales +label.orespawn.ore_source.none_attention=Ningún grupo de minerales requiere atención. +status.orespawn.ore_source.external_short=externo +status.orespawn.ore_source.missing_short=ausente +status.orespawn.ore_source.output_only=solo salida +tooltip.orespawn.ore_source.output_only=Esta salida del proveedor está disponible, pero su regla de colocación está inactiva. +tooltip.orespawn.ore_source.output_mode=Mantener original conserva cada regla. Equilibrado reparte por igual, Único elige un bloque y Personalizado permite elecciones ponderadas. +tooltip.orespawn.ore_source.alias=Añade un nombre exacto de mineral del diccionario. Pulsa Mover otra vez para confirmar la reasignación desde otro grupo. +tooltip.orespawn.ore_source.reset_group=Restaura el nombre y las entradas del diccionario de este grupo seleccionado. +tooltip.orespawn.ore_source.delete_group=Elimina este grupo personalizado sin reescribir los chunks generados. +error.orespawn.ore_source.alias=Usa un nombre exacto del diccionario de minerales, como oreSulfur. +error.orespawn.ore_source.alias_owned=%s ya posee esta entrada. Pulsa Mover para reasignarla. +error.orespawn.ore_source.defaults_write=No se pudieron guardar los valores predeterminados de los grupos de materiales. diff --git a/src/main/resources/assets/orespawn/lang/es_MX.lang b/src/main/resources/assets/orespawn/lang/es_MX.lang index f5d37926..9b776fbc 100644 --- a/src/main/resources/assets/orespawn/lang/es_MX.lang +++ b/src/main/resources/assets/orespawn/lang/es_MX.lang @@ -384,6 +384,10 @@ screen.orespawn.ore_sources=Fuentes de mineral screen.orespawn.ore_source_detail=Política de fuente de mineral label.orespawn.ore_source.none=No se encontraron fuentes de mineral equivalentes gestionadas. label.orespawn.ore_source.row=%s - %s - %s candidatos - %s +label.orespawn.ore_source.intro=Elige cómo se generan los minerales equivalentes. +label.orespawn.ore_source.summary=%s candidatos - %s +button.orespawn.ore_source.review=Revisar... +button.orespawn.ore_source.configure=Configurar... label.orespawn.ore_source.material=Materia: %s label.orespawn.ore_source.domain=Dimensión/dominio: %s label.orespawn.ore_source.channels=Canales de colocación: %s @@ -412,3 +416,31 @@ tooltip.orespawn.ore_source.controlled=OreSpawn controla esta colocación y fuen tooltip.orespawn.ore_source.enrichment=Esta entrada es enriquecimiento, no una fuente de mineral intercambiable. tooltip.orespawn.ore_source.missing=Esta fuente no está disponible. La selección guardada se restaurará si vuelve el mod propietario. error.orespawn.ore_source.weight=Los pesos de salida deben ser números positivos. +button.orespawn.back=Atrás +button.orespawn.ore_source.needs_attention=Requiere atención +button.orespawn.ore_source.all_groups=Todos los grupos +button.orespawn.ore_source.add_group=Añadir grupo +button.orespawn.ore_source.delete_group=Eliminar +button.orespawn.ore_source.move=Mover +button.orespawn.ore_source.advanced=Avanzado... +button.orespawn.ore_source.hide_advanced=Ocultar avanzado +option.orespawn.ore_source.group_name=Nombre del grupo +option.orespawn.ore_source.alias=Entrada del diccionario de minerales +option.orespawn.ore_source.output_mode=Salida: %s +mode.orespawn.ore_source.keep_original=Mantener original +mode.orespawn.ore_source.balanced=Equilibrado +mode.orespawn.ore_source.single=Único +mode.orespawn.ore_source.custom=Personalizado +label.orespawn.ore_source.no_aliases=Sin entradas del diccionario de minerales +label.orespawn.ore_source.none_attention=Ningún grupo de minerales requiere atención. +status.orespawn.ore_source.external_short=externo +status.orespawn.ore_source.missing_short=ausente +status.orespawn.ore_source.output_only=solo salida +tooltip.orespawn.ore_source.output_only=Esta salida del proveedor está disponible, pero su regla de colocación está inactiva. +tooltip.orespawn.ore_source.output_mode=Mantener original conserva cada regla. Equilibrado reparte por igual, Único elige un bloque y Personalizado permite elecciones ponderadas. +tooltip.orespawn.ore_source.alias=Añade un nombre exacto de mineral del diccionario. Pulsa Mover otra vez para confirmar la reasignación desde otro grupo. +tooltip.orespawn.ore_source.reset_group=Restaura el nombre y las entradas del diccionario de este grupo seleccionado. +tooltip.orespawn.ore_source.delete_group=Elimina este grupo personalizado sin reescribir los chunks generados. +error.orespawn.ore_source.alias=Usa un nombre exacto del diccionario de minerales, como oreSulfur. +error.orespawn.ore_source.alias_owned=%s ya posee esta entrada. Pulsa Mover para reasignarla. +error.orespawn.ore_source.defaults_write=No se pudieron guardar los valores predeterminados de los grupos de materiales. diff --git a/src/main/resources/assets/orespawn/lang/fr_CA.lang b/src/main/resources/assets/orespawn/lang/fr_CA.lang index 608bceee..6e74ef1f 100644 --- a/src/main/resources/assets/orespawn/lang/fr_CA.lang +++ b/src/main/resources/assets/orespawn/lang/fr_CA.lang @@ -384,6 +384,10 @@ screen.orespawn.ore_sources=Sources de minerai screen.orespawn.ore_source_detail=Politique de source de minerai label.orespawn.ore_source.none=Aucune source de minerai gérée équivalente n'a été trouvée. label.orespawn.ore_source.row=%s - %s - %s candidats - %s +label.orespawn.ore_source.intro=Choisissez comment les minerais équivalents sont générés. +label.orespawn.ore_source.summary=%s candidats - %s +button.orespawn.ore_source.review=Examiner... +button.orespawn.ore_source.configure=Configurer... label.orespawn.ore_source.material=Matériau : %s label.orespawn.ore_source.domain=Dimension/domaine : %s label.orespawn.ore_source.channels=Canaux de placement : %s @@ -412,3 +416,31 @@ tooltip.orespawn.ore_source.controlled=OreSpawn contrôle cette source de placem tooltip.orespawn.ore_source.enrichment=Cette entrée est un enrichissement, pas une source de minerai interchangeable. tooltip.orespawn.ore_source.missing=Cette source est indisponible. Le choix enregistré sera restauré si son mod revient. error.orespawn.ore_source.weight=Les poids de sortie doivent être des nombres positifs. +button.orespawn.back=Retour +button.orespawn.ore_source.needs_attention=À vérifier +button.orespawn.ore_source.all_groups=Tous les groupes +button.orespawn.ore_source.add_group=Ajouter un groupe +button.orespawn.ore_source.delete_group=Supprimer +button.orespawn.ore_source.move=Déplacer +button.orespawn.ore_source.advanced=Avancé... +button.orespawn.ore_source.hide_advanced=Masquer les options avancées +option.orespawn.ore_source.group_name=Nom du groupe +option.orespawn.ore_source.alias=Entrée du dictionnaire de minerais +option.orespawn.ore_source.output_mode=Sortie : %s +mode.orespawn.ore_source.keep_original=Conserver l'origine +mode.orespawn.ore_source.balanced=Équilibré +mode.orespawn.ore_source.single=Unique +mode.orespawn.ore_source.custom=Personnalisé +label.orespawn.ore_source.no_aliases=Aucune entrée du dictionnaire de minerais +label.orespawn.ore_source.none_attention=Aucun groupe de minerais ne nécessite d'attention. +status.orespawn.ore_source.external_short=externe +status.orespawn.ore_source.missing_short=absente +status.orespawn.ore_source.output_only=sortie uniquement +tooltip.orespawn.ore_source.output_only=Cette sortie de fournisseur est disponible, mais sa règle de placement est inactive. +tooltip.orespawn.ore_source.output_mode=Conserver l'origine préserve chaque règle. Équilibré partage également, Unique choisit un bloc et Personnalisé permet des choix pondérés. +tooltip.orespawn.ore_source.alias=Ajoutez un nom exact de minerai du dictionnaire. Appuyez à nouveau sur Déplacer pour confirmer sa réaffectation depuis un autre groupe. +tooltip.orespawn.ore_source.reset_group=Restaure le nom et les entrées du dictionnaire de ce groupe sélectionné. +tooltip.orespawn.ore_source.delete_group=Supprime ce groupe personnalisé sans réécrire les chunks générés. +error.orespawn.ore_source.alias=Utilisez un nom exact du dictionnaire de minerais, comme oreSulfur. +error.orespawn.ore_source.alias_owned=%s possède déjà cette entrée. Appuyez sur Déplacer pour la réaffecter. +error.orespawn.ore_source.defaults_write=Impossible d'enregistrer les valeurs par défaut des groupes de matériaux. diff --git a/src/main/resources/assets/orespawn/lang/fr_FR.lang b/src/main/resources/assets/orespawn/lang/fr_FR.lang index 608bceee..6e74ef1f 100644 --- a/src/main/resources/assets/orespawn/lang/fr_FR.lang +++ b/src/main/resources/assets/orespawn/lang/fr_FR.lang @@ -384,6 +384,10 @@ screen.orespawn.ore_sources=Sources de minerai screen.orespawn.ore_source_detail=Politique de source de minerai label.orespawn.ore_source.none=Aucune source de minerai gérée équivalente n'a été trouvée. label.orespawn.ore_source.row=%s - %s - %s candidats - %s +label.orespawn.ore_source.intro=Choisissez comment les minerais équivalents sont générés. +label.orespawn.ore_source.summary=%s candidats - %s +button.orespawn.ore_source.review=Examiner... +button.orespawn.ore_source.configure=Configurer... label.orespawn.ore_source.material=Matériau : %s label.orespawn.ore_source.domain=Dimension/domaine : %s label.orespawn.ore_source.channels=Canaux de placement : %s @@ -412,3 +416,31 @@ tooltip.orespawn.ore_source.controlled=OreSpawn contrôle cette source de placem tooltip.orespawn.ore_source.enrichment=Cette entrée est un enrichissement, pas une source de minerai interchangeable. tooltip.orespawn.ore_source.missing=Cette source est indisponible. Le choix enregistré sera restauré si son mod revient. error.orespawn.ore_source.weight=Les poids de sortie doivent être des nombres positifs. +button.orespawn.back=Retour +button.orespawn.ore_source.needs_attention=À vérifier +button.orespawn.ore_source.all_groups=Tous les groupes +button.orespawn.ore_source.add_group=Ajouter un groupe +button.orespawn.ore_source.delete_group=Supprimer +button.orespawn.ore_source.move=Déplacer +button.orespawn.ore_source.advanced=Avancé... +button.orespawn.ore_source.hide_advanced=Masquer les options avancées +option.orespawn.ore_source.group_name=Nom du groupe +option.orespawn.ore_source.alias=Entrée du dictionnaire de minerais +option.orespawn.ore_source.output_mode=Sortie : %s +mode.orespawn.ore_source.keep_original=Conserver l'origine +mode.orespawn.ore_source.balanced=Équilibré +mode.orespawn.ore_source.single=Unique +mode.orespawn.ore_source.custom=Personnalisé +label.orespawn.ore_source.no_aliases=Aucune entrée du dictionnaire de minerais +label.orespawn.ore_source.none_attention=Aucun groupe de minerais ne nécessite d'attention. +status.orespawn.ore_source.external_short=externe +status.orespawn.ore_source.missing_short=absente +status.orespawn.ore_source.output_only=sortie uniquement +tooltip.orespawn.ore_source.output_only=Cette sortie de fournisseur est disponible, mais sa règle de placement est inactive. +tooltip.orespawn.ore_source.output_mode=Conserver l'origine préserve chaque règle. Équilibré partage également, Unique choisit un bloc et Personnalisé permet des choix pondérés. +tooltip.orespawn.ore_source.alias=Ajoutez un nom exact de minerai du dictionnaire. Appuyez à nouveau sur Déplacer pour confirmer sa réaffectation depuis un autre groupe. +tooltip.orespawn.ore_source.reset_group=Restaure le nom et les entrées du dictionnaire de ce groupe sélectionné. +tooltip.orespawn.ore_source.delete_group=Supprime ce groupe personnalisé sans réécrire les chunks générés. +error.orespawn.ore_source.alias=Utilisez un nom exact du dictionnaire de minerais, comme oreSulfur. +error.orespawn.ore_source.alias_owned=%s possède déjà cette entrée. Appuyez sur Déplacer pour la réaffecter. +error.orespawn.ore_source.defaults_write=Impossible d'enregistrer les valeurs par défaut des groupes de matériaux. diff --git a/src/main/resources/assets/orespawn/lang/ja_JP.lang b/src/main/resources/assets/orespawn/lang/ja_JP.lang index d4359c43..f553d979 100644 --- a/src/main/resources/assets/orespawn/lang/ja_JP.lang +++ b/src/main/resources/assets/orespawn/lang/ja_JP.lang @@ -384,6 +384,10 @@ screen.orespawn.ore_sources=鉱石ソース screen.orespawn.ore_source_detail=鉱石ソース方針 label.orespawn.ore_source.none=同等の管理対象鉱石ソースは見つかりませんでした。 label.orespawn.ore_source.row=%s - %s - 候補 %s 件 - %s +label.orespawn.ore_source.intro=同等の鉱石の生成方法を選択します。 +label.orespawn.ore_source.summary=候補 %s 件 - %s +button.orespawn.ore_source.review=確認... +button.orespawn.ore_source.configure=設定... label.orespawn.ore_source.material=素材: %s label.orespawn.ore_source.domain=ディメンション/領域: %s label.orespawn.ore_source.channels=配置チャンネル: %s @@ -412,3 +416,31 @@ tooltip.orespawn.ore_source.controlled=OreSpawnがこの設定済み配置と出 tooltip.orespawn.ore_source.enrichment=この項目は富化用であり、交換可能な鉱石ソースではありません。 tooltip.orespawn.ore_source.missing=このソースは利用できません。所有Modが戻ると保存済み選択が復元されます。 error.orespawn.ore_source.weight=出力の重みは正の数である必要があります。 +button.orespawn.back=戻る +button.orespawn.ore_source.needs_attention=要確認 +button.orespawn.ore_source.all_groups=すべてのグループ +button.orespawn.ore_source.add_group=グループを追加 +button.orespawn.ore_source.delete_group=削除 +button.orespawn.ore_source.move=移動 +button.orespawn.ore_source.advanced=詳細... +button.orespawn.ore_source.hide_advanced=詳細を隠す +option.orespawn.ore_source.group_name=グループ名 +option.orespawn.ore_source.alias=鉱石辞書エントリ +option.orespawn.ore_source.output_mode=出力: %s +mode.orespawn.ore_source.keep_original=元の設定を維持 +mode.orespawn.ore_source.balanced=均等 +mode.orespawn.ore_source.single=単一 +mode.orespawn.ore_source.custom=カスタム +label.orespawn.ore_source.no_aliases=鉱石辞書エントリなし +label.orespawn.ore_source.none_attention=確認が必要な鉱石グループはありません。 +status.orespawn.ore_source.external_short=外部 +status.orespawn.ore_source.missing_short=不足 +status.orespawn.ore_source.output_only=出力のみ +tooltip.orespawn.ore_source.output_only=このプロバイダー出力は利用できますが、配置ルールは無効です。 +tooltip.orespawn.ore_source.output_mode=元の設定を維持は各ルールを保持します。均等は同じ比率、単一は1ブロック、カスタムは重み付き選択を使用します。 +tooltip.orespawn.ore_source.alias=鉱石辞書の正確な鉱石名を追加します。別グループからの再割り当てを確定するには、もう一度「移動」を押します。 +tooltip.orespawn.ore_source.reset_group=この既定グループの名前と鉱石辞書エントリを復元します。 +tooltip.orespawn.ore_source.delete_group=生成済みチャンクを書き換えずに、このカスタムグループを削除します。 +error.orespawn.ore_source.alias=oreSulfur のような正確な鉱石辞書名を使用してください。 +error.orespawn.ore_source.alias_owned=%s がこのエントリを既に所有しています。再割り当てするには「移動」を押してください。 +error.orespawn.ore_source.defaults_write=素材グループの既定値を保存できませんでした。 diff --git a/src/main/resources/assets/orespawn/lang/ko_KR.lang b/src/main/resources/assets/orespawn/lang/ko_KR.lang index 7b668840..8ec4efef 100644 --- a/src/main/resources/assets/orespawn/lang/ko_KR.lang +++ b/src/main/resources/assets/orespawn/lang/ko_KR.lang @@ -384,6 +384,10 @@ screen.orespawn.ore_sources=광석 소스 screen.orespawn.ore_source_detail=광석 소스 정책 label.orespawn.ore_source.none=동등한 관리 광석 소스를 찾지 못했습니다. label.orespawn.ore_source.row=%s - %s - 후보 %s개 - %s +label.orespawn.ore_source.intro=동등한 광석의 생성 방식을 선택하세요. +label.orespawn.ore_source.summary=후보 %s개 - %s +button.orespawn.ore_source.review=검토... +button.orespawn.ore_source.configure=설정... label.orespawn.ore_source.material=재료: %s label.orespawn.ore_source.domain=차원/영역: %s label.orespawn.ore_source.channels=배치 채널: %s @@ -412,3 +416,31 @@ tooltip.orespawn.ore_source.controlled=OreSpawn이 이 설정된 배치와 출 tooltip.orespawn.ore_source.enrichment=이 항목은 농축용이며 교환 가능한 광석 소스가 아닙니다. tooltip.orespawn.ore_source.missing=이 소스를 사용할 수 없습니다. 소유 Mod가 돌아오면 저장된 선택이 복원됩니다. error.orespawn.ore_source.weight=출력 가중치는 양수여야 합니다. +button.orespawn.back=뒤로 +button.orespawn.ore_source.needs_attention=확인 필요 +button.orespawn.ore_source.all_groups=모든 그룹 +button.orespawn.ore_source.add_group=그룹 추가 +button.orespawn.ore_source.delete_group=삭제 +button.orespawn.ore_source.move=이동 +button.orespawn.ore_source.advanced=고급... +button.orespawn.ore_source.hide_advanced=고급 숨기기 +option.orespawn.ore_source.group_name=그룹 이름 +option.orespawn.ore_source.alias=광석 사전 항목 +option.orespawn.ore_source.output_mode=출력: %s +mode.orespawn.ore_source.keep_original=원본 유지 +mode.orespawn.ore_source.balanced=균등 +mode.orespawn.ore_source.single=단일 +mode.orespawn.ore_source.custom=사용자 지정 +label.orespawn.ore_source.no_aliases=광석 사전 항목 없음 +label.orespawn.ore_source.none_attention=확인이 필요한 광석 그룹이 없습니다. +status.orespawn.ore_source.external_short=외부 +status.orespawn.ore_source.missing_short=누락 +status.orespawn.ore_source.output_only=출력 전용 +tooltip.orespawn.ore_source.output_only=이 공급자 출력은 사용할 수 있지만 배치 규칙은 비활성 상태입니다. +tooltip.orespawn.ore_source.output_mode=원본 유지는 각 규칙을 보존합니다. 균등은 같은 비율로, 단일은 한 블록을, 사용자 지정은 가중 선택을 사용합니다. +tooltip.orespawn.ore_source.alias=정확한 광석 사전 이름을 추가합니다. 다른 그룹에서 재할당하려면 이동을 다시 누르세요. +tooltip.orespawn.ore_source.reset_group=이 선별 그룹의 이름과 광석 사전 항목을 복원합니다. +tooltip.orespawn.ore_source.delete_group=생성된 청크를 다시 쓰지 않고 이 사용자 지정 그룹을 삭제합니다. +error.orespawn.ore_source.alias=oreSulfur 같은 정확한 광석 사전 이름을 사용하세요. +error.orespawn.ore_source.alias_owned=%s에서 이미 이 항목을 소유합니다. 이동을 눌러 재할당하세요. +error.orespawn.ore_source.defaults_write=재료 그룹 기본값을 저장하지 못했습니다. diff --git a/src/main/resources/assets/orespawn/lang/pt_BR.lang b/src/main/resources/assets/orespawn/lang/pt_BR.lang index 18b8f311..ff7efc57 100644 --- a/src/main/resources/assets/orespawn/lang/pt_BR.lang +++ b/src/main/resources/assets/orespawn/lang/pt_BR.lang @@ -384,6 +384,10 @@ screen.orespawn.ore_sources=Fontes de minério screen.orespawn.ore_source_detail=Política de fonte de minério label.orespawn.ore_source.none=Nenhuma fonte de minério gerenciada equivalente foi encontrada. label.orespawn.ore_source.row=%s - %s - %s candidatos - %s +label.orespawn.ore_source.intro=Escolha como minérios equivalentes são gerados. +label.orespawn.ore_source.summary=%s candidatos - %s +button.orespawn.ore_source.review=Revisar... +button.orespawn.ore_source.configure=Configurar... label.orespawn.ore_source.material=Matéria-prima: %s label.orespawn.ore_source.domain=Dimensão/domínio: %s label.orespawn.ore_source.channels=Canais de colocação: %s @@ -412,3 +416,31 @@ tooltip.orespawn.ore_source.controlled=O OreSpawn controla esta colocação e fo tooltip.orespawn.ore_source.enrichment=Esta entrada é um enriquecimento, não uma fonte de minério intercambiável. tooltip.orespawn.ore_source.missing=Esta fonte está indisponível. A seleção salva será restaurada se o mod proprietário retornar. error.orespawn.ore_source.weight=Os pesos de saída devem ser números positivos. +button.orespawn.back=Voltar +button.orespawn.ore_source.needs_attention=Requer atenção +button.orespawn.ore_source.all_groups=Todos os grupos +button.orespawn.ore_source.add_group=Adicionar grupo +button.orespawn.ore_source.delete_group=Excluir +button.orespawn.ore_source.move=Mover +button.orespawn.ore_source.advanced=Avançado... +button.orespawn.ore_source.hide_advanced=Ocultar avançado +option.orespawn.ore_source.group_name=Nome do grupo +option.orespawn.ore_source.alias=Entrada do dicionário de minérios +option.orespawn.ore_source.output_mode=Saída: %s +mode.orespawn.ore_source.keep_original=Manter original +mode.orespawn.ore_source.balanced=Equilibrado +mode.orespawn.ore_source.single=Único +mode.orespawn.ore_source.custom=Personalizado +label.orespawn.ore_source.no_aliases=Sem entradas do dicionário de minérios +label.orespawn.ore_source.none_attention=Nenhum grupo de minérios requer atenção. +status.orespawn.ore_source.external_short=externo +status.orespawn.ore_source.missing_short=ausente +status.orespawn.ore_source.output_only=somente saída +tooltip.orespawn.ore_source.output_only=Esta saída do provedor está disponível, mas sua regra de posicionamento está inativa. +tooltip.orespawn.ore_source.output_mode=Manter original preserva cada regra. Equilibrado divide igualmente, Único escolhe um bloco e Personalizado permite escolhas ponderadas. +tooltip.orespawn.ore_source.alias=Adicione um nome exato de minério do dicionário. Pressione Mover novamente para confirmar a transferência de outro grupo. +tooltip.orespawn.ore_source.reset_group=Restaura o nome e as entradas do dicionário deste grupo selecionado. +tooltip.orespawn.ore_source.delete_group=Exclui este grupo personalizado sem reescrever os chunks gerados. +error.orespawn.ore_source.alias=Use um nome exato do dicionário de minérios, como oreSulfur. +error.orespawn.ore_source.alias_owned=%s já possui esta entrada. Pressione Mover para transferi-la. +error.orespawn.ore_source.defaults_write=Não foi possível salvar os padrões dos grupos de materiais. diff --git a/src/main/resources/assets/orespawn/lang/ru_RU.lang b/src/main/resources/assets/orespawn/lang/ru_RU.lang index 7d009862..14647977 100644 --- a/src/main/resources/assets/orespawn/lang/ru_RU.lang +++ b/src/main/resources/assets/orespawn/lang/ru_RU.lang @@ -384,6 +384,10 @@ screen.orespawn.ore_sources=Источники руд screen.orespawn.ore_source_detail=Правило источника руды label.orespawn.ore_source.none=Равнозначные управляемые источники руд не найдены. label.orespawn.ore_source.row=%s - %s - кандидатов: %s - %s +label.orespawn.ore_source.intro=Выберите способ генерации равнозначных руд. +label.orespawn.ore_source.summary=Кандидатов: %s - %s +button.orespawn.ore_source.review=Проверить... +button.orespawn.ore_source.configure=Настроить... label.orespawn.ore_source.material=Материал: %s label.orespawn.ore_source.domain=Измерение/область: %s label.orespawn.ore_source.channels=Каналы размещения: %s @@ -412,3 +416,31 @@ tooltip.orespawn.ore_source.controlled=OreSpawn управляет этим на tooltip.orespawn.ore_source.enrichment=Эта запись является обогащением, а не взаимозаменяемым источником руды. tooltip.orespawn.ore_source.missing=Источник недоступен. Сохранённый выбор восстановится, когда вернётся его мод. error.orespawn.ore_source.weight=Веса выходов должны быть положительными числами. +button.orespawn.back=Назад +button.orespawn.ore_source.needs_attention=Требует внимания +button.orespawn.ore_source.all_groups=Все группы +button.orespawn.ore_source.add_group=Добавить группу +button.orespawn.ore_source.delete_group=Удалить +button.orespawn.ore_source.move=Переместить +button.orespawn.ore_source.advanced=Расширенные... +button.orespawn.ore_source.hide_advanced=Скрыть расширенные +option.orespawn.ore_source.group_name=Название группы +option.orespawn.ore_source.alias=Запись словаря руд +option.orespawn.ore_source.output_mode=Результат: %s +mode.orespawn.ore_source.keep_original=Сохранить исходное +mode.orespawn.ore_source.balanced=Равномерно +mode.orespawn.ore_source.single=Один +mode.orespawn.ore_source.custom=Настраиваемый +label.orespawn.ore_source.no_aliases=Нет записей словаря руд +label.orespawn.ore_source.none_attention=Нет групп руд, требующих внимания. +status.orespawn.ore_source.external_short=внешний +status.orespawn.ore_source.missing_short=отсутствует +status.orespawn.ore_source.output_only=только результат +tooltip.orespawn.ore_source.output_only=Результат этого поставщика доступен, но его правило размещения неактивно. +tooltip.orespawn.ore_source.output_mode=Сохранить исходное оставляет каждое правило. Равномерно делит поровну, Один выбирает один блок, а Настраиваемый использует веса. +tooltip.orespawn.ore_source.alias=Добавьте точное имя руды из словаря. Нажмите Переместить ещё раз, чтобы подтвердить перенос из другой группы. +tooltip.orespawn.ore_source.reset_group=Восстанавливает название и записи словаря этой подготовленной группы. +tooltip.orespawn.ore_source.delete_group=Удаляет эту настраиваемую группу без перезаписи созданных чанков. +error.orespawn.ore_source.alias=Используйте точное имя из словаря руд, например oreSulfur. +error.orespawn.ore_source.alias_owned=%s уже владеет этой записью. Нажмите Переместить для переназначения. +error.orespawn.ore_source.defaults_write=Не удалось сохранить значения групп материалов по умолчанию. diff --git a/src/main/resources/assets/orespawn/lang/zh_CN.lang b/src/main/resources/assets/orespawn/lang/zh_CN.lang index 62ec7359..2a9c5afc 100644 --- a/src/main/resources/assets/orespawn/lang/zh_CN.lang +++ b/src/main/resources/assets/orespawn/lang/zh_CN.lang @@ -384,6 +384,10 @@ screen.orespawn.ore_sources=矿石来源 screen.orespawn.ore_source_detail=矿石来源策略 label.orespawn.ore_source.none=未找到等价的受管理矿石来源。 label.orespawn.ore_source.row=%s - %s - %s 个候选 - %s +label.orespawn.ore_source.intro=选择等价矿石的生成方式。 +label.orespawn.ore_source.summary=%s 个候选 - %s +button.orespawn.ore_source.review=审查... +button.orespawn.ore_source.configure=配置... label.orespawn.ore_source.material=材料:%s label.orespawn.ore_source.domain=维度/范围:%s label.orespawn.ore_source.channels=放置通道:%s @@ -412,3 +416,31 @@ tooltip.orespawn.ore_source.controlled=OreSpawn 控制此已配置的放置与 tooltip.orespawn.ore_source.enrichment=此条目属于富集矿,不是可互换的矿石来源。 tooltip.orespawn.ore_source.missing=此来源不可用。其所属模组恢复后将自动恢复已保存的选择。 error.orespawn.ore_source.weight=输出权重必须为正数。 +button.orespawn.back=返回 +button.orespawn.ore_source.needs_attention=需要处理 +button.orespawn.ore_source.all_groups=所有分组 +button.orespawn.ore_source.add_group=添加分组 +button.orespawn.ore_source.delete_group=删除 +button.orespawn.ore_source.move=移动 +button.orespawn.ore_source.advanced=高级... +button.orespawn.ore_source.hide_advanced=隐藏高级选项 +option.orespawn.ore_source.group_name=分组名称 +option.orespawn.ore_source.alias=矿物词典条目 +option.orespawn.ore_source.output_mode=输出:%s +mode.orespawn.ore_source.keep_original=保持原样 +mode.orespawn.ore_source.balanced=均衡 +mode.orespawn.ore_source.single=单一 +mode.orespawn.ore_source.custom=自定义 +label.orespawn.ore_source.no_aliases=无矿物词典条目 +label.orespawn.ore_source.none_attention=没有需要处理的矿物分组。 +status.orespawn.ore_source.external_short=外部 +status.orespawn.ore_source.missing_short=缺失 +status.orespawn.ore_source.output_only=仅输出 +tooltip.orespawn.ore_source.output_only=此提供方输出可用,但其放置规则未启用。 +tooltip.orespawn.ore_source.output_mode=保持原样会保留每条规则;均衡会平均选择;单一只选择一个方块;自定义允许加权选择。 +tooltip.orespawn.ore_source.alias=添加准确的矿物词典名称。再次按“移动”以确认从其他分组重新分配。 +tooltip.orespawn.ore_source.reset_group=恢复此预设分组的名称和矿物词典条目。 +tooltip.orespawn.ore_source.delete_group=删除此自定义分组,不重写已生成的区块。 +error.orespawn.ore_source.alias=请使用准确的矿物词典名称,例如 oreSulfur。 +error.orespawn.ore_source.alias_owned=%s 已拥有此条目。按“移动”可重新分配。 +error.orespawn.ore_source.defaults_write=无法保存材料分组默认值。 diff --git a/src/test/java/zone/moddev/mc/orespawn/LocalizationParityTest.java b/src/test/java/zone/moddev/mc/orespawn/LocalizationParityTest.java index 7e54c49c..88bfd0c8 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(414, englishKeys.size(), + assertEquals(446, 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/client/GeologyEditorSessionTest.java b/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java index 0f08c1a4..4c77ebec 100644 --- a/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java @@ -193,6 +193,7 @@ void oreSourceEditsStayInsideThePendingEditorSession() { () -> group.outputs.put("example:other", 2.0D)); session.setOreSourceMode(group.key, true); + session.setOreSourceOutputMode(group.key, "custom"); session.setOreSourcePlacement(group.key, "orespawn:standard", "mineralogy:sulfur"); session.setOreSourceOutput(group.key, "mineralogy:sulfur", true, 3.5D); GeologyEditorSession.OreSourceGroup edited = session.oreSourceGroups().get(0); @@ -203,6 +204,93 @@ void oreSourceEditsStayInsideThePendingEditorSession() { assertFalse(before.equals(session.profile().rootCopy())); } + @Test + void equivalentPlacementRulesAreHiddenWithoutChangingThePendingProfile() { + JsonObject root = WorldGeologyProfile.recommended(false).toJson(); + JsonObject policy = new JsonObject(); + policy.addProperty("material", "orespawn:gold"); + policy.addProperty("domain", "minecraft:overworld"); + policy.addProperty("mode", "keep_separate"); + policy.addProperty("status", "review_required"); + policy.addProperty("review_required", true); + JsonObject outputs = new JsonObject(); + outputs.addProperty("minecraft:gold_ore", 1.0D); + outputs.addProperty("orespawn:vanilla_gold_badlands", 1.0D); + policy.add("outputs", outputs); + JsonObject placements = new JsonObject(); + placements.addProperty("orespawn:standard", "minecraft:gold_ore"); + policy.add("placement_sources", placements); + JsonArray candidates = new JsonArray(); + candidates.add(candidate("minecraft:gold_ore", "minecraft", "minecraft:gold_ore", + "orespawn:standard", false)); + candidates.add(candidate("orespawn:vanilla_gold_badlands", "minecraft", "minecraft:gold_ore", + "orespawn:standard", false)); + policy.add("candidates", candidates); + JsonObject policies = new JsonObject(); + policies.add("orespawn:gold|minecraft:overworld", policy); + root.add("ore_source_policies", policies); + WorldGeologyProfile profile = WorldGeologyProfile.fromJson(root, + WorldGeologyProfile.recommended(false)); + GeologyEditorSession session = new GeologyEditorSession(profile); + JsonObject before = session.profile().rootCopy(); + + assertEquals(1, session.oreSourceGroups().size()); + assertTrue(session.oreSourceGroups().get(0).isRedundantSeparatePolicy()); + assertEquals(before, session.profile().rootCopy(), + "Listing a harmless group must not rewrite the pending profile"); + } + + @Test + void customMaterialGroupsKeepStableIdsAndRequireConfirmedAliasMoves() { + WorldGeologyProfile original = profileWithOreSourcePolicy(); + JsonObject before = original.rootCopy(); + GeologyEditorSession session = new GeologyEditorSession(original); + + String key = session.addOreMaterialGroup(); + String material = key.substring(0, key.indexOf('|')); + session.renameOreMaterialGroup(material, "Volcanogenic Sulphides"); + assertTrue(session.addOreMaterialAlias(material, "oreCopperZinc", false)); + assertFalse(session.addOreMaterialAlias(material, "oreSulfur", false)); + assertTrue(session.addOreMaterialAlias(material, "oreSulfur", true)); + + assertEquals(material, session.oreDictionaryOwner("oreSulfur")); + assertEquals("Volcanogenic Sulphides", session.oreSourceGroups().stream() + .filter(group -> material.equals(group.material)).findFirst().get().displayName); + assertTrue(session.oreMaterialGroupsChanged()); + assertEquals(before, original.rootCopy(), "pending group edits must not persist before main Done"); + + session.deleteOreMaterialGroup(material); + assertEquals(null, session.oreDictionaryOwner("oreSulfur")); + assertFalse(key.equals(session.addOreMaterialGroup()), + "deleted group IDs remain reserved by their dormant policy"); + session.resetOreMaterialGroup("orespawn:sulfur"); + assertEquals("orespawn:sulfur", session.oreDictionaryOwner("oreSulfur")); + } + + @Test + void balancedSingleAndCustomModesKeepOneExplicitOutputPolicy() { + GeologyEditorSession session = new GeologyEditorSession(profileWithTwoOreOutputs()); + String key = session.oreSourceGroups().get(0).key; + + session.setOreSourceOutputMode(key, "balanced"); + GeologyEditorSession.OreSourceGroup balanced = session.oreSourceGroups().get(0); + assertEquals(2, balanced.outputs.size()); + assertTrue(balanced.outputs.values().stream().allMatch(weight -> weight == 1.0D)); + + session.setOreSourceOutputMode(key, "single"); + GeologyEditorSession.OreSourceGroup single = session.oreSourceGroups().get(0); + assertEquals(1, single.outputs.size()); + assertTrue(single.outputs.containsKey("mineralogy:sulfur"), + "Single initially follows the reviewed placement priority"); + + session.setOreSourceOutputMode(key, "custom"); + session.setOreSourceOutput(key, "baseminerals:sulfur", true, 2.5D); + session.setOreSourceOutput(key, "mineralogy:sulfur", false, 1.0D); + GeologyEditorSession.OreSourceGroup custom = session.oreSourceGroups().get(0); + assertEquals(1, custom.outputs.size()); + assertEquals(2.5D, custom.outputs.get("baseminerals:sulfur").doubleValue()); + } + private static WorldGeologyProfile profileWithOreSourcePolicy() { JsonObject root = WorldGeologyProfile.recommended(false).toJson(); JsonObject policy = new JsonObject(); @@ -224,8 +312,11 @@ private static WorldGeologyProfile profileWithOreSourcePolicy() { candidate.addProperty("metadata", 0); candidate.addProperty("placement_channel", "orespawn:standard"); candidate.addProperty("loaded", true); + candidate.addProperty("placement_active", true); candidate.addProperty("external", false); candidate.addProperty("enrichment", false); + candidate.addProperty("review_required", true); + candidate.addProperty("material_declared", false); candidate.add("ore_dictionary", new JsonArray()); JsonArray candidates = new JsonArray(); candidates.add(candidate); @@ -235,4 +326,36 @@ private static WorldGeologyProfile profileWithOreSourcePolicy() { root.add("ore_source_policies", policies); return WorldGeologyProfile.fromJson(root, WorldGeologyProfile.recommended(false)); } + + private static WorldGeologyProfile profileWithTwoOreOutputs() { + JsonObject root = profileWithOreSourcePolicy().rootCopy(); + JsonObject policy = root.getAsJsonObject("ore_source_policies") + .getAsJsonObject("orespawn:sulfur|minecraft:overworld"); + policy.getAsJsonObject("outputs").addProperty("baseminerals:sulfur", 1.0D); + policy.getAsJsonObject("placement_sources") + .addProperty("orespawn:standard", "mineralogy:sulfur"); + policy.getAsJsonArray("candidates").add(candidate("baseminerals:sulfur", + "baseminerals", "minecraft:gold_ore", "orespawn:standard", false)); + return WorldGeologyProfile.fromJson(root, WorldGeologyProfile.recommended(false)); + } + + private static JsonObject candidate(String sourceId, String owner, String registryId, + String channel, boolean reviewRequired) { + JsonObject candidate = new JsonObject(); + candidate.addProperty("source_id", sourceId); + candidate.addProperty("owner", owner); + candidate.addProperty("owner_name", owner); + candidate.addProperty("owner_version", ""); + candidate.addProperty("registry_id", registryId); + candidate.addProperty("metadata", 0); + candidate.addProperty("placement_channel", channel); + candidate.addProperty("loaded", true); + candidate.addProperty("placement_active", true); + candidate.addProperty("external", false); + candidate.addProperty("enrichment", false); + candidate.addProperty("review_required", reviewRequired); + candidate.addProperty("material_declared", false); + candidate.add("ore_dictionary", new JsonArray()); + return candidate; + } } 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 33621a0b..b3a07f22 100644 --- a/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java @@ -1,6 +1,7 @@ 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.assertTrue; import java.nio.charset.StandardCharsets; @@ -26,7 +27,7 @@ void everyConcreteScreenClearsThePreviousFrameBeforeDrawingWidgets() throws Exce .sorted() .collect(Collectors.toList()); } - assertEquals(27, screens.size(), "Review this render-order gate when screens are added or removed"); + assertEquals(26, 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( @@ -93,14 +94,35 @@ void oreSourceDirectoryPaginatesClipsAndPreservesParentNavigation() throws Excep assertEquals(1, OreSourceListScreen.pageCount(4, 4)); assertEquals(2, OreSourceListScreen.pageCount(5, 4)); String list = screenSource("OreSourceListScreen.java"); - String detail = screenSource("OreSourceDetailScreen.java"); - assertTrue(list.contains("pageSize = Math.max(1")); - assertTrue(detail.contains("pageSize = Math.max(1")); + assertTrue(list.contains("private static final int WIDE_MINIMUM = 520")); + assertTrue(list.contains("compact(width)")); + assertTrue(list.contains("initDirectory(")); + assertTrue(list.contains("initOutputs(")); assertTrue(list.contains("OreSpawnScreenLayout.fit")); - assertTrue(detail.contains("OreSpawnScreenLayout.fit")); + assertTrue(list.contains("button.orespawn.ore_source.needs_attention")); + assertTrue(list.contains("button.orespawn.ore_source.all_groups")); + assertTrue(list.contains("button.orespawn.ore_source.add_group")); + assertTrue(list.contains("outputCandidates()")); + assertTrue(list.contains("button.orespawn.ore_source.advanced")); + assertTrue(list.contains(", 20,"), "Material Groups must use normal Forge-height controls"); assertTrue(list.contains("minecraft.displayGuiScreen(parent)")); - assertTrue(detail.contains("syncWeights();\n\t\tif (error == null) minecraft.displayGuiScreen(parent)")); - assertTrue(detail.contains("toggle.enabled = !candidate.external && !candidate.enrichment")); + assertTrue(list.contains("toggle.enabled = candidate.loaded && !candidate.enrichment")); + assertFalse(Files.exists(Paths.get("src", "main", "java", "zone", "moddev", "mc", + "orespawn", "client", "OreSourceDetailScreen.java")), + "The technical detail screen must not remain as a second competing flow"); + } + + @Test + void materialGroupDefaultsPersistOnlyThroughMainDone() throws Exception { + String main = screenSource("OreSpawnWorldSettingsScreen.java"); + String groups = screenSource("OreSourceListScreen.java"); + int save = main.indexOf("private void saveAndClose()"); + int persistence = main.indexOf("GeomeConfig.persistOreMaterialGroups", save); + assertTrue(save >= 0 && persistence > save); + assertFalse(groups.contains("persistOreMaterialGroups"), + "leaving Material Groups must not persist future-world defaults"); + assertTrue(groups.contains("minecraft.displayGuiScreen(parent)"), + "Done and Escape return to the pending ORES editor session"); } private static String screenSource(String name) throws Exception { diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/OreSourcePoliciesTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/OreSourcePoliciesTest.java index 79d4a6c9..369d5393 100644 --- a/src/test/java/zone/moddev/mc/orespawn/worldgen/OreSourcePoliciesTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/OreSourcePoliciesTest.java @@ -11,6 +11,7 @@ import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import zone.moddev.mc.orespawn.test.Forge12TestBootstrap; @@ -48,8 +49,8 @@ void niterAndSaltpeterRemainDistinctAndAmbiguousMembershipRequiresReview() { Arrays.asList("dustSulfur", "crushedSulfur")).material); OreSourcePolicies.Inference invalid = OreSourcePolicies.inferMaterial( Collections.singletonList("oreBad Path")); - assertEquals("orespawn:review_required", invalid.material.toString()); - assertTrue(invalid.reviewRequired); + assertEquals(null, invalid.material); + assertFalse(invalid.reviewRequired); } @Test @@ -60,8 +61,10 @@ void freshCataloguedMmdConflictConsolidatesUsingReviewedPriority() { assertEquals("consolidated", policy.get("mode").getAsString()); assertEquals("consolidated", policy.get("status").getAsString()); - assertEquals(1, policy.getAsJsonObject("outputs").entrySet().size()); + assertEquals("balanced", policy.get("output_mode").getAsString()); + assertEquals(2, policy.getAsJsonObject("outputs").entrySet().size()); assertTrue(policy.getAsJsonObject("outputs").has("mineralogy:sulfur")); + assertTrue(policy.getAsJsonObject("outputs").has("baseminerals:sulfur")); assertEquals("mineralogy:sulfur", policy.getAsJsonObject("placement_sources") .get("orespawn:standard").getAsString()); } @@ -148,7 +151,7 @@ void placementChannelsStayIndependentWhileOutputsRemainMaterialWide() { .get("orespawn:standard").getAsString()); assertEquals("electricadvantage:sulfur_district", policy.getAsJsonObject("placement_sources") .get("realisticdeposits:district").getAsString()); - assertEquals(1, policy.getAsJsonObject("outputs").entrySet().size()); + assertEquals(3, policy.getAsJsonObject("outputs").entrySet().size()); } @Test @@ -212,6 +215,160 @@ void customPatternsDefaultToTheirOwnPlacementChannel() { assertEquals("orespawn:standard", OreSourcePolicies.placementChannel(builtIn).toString()); } + @Test + void sameVanillaOutputWithComplementaryPlacementRulesIsNotAConflict() { + JsonObject root = root(); + addOre(root, "minecraft:gold_ore", "minecraft", "minecraft:gold_ore", + "orespawn:gold", "minecraft:overworld", null); + addOre(root, "orespawn:vanilla_gold_badlands", "minecraft", "minecraft:gold_ore", + "orespawn:gold", "minecraft:overworld", null); + + OreSourcePolicies.initialize(root, false); + + assertEquals(1, root.getAsJsonObject(OreSourcePolicies.SECTION).entrySet().size()); + OreSourcePolicies.GroupView group = OreSourcePolicies.snapshot(root).groups().get(0); + assertEquals("separate", group.status, + "Complementary normal and Badlands rules are one harmless material group"); + assertEquals(1, group.outputs.size()); + } + + @Test + void sameOutputAcrossIndependentPlacementChannelsIsNotAConflict() { + JsonObject root = root(); + addOre(root, "minecraft:iron_ore", "minecraft", "minecraft:iron_ore", + "orespawn:iron", "minecraft:overworld", null); + addOre(root, "realisticdeposits:overworld_iron", "realisticdeposits", "minecraft:iron_ore", + "orespawn:iron", "minecraft:overworld", "realisticdeposits:stratiform_seam"); + + OreSourcePolicies.initialize(root, false); + + assertEquals(1, root.getAsJsonObject(OreSourcePolicies.SECTION).entrySet().size()); + OreSourcePolicies.GroupView group = OreSourcePolicies.snapshot(root).groups().get(0); + assertEquals("separate", group.status, + "A placement engine using the same output remains an independent channel"); + assertEquals(1, group.outputs.size()); + assertEquals(2, group.placements.size()); + } + + @Test + void differentOwnersInTheSameChannelStillCompeteForOnePlacementBudget() { + JsonObject root = root(); + addOre(root, "otherone:sulfur", "otherone", "minecraft:gold_ore", + "orespawn:sulfur", "minecraft:overworld", null); + addOre(root, "othertwo:sulfur", "othertwo", "minecraft:gold_ore", + "orespawn:sulfur", "minecraft:overworld", null); + + OreSourcePolicies.initialize(root, false); + + assertEquals(1, root.getAsJsonObject(OreSourcePolicies.SECTION).entrySet().size()); + assertEquals("review_required", policy(root).get("status").getAsString()); + } + + @Test + void duplicateRegistryStatesAppearAsOneSelectableOutput() { + JsonObject root = root(); + addOre(root, "otherone:sulfur", "otherone", "minecraft:gold_ore", + "orespawn:sulfur", "minecraft:overworld", null); + addOre(root, "othertwo:sulfur", "othertwo", "minecraft:gold_ore", + "orespawn:sulfur", "minecraft:overworld", null); + + OreSourcePolicies.initialize(root, false); + + assertEquals(1, policy(root).getAsJsonObject("outputs").entrySet().size()); + assertEquals(2, policy(root).getAsJsonArray("candidates").size(), + "the placement candidates remain auditable even when their output state is shared"); + } + + @Test + void snapshotsOrderFriendlyMaterialGroupsDeterministically() { + JsonObject root = root(); + addOre(root, "example:zinc", "example", "minecraft:gold_ore", + "orespawn:zinc", "minecraft:overworld", null); + addOre(root, "example:copper", "example", "minecraft:iron_ore", + "orespawn:copper", "minecraft:overworld", null); + OreSourcePolicies.initialize(root, true); + + java.util.List groups = OreSourcePolicies.snapshot(root).groups(); + assertEquals("orespawn:copper", groups.get(0).material.toString()); + assertEquals("orespawn:zinc", groups.get(1).material.toString()); + } + + @Test + void separateLegacyPolicyForOneLoadedOutputIsHiddenWithoutBeingDeleted() { + JsonObject root = root(); + addOre(root, "minecraft:gold_ore", "minecraft", "minecraft:gold_ore", + "orespawn:gold", "minecraft:overworld", null); + addOre(root, "orespawn:vanilla_gold_badlands", "minecraft", "minecraft:iron_ore", + "orespawn:gold", "minecraft:overworld", null); + OreSourcePolicies.initialize(root, true); + JsonObject policies = root.getAsJsonObject(OreSourcePolicies.SECTION); + assertEquals(1, policies.entrySet().size()); + + root.getAsJsonObject("ores").getAsJsonObject("orespawn:vanilla_gold_badlands") + .addProperty("block", "minecraft:gold_ore"); + OreSourcePolicies.initialize(root, true); + + assertEquals(1, policies.entrySet().size(), "Existing profile data must not be rewritten away"); + assertEquals(1, OreSourcePolicies.snapshot(root).groups().size(), + "All Groups must retain harmless single-output material groups"); + } + + @Test + void inactiveManagedProviderRemainsAnOutputWithoutOwningPlacement() { + JsonObject root = root(); + addOre(root, "mineralogy:sulfur", "mineralogy", "minecraft:iron_ore", + "orespawn:sulfur", "minecraft:overworld", null); + addOre(root, "electricadvantage:sulfur", "electricadvantage", "minecraft:gold_ore", + "orespawn:sulfur", "minecraft:overworld", null); + root.getAsJsonObject("ores").getAsJsonObject("electricadvantage:sulfur") + .addProperty("enabled", false); + + OreSourcePolicies.initialize(root, false); + + JsonObject policy = policy(root); + assertEquals("balanced", policy.get("output_mode").getAsString()); + assertEquals(2, policy.getAsJsonObject("outputs").entrySet().size()); + assertEquals("mineralogy:sulfur", policy.getAsJsonObject("placement_sources") + .get("orespawn:standard").getAsString()); + boolean outputOnly = false; + for (JsonElement element : policy.getAsJsonArray("candidates")) { + JsonObject candidate = element.getAsJsonObject(); + if ("electricadvantage:sulfur".equals(candidate.get("source_id").getAsString())) { + outputOnly = candidate.get("loaded").getAsBoolean() + && !candidate.get("placement_active").getAsBoolean() + && !candidate.get("external").getAsBoolean(); + } + } + assertTrue(outputOnly); + } + + @Test + void materialGroupsInferExactNamesAndPreserveCuratedEdits() { + JsonObject root = root(); + JsonObject groups = new JsonObject(); + JsonObject sulfur = new JsonObject(); + sulfur.addProperty("display_name", "Brimstone"); + JsonArray aliases = new JsonArray(); + aliases.add(new JsonPrimitive("oreSulfur")); + sulfur.add("ore_dictionary_entries", aliases); + groups.add("orespawn:sulfur", sulfur); + root.add(OreMaterialGroups.SECTION, groups); + addOre(root, "example:cobalt", "example", "minecraft:coal_ore", + "minecraft:overworld"); + root.getAsJsonObject("ores").getAsJsonObject("example:cobalt").remove("material"); + // The test block has no cobalt dictionary entry; exercise inference directly. + OreSourcePolicies.Inference cobalt = OreSourcePolicies.inferMaterial(root, + Collections.singletonList("oreCobalt")); + assertEquals("orespawn:cobalt", cobalt.material.toString()); + + OreMaterialGroups.initialize(root); + JsonObject savedSulfur = root.getAsJsonObject(OreMaterialGroups.SECTION) + .getAsJsonObject("orespawn:sulfur"); + assertEquals("Brimstone", savedSulfur.get("display_name").getAsString()); + assertEquals(1, savedSulfur.getAsJsonArray("ore_dictionary_entries").size(), + "curated aliases return only through the explicit Reset Defaults action"); + } + private static JsonObject rootWithSulfurConflict() { JsonObject root = root(); addOre(root, "mineralogy:sulfur", "mineralogy", "minecraft:iron_ore", "minecraft:overworld"); diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileTest.java index 929b0b78..495e4a09 100644 --- a/src/test/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileTest.java @@ -137,6 +137,8 @@ void freshProfilesConsolidateReviewedConflictsButUpgradesStaySeparate() { global, GeologyMode.GEOME, false); JsonObject freshPolicy = onlyPolicy(fresh.toJson()); assertEquals("consolidated", freshPolicy.get("mode").getAsString()); + assertEquals("balanced", freshPolicy.get("output_mode").getAsString()); + assertEquals(2, freshPolicy.getAsJsonObject("outputs").entrySet().size()); JsonObject legacy = JsonCopies.copy(global); legacy.addProperty("schema_version", 5); @@ -145,6 +147,27 @@ void freshProfilesConsolidateReviewedConflictsButUpgradesStaySeparate() { JsonObject upgradedPolicy = onlyPolicy(upgraded.toJson()); assertEquals("keep_separate", upgradedPolicy.get("mode").getAsString()); assertEquals(2, upgradedPolicy.getAsJsonObject("outputs").entrySet().size()); + assertTrue(upgraded.toJson().has(OreMaterialGroups.SECTION)); + assertEquals(WorldGeologyProfile.SCHEMA_VERSION, + upgraded.toJson().get("schema_version").getAsInt()); + } + + @Test + void schemaSixConsolidatedPoliciesInferBalancedOutputMode() { + JsonObject global = completeGlobalFixture(); + addSulfurConflict(global); + WorldGeologyProfile fresh = WorldGeologyProfile.fromGlobalConfig( + global, GeologyMode.GEOME, false); + JsonObject oldWorld = fresh.rootCopy(); + oldWorld.addProperty("schema_version", 6); + oldWorld.remove(OreMaterialGroups.SECTION); + onlyPolicy(oldWorld).remove("output_mode"); + + WorldGeologyProfile migrated = WorldGeologyProfile.fromJson(oldWorld, fresh); + + assertEquals(7, migrated.toJson().get("schema_version").getAsInt()); + assertEquals("balanced", onlyPolicy(migrated.toJson()).get("output_mode").getAsString()); + assertTrue(migrated.toJson().has(OreMaterialGroups.SECTION)); } @Test From 08ec58701137231754e2079f1b77dfd6c89e9632 Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Thu, 17 Sep 2026 09:48:08 +0100 Subject: [PATCH 12/31] Keep Ore Sources on one screen --- CHANGELOG.txt | 7 +- README.md | 21 +- build.gradle | 1 + docs/PLAYER_GUIDE.md | 15 +- .../orespawn/client/ClientProbeTestMod.java | 39 +- .../orespawn/client/OreSourceListScreen.java | 397 ++++++++++++------ .../resources/assets/orespawn/lang/de_AU.lang | 2 - .../resources/assets/orespawn/lang/de_DE.lang | 2 - .../resources/assets/orespawn/lang/en_CA.lang | 2 - .../resources/assets/orespawn/lang/en_EN.lang | 2 - .../resources/assets/orespawn/lang/en_GB.lang | 2 - .../resources/assets/orespawn/lang/en_PT.lang | 2 - .../resources/assets/orespawn/lang/en_US.lang | 2 - .../resources/assets/orespawn/lang/es_ES.lang | 2 - .../resources/assets/orespawn/lang/es_MX.lang | 2 - .../resources/assets/orespawn/lang/fr_CA.lang | 2 - .../resources/assets/orespawn/lang/fr_FR.lang | 2 - .../resources/assets/orespawn/lang/ja_JP.lang | 2 - .../resources/assets/orespawn/lang/ko_KR.lang | 2 - .../resources/assets/orespawn/lang/pt_BR.lang | 2 - .../resources/assets/orespawn/lang/ru_RU.lang | 2 - .../resources/assets/orespawn/lang/zh_CN.lang | 2 - .../mc/orespawn/LocalizationParityTest.java | 2 +- .../client/GeologyEditorSessionTest.java | 5 + .../client/OreSpawnScreenLayoutTest.java | 27 +- 25 files changed, 348 insertions(+), 198 deletions(-) diff --git a/CHANGELOG.txt b/CHANGELOG.txt index e10ba3e4..d910fc0b 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -6,10 +6,9 @@ Version 4.1.0.110021 upgraded worlds retain their previous separate placement and output behaviour. * Select one weighted output for an entire ordinary vein or custom deposit body using stable world/deposit identity, including safe missing-mod restoration. -* Replace the technical Ore Sources list/detail flow with one responsive - material-group editor for friendly names, exact Ore Dictionary aliases and - selectable ore blocks. It uses side-by-side panes when wide and list/detail - pages at compact resolutions. +* Replace the technical Ore Sources list/detail flow with one material-group + editor that always keeps the group list and aliases beside the deduplicated + ore-block list. Each list paginates independently at compact resolutions. * Add Keep Original, Balanced, Single and Custom output modes. Reviewed fresh MMD conflicts balance every eligible output through one placement budget; inactive provider definitions remain selectable outputs without contributing diff --git a/README.md b/README.md index d2bbfc2f..6e349be7 100644 --- a/README.md +++ b/README.md @@ -58,16 +58,17 @@ 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. -On the ORES tab, **Ore Sources...** opens a material-group editor. Its initial -**Needs Attention** list keeps ordinary one-source materials out of the way; -**All Groups** reveals everything. A group has a friendly name, exact Ore -Dictionary aliases, and the installed ore blocks that may represent it. New -worlds balance all eligible outputs in reviewed MMD conflicts while running one -placement budget, so sulfur/sulphur providers do not multiply abundance. -**Single** chooses one output, **Custom** allows a weighted subset, and **Keep -Original** preserves every active rule. Existing worlds start in Keep Original -until their owner explicitly changes a group. Third-party generators remain -outside OreSpawn's control and are clearly reported rather than disabled. +On the ORES tab, **Ore Sources...** opens one material-group editor. The left +pane lists every discovered group and keeps the selected group's friendly name +and exact Ore Dictionary aliases directly underneath it. The right pane shows +the deduplicated union of installed ore blocks represented by those aliases. +Groups needing attention are sorted first. New worlds balance all eligible +outputs in reviewed MMD conflicts while running one placement budget, so +sulfur/sulphur providers do not multiply abundance. **Single** chooses one +output, **Custom** allows a weighted subset, and **Keep Original** preserves +every active rule. Existing worlds start in Keep Original until their owner +explicitly changes a group. Third-party generators remain outside OreSpawn's +control and are clearly reported rather than disabled. Important files: diff --git a/build.gradle b/build.gradle index ee87396d..f309bc9e 100644 --- a/build.gradle +++ b/build.gradle @@ -676,6 +676,7 @@ task clientIntegrationProcess(type: JavaExec, dependsOn: prepareClientIntegratio || 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}") diff --git a/docs/PLAYER_GUIDE.md b/docs/PLAYER_GUIDE.md index ecbadff1..9bf91ed6 100644 --- a/docs/PLAYER_GUIDE.md +++ b/docs/PLAYER_GUIDE.md @@ -47,13 +47,14 @@ safely instead of breaking world creation. - **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**. It - starts on **Needs Attention**; use **All Groups** for harmless single-source - materials. The left side edits the friendly group name and exact Ore - Dictionary aliases. The right side selects installed ore blocks: **Balanced** - shares them equally, **Single** uses one, **Custom** enables a weighted - subset, and **Keep Original** preserves every old placement rule. At narrow - resolutions the same editor uses separate list and selected-group pages. - Advanced placement-channel choices stay collapsed unless needed. + keeps every discovered group, the selected friendly name and its exact Ore + Dictionary aliases together in the left pane. The right pane always shows + the deduplicated union of installed ore blocks represented by those aliases: + **Balanced** shares them equally, **Single** uses one, **Custom** enables a + weighted subset, and **Keep Original** preserves every old placement rule. + Groups needing attention sort first. Each list paginates independently at + smaller resolutions, so both panes remain visible. Advanced + placement-channel choices stay collapsed unless needed. - **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 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 513d6585..a15fd839 100644 --- a/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java +++ b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java @@ -54,6 +54,8 @@ public final class ClientProbeTestMod { 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 @@ -246,8 +248,9 @@ private void validateExpectedDirectory(GuiScreen root) { } private void validateExpectedOreSources(OreSpawnWorldSettingsScreen root) { + GeologyEditorSession session = editorSession(root); GeologyEditorSession.OreSourceGroup sulfur = null; - for (GeologyEditorSession.OreSourceGroup group : editorSession(root).oreSourceGroups()) { + for (GeologyEditorSession.OreSourceGroup group : session.oreSourceGroups()) { if ("orespawn:sulfur".equals(group.material) && "minecraft:overworld".equals(group.domain)) sulfur = group; } @@ -282,9 +285,42 @@ private void validateExpectedOreSources(OreSpawnWorldSettingsScreen root) { 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 sulfurName = false; + boolean sulfur = false; + boolean sulphur = false; + boolean mineralogy = false; + boolean electric = false; + boolean back = false; + for (GuiButton widget : screen.buttons) { + String caption = TextFormatting.getTextWithoutFormattingCodes(widget.displayString); + if (widget instanceof TextFieldWidget && "Sulfur".equals(((TextFieldWidget) widget).getValue()) + && widget.xPosition < right) sulfurName = true; + if ("oreSulfur".equals(caption) && widget.xPosition < right) sulfur = true; + if ("oreSulphur".equals(caption) && widget.xPosition < right) sulphur = true; + if (caption != null && widget.xPosition >= right && caption.contains("Mineralogy")) mineralogy = true; + if (caption != null && widget.xPosition >= right && caption.contains("Electric")) electric = true; + if ("Back".equals(caption)) back = true; + } + if (!sulfurName || !sulfur || !sulphur || !mineralogy || !electric || back) { + throw new IllegalStateException("Ore Sources was not one complete two-pane screen: name=" + + sulfurName + ", aliases=" + sulfur + "/" + sulphur + ", outputs=" + + mineralogy + "/" + electric + ", back=" + back + ", left=" + left + + ", right=" + right); + } + oreSourcesLayoutValidated = true; + } + private static GeologyEditorSession editorSession(OreSpawnWorldSettingsScreen root) { try { Field field = OreSpawnWorldSettingsScreen.class.getDeclaredField("session"); @@ -450,6 +486,7 @@ private void writeMarker() throws IOException { 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/client/OreSourceListScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceListScreen.java index 33036870..3043af7f 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/OreSourceListScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceListScreen.java @@ -13,25 +13,37 @@ import net.minecraft.util.text.TextComponentString; import net.minecraft.util.text.TextComponentTranslation; -/** Responsive master/detail editor for material groups and their output blocks. */ +/** Single-screen material-group editor with independently paginated panes. */ final class OreSourceListScreen extends OreSpawnScreen { - private static final int WIDE_MINIMUM = 520; + private static final int HORIZONTAL_MARGIN = 10; + private static final int PANE_GAP = 6; + private static final int CONTENT_TOP = 28; private static final int ROW_HEIGHT = 22; private final GuiScreen parent; private final GeologyEditorSession session; private final Map weights = new LinkedHashMap<>(); - private boolean showAll; - private boolean compactDetail; private boolean advanced; private int groupPage; + private int aliasPage; private int outputPage; - private int aliasIndex; private int channelIndex; private String selectedKey; private String pendingAliasMove; private String error; private TextFieldWidget nameField; private TextFieldWidget aliasField; + private int leftPaneX; + private int leftPaneWidth; + private int rightPaneX; + private int rightPaneWidth; + private int paneBottom; + private int groupNavigationY; + private int groupPages = 1; + private int nameLabelY; + private int aliasLabelY; + private int aliasPages = 1; + private int outputNavigationY; + private int outputPages = 1; OreSourceListScreen(GuiScreen parent, GeologyEditorSession session) { super(new TextComponentTranslation("screen.orespawn.ore_sources")); @@ -45,186 +57,234 @@ protected void init() { weights.clear(); nameField = null; aliasField = null; - List groups = visibleGroups(); + List groups = orderedGroups(); if (selected(groups) == null && !groups.isEmpty()) selectedKey = groups.get(0).key; - boolean compact = compact(width); - int footer = height - 28; - if (compact && !compactDetail) { - initDirectory(12, 30, width - 24, footer - 34, groups, true); - } else if (compact) { - initCompactDetail(12, 28, width - 24, footer - 32); - } else { - int contentWidth = Math.min(700, width - 20); - int left = (width - contentWidth) / 2; - int leftWidth = Math.max(190, (contentWidth * 2) / 5); - initDirectory(left, 28, leftWidth, footer - 32, groups, false); - initOutputs(left + leftWidth + 8, 28, contentWidth - leftWidth - 8, footer - 32); - } - if (compact && compactDetail) { - addButton(new Button(12, footer, 92, 20, - new TextComponentTranslation("button.orespawn.back"), button -> { - syncFields(); compactDetail = false; rebuild(false); - })); - addButton(new Button(width - 162, footer, 150, 20, - DialogTexts.GUI_DONE, button -> onClose())); - } else { - addButton(new Button(width / 2 - 75, footer, 150, 20, - DialogTexts.GUI_DONE, button -> onClose())); - } + + int contentWidth = contentWidth(width); + leftPaneX = (width - contentWidth) / 2; + leftPaneWidth = leftPaneWidth(width); + rightPaneX = leftPaneX + leftPaneWidth + PANE_GAP; + rightPaneWidth = contentWidth - leftPaneWidth - PANE_GAP; + paneBottom = height - 32; + + initDirectory(leftPaneX, CONTENT_TOP, leftPaneWidth, groups); + initOutputs(rightPaneX, CONTENT_TOP, rightPaneWidth); + addButton(new Button(width / 2 - 75, height - 28, 150, 20, + DialogTexts.GUI_DONE, button -> onClose())); } - private void initDirectory(int left, int top, int paneWidth, int bottom, - List groups, boolean compactLayout) { - int half = (paneWidth - 5) / 2; - addButton(OreSpawnScreenLayout.button(this, font, left, top, half, 20, - new TextComponentTranslation(showAll ? "button.orespawn.ore_source.needs_attention" - : "button.orespawn.ore_source.all_groups"), button -> { - showAll = !showAll; groupPage = 0; selectedKey = null; rebuild(true); - })); - addButton(OreSpawnScreenLayout.button(this, font, left + half + 5, top, half, 20, - new TextComponentTranslation("button.orespawn.ore_source.add_group"), button -> { - syncFields(); selectedKey = session.addOreMaterialGroup(); showAll = true; - compactDetail = compact(width); groupPage = 0; rebuild(false); - })); + private void initDirectory(int left, int top, int paneWidth, List groups) { + addButton(new Button(left + paneWidth - 24, top, 24, 20, + new TextComponentString("+"), button -> { + syncFields(); + selectedKey = session.addOreMaterialGroup(); + pendingAliasMove = null; + aliasPage = 0; + outputPage = 0; + List updated = orderedGroups(); + int index = groupIndexOf(updated, selectedKey); + groupPage = index < 0 ? 0 : index / groupRowCount(height); + rebuild(false); + }, (button, mouseX, mouseY) -> renderStringTooltip( + java.util.Collections.singletonList(I18n.format( + "button.orespawn.ore_source.add_group")), mouseX, mouseY))); - int listTop = top + 24; - int editorReserve = compactLayout ? 24 : 94; - int rows = Math.max(1, Math.min(5, (bottom - listTop - editorReserve - 24) / ROW_HEIGHT)); - int pages = pageCount(groups.size(), rows); - groupPage = Math.max(0, Math.min(groupPage, pages - 1)); + int listTop = top + 22; + int rows = groupRowCount(height); + groupPages = pageCount(groups.size(), rows); + groupPage = Math.max(0, Math.min(groupPage, groupPages - 1)); int start = groupPage * rows; for (int index = 0; index < rows && start + index < groups.size(); index++) { OreSourceGroup group = groups.get(start + index); int y = listTop + (index * ROW_HEIGHT); - String prefix = group.key.equals(selectedKey) ? "> " : ""; + String prefix = group.key.equals(selectedKey) ? "> " : group.needsAttention() ? "! " : ""; String label = prefix + group.displayName + " - " + display(group.domain); - Button row = addButton(new Button(left, y, paneWidth, 20, + addButton(new Button(left, y, paneWidth, 20, OreSpawnScreenLayout.fit(font, new TextComponentString(label), paneWidth - 8), button -> { - syncFields(); selectedKey = group.key; compactDetail = compact(width); - outputPage = 0; aliasIndex = 0; error = null; rebuild(false); + syncFields(); + selectedKey = group.key; + pendingAliasMove = null; + aliasPage = 0; + outputPage = 0; + error = null; + rebuild(false); }, (button, mouseX, mouseY) -> renderStringTooltip(groupTooltip(group), mouseX, mouseY))); - row.enabled = !group.key.equals(selectedKey) || compactLayout; } - int navigationY = listTop + (rows * ROW_HEIGHT); - Button previous = addButton(new Button(left, navigationY, 48, 20, - new TextComponentString("<"), button -> { syncFields(); groupPage--; rebuild(false); })); - Button next = addButton(new Button(left + paneWidth - 48, navigationY, 48, 20, - new TextComponentString(">"), button -> { syncFields(); groupPage++; rebuild(false); })); + + groupNavigationY = listTop + (rows * ROW_HEIGHT); + Button previous = addButton(new Button(left, groupNavigationY, 32, 20, + new TextComponentString("<"), button -> { + syncFields(); + groupPage--; + rebuild(false); + })); + Button next = addButton(new Button(left + paneWidth - 32, groupNavigationY, 32, 20, + new TextComponentString(">"), button -> { + syncFields(); + groupPage++; + rebuild(false); + })); previous.enabled = groupPage > 0; - next.enabled = groupPage + 1 < pages; - if (!compactLayout) initGroupEditor(left, navigationY + 24, paneWidth, bottom); + next.enabled = groupPage + 1 < groupPages; + initGroupEditor(left, groupNavigationY + 24, paneWidth); } - private void initCompactDetail(int left, int top, int paneWidth, int bottom) { - OreSourceGroup group = selected(session.oreSourceGroups()); + private void initGroupEditor(int left, int top, int paneWidth) { + OreSourceGroup group = selected(orderedGroups()); if (group == null) return; - initGroupEditor(left, top, paneWidth, top + 70); - initOutputs(left, top + 74, paneWidth, bottom); - } - private void initGroupEditor(int left, int top, int paneWidth, int bottom) { - OreSourceGroup group = selected(session.oreSourceGroups()); - if (group == null || top + 20 > bottom) return; - int actionWidth = 58; - nameField = addButton(new TextFieldWidget(font, left, top, paneWidth - actionWidth - 5, 20, + nameLabelY = top; + int nameTop = top + 10; + int actionWidth = Math.min(58, Math.max(41, paneWidth / 3)); + nameField = addButton(new TextFieldWidget(font, left, nameTop, + paneWidth - actionWidth - 5, 20, new TextComponentTranslation("option.orespawn.ore_source.group_name"))); nameField.setMaxLength(64); nameField.setValue(group.displayName); Button groupAction = addButton(OreSpawnScreenLayout.button(this, font, - left + paneWidth - actionWidth, top, actionWidth, 20, + left + paneWidth - actionWidth, nameTop, actionWidth, 20, new TextComponentTranslation(group.curated ? "button.orespawn.reset" : "button.orespawn.ore_source.delete_group"), button -> { if (group.curated) session.resetOreMaterialGroup(group.material); - else { session.deleteOreMaterialGroup(group.material); selectedKey = null; } + else { + session.deleteOreMaterialGroup(group.material); + selectedKey = null; + groupPage = 0; + } + pendingAliasMove = null; rebuild(false); })); OreSpawnScreenLayout.explain(this, groupAction, group.curated ? "tooltip.orespawn.ore_source.reset_group" : "tooltip.orespawn.ore_source.delete_group"); - if (top + 44 > bottom) return; + + aliasLabelY = nameTop + 24; + int aliasesTop = aliasLabelY + 10; + int rows = aliasRowCount(height); List aliases = group.oreDictionaryEntries; - aliasIndex = aliases.isEmpty() ? 0 : Math.max(0, Math.min(aliasIndex, aliases.size() - 1)); - String alias = aliases.isEmpty() ? I18n.format("label.orespawn.ore_source.no_aliases") - : aliases.get(aliasIndex); - Button aliasButton = addButton(new Button(left, top + 24, paneWidth - 46, 20, - OreSpawnScreenLayout.fit(font, new TextComponentString(alias), paneWidth - 54), button -> { - if (!aliases.isEmpty()) { aliasIndex = (aliasIndex + 1) % aliases.size(); rebuild(false); } - })); - aliasButton.enabled = aliases.size() > 1; - Button remove = addButton(new Button(left + paneWidth - 41, top + 24, 41, 20, - new TextComponentString("-"), button -> { - if (!aliases.isEmpty()) session.removeOreMaterialAlias(group.material, aliases.get(aliasIndex)); - aliasIndex = 0; rebuild(false); - })); - remove.enabled = !aliases.isEmpty(); - if (top + 68 > bottom) return; - aliasField = addButton(new TextFieldWidget(font, left, top + 48, paneWidth - 46, 20, + aliasPages = pageCount(aliases.size(), rows); + aliasPage = Math.max(0, Math.min(aliasPage, aliasPages - 1)); + int start = aliasPage * rows; + for (int index = 0; index < rows && start + index < aliases.size(); index++) { + String alias = aliases.get(start + index); + int y = aliasesTop + (index * ROW_HEIGHT); + addButton(new Button(left, y, paneWidth - 27, 20, + OreSpawnScreenLayout.fit(font, new TextComponentString(alias), paneWidth - 35), + button -> { }, (button, mouseX, mouseY) -> renderStringTooltip( + java.util.Collections.singletonList(alias), mouseX, mouseY))); + addButton(new Button(left + paneWidth - 22, y, 22, 20, + new TextComponentString("-"), button -> { + syncName(); + session.removeOreMaterialAlias(group.material, alias); + pendingAliasMove = null; + rebuild(false); + })); + } + + int addTop = aliasesTop + (rows * ROW_HEIGHT) + 2; + int addWidth = 41; + int fieldWidth = Math.max(30, paneWidth - addWidth - 49); + aliasField = addButton(new TextFieldWidget(font, left, addTop, fieldWidth, 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)); } - Button add = addButton(new Button(left + paneWidth - 41, top + 48, 41, 20, - new TextComponentString(pendingAliasMove == null ? "+" : I18n.format("button.orespawn.ore_source.move")), - button -> addAlias(group))); + Button aliasPrevious = addButton(new Button(left + fieldWidth + 3, addTop, 20, 20, + new TextComponentString("<"), button -> { + syncName(); + aliasPage--; + rebuild(false); + })); + Button aliasNext = addButton(new Button(left + fieldWidth + 26, addTop, 20, 20, + new TextComponentString(">"), button -> { + syncName(); + aliasPage++; + rebuild(false); + })); + aliasPrevious.enabled = aliasPage > 0; + aliasNext.enabled = aliasPage + 1 < aliasPages; + Button add = addButton(new Button(left + paneWidth - addWidth, addTop, addWidth, 20, + new TextComponentString(pendingAliasMove == null ? "+" + : I18n.format("button.orespawn.ore_source.move")), button -> addAlias(group))); OreSpawnScreenLayout.explain(this, add, "tooltip.orespawn.ore_source.alias"); } - private void initOutputs(int left, int top, int paneWidth, int bottom) { - OreSourceGroup group = selected(session.oreSourceGroups()); + private void initOutputs(int left, int top, int paneWidth) { + OreSourceGroup group = selected(orderedGroups()); if (group == null) return; String mode = "keep_separate".equals(group.mode) ? "keep_original" : group.outputMode; Button modeButton = addButton(OreSpawnScreenLayout.button(this, font, left, top, paneWidth, 20, new TextComponentTranslation("option.orespawn.ore_source.output_mode", new TextComponentTranslation("mode.orespawn.ore_source." + mode).getFormattedText()), button -> { - syncFields(); cycleMode(group); rebuild(false); + syncFields(); + cycleMode(group); + outputPage = 0; + rebuild(false); })); OreSpawnScreenLayout.explain(this, modeButton, "tooltip.orespawn.ore_source.output_mode"); - int advancedReserve = advanced ? 48 : 24; int listTop = top + 24; List candidates = group.outputCandidates(); - int rows = Math.max(1, (bottom - listTop - advancedReserve - 22) / ROW_HEIGHT); - int pages = pageCount(candidates.size(), rows); - outputPage = Math.max(0, Math.min(outputPage, pages - 1)); + int rows = outputRowCount(height, advanced); + outputPages = pageCount(candidates.size(), rows); + outputPage = Math.max(0, Math.min(outputPage, outputPages - 1)); int start = outputPage * rows; for (int index = 0; index < rows && start + index < candidates.size(); index++) { OreSourceCandidate candidate = candidates.get(start + index); int y = listTop + (index * ROW_HEIGHT); boolean selected = group.outputs.containsKey(candidate.sourceId); - int weightWidth = "custom".equals(group.outputMode) && !"keep_separate".equals(group.mode) ? 58 : 0; - Button toggle = addButton(new Button(left, y, paneWidth - weightWidth - (weightWidth == 0 ? 0 : 5), 20, - OreSpawnScreenLayout.fit(font, new TextComponentString((selected ? "[x] " : "[ ] ") - + candidateLabel(candidate)), paneWidth - weightWidth - 13), button -> { - syncFields(); session.setOreSourceOutput(group.key, candidate.sourceId, - !group.outputs.containsKey(candidate.sourceId), - group.outputs.containsKey(candidate.sourceId) ? group.outputs.get(candidate.sourceId) : 1.0D); + boolean single = "single".equals(group.outputMode) && !"keep_separate".equals(group.mode); + String marker = single ? (selected ? "(*) " : "( ) ") : (selected ? "[x] " : "[ ] "); + int weightWidth = "custom".equals(group.outputMode) + && !"keep_separate".equals(group.mode) ? 58 : 0; + Button toggle = addButton(new Button(left, y, + paneWidth - weightWidth - (weightWidth == 0 ? 0 : 5), 20, + OreSpawnScreenLayout.fit(font, new TextComponentString(marker + candidateLabel(candidate)), + paneWidth - weightWidth - 13), button -> { + syncFields(); + session.setOreSourceOutput(group.key, candidate.sourceId, + single || !group.outputs.containsKey(candidate.sourceId), + group.outputs.containsKey(candidate.sourceId) + ? group.outputs.get(candidate.sourceId) : 1.0D); rebuild(false); }, (button, mouseX, mouseY) -> renderStringTooltip(candidateTooltip(candidate), mouseX, mouseY))); toggle.enabled = candidate.loaded && !candidate.enrichment && !"keep_separate".equals(group.mode); if (weightWidth > 0) { TextFieldWidget weight = addButton(new TextFieldWidget(font, left + paneWidth - weightWidth, - y, weightWidth, 20, new TextComponentTranslation("option.orespawn.ore_source.weight"))); + y, weightWidth, 20, + new TextComponentTranslation("option.orespawn.ore_source.weight"))); weight.setMaxLength(12); weight.setValue(formatWeight(group.outputs.get(candidate.sourceId))); weight.enabled = selected && candidate.loaded && !candidate.enrichment; weights.put(candidate.sourceId, weight); } } - int navigationY = listTop + (rows * ROW_HEIGHT); - Button previous = addButton(new Button(left, navigationY, 40, 20, - new TextComponentString("<"), button -> { syncFields(); outputPage--; rebuild(false); })); - Button next = addButton(new Button(left + paneWidth - 40, navigationY, 40, 20, - new TextComponentString(">"), button -> { syncFields(); outputPage++; rebuild(false); })); + + outputNavigationY = listTop + (rows * ROW_HEIGHT); + Button previous = addButton(new Button(left, outputNavigationY, 32, 20, + new TextComponentString("<"), button -> { + syncFields(); + outputPage--; + rebuild(false); + })); + Button next = addButton(new Button(left + paneWidth - 32, outputNavigationY, 32, 20, + new TextComponentString(">"), button -> { + syncFields(); + outputPage++; + rebuild(false); + })); previous.enabled = outputPage > 0; - next.enabled = outputPage + 1 < pages; - Button advancedButton = addButton(OreSpawnScreenLayout.button(this, font, - left + 45, navigationY, paneWidth - 90, 20, + next.enabled = outputPage + 1 < outputPages; + addButton(OreSpawnScreenLayout.button(this, font, left + 37, outputNavigationY, + paneWidth - 74, 20, new TextComponentTranslation(advanced ? "button.orespawn.ore_source.hide_advanced" : "button.orespawn.ore_source.advanced"), button -> { - syncFields(); advanced = !advanced; rebuild(false); - })); - if (advanced) initAdvanced(group, left, navigationY + 24, paneWidth); + syncFields(); + advanced = !advanced; + rebuild(false); + })); + if (advanced) initAdvanced(group, left, outputNavigationY + 24, paneWidth); } private void initAdvanced(OreSourceGroup group, int left, int y, int paneWidth) { @@ -235,19 +295,22 @@ private void initAdvanced(OreSourceGroup group, int left, int y, int paneWidth) int half = (paneWidth - 5) / 2; addButton(OreSpawnScreenLayout.button(this, font, left, y, half, 20, new TextComponentString(display(channel)), button -> { - channelIndex = (channelIndex + 1) % channels.size(); rebuild(false); + channelIndex = (channelIndex + 1) % channels.size(); + rebuild(false); })); List placements = placementCandidates(group, channel); String selected = group.placements.get(channel); int index = indexOf(placements, selected); if (index < 0 && !placements.isEmpty()) index = 0; final int selectedIndex = index; - Button placement = addButton(OreSpawnScreenLayout.button(this, font, left + half + 5, y, half, 20, + Button placement = addButton(OreSpawnScreenLayout.button(this, font, + left + half + 5, y, half, 20, new TextComponentString(index < 0 ? I18n.format("label.orespawn.ore_source.missing") : candidateLabel(placements.get(index))), button -> { if (!placements.isEmpty()) { OreSourceCandidate next = placements.get((selectedIndex + 1) % placements.size()); - session.setOreSourcePlacement(group.key, channel, next.sourceId); rebuild(false); + session.setOreSourcePlacement(group.key, channel, next.sourceId); + rebuild(false); } })); placement.enabled = !placements.isEmpty(); @@ -277,13 +340,14 @@ private void addAlias(OreSourceGroup group) { } pendingAliasMove = null; error = null; - aliasIndex = 0; + aliasPage = Math.max(0, pageCount(group.oreDictionaryEntries.size() + 1, + aliasRowCount(height)) - 1); rebuild(false); } private void syncFields() { syncName(); - OreSourceGroup group = selected(session.oreSourceGroups()); + OreSourceGroup group = selected(orderedGroups()); if (group == null || !"custom".equals(group.outputMode)) return; for (Map.Entry entry : weights.entrySet()) { if (!group.outputs.containsKey(entry.getKey())) continue; @@ -298,15 +362,21 @@ private void syncFields() { } private void syncName() { - OreSourceGroup group = selected(session.oreSourceGroups()); - if (group != null && nameField != null) session.renameOreMaterialGroup(group.material, nameField.getValue()); + OreSourceGroup group = selected(orderedGroups()); + if (group != null && nameField != null) { + session.renameOreMaterialGroup(group.material, nameField.getValue()); + } } - private List visibleGroups() { - List all = session.oreSourceGroups(); - if (showAll) return all; - List result = new ArrayList<>(); - for (OreSourceGroup group : all) if (group.needsAttention()) result.add(group); + private List orderedGroups() { + List result = new ArrayList<>(session.oreSourceGroups()); + result.sort((left, right) -> { + if (left.needsAttention() != right.needsAttention()) return left.needsAttention() ? -1 : 1; + 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; } @@ -332,6 +402,13 @@ private static int indexOf(List candidates, String sourceId) return -1; } + private 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; + } + private 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") @@ -359,7 +436,8 @@ private static List groupTooltip(OreSourceGroup group) { 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(group.oreDictionaryEntries.isEmpty() ? "-" + : String.join(", ", group.oreDictionaryEntries)); return result; } @@ -382,7 +460,29 @@ private void rebuild(boolean clearError) { init(); } - static boolean compact(int width) { return width < WIDE_MINIMUM; } + 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 aliasRowCount(int height) { + return height >= 252 ? 2 : 1; + } + + static int groupRowCount(int height) { + int available = (height - 32) - CONTENT_TOP; + int fixed = 112 + (aliasRowCount(height) * ROW_HEIGHT); + return Math.max(1, Math.min(5, (available - fixed) / ROW_HEIGHT)); + } + + static int outputRowCount(int height, boolean advanced) { + int available = (height - 32) - (CONTENT_TOP + 24) - (advanced ? 68 : 44); + return Math.max(1, available / 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)); @@ -397,13 +497,34 @@ public void onClose() { @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); - if (error != null) drawCenteredString(font, new TextComponentString( - OreSpawnScreenLayout.fit(font, new TextComponentString(error), width - 24)), width / 2, 19, 0xFF5555); - List groups = visibleGroups(); - if (groups.isEmpty()) drawCenteredString(font, - new TextComponentTranslation(showAll ? "label.orespawn.ore_source.none" - : "label.orespawn.ore_source.none_attention"), width / 2, height / 2, 0xA0A0A0); + if (error != null) { + drawCenteredString(font, new TextComponentString(OreSpawnScreenLayout.fit(font, + new TextComponentString(error), width - 24)), width / 2, 19, 0xFF5555); + } + drawString(font, new TextComponentTranslation("button.orespawn.ore_source.all_groups"), + leftPaneX + 4, CONTENT_TOP + 6, 0xFFFFFF); + drawCenteredString(font, new TextComponentString((groupPage + 1) + " / " + groupPages), + leftPaneX + (leftPaneWidth / 2), groupNavigationY + 6, 0xA0A0A0); + OreSourceGroup group = selected(orderedGroups()); + if (group == null) { + drawCenteredString(font, new TextComponentTranslation("label.orespawn.ore_source.none"), + leftPaneX + (leftPaneWidth / 2), CONTENT_TOP + 68, 0xA0A0A0); + } else { + drawString(font, new TextComponentTranslation("option.orespawn.ore_source.group_name"), + leftPaneX + 2, nameLabelY, 0xA0A0A0); + String aliases = I18n.format("option.orespawn.ore_source.alias") + + " " + (aliasPage + 1) + " / " + aliasPages; + drawString(font, new TextComponentString(OreSpawnScreenLayout.fit(font, + new TextComponentString(aliases), leftPaneWidth - 4)), + leftPaneX + 2, aliasLabelY, 0xA0A0A0); + drawCenteredString(font, new TextComponentString((outputPage + 1) + " / " + outputPages), + rightPaneX + (rightPaneWidth / 2), outputNavigationY + 6, 0xA0A0A0); + } super.render(mouseX, mouseY, partialTick); OreSpawnScreenLayout.renderExplanations(this, mouseX, mouseY); } diff --git a/src/main/resources/assets/orespawn/lang/de_AU.lang b/src/main/resources/assets/orespawn/lang/de_AU.lang index e06d6691..5363334e 100644 --- a/src/main/resources/assets/orespawn/lang/de_AU.lang +++ b/src/main/resources/assets/orespawn/lang/de_AU.lang @@ -417,7 +417,6 @@ tooltip.orespawn.ore_source.enrichment=Dieser Eintrag ist eine Anreicherung und tooltip.orespawn.ore_source.missing=Diese Quelle ist nicht verfügbar. Die gespeicherte Auswahl wird wiederhergestellt, wenn der Mod zurückkehrt. error.orespawn.ore_source.weight=Ausgabegewichte müssen positive Zahlen sein. button.orespawn.back=Zurück -button.orespawn.ore_source.needs_attention=Zu prüfen button.orespawn.ore_source.all_groups=Alle Gruppen button.orespawn.ore_source.add_group=Gruppe hinzufügen button.orespawn.ore_source.delete_group=Löschen @@ -432,7 +431,6 @@ mode.orespawn.ore_source.balanced=Ausgewogen mode.orespawn.ore_source.single=Einzeln mode.orespawn.ore_source.custom=Benutzerdefiniert label.orespawn.ore_source.no_aliases=Keine Ore-Dictionary-Einträge -label.orespawn.ore_source.none_attention=Keine Erzgruppen müssen geprüft werden. status.orespawn.ore_source.external_short=extern status.orespawn.ore_source.missing_short=fehlt status.orespawn.ore_source.output_only=nur Ausgabe diff --git a/src/main/resources/assets/orespawn/lang/de_DE.lang b/src/main/resources/assets/orespawn/lang/de_DE.lang index e06d6691..5363334e 100644 --- a/src/main/resources/assets/orespawn/lang/de_DE.lang +++ b/src/main/resources/assets/orespawn/lang/de_DE.lang @@ -417,7 +417,6 @@ tooltip.orespawn.ore_source.enrichment=Dieser Eintrag ist eine Anreicherung und tooltip.orespawn.ore_source.missing=Diese Quelle ist nicht verfügbar. Die gespeicherte Auswahl wird wiederhergestellt, wenn der Mod zurückkehrt. error.orespawn.ore_source.weight=Ausgabegewichte müssen positive Zahlen sein. button.orespawn.back=Zurück -button.orespawn.ore_source.needs_attention=Zu prüfen button.orespawn.ore_source.all_groups=Alle Gruppen button.orespawn.ore_source.add_group=Gruppe hinzufügen button.orespawn.ore_source.delete_group=Löschen @@ -432,7 +431,6 @@ mode.orespawn.ore_source.balanced=Ausgewogen mode.orespawn.ore_source.single=Einzeln mode.orespawn.ore_source.custom=Benutzerdefiniert label.orespawn.ore_source.no_aliases=Keine Ore-Dictionary-Einträge -label.orespawn.ore_source.none_attention=Keine Erzgruppen müssen geprüft werden. status.orespawn.ore_source.external_short=extern status.orespawn.ore_source.missing_short=fehlt status.orespawn.ore_source.output_only=nur Ausgabe diff --git a/src/main/resources/assets/orespawn/lang/en_CA.lang b/src/main/resources/assets/orespawn/lang/en_CA.lang index ff720396..26114a23 100644 --- a/src/main/resources/assets/orespawn/lang/en_CA.lang +++ b/src/main/resources/assets/orespawn/lang/en_CA.lang @@ -417,7 +417,6 @@ tooltip.orespawn.ore_source.enrichment=This entry is enrichment, not an intercha tooltip.orespawn.ore_source.missing=This source is unavailable. Its saved selection will be restored if the owning mod returns. error.orespawn.ore_source.weight=Output weights must be positive numbers. button.orespawn.back=Back -button.orespawn.ore_source.needs_attention=Needs Attention button.orespawn.ore_source.all_groups=All Groups button.orespawn.ore_source.add_group=Add Group button.orespawn.ore_source.delete_group=Delete @@ -432,7 +431,6 @@ mode.orespawn.ore_source.balanced=Balanced mode.orespawn.ore_source.single=Single mode.orespawn.ore_source.custom=Custom label.orespawn.ore_source.no_aliases=No Ore Dictionary entries -label.orespawn.ore_source.none_attention=No ore groups need attention. status.orespawn.ore_source.external_short=external status.orespawn.ore_source.missing_short=missing status.orespawn.ore_source.output_only=output only diff --git a/src/main/resources/assets/orespawn/lang/en_EN.lang b/src/main/resources/assets/orespawn/lang/en_EN.lang index ff720396..26114a23 100644 --- a/src/main/resources/assets/orespawn/lang/en_EN.lang +++ b/src/main/resources/assets/orespawn/lang/en_EN.lang @@ -417,7 +417,6 @@ tooltip.orespawn.ore_source.enrichment=This entry is enrichment, not an intercha tooltip.orespawn.ore_source.missing=This source is unavailable. Its saved selection will be restored if the owning mod returns. error.orespawn.ore_source.weight=Output weights must be positive numbers. button.orespawn.back=Back -button.orespawn.ore_source.needs_attention=Needs Attention button.orespawn.ore_source.all_groups=All Groups button.orespawn.ore_source.add_group=Add Group button.orespawn.ore_source.delete_group=Delete @@ -432,7 +431,6 @@ mode.orespawn.ore_source.balanced=Balanced mode.orespawn.ore_source.single=Single mode.orespawn.ore_source.custom=Custom label.orespawn.ore_source.no_aliases=No Ore Dictionary entries -label.orespawn.ore_source.none_attention=No ore groups need attention. status.orespawn.ore_source.external_short=external status.orespawn.ore_source.missing_short=missing status.orespawn.ore_source.output_only=output only diff --git a/src/main/resources/assets/orespawn/lang/en_GB.lang b/src/main/resources/assets/orespawn/lang/en_GB.lang index ff720396..26114a23 100644 --- a/src/main/resources/assets/orespawn/lang/en_GB.lang +++ b/src/main/resources/assets/orespawn/lang/en_GB.lang @@ -417,7 +417,6 @@ tooltip.orespawn.ore_source.enrichment=This entry is enrichment, not an intercha tooltip.orespawn.ore_source.missing=This source is unavailable. Its saved selection will be restored if the owning mod returns. error.orespawn.ore_source.weight=Output weights must be positive numbers. button.orespawn.back=Back -button.orespawn.ore_source.needs_attention=Needs Attention button.orespawn.ore_source.all_groups=All Groups button.orespawn.ore_source.add_group=Add Group button.orespawn.ore_source.delete_group=Delete @@ -432,7 +431,6 @@ mode.orespawn.ore_source.balanced=Balanced mode.orespawn.ore_source.single=Single mode.orespawn.ore_source.custom=Custom label.orespawn.ore_source.no_aliases=No Ore Dictionary entries -label.orespawn.ore_source.none_attention=No ore groups need attention. status.orespawn.ore_source.external_short=external status.orespawn.ore_source.missing_short=missing status.orespawn.ore_source.output_only=output only diff --git a/src/main/resources/assets/orespawn/lang/en_PT.lang b/src/main/resources/assets/orespawn/lang/en_PT.lang index ff720396..26114a23 100644 --- a/src/main/resources/assets/orespawn/lang/en_PT.lang +++ b/src/main/resources/assets/orespawn/lang/en_PT.lang @@ -417,7 +417,6 @@ tooltip.orespawn.ore_source.enrichment=This entry is enrichment, not an intercha tooltip.orespawn.ore_source.missing=This source is unavailable. Its saved selection will be restored if the owning mod returns. error.orespawn.ore_source.weight=Output weights must be positive numbers. button.orespawn.back=Back -button.orespawn.ore_source.needs_attention=Needs Attention button.orespawn.ore_source.all_groups=All Groups button.orespawn.ore_source.add_group=Add Group button.orespawn.ore_source.delete_group=Delete @@ -432,7 +431,6 @@ mode.orespawn.ore_source.balanced=Balanced mode.orespawn.ore_source.single=Single mode.orespawn.ore_source.custom=Custom label.orespawn.ore_source.no_aliases=No Ore Dictionary entries -label.orespawn.ore_source.none_attention=No ore groups need attention. status.orespawn.ore_source.external_short=external status.orespawn.ore_source.missing_short=missing status.orespawn.ore_source.output_only=output only diff --git a/src/main/resources/assets/orespawn/lang/en_US.lang b/src/main/resources/assets/orespawn/lang/en_US.lang index 0f5e18ee..c92a501b 100644 --- a/src/main/resources/assets/orespawn/lang/en_US.lang +++ b/src/main/resources/assets/orespawn/lang/en_US.lang @@ -417,7 +417,6 @@ tooltip.orespawn.ore_source.enrichment=This entry is enrichment, not an intercha tooltip.orespawn.ore_source.missing=This source is unavailable. Its saved selection will be restored if the owning mod returns. error.orespawn.ore_source.weight=Output weights must be positive numbers. button.orespawn.back=Back -button.orespawn.ore_source.needs_attention=Needs Attention button.orespawn.ore_source.all_groups=All Groups button.orespawn.ore_source.add_group=Add Group button.orespawn.ore_source.delete_group=Delete @@ -432,7 +431,6 @@ mode.orespawn.ore_source.balanced=Balanced mode.orespawn.ore_source.single=Single mode.orespawn.ore_source.custom=Custom label.orespawn.ore_source.no_aliases=No Ore Dictionary entries -label.orespawn.ore_source.none_attention=No ore groups need attention. status.orespawn.ore_source.external_short=external status.orespawn.ore_source.missing_short=missing status.orespawn.ore_source.output_only=output only diff --git a/src/main/resources/assets/orespawn/lang/es_ES.lang b/src/main/resources/assets/orespawn/lang/es_ES.lang index 9b776fbc..dc3b70e9 100644 --- a/src/main/resources/assets/orespawn/lang/es_ES.lang +++ b/src/main/resources/assets/orespawn/lang/es_ES.lang @@ -417,7 +417,6 @@ tooltip.orespawn.ore_source.enrichment=Esta entrada es enriquecimiento, no una f tooltip.orespawn.ore_source.missing=Esta fuente no está disponible. La selección guardada se restaurará si vuelve el mod propietario. error.orespawn.ore_source.weight=Los pesos de salida deben ser números positivos. button.orespawn.back=Atrás -button.orespawn.ore_source.needs_attention=Requiere atención button.orespawn.ore_source.all_groups=Todos los grupos button.orespawn.ore_source.add_group=Añadir grupo button.orespawn.ore_source.delete_group=Eliminar @@ -432,7 +431,6 @@ mode.orespawn.ore_source.balanced=Equilibrado mode.orespawn.ore_source.single=Único mode.orespawn.ore_source.custom=Personalizado label.orespawn.ore_source.no_aliases=Sin entradas del diccionario de minerales -label.orespawn.ore_source.none_attention=Ningún grupo de minerales requiere atención. status.orespawn.ore_source.external_short=externo status.orespawn.ore_source.missing_short=ausente status.orespawn.ore_source.output_only=solo salida diff --git a/src/main/resources/assets/orespawn/lang/es_MX.lang b/src/main/resources/assets/orespawn/lang/es_MX.lang index 9b776fbc..dc3b70e9 100644 --- a/src/main/resources/assets/orespawn/lang/es_MX.lang +++ b/src/main/resources/assets/orespawn/lang/es_MX.lang @@ -417,7 +417,6 @@ tooltip.orespawn.ore_source.enrichment=Esta entrada es enriquecimiento, no una f tooltip.orespawn.ore_source.missing=Esta fuente no está disponible. La selección guardada se restaurará si vuelve el mod propietario. error.orespawn.ore_source.weight=Los pesos de salida deben ser números positivos. button.orespawn.back=Atrás -button.orespawn.ore_source.needs_attention=Requiere atención button.orespawn.ore_source.all_groups=Todos los grupos button.orespawn.ore_source.add_group=Añadir grupo button.orespawn.ore_source.delete_group=Eliminar @@ -432,7 +431,6 @@ mode.orespawn.ore_source.balanced=Equilibrado mode.orespawn.ore_source.single=Único mode.orespawn.ore_source.custom=Personalizado label.orespawn.ore_source.no_aliases=Sin entradas del diccionario de minerales -label.orespawn.ore_source.none_attention=Ningún grupo de minerales requiere atención. status.orespawn.ore_source.external_short=externo status.orespawn.ore_source.missing_short=ausente status.orespawn.ore_source.output_only=solo salida diff --git a/src/main/resources/assets/orespawn/lang/fr_CA.lang b/src/main/resources/assets/orespawn/lang/fr_CA.lang index 6e74ef1f..9054368f 100644 --- a/src/main/resources/assets/orespawn/lang/fr_CA.lang +++ b/src/main/resources/assets/orespawn/lang/fr_CA.lang @@ -417,7 +417,6 @@ tooltip.orespawn.ore_source.enrichment=Cette entrée est un enrichissement, pas tooltip.orespawn.ore_source.missing=Cette source est indisponible. Le choix enregistré sera restauré si son mod revient. error.orespawn.ore_source.weight=Les poids de sortie doivent être des nombres positifs. button.orespawn.back=Retour -button.orespawn.ore_source.needs_attention=À vérifier button.orespawn.ore_source.all_groups=Tous les groupes button.orespawn.ore_source.add_group=Ajouter un groupe button.orespawn.ore_source.delete_group=Supprimer @@ -432,7 +431,6 @@ mode.orespawn.ore_source.balanced=Équilibré mode.orespawn.ore_source.single=Unique mode.orespawn.ore_source.custom=Personnalisé label.orespawn.ore_source.no_aliases=Aucune entrée du dictionnaire de minerais -label.orespawn.ore_source.none_attention=Aucun groupe de minerais ne nécessite d'attention. status.orespawn.ore_source.external_short=externe status.orespawn.ore_source.missing_short=absente status.orespawn.ore_source.output_only=sortie uniquement diff --git a/src/main/resources/assets/orespawn/lang/fr_FR.lang b/src/main/resources/assets/orespawn/lang/fr_FR.lang index 6e74ef1f..9054368f 100644 --- a/src/main/resources/assets/orespawn/lang/fr_FR.lang +++ b/src/main/resources/assets/orespawn/lang/fr_FR.lang @@ -417,7 +417,6 @@ tooltip.orespawn.ore_source.enrichment=Cette entrée est un enrichissement, pas tooltip.orespawn.ore_source.missing=Cette source est indisponible. Le choix enregistré sera restauré si son mod revient. error.orespawn.ore_source.weight=Les poids de sortie doivent être des nombres positifs. button.orespawn.back=Retour -button.orespawn.ore_source.needs_attention=À vérifier button.orespawn.ore_source.all_groups=Tous les groupes button.orespawn.ore_source.add_group=Ajouter un groupe button.orespawn.ore_source.delete_group=Supprimer @@ -432,7 +431,6 @@ mode.orespawn.ore_source.balanced=Équilibré mode.orespawn.ore_source.single=Unique mode.orespawn.ore_source.custom=Personnalisé label.orespawn.ore_source.no_aliases=Aucune entrée du dictionnaire de minerais -label.orespawn.ore_source.none_attention=Aucun groupe de minerais ne nécessite d'attention. status.orespawn.ore_source.external_short=externe status.orespawn.ore_source.missing_short=absente status.orespawn.ore_source.output_only=sortie uniquement diff --git a/src/main/resources/assets/orespawn/lang/ja_JP.lang b/src/main/resources/assets/orespawn/lang/ja_JP.lang index f553d979..d2eec0e4 100644 --- a/src/main/resources/assets/orespawn/lang/ja_JP.lang +++ b/src/main/resources/assets/orespawn/lang/ja_JP.lang @@ -417,7 +417,6 @@ tooltip.orespawn.ore_source.enrichment=この項目は富化用であり、交 tooltip.orespawn.ore_source.missing=このソースは利用できません。所有Modが戻ると保存済み選択が復元されます。 error.orespawn.ore_source.weight=出力の重みは正の数である必要があります。 button.orespawn.back=戻る -button.orespawn.ore_source.needs_attention=要確認 button.orespawn.ore_source.all_groups=すべてのグループ button.orespawn.ore_source.add_group=グループを追加 button.orespawn.ore_source.delete_group=削除 @@ -432,7 +431,6 @@ mode.orespawn.ore_source.balanced=均等 mode.orespawn.ore_source.single=単一 mode.orespawn.ore_source.custom=カスタム label.orespawn.ore_source.no_aliases=鉱石辞書エントリなし -label.orespawn.ore_source.none_attention=確認が必要な鉱石グループはありません。 status.orespawn.ore_source.external_short=外部 status.orespawn.ore_source.missing_short=不足 status.orespawn.ore_source.output_only=出力のみ diff --git a/src/main/resources/assets/orespawn/lang/ko_KR.lang b/src/main/resources/assets/orespawn/lang/ko_KR.lang index 8ec4efef..0c12a94a 100644 --- a/src/main/resources/assets/orespawn/lang/ko_KR.lang +++ b/src/main/resources/assets/orespawn/lang/ko_KR.lang @@ -417,7 +417,6 @@ tooltip.orespawn.ore_source.enrichment=이 항목은 농축용이며 교환 가 tooltip.orespawn.ore_source.missing=이 소스를 사용할 수 없습니다. 소유 Mod가 돌아오면 저장된 선택이 복원됩니다. error.orespawn.ore_source.weight=출력 가중치는 양수여야 합니다. button.orespawn.back=뒤로 -button.orespawn.ore_source.needs_attention=확인 필요 button.orespawn.ore_source.all_groups=모든 그룹 button.orespawn.ore_source.add_group=그룹 추가 button.orespawn.ore_source.delete_group=삭제 @@ -432,7 +431,6 @@ mode.orespawn.ore_source.balanced=균등 mode.orespawn.ore_source.single=단일 mode.orespawn.ore_source.custom=사용자 지정 label.orespawn.ore_source.no_aliases=광석 사전 항목 없음 -label.orespawn.ore_source.none_attention=확인이 필요한 광석 그룹이 없습니다. status.orespawn.ore_source.external_short=외부 status.orespawn.ore_source.missing_short=누락 status.orespawn.ore_source.output_only=출력 전용 diff --git a/src/main/resources/assets/orespawn/lang/pt_BR.lang b/src/main/resources/assets/orespawn/lang/pt_BR.lang index ff7efc57..e9fa6345 100644 --- a/src/main/resources/assets/orespawn/lang/pt_BR.lang +++ b/src/main/resources/assets/orespawn/lang/pt_BR.lang @@ -417,7 +417,6 @@ tooltip.orespawn.ore_source.enrichment=Esta entrada é um enriquecimento, não u tooltip.orespawn.ore_source.missing=Esta fonte está indisponível. A seleção salva será restaurada se o mod proprietário retornar. error.orespawn.ore_source.weight=Os pesos de saída devem ser números positivos. button.orespawn.back=Voltar -button.orespawn.ore_source.needs_attention=Requer atenção button.orespawn.ore_source.all_groups=Todos os grupos button.orespawn.ore_source.add_group=Adicionar grupo button.orespawn.ore_source.delete_group=Excluir @@ -432,7 +431,6 @@ mode.orespawn.ore_source.balanced=Equilibrado mode.orespawn.ore_source.single=Único mode.orespawn.ore_source.custom=Personalizado label.orespawn.ore_source.no_aliases=Sem entradas do dicionário de minérios -label.orespawn.ore_source.none_attention=Nenhum grupo de minérios requer atenção. status.orespawn.ore_source.external_short=externo status.orespawn.ore_source.missing_short=ausente status.orespawn.ore_source.output_only=somente saída diff --git a/src/main/resources/assets/orespawn/lang/ru_RU.lang b/src/main/resources/assets/orespawn/lang/ru_RU.lang index 14647977..6bf42951 100644 --- a/src/main/resources/assets/orespawn/lang/ru_RU.lang +++ b/src/main/resources/assets/orespawn/lang/ru_RU.lang @@ -417,7 +417,6 @@ tooltip.orespawn.ore_source.enrichment=Эта запись является об tooltip.orespawn.ore_source.missing=Источник недоступен. Сохранённый выбор восстановится, когда вернётся его мод. error.orespawn.ore_source.weight=Веса выходов должны быть положительными числами. button.orespawn.back=Назад -button.orespawn.ore_source.needs_attention=Требует внимания button.orespawn.ore_source.all_groups=Все группы button.orespawn.ore_source.add_group=Добавить группу button.orespawn.ore_source.delete_group=Удалить @@ -432,7 +431,6 @@ mode.orespawn.ore_source.balanced=Равномерно mode.orespawn.ore_source.single=Один mode.orespawn.ore_source.custom=Настраиваемый label.orespawn.ore_source.no_aliases=Нет записей словаря руд -label.orespawn.ore_source.none_attention=Нет групп руд, требующих внимания. status.orespawn.ore_source.external_short=внешний status.orespawn.ore_source.missing_short=отсутствует status.orespawn.ore_source.output_only=только результат diff --git a/src/main/resources/assets/orespawn/lang/zh_CN.lang b/src/main/resources/assets/orespawn/lang/zh_CN.lang index 2a9c5afc..7cbac5da 100644 --- a/src/main/resources/assets/orespawn/lang/zh_CN.lang +++ b/src/main/resources/assets/orespawn/lang/zh_CN.lang @@ -417,7 +417,6 @@ tooltip.orespawn.ore_source.enrichment=此条目属于富集矿,不是可互 tooltip.orespawn.ore_source.missing=此来源不可用。其所属模组恢复后将自动恢复已保存的选择。 error.orespawn.ore_source.weight=输出权重必须为正数。 button.orespawn.back=返回 -button.orespawn.ore_source.needs_attention=需要处理 button.orespawn.ore_source.all_groups=所有分组 button.orespawn.ore_source.add_group=添加分组 button.orespawn.ore_source.delete_group=删除 @@ -432,7 +431,6 @@ mode.orespawn.ore_source.balanced=均衡 mode.orespawn.ore_source.single=单一 mode.orespawn.ore_source.custom=自定义 label.orespawn.ore_source.no_aliases=无矿物词典条目 -label.orespawn.ore_source.none_attention=没有需要处理的矿物分组。 status.orespawn.ore_source.external_short=外部 status.orespawn.ore_source.missing_short=缺失 status.orespawn.ore_source.output_only=仅输出 diff --git a/src/test/java/zone/moddev/mc/orespawn/LocalizationParityTest.java b/src/test/java/zone/moddev/mc/orespawn/LocalizationParityTest.java index 88bfd0c8..9670a5d3 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(446, englishKeys.size(), + assertEquals(444, 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/client/GeologyEditorSessionTest.java b/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java index 4c77ebec..c299a13d 100644 --- a/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java @@ -271,6 +271,11 @@ void customMaterialGroupsKeepStableIdsAndRequireConfirmedAliasMoves() { void balancedSingleAndCustomModesKeepOneExplicitOutputPolicy() { GeologyEditorSession session = new GeologyEditorSession(profileWithTwoOreOutputs()); String key = session.oreSourceGroups().get(0).key; + GeologyEditorSession.OreSourceGroup initial = session.oreSourceGroups().get(0); + assertTrue(initial.oreDictionaryEntries.contains("oreSulfur")); + assertTrue(initial.oreDictionaryEntries.contains("oreSulphur")); + assertEquals(2, initial.outputCandidates().size(), + "the selected group exposes the union of candidates from all aliases"); session.setOreSourceOutputMode(key, "balanced"); GeologyEditorSession.OreSourceGroup balanced = session.oreSourceGroups().get(0); 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 b3a07f22..62c09ff8 100644 --- a/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java @@ -89,24 +89,41 @@ void oreSourcesAndAddBlockShareTheCompactOreFooter() throws Exception { } @Test - void oreSourceDirectoryPaginatesClipsAndPreservesParentNavigation() throws Exception { + void oreSourceEditorKeepsBothPanesVisibleAndPaginatesIndependently() throws Exception { assertEquals(1, OreSourceListScreen.pageCount(0, 4)); assertEquals(1, OreSourceListScreen.pageCount(4, 4)); assertEquals(2, OreSourceListScreen.pageCount(5, 4)); + assertEquals(406, OreSourceListScreen.contentWidth(426)); + assertEquals(174, OreSourceListScreen.leftPaneWidth(426)); + assertEquals(2, OreSourceListScreen.groupRowCount(265)); + assertEquals(2, OreSourceListScreen.aliasRowCount(265)); + assertEquals(6, OreSourceListScreen.outputRowCount(265, false)); + assertEquals(5, OreSourceListScreen.outputRowCount(265, true)); + assertEquals(300, OreSourceListScreen.contentWidth(320)); + assertEquals(129, OreSourceListScreen.leftPaneWidth(320)); + assertEquals(2, OreSourceListScreen.groupRowCount(240)); + assertEquals(1, OreSourceListScreen.aliasRowCount(240)); + assertEquals(5, OreSourceListScreen.outputRowCount(240, false)); + assertEquals(4, OreSourceListScreen.outputRowCount(240, true)); String list = screenSource("OreSourceListScreen.java"); - assertTrue(list.contains("private static final int WIDE_MINIMUM = 520")); - assertTrue(list.contains("compact(width)")); assertTrue(list.contains("initDirectory(")); assertTrue(list.contains("initOutputs(")); + assertTrue(list.contains("initDirectory(leftPaneX, CONTENT_TOP, leftPaneWidth, groups);")); + assertTrue(list.contains("initOutputs(rightPaneX, CONTENT_TOP, rightPaneWidth);")); assertTrue(list.contains("OreSpawnScreenLayout.fit")); - assertTrue(list.contains("button.orespawn.ore_source.needs_attention")); assertTrue(list.contains("button.orespawn.ore_source.all_groups")); - assertTrue(list.contains("button.orespawn.ore_source.add_group")); + assertTrue(list.contains("aliasPage")); assertTrue(list.contains("outputCandidates()")); + assertTrue(list.contains("single || !group.outputs.containsKey(candidate.sourceId)"), + "Single output rows behave as radio buttons and cannot deselect their only output"); assertTrue(list.contains("button.orespawn.ore_source.advanced")); assertTrue(list.contains(", 20,"), "Material Groups must use normal Forge-height controls"); assertTrue(list.contains("minecraft.displayGuiScreen(parent)")); assertTrue(list.contains("toggle.enabled = candidate.loaded && !candidate.enrichment")); + assertFalse(list.contains("WIDE_MINIMUM")); + assertFalse(list.contains("compactDetail")); + assertFalse(list.contains("button.orespawn.back")); + assertFalse(list.contains("button.orespawn.ore_source.needs_attention")); assertFalse(Files.exists(Paths.get("src", "main", "java", "zone", "moddev", "mc", "orespawn", "client", "OreSourceDetailScreen.java")), "The technical detail screen must not remain as a second competing flow"); From a85b06a7049e4b44472e772967c2ff132563a741 Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Thu, 17 Sep 2026 12:06:12 +0100 Subject: [PATCH 13/31] Refine Ore Sources with compact group settings --- CHANGELOG.txt | 10 +- README.md | 24 +- docs/CONFIGURATION.md | 10 + docs/PLAYER_GUIDE.md | 16 +- .../orespawn/client/ClientProbeTestMod.java | 51 +- .../mc/orespawn/client/CompactScrollList.java | 293 ++++++++++ .../client/OreSourceGroupSettingsScreen.java | 302 ++++++++++ .../orespawn/client/OreSourceListScreen.java | 526 +++++++----------- .../mc/orespawn/client/TextFieldWidget.java | 11 + .../resources/assets/orespawn/lang/de_AU.lang | 15 + .../resources/assets/orespawn/lang/de_DE.lang | 15 + .../resources/assets/orespawn/lang/en_CA.lang | 15 + .../resources/assets/orespawn/lang/en_EN.lang | 15 + .../resources/assets/orespawn/lang/en_GB.lang | 15 + .../resources/assets/orespawn/lang/en_PT.lang | 15 + .../resources/assets/orespawn/lang/en_US.lang | 15 + .../resources/assets/orespawn/lang/es_ES.lang | 15 + .../resources/assets/orespawn/lang/es_MX.lang | 15 + .../resources/assets/orespawn/lang/fr_CA.lang | 15 + .../resources/assets/orespawn/lang/fr_FR.lang | 15 + .../resources/assets/orespawn/lang/ja_JP.lang | 15 + .../resources/assets/orespawn/lang/ko_KR.lang | 15 + .../resources/assets/orespawn/lang/pt_BR.lang | 15 + .../resources/assets/orespawn/lang/ru_RU.lang | 15 + .../resources/assets/orespawn/lang/zh_CN.lang | 15 + .../mc/orespawn/LocalizationParityTest.java | 2 +- .../client/CompactScrollListTest.java | 61 ++ .../client/OreSourcePlacementScreenTest.java | 66 +++ .../client/OreSpawnScreenLayoutTest.java | 56 +- 29 files changed, 1289 insertions(+), 379 deletions(-) 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/test/java/zone/moddev/mc/orespawn/client/CompactScrollListTest.java create mode 100644 src/test/java/zone/moddev/mc/orespawn/client/OreSourcePlacementScreenTest.java diff --git a/CHANGELOG.txt b/CHANGELOG.txt index d910fc0b..964ee9da 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -6,9 +6,13 @@ Version 4.1.0.110021 upgraded worlds retain their previous separate placement and output behaviour. * Select one weighted output for an entire ordinary vein or custom deposit body using stable world/deposit identity, including safe missing-mod restoration. -* Replace the technical Ore Sources list/detail flow with one material-group - editor that always keeps the group list and aliases beside the deduplicated - ore-block list. Each list paginates independently at compact resolutions. +* Replace the technical Ore Sources list/detail flow with compact independently + scrolling group and output lists that remain visible together at compact + resolutions. Each group has a cog for its friendly name and aliases. +* Rename the old Advanced channel controls to Placement Rules and explain their + separate ownership of frequency, shape, depth and host restrictions. Single + managed sources are read-only, multiple sources are selectable and external + generators remain excluded. * Add Keep Original, Balanced, Single and Custom output modes. Reviewed fresh MMD conflicts balance every eligible output through one placement budget; inactive provider definitions remain selectable outputs without contributing diff --git a/README.md b/README.md index 6e349be7..482eb1fc 100644 --- a/README.md +++ b/README.md @@ -58,17 +58,19 @@ 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. -On the ORES tab, **Ore Sources...** opens one material-group editor. The left -pane lists every discovered group and keeps the selected group's friendly name -and exact Ore Dictionary aliases directly underneath it. The right pane shows -the deduplicated union of installed ore blocks represented by those aliases. -Groups needing attention are sorted first. New worlds balance all eligible -outputs in reviewed MMD conflicts while running one placement budget, so -sulfur/sulphur providers do not multiply abundance. **Single** chooses one -output, **Custom** allows a weighted subset, and **Keep Original** preserves -every active rule. Existing worlds start in Keep Original until their owner -explicitly changes a group. Third-party generators remain outside OreSpawn's -control and are clearly reported rather than disabled. +On the ORES tab, **Ore Sources...** opens one material-group editor. Compact, +independently scrolling lists keep every discovered group on the left and the +selected group's deduplicated ore blocks on the right. Groups needing attention +are sorted first. Each group's cog opens its friendly name, exact Ore Dictionary +aliases and clearly labelled Placement Rules without crowding the selection +screen. Outputs choose the ore block; Placement Rules choose the frequency, +shape, depth and host restrictions for each managed channel. New worlds balance +all eligible outputs in reviewed MMD conflicts while running one placement +budget, so sulfur/sulphur providers do not multiply abundance. **Single** +chooses one output, **Custom** allows a weighted subset, and **Keep Original** +preserves every active rule. Existing worlds start in Keep Original until their +owner explicitly changes a group. Third-party generators remain outside +OreSpawn's control and are clearly reported rather than disabled. Important files: diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 9801d284..604e3e14 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -236,6 +236,16 @@ 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**. +The Ore Sources screen keeps compact scrollable group and output lists visible +together. 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. + 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 diff --git a/docs/PLAYER_GUIDE.md b/docs/PLAYER_GUIDE.md index 9bf91ed6..1e71b8a5 100644 --- a/docs/PLAYER_GUIDE.md +++ b/docs/PLAYER_GUIDE.md @@ -46,15 +46,17 @@ 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**. It - keeps every discovered group, the selected friendly name and its exact Ore - Dictionary aliases together in the left pane. The right pane always shows - the deduplicated union of installed ore blocks represented by those aliases: +- **Ore Sources** opens the material-group editor beside **Add Block**. Compact + scrolling lists keep every discovered group on the left and the deduplicated + union of installed ore blocks represented by its aliases on the right: **Balanced** shares them equally, **Single** uses one, **Custom** enables a weighted subset, and **Keep Original** preserves every old placement rule. - Groups needing attention sort first. Each list paginates independently at - smaller resolutions, so both panes remain visible. Advanced - placement-channel choices stay collapsed unless needed. + Groups needing attention sort first. 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; + multiple managed sources can be selected. 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 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 a15fd839..d5745569 100644 --- a/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java +++ b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java @@ -296,27 +296,44 @@ private void validateOreSourcesSingleScreen(GuiScreen parent, GeologyEditorSessi int contentWidth = OreSourceListScreen.contentWidth(426); int left = (426 - contentWidth) / 2; int right = left + OreSourceListScreen.leftPaneWidth(426) + 6; - boolean sulfurName = false; - boolean sulfur = false; - boolean sulphur = false; - boolean mineralogy = false; - boolean electric = false; + boolean groupList = false; + boolean outputList = false; + boolean inlineAdministration = false; boolean back = false; for (GuiButton widget : screen.buttons) { String caption = TextFormatting.getTextWithoutFormattingCodes(widget.displayString); - if (widget instanceof TextFieldWidget && "Sulfur".equals(((TextFieldWidget) widget).getValue()) - && widget.xPosition < right) sulfurName = true; - if ("oreSulfur".equals(caption) && widget.xPosition < right) sulfur = true; - if ("oreSulphur".equals(caption) && widget.xPosition < right) sulphur = true; - if (caption != null && widget.xPosition >= right && caption.contains("Mineralogy")) mineralogy = true; - if (caption != null && widget.xPosition >= right && caption.contains("Electric")) electric = true; + if (widget instanceof CompactScrollList && widget.xPosition < right) groupList = true; + if (widget instanceof CompactScrollList && widget.xPosition >= right) outputList = true; + if (widget instanceof TextFieldWidget && widget.xPosition < right) inlineAdministration = true; if ("Back".equals(caption)) back = true; } - if (!sulfurName || !sulfur || !sulphur || !mineralogy || !electric || back) { - throw new IllegalStateException("Ore Sources was not one complete two-pane screen: name=" - + sulfurName + ", aliases=" + sulfur + "/" + sulphur + ", outputs=" - + mineralogy + "/" + electric + ", back=" + back + ", left=" + left - + ", right=" + right); + if (!groupList || !outputList || inlineAdministration || back) { + throw new IllegalStateException("Ore Sources was not one compact two-pane screen: groups=" + + groupList + ", outputs=" + outputList + ", inlineAdministration=" + + inlineAdministration + ", back=" + back + ", 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; + for (GuiButton widget : settings.buttons) { + if (widget instanceof CompactScrollList) compactLists++; + if (widget instanceof TextFieldWidget + && "Sulfur".equals(((TextFieldWidget) widget).getValue())) sulfurName = true; + } + if (compactLists != 2 || !sulfurName + || !OreSourceGroupSettingsScreen.placementChannels(sulfurGroup) + .contains("orespawn:standard")) { + throw new IllegalStateException("Group Settings did not expose aliases and placement rules: lists=" + + compactLists + ", name=" + sulfurName + ", channels=" + + OreSourceGroupSettingsScreen.placementChannels(sulfurGroup)); } oreSourcesLayoutValidated = true; } @@ -346,7 +363,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") 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..360c4a47 --- /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) { + if (!visible) return; + clampFirstIndex(); + if (dragging) { + if (Mouse.isButtonDown(0)) dragTo(mouseY - dragOffset); + else dragging = false; + } + + drawRect(xPosition, yPosition, xPosition + width, yPosition + height, 0x76000000); + hoveredIndex = rowAt(mouseX, mouseY); + hoveredAction = hoveredIndex >= 0 && isInRowAction(hoveredIndex, mouseX); + FontRenderer font = minecraft.fontRendererObj; + int rows = visibleRows(); + int contentRight = contentRight(); + for (int row = 0; row < rows; row++) { + int index = firstIndex + row; + if (index >= size()) break; + int rowY = yPosition + (row * rowHeight); + boolean hovered = index == hoveredIndex; + if (rowSelected(index)) { + drawRect(xPosition + 1, rowY, contentRight, rowY + rowHeight - 1, 0xA052648C); + } else if (hovered) { + drawRect(xPosition + 1, rowY, contentRight, rowY + rowHeight - 1, 0x80404040); + } + int actionWidth = Math.max(0, rowActionWidth(index)); + int textWidth = Math.max(0, contentRight - xPosition - actionWidth - 8); + String text = fit(font, rowText(index), textWidth); + font.drawStringWithShadow(text, xPosition + 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 : yPosition + ((index - firstIndex) * rowHeight); + } + + int contentRight() { + return xPosition + width - SCROLLBAR_WIDTH; + } + + private boolean contains(int mouseX, int mouseY) { + return mouseX >= xPosition && mouseX < xPosition + width + && mouseY >= yPosition && mouseY < yPosition + height; + } + + private int rowAt(int mouseX, int mouseY) { + if (!contains(mouseX, mouseY) || mouseX >= contentRight()) return -1; + int index = firstIndex + ((mouseY - yPosition) / 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 < yPosition + ARROW_HEIGHT) { + scrollBy(-1); + return; + } + if (mouseY >= yPosition + 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 - (yPosition + 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 yPosition + 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 = xPosition + width; + drawRect(left, yPosition, right, yPosition + height, 0xB0202020); + FontRenderer font = minecraft.fontRendererObj; + drawCenteredString(font, "^", left + (SCROLLBAR_WIDTH / 2), yPosition, 0xDDDDDD); + drawCenteredString(font, "v", left + (SCROLLBAR_WIDTH / 2), + yPosition + 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/OreSourceGroupSettingsScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceGroupSettingsScreen.java new file mode 100644 index 00000000..6f317e8a --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceGroupSettingsScreen.java @@ -0,0 +1,302 @@ +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 = 35; + private static final int ALIAS_TOP = 70; + private final GuiScreen parent; + private final GeologyEditorSession session; + private final String groupKey; + private String pendingAliasMove; + 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; + int actionWidth = Math.min(78, Math.max(55, contentWidth / 4)); + + nameField = addButton(new TextFieldWidget(font, contentX, NAME_TOP, + contentWidth - actionWidth - 5, 20, + new TextComponentTranslation("option.orespawn.ore_source.group_name"))); + nameField.setMaxLength(64); + nameField.setValue(group.displayName); + Button groupAction = addButton(OreSpawnScreenLayout.button(this, font, + contentX + contentWidth - actionWidth, NAME_TOP, actionWidth, 20, + new TextComponentTranslation(group.curated ? "button.orespawn.reset" + : "button.orespawn.ore_source.delete_group"), button -> { + if (group.curated) { + session.resetOreMaterialGroup(group.material); + pendingAliasMove = null; + error = null; + rebuild(); + } else { + session.deleteOreMaterialGroup(group.material); + minecraft.displayGuiScreen(parent); + } + })); + OreSpawnScreenLayout.explain(this, groupAction, group.curated + ? "tooltip.orespawn.ore_source.reset_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.fontRendererObj, "-", x + (actionWidth / 2), y + 3, 0xFFFFFF); + } + @Override protected void onRowActionPressed(int index) { + syncName(); + session.removeOreMaterialAlias(group.material, aliases.get(index)); + pendingAliasMove = null; + 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)); + } + 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"); + + placementLabelY = addAliasY + 27; + placementListY = placementLabelY + 22; + final List channels = placementChannels(group); + 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) + ": " + source; + } + @Override protected boolean rowEnabled(int index) { + String channel = channels.get(index); + return placementSelectable(group, channel); + } + @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); + + addButton(new Button(width / 2 - 75, height - 28, 150, 20, + DialogTexts.GUI_DONE, button -> onClose())); + } + + 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 && !move.equals(pendingAliasMove)) { + pendingAliasMove = move; + 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; + 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 ? 48 : 32; + } + + 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 boolean placementSelectable(OreSourceGroup group, String channel) { + return !"keep_separate".equals(group.mode) + && OreSourceListScreen.placementCandidates(group, channel).size() > 1; + } + + @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, 24, 0xA0A0A0); + drawString(font, new TextComponentTranslation("label.orespawn.ore_source.aliases"), + contentX + 2, 59, 0xA0A0A0); + drawString(font, new TextComponentTranslation("label.orespawn.ore_source.placement_rules"), + contentX + 2, placementLabelY, 0xFFFFFF); + String explanation = I18n.format("label.orespawn.ore_source.placement_explanation"); + drawString(font, new TextComponentString(OreSpawnScreenLayout.fit(font, + new TextComponentString(explanation), contentWidth - 4)), + contentX + 2, placementLabelY + 10, 0xA0A0A0); + if (error != null) { + drawCenteredString(font, new TextComponentString(OreSpawnScreenLayout.fit(font, + new TextComponentString(error), width - 24)), width / 2, 19, 0xFF5555); + } + 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); + } + if (group != null && placementChannels(group).isEmpty()) { + drawCenteredString(font, + new TextComponentTranslation("label.orespawn.ore_source.no_placement_rules"), + contentX + (contentWidth / 2), placementListY + 4, 0x777777); + } + super.render(mouseX, mouseY, partialTick); + OreSpawnScreenLayout.renderExplanations(this, 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 index 3043af7f..927b3e72 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/OreSourceListScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceListScreen.java @@ -1,49 +1,46 @@ 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; -/** Single-screen material-group editor with independently paginated panes. */ +/** 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 = 28; - private static final int ROW_HEIGHT = 22; + private static final int CONTENT_TOP = 24; private final GuiScreen parent; private final GeologyEditorSession session; private final Map weights = new LinkedHashMap<>(); - private boolean advanced; - private int groupPage; - private int aliasPage; - private int outputPage; - private int channelIndex; private String selectedKey; - private String pendingAliasMove; private String error; - private TextFieldWidget nameField; - private TextFieldWidget aliasField; + private int groupScroll; + private int outputScroll; private int leftPaneX; private int leftPaneWidth; private int rightPaneX; private int rightPaneWidth; private int paneBottom; - private int groupNavigationY; - private int groupPages = 1; - private int nameLabelY; - private int aliasLabelY; - private int aliasPages = 1; - private int outputNavigationY; - private int outputPages = 1; + 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")); @@ -55,8 +52,6 @@ final class OreSourceListScreen extends OreSpawnScreen { protected void init() { OreSpawnScreenLayout.beginHelp(this); weights.clear(); - nameField = null; - aliasField = null; List groups = orderedGroups(); if (selected(groups) == null && !groups.isEmpty()) selectedKey = groups.get(0).key; @@ -67,253 +62,171 @@ protected void init() { rightPaneWidth = contentWidth - leftPaneWidth - PANE_GAP; paneBottom = height - 32; - initDirectory(leftPaneX, CONTENT_TOP, leftPaneWidth, groups); - initOutputs(rightPaneX, CONTENT_TOP, rightPaneWidth); + initGroups(groups); + initOutputs(selected(groups)); addButton(new Button(width / 2 - 75, height - 28, 150, 20, DialogTexts.GUI_DONE, button -> onClose())); } - private void initDirectory(int left, int top, int paneWidth, List groups) { - addButton(new Button(left + paneWidth - 24, top, 24, 20, + 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 -> { - syncFields(); - selectedKey = session.addOreMaterialGroup(); - pendingAliasMove = null; - aliasPage = 0; - outputPage = 0; - List updated = orderedGroups(); - int index = groupIndexOf(updated, selectedKey); - groupPage = index < 0 ? 0 : index / groupRowCount(height); - rebuild(false); - }, (button, mouseX, mouseY) -> renderStringTooltip( - java.util.Collections.singletonList(I18n.format( - "button.orespawn.ore_source.add_group")), mouseX, mouseY))); - - int listTop = top + 22; - int rows = groupRowCount(height); - groupPages = pageCount(groups.size(), rows); - groupPage = Math.max(0, Math.min(groupPage, groupPages - 1)); - int start = groupPage * rows; - for (int index = 0; index < rows && start + index < groups.size(); index++) { - OreSourceGroup group = groups.get(start + index); - int y = listTop + (index * ROW_HEIGHT); - String prefix = group.key.equals(selectedKey) ? "> " : group.needsAttention() ? "! " : ""; - String label = prefix + group.displayName + " - " + display(group.domain); - addButton(new Button(left, y, paneWidth, 20, - OreSpawnScreenLayout.fit(font, new TextComponentString(label), paneWidth - 8), button -> { - syncFields(); - selectedKey = group.key; - pendingAliasMove = null; - aliasPage = 0; - outputPage = 0; - error = null; - rebuild(false); - }, (button, mouseX, mouseY) -> renderStringTooltip(groupTooltip(group), mouseX, mouseY))); - } + if (!syncWeights()) return; + captureScroll(); + selectedKey = session.addOreMaterialGroup(); + outputScroll = 0; + error = null; + rebuild(); + })); + OreSpawnScreenLayout.explain(this, add, "button.orespawn.ore_source.add_group"); + + 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.needsAttention() ? "! " : "") + group.displayName + + " - " + display(group.domain); + } - groupNavigationY = listTop + (rows * ROW_HEIGHT); - Button previous = addButton(new Button(left, groupNavigationY, 32, 20, - new TextComponentString("<"), button -> { - syncFields(); - groupPage--; - rebuild(false); - })); - Button next = addButton(new Button(left + paneWidth - 32, groupNavigationY, 32, 20, - new TextComponentString(">"), button -> { - syncFields(); - groupPage++; - rebuild(false); - })); - previous.enabled = groupPage > 0; - next.enabled = groupPage + 1 < groupPages; - initGroupEditor(left, groupNavigationY + 24, paneWidth); - } + @Override protected boolean rowSelected(int index) { + return groups.get(index).key.equals(selectedKey); + } - private void initGroupEditor(int left, int top, int paneWidth) { - OreSourceGroup group = selected(orderedGroups()); - if (group == null) return; - - nameLabelY = top; - int nameTop = top + 10; - int actionWidth = Math.min(58, Math.max(41, paneWidth / 3)); - nameField = addButton(new TextFieldWidget(font, left, nameTop, - paneWidth - actionWidth - 5, 20, - new TextComponentTranslation("option.orespawn.ore_source.group_name"))); - nameField.setMaxLength(64); - nameField.setValue(group.displayName); - Button groupAction = addButton(OreSpawnScreenLayout.button(this, font, - left + paneWidth - actionWidth, nameTop, actionWidth, 20, - new TextComponentTranslation(group.curated ? "button.orespawn.reset" - : "button.orespawn.ore_source.delete_group"), button -> { - if (group.curated) session.resetOreMaterialGroup(group.material); - else { - session.deleteOreMaterialGroup(group.material); - selectedKey = null; - groupPage = 0; - } - pendingAliasMove = null; - rebuild(false); - })); - OreSpawnScreenLayout.explain(this, groupAction, group.curated - ? "tooltip.orespawn.ore_source.reset_group" : "tooltip.orespawn.ore_source.delete_group"); - - aliasLabelY = nameTop + 24; - int aliasesTop = aliasLabelY + 10; - int rows = aliasRowCount(height); - List aliases = group.oreDictionaryEntries; - aliasPages = pageCount(aliases.size(), rows); - aliasPage = Math.max(0, Math.min(aliasPage, aliasPages - 1)); - int start = aliasPage * rows; - for (int index = 0; index < rows && start + index < aliases.size(); index++) { - String alias = aliases.get(start + index); - int y = aliasesTop + (index * ROW_HEIGHT); - addButton(new Button(left, y, paneWidth - 27, 20, - OreSpawnScreenLayout.fit(font, new TextComponentString(alias), paneWidth - 35), - button -> { }, (button, mouseX, mouseY) -> renderStringTooltip( - java.util.Collections.singletonList(alias), mouseX, mouseY))); - addButton(new Button(left + paneWidth - 22, y, 22, 20, - new TextComponentString("-"), button -> { - syncName(); - session.removeOreMaterialAlias(group.material, alias); - pendingAliasMove = null; - rebuild(false); - })); - } + @Override protected void onRowPressed(int index) { + if (!syncWeights()) return; + captureScroll(); + selectedKey = groups.get(index).key; + outputScroll = 0; + error = null; + rebuild(); + } - int addTop = aliasesTop + (rows * ROW_HEIGHT) + 2; - int addWidth = 41; - int fieldWidth = Math.max(30, paneWidth - addWidth - 49); - aliasField = addButton(new TextFieldWidget(font, left, addTop, fieldWidth, 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)); - } - Button aliasPrevious = addButton(new Button(left + fieldWidth + 3, addTop, 20, 20, - new TextComponentString("<"), button -> { - syncName(); - aliasPage--; - rebuild(false); - })); - Button aliasNext = addButton(new Button(left + fieldWidth + 26, addTop, 20, 20, - new TextComponentString(">"), button -> { - syncName(); - aliasPage++; - rebuild(false); - })); - aliasPrevious.enabled = aliasPage > 0; - aliasNext.enabled = aliasPage + 1 < aliasPages; - Button add = addButton(new Button(left + paneWidth - addWidth, addTop, addWidth, 20, - new TextComponentString(pendingAliasMove == null ? "+" - : I18n.format("button.orespawn.ore_source.move")), button -> addAlias(group))); - OreSpawnScreenLayout.explain(this, add, "tooltip.orespawn.ore_source.alias"); + @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(int left, int top, int paneWidth) { - OreSourceGroup group = selected(orderedGroups()); - if (group == null) return; + 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; - Button modeButton = addButton(OreSpawnScreenLayout.button(this, font, left, top, paneWidth, 20, + Button modeButton = addButton(OreSpawnScreenLayout.button(this, font, + rightPaneX, CONTENT_TOP, rightPaneWidth, 20, new TextComponentTranslation("option.orespawn.ore_source.output_mode", - new TextComponentTranslation("mode.orespawn.ore_source." + mode).getFormattedText()), button -> { - syncFields(); + new TextComponentTranslation("mode.orespawn.ore_source." + mode).getFormattedText()), + button -> { + if (!syncWeights()) return; cycleMode(group); - outputPage = 0; - rebuild(false); + outputScroll = 0; + rebuild(); })); OreSpawnScreenLayout.explain(this, modeButton, "tooltip.orespawn.ore_source.output_mode"); - int listTop = top + 24; - List candidates = group.outputCandidates(); - int rows = outputRowCount(height, advanced); - outputPages = pageCount(candidates.size(), rows); - outputPage = Math.max(0, Math.min(outputPage, outputPages - 1)); - int start = outputPage * rows; - for (int index = 0; index < rows && start + index < candidates.size(); index++) { - OreSourceCandidate candidate = candidates.get(start + index); - int y = listTop + (index * ROW_HEIGHT); - boolean selected = group.outputs.containsKey(candidate.sourceId); - boolean single = "single".equals(group.outputMode) && !"keep_separate".equals(group.mode); - String marker = single ? (selected ? "(*) " : "( ) ") : (selected ? "[x] " : "[ ] "); - int weightWidth = "custom".equals(group.outputMode) - && !"keep_separate".equals(group.mode) ? 58 : 0; - Button toggle = addButton(new Button(left, y, - paneWidth - weightWidth - (weightWidth == 0 ? 0 : 5), 20, - OreSpawnScreenLayout.fit(font, new TextComponentString(marker + candidateLabel(candidate)), - paneWidth - weightWidth - 13), button -> { - syncFields(); - session.setOreSourceOutput(group.key, candidate.sourceId, - single || !group.outputs.containsKey(candidate.sourceId), - group.outputs.containsKey(candidate.sourceId) - ? group.outputs.get(candidate.sourceId) : 1.0D); - rebuild(false); - }, (button, mouseX, mouseY) -> renderStringTooltip(candidateTooltip(candidate), mouseX, mouseY))); - toggle.enabled = candidate.loaded && !candidate.enrichment && !"keep_separate".equals(group.mode); - if (weightWidth > 0) { - TextFieldWidget weight = addButton(new TextFieldWidget(font, left + paneWidth - weightWidth, - y, weightWidth, 20, + 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 = selected && candidate.loaded && !candidate.enrichment; + weight.enabled = candidate.loaded && !candidate.enrichment; + weight.visible = false; weights.put(candidate.sourceId, weight); } } - - outputNavigationY = listTop + (rows * ROW_HEIGHT); - Button previous = addButton(new Button(left, outputNavigationY, 32, 20, - new TextComponentString("<"), button -> { - syncFields(); - outputPage--; - rebuild(false); - })); - Button next = addButton(new Button(left + paneWidth - 32, outputNavigationY, 32, 20, - new TextComponentString(">"), button -> { - syncFields(); - outputPage++; - rebuild(false); - })); - previous.enabled = outputPage > 0; - next.enabled = outputPage + 1 < outputPages; - addButton(OreSpawnScreenLayout.button(this, font, left + 37, outputNavigationY, - paneWidth - 74, 20, - new TextComponentTranslation(advanced ? "button.orespawn.ore_source.hide_advanced" - : "button.orespawn.ore_source.advanced"), button -> { - syncFields(); - advanced = !advanced; - rebuild(false); - })); - if (advanced) initAdvanced(group, left, outputNavigationY + 24, paneWidth); } - private void initAdvanced(OreSourceGroup group, int left, int y, int paneWidth) { - List channels = group.channels(); - if (channels.isEmpty()) return; - channelIndex = Math.max(0, Math.min(channelIndex, channels.size() - 1)); - String channel = channels.get(channelIndex); - int half = (paneWidth - 5) / 2; - addButton(OreSpawnScreenLayout.button(this, font, left, y, half, 20, - new TextComponentString(display(channel)), button -> { - channelIndex = (channelIndex + 1) % channels.size(); - rebuild(false); - })); - List placements = placementCandidates(group, channel); - String selected = group.placements.get(channel); - int index = indexOf(placements, selected); - if (index < 0 && !placements.isEmpty()) index = 0; - final int selectedIndex = index; - Button placement = addButton(OreSpawnScreenLayout.button(this, font, - left + half + 5, y, half, 20, - new TextComponentString(index < 0 ? I18n.format("label.orespawn.ore_source.missing") - : candidateLabel(placements.get(index))), button -> { - if (!placements.isEmpty()) { - OreSourceCandidate next = placements.get((selectedIndex + 1) % placements.size()); - session.setOreSourcePlacement(group.key, channel, next.sourceId); - rebuild(false); - } - })); - placement.enabled = !placements.isEmpty(); + 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) { @@ -323,32 +236,9 @@ private void cycleMode(OreSourceGroup group) { else session.setOreSourceMode(group.key, false); } - 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 && !move.equals(pendingAliasMove)) { - pendingAliasMove = move; - error = I18n.format("error.orespawn.ore_source.alias_owned", display(owner)); - rebuild(false); - return; - } - if (!session.addOreMaterialAlias(group.material, alias, move != null)) { - error = I18n.format("error.orespawn.ore_source.alias"); - return; - } - pendingAliasMove = null; - error = null; - aliasPage = Math.max(0, pageCount(group.oreDictionaryEntries.size() + 1, - aliasRowCount(height)) - 1); - rebuild(false); - } - - private void syncFields() { - syncName(); + private boolean syncWeights() { OreSourceGroup group = selected(orderedGroups()); - if (group == null || !"custom".equals(group.outputMode)) return; + if (group == null || !"custom".equals(group.outputMode)) return true; for (Map.Entry entry : weights.entrySet()) { if (!group.outputs.containsKey(entry.getKey())) continue; try { @@ -357,15 +247,16 @@ private void syncFields() { 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 syncName() { - OreSourceGroup group = selected(orderedGroups()); - if (group != null && nameField != null) { - session.renameOreMaterialGroup(group.material, nameField.getValue()); - } + private void captureScroll() { + if (groupList != null) groupScroll = groupList.firstIndex(); + if (outputList != null) outputScroll = outputList.firstIndex(); } private List orderedGroups() { @@ -386,7 +277,7 @@ private OreSourceGroup selected(List groups) { return null; } - private static List placementCandidates(OreSourceGroup group, String channel) { + 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 @@ -395,21 +286,21 @@ private static List placementCandidates(OreSourceGroup group return result; } - private static int indexOf(List candidates, String sourceId) { + 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; } - private static int groupIndexOf(List groups, String key) { + 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; } - private static String candidateLabel(OreSourceCandidate candidate) { + 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") @@ -417,7 +308,7 @@ private static String candidateLabel(OreSourceCandidate candidate) { return owner + " - " + candidate.registryId + (state.isEmpty() ? "" : " (" + state + ")"); } - private static List candidateTooltip(OreSourceCandidate candidate) { + 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)); @@ -431,7 +322,7 @@ private static List candidateTooltip(OreSourceCandidate candidate) { return result; } - private static List groupTooltip(OreSourceGroup group) { + 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(), @@ -441,20 +332,33 @@ private static List groupTooltip(OreSourceGroup group) { return result; } - private static String formatWeight(Double weight) { + 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()); } - private static String display(String id) { + 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 void rebuild(boolean clearError) { - if (clearError) error = null; + 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(); @@ -469,29 +373,27 @@ static int leftPaneWidth(int width) { return Math.max(104, Math.min(280, (content * 43) / 100)); } - static int aliasRowCount(int height) { - return height >= 252 ? 2 : 1; + static int listHeight(int height) { + return Math.max(32, (height - 32) - CONTENT_TOP); } - static int groupRowCount(int height) { - int available = (height - 32) - CONTENT_TOP; - int fixed = 112 + (aliasRowCount(height) * ROW_HEIGHT); - return Math.max(1, Math.min(5, (available - fixed) / ROW_HEIGHT)); - } - - static int outputRowCount(int height, boolean advanced) { - int available = (height - 32) - (CONTENT_TOP + 24) - (advanced ? 68 : 44); - return Math.max(1, available / 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 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() { - syncFields(); - if (error == null) minecraft.displayGuiScreen(parent); + captureScroll(); + if (syncWeights()) minecraft.displayGuiScreen(parent); } @Override @@ -502,29 +404,17 @@ public void render(int mouseX, int mouseY, float partialTick) { 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, 19, 0xFF5555); + new TextComponentString(error), width - 24)), width / 2, height - 39, 0xFF5555); } - drawString(font, new TextComponentTranslation("button.orespawn.ore_source.all_groups"), - leftPaneX + 4, CONTENT_TOP + 6, 0xFFFFFF); - drawCenteredString(font, new TextComponentString((groupPage + 1) + " / " + groupPages), - leftPaneX + (leftPaneWidth / 2), groupNavigationY + 6, 0xA0A0A0); - OreSourceGroup group = selected(orderedGroups()); - if (group == null) { + if (selected(orderedGroups()) == null) { drawCenteredString(font, new TextComponentTranslation("label.orespawn.ore_source.none"), - leftPaneX + (leftPaneWidth / 2), CONTENT_TOP + 68, 0xA0A0A0); - } else { - drawString(font, new TextComponentTranslation("option.orespawn.ore_source.group_name"), - leftPaneX + 2, nameLabelY, 0xA0A0A0); - String aliases = I18n.format("option.orespawn.ore_source.alias") - + " " + (aliasPage + 1) + " / " + aliasPages; - drawString(font, new TextComponentString(OreSpawnScreenLayout.fit(font, - new TextComponentString(aliases), leftPaneWidth - 4)), - leftPaneX + 2, aliasLabelY, 0xA0A0A0); - drawCenteredString(font, new TextComponentString((outputPage + 1) + " / " + outputPages), - rightPaneX + (rightPaneWidth / 2), outputNavigationY + 6, 0xA0A0A0); + 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/TextFieldWidget.java b/src/main/java/zone/moddev/mc/orespawn/client/TextFieldWidget.java index fce89288..d644c5dd 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/TextFieldWidget.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/TextFieldWidget.java @@ -50,6 +50,17 @@ void setMaxLength(int length) { field.setMaxStringLength(length); } + void setBounds(int x, int y, int width, int height) { + xPosition = x; + yPosition = y; + this.width = width; + this.height = height; + field.xPosition = x; + field.yPosition = 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/resources/assets/orespawn/lang/de_AU.lang b/src/main/resources/assets/orespawn/lang/de_AU.lang index 5363334e..8242157a 100644 --- a/src/main/resources/assets/orespawn/lang/de_AU.lang +++ b/src/main/resources/assets/orespawn/lang/de_AU.lang @@ -442,3 +442,18 @@ tooltip.orespawn.ore_source.delete_group=Lösche diese benutzerdefinierte Gruppe error.orespawn.ore_source.alias=Verwende einen exakten Ore-Dictionary-Erznamen wie oreSulfur. error.orespawn.ore_source.alias_owned=%s besitzt diesen Eintrag bereits. Drücke Verschieben, um ihn neu zuzuweisen. error.orespawn.ore_source.defaults_write=Die Standardwerte der Materialgruppen konnten nicht gespeichert werden. +screen.orespawn.ore_source.group_settings=Erzquellen-Gruppeneinstellungen +label.orespawn.ore_source.groups=Gruppen +label.orespawn.ore_source.aliases=Ore-Dictionary-Einträge +label.orespawn.ore_source.placement_rules=Platzierungsregeln +label.orespawn.ore_source.placement_explanation=Ausgaben wählen den Block; Platzierungsregeln wählen Häufigkeit, Form, Tiefe und Wirte. +label.orespawn.ore_source.standard_veins=Standardadern +label.orespawn.ore_source.no_placement_rules=Keine verwalteten Platzierungsregeln +tooltip.orespawn.ore_source.group_settings=Name, Aliase und Platzierungsregeln dieser Gruppe bearbeiten. +tooltip.orespawn.ore_source.remove_alias=%s aus dieser Gruppe entfernen. +tooltip.orespawn.ore_source.placement_rules=Ausgaben wählen den Erzblock; Platzierungsregeln wählen Häufigkeit, Form, Tiefe und Wirtsbeschränkungen. +tooltip.orespawn.ore_source.placement_requires_consolidated=Platzierungsbesitz gilt nur für konsolidierte Modi Ausgeglichen, Einzeln oder Benutzerdefiniert. +tooltip.orespawn.ore_source.placement_missing=Die gespeicherte Platzierungsquelle fehlt; dieser Kanal bleibt deaktiviert. +tooltip.orespawn.ore_source.placement_readonly=Nur eine aktive verwaltete Regel ist verfügbar; diese Quelle ist schreibgeschützt. +tooltip.orespawn.ore_source.placement_select=Klicken, um zwischen aktiven verwalteten Platzierungsquellen zu wählen. +tooltip.orespawn.ore_source.external_excluded=Unabhängige externe Generatoren werden gemeldet, können aber nicht als Platzierungsregeln gewählt werden. diff --git a/src/main/resources/assets/orespawn/lang/de_DE.lang b/src/main/resources/assets/orespawn/lang/de_DE.lang index 5363334e..8242157a 100644 --- a/src/main/resources/assets/orespawn/lang/de_DE.lang +++ b/src/main/resources/assets/orespawn/lang/de_DE.lang @@ -442,3 +442,18 @@ tooltip.orespawn.ore_source.delete_group=Lösche diese benutzerdefinierte Gruppe error.orespawn.ore_source.alias=Verwende einen exakten Ore-Dictionary-Erznamen wie oreSulfur. error.orespawn.ore_source.alias_owned=%s besitzt diesen Eintrag bereits. Drücke Verschieben, um ihn neu zuzuweisen. error.orespawn.ore_source.defaults_write=Die Standardwerte der Materialgruppen konnten nicht gespeichert werden. +screen.orespawn.ore_source.group_settings=Erzquellen-Gruppeneinstellungen +label.orespawn.ore_source.groups=Gruppen +label.orespawn.ore_source.aliases=Ore-Dictionary-Einträge +label.orespawn.ore_source.placement_rules=Platzierungsregeln +label.orespawn.ore_source.placement_explanation=Ausgaben wählen den Block; Platzierungsregeln wählen Häufigkeit, Form, Tiefe und Wirte. +label.orespawn.ore_source.standard_veins=Standardadern +label.orespawn.ore_source.no_placement_rules=Keine verwalteten Platzierungsregeln +tooltip.orespawn.ore_source.group_settings=Name, Aliase und Platzierungsregeln dieser Gruppe bearbeiten. +tooltip.orespawn.ore_source.remove_alias=%s aus dieser Gruppe entfernen. +tooltip.orespawn.ore_source.placement_rules=Ausgaben wählen den Erzblock; Platzierungsregeln wählen Häufigkeit, Form, Tiefe und Wirtsbeschränkungen. +tooltip.orespawn.ore_source.placement_requires_consolidated=Platzierungsbesitz gilt nur für konsolidierte Modi Ausgeglichen, Einzeln oder Benutzerdefiniert. +tooltip.orespawn.ore_source.placement_missing=Die gespeicherte Platzierungsquelle fehlt; dieser Kanal bleibt deaktiviert. +tooltip.orespawn.ore_source.placement_readonly=Nur eine aktive verwaltete Regel ist verfügbar; diese Quelle ist schreibgeschützt. +tooltip.orespawn.ore_source.placement_select=Klicken, um zwischen aktiven verwalteten Platzierungsquellen zu wählen. +tooltip.orespawn.ore_source.external_excluded=Unabhängige externe Generatoren werden gemeldet, können aber nicht als Platzierungsregeln gewählt werden. diff --git a/src/main/resources/assets/orespawn/lang/en_CA.lang b/src/main/resources/assets/orespawn/lang/en_CA.lang index 26114a23..18683846 100644 --- a/src/main/resources/assets/orespawn/lang/en_CA.lang +++ b/src/main/resources/assets/orespawn/lang/en_CA.lang @@ -442,3 +442,18 @@ tooltip.orespawn.ore_source.delete_group=Delete this custom group without rewrit error.orespawn.ore_source.alias=Use an exact Ore Dictionary ore name such as oreSulfur. error.orespawn.ore_source.alias_owned=%s already owns this entry. Press Move to reassign it. error.orespawn.ore_source.defaults_write=Could not save material-group defaults. +screen.orespawn.ore_source.group_settings=Ore Source Group Settings +label.orespawn.ore_source.groups=Groups +label.orespawn.ore_source.aliases=Ore Dictionary entries +label.orespawn.ore_source.placement_rules=Placement Rules +label.orespawn.ore_source.placement_explanation=Outputs choose the block; placement rules choose frequency, shape, depth and hosts. +label.orespawn.ore_source.standard_veins=Standard veins +label.orespawn.ore_source.no_placement_rules=No managed placement rules +tooltip.orespawn.ore_source.group_settings=Edit this group's name, aliases and placement rules. +tooltip.orespawn.ore_source.remove_alias=Remove %s from this group. +tooltip.orespawn.ore_source.placement_rules=Outputs select the ore block; placement rules select frequency, shape, depth and host restrictions. +tooltip.orespawn.ore_source.placement_requires_consolidated=Placement ownership applies only to consolidated Balanced, Single or Custom modes. +tooltip.orespawn.ore_source.placement_missing=The saved placement source is missing; this channel remains disabled. +tooltip.orespawn.ore_source.placement_readonly=Only one active managed rule is available, so this source is read-only. +tooltip.orespawn.ore_source.placement_select=Click to choose between active managed placement sources. +tooltip.orespawn.ore_source.external_excluded=Independent external generators are reported but cannot be selected as placement rules. diff --git a/src/main/resources/assets/orespawn/lang/en_EN.lang b/src/main/resources/assets/orespawn/lang/en_EN.lang index 26114a23..18683846 100644 --- a/src/main/resources/assets/orespawn/lang/en_EN.lang +++ b/src/main/resources/assets/orespawn/lang/en_EN.lang @@ -442,3 +442,18 @@ tooltip.orespawn.ore_source.delete_group=Delete this custom group without rewrit error.orespawn.ore_source.alias=Use an exact Ore Dictionary ore name such as oreSulfur. error.orespawn.ore_source.alias_owned=%s already owns this entry. Press Move to reassign it. error.orespawn.ore_source.defaults_write=Could not save material-group defaults. +screen.orespawn.ore_source.group_settings=Ore Source Group Settings +label.orespawn.ore_source.groups=Groups +label.orespawn.ore_source.aliases=Ore Dictionary entries +label.orespawn.ore_source.placement_rules=Placement Rules +label.orespawn.ore_source.placement_explanation=Outputs choose the block; placement rules choose frequency, shape, depth and hosts. +label.orespawn.ore_source.standard_veins=Standard veins +label.orespawn.ore_source.no_placement_rules=No managed placement rules +tooltip.orespawn.ore_source.group_settings=Edit this group's name, aliases and placement rules. +tooltip.orespawn.ore_source.remove_alias=Remove %s from this group. +tooltip.orespawn.ore_source.placement_rules=Outputs select the ore block; placement rules select frequency, shape, depth and host restrictions. +tooltip.orespawn.ore_source.placement_requires_consolidated=Placement ownership applies only to consolidated Balanced, Single or Custom modes. +tooltip.orespawn.ore_source.placement_missing=The saved placement source is missing; this channel remains disabled. +tooltip.orespawn.ore_source.placement_readonly=Only one active managed rule is available, so this source is read-only. +tooltip.orespawn.ore_source.placement_select=Click to choose between active managed placement sources. +tooltip.orespawn.ore_source.external_excluded=Independent external generators are reported but cannot be selected as placement rules. diff --git a/src/main/resources/assets/orespawn/lang/en_GB.lang b/src/main/resources/assets/orespawn/lang/en_GB.lang index 26114a23..18683846 100644 --- a/src/main/resources/assets/orespawn/lang/en_GB.lang +++ b/src/main/resources/assets/orespawn/lang/en_GB.lang @@ -442,3 +442,18 @@ tooltip.orespawn.ore_source.delete_group=Delete this custom group without rewrit error.orespawn.ore_source.alias=Use an exact Ore Dictionary ore name such as oreSulfur. error.orespawn.ore_source.alias_owned=%s already owns this entry. Press Move to reassign it. error.orespawn.ore_source.defaults_write=Could not save material-group defaults. +screen.orespawn.ore_source.group_settings=Ore Source Group Settings +label.orespawn.ore_source.groups=Groups +label.orespawn.ore_source.aliases=Ore Dictionary entries +label.orespawn.ore_source.placement_rules=Placement Rules +label.orespawn.ore_source.placement_explanation=Outputs choose the block; placement rules choose frequency, shape, depth and hosts. +label.orespawn.ore_source.standard_veins=Standard veins +label.orespawn.ore_source.no_placement_rules=No managed placement rules +tooltip.orespawn.ore_source.group_settings=Edit this group's name, aliases and placement rules. +tooltip.orespawn.ore_source.remove_alias=Remove %s from this group. +tooltip.orespawn.ore_source.placement_rules=Outputs select the ore block; placement rules select frequency, shape, depth and host restrictions. +tooltip.orespawn.ore_source.placement_requires_consolidated=Placement ownership applies only to consolidated Balanced, Single or Custom modes. +tooltip.orespawn.ore_source.placement_missing=The saved placement source is missing; this channel remains disabled. +tooltip.orespawn.ore_source.placement_readonly=Only one active managed rule is available, so this source is read-only. +tooltip.orespawn.ore_source.placement_select=Click to choose between active managed placement sources. +tooltip.orespawn.ore_source.external_excluded=Independent external generators are reported but cannot be selected as placement rules. diff --git a/src/main/resources/assets/orespawn/lang/en_PT.lang b/src/main/resources/assets/orespawn/lang/en_PT.lang index 26114a23..18683846 100644 --- a/src/main/resources/assets/orespawn/lang/en_PT.lang +++ b/src/main/resources/assets/orespawn/lang/en_PT.lang @@ -442,3 +442,18 @@ tooltip.orespawn.ore_source.delete_group=Delete this custom group without rewrit error.orespawn.ore_source.alias=Use an exact Ore Dictionary ore name such as oreSulfur. error.orespawn.ore_source.alias_owned=%s already owns this entry. Press Move to reassign it. error.orespawn.ore_source.defaults_write=Could not save material-group defaults. +screen.orespawn.ore_source.group_settings=Ore Source Group Settings +label.orespawn.ore_source.groups=Groups +label.orespawn.ore_source.aliases=Ore Dictionary entries +label.orespawn.ore_source.placement_rules=Placement Rules +label.orespawn.ore_source.placement_explanation=Outputs choose the block; placement rules choose frequency, shape, depth and hosts. +label.orespawn.ore_source.standard_veins=Standard veins +label.orespawn.ore_source.no_placement_rules=No managed placement rules +tooltip.orespawn.ore_source.group_settings=Edit this group's name, aliases and placement rules. +tooltip.orespawn.ore_source.remove_alias=Remove %s from this group. +tooltip.orespawn.ore_source.placement_rules=Outputs select the ore block; placement rules select frequency, shape, depth and host restrictions. +tooltip.orespawn.ore_source.placement_requires_consolidated=Placement ownership applies only to consolidated Balanced, Single or Custom modes. +tooltip.orespawn.ore_source.placement_missing=The saved placement source is missing; this channel remains disabled. +tooltip.orespawn.ore_source.placement_readonly=Only one active managed rule is available, so this source is read-only. +tooltip.orespawn.ore_source.placement_select=Click to choose between active managed placement sources. +tooltip.orespawn.ore_source.external_excluded=Independent external generators are reported but cannot be selected as placement rules. diff --git a/src/main/resources/assets/orespawn/lang/en_US.lang b/src/main/resources/assets/orespawn/lang/en_US.lang index c92a501b..c9bdca68 100644 --- a/src/main/resources/assets/orespawn/lang/en_US.lang +++ b/src/main/resources/assets/orespawn/lang/en_US.lang @@ -442,3 +442,18 @@ tooltip.orespawn.ore_source.delete_group=Delete this custom group without rewrit error.orespawn.ore_source.alias=Use an exact Ore Dictionary ore name such as oreSulfur. error.orespawn.ore_source.alias_owned=%s already owns this entry. Press Move to reassign it. error.orespawn.ore_source.defaults_write=Could not save material-group defaults. +screen.orespawn.ore_source.group_settings=Ore Source Group Settings +label.orespawn.ore_source.groups=Groups +label.orespawn.ore_source.aliases=Ore Dictionary entries +label.orespawn.ore_source.placement_rules=Placement Rules +label.orespawn.ore_source.placement_explanation=Outputs choose the block; placement rules choose frequency, shape, depth and hosts. +label.orespawn.ore_source.standard_veins=Standard veins +label.orespawn.ore_source.no_placement_rules=No managed placement rules +tooltip.orespawn.ore_source.group_settings=Edit this group's name, aliases and placement rules. +tooltip.orespawn.ore_source.remove_alias=Remove %s from this group. +tooltip.orespawn.ore_source.placement_rules=Outputs select the ore block; placement rules select frequency, shape, depth and host restrictions. +tooltip.orespawn.ore_source.placement_requires_consolidated=Placement ownership applies only to consolidated Balanced, Single or Custom modes. +tooltip.orespawn.ore_source.placement_missing=The saved placement source is missing; this channel remains disabled. +tooltip.orespawn.ore_source.placement_readonly=Only one active managed rule is available, so this source is read-only. +tooltip.orespawn.ore_source.placement_select=Click to choose between active managed placement sources. +tooltip.orespawn.ore_source.external_excluded=Independent external generators are reported but cannot be selected as placement rules. diff --git a/src/main/resources/assets/orespawn/lang/es_ES.lang b/src/main/resources/assets/orespawn/lang/es_ES.lang index dc3b70e9..155c7025 100644 --- a/src/main/resources/assets/orespawn/lang/es_ES.lang +++ b/src/main/resources/assets/orespawn/lang/es_ES.lang @@ -442,3 +442,18 @@ tooltip.orespawn.ore_source.delete_group=Elimina este grupo personalizado sin re error.orespawn.ore_source.alias=Usa un nombre exacto del diccionario de minerales, como oreSulfur. error.orespawn.ore_source.alias_owned=%s ya posee esta entrada. Pulsa Mover para reasignarla. error.orespawn.ore_source.defaults_write=No se pudieron guardar los valores predeterminados de los grupos de materiales. +screen.orespawn.ore_source.group_settings=Configuración del grupo de fuentes de mena +label.orespawn.ore_source.groups=Grupos +label.orespawn.ore_source.aliases=Entradas del diccionario de menas +label.orespawn.ore_source.placement_rules=Reglas de colocación +label.orespawn.ore_source.placement_explanation=Las salidas eligen el bloque; las reglas eligen frecuencia, forma, profundidad y roca anfitriona. +label.orespawn.ore_source.standard_veins=Vetas estándar +label.orespawn.ore_source.no_placement_rules=Sin reglas de colocación administradas +tooltip.orespawn.ore_source.group_settings=Edita el nombre, los alias y las reglas de colocación de este grupo. +tooltip.orespawn.ore_source.remove_alias=Eliminar %s de este grupo. +tooltip.orespawn.ore_source.placement_rules=Las salidas eligen el bloque; las reglas eligen frecuencia, forma, profundidad y restricciones de roca anfitriona. +tooltip.orespawn.ore_source.placement_requires_consolidated=La selección de colocación solo se aplica a los modos consolidados Equilibrado, Único o Personalizado. +tooltip.orespawn.ore_source.placement_missing=Falta la fuente de colocación guardada; este canal permanece desactivado. +tooltip.orespawn.ore_source.placement_readonly=Solo hay una regla administrada activa; esta fuente es de solo lectura. +tooltip.orespawn.ore_source.placement_select=Haz clic para elegir entre fuentes de colocación administradas activas. +tooltip.orespawn.ore_source.external_excluded=Los generadores externos se informan, pero no pueden elegirse como reglas de colocación. diff --git a/src/main/resources/assets/orespawn/lang/es_MX.lang b/src/main/resources/assets/orespawn/lang/es_MX.lang index dc3b70e9..155c7025 100644 --- a/src/main/resources/assets/orespawn/lang/es_MX.lang +++ b/src/main/resources/assets/orespawn/lang/es_MX.lang @@ -442,3 +442,18 @@ tooltip.orespawn.ore_source.delete_group=Elimina este grupo personalizado sin re error.orespawn.ore_source.alias=Usa un nombre exacto del diccionario de minerales, como oreSulfur. error.orespawn.ore_source.alias_owned=%s ya posee esta entrada. Pulsa Mover para reasignarla. error.orespawn.ore_source.defaults_write=No se pudieron guardar los valores predeterminados de los grupos de materiales. +screen.orespawn.ore_source.group_settings=Configuración del grupo de fuentes de mena +label.orespawn.ore_source.groups=Grupos +label.orespawn.ore_source.aliases=Entradas del diccionario de menas +label.orespawn.ore_source.placement_rules=Reglas de colocación +label.orespawn.ore_source.placement_explanation=Las salidas eligen el bloque; las reglas eligen frecuencia, forma, profundidad y roca anfitriona. +label.orespawn.ore_source.standard_veins=Vetas estándar +label.orespawn.ore_source.no_placement_rules=Sin reglas de colocación administradas +tooltip.orespawn.ore_source.group_settings=Edita el nombre, los alias y las reglas de colocación de este grupo. +tooltip.orespawn.ore_source.remove_alias=Eliminar %s de este grupo. +tooltip.orespawn.ore_source.placement_rules=Las salidas eligen el bloque; las reglas eligen frecuencia, forma, profundidad y restricciones de roca anfitriona. +tooltip.orespawn.ore_source.placement_requires_consolidated=La selección de colocación solo se aplica a los modos consolidados Equilibrado, Único o Personalizado. +tooltip.orespawn.ore_source.placement_missing=Falta la fuente de colocación guardada; este canal permanece desactivado. +tooltip.orespawn.ore_source.placement_readonly=Solo hay una regla administrada activa; esta fuente es de solo lectura. +tooltip.orespawn.ore_source.placement_select=Haz clic para elegir entre fuentes de colocación administradas activas. +tooltip.orespawn.ore_source.external_excluded=Los generadores externos se informan, pero no pueden elegirse como reglas de colocación. diff --git a/src/main/resources/assets/orespawn/lang/fr_CA.lang b/src/main/resources/assets/orespawn/lang/fr_CA.lang index 9054368f..8d1d56e8 100644 --- a/src/main/resources/assets/orespawn/lang/fr_CA.lang +++ b/src/main/resources/assets/orespawn/lang/fr_CA.lang @@ -442,3 +442,18 @@ tooltip.orespawn.ore_source.delete_group=Supprime ce groupe personnalisé sans r error.orespawn.ore_source.alias=Utilisez un nom exact du dictionnaire de minerais, comme oreSulfur. error.orespawn.ore_source.alias_owned=%s possède déjà cette entrée. Appuyez sur Déplacer pour la réaffecter. error.orespawn.ore_source.defaults_write=Impossible d'enregistrer les valeurs par défaut des groupes de matériaux. +screen.orespawn.ore_source.group_settings=Paramètres du groupe de sources de minerai +label.orespawn.ore_source.groups=Groupes +label.orespawn.ore_source.aliases=Entrées du dictionnaire de minerais +label.orespawn.ore_source.placement_rules=Règles de placement +label.orespawn.ore_source.placement_explanation=Les sorties choisissent le bloc; les règles choisissent fréquence, forme, profondeur et hôtes. +label.orespawn.ore_source.standard_veins=Veines standard +label.orespawn.ore_source.no_placement_rules=Aucune règle de placement gérée +tooltip.orespawn.ore_source.group_settings=Modifier le nom, les alias et les règles de placement de ce groupe. +tooltip.orespawn.ore_source.remove_alias=Retirer %s de ce groupe. +tooltip.orespawn.ore_source.placement_rules=Les sorties choisissent le bloc; les règles choisissent fréquence, forme, profondeur et restrictions d'hôte. +tooltip.orespawn.ore_source.placement_requires_consolidated=Le propriétaire du placement s'applique seulement aux modes consolidés Équilibré, Unique ou Personnalisé. +tooltip.orespawn.ore_source.placement_missing=La source de placement enregistrée manque; ce canal reste désactivé. +tooltip.orespawn.ore_source.placement_readonly=Une seule règle gérée active est disponible; cette source est en lecture seule. +tooltip.orespawn.ore_source.placement_select=Cliquez pour choisir entre les sources de placement gérées actives. +tooltip.orespawn.ore_source.external_excluded=Les générateurs externes sont signalés mais ne peuvent pas être choisis comme règles de placement. diff --git a/src/main/resources/assets/orespawn/lang/fr_FR.lang b/src/main/resources/assets/orespawn/lang/fr_FR.lang index 9054368f..8d1d56e8 100644 --- a/src/main/resources/assets/orespawn/lang/fr_FR.lang +++ b/src/main/resources/assets/orespawn/lang/fr_FR.lang @@ -442,3 +442,18 @@ tooltip.orespawn.ore_source.delete_group=Supprime ce groupe personnalisé sans r error.orespawn.ore_source.alias=Utilisez un nom exact du dictionnaire de minerais, comme oreSulfur. error.orespawn.ore_source.alias_owned=%s possède déjà cette entrée. Appuyez sur Déplacer pour la réaffecter. error.orespawn.ore_source.defaults_write=Impossible d'enregistrer les valeurs par défaut des groupes de matériaux. +screen.orespawn.ore_source.group_settings=Paramètres du groupe de sources de minerai +label.orespawn.ore_source.groups=Groupes +label.orespawn.ore_source.aliases=Entrées du dictionnaire de minerais +label.orespawn.ore_source.placement_rules=Règles de placement +label.orespawn.ore_source.placement_explanation=Les sorties choisissent le bloc; les règles choisissent fréquence, forme, profondeur et hôtes. +label.orespawn.ore_source.standard_veins=Veines standard +label.orespawn.ore_source.no_placement_rules=Aucune règle de placement gérée +tooltip.orespawn.ore_source.group_settings=Modifier le nom, les alias et les règles de placement de ce groupe. +tooltip.orespawn.ore_source.remove_alias=Retirer %s de ce groupe. +tooltip.orespawn.ore_source.placement_rules=Les sorties choisissent le bloc; les règles choisissent fréquence, forme, profondeur et restrictions d'hôte. +tooltip.orespawn.ore_source.placement_requires_consolidated=Le propriétaire du placement s'applique seulement aux modes consolidés Équilibré, Unique ou Personnalisé. +tooltip.orespawn.ore_source.placement_missing=La source de placement enregistrée manque; ce canal reste désactivé. +tooltip.orespawn.ore_source.placement_readonly=Une seule règle gérée active est disponible; cette source est en lecture seule. +tooltip.orespawn.ore_source.placement_select=Cliquez pour choisir entre les sources de placement gérées actives. +tooltip.orespawn.ore_source.external_excluded=Les générateurs externes sont signalés mais ne peuvent pas être choisis comme règles de placement. diff --git a/src/main/resources/assets/orespawn/lang/ja_JP.lang b/src/main/resources/assets/orespawn/lang/ja_JP.lang index d2eec0e4..db14483e 100644 --- a/src/main/resources/assets/orespawn/lang/ja_JP.lang +++ b/src/main/resources/assets/orespawn/lang/ja_JP.lang @@ -442,3 +442,18 @@ tooltip.orespawn.ore_source.delete_group=生成済みチャンクを書き換え error.orespawn.ore_source.alias=oreSulfur のような正確な鉱石辞書名を使用してください。 error.orespawn.ore_source.alias_owned=%s がこのエントリを既に所有しています。再割り当てするには「移動」を押してください。 error.orespawn.ore_source.defaults_write=素材グループの既定値を保存できませんでした。 +screen.orespawn.ore_source.group_settings=鉱石ソースグループ設定 +label.orespawn.ore_source.groups=グループ +label.orespawn.ore_source.aliases=鉱石辞書エントリ +label.orespawn.ore_source.placement_rules=配置ルール +label.orespawn.ore_source.placement_explanation=出力はブロックを、配置ルールは頻度、形状、深さ、母岩を選びます。 +label.orespawn.ore_source.standard_veins=標準鉱脈 +label.orespawn.ore_source.no_placement_rules=管理対象の配置ルールはありません +tooltip.orespawn.ore_source.group_settings=このグループの名前、別名、配置ルールを編集します。 +tooltip.orespawn.ore_source.remove_alias=%s をこのグループから削除します。 +tooltip.orespawn.ore_source.placement_rules=出力は鉱石ブロックを、配置ルールは頻度、形状、深さ、母岩制限を選びます。 +tooltip.orespawn.ore_source.placement_requires_consolidated=配置元の選択は統合された均等、単一、カスタムモードだけに適用されます。 +tooltip.orespawn.ore_source.placement_missing=保存された配置元がありません。このチャンネルは無効のままです。 +tooltip.orespawn.ore_source.placement_readonly=有効な管理ルールは1つだけなので、このソースは読み取り専用です。 +tooltip.orespawn.ore_source.placement_select=クリックして有効な管理対象配置元を選択します。 +tooltip.orespawn.ore_source.external_excluded=独立した外部生成は報告されますが、配置ルールには選択できません。 diff --git a/src/main/resources/assets/orespawn/lang/ko_KR.lang b/src/main/resources/assets/orespawn/lang/ko_KR.lang index 0c12a94a..315f206d 100644 --- a/src/main/resources/assets/orespawn/lang/ko_KR.lang +++ b/src/main/resources/assets/orespawn/lang/ko_KR.lang @@ -442,3 +442,18 @@ tooltip.orespawn.ore_source.delete_group=생성된 청크를 다시 쓰지 않 error.orespawn.ore_source.alias=oreSulfur 같은 정확한 광석 사전 이름을 사용하세요. error.orespawn.ore_source.alias_owned=%s에서 이미 이 항목을 소유합니다. 이동을 눌러 재할당하세요. error.orespawn.ore_source.defaults_write=재료 그룹 기본값을 저장하지 못했습니다. +screen.orespawn.ore_source.group_settings=광석 소스 그룹 설정 +label.orespawn.ore_source.groups=그룹 +label.orespawn.ore_source.aliases=광석 사전 항목 +label.orespawn.ore_source.placement_rules=배치 규칙 +label.orespawn.ore_source.placement_explanation=출력은 블록을, 배치 규칙은 빈도, 모양, 깊이와 모암을 선택합니다. +label.orespawn.ore_source.standard_veins=표준 광맥 +label.orespawn.ore_source.no_placement_rules=관리되는 배치 규칙 없음 +tooltip.orespawn.ore_source.group_settings=이 그룹의 이름, 별칭과 배치 규칙을 편집합니다. +tooltip.orespawn.ore_source.remove_alias=이 그룹에서 %s 항목을 제거합니다. +tooltip.orespawn.ore_source.placement_rules=출력은 광석 블록을, 배치 규칙은 빈도, 모양, 깊이와 모암 제한을 선택합니다. +tooltip.orespawn.ore_source.placement_requires_consolidated=배치 소유권은 통합된 균형, 단일 또는 사용자 지정 모드에만 적용됩니다. +tooltip.orespawn.ore_source.placement_missing=저장된 배치 소스가 없습니다. 이 채널은 비활성화 상태로 유지됩니다. +tooltip.orespawn.ore_source.placement_readonly=활성 관리 규칙이 하나뿐이므로 이 소스는 읽기 전용입니다. +tooltip.orespawn.ore_source.placement_select=활성 관리 배치 소스 중 하나를 선택하려면 클릭하세요. +tooltip.orespawn.ore_source.external_excluded=독립 외부 생성기는 보고되지만 배치 규칙으로 선택할 수 없습니다. diff --git a/src/main/resources/assets/orespawn/lang/pt_BR.lang b/src/main/resources/assets/orespawn/lang/pt_BR.lang index e9fa6345..66e522ac 100644 --- a/src/main/resources/assets/orespawn/lang/pt_BR.lang +++ b/src/main/resources/assets/orespawn/lang/pt_BR.lang @@ -442,3 +442,18 @@ tooltip.orespawn.ore_source.delete_group=Exclui este grupo personalizado sem ree error.orespawn.ore_source.alias=Use um nome exato do dicionário de minérios, como oreSulfur. error.orespawn.ore_source.alias_owned=%s já possui esta entrada. Pressione Mover para transferi-la. error.orespawn.ore_source.defaults_write=Não foi possível salvar os padrões dos grupos de materiais. +screen.orespawn.ore_source.group_settings=Configurações do grupo de fontes de minério +label.orespawn.ore_source.groups=Grupos +label.orespawn.ore_source.aliases=Entradas do Dicionário de Minérios +label.orespawn.ore_source.placement_rules=Regras de colocação +label.orespawn.ore_source.placement_explanation=Saídas escolhem o bloco; regras escolhem frequência, forma, profundidade e hospedeiros. +label.orespawn.ore_source.standard_veins=Veios padrão +label.orespawn.ore_source.no_placement_rules=Nenhuma regra de colocação gerenciada +tooltip.orespawn.ore_source.group_settings=Edite o nome, aliases e regras de colocação deste grupo. +tooltip.orespawn.ore_source.remove_alias=Remover %s deste grupo. +tooltip.orespawn.ore_source.placement_rules=Saídas escolhem o bloco; regras escolhem frequência, forma, profundidade e restrições de hospedeiro. +tooltip.orespawn.ore_source.placement_requires_consolidated=A seleção de colocação vale apenas nos modos consolidados Balanceado, Único ou Personalizado. +tooltip.orespawn.ore_source.placement_missing=A fonte de colocação salva está ausente; este canal permanece desativado. +tooltip.orespawn.ore_source.placement_readonly=Há apenas uma regra gerenciada ativa; esta fonte é somente leitura. +tooltip.orespawn.ore_source.placement_select=Clique para escolher entre fontes de colocação gerenciadas ativas. +tooltip.orespawn.ore_source.external_excluded=Geradores externos independentes são informados, mas não podem ser escolhidos como regras de colocação. diff --git a/src/main/resources/assets/orespawn/lang/ru_RU.lang b/src/main/resources/assets/orespawn/lang/ru_RU.lang index 6bf42951..a9c643cd 100644 --- a/src/main/resources/assets/orespawn/lang/ru_RU.lang +++ b/src/main/resources/assets/orespawn/lang/ru_RU.lang @@ -442,3 +442,18 @@ tooltip.orespawn.ore_source.delete_group=Удаляет эту настраив error.orespawn.ore_source.alias=Используйте точное имя из словаря руд, например oreSulfur. error.orespawn.ore_source.alias_owned=%s уже владеет этой записью. Нажмите Переместить для переназначения. error.orespawn.ore_source.defaults_write=Не удалось сохранить значения групп материалов по умолчанию. +screen.orespawn.ore_source.group_settings=Настройки группы источников руды +label.orespawn.ore_source.groups=Группы +label.orespawn.ore_source.aliases=Записи словаря руд +label.orespawn.ore_source.placement_rules=Правила размещения +label.orespawn.ore_source.placement_explanation=Выходы выбирают блок; правила выбирают частоту, форму, глубину и вмещающие породы. +label.orespawn.ore_source.standard_veins=Стандартные жилы +label.orespawn.ore_source.no_placement_rules=Нет управляемых правил размещения +tooltip.orespawn.ore_source.group_settings=Изменить имя, псевдонимы и правила размещения этой группы. +tooltip.orespawn.ore_source.remove_alias=Удалить %s из этой группы. +tooltip.orespawn.ore_source.placement_rules=Выходы выбирают блок руды; правила выбирают частоту, форму, глубину и ограничения вмещающих пород. +tooltip.orespawn.ore_source.placement_requires_consolidated=Выбор размещения применяется только к объединённым режимам Равный, Один или Пользовательский. +tooltip.orespawn.ore_source.placement_missing=Сохранённый источник размещения отсутствует; канал остаётся отключённым. +tooltip.orespawn.ore_source.placement_readonly=Доступно только одно активное управляемое правило; источник доступен только для чтения. +tooltip.orespawn.ore_source.placement_select=Нажмите для выбора активного управляемого источника размещения. +tooltip.orespawn.ore_source.external_excluded=Независимые внешние генераторы отображаются, но не могут быть выбраны как правила размещения. diff --git a/src/main/resources/assets/orespawn/lang/zh_CN.lang b/src/main/resources/assets/orespawn/lang/zh_CN.lang index 7cbac5da..07e3bd51 100644 --- a/src/main/resources/assets/orespawn/lang/zh_CN.lang +++ b/src/main/resources/assets/orespawn/lang/zh_CN.lang @@ -442,3 +442,18 @@ tooltip.orespawn.ore_source.delete_group=删除此自定义分组,不重写已 error.orespawn.ore_source.alias=请使用准确的矿物词典名称,例如 oreSulfur。 error.orespawn.ore_source.alias_owned=%s 已拥有此条目。按“移动”可重新分配。 error.orespawn.ore_source.defaults_write=无法保存材料分组默认值。 +screen.orespawn.ore_source.group_settings=矿石来源分组设置 +label.orespawn.ore_source.groups=分组 +label.orespawn.ore_source.aliases=矿物词典条目 +label.orespawn.ore_source.placement_rules=放置规则 +label.orespawn.ore_source.placement_explanation=输出选择方块;放置规则选择频率、形状、深度和宿主。 +label.orespawn.ore_source.standard_veins=标准矿脉 +label.orespawn.ore_source.no_placement_rules=没有受管理的放置规则 +tooltip.orespawn.ore_source.group_settings=编辑此分组的名称、别名和放置规则。 +tooltip.orespawn.ore_source.remove_alias=从此分组移除 %s。 +tooltip.orespawn.ore_source.placement_rules=输出选择矿石方块;放置规则选择频率、形状、深度和宿主限制。 +tooltip.orespawn.ore_source.placement_requires_consolidated=放置来源仅适用于合并的均衡、单一或自定义模式。 +tooltip.orespawn.ore_source.placement_missing=保存的放置来源缺失;此通道保持禁用。 +tooltip.orespawn.ore_source.placement_readonly=只有一个有效的受管理规则,因此此来源为只读。 +tooltip.orespawn.ore_source.placement_select=点击以选择有效的受管理放置来源。 +tooltip.orespawn.ore_source.external_excluded=独立外部生成器会显示,但不能选作放置规则。 diff --git a/src/test/java/zone/moddev/mc/orespawn/LocalizationParityTest.java b/src/test/java/zone/moddev/mc/orespawn/LocalizationParityTest.java index 9670a5d3..4b442ccb 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(444, englishKeys.size(), + assertEquals(459, 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/client/CompactScrollListTest.java b/src/test/java/zone/moddev/mc/orespawn/client/CompactScrollListTest.java new file mode 100644 index 00000000..d0899a71 --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/client/CompactScrollListTest.java @@ -0,0 +1,61 @@ +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.assertTrue; + +import org.junit.jupiter.api.Test; + +class CompactScrollListTest { + @Test + void geometryFitsBothSupportedOreSourceSizes() { + assertEquals(13, CompactScrollList.visibleRows(209, 16)); + assertEquals(11, CompactScrollList.visibleRows(184, 16)); + assertEquals(5, CompactScrollList.maxFirst(18, 13)); + assertEquals(7, CompactScrollList.maxFirst(18, 11)); + assertEquals(137, CompactScrollList.thumbHeight(18, 13, 191)); + assertEquals(3, CompactScrollList.firstFromThumb(27, 54, 5)); + assertEquals(5, CompactScrollList.firstFromThumb(54, 54, 5)); + } + + @Test + void wheelArrowsTrackAndRowsOperateIndependently() { + ProbeList list = new ProbeList(); + assertTrue(list.mouseWheel(20, 30, -120)); + assertEquals(3, list.firstIndex()); + assertFalse(list.mouseWheel(2, 2, -120)); + + list.mousePressed(null, 105, 83); // lower arrow + assertEquals(4, list.firstIndex()); + list.mousePressed(null, 105, 65); // track below thumb: one visible page + assertEquals(8, list.firstIndex()); + + list.setFirstIndex(0); + list.mousePressed(null, 20, 53); + list.press(); + assertEquals(2, list.selected); + list.mousePressed(null, 95, 21); + list.press(); + assertEquals(0, list.action); + list.setFirstIndex(20); + list.ensureVisible(5); + assertEquals(5, list.firstIndex()); + list.ensureVisible(12); + assertEquals(9, list.firstIndex()); + } + + private static final class ProbeList extends CompactScrollList { + int selected = -1; + int action = -1; + + ProbeList() { + super(null, 10, 20, 100, 64); + } + + @Override protected int size() { return 30; } + @Override protected String rowText(int index) { return Integer.toString(index); } + @Override protected void onRowPressed(int index) { selected = index; } + @Override protected int rowActionWidth(int index) { return 10; } + @Override protected void onRowActionPressed(int index) { action = index; } + } +} diff --git a/src/test/java/zone/moddev/mc/orespawn/client/OreSourcePlacementScreenTest.java b/src/test/java/zone/moddev/mc/orespawn/client/OreSourcePlacementScreenTest.java new file mode 100644 index 00000000..072542bd --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/client/OreSourcePlacementScreenTest.java @@ -0,0 +1,66 @@ +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.assertTrue; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import zone.moddev.mc.orespawn.client.GeologyEditorSession.OreSourceCandidate; +import zone.moddev.mc.orespawn.client.GeologyEditorSession.OreSourceGroup; + +class OreSourcePlacementScreenTest { + @Test + void placementRulesExcludeExternalMissingAndOutputOnlyCandidates() { + OreSourceCandidate managed = candidate("managed", "orespawn:standard", true, true, false); + OreSourceCandidate second = candidate("second", "orespawn:standard", true, true, false); + OreSourceCandidate external = candidate("external", "orespawn:standard", true, true, true); + OreSourceCandidate missing = candidate("missing", "orespawn:standard", false, true, false); + OreSourceCandidate outputOnly = candidate("output", "realisticdeposits:district", true, false, false); + OreSourceGroup group = group("consolidated", + Arrays.asList(managed, second, external, missing, outputOnly), + Collections.singletonMap("lost:channel", "gone")); + + List standard = OreSourceListScreen.placementCandidates( + group, "orespawn:standard"); + assertEquals(Arrays.asList(managed, second), standard); + assertTrue(OreSourceGroupSettingsScreen.placementSelectable(group, "orespawn:standard")); + assertFalse(OreSourceGroupSettingsScreen.placementSelectable(group, + "realisticdeposits:district")); + assertEquals(Arrays.asList("lost:channel", "orespawn:standard"), + OreSourceGroupSettingsScreen.placementChannels(group)); + } + + @Test + void keepOriginalAndSingleManagedSourcesRemainReadOnly() { + OreSourceCandidate first = candidate("first", "orespawn:standard", true, true, false); + OreSourceCandidate second = candidate("second", "orespawn:standard", true, true, false); + assertFalse(OreSourceGroupSettingsScreen.placementSelectable( + group("consolidated", Collections.singletonList(first), Collections.emptyMap()), + "orespawn:standard")); + assertFalse(OreSourceGroupSettingsScreen.placementSelectable( + group("keep_separate", Arrays.asList(first, second), Collections.emptyMap()), + "orespawn:standard")); + } + + private static OreSourceCandidate candidate(String source, String channel, + boolean loaded, boolean active, boolean external) { + return new OreSourceCandidate(source, "owner", "Owner", "1", "owner:ore", 0, + channel, Collections.singletonList("oreTest"), loaded, active, + external, false, false); + } + + private static OreSourceGroup group(String mode, List candidates, + java.util.Map placements) { + return new OreSourceGroup("orespawn:test|minecraft:overworld", "orespawn:test", + "minecraft:overworld", "Test", Collections.singletonList("oreTest"), false, + mode, "balanced", "consolidated", candidates, + Collections.unmodifiableMap(new LinkedHashMap<>()), + Collections.unmodifiableMap(new LinkedHashMap<>(placements))); + } +} 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 62c09ff8..f176da75 100644 --- a/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java @@ -27,7 +27,7 @@ void everyConcreteScreenClearsThePreviousFrameBeforeDrawingWidgets() throws Exce .sorted() .collect(Collectors.toList()); } - assertEquals(26, screens.size(), "Review this render-order gate when screens are added or removed"); + assertEquals(27, 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( @@ -89,37 +89,33 @@ void oreSourcesAndAddBlockShareTheCompactOreFooter() throws Exception { } @Test - void oreSourceEditorKeepsBothPanesVisibleAndPaginatesIndependently() throws Exception { - assertEquals(1, OreSourceListScreen.pageCount(0, 4)); - assertEquals(1, OreSourceListScreen.pageCount(4, 4)); - assertEquals(2, OreSourceListScreen.pageCount(5, 4)); + void oreSourceEditorKeepsBothCompactScrollablePanesVisible() throws Exception { assertEquals(406, OreSourceListScreen.contentWidth(426)); assertEquals(174, OreSourceListScreen.leftPaneWidth(426)); - assertEquals(2, OreSourceListScreen.groupRowCount(265)); - assertEquals(2, OreSourceListScreen.aliasRowCount(265)); - assertEquals(6, OreSourceListScreen.outputRowCount(265, false)); - assertEquals(5, OreSourceListScreen.outputRowCount(265, true)); + assertEquals(209, OreSourceListScreen.listHeight(265)); + assertEquals(13, CompactScrollList.visibleRows(209, 16)); assertEquals(300, OreSourceListScreen.contentWidth(320)); assertEquals(129, OreSourceListScreen.leftPaneWidth(320)); - assertEquals(2, OreSourceListScreen.groupRowCount(240)); - assertEquals(1, OreSourceListScreen.aliasRowCount(240)); - assertEquals(5, OreSourceListScreen.outputRowCount(240, false)); - assertEquals(4, OreSourceListScreen.outputRowCount(240, true)); + assertEquals(184, OreSourceListScreen.listHeight(240)); + assertEquals(11, CompactScrollList.visibleRows(184, 16)); String list = screenSource("OreSourceListScreen.java"); - assertTrue(list.contains("initDirectory(")); + assertTrue(list.contains("initGroups(")); assertTrue(list.contains("initOutputs(")); - assertTrue(list.contains("initDirectory(leftPaneX, CONTENT_TOP, leftPaneWidth, groups);")); - assertTrue(list.contains("initOutputs(rightPaneX, CONTENT_TOP, rightPaneWidth);")); - assertTrue(list.contains("OreSpawnScreenLayout.fit")); - assertTrue(list.contains("button.orespawn.ore_source.all_groups")); - assertTrue(list.contains("aliasPage")); + assertTrue(list.contains("new CompactScrollList(this, leftPaneX, CONTENT_TOP")); + assertTrue(list.contains("new CompactScrollList(this, rightPaneX, CONTENT_TOP + 24")); + assertTrue(list.contains("label.orespawn.ore_source.groups")); + assertTrue(list.contains("drawCompactCog")); assertTrue(list.contains("outputCandidates()")); assertTrue(list.contains("single || !group.outputs.containsKey(candidate.sourceId)"), "Single output rows behave as radio buttons and cannot deselect their only output"); - assertTrue(list.contains("button.orespawn.ore_source.advanced")); - assertTrue(list.contains(", 20,"), "Material Groups must use normal Forge-height controls"); + assertTrue(list.contains("mouseWheel(mouseX, mouseY, wheel)")); assertTrue(list.contains("minecraft.displayGuiScreen(parent)")); - assertTrue(list.contains("toggle.enabled = candidate.loaded && !candidate.enrichment")); + assertFalse(list.contains("button.orespawn.ore_source.all_groups")); + assertFalse(list.contains("button.orespawn.ore_source.advanced")); + assertFalse(list.contains("groupPage")); + assertFalse(list.contains("outputPage")); + assertFalse(list.contains("nameField")); + assertFalse(list.contains("aliasField")); assertFalse(list.contains("WIDE_MINIMUM")); assertFalse(list.contains("compactDetail")); assertFalse(list.contains("button.orespawn.back")); @@ -129,6 +125,22 @@ void oreSourceEditorKeepsBothPanesVisibleAndPaginatesIndependently() throws Exce "The technical detail screen must not remain as a second competing flow"); } + @Test + void groupSettingsOwnsAliasesAndClearlyNamedPlacementRules() throws Exception { + assertEquals(48, OreSourceGroupSettingsScreen.aliasListHeight(265)); + assertEquals(32, OreSourceGroupSettingsScreen.aliasListHeight(240)); + String settings = screenSource("OreSourceGroupSettingsScreen.java"); + assertTrue(settings.contains("option.orespawn.ore_source.group_name")); + assertTrue(settings.contains("option.orespawn.ore_source.alias")); + assertTrue(settings.contains("label.orespawn.ore_source.placement_rules")); + assertTrue(settings.contains("label.orespawn.ore_source.placement_explanation")); + assertTrue(settings.contains("label.orespawn.ore_source.standard_veins")); + assertTrue(settings.contains("placementSelectable(group, channel)")); + assertTrue(screenSource("OreSourceListScreen.java").contains("!candidate.external")); + assertTrue(settings.contains("minecraft.displayGuiScreen(parent)")); + assertFalse(settings.contains("button.orespawn.ore_source.advanced")); + } + @Test void materialGroupDefaultsPersistOnlyThroughMainDone() throws Exception { String main = screenSource("OreSpawnWorldSettingsScreen.java"); From 2629c5c780ade8932b2585996bc17bd4eb3cf8b6 Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Thu, 17 Sep 2026 12:41:19 +0100 Subject: [PATCH 14/31] Polish Ore Sources guidance and restore Keep Original --- CHANGELOG.txt | 6 +- README.md | 6 +- docs/CONFIGURATION.md | 7 +- docs/PLAYER_GUIDE.md | 7 +- .../orespawn/client/ClientProbeTestMod.java | 23 +++--- .../orespawn/client/GeologyEditorSession.java | 37 ++++++++++ .../client/OreSourceGroupSettingsScreen.java | 72 +++++++++++++++---- .../orespawn/client/OreSourceListScreen.java | 2 +- .../mc/orespawn/client/OreSpawnScreen.java | 13 +++- .../orespawn/client/OreSpawnScreenLayout.java | 4 +- .../client/GeologyEditorSessionTest.java | 22 ++++++ .../client/OreSourcePlacementScreenTest.java | 9 +++ .../client/OreSpawnScreenLayoutTest.java | 12 +++- 13 files changed, 184 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 964ee9da..d6b25885 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -16,7 +16,11 @@ Version 4.1.0.110021 * Add Keep Original, Balanced, Single and Custom output modes. Reviewed fresh MMD conflicts balance every eligible output through one placement budget; inactive provider definitions remain selectable outputs without contributing - another placement rule. + another placement rule. Returning to Keep Original restores the output and + placement selections from when the editor was opened. +* Keep tooltips inside the available side of the screen and wrap Placement + Rules guidance above the list, with an explicit fixed, selectable, missing or + Keep Original status instead of an unexplained disabled control. * Ship curated Sulfur/Sulphur and Aluminum/Aluminium groups, keep Niter and Saltpeter distinct, deduplicate identical registry states, and allow confirmed alias reassignment plus stable custom groups. Group edits are saved to the diff --git a/README.md b/README.md index 482eb1fc..6b1eee9f 100644 --- a/README.md +++ b/README.md @@ -68,8 +68,10 @@ shape, depth and host restrictions for each managed channel. New worlds balance all eligible outputs in reviewed MMD conflicts while running one placement budget, so sulfur/sulphur providers do not multiply abundance. **Single** chooses one output, **Custom** allows a weighted subset, and **Keep Original** -preserves every active rule. Existing worlds start in Keep Original until their -owner explicitly changes a group. Third-party generators remain outside +preserves every active rule and restores the selections that were present when +the editor was opened. Placement Rules states whether a channel is fixed, +selectable, missing, or inactive under Keep Original. Existing worlds start in +Keep Original until their owner explicitly changes a group. Third-party generators remain outside OreSpawn's control and are clearly reported rather than disabled. Important files: diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 604e3e14..2061a812 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -234,7 +234,9 @@ 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**. +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. The Ore Sources screen keeps compact scrollable group and output lists visible together. Use a group's cog for its friendly name, Ore Dictionary aliases and @@ -244,7 +246,8 @@ frequency, shape, depth and host restrictions for each channel. `orespawn:standa 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. +as placement owners. The settings screen labels that state explicitly and wraps +its purpose and status above the placement list at compact resolutions. Consolidation runs the chosen placement rule's pattern, hosts, filters, height and frequency once; selected candidates contribute output bundles only. An diff --git a/docs/PLAYER_GUIDE.md b/docs/PLAYER_GUIDE.md index 1e71b8a5..cb47d18f 100644 --- a/docs/PLAYER_GUIDE.md +++ b/docs/PLAYER_GUIDE.md @@ -50,12 +50,15 @@ safely instead of breaking world creation. scrolling lists keep every discovered group on the left and the deduplicated union of installed ore blocks represented by its aliases on the right: **Balanced** shares them equally, **Single** uses one, **Custom** enables a - weighted subset, and **Keep Original** preserves every old placement rule. + 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. 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; - multiple managed sources can be selected. Independent external generators + multiple managed sources can be selected. The screen states whether each + channel is fixed, selectable, missing, or inactive under Keep Original. + 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 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 d5745569..700b2881 100644 --- a/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java +++ b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java @@ -159,7 +159,8 @@ public void onClientTick(TickEvent.ClientTickEvent event) { } break; case 7: - if (minecraft.world == null && !minecraft.isIntegratedServerRunning() && stateTicks >= 20) { + if (!minecraft.isIntegratedServerRunning() && stateTicks >= 20) { + if (minecraft.world != null) minecraft.loadWorld(null); minecraft.launchIntegratedServer(WORLD_DIRECTORY, "OreSpawn Client Smoke", new WorldSettings(0L, GameType.CREATIVE, false, false, WorldType.DEFAULT)); nextState(8); @@ -173,7 +174,8 @@ public void onClientTick(TickEvent.ClientTickEvent event) { } break; case 9: - if (minecraft.world == null && !minecraft.isIntegratedServerRunning()) { + if (!minecraft.isIntegratedServerRunning() && stateTicks >= 20) { + if (minecraft.world != null) minecraft.loadWorld(null); writeMarker(); minecraft.shutdown(); nextState(10); @@ -323,16 +325,21 @@ private void validateOreSourcesSingleScreen(GuiScreen parent, GeologyEditorSessi settings.setWorldAndResolution(minecraft, 426, 265); int compactLists = 0; boolean sulfurName = false; + int lowestListBottom = 0; for (GuiButton widget : settings.buttons) { - if (widget instanceof CompactScrollList) compactLists++; + if (widget instanceof CompactScrollList) { + compactLists++; + lowestListBottom = Math.max(lowestListBottom, widget.yPosition + widget.height); + } if (widget instanceof TextFieldWidget && "Sulfur".equals(((TextFieldWidget) widget).getValue())) sulfurName = true; } if (compactLists != 2 || !sulfurName + || lowestListBottom > 233 || !OreSourceGroupSettingsScreen.placementChannels(sulfurGroup) .contains("orespawn:standard")) { throw new IllegalStateException("Group Settings did not expose aliases and placement rules: lists=" - + compactLists + ", name=" + sulfurName + ", channels=" + + compactLists + ", name=" + sulfurName + ", listBottom=" + lowestListBottom + ", channels=" + OreSourceGroupSettingsScreen.placementChannels(sulfurGroup)); } oreSourcesLayoutValidated = true; @@ -488,12 +495,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()); } 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 b0d8bf62..e12aa271 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java @@ -602,6 +602,43 @@ void setOreSourceMode(String key, boolean consolidated) { refreshOreSourceStatus(policy); } + void restoreOreSourceOriginalMode(String key) { + JsonObject policy = objectEntry(section("ore_source_policies"), key); + JsonObject originalPolicies = object(original, "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; diff --git a/src/main/java/zone/moddev/mc/orespawn/client/OreSourceGroupSettingsScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceGroupSettingsScreen.java index 6f317e8a..3f0698ab 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/OreSourceGroupSettingsScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceGroupSettingsScreen.java @@ -34,6 +34,10 @@ final class OreSourceGroupSettingsScreen extends OreSpawnScreen { private int addAliasY; private int placementLabelY; private int placementListY; + private int placementStatusY; + private List placementExplanationLines = java.util.Collections.emptyList(); + private List placementStatusLines = java.util.Collections.emptyList(); + private int placementStatusColor = 0xA0A0A0; private TextFieldWidget nameField; private TextFieldWidget aliasField; private CompactScrollList aliasList; @@ -125,9 +129,13 @@ protected void init() { : I18n.format("button.orespawn.ore_source.move")), button -> addAlias(group))); OreSpawnScreenLayout.explain(this, add, "tooltip.orespawn.ore_source.alias"); - placementLabelY = addAliasY + 27; - placementListY = placementLabelY + 22; final List channels = placementChannels(group); + placementLabelY = addAliasY + 27; + placementExplanationLines = wrap(I18n.format("label.orespawn.ore_source.placement_explanation")); + placementStatusLines = wrap(placementStatus(group, channels)); + placementStatusY = placementLabelY + 11 + (placementExplanationLines.size() * 10); + placementListY = placementStatusY + (placementStatusLines.size() * 10) + 3; + placementStatusColor = placementStatusColor(group, channels); placementList = addButton(new CompactScrollList(this, contentX, placementListY, contentWidth, Math.max(16, height - 32 - placementListY)) { @Override protected int size() { return channels.size(); } @@ -139,12 +147,18 @@ protected void init() { String source = choice < 0 ? I18n.format("label.orespawn.ore_source.missing") : OreSourceListScreen.candidateLabel(choices.get(choice)); - return channelLabel(channel) + ": " + source; + return channelLabel(channel) + (placementSelectable(group, channel) ? " > " : " = ") + source; } @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); @@ -228,7 +242,11 @@ private void rebuild() { } static int aliasListHeight(int height) { - return height >= 260 ? 48 : 32; + return height >= 260 ? 32 : 16; + } + + private List wrap(String text) { + return font.listFormattedStringToWidth(text, Math.max(80, contentWidth - 4)); } static List placementChannels(OreSourceGroup group) { @@ -248,6 +266,33 @@ static boolean placementSelectable(OreSourceGroup group, String channel) { && 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"); + } + + static int placementStatusColor(OreSourceGroup group, List channels) { + if (channels.isEmpty()) return 0x777777; + if ("keep_separate".equals(group.mode)) return 0xA0A0A0; + for (String channel : channels) { + if (OreSourceListScreen.placementCandidates(group, channel).isEmpty()) return 0xFF5555; + } + for (String channel : channels) { + if (placementSelectable(group, channel)) return 0x55FF55; + } + return 0xFFFF55; + } + @Override public void handleMouseInput() throws IOException { int mouseX = Mouse.getEventX() * width / minecraft.displayWidth; @@ -278,10 +323,16 @@ public void render(int mouseX, int mouseY, float partialTick) { contentX + 2, 59, 0xA0A0A0); drawString(font, new TextComponentTranslation("label.orespawn.ore_source.placement_rules"), contentX + 2, placementLabelY, 0xFFFFFF); - String explanation = I18n.format("label.orespawn.ore_source.placement_explanation"); - drawString(font, new TextComponentString(OreSpawnScreenLayout.fit(font, - new TextComponentString(explanation), contentWidth - 4)), - contentX + 2, placementLabelY + 10, 0xA0A0A0); + int lineY = placementLabelY + 10; + for (String line : placementExplanationLines) { + drawString(font, new TextComponentString(line), contentX + 2, lineY, 0xA0A0A0); + lineY += 10; + } + lineY = placementStatusY; + for (String line : placementStatusLines) { + drawString(font, new TextComponentString(line), contentX + 2, lineY, placementStatusColor); + lineY += 10; + } if (error != null) { drawCenteredString(font, new TextComponentString(OreSpawnScreenLayout.fit(font, new TextComponentString(error), width - 24)), width / 2, 19, 0xFF5555); @@ -291,11 +342,6 @@ public void render(int mouseX, int mouseY, float partialTick) { drawCenteredString(font, new TextComponentTranslation("label.orespawn.ore_source.no_aliases"), contentX + (contentWidth / 2), ALIAS_TOP + 4, 0x777777); } - if (group != null && placementChannels(group).isEmpty()) { - drawCenteredString(font, - new TextComponentTranslation("label.orespawn.ore_source.no_placement_rules"), - contentX + (contentWidth / 2), placementListY + 4, 0x777777); - } super.render(mouseX, mouseY, partialTick); OreSpawnScreenLayout.renderExplanations(this, 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 index 927b3e72..878330c5 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/OreSourceListScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceListScreen.java @@ -233,7 +233,7 @@ private void cycleMode(OreSourceGroup group) { 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.setOreSourceMode(group.key, false); + else session.restoreOreSourceOriginalMode(group.key); } private boolean syncWeights() { 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 f553a590..45bbe2fc 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 lines } final void renderStringTooltip(List lines, int mouseX, int mouseY) { - drawHoveringText(lines, mouseX, mouseY, fontRendererObj); + List wrapped = new ArrayList<>(); + int maximumWidth = tooltipWidth(width, mouseX); + for (String line : lines) { + if (line == null || line.isEmpty()) wrapped.add(""); + else wrapped.addAll(fontRendererObj.listFormattedStringToWidth(line, maximumWidth)); + } + drawHoveringText(wrapped, mouseX, mouseY, fontRendererObj); + } + + 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 9c21d7ee..2bef8d9b 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.xPosition + explanation.widget.width && mouseY >= explanation.widget.yPosition && mouseY < explanation.widget.yPosition + explanation.widget.height) { - FontRenderer font = Minecraft.getMinecraft().fontRendererObj; 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/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java b/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java index c299a13d..e37ff06b 100644 --- a/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java @@ -296,6 +296,28 @@ void balancedSingleAndCustomModesKeepOneExplicitOutputPolicy() { assertEquals(2.5D, custom.outputs.get("baseminerals:sulfur").doubleValue()); } + @Test + void keepOriginalRestoresThePolicySnapshotFromWhenTheEditorOpened() { + GeologyEditorSession session = new GeologyEditorSession(profileWithTwoOreOutputs()); + GeologyEditorSession.OreSourceGroup original = session.oreSourceGroups().get(0); + String key = original.key; + + session.setOreSourceOutputMode(key, "custom"); + session.setOreSourceOutput(key, "mineralogy:sulfur", false, 1.0D); + session.setOreSourceOutput(key, "baseminerals:sulfur", true, 4.5D); + session.setOreSourcePlacement(key, "orespawn:standard", "baseminerals:sulfur"); + GeologyEditorSession.OreSourceGroup edited = session.oreSourceGroups().get(0); + assertEquals(java.util.Collections.singleton("baseminerals:sulfur"), edited.outputs.keySet()); + assertEquals("baseminerals:sulfur", edited.placements.get("orespawn:standard")); + + session.restoreOreSourceOriginalMode(key); + GeologyEditorSession.OreSourceGroup restored = session.oreSourceGroups().get(0); + assertEquals("keep_separate", restored.mode); + assertEquals(original.outputMode, restored.outputMode); + assertEquals(original.outputs, restored.outputs); + assertEquals(original.placements, restored.placements); + } + private static WorldGeologyProfile profileWithOreSourcePolicy() { JsonObject root = WorldGeologyProfile.recommended(false).toJson(); JsonObject policy = new JsonObject(); diff --git a/src/test/java/zone/moddev/mc/orespawn/client/OreSourcePlacementScreenTest.java b/src/test/java/zone/moddev/mc/orespawn/client/OreSourcePlacementScreenTest.java index 072542bd..7b310b55 100644 --- a/src/test/java/zone/moddev/mc/orespawn/client/OreSourcePlacementScreenTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/client/OreSourcePlacementScreenTest.java @@ -46,6 +46,15 @@ void keepOriginalAndSingleManagedSourcesRemainReadOnly() { assertFalse(OreSourceGroupSettingsScreen.placementSelectable( group("keep_separate", Arrays.asList(first, second), Collections.emptyMap()), "orespawn:standard")); + assertEquals(0xFFFF55, OreSourceGroupSettingsScreen.placementStatusColor( + group("consolidated", Collections.singletonList(first), Collections.emptyMap()), + Collections.singletonList("orespawn:standard"))); + assertEquals(0x55FF55, OreSourceGroupSettingsScreen.placementStatusColor( + group("consolidated", Arrays.asList(first, second), Collections.emptyMap()), + Collections.singletonList("orespawn:standard"))); + assertEquals(0xA0A0A0, OreSourceGroupSettingsScreen.placementStatusColor( + group("keep_separate", Arrays.asList(first, second), Collections.emptyMap()), + Collections.singletonList("orespawn:standard"))); } private static OreSourceCandidate candidate(String source, String channel, 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 f176da75..eecb7746 100644 --- a/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java @@ -127,8 +127,8 @@ void oreSourceEditorKeepsBothCompactScrollablePanesVisible() throws Exception { @Test void groupSettingsOwnsAliasesAndClearlyNamedPlacementRules() throws Exception { - assertEquals(48, OreSourceGroupSettingsScreen.aliasListHeight(265)); - assertEquals(32, OreSourceGroupSettingsScreen.aliasListHeight(240)); + assertEquals(32, OreSourceGroupSettingsScreen.aliasListHeight(265)); + assertEquals(16, OreSourceGroupSettingsScreen.aliasListHeight(240)); String settings = screenSource("OreSourceGroupSettingsScreen.java"); assertTrue(settings.contains("option.orespawn.ore_source.group_name")); assertTrue(settings.contains("option.orespawn.ore_source.alias")); @@ -141,6 +141,14 @@ void groupSettingsOwnsAliasesAndClearlyNamedPlacementRules() throws Exception { assertFalse(settings.contains("button.orespawn.ore_source.advanced")); } + @Test + void tooltipsFitBesideTheCursorInsteadOfRunningOffScreen() { + assertEquals(260, OreSpawnScreen.tooltipWidth(426, 300)); + assertEquals(189, OreSpawnScreen.tooltipWidth(426, 213)); + assertEquals(136, OreSpawnScreen.tooltipWidth(320, 160)); + assertEquals(96, OreSpawnScreen.tooltipWidth(240, 120)); + } + @Test void materialGroupDefaultsPersistOnlyThroughMainDone() throws Exception { String main = screenSource("OreSpawnWorldSettingsScreen.java"); From 57eef59bb9faba44318d1736132e7ddca786e8cd Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Thu, 17 Sep 2026 13:08:35 +0100 Subject: [PATCH 15/31] Clarify Ore Source group status and filtering --- CHANGELOG.txt | 11 +++-- README.md | 17 ++++--- docs/CONFIGURATION.md | 13 +++-- docs/PLAYER_GUIDE.md | 15 ++++-- .../orespawn/client/ClientProbeTestMod.java | 12 ++++- .../orespawn/client/GeologyEditorSession.java | 7 ++- .../client/OreSourceGroupSettingsScreen.java | 48 +++++-------------- .../orespawn/client/OreSourceListScreen.java | 46 ++++++++++++++++-- .../resources/assets/orespawn/lang/de_AU.lang | 2 + .../resources/assets/orespawn/lang/de_DE.lang | 2 + .../resources/assets/orespawn/lang/en_CA.lang | 2 + .../resources/assets/orespawn/lang/en_EN.lang | 2 + .../resources/assets/orespawn/lang/en_GB.lang | 2 + .../resources/assets/orespawn/lang/en_PT.lang | 2 + .../resources/assets/orespawn/lang/en_US.lang | 2 + .../resources/assets/orespawn/lang/es_ES.lang | 2 + .../resources/assets/orespawn/lang/es_MX.lang | 2 + .../resources/assets/orespawn/lang/fr_CA.lang | 2 + .../resources/assets/orespawn/lang/fr_FR.lang | 2 + .../resources/assets/orespawn/lang/ja_JP.lang | 2 + .../resources/assets/orespawn/lang/ko_KR.lang | 2 + .../resources/assets/orespawn/lang/pt_BR.lang | 2 + .../resources/assets/orespawn/lang/ru_RU.lang | 2 + .../resources/assets/orespawn/lang/zh_CN.lang | 2 + .../mc/orespawn/LocalizationParityTest.java | 2 +- .../client/GeologyEditorSessionTest.java | 2 + .../client/OreSourcePlacementScreenTest.java | 48 +++++++++++++++---- .../client/OreSpawnScreenLayoutTest.java | 11 +++-- 28 files changed, 187 insertions(+), 77 deletions(-) diff --git a/CHANGELOG.txt b/CHANGELOG.txt index d6b25885..9c8d227b 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -8,7 +8,9 @@ Version 4.1.0.110021 using stable world/deposit identity, including safe missing-mod restoration. * Replace the technical Ore Sources list/detail flow with compact independently scrolling group and output lists that remain visible together at compact - resolutions. Each group has a cog for its friendly name and aliases. + resolutions. The default view hides harmless single-source entries behind + Show All; red groups need attention, yellow groups have a saved rule and + green entries need no action. Each group has a cog for its name and aliases. * Rename the old Advanced channel controls to Placement Rules and explain their separate ownership of frequency, shape, depth and host restrictions. Single managed sources are read-only, multiple sources are selectable and external @@ -18,9 +20,10 @@ Version 4.1.0.110021 inactive provider definitions remain selectable outputs without contributing another placement rule. Returning to Keep Original restores the output and placement selections from when the editor was opened. -* Keep tooltips inside the available side of the screen and wrap Placement - Rules guidance above the list, with an explicit fixed, selectable, missing or - Keep Original status instead of an unexplained disabled control. +* Keep tooltips inside the available side of the screen and move Placement + Rules guidance into a compact help tooltip, with an explicit fixed, + selectable, missing or Keep Original status instead of an unexplained + disabled control. * Ship curated Sulfur/Sulphur and Aluminum/Aluminium groups, keep Niter and Saltpeter distinct, deduplicate identical registry states, and allow confirmed alias reassignment plus stable custom groups. Group edits are saved to the diff --git a/README.md b/README.md index 6b1eee9f..384988b0 100644 --- a/README.md +++ b/README.md @@ -59,18 +59,21 @@ screen when one is available. The in-game **Help & Guide** explains the other controls. On the ORES tab, **Ore Sources...** opens one material-group editor. Compact, -independently scrolling lists keep every discovered group on the left and the -selected group's deduplicated ore blocks on the right. Groups needing attention -are sorted first. Each group's cog opens its friendly name, exact Ore Dictionary -aliases and clearly labelled Placement Rules without crowding the selection -screen. Outputs choose the ore block; Placement Rules choose the frequency, +independently scrolling lists keep relevant material groups on the left and the +selected group's deduplicated ore blocks on the right. Harmless one-alias, +one-output entries are hidden by default and available through **Show All**. +Red groups need attention, yellow groups have a saved rule and green entries +need no action. Each group's cog opens its friendly name, exact Ore Dictionary +aliases and clearly labelled Placement Rules. Outputs choose the ore block; +Placement Rules choose the frequency, shape, depth and host restrictions for each managed channel. New worlds balance all eligible outputs in reviewed MMD conflicts while running one placement budget, so sulfur/sulphur providers do not multiply abundance. **Single** chooses one output, **Custom** allows a weighted subset, and **Keep Original** preserves every active rule and restores the selections that were present when -the editor was opened. Placement Rules states whether a channel is fixed, -selectable, missing, or inactive under Keep Original. Existing worlds start in +the editor was opened. The Placement Rules help and row tooltips state whether +a channel is fixed, selectable, missing, or inactive under Keep Original. +Existing worlds start in Keep Original until their owner explicitly changes a group. Third-party generators remain outside OreSpawn's control and are clearly reported rather than disabled. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 2061a812..8113fb03 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -239,15 +239,20 @@ Keep Original restores the output weights and placement-source selections from the profile snapshot taken when the editor was opened. The Ore Sources screen keeps compact scrollable group and output lists visible -together. Use a group's cog for its friendly name, Ore Dictionary aliases and -**Placement Rules**. Output selection controls which registered block represents +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 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. The settings screen labels that state explicitly and wraps -its purpose and status above the placement list at compact resolutions. +as placement owners. The compact help and row tooltips explain whether a rule is +fixed, selectable, missing or inactive under Keep Original without reducing the +alias-list height. Consolidation runs the chosen placement rule's pattern, hosts, filters, height and frequency once; selected candidates contribute output bundles only. An diff --git a/docs/PLAYER_GUIDE.md b/docs/PLAYER_GUIDE.md index cb47d18f..8f4b93f6 100644 --- a/docs/PLAYER_GUIDE.md +++ b/docs/PLAYER_GUIDE.md @@ -47,17 +47,22 @@ safely instead of breaking world creation. - **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 every discovered group on the left and the deduplicated - union of installed ore blocks represented by its aliases on the right: + 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. The cog beside a group opens its friendly + Groups needing attention sort first. Saving a consolidated rule clears that + attention state. 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; - multiple managed sources can be selected. The screen states whether each - channel is fixed, selectable, missing, or inactive under Keep Original. + multiple managed sources can be selected. Hover the Placement Rules help or + a rule row to see whether it is fixed, selectable, missing, or inactive under + Keep Original. Independent external generators are explained but never offered as controllable placement sources. - **Mods** opens a paginated directory of loaded OreSpawn integrations. It 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 700b2881..fd54a367 100644 --- a/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java +++ b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java @@ -21,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; @@ -267,6 +268,10 @@ private void validateExpectedOreSources(OreSpawnWorldSettingsScreen root) { 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()) { @@ -302,17 +307,20 @@ private void validateOreSourcesSingleScreen(GuiScreen parent, GeologyEditorSessi boolean outputList = false; boolean inlineAdministration = false; boolean back = false; + boolean showAll = false; for (GuiButton widget : screen.buttons) { String caption = TextFormatting.getTextWithoutFormattingCodes(widget.displayString); if (widget instanceof CompactScrollList && widget.xPosition < right) groupList = true; if (widget instanceof CompactScrollList && widget.xPosition >= right) outputList = true; if (widget instanceof TextFieldWidget && widget.xPosition < right) inlineAdministration = true; if ("Back".equals(caption)) back = true; + if (I18n.format("button.orespawn.show_all").equals(caption)) showAll = true; } - if (!groupList || !outputList || inlineAdministration || back) { + if (!groupList || !outputList || inlineAdministration || back || !showAll) { throw new IllegalStateException("Ore Sources was not one compact two-pane screen: groups=" + groupList + ", outputs=" + outputList + ", inlineAdministration=" - + inlineAdministration + ", back=" + back + ", left=" + left + ", right=" + right); + + inlineAdministration + ", back=" + back + ", showAll=" + showAll + + ", left=" + left + ", right=" + right); } GeologyEditorSession.OreSourceGroup sulfurGroup = null; 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 e12aa271..b9c27131 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java @@ -839,7 +839,12 @@ List outputCandidates() { boolean needsAttention() { return "review_required".equals(status) || "missing_source".equals(status) - || "external_generation".equals(status) || outputCandidates().size() > 1; + || "external_generation".equals(status) || outputCandidates().isEmpty(); + } + + boolean isRoutineSingleSource() { + return !needsAttention() && oreDictionaryEntries.size() == 1 + && outputCandidates().size() == 1; } boolean isRedundantSeparatePolicy() { diff --git a/src/main/java/zone/moddev/mc/orespawn/client/OreSourceGroupSettingsScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceGroupSettingsScreen.java index 3f0698ab..d8d33f3c 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/OreSourceGroupSettingsScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceGroupSettingsScreen.java @@ -34,10 +34,6 @@ final class OreSourceGroupSettingsScreen extends OreSpawnScreen { private int addAliasY; private int placementLabelY; private int placementListY; - private int placementStatusY; - private List placementExplanationLines = java.util.Collections.emptyList(); - private List placementStatusLines = java.util.Collections.emptyList(); - private int placementStatusColor = 0xA0A0A0; private TextFieldWidget nameField; private TextFieldWidget aliasField; private CompactScrollList aliasList; @@ -131,11 +127,17 @@ protected void init() { final List channels = placementChannels(group); placementLabelY = addAliasY + 27; - placementExplanationLines = wrap(I18n.format("label.orespawn.ore_source.placement_explanation")); - placementStatusLines = wrap(placementStatus(group, channels)); - placementStatusY = placementLabelY + 11 + (placementExplanationLines.size() * 10); - placementListY = placementStatusY + (placementStatusLines.size() * 10) + 3; - placementStatusColor = placementStatusColor(group, channels); + 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(); } @@ -242,11 +244,7 @@ private void rebuild() { } static int aliasListHeight(int height) { - return height >= 260 ? 32 : 16; - } - - private List wrap(String text) { - return font.listFormattedStringToWidth(text, Math.max(80, contentWidth - 4)); + return height >= 260 ? 48 : 32; } static List placementChannels(OreSourceGroup group) { @@ -281,18 +279,6 @@ static String placementStatus(OreSourceGroup group, List channels) { : "tooltip.orespawn.ore_source.placement_readonly"); } - static int placementStatusColor(OreSourceGroup group, List channels) { - if (channels.isEmpty()) return 0x777777; - if ("keep_separate".equals(group.mode)) return 0xA0A0A0; - for (String channel : channels) { - if (OreSourceListScreen.placementCandidates(group, channel).isEmpty()) return 0xFF5555; - } - for (String channel : channels) { - if (placementSelectable(group, channel)) return 0x55FF55; - } - return 0xFFFF55; - } - @Override public void handleMouseInput() throws IOException { int mouseX = Mouse.getEventX() * width / minecraft.displayWidth; @@ -323,16 +309,6 @@ public void render(int mouseX, int mouseY, float partialTick) { contentX + 2, 59, 0xA0A0A0); drawString(font, new TextComponentTranslation("label.orespawn.ore_source.placement_rules"), contentX + 2, placementLabelY, 0xFFFFFF); - int lineY = placementLabelY + 10; - for (String line : placementExplanationLines) { - drawString(font, new TextComponentString(line), contentX + 2, lineY, 0xA0A0A0); - lineY += 10; - } - lineY = placementStatusY; - for (String line : placementStatusLines) { - drawString(font, new TextComponentString(line), contentX + 2, lineY, placementStatusColor); - lineY += 10; - } if (error != null) { drawCenteredString(font, new TextComponentString(OreSpawnScreenLayout.fit(font, new TextComponentString(error), width - 24)), width / 2, 19, 0xFF5555); diff --git a/src/main/java/zone/moddev/mc/orespawn/client/OreSourceListScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceListScreen.java index 878330c5..8a2a6c78 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/OreSourceListScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceListScreen.java @@ -31,6 +31,7 @@ final class OreSourceListScreen extends OreSpawnScreen { private final Map weights = new LinkedHashMap<>(); private String selectedKey; private String error; + private boolean showAll; private int groupScroll; private int outputScroll; private int leftPaneX; @@ -80,6 +81,21 @@ private void initGroups(final List groups) { rebuild(); })); OreSpawnScreenLayout.explain(this, add, "button.orespawn.ore_source.add_group"); + int showX = addX + 20; + int showWidth = Math.max(38, leftPaneX + leftPaneWidth - showX); + Button show = addButton(OreSpawnScreenLayout.button(this, font, + showX, 4, showWidth, 16, + new TextComponentTranslation(showAll + ? "button.orespawn.ore_source.hide_single" + : "button.orespawn.show_all"), 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) { @@ -87,8 +103,11 @@ private void initGroups(final List groups) { @Override protected String rowText(int index) { OreSourceGroup group = groups.get(index); - return (group.needsAttention() ? "! " : "") + group.displayName - + " - " + display(group.domain); + return group.displayName + " - " + display(group.domain); + } + + @Override protected int rowColor(int index) { + return groupRowColor(groups.get(index)); } @Override protected boolean rowSelected(int index) { @@ -260,9 +279,14 @@ private void captureScroll() { } private List orderedGroups() { - List result = new ArrayList<>(session.oreSourceGroups()); + List result = new ArrayList<>(); + for (OreSourceGroup group : session.oreSourceGroups()) { + if (showAll || showByDefault(group)) result.add(group); + } result.sort((left, right) -> { - if (left.needsAttention() != right.needsAttention()) return left.needsAttention() ? -1 : 1; + 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); @@ -271,6 +295,19 @@ private List orderedGroups() { 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; @@ -329,6 +366,7 @@ static List groupTooltip(OreSourceGroup group) { 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; } diff --git a/src/main/resources/assets/orespawn/lang/de_AU.lang b/src/main/resources/assets/orespawn/lang/de_AU.lang index 8242157a..50f3adf3 100644 --- a/src/main/resources/assets/orespawn/lang/de_AU.lang +++ b/src/main/resources/assets/orespawn/lang/de_AU.lang @@ -457,3 +457,5 @@ tooltip.orespawn.ore_source.placement_missing=Die gespeicherte Platzierungsquell tooltip.orespawn.ore_source.placement_readonly=Nur eine aktive verwaltete Regel ist verfügbar; diese Quelle ist schreibgeschützt. tooltip.orespawn.ore_source.placement_select=Klicken, um zwischen aktiven verwalteten Platzierungsquellen zu wählen. tooltip.orespawn.ore_source.external_excluded=Unabhängige externe Generatoren werden gemeldet, können aber nicht als Platzierungsregeln gewählt werden. +button.orespawn.ore_source.hide_single=Einzelne ausblenden +tooltip.orespawn.ore_source.group_colours=Grüne Einträge haben einen Alias und eine Ausgabe und benötigen keine Aktion. Gelbe Gruppen haben eine gespeicherte Regel. Rote Gruppen benötigen Aufmerksamkeit. Alle anzeigen schließt grüne Einträge ein. diff --git a/src/main/resources/assets/orespawn/lang/de_DE.lang b/src/main/resources/assets/orespawn/lang/de_DE.lang index 8242157a..50f3adf3 100644 --- a/src/main/resources/assets/orespawn/lang/de_DE.lang +++ b/src/main/resources/assets/orespawn/lang/de_DE.lang @@ -457,3 +457,5 @@ tooltip.orespawn.ore_source.placement_missing=Die gespeicherte Platzierungsquell tooltip.orespawn.ore_source.placement_readonly=Nur eine aktive verwaltete Regel ist verfügbar; diese Quelle ist schreibgeschützt. tooltip.orespawn.ore_source.placement_select=Klicken, um zwischen aktiven verwalteten Platzierungsquellen zu wählen. tooltip.orespawn.ore_source.external_excluded=Unabhängige externe Generatoren werden gemeldet, können aber nicht als Platzierungsregeln gewählt werden. +button.orespawn.ore_source.hide_single=Einzelne ausblenden +tooltip.orespawn.ore_source.group_colours=Grüne Einträge haben einen Alias und eine Ausgabe und benötigen keine Aktion. Gelbe Gruppen haben eine gespeicherte Regel. Rote Gruppen benötigen Aufmerksamkeit. Alle anzeigen schließt grüne Einträge ein. diff --git a/src/main/resources/assets/orespawn/lang/en_CA.lang b/src/main/resources/assets/orespawn/lang/en_CA.lang index 18683846..5c92a8dd 100644 --- a/src/main/resources/assets/orespawn/lang/en_CA.lang +++ b/src/main/resources/assets/orespawn/lang/en_CA.lang @@ -457,3 +457,5 @@ tooltip.orespawn.ore_source.placement_missing=The saved placement source is miss tooltip.orespawn.ore_source.placement_readonly=Only one active managed rule is available, so this source is read-only. tooltip.orespawn.ore_source.placement_select=Click to choose between active managed placement sources. tooltip.orespawn.ore_source.external_excluded=Independent external generators are reported but cannot be selected as placement rules. +button.orespawn.ore_source.hide_single=Hide Singles +tooltip.orespawn.ore_source.group_colours=Green entries have one alias and one output and need no action. Yellow groups have a saved rule. Red groups need attention. Show All includes green entries. diff --git a/src/main/resources/assets/orespawn/lang/en_EN.lang b/src/main/resources/assets/orespawn/lang/en_EN.lang index 18683846..5c92a8dd 100644 --- a/src/main/resources/assets/orespawn/lang/en_EN.lang +++ b/src/main/resources/assets/orespawn/lang/en_EN.lang @@ -457,3 +457,5 @@ tooltip.orespawn.ore_source.placement_missing=The saved placement source is miss tooltip.orespawn.ore_source.placement_readonly=Only one active managed rule is available, so this source is read-only. tooltip.orespawn.ore_source.placement_select=Click to choose between active managed placement sources. tooltip.orespawn.ore_source.external_excluded=Independent external generators are reported but cannot be selected as placement rules. +button.orespawn.ore_source.hide_single=Hide Singles +tooltip.orespawn.ore_source.group_colours=Green entries have one alias and one output and need no action. Yellow groups have a saved rule. Red groups need attention. Show All includes green entries. diff --git a/src/main/resources/assets/orespawn/lang/en_GB.lang b/src/main/resources/assets/orespawn/lang/en_GB.lang index 18683846..5c92a8dd 100644 --- a/src/main/resources/assets/orespawn/lang/en_GB.lang +++ b/src/main/resources/assets/orespawn/lang/en_GB.lang @@ -457,3 +457,5 @@ tooltip.orespawn.ore_source.placement_missing=The saved placement source is miss tooltip.orespawn.ore_source.placement_readonly=Only one active managed rule is available, so this source is read-only. tooltip.orespawn.ore_source.placement_select=Click to choose between active managed placement sources. tooltip.orespawn.ore_source.external_excluded=Independent external generators are reported but cannot be selected as placement rules. +button.orespawn.ore_source.hide_single=Hide Singles +tooltip.orespawn.ore_source.group_colours=Green entries have one alias and one output and need no action. Yellow groups have a saved rule. Red groups need attention. Show All includes green entries. diff --git a/src/main/resources/assets/orespawn/lang/en_PT.lang b/src/main/resources/assets/orespawn/lang/en_PT.lang index 18683846..5c92a8dd 100644 --- a/src/main/resources/assets/orespawn/lang/en_PT.lang +++ b/src/main/resources/assets/orespawn/lang/en_PT.lang @@ -457,3 +457,5 @@ tooltip.orespawn.ore_source.placement_missing=The saved placement source is miss tooltip.orespawn.ore_source.placement_readonly=Only one active managed rule is available, so this source is read-only. tooltip.orespawn.ore_source.placement_select=Click to choose between active managed placement sources. tooltip.orespawn.ore_source.external_excluded=Independent external generators are reported but cannot be selected as placement rules. +button.orespawn.ore_source.hide_single=Hide Singles +tooltip.orespawn.ore_source.group_colours=Green entries have one alias and one output and need no action. Yellow groups have a saved rule. Red groups need attention. Show All includes green entries. diff --git a/src/main/resources/assets/orespawn/lang/en_US.lang b/src/main/resources/assets/orespawn/lang/en_US.lang index c9bdca68..2f5a6962 100644 --- a/src/main/resources/assets/orespawn/lang/en_US.lang +++ b/src/main/resources/assets/orespawn/lang/en_US.lang @@ -457,3 +457,5 @@ tooltip.orespawn.ore_source.placement_missing=The saved placement source is miss tooltip.orespawn.ore_source.placement_readonly=Only one active managed rule is available, so this source is read-only. tooltip.orespawn.ore_source.placement_select=Click to choose between active managed placement sources. tooltip.orespawn.ore_source.external_excluded=Independent external generators are reported but cannot be selected as placement rules. +button.orespawn.ore_source.hide_single=Hide Singles +tooltip.orespawn.ore_source.group_colours=Green entries have one alias and one output and need no action. Yellow groups have a saved rule. Red groups need attention. Show All includes green entries. diff --git a/src/main/resources/assets/orespawn/lang/es_ES.lang b/src/main/resources/assets/orespawn/lang/es_ES.lang index 155c7025..0162c59a 100644 --- a/src/main/resources/assets/orespawn/lang/es_ES.lang +++ b/src/main/resources/assets/orespawn/lang/es_ES.lang @@ -457,3 +457,5 @@ tooltip.orespawn.ore_source.placement_missing=Falta la fuente de colocación gua tooltip.orespawn.ore_source.placement_readonly=Solo hay una regla administrada activa; esta fuente es de solo lectura. tooltip.orespawn.ore_source.placement_select=Haz clic para elegir entre fuentes de colocación administradas activas. tooltip.orespawn.ore_source.external_excluded=Los generadores externos se informan, pero no pueden elegirse como reglas de colocación. +button.orespawn.ore_source.hide_single=Ocultar individuales +tooltip.orespawn.ore_source.group_colours=Las entradas verdes tienen un alias y una salida y no requieren acción. Los grupos amarillos tienen una regla guardada. Los grupos rojos requieren atención. Mostrar todo incluye las entradas verdes. diff --git a/src/main/resources/assets/orespawn/lang/es_MX.lang b/src/main/resources/assets/orespawn/lang/es_MX.lang index 155c7025..0162c59a 100644 --- a/src/main/resources/assets/orespawn/lang/es_MX.lang +++ b/src/main/resources/assets/orespawn/lang/es_MX.lang @@ -457,3 +457,5 @@ tooltip.orespawn.ore_source.placement_missing=Falta la fuente de colocación gua tooltip.orespawn.ore_source.placement_readonly=Solo hay una regla administrada activa; esta fuente es de solo lectura. tooltip.orespawn.ore_source.placement_select=Haz clic para elegir entre fuentes de colocación administradas activas. tooltip.orespawn.ore_source.external_excluded=Los generadores externos se informan, pero no pueden elegirse como reglas de colocación. +button.orespawn.ore_source.hide_single=Ocultar individuales +tooltip.orespawn.ore_source.group_colours=Las entradas verdes tienen un alias y una salida y no requieren acción. Los grupos amarillos tienen una regla guardada. Los grupos rojos requieren atención. Mostrar todo incluye las entradas verdes. diff --git a/src/main/resources/assets/orespawn/lang/fr_CA.lang b/src/main/resources/assets/orespawn/lang/fr_CA.lang index 8d1d56e8..d4435545 100644 --- a/src/main/resources/assets/orespawn/lang/fr_CA.lang +++ b/src/main/resources/assets/orespawn/lang/fr_CA.lang @@ -457,3 +457,5 @@ tooltip.orespawn.ore_source.placement_missing=La source de placement enregistré tooltip.orespawn.ore_source.placement_readonly=Une seule règle gérée active est disponible; cette source est en lecture seule. tooltip.orespawn.ore_source.placement_select=Cliquez pour choisir entre les sources de placement gérées actives. tooltip.orespawn.ore_source.external_excluded=Les générateurs externes sont signalés mais ne peuvent pas être choisis comme règles de placement. +button.orespawn.ore_source.hide_single=Masquer les entrées simples +tooltip.orespawn.ore_source.group_colours=Les entrées vertes ont un alias et une sortie et ne demandent aucune action. Les groupes jaunes ont une règle enregistrée. Les groupes rouges demandent votre attention. Afficher tout inclut les entrées vertes. diff --git a/src/main/resources/assets/orespawn/lang/fr_FR.lang b/src/main/resources/assets/orespawn/lang/fr_FR.lang index 8d1d56e8..d4435545 100644 --- a/src/main/resources/assets/orespawn/lang/fr_FR.lang +++ b/src/main/resources/assets/orespawn/lang/fr_FR.lang @@ -457,3 +457,5 @@ tooltip.orespawn.ore_source.placement_missing=La source de placement enregistré tooltip.orespawn.ore_source.placement_readonly=Une seule règle gérée active est disponible; cette source est en lecture seule. tooltip.orespawn.ore_source.placement_select=Cliquez pour choisir entre les sources de placement gérées actives. tooltip.orespawn.ore_source.external_excluded=Les générateurs externes sont signalés mais ne peuvent pas être choisis comme règles de placement. +button.orespawn.ore_source.hide_single=Masquer les entrées simples +tooltip.orespawn.ore_source.group_colours=Les entrées vertes ont un alias et une sortie et ne demandent aucune action. Les groupes jaunes ont une règle enregistrée. Les groupes rouges demandent votre attention. Afficher tout inclut les entrées vertes. diff --git a/src/main/resources/assets/orespawn/lang/ja_JP.lang b/src/main/resources/assets/orespawn/lang/ja_JP.lang index db14483e..e3acf428 100644 --- a/src/main/resources/assets/orespawn/lang/ja_JP.lang +++ b/src/main/resources/assets/orespawn/lang/ja_JP.lang @@ -457,3 +457,5 @@ tooltip.orespawn.ore_source.placement_missing=保存された配置元があり tooltip.orespawn.ore_source.placement_readonly=有効な管理ルールは1つだけなので、このソースは読み取り専用です。 tooltip.orespawn.ore_source.placement_select=クリックして有効な管理対象配置元を選択します。 tooltip.orespawn.ore_source.external_excluded=独立した外部生成は報告されますが、配置ルールには選択できません。 +button.orespawn.ore_source.hide_single=単一項目を隠す +tooltip.orespawn.ore_source.group_colours=緑は別名と出力が各1つで操作不要です。黄は保存済みルールがあるグループです。赤は確認が必要です。すべて表示で緑の項目も表示します。 diff --git a/src/main/resources/assets/orespawn/lang/ko_KR.lang b/src/main/resources/assets/orespawn/lang/ko_KR.lang index 315f206d..ea8d234d 100644 --- a/src/main/resources/assets/orespawn/lang/ko_KR.lang +++ b/src/main/resources/assets/orespawn/lang/ko_KR.lang @@ -457,3 +457,5 @@ tooltip.orespawn.ore_source.placement_missing=저장된 배치 소스가 없습 tooltip.orespawn.ore_source.placement_readonly=활성 관리 규칙이 하나뿐이므로 이 소스는 읽기 전용입니다. tooltip.orespawn.ore_source.placement_select=활성 관리 배치 소스 중 하나를 선택하려면 클릭하세요. tooltip.orespawn.ore_source.external_excluded=독립 외부 생성기는 보고되지만 배치 규칙으로 선택할 수 없습니다. +button.orespawn.ore_source.hide_single=단일 항목 숨기기 +tooltip.orespawn.ore_source.group_colours=녹색 항목은 별칭과 출력이 각각 하나라 조치가 필요 없습니다. 노란색 그룹에는 저장된 규칙이 있습니다. 빨간색 그룹은 확인이 필요합니다. 모두 표시를 누르면 녹색 항목도 표시됩니다. diff --git a/src/main/resources/assets/orespawn/lang/pt_BR.lang b/src/main/resources/assets/orespawn/lang/pt_BR.lang index 66e522ac..b46819a0 100644 --- a/src/main/resources/assets/orespawn/lang/pt_BR.lang +++ b/src/main/resources/assets/orespawn/lang/pt_BR.lang @@ -457,3 +457,5 @@ tooltip.orespawn.ore_source.placement_missing=A fonte de colocação salva está tooltip.orespawn.ore_source.placement_readonly=Há apenas uma regra gerenciada ativa; esta fonte é somente leitura. tooltip.orespawn.ore_source.placement_select=Clique para escolher entre fontes de colocação gerenciadas ativas. tooltip.orespawn.ore_source.external_excluded=Geradores externos independentes são informados, mas não podem ser escolhidos como regras de colocação. +button.orespawn.ore_source.hide_single=Ocultar individuais +tooltip.orespawn.ore_source.group_colours=Entradas verdes têm um alias e uma saída e não exigem ação. Grupos amarelos têm uma regra salva. Grupos vermelhos exigem atenção. Mostrar tudo inclui as entradas verdes. diff --git a/src/main/resources/assets/orespawn/lang/ru_RU.lang b/src/main/resources/assets/orespawn/lang/ru_RU.lang index a9c643cd..405f9d36 100644 --- a/src/main/resources/assets/orespawn/lang/ru_RU.lang +++ b/src/main/resources/assets/orespawn/lang/ru_RU.lang @@ -457,3 +457,5 @@ tooltip.orespawn.ore_source.placement_missing=Сохранённый источ tooltip.orespawn.ore_source.placement_readonly=Доступно только одно активное управляемое правило; источник доступен только для чтения. tooltip.orespawn.ore_source.placement_select=Нажмите для выбора активного управляемого источника размещения. tooltip.orespawn.ore_source.external_excluded=Независимые внешние генераторы отображаются, но не могут быть выбраны как правила размещения. +button.orespawn.ore_source.hide_single=Скрыть одиночные +tooltip.orespawn.ore_source.group_colours=Зелёные записи имеют один псевдоним и один выход и не требуют действий. Жёлтые группы имеют сохранённое правило. Красные группы требуют внимания. Показать все также показывает зелёные записи. diff --git a/src/main/resources/assets/orespawn/lang/zh_CN.lang b/src/main/resources/assets/orespawn/lang/zh_CN.lang index 07e3bd51..4b4ccfad 100644 --- a/src/main/resources/assets/orespawn/lang/zh_CN.lang +++ b/src/main/resources/assets/orespawn/lang/zh_CN.lang @@ -457,3 +457,5 @@ tooltip.orespawn.ore_source.placement_missing=保存的放置来源缺失;此 tooltip.orespawn.ore_source.placement_readonly=只有一个有效的受管理规则,因此此来源为只读。 tooltip.orespawn.ore_source.placement_select=点击以选择有效的受管理放置来源。 tooltip.orespawn.ore_source.external_excluded=独立外部生成器会显示,但不能选作放置规则。 +button.orespawn.ore_source.hide_single=隐藏单项 +tooltip.orespawn.ore_source.group_colours=绿色条目只有一个别名和一个输出,无需处理。黄色分组已有保存的规则。红色分组需要处理。显示全部会包含绿色条目。 diff --git a/src/test/java/zone/moddev/mc/orespawn/LocalizationParityTest.java b/src/test/java/zone/moddev/mc/orespawn/LocalizationParityTest.java index 4b442ccb..58e3a165 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(459, englishKeys.size(), + assertEquals(461, 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/client/GeologyEditorSessionTest.java b/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java index e37ff06b..33ef73e1 100644 --- a/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java @@ -281,6 +281,8 @@ void balancedSingleAndCustomModesKeepOneExplicitOutputPolicy() { GeologyEditorSession.OreSourceGroup balanced = session.oreSourceGroups().get(0); assertEquals(2, balanced.outputs.size()); assertTrue(balanced.outputs.values().stream().allMatch(weight -> weight == 1.0D)); + assertFalse(balanced.needsAttention(), + "a saved consolidated rule must no longer be marked for review"); session.setOreSourceOutputMode(key, "single"); GeologyEditorSession.OreSourceGroup single = session.oreSourceGroups().get(0); diff --git a/src/test/java/zone/moddev/mc/orespawn/client/OreSourcePlacementScreenTest.java b/src/test/java/zone/moddev/mc/orespawn/client/OreSourcePlacementScreenTest.java index 7b310b55..48329630 100644 --- a/src/test/java/zone/moddev/mc/orespawn/client/OreSourcePlacementScreenTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/client/OreSourcePlacementScreenTest.java @@ -46,20 +46,40 @@ void keepOriginalAndSingleManagedSourcesRemainReadOnly() { assertFalse(OreSourceGroupSettingsScreen.placementSelectable( group("keep_separate", Arrays.asList(first, second), Collections.emptyMap()), "orespawn:standard")); - assertEquals(0xFFFF55, OreSourceGroupSettingsScreen.placementStatusColor( - group("consolidated", Collections.singletonList(first), Collections.emptyMap()), - Collections.singletonList("orespawn:standard"))); - assertEquals(0x55FF55, OreSourceGroupSettingsScreen.placementStatusColor( - group("consolidated", Arrays.asList(first, second), Collections.emptyMap()), - Collections.singletonList("orespawn:standard"))); - assertEquals(0xA0A0A0, OreSourceGroupSettingsScreen.placementStatusColor( - group("keep_separate", Arrays.asList(first, second), Collections.emptyMap()), - Collections.singletonList("orespawn:standard"))); + } + + @Test + void groupColoursSeparateRoutineResolvedAndUnresolvedEntries() { + OreSourceCandidate first = candidate("first", "first:ore", "orespawn:standard", + true, true, false); + OreSourceCandidate second = candidate("second", "second:ore", "orespawn:standard", + true, true, false); + OreSourceGroup routine = group("keep_separate", "separate", + Collections.singletonList("oreIron"), Collections.singletonList(first)); + OreSourceGroup resolved = group("consolidated", "consolidated", + Arrays.asList("oreSulfur", "oreSulphur"), Arrays.asList(first, second)); + OreSourceGroup unresolved = group("keep_separate", "review_required", + Arrays.asList("oreSulfur", "oreSulphur"), Arrays.asList(first, second)); + + assertTrue(routine.isRoutineSingleSource()); + assertFalse(OreSourceListScreen.showByDefault(routine)); + assertEquals(0x55FF55, OreSourceListScreen.groupRowColor(routine)); + assertFalse(resolved.needsAttention()); + assertTrue(OreSourceListScreen.showByDefault(resolved)); + assertEquals(0xFFFF55, OreSourceListScreen.groupRowColor(resolved)); + assertTrue(unresolved.needsAttention()); + assertTrue(OreSourceListScreen.showByDefault(unresolved)); + assertEquals(0xFF5555, OreSourceListScreen.groupRowColor(unresolved)); } private static OreSourceCandidate candidate(String source, String channel, boolean loaded, boolean active, boolean external) { - return new OreSourceCandidate(source, "owner", "Owner", "1", "owner:ore", 0, + return candidate(source, "owner:ore", channel, loaded, active, external); + } + + private static OreSourceCandidate candidate(String source, String registry, String channel, + boolean loaded, boolean active, boolean external) { + return new OreSourceCandidate(source, "owner", "Owner", "1", registry, 0, channel, Collections.singletonList("oreTest"), loaded, active, external, false, false); } @@ -72,4 +92,12 @@ private static OreSourceGroup group(String mode, List candid Collections.unmodifiableMap(new LinkedHashMap<>()), Collections.unmodifiableMap(new LinkedHashMap<>(placements))); } + + private static OreSourceGroup group(String mode, String status, List aliases, + List candidates) { + return new OreSourceGroup("orespawn:test|minecraft:overworld", "orespawn:test", + "minecraft:overworld", "Test", aliases, false, mode, "balanced", status, + candidates, Collections.unmodifiableMap(new LinkedHashMap<>()), + Collections.emptyMap()); + } } 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 eecb7746..b2ff42e3 100644 --- a/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java @@ -104,6 +104,9 @@ void oreSourceEditorKeepsBothCompactScrollablePanesVisible() throws Exception { assertTrue(list.contains("new CompactScrollList(this, leftPaneX, CONTENT_TOP")); assertTrue(list.contains("new CompactScrollList(this, rightPaneX, CONTENT_TOP + 24")); assertTrue(list.contains("label.orespawn.ore_source.groups")); + assertTrue(list.contains("button.orespawn.show_all")); + assertTrue(list.contains("button.orespawn.ore_source.hide_single")); + assertTrue(list.contains("groupRowColor")); assertTrue(list.contains("drawCompactCog")); assertTrue(list.contains("outputCandidates()")); assertTrue(list.contains("single || !group.outputs.containsKey(candidate.sourceId)"), @@ -127,18 +130,20 @@ void oreSourceEditorKeepsBothCompactScrollablePanesVisible() throws Exception { @Test void groupSettingsOwnsAliasesAndClearlyNamedPlacementRules() throws Exception { - assertEquals(32, OreSourceGroupSettingsScreen.aliasListHeight(265)); - assertEquals(16, OreSourceGroupSettingsScreen.aliasListHeight(240)); + assertEquals(48, OreSourceGroupSettingsScreen.aliasListHeight(265)); + assertEquals(32, OreSourceGroupSettingsScreen.aliasListHeight(240)); String settings = screenSource("OreSourceGroupSettingsScreen.java"); assertTrue(settings.contains("option.orespawn.ore_source.group_name")); assertTrue(settings.contains("option.orespawn.ore_source.alias")); assertTrue(settings.contains("label.orespawn.ore_source.placement_rules")); - assertTrue(settings.contains("label.orespawn.ore_source.placement_explanation")); + assertTrue(settings.contains("tooltip.orespawn.ore_source.placement_rules")); + assertTrue(settings.contains("new TextComponentString(\"?\")")); assertTrue(settings.contains("label.orespawn.ore_source.standard_veins")); assertTrue(settings.contains("placementSelectable(group, channel)")); assertTrue(screenSource("OreSourceListScreen.java").contains("!candidate.external")); assertTrue(settings.contains("minecraft.displayGuiScreen(parent)")); assertFalse(settings.contains("button.orespawn.ore_source.advanced")); + assertFalse(settings.contains("placementExplanationLines")); } @Test From 326e25c32abf1af7c0297ac6445624321e84ecd7 Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Thu, 17 Sep 2026 13:40:46 +0100 Subject: [PATCH 16/31] Refine Ore Source review and compact layout --- CHANGELOG.txt | 9 ++- README.md | 4 +- docs/CONFIGURATION.md | 10 ++- docs/PLAYER_GUIDE.md | 5 +- .../orespawn/client/ClientProbeTestMod.java | 62 +++++++++++++++++-- .../orespawn/client/GeologyEditorSession.java | 12 +++- .../client/OreSourceGroupSettingsScreen.java | 17 ++--- .../orespawn/client/OreSourceListScreen.java | 33 ++++++++-- .../resources/assets/orespawn/lang/de_AU.lang | 2 + .../resources/assets/orespawn/lang/de_DE.lang | 2 + .../resources/assets/orespawn/lang/en_CA.lang | 2 + .../resources/assets/orespawn/lang/en_EN.lang | 2 + .../resources/assets/orespawn/lang/en_GB.lang | 2 + .../resources/assets/orespawn/lang/en_PT.lang | 2 + .../resources/assets/orespawn/lang/en_US.lang | 2 + .../resources/assets/orespawn/lang/es_ES.lang | 2 + .../resources/assets/orespawn/lang/es_MX.lang | 2 + .../resources/assets/orespawn/lang/fr_CA.lang | 2 + .../resources/assets/orespawn/lang/fr_FR.lang | 2 + .../resources/assets/orespawn/lang/ja_JP.lang | 2 + .../resources/assets/orespawn/lang/ko_KR.lang | 2 + .../resources/assets/orespawn/lang/pt_BR.lang | 2 + .../resources/assets/orespawn/lang/ru_RU.lang | 2 + .../resources/assets/orespawn/lang/zh_CN.lang | 2 + .../mc/orespawn/LocalizationParityTest.java | 2 +- .../client/GeologyEditorSessionTest.java | 23 +++++++ .../client/OreSourcePlacementScreenTest.java | 2 + .../client/OreSpawnScreenLayoutTest.java | 10 ++- 28 files changed, 193 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 9c8d227b..5dacf3d8 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -9,7 +9,7 @@ Version 4.1.0.110021 * Replace the technical Ore Sources list/detail flow with compact independently scrolling group and output lists that remain visible together at compact resolutions. The default view hides harmless single-source entries behind - Show All; red groups need attention, yellow groups have a saved rule and + a compact Show All control; red groups need attention, yellow groups have a saved rule and green entries need no action. Each group has a cog for its name and aliases. * Rename the old Advanced channel controls to Placement Rules and explain their separate ownership of frequency, shape, depth and host restrictions. Single @@ -19,11 +19,14 @@ Version 4.1.0.110021 MMD conflicts balance every eligible output through one placement budget; inactive provider definitions remain selectable outputs without contributing another placement rule. Returning to Keep Original restores the output and - placement selections from when the editor was opened. + placement selections from when the editor was opened. An explicit Accept + action keeps that policy unchanged while clearing its pending review state; + the main editor's Done action remains the persistence boundary. * Keep tooltips inside the available side of the screen and move Placement Rules guidance into a compact help tooltip, with an explicit fixed, selectable, missing or Keep Original status instead of an unexplained - disabled control. + disabled control. Keep the group-name label inline, expand the alias list by + two rows and reduce the Placement Rules list by one row at compact sizes. * Ship curated Sulfur/Sulphur and Aluminum/Aluminium groups, keep Niter and Saltpeter distinct, deduplicate identical registry states, and allow confirmed alias reassignment plus stable custom groups. Group edits are saved to the diff --git a/README.md b/README.md index 384988b0..1a4b88d0 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,9 @@ all eligible outputs in reviewed MMD conflicts while running one placement budget, so sulfur/sulphur providers do not multiply abundance. **Single** chooses one output, **Custom** allows a weighted subset, and **Keep Original** preserves every active rule and restores the selections that were present when -the editor was opened. The Placement Rules help and row tooltips state whether +the editor was opened. A red Keep Original group exposes **Accept** so its +unchanged policy can be marked reviewed; this remains pending until the main +editor's **Done** is used. The Placement Rules help and row tooltips state whether a channel is fixed, selectable, missing, or inactive under Keep Original. Existing worlds start in Keep Original until their owner explicitly changes a group. Third-party generators remain outside diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 8113fb03..7997b660 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -236,13 +236,17 @@ 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. +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. 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 therefore clears a -multi-output group's attention state without hiding the resolved group. Use a +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 diff --git a/docs/PLAYER_GUIDE.md b/docs/PLAYER_GUIDE.md index 8f4b93f6..4f452b27 100644 --- a/docs/PLAYER_GUIDE.md +++ b/docs/PLAYER_GUIDE.md @@ -56,7 +56,10 @@ safely instead of breaking world creation. 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. The cog beside a group opens its friendly + 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. + 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; 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 fd54a367..44fbb66e 100644 --- a/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java +++ b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java @@ -308,18 +308,24 @@ private void validateOreSourcesSingleScreen(GuiScreen parent, GeologyEditorSessi 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.xPosition < right) groupList = true; if (widget instanceof CompactScrollList && widget.xPosition >= right) outputList = true; if (widget instanceof TextFieldWidget && widget.xPosition < right) inlineAdministration = true; if ("Back".equals(caption)) back = true; - if (I18n.format("button.orespawn.show_all").equals(caption)) showAll = true; + if (I18n.format("button.orespawn.show_all").equals(caption)) { + showAll = true; + showAllWidth = widget.width; + } } - if (!groupList || !outputList || inlineAdministration || back || !showAll) { + 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); } @@ -334,25 +340,73 @@ private void validateOreSourcesSingleScreen(GuiScreen parent, GeologyEditorSessi 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.yPosition + widget.height); + if (widget.yPosition < 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 + 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 + ", listBottom=" + lowestListBottom + ", channels=" + + compactLists + ", name=" + sulfurName + ", aliasRows=" + aliasRows + + ", placementRows=" + placementRows + ", listBottom=" + lowestListBottom + ", channels=" + OreSourceGroupSettingsScreen.placementChannels(sulfurGroup)); } + validateKeepOriginalAcceptance(minecraft, parent, session, sulfurGroup.key); oreSourcesLayoutValidated = true; } + 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 static GeologyEditorSession editorSession(OreSpawnWorldSettingsScreen root) { try { Field field = OreSpawnWorldSettingsScreen.class.getDeclaredField("session"); 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 b9c27131..a7228806 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java @@ -602,6 +602,12 @@ void setOreSourceMode(String key, boolean consolidated) { 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(original, "ore_source_policies"); @@ -838,10 +844,14 @@ List outputCandidates() { } boolean needsAttention() { - return "review_required".equals(status) || "missing_source".equals(status) + return needsReview() || "missing_source".equals(status) || "external_generation".equals(status) || outputCandidates().isEmpty(); } + boolean needsReview() { + return "review_required".equals(status); + } + boolean isRoutineSingleSource() { return !needsAttention() && oreDictionaryEntries.size() == 1 && outputCandidates().size() == 1; diff --git a/src/main/java/zone/moddev/mc/orespawn/client/OreSourceGroupSettingsScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceGroupSettingsScreen.java index d8d33f3c..931ceade 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/OreSourceGroupSettingsScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceGroupSettingsScreen.java @@ -19,8 +19,9 @@ /** 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 = 35; - private static final int ALIAS_TOP = 70; + 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; @@ -57,9 +58,11 @@ protected void init() { contentWidth = Math.max(240, Math.min(600, width - (MARGIN * 2))); contentX = (width - contentWidth) / 2; int actionWidth = Math.min(78, Math.max(55, contentWidth / 4)); + int nameLabelWidth = font.getStringWidth( + I18n.format("option.orespawn.ore_source.group_name")) + 8; - nameField = addButton(new TextFieldWidget(font, contentX, NAME_TOP, - contentWidth - actionWidth - 5, 20, + nameField = addButton(new TextFieldWidget(font, contentX + nameLabelWidth, NAME_TOP, + contentWidth - nameLabelWidth - actionWidth - 5, 20, new TextComponentTranslation("option.orespawn.ore_source.group_name"))); nameField.setMaxLength(64); nameField.setValue(group.displayName); @@ -244,7 +247,7 @@ private void rebuild() { } static int aliasListHeight(int height) { - return height >= 260 ? 48 : 32; + return height >= 260 ? 80 : 64; } static List placementChannels(OreSourceGroup group) { @@ -304,9 +307,9 @@ 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, 24, 0xA0A0A0); + contentX + 2, NAME_TOP + 6, 0xA0A0A0); drawString(font, new TextComponentTranslation("label.orespawn.ore_source.aliases"), - contentX + 2, 59, 0xA0A0A0); + contentX + 2, ALIAS_LABEL_TOP, 0xA0A0A0); drawString(font, new TextComponentTranslation("label.orespawn.ore_source.placement_rules"), contentX + 2, placementLabelY, 0xFFFFFF); if (error != null) { diff --git a/src/main/java/zone/moddev/mc/orespawn/client/OreSourceListScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceListScreen.java index 8a2a6c78..1a4f0ef7 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/OreSourceListScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceListScreen.java @@ -82,12 +82,14 @@ private void initGroups(final List groups) { })); OreSpawnScreenLayout.explain(this, add, "button.orespawn.ore_source.add_group"); int showX = addX + 20; - int showWidth = Math.max(38, leftPaneX + leftPaneWidth - showX); + 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 TextComponentTranslation(showAll - ? "button.orespawn.ore_source.hide_single" - : "button.orespawn.show_all"), button -> { + new TextComponentString(showLabel), button -> { if (!syncWeights()) return; captureScroll(); showAll = !showAll; @@ -158,8 +160,13 @@ private void initOutputs(final OreSourceGroup group) { 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, rightPaneWidth, 20, + rightPaneX, CONTENT_TOP, modeWidth, 20, new TextComponentTranslation("option.orespawn.ore_source.output_mode", new TextComponentTranslation("mode.orespawn.ore_source." + mode).getFormattedText()), button -> { @@ -169,6 +176,18 @@ private void initOutputs(final OreSourceGroup group) { 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; @@ -415,6 +434,10 @@ 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; diff --git a/src/main/resources/assets/orespawn/lang/de_AU.lang b/src/main/resources/assets/orespawn/lang/de_AU.lang index 50f3adf3..ebcf4b84 100644 --- a/src/main/resources/assets/orespawn/lang/de_AU.lang +++ b/src/main/resources/assets/orespawn/lang/de_AU.lang @@ -419,6 +419,8 @@ error.orespawn.ore_source.weight=Ausgabegewichte müssen positive Zahlen sein. button.orespawn.back=Zurück button.orespawn.ore_source.all_groups=Alle Gruppen button.orespawn.ore_source.add_group=Gruppe hinzufügen +button.orespawn.ore_source.accept=Akzeptieren +tooltip.orespawn.ore_source.accept=Behält „Original beibehalten“ unverändert bei und markiert die Gruppe als geprüft. Gespeichert wird dies erst mit „Fertig“ im Haupteditor. button.orespawn.ore_source.delete_group=Löschen button.orespawn.ore_source.move=Verschieben button.orespawn.ore_source.advanced=Erweitert... diff --git a/src/main/resources/assets/orespawn/lang/de_DE.lang b/src/main/resources/assets/orespawn/lang/de_DE.lang index 50f3adf3..ebcf4b84 100644 --- a/src/main/resources/assets/orespawn/lang/de_DE.lang +++ b/src/main/resources/assets/orespawn/lang/de_DE.lang @@ -419,6 +419,8 @@ error.orespawn.ore_source.weight=Ausgabegewichte müssen positive Zahlen sein. button.orespawn.back=Zurück button.orespawn.ore_source.all_groups=Alle Gruppen button.orespawn.ore_source.add_group=Gruppe hinzufügen +button.orespawn.ore_source.accept=Akzeptieren +tooltip.orespawn.ore_source.accept=Behält „Original beibehalten“ unverändert bei und markiert die Gruppe als geprüft. Gespeichert wird dies erst mit „Fertig“ im Haupteditor. button.orespawn.ore_source.delete_group=Löschen button.orespawn.ore_source.move=Verschieben button.orespawn.ore_source.advanced=Erweitert... diff --git a/src/main/resources/assets/orespawn/lang/en_CA.lang b/src/main/resources/assets/orespawn/lang/en_CA.lang index 5c92a8dd..bcf251be 100644 --- a/src/main/resources/assets/orespawn/lang/en_CA.lang +++ b/src/main/resources/assets/orespawn/lang/en_CA.lang @@ -419,6 +419,8 @@ error.orespawn.ore_source.weight=Output weights must be positive numbers. button.orespawn.back=Back button.orespawn.ore_source.all_groups=All Groups button.orespawn.ore_source.add_group=Add Group +button.orespawn.ore_source.accept=Accept +tooltip.orespawn.ore_source.accept=Keep the current Keep Original policy unchanged and mark this group as reviewed. It is saved only when the main editor's Done is used. button.orespawn.ore_source.delete_group=Delete button.orespawn.ore_source.move=Move button.orespawn.ore_source.advanced=Advanced... diff --git a/src/main/resources/assets/orespawn/lang/en_EN.lang b/src/main/resources/assets/orespawn/lang/en_EN.lang index 5c92a8dd..bcf251be 100644 --- a/src/main/resources/assets/orespawn/lang/en_EN.lang +++ b/src/main/resources/assets/orespawn/lang/en_EN.lang @@ -419,6 +419,8 @@ error.orespawn.ore_source.weight=Output weights must be positive numbers. button.orespawn.back=Back button.orespawn.ore_source.all_groups=All Groups button.orespawn.ore_source.add_group=Add Group +button.orespawn.ore_source.accept=Accept +tooltip.orespawn.ore_source.accept=Keep the current Keep Original policy unchanged and mark this group as reviewed. It is saved only when the main editor's Done is used. button.orespawn.ore_source.delete_group=Delete button.orespawn.ore_source.move=Move button.orespawn.ore_source.advanced=Advanced... diff --git a/src/main/resources/assets/orespawn/lang/en_GB.lang b/src/main/resources/assets/orespawn/lang/en_GB.lang index 5c92a8dd..bcf251be 100644 --- a/src/main/resources/assets/orespawn/lang/en_GB.lang +++ b/src/main/resources/assets/orespawn/lang/en_GB.lang @@ -419,6 +419,8 @@ error.orespawn.ore_source.weight=Output weights must be positive numbers. button.orespawn.back=Back button.orespawn.ore_source.all_groups=All Groups button.orespawn.ore_source.add_group=Add Group +button.orespawn.ore_source.accept=Accept +tooltip.orespawn.ore_source.accept=Keep the current Keep Original policy unchanged and mark this group as reviewed. It is saved only when the main editor's Done is used. button.orespawn.ore_source.delete_group=Delete button.orespawn.ore_source.move=Move button.orespawn.ore_source.advanced=Advanced... diff --git a/src/main/resources/assets/orespawn/lang/en_PT.lang b/src/main/resources/assets/orespawn/lang/en_PT.lang index 5c92a8dd..bcf251be 100644 --- a/src/main/resources/assets/orespawn/lang/en_PT.lang +++ b/src/main/resources/assets/orespawn/lang/en_PT.lang @@ -419,6 +419,8 @@ error.orespawn.ore_source.weight=Output weights must be positive numbers. button.orespawn.back=Back button.orespawn.ore_source.all_groups=All Groups button.orespawn.ore_source.add_group=Add Group +button.orespawn.ore_source.accept=Accept +tooltip.orespawn.ore_source.accept=Keep the current Keep Original policy unchanged and mark this group as reviewed. It is saved only when the main editor's Done is used. button.orespawn.ore_source.delete_group=Delete button.orespawn.ore_source.move=Move button.orespawn.ore_source.advanced=Advanced... diff --git a/src/main/resources/assets/orespawn/lang/en_US.lang b/src/main/resources/assets/orespawn/lang/en_US.lang index 2f5a6962..08b298df 100644 --- a/src/main/resources/assets/orespawn/lang/en_US.lang +++ b/src/main/resources/assets/orespawn/lang/en_US.lang @@ -419,6 +419,8 @@ error.orespawn.ore_source.weight=Output weights must be positive numbers. button.orespawn.back=Back button.orespawn.ore_source.all_groups=All Groups button.orespawn.ore_source.add_group=Add Group +button.orespawn.ore_source.accept=Accept +tooltip.orespawn.ore_source.accept=Keep the current Keep Original policy unchanged and mark this group as reviewed. It is saved only when the main editor's Done is used. button.orespawn.ore_source.delete_group=Delete button.orespawn.ore_source.move=Move button.orespawn.ore_source.advanced=Advanced... diff --git a/src/main/resources/assets/orespawn/lang/es_ES.lang b/src/main/resources/assets/orespawn/lang/es_ES.lang index 0162c59a..e8f8dac5 100644 --- a/src/main/resources/assets/orespawn/lang/es_ES.lang +++ b/src/main/resources/assets/orespawn/lang/es_ES.lang @@ -419,6 +419,8 @@ error.orespawn.ore_source.weight=Los pesos de salida deben ser números positivo button.orespawn.back=Atrás button.orespawn.ore_source.all_groups=Todos los grupos button.orespawn.ore_source.add_group=Añadir grupo +button.orespawn.ore_source.accept=Aceptar +tooltip.orespawn.ore_source.accept=Conserva sin cambios «Mantener original» y marca el grupo como revisado. Solo se guarda al pulsar Hecho en el editor principal. button.orespawn.ore_source.delete_group=Eliminar button.orespawn.ore_source.move=Mover button.orespawn.ore_source.advanced=Avanzado... diff --git a/src/main/resources/assets/orespawn/lang/es_MX.lang b/src/main/resources/assets/orespawn/lang/es_MX.lang index 0162c59a..e8f8dac5 100644 --- a/src/main/resources/assets/orespawn/lang/es_MX.lang +++ b/src/main/resources/assets/orespawn/lang/es_MX.lang @@ -419,6 +419,8 @@ error.orespawn.ore_source.weight=Los pesos de salida deben ser números positivo button.orespawn.back=Atrás button.orespawn.ore_source.all_groups=Todos los grupos button.orespawn.ore_source.add_group=Añadir grupo +button.orespawn.ore_source.accept=Aceptar +tooltip.orespawn.ore_source.accept=Conserva sin cambios «Mantener original» y marca el grupo como revisado. Solo se guarda al pulsar Hecho en el editor principal. button.orespawn.ore_source.delete_group=Eliminar button.orespawn.ore_source.move=Mover button.orespawn.ore_source.advanced=Avanzado... diff --git a/src/main/resources/assets/orespawn/lang/fr_CA.lang b/src/main/resources/assets/orespawn/lang/fr_CA.lang index d4435545..7123f3b6 100644 --- a/src/main/resources/assets/orespawn/lang/fr_CA.lang +++ b/src/main/resources/assets/orespawn/lang/fr_CA.lang @@ -419,6 +419,8 @@ error.orespawn.ore_source.weight=Les poids de sortie doivent être des nombres p button.orespawn.back=Retour button.orespawn.ore_source.all_groups=Tous les groupes button.orespawn.ore_source.add_group=Ajouter un groupe +button.orespawn.ore_source.accept=Accepter +tooltip.orespawn.ore_source.accept=Conserve « Garder l'original » sans modification et marque le groupe comme vérifié. L'enregistrement se fait seulement avec Terminé dans l'éditeur principal. button.orespawn.ore_source.delete_group=Supprimer button.orespawn.ore_source.move=Déplacer button.orespawn.ore_source.advanced=Avancé... diff --git a/src/main/resources/assets/orespawn/lang/fr_FR.lang b/src/main/resources/assets/orespawn/lang/fr_FR.lang index d4435545..7123f3b6 100644 --- a/src/main/resources/assets/orespawn/lang/fr_FR.lang +++ b/src/main/resources/assets/orespawn/lang/fr_FR.lang @@ -419,6 +419,8 @@ error.orespawn.ore_source.weight=Les poids de sortie doivent être des nombres p button.orespawn.back=Retour button.orespawn.ore_source.all_groups=Tous les groupes button.orespawn.ore_source.add_group=Ajouter un groupe +button.orespawn.ore_source.accept=Accepter +tooltip.orespawn.ore_source.accept=Conserve « Garder l'original » sans modification et marque le groupe comme vérifié. L'enregistrement se fait seulement avec Terminé dans l'éditeur principal. button.orespawn.ore_source.delete_group=Supprimer button.orespawn.ore_source.move=Déplacer button.orespawn.ore_source.advanced=Avancé... diff --git a/src/main/resources/assets/orespawn/lang/ja_JP.lang b/src/main/resources/assets/orespawn/lang/ja_JP.lang index e3acf428..dd4ab0d3 100644 --- a/src/main/resources/assets/orespawn/lang/ja_JP.lang +++ b/src/main/resources/assets/orespawn/lang/ja_JP.lang @@ -419,6 +419,8 @@ error.orespawn.ore_source.weight=出力の重みは正の数である必要が button.orespawn.back=戻る button.orespawn.ore_source.all_groups=すべてのグループ button.orespawn.ore_source.add_group=グループを追加 +button.orespawn.ore_source.accept=承認 +tooltip.orespawn.ore_source.accept=「元のまま」を変更せず、このグループを確認済みにします。保存されるのはメインエディターで「完了」を押したときだけです。 button.orespawn.ore_source.delete_group=削除 button.orespawn.ore_source.move=移動 button.orespawn.ore_source.advanced=詳細... diff --git a/src/main/resources/assets/orespawn/lang/ko_KR.lang b/src/main/resources/assets/orespawn/lang/ko_KR.lang index ea8d234d..99f26d93 100644 --- a/src/main/resources/assets/orespawn/lang/ko_KR.lang +++ b/src/main/resources/assets/orespawn/lang/ko_KR.lang @@ -419,6 +419,8 @@ error.orespawn.ore_source.weight=출력 가중치는 양수여야 합니다. button.orespawn.back=뒤로 button.orespawn.ore_source.all_groups=모든 그룹 button.orespawn.ore_source.add_group=그룹 추가 +button.orespawn.ore_source.accept=승인 +tooltip.orespawn.ore_source.accept='원래대로 유지' 설정은 바꾸지 않고 이 그룹을 검토 완료로 표시합니다. 기본 편집기에서 완료를 눌러야 저장됩니다. button.orespawn.ore_source.delete_group=삭제 button.orespawn.ore_source.move=이동 button.orespawn.ore_source.advanced=고급... diff --git a/src/main/resources/assets/orespawn/lang/pt_BR.lang b/src/main/resources/assets/orespawn/lang/pt_BR.lang index b46819a0..15401dbc 100644 --- a/src/main/resources/assets/orespawn/lang/pt_BR.lang +++ b/src/main/resources/assets/orespawn/lang/pt_BR.lang @@ -419,6 +419,8 @@ error.orespawn.ore_source.weight=Os pesos de saída devem ser números positivos button.orespawn.back=Voltar button.orespawn.ore_source.all_groups=Todos os grupos button.orespawn.ore_source.add_group=Adicionar grupo +button.orespawn.ore_source.accept=Aceitar +tooltip.orespawn.ore_source.accept=Mantém Conservar original sem alterações e marca o grupo como revisado. Só é salvo ao usar Concluído no editor principal. button.orespawn.ore_source.delete_group=Excluir button.orespawn.ore_source.move=Mover button.orespawn.ore_source.advanced=Avançado... diff --git a/src/main/resources/assets/orespawn/lang/ru_RU.lang b/src/main/resources/assets/orespawn/lang/ru_RU.lang index 405f9d36..ca5e3cb2 100644 --- a/src/main/resources/assets/orespawn/lang/ru_RU.lang +++ b/src/main/resources/assets/orespawn/lang/ru_RU.lang @@ -419,6 +419,8 @@ error.orespawn.ore_source.weight=Веса выходов должны быть button.orespawn.back=Назад button.orespawn.ore_source.all_groups=Все группы button.orespawn.ore_source.add_group=Добавить группу +button.orespawn.ore_source.accept=Принять +tooltip.orespawn.ore_source.accept=Сохраняет режим «Оставить исходное» без изменений и отмечает группу как проверенную. Изменение сохраняется только кнопкой «Готово» в главном редакторе. button.orespawn.ore_source.delete_group=Удалить button.orespawn.ore_source.move=Переместить button.orespawn.ore_source.advanced=Расширенные... diff --git a/src/main/resources/assets/orespawn/lang/zh_CN.lang b/src/main/resources/assets/orespawn/lang/zh_CN.lang index 4b4ccfad..de481a5e 100644 --- a/src/main/resources/assets/orespawn/lang/zh_CN.lang +++ b/src/main/resources/assets/orespawn/lang/zh_CN.lang @@ -419,6 +419,8 @@ error.orespawn.ore_source.weight=输出权重必须为正数。 button.orespawn.back=返回 button.orespawn.ore_source.all_groups=所有分组 button.orespawn.ore_source.add_group=添加分组 +button.orespawn.ore_source.accept=接受 +tooltip.orespawn.ore_source.accept=保持“保留原始设置”不变,并将此分组标记为已审核。只有在主编辑器中点击“完成”后才会保存。 button.orespawn.ore_source.delete_group=删除 button.orespawn.ore_source.move=移动 button.orespawn.ore_source.advanced=高级... diff --git a/src/test/java/zone/moddev/mc/orespawn/LocalizationParityTest.java b/src/test/java/zone/moddev/mc/orespawn/LocalizationParityTest.java index 58e3a165..f8aceb07 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(461, englishKeys.size(), + assertEquals(463, 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/client/GeologyEditorSessionTest.java b/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java index 33ef73e1..7f1fe168 100644 --- a/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java @@ -320,6 +320,29 @@ void keepOriginalRestoresThePolicySnapshotFromWhenTheEditorOpened() { assertEquals(original.placements, restored.placements); } + @Test + void acceptingKeepOriginalClearsReviewWithoutChangingItsPolicy() { + WorldGeologyProfile originalProfile = profileWithTwoOreOutputs(); + GeologyEditorSession session = new GeologyEditorSession(originalProfile); + GeologyEditorSession.OreSourceGroup before = session.oreSourceGroups().get(0); + JsonObject persistedBefore = originalProfile.rootCopy(); + + assertTrue(before.needsReview()); + assertEquals("keep_separate", before.mode); + session.acceptOreSourcePolicy(before.key); + + GeologyEditorSession.OreSourceGroup accepted = session.oreSourceGroups().get(0); + assertFalse(accepted.needsReview()); + assertFalse(accepted.needsAttention()); + assertEquals("separate", accepted.status); + assertEquals(before.mode, accepted.mode); + assertEquals(before.outputMode, accepted.outputMode); + assertEquals(before.outputs, accepted.outputs); + assertEquals(before.placements, accepted.placements); + assertEquals(persistedBefore, originalProfile.rootCopy(), + "Accept remains pending until the main editor saves the profile"); + } + private static WorldGeologyProfile profileWithOreSourcePolicy() { JsonObject root = WorldGeologyProfile.recommended(false).toJson(); JsonObject policy = new JsonObject(); diff --git a/src/test/java/zone/moddev/mc/orespawn/client/OreSourcePlacementScreenTest.java b/src/test/java/zone/moddev/mc/orespawn/client/OreSourcePlacementScreenTest.java index 48329630..1f5da732 100644 --- a/src/test/java/zone/moddev/mc/orespawn/client/OreSourcePlacementScreenTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/client/OreSourcePlacementScreenTest.java @@ -68,6 +68,8 @@ void groupColoursSeparateRoutineResolvedAndUnresolvedEntries() { assertTrue(OreSourceListScreen.showByDefault(resolved)); assertEquals(0xFFFF55, OreSourceListScreen.groupRowColor(resolved)); assertTrue(unresolved.needsAttention()); + assertTrue(unresolved.needsReview()); + assertFalse(resolved.needsReview()); assertTrue(OreSourceListScreen.showByDefault(unresolved)); assertEquals(0xFF5555, OreSourceListScreen.groupRowColor(unresolved)); } 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 b2ff42e3..d9acad53 100644 --- a/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java @@ -106,6 +106,10 @@ void oreSourceEditorKeepsBothCompactScrollablePanesVisible() throws Exception { assertTrue(list.contains("label.orespawn.ore_source.groups")); assertTrue(list.contains("button.orespawn.show_all")); assertTrue(list.contains("button.orespawn.ore_source.hide_single")); + assertTrue(list.contains("compactFilterWidth")); + assertTrue(list.contains("button.orespawn.ore_source.accept")); + assertTrue(list.contains("acceptOreSourcePolicy(group.key)")); + assertEquals(58, OreSourceListScreen.compactFilterWidth(110, 48)); assertTrue(list.contains("groupRowColor")); assertTrue(list.contains("drawCompactCog")); assertTrue(list.contains("outputCandidates()")); @@ -130,9 +134,11 @@ void oreSourceEditorKeepsBothCompactScrollablePanesVisible() throws Exception { @Test void groupSettingsOwnsAliasesAndClearlyNamedPlacementRules() throws Exception { - assertEquals(48, OreSourceGroupSettingsScreen.aliasListHeight(265)); - assertEquals(32, OreSourceGroupSettingsScreen.aliasListHeight(240)); + assertEquals(80, OreSourceGroupSettingsScreen.aliasListHeight(265)); + assertEquals(64, OreSourceGroupSettingsScreen.aliasListHeight(240)); String settings = screenSource("OreSourceGroupSettingsScreen.java"); + assertTrue(settings.contains("contentX + nameLabelWidth, NAME_TOP")); + assertTrue(settings.contains("NAME_TOP + 6")); assertTrue(settings.contains("option.orespawn.ore_source.group_name")); assertTrue(settings.contains("option.orespawn.ore_source.alias")); assertTrue(settings.contains("label.orespawn.ore_source.placement_rules")); From a6788b5b39253beb9c6d2bf06c23957a26681511 Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Thu, 17 Sep 2026 14:24:01 +0100 Subject: [PATCH 17/31] Fix stale Ore Source conflict status --- CHANGELOG.txt | 3 ++ .../orespawn/client/ClientProbeTestMod.java | 44 +++++++++++++++++++ .../orespawn/client/GeologyEditorSession.java | 6 ++- .../orespawn/worldgen/OreSourcePolicies.java | 2 +- .../client/GeologyEditorSessionTest.java | 26 +++++++++++ .../worldgen/OreSourcePoliciesTest.java | 38 ++++++++++++++++ 6 files changed, 116 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 5dacf3d8..18b82070 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -31,6 +31,9 @@ Version 4.1.0.110021 Saltpeter distinct, deduplicate identical registry states, and allow confirmed alias reassignment plus stable custom groups. Group edits are saved to the world and future-world defaults only when the main editor's Done is accepted. +* Ignore missing historical external-candidate records when the same registry + state is now supplied by a loaded managed provider, preventing resolved + groups from remaining falsely red while retaining the dormant record. * Add provider schema 5 material and placement-channel declarations, global schema 8 and world-profile schema 7 while retaining older readable schemas. * Add an extended, binary-compatible ore generation context exposing stable 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 44fbb66e..4cbc5c08 100644 --- a/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java +++ b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java @@ -33,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. */ @@ -362,6 +363,7 @@ private void validateOreSourcesSingleScreen(GuiScreen parent, GeologyEditorSessi + OreSourceGroupSettingsScreen.placementChannels(sulfurGroup)); } validateKeepOriginalAcceptance(minecraft, parent, session, sulfurGroup.key); + validateStaleExternalClassification(minecraft, parent, session, sulfurGroup.key); oreSourcesLayoutValidated = true; } @@ -407,6 +409,48 @@ private void validateKeepOriginalAcceptance(Minecraft minecraft, GuiScreen paren } } + 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"); 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 a7228806..72974bae 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java @@ -392,10 +392,12 @@ List oreSourceGroups() { 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(policy, "status", "review_required"), + string(viewPolicy, "status", "review_required"), candidates, decimalMap(policy, "outputs"), stringMap(policy, "placement_sources")); if (!bool(policy, "dormant", false)) result.add(group); } @@ -746,7 +748,7 @@ private static void refreshOreSourceStatus(JsonObject policy) { 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); + external |= bool(candidate, "external", false) && bool(candidate, "loaded", false); } } Map outputs = decimalMap(policy, "outputs"); diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSourcePolicies.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSourcePolicies.java index d0f2e053..b19f5a48 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSourcePolicies.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSourcePolicies.java @@ -327,7 +327,7 @@ private static String status(JsonObject policy, List candidates) { boolean review = false; for (Candidate candidate : candidates) { current.put(candidate.sourceId, candidate); - external |= candidate.external; + external |= candidate.external && candidate.loaded; review |= candidate.reviewRequired; } Map outputs = stringMap(policy, "outputs"); diff --git a/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java b/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java index 7f1fe168..63173ed6 100644 --- a/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java @@ -343,6 +343,32 @@ void acceptingKeepOriginalClearsReviewWithoutChangingItsPolicy() { "Accept remains pending until the main editor saves the profile"); } + @Test + void missingExternalDuplicateCannotOverrideItsLoadedManagedOutput() { + JsonObject root = profileWithTwoOreOutputs().rootCopy(); + JsonObject policy = root.getAsJsonObject("ore_source_policies") + .getAsJsonObject("orespawn:sulfur|minecraft:overworld"); + JsonObject managed = policy.getAsJsonArray("candidates").get(1).getAsJsonObject(); + JsonObject external = zone.moddev.mc.orespawn.util.JsonCopies.copy(managed); + external.addProperty("source_id", "external/minecraft:gold_ore/0"); + external.addProperty("loaded", false); + external.addProperty("placement_active", false); + external.addProperty("external", true); + policy.getAsJsonArray("candidates").add(external); + policy.addProperty("review_required", false); + policy.addProperty("status", "external_generation"); + WorldGeologyProfile profile = WorldGeologyProfile.fromJson(root, + WorldGeologyProfile.recommended(false)); + JsonObject before = profile.rootCopy(); + + GeologyEditorSession.OreSourceGroup group = + new GeologyEditorSession(profile).oreSourceGroups().get(0); + + assertEquals("separate", group.status); + assertFalse(group.needsAttention()); + assertEquals(before, profile.rootCopy(), "Deriving the visible status must not rewrite the profile"); + } + private static WorldGeologyProfile profileWithOreSourcePolicy() { JsonObject root = WorldGeologyProfile.recommended(false).toJson(); JsonObject policy = new JsonObject(); diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/OreSourcePoliciesTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/OreSourcePoliciesTest.java index 369d5393..38756fa5 100644 --- a/src/test/java/zone/moddev/mc/orespawn/worldgen/OreSourcePoliciesTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/OreSourcePoliciesTest.java @@ -15,6 +15,7 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import zone.moddev.mc.orespawn.test.Forge12TestBootstrap; +import zone.moddev.mc.orespawn.util.JsonCopies; class OreSourcePoliciesTest { @BeforeAll @@ -342,6 +343,43 @@ void inactiveManagedProviderRemainsAnOutputWithoutOwningPlacement() { assertTrue(outputOnly); } + @Test + void staleMissingExternalDuplicateDoesNotKeepManagedOutputRed() { + JsonObject root = root(); + addOre(root, "mineralogy:sulfur", "mineralogy", "minecraft:iron_ore", + "orespawn:sulfur", "minecraft:overworld", null); + addOre(root, "electricadvantage:sulfur", "electricadvantage", "minecraft:gold_ore", + "orespawn:sulfur", "minecraft:overworld", null); + root.getAsJsonObject("ores").getAsJsonObject("electricadvantage:sulfur") + .addProperty("enabled", false); + OreSourcePolicies.initialize(root, false); + + JsonObject policy = policy(root); + JsonObject external = null; + for (JsonElement element : policy.getAsJsonArray("candidates")) { + JsonObject candidate = element.getAsJsonObject(); + if ("electricadvantage:sulfur".equals(candidate.get("source_id").getAsString())) { + external = JsonCopies.copy(candidate); + } + } + assertTrue(external != null); + external.addProperty("source_id", "external/minecraft:gold_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"); + + assertTrue(OreSourcePolicies.initialize(root, false)); + assertEquals("separate", policy(root).get("status").getAsString()); + assertEquals("separate", OreSourcePolicies.snapshot(root).groups().get(0).status); + assertEquals(3, policy(root).getAsJsonArray("candidates").size(), + "The missing historical candidate remains available for deterministic restoration"); + } + @Test void materialGroupsInferExactNamesAndPreserveCuratedEdits() { JsonObject root = root(); From 830a425743239adaed512790c94c266b7c26a286 Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Thu, 17 Sep 2026 22:09:58 +0100 Subject: [PATCH 18/31] Keep Ore Source validation visible --- CHANGELOG.txt | 3 + .../orespawn/client/ClientProbeTestMod.java | 64 +++++++++++++++++++ .../client/OreSourceGroupSettingsScreen.java | 28 ++++++-- .../client/OreSpawnScreenLayoutTest.java | 9 +++ 4 files changed, 99 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 18b82070..9b6eea71 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -34,6 +34,9 @@ Version 4.1.0.110021 * Ignore missing historical external-candidate records when the same registry state is now supplied by a loaded managed provider, preventing resolved groups from remaining falsely red while retaining the dormant record. +* Keep Group Settings validation visible on a dedicated bottom row, with the + Done action in the lower-right corner and full clipped messages available by + tooltip during confirmed Ore Dictionary alias reassignment. * Add provider schema 5 material and placement-channel declarations, global schema 8 and world-profile schema 7 while retaining older readable schemas. * Add an extended, binary-compatible ore generation context exposing stable 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 4cbc5c08..f635e360 100644 --- a/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java +++ b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java @@ -364,9 +364,73 @@ private void validateOreSourcesSingleScreen(GuiScreen parent, GeologyEditorSessi } validateKeepOriginalAcceptance(minecraft, parent, session, sulfurGroup.key); validateStaleExternalClassification(minecraft, parent, session, sulfurGroup.key); + validateCustomGroupReassignmentLayout(minecraft, screen, session); oreSourcesLayoutValidated = true; } + private void validateCustomGroupReassignmentLayout(Minecraft minecraft, GuiScreen parent, + GeologyEditorSession source) { + GeologyEditorSession customSession = new GeologyEditorSession( + WorldGeologyProfile.fromJson(source.profile().rootCopy(), source.profile())); + 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 || message == null || message.isEmpty() || done == null + || done.xPosition != expectedDoneX || done.yPosition != 237 + || OreSourceGroupSettingsScreen.validationMessageWidth(contentWidth) <= 0) { + throw new IllegalStateException("Alias reassignment message did not own the bottom row: move=" + + (move != null) + ", message=" + message + ", done=" + + (done == null ? "missing" : done.xPosition + "," + done.yPosition)); + } + if (!originalOwner.equals(customSession.oreDictionaryOwner("oreDiamond"))) { + throw new IllegalStateException("First alias action reassigned oreDiamond without confirmation"); + } + move.press(); + if (!material.equals(customSession.oreDictionaryOwner("oreDiamond"))) { + throw new IllegalStateException("Move did not confirm oreDiamond reassignment"); + } + } + private void validateKeepOriginalAcceptance(Minecraft minecraft, GuiScreen parent, GeologyEditorSession source, String key) { JsonObject root = source.profile().rootCopy(); diff --git a/src/main/java/zone/moddev/mc/orespawn/client/OreSourceGroupSettingsScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceGroupSettingsScreen.java index 931ceade..43fb1fc4 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/OreSourceGroupSettingsScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceGroupSettingsScreen.java @@ -196,7 +196,8 @@ protected void init() { }); placementList.setFirstIndex(placementScroll); - addButton(new Button(width / 2 - 75, height - 28, 150, 20, + int doneWidth = doneButtonWidth(contentWidth); + addButton(new Button(contentX + contentWidth - doneWidth, height - 28, doneWidth, 20, DialogTexts.GUI_DONE, button -> onClose())); } @@ -250,6 +251,14 @@ 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()); @@ -312,16 +321,25 @@ public void render(int mouseX, int mouseY, float partialTick) { contentX + 2, ALIAS_LABEL_TOP, 0xA0A0A0); drawString(font, new TextComponentTranslation("label.orespawn.ore_source.placement_rules"), contentX + 2, placementLabelY, 0xFFFFFF); - if (error != null) { - drawCenteredString(font, new TextComponentString(OreSpawnScreenLayout.fit(font, - new TextComponentString(error), width - 24)), width / 2, 19, 0xFF5555); - } 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/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java b/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java index d9acad53..e490fe79 100644 --- a/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java @@ -136,6 +136,10 @@ void oreSourceEditorKeepsBothCompactScrollablePanesVisible() throws Exception { void groupSettingsOwnsAliasesAndClearlyNamedPlacementRules() throws Exception { assertEquals(80, OreSourceGroupSettingsScreen.aliasListHeight(265)); assertEquals(64, OreSourceGroupSettingsScreen.aliasListHeight(240)); + assertEquals(101, OreSourceGroupSettingsScreen.doneButtonWidth(406)); + assertEquals(80, OreSourceGroupSettingsScreen.doneButtonWidth(240)); + assertEquals(120, OreSourceGroupSettingsScreen.doneButtonWidth(600)); + assertEquals(297, OreSourceGroupSettingsScreen.validationMessageWidth(406)); String settings = screenSource("OreSourceGroupSettingsScreen.java"); assertTrue(settings.contains("contentX + nameLabelWidth, NAME_TOP")); assertTrue(settings.contains("NAME_TOP + 6")); @@ -146,6 +150,11 @@ void groupSettingsOwnsAliasesAndClearlyNamedPlacementRules() throws Exception { assertTrue(settings.contains("new TextComponentString(\"?\")")); assertTrue(settings.contains("label.orespawn.ore_source.standard_veins")); assertTrue(settings.contains("placementSelectable(group, channel)")); + assertTrue(settings.contains("contentX + contentWidth - doneWidth, height - 28")); + assertTrue(settings.contains("contentX + 2, height - 22, 0xFF5555")); + assertTrue(settings.contains("renderStringTooltip(java.util.Collections.singletonList(error)")); + assertFalse(settings.contains("width / 2 - 75, height - 28")); + assertFalse(settings.contains("width / 2, 19, 0xFF5555")); assertTrue(screenSource("OreSourceListScreen.java").contains("!candidate.external")); assertTrue(settings.contains("minecraft.displayGuiScreen(parent)")); assertFalse(settings.contains("button.orespawn.ore_source.advanced")); From 73f05d05cdf3373f9994bdd7d98198db07b73028 Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Thu, 17 Sep 2026 22:36:47 +0100 Subject: [PATCH 19/31] Reject Ore Source modes without placement rules --- CHANGELOG.txt | 3 ++ .../orespawn/client/ClientProbeTestMod.java | 46 +++++++++++++++++++ .../orespawn/client/GeologyEditorSession.java | 21 +++++++++ .../orespawn/client/OreSourceListScreen.java | 6 +++ .../client/GeologyEditorSessionTest.java | 28 ++++++++++- .../client/OreSourcePlacementScreenTest.java | 6 +++ 6 files changed, 109 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 9b6eea71..7c4f8e3a 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -37,6 +37,9 @@ Version 4.1.0.110021 * Keep Group Settings validation visible on a dedicated bottom row, with the Done action in the lower-right corner and full clipped messages available by tooltip during confirmed Ore Dictionary alias reassignment. +* Prevent Balanced, Single or Custom consolidation when a material group has + no active OreSpawn-managed placement rule, retaining the review action and + Keep Original behaviour instead of entering an unresolved red policy. * Add provider schema 5 material and placement-channel declarations, global schema 8 and world-profile schema 7 while retaining older readable schemas. * Add an extended, binary-compatible ore generation context exposing stable 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 f635e360..b6aaa757 100644 --- a/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java +++ b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java @@ -429,6 +429,52 @@ private void validateCustomGroupReassignmentLayout(Minecraft minecraft, GuiScree 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 output-only 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) { + throw new IllegalStateException("Output-only 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("Output-only review did not retain Keep Original and Accept"); + } + 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 || !"keep_separate".equals(after.mode) || !after.needsReview() + || outputError == null || outputError.isEmpty() + || button(outputs, I18n.format("button.orespawn.ore_source.accept")) == null) { + throw new IllegalStateException("Output-only group entered invalid Balanced mode: error=" + + outputError + ", mode=" + (after == null ? "missing" : after.mode)); + } + } + + 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, 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 72974bae..74fde7ba 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java @@ -651,6 +651,7 @@ 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"); @@ -736,6 +737,18 @@ private static boolean loadedOutput(JsonObject policy, String sourceId) { 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<>(); @@ -854,6 +867,14 @@ 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; diff --git a/src/main/java/zone/moddev/mc/orespawn/client/OreSourceListScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceListScreen.java index 1a4f0ef7..bc40717a 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/OreSourceListScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceListScreen.java @@ -268,6 +268,12 @@ private void updateWeightBounds() { } private void cycleMode(OreSourceGroup group) { + 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"); diff --git a/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java b/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java index 63173ed6..9aeb5d91 100644 --- a/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java @@ -10,8 +10,9 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; -import com.google.gson.JsonObject; 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.WorldGeologyProfile; @@ -298,6 +299,31 @@ void balancedSingleAndCustomModesKeepOneExplicitOutputPolicy() { assertEquals(2.5D, custom.outputs.get("baseminerals:sulfur").doubleValue()); } + @Test + void outputOnlyCustomGroupCannotEnterAnInvalidBalancedPolicy() { + JsonObject root = profileWithTwoOreOutputs().rootCopy(); + JsonObject policy = root.getAsJsonObject("ore_source_policies") + .getAsJsonObject("orespawn:sulfur|minecraft:overworld"); + policy.getAsJsonObject("placement_sources").entrySet().clear(); + for (JsonElement element : policy.getAsJsonArray("candidates")) { + element.getAsJsonObject().addProperty("placement_active", false); + } + policy.addProperty("mode", "keep_separate"); + policy.addProperty("output_mode", "custom"); + policy.addProperty("review_required", true); + policy.addProperty("status", "review_required"); + GeologyEditorSession session = new GeologyEditorSession(WorldGeologyProfile.fromJson( + root, WorldGeologyProfile.recommended(false))); + + session.setOreSourceOutputMode("orespawn:sulfur|minecraft:overworld", "balanced"); + + GeologyEditorSession.OreSourceGroup group = session.oreSourceGroups().get(0); + assertEquals("keep_separate", group.mode); + assertEquals("custom", group.outputMode); + assertTrue(group.needsReview(), "Keep Original remains available for explicit review"); + assertFalse(group.hasManagedPlacementSource()); + } + @Test void keepOriginalRestoresThePolicySnapshotFromWhenTheEditorOpened() { GeologyEditorSession session = new GeologyEditorSession(profileWithTwoOreOutputs()); diff --git a/src/test/java/zone/moddev/mc/orespawn/client/OreSourcePlacementScreenTest.java b/src/test/java/zone/moddev/mc/orespawn/client/OreSourcePlacementScreenTest.java index 1f5da732..f894bb77 100644 --- a/src/test/java/zone/moddev/mc/orespawn/client/OreSourcePlacementScreenTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/client/OreSourcePlacementScreenTest.java @@ -46,6 +46,12 @@ void keepOriginalAndSingleManagedSourcesRemainReadOnly() { assertFalse(OreSourceGroupSettingsScreen.placementSelectable( group("keep_separate", Arrays.asList(first, second), Collections.emptyMap()), "orespawn:standard")); + assertTrue(group("keep_separate", Collections.singletonList(first), + Collections.emptyMap()).hasManagedPlacementSource()); + OreSourceCandidate outputOnly = candidate("output", "orespawn:standard", + true, false, false); + assertFalse(group("keep_separate", Collections.singletonList(outputOnly), + Collections.emptyMap()).hasManagedPlacementSource()); } @Test From 5b54ba9e8d2818ac6505c0c4d3a20b996527eb22 Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Thu, 17 Sep 2026 23:07:46 +0100 Subject: [PATCH 20/31] Refresh vanilla Ore Source controls --- CHANGELOG.txt | 4 + README.md | 4 + docs/CONFIGURATION.md | 7 ++ docs/PLAYER_GUIDE.md | 4 + .../orespawn/client/ClientProbeTestMod.java | 45 ++++++++--- .../orespawn/client/GeologyEditorSession.java | 55 +++++++++++++ .../client/OreSourceGroupSettingsScreen.java | 14 ++++ .../client/OreSpawnWorldSettingsScreen.java | 5 +- .../resources/assets/orespawn/lang/de_AU.lang | 1 + .../resources/assets/orespawn/lang/de_DE.lang | 1 + .../resources/assets/orespawn/lang/en_CA.lang | 1 + .../resources/assets/orespawn/lang/en_EN.lang | 1 + .../resources/assets/orespawn/lang/en_GB.lang | 1 + .../resources/assets/orespawn/lang/en_PT.lang | 1 + .../resources/assets/orespawn/lang/en_US.lang | 1 + .../resources/assets/orespawn/lang/es_ES.lang | 1 + .../resources/assets/orespawn/lang/es_MX.lang | 1 + .../resources/assets/orespawn/lang/fr_CA.lang | 1 + .../resources/assets/orespawn/lang/fr_FR.lang | 1 + .../resources/assets/orespawn/lang/ja_JP.lang | 1 + .../resources/assets/orespawn/lang/ko_KR.lang | 1 + .../resources/assets/orespawn/lang/pt_BR.lang | 1 + .../resources/assets/orespawn/lang/ru_RU.lang | 1 + .../resources/assets/orespawn/lang/zh_CN.lang | 1 + .../mc/orespawn/LocalizationParityTest.java | 2 +- .../client/GeologyEditorSessionTest.java | 78 +++++++++++++++++++ 26 files changed, 219 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 7c4f8e3a..6204a196 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -40,6 +40,10 @@ Version 4.1.0.110021 * Prevent Balanced, Single or Custom consolidation when a material group has no active OreSpawn-managed placement rule, retaining the review action and Keep Original behaviour instead of entering an unresolved red policy. +* Refresh Ore Sources immediately when Manage Vanilla Ores changes. Native + vanilla rules become usable placement sources while it is enabled; while it + is disabled, attempts to move their Ore Dictionary aliases are rejected with + guidance instead of creating a policy OreSpawn cannot enforce. * Add provider schema 5 material and placement-channel declarations, global schema 8 and world-profile schema 7 while retaining older readable schemas. * Add an extended, binary-compatible ore generation context exposing stable diff --git a/README.md b/README.md index 1a4b88d0..229b314b 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,10 @@ a channel is fixed, selectable, missing, or inactive under Keep Original. Existing worlds start in Keep Original until their owner explicitly changes a group. Third-party generators remain outside OreSpawn's control and are clearly reported rather than disabled. +Native vanilla outputs become controllable placement sources only while +**Manage Vanilla Ores** is enabled. Ore Sources updates immediately when that +option changes, and Group Settings prevents moving a vanilla ore alias while +management is off, with a prompt to enable it first. Important files: diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 7997b660..d118729b 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -258,6 +258,13 @@ as placement owners. The compact help and row tooltips explain whether a rule is fixed, selectable, missing or inactive under Keep Original 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 diff --git a/docs/PLAYER_GUIDE.md b/docs/PLAYER_GUIDE.md index 4f452b27..a82a5c8d 100644 --- a/docs/PLAYER_GUIDE.md +++ b/docs/PLAYER_GUIDE.md @@ -66,6 +66,10 @@ safely instead of breaking world creation. multiple managed sources can be selected. Hover the Placement Rules help or a rule row to see whether it is fixed, selectable, missing, or inactive under Keep Original. + 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 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 b6aaa757..bc4132d7 100644 --- a/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java +++ b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java @@ -372,6 +372,7 @@ private void validateCustomGroupReassignmentLayout(Minecraft minecraft, GuiScree 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"); @@ -415,30 +416,52 @@ private void validateCustomGroupReassignmentLayout(Minecraft minecraft, GuiScree int contentWidth = 406; int expectedDoneX = 10 + contentWidth - OreSourceGroupSettingsScreen.doneButtonWidth(contentWidth); - if (move == null || message == null || message.isEmpty() || done == null + if (move != null || !I18n.format("error.orespawn.ore_source.manage_vanilla_first").equals(message) + || done == null || done.xPosition != expectedDoneX || done.yPosition != 237 || OreSourceGroupSettingsScreen.validationMessageWidth(contentWidth) <= 0) { - throw new IllegalStateException("Alias reassignment message did not own the bottom row: move=" + throw new IllegalStateException("Disabled vanilla management did not block reassignment: move=" + (move != null) + ", message=" + message + ", done=" + (done == null ? "missing" : done.xPosition + "," + done.yPosition)); } if (!originalOwner.equals(customSession.oreDictionaryOwner("oreDiamond"))) { - throw new IllegalStateException("First alias action reassigned oreDiamond without confirmation"); + 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 output-only Precious Stones group"); + 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() + if (customGroup == null || !customGroup.hasManagedPlacementSource() || !customGroup.needsReview() || customGroup.outputCandidates().size() != 2) { - throw new IllegalStateException("Output-only Precious Stones review was not constructed"); + throw new IllegalStateException("Managed Precious Stones review was not constructed"); } OreSourceListScreen outputs = new OreSourceListScreen(parent, customSession); outputs.setWorldAndResolution(minecraft, 426, 265); @@ -446,7 +469,7 @@ private void validateCustomGroupReassignmentLayout(Minecraft minecraft, GuiScree 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("Output-only review did not retain Keep Original and Accept"); + throw new IllegalStateException("Managed vanilla review did not retain Keep Original and Accept"); } mode.press(); GeologyEditorSession.OreSourceGroup after = null; @@ -461,10 +484,10 @@ private void validateCustomGroupReassignmentLayout(Minecraft minecraft, GuiScree } catch (ReflectiveOperationException exception) { throw new IllegalStateException("Could not inspect Ore Sources validation state", exception); } - if (after == null || !"keep_separate".equals(after.mode) || !after.needsReview() - || outputError == null || outputError.isEmpty() - || button(outputs, I18n.format("button.orespawn.ore_source.accept")) == null) { - throw new IllegalStateException("Output-only group entered invalid Balanced mode: error=" + if (after == null || !"consolidated".equals(after.mode) || after.needsAttention() + || 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)); } } 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 74fde7ba..57fcd1bf 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java @@ -95,6 +95,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); } @@ -454,11 +492,28 @@ String oreDictionaryOwner(String alias) { 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); diff --git a/src/main/java/zone/moddev/mc/orespawn/client/OreSourceGroupSettingsScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceGroupSettingsScreen.java index 43fb1fc4..51d017dc 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/OreSourceGroupSettingsScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceGroupSettingsScreen.java @@ -26,6 +26,7 @@ final class OreSourceGroupSettingsScreen extends OreSpawnScreen { private final GeologyEditorSession session; private final String groupKey; private String pendingAliasMove; + private String pendingAliasValue; private String error; private int aliasScroll; private int placementScroll; @@ -73,6 +74,7 @@ protected void init() { if (group.curated) { session.resetOreMaterialGroup(group.material); pendingAliasMove = null; + pendingAliasValue = null; error = null; rebuild(); } else { @@ -103,6 +105,7 @@ protected void init() { syncName(); session.removeOreMaterialAlias(group.material, aliases.get(index)); pendingAliasMove = null; + pendingAliasValue = null; error = null; captureScroll(); rebuild(); @@ -122,6 +125,8 @@ protected void init() { 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 ? "+" @@ -206,8 +211,16 @@ private void addAlias(OreSourceGroup group) { 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)) { + pendingAliasMove = null; + pendingAliasValue = alias; + error = I18n.format("error.orespawn.ore_source.manage_vanilla_first"); + rebuild(); + return; + } if (move != null && !move.equals(pendingAliasMove)) { pendingAliasMove = move; + pendingAliasValue = alias; error = I18n.format("error.orespawn.ore_source.alias_owned", OreSourceListScreen.display(owner)); rebuild(); @@ -218,6 +231,7 @@ private void addAlias(OreSourceGroup group) { return; } pendingAliasMove = null; + pendingAliasValue = null; error = null; rebuild(); } 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 23432817..0e0ff0e5 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnWorldSettingsScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnWorldSettingsScreen.java @@ -294,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/resources/assets/orespawn/lang/de_AU.lang b/src/main/resources/assets/orespawn/lang/de_AU.lang index ebcf4b84..2fe8cc74 100644 --- a/src/main/resources/assets/orespawn/lang/de_AU.lang +++ b/src/main/resources/assets/orespawn/lang/de_AU.lang @@ -443,6 +443,7 @@ tooltip.orespawn.ore_source.reset_group=Stelle den Namen und die Ore-Dictionary- tooltip.orespawn.ore_source.delete_group=Lösche diese benutzerdefinierte Gruppe, ohne erzeugte Chunks neu zu schreiben. error.orespawn.ore_source.alias=Verwende einen exakten Ore-Dictionary-Erznamen wie oreSulfur. error.orespawn.ore_source.alias_owned=%s besitzt diesen Eintrag bereits. Drücke Verschieben, um ihn neu zuzuweisen. +error.orespawn.ore_source.manage_vanilla_first=Aktiviere „Vanilla-Erze verwalten“, bevor du ein natives Vanilla-Erz in eine andere Gruppe verschiebst. error.orespawn.ore_source.defaults_write=Die Standardwerte der Materialgruppen konnten nicht gespeichert werden. screen.orespawn.ore_source.group_settings=Erzquellen-Gruppeneinstellungen label.orespawn.ore_source.groups=Gruppen diff --git a/src/main/resources/assets/orespawn/lang/de_DE.lang b/src/main/resources/assets/orespawn/lang/de_DE.lang index ebcf4b84..2fe8cc74 100644 --- a/src/main/resources/assets/orespawn/lang/de_DE.lang +++ b/src/main/resources/assets/orespawn/lang/de_DE.lang @@ -443,6 +443,7 @@ tooltip.orespawn.ore_source.reset_group=Stelle den Namen und die Ore-Dictionary- tooltip.orespawn.ore_source.delete_group=Lösche diese benutzerdefinierte Gruppe, ohne erzeugte Chunks neu zu schreiben. error.orespawn.ore_source.alias=Verwende einen exakten Ore-Dictionary-Erznamen wie oreSulfur. error.orespawn.ore_source.alias_owned=%s besitzt diesen Eintrag bereits. Drücke Verschieben, um ihn neu zuzuweisen. +error.orespawn.ore_source.manage_vanilla_first=Aktiviere „Vanilla-Erze verwalten“, bevor du ein natives Vanilla-Erz in eine andere Gruppe verschiebst. error.orespawn.ore_source.defaults_write=Die Standardwerte der Materialgruppen konnten nicht gespeichert werden. screen.orespawn.ore_source.group_settings=Erzquellen-Gruppeneinstellungen label.orespawn.ore_source.groups=Gruppen diff --git a/src/main/resources/assets/orespawn/lang/en_CA.lang b/src/main/resources/assets/orespawn/lang/en_CA.lang index bcf251be..59c08051 100644 --- a/src/main/resources/assets/orespawn/lang/en_CA.lang +++ b/src/main/resources/assets/orespawn/lang/en_CA.lang @@ -443,6 +443,7 @@ tooltip.orespawn.ore_source.reset_group=Restore this curated group name and Ore tooltip.orespawn.ore_source.delete_group=Delete this custom group without rewriting generated chunks. error.orespawn.ore_source.alias=Use an exact Ore Dictionary ore name such as oreSulfur. error.orespawn.ore_source.alias_owned=%s already owns this entry. Press Move to reassign it. +error.orespawn.ore_source.manage_vanilla_first=Enable Manage Vanilla Ores before moving a native vanilla ore into another group. error.orespawn.ore_source.defaults_write=Could not save material-group defaults. screen.orespawn.ore_source.group_settings=Ore Source Group Settings label.orespawn.ore_source.groups=Groups diff --git a/src/main/resources/assets/orespawn/lang/en_EN.lang b/src/main/resources/assets/orespawn/lang/en_EN.lang index bcf251be..59c08051 100644 --- a/src/main/resources/assets/orespawn/lang/en_EN.lang +++ b/src/main/resources/assets/orespawn/lang/en_EN.lang @@ -443,6 +443,7 @@ tooltip.orespawn.ore_source.reset_group=Restore this curated group name and Ore tooltip.orespawn.ore_source.delete_group=Delete this custom group without rewriting generated chunks. error.orespawn.ore_source.alias=Use an exact Ore Dictionary ore name such as oreSulfur. error.orespawn.ore_source.alias_owned=%s already owns this entry. Press Move to reassign it. +error.orespawn.ore_source.manage_vanilla_first=Enable Manage Vanilla Ores before moving a native vanilla ore into another group. error.orespawn.ore_source.defaults_write=Could not save material-group defaults. screen.orespawn.ore_source.group_settings=Ore Source Group Settings label.orespawn.ore_source.groups=Groups diff --git a/src/main/resources/assets/orespawn/lang/en_GB.lang b/src/main/resources/assets/orespawn/lang/en_GB.lang index bcf251be..59c08051 100644 --- a/src/main/resources/assets/orespawn/lang/en_GB.lang +++ b/src/main/resources/assets/orespawn/lang/en_GB.lang @@ -443,6 +443,7 @@ tooltip.orespawn.ore_source.reset_group=Restore this curated group name and Ore tooltip.orespawn.ore_source.delete_group=Delete this custom group without rewriting generated chunks. error.orespawn.ore_source.alias=Use an exact Ore Dictionary ore name such as oreSulfur. error.orespawn.ore_source.alias_owned=%s already owns this entry. Press Move to reassign it. +error.orespawn.ore_source.manage_vanilla_first=Enable Manage Vanilla Ores before moving a native vanilla ore into another group. error.orespawn.ore_source.defaults_write=Could not save material-group defaults. screen.orespawn.ore_source.group_settings=Ore Source Group Settings label.orespawn.ore_source.groups=Groups diff --git a/src/main/resources/assets/orespawn/lang/en_PT.lang b/src/main/resources/assets/orespawn/lang/en_PT.lang index bcf251be..59c08051 100644 --- a/src/main/resources/assets/orespawn/lang/en_PT.lang +++ b/src/main/resources/assets/orespawn/lang/en_PT.lang @@ -443,6 +443,7 @@ tooltip.orespawn.ore_source.reset_group=Restore this curated group name and Ore tooltip.orespawn.ore_source.delete_group=Delete this custom group without rewriting generated chunks. error.orespawn.ore_source.alias=Use an exact Ore Dictionary ore name such as oreSulfur. error.orespawn.ore_source.alias_owned=%s already owns this entry. Press Move to reassign it. +error.orespawn.ore_source.manage_vanilla_first=Enable Manage Vanilla Ores before moving a native vanilla ore into another group. error.orespawn.ore_source.defaults_write=Could not save material-group defaults. screen.orespawn.ore_source.group_settings=Ore Source Group Settings label.orespawn.ore_source.groups=Groups diff --git a/src/main/resources/assets/orespawn/lang/en_US.lang b/src/main/resources/assets/orespawn/lang/en_US.lang index 08b298df..c409c68e 100644 --- a/src/main/resources/assets/orespawn/lang/en_US.lang +++ b/src/main/resources/assets/orespawn/lang/en_US.lang @@ -443,6 +443,7 @@ tooltip.orespawn.ore_source.reset_group=Restore this curated group name and Ore tooltip.orespawn.ore_source.delete_group=Delete this custom group without rewriting generated chunks. error.orespawn.ore_source.alias=Use an exact Ore Dictionary ore name such as oreSulfur. error.orespawn.ore_source.alias_owned=%s already owns this entry. Press Move to reassign it. +error.orespawn.ore_source.manage_vanilla_first=Enable Manage Vanilla Ores before moving a native vanilla ore into another group. error.orespawn.ore_source.defaults_write=Could not save material-group defaults. screen.orespawn.ore_source.group_settings=Ore Source Group Settings label.orespawn.ore_source.groups=Groups diff --git a/src/main/resources/assets/orespawn/lang/es_ES.lang b/src/main/resources/assets/orespawn/lang/es_ES.lang index e8f8dac5..bf3350ba 100644 --- a/src/main/resources/assets/orespawn/lang/es_ES.lang +++ b/src/main/resources/assets/orespawn/lang/es_ES.lang @@ -443,6 +443,7 @@ tooltip.orespawn.ore_source.reset_group=Restaura el nombre y las entradas del di tooltip.orespawn.ore_source.delete_group=Elimina este grupo personalizado sin reescribir los chunks generados. error.orespawn.ore_source.alias=Usa un nombre exacto del diccionario de minerales, como oreSulfur. error.orespawn.ore_source.alias_owned=%s ya posee esta entrada. Pulsa Mover para reasignarla. +error.orespawn.ore_source.manage_vanilla_first=Activa «Gestionar minerales de Vanilla» antes de mover un mineral nativo de Vanilla a otro grupo. error.orespawn.ore_source.defaults_write=No se pudieron guardar los valores predeterminados de los grupos de materiales. screen.orespawn.ore_source.group_settings=Configuración del grupo de fuentes de mena label.orespawn.ore_source.groups=Grupos diff --git a/src/main/resources/assets/orespawn/lang/es_MX.lang b/src/main/resources/assets/orespawn/lang/es_MX.lang index e8f8dac5..bf3350ba 100644 --- a/src/main/resources/assets/orespawn/lang/es_MX.lang +++ b/src/main/resources/assets/orespawn/lang/es_MX.lang @@ -443,6 +443,7 @@ tooltip.orespawn.ore_source.reset_group=Restaura el nombre y las entradas del di tooltip.orespawn.ore_source.delete_group=Elimina este grupo personalizado sin reescribir los chunks generados. error.orespawn.ore_source.alias=Usa un nombre exacto del diccionario de minerales, como oreSulfur. error.orespawn.ore_source.alias_owned=%s ya posee esta entrada. Pulsa Mover para reasignarla. +error.orespawn.ore_source.manage_vanilla_first=Activa «Gestionar minerales de Vanilla» antes de mover un mineral nativo de Vanilla a otro grupo. error.orespawn.ore_source.defaults_write=No se pudieron guardar los valores predeterminados de los grupos de materiales. screen.orespawn.ore_source.group_settings=Configuración del grupo de fuentes de mena label.orespawn.ore_source.groups=Grupos diff --git a/src/main/resources/assets/orespawn/lang/fr_CA.lang b/src/main/resources/assets/orespawn/lang/fr_CA.lang index 7123f3b6..adf70b44 100644 --- a/src/main/resources/assets/orespawn/lang/fr_CA.lang +++ b/src/main/resources/assets/orespawn/lang/fr_CA.lang @@ -443,6 +443,7 @@ tooltip.orespawn.ore_source.reset_group=Restaure le nom et les entrées du dicti tooltip.orespawn.ore_source.delete_group=Supprime ce groupe personnalisé sans réécrire les chunks générés. error.orespawn.ore_source.alias=Utilisez un nom exact du dictionnaire de minerais, comme oreSulfur. error.orespawn.ore_source.alias_owned=%s possède déjà cette entrée. Appuyez sur Déplacer pour la réaffecter. +error.orespawn.ore_source.manage_vanilla_first=Activez « Gérer les minerais vanilla » avant de déplacer un minerai vanilla natif vers un autre groupe. error.orespawn.ore_source.defaults_write=Impossible d'enregistrer les valeurs par défaut des groupes de matériaux. screen.orespawn.ore_source.group_settings=Paramètres du groupe de sources de minerai label.orespawn.ore_source.groups=Groupes diff --git a/src/main/resources/assets/orespawn/lang/fr_FR.lang b/src/main/resources/assets/orespawn/lang/fr_FR.lang index 7123f3b6..adf70b44 100644 --- a/src/main/resources/assets/orespawn/lang/fr_FR.lang +++ b/src/main/resources/assets/orespawn/lang/fr_FR.lang @@ -443,6 +443,7 @@ tooltip.orespawn.ore_source.reset_group=Restaure le nom et les entrées du dicti tooltip.orespawn.ore_source.delete_group=Supprime ce groupe personnalisé sans réécrire les chunks générés. error.orespawn.ore_source.alias=Utilisez un nom exact du dictionnaire de minerais, comme oreSulfur. error.orespawn.ore_source.alias_owned=%s possède déjà cette entrée. Appuyez sur Déplacer pour la réaffecter. +error.orespawn.ore_source.manage_vanilla_first=Activez « Gérer les minerais vanilla » avant de déplacer un minerai vanilla natif vers un autre groupe. error.orespawn.ore_source.defaults_write=Impossible d'enregistrer les valeurs par défaut des groupes de matériaux. screen.orespawn.ore_source.group_settings=Paramètres du groupe de sources de minerai label.orespawn.ore_source.groups=Groupes diff --git a/src/main/resources/assets/orespawn/lang/ja_JP.lang b/src/main/resources/assets/orespawn/lang/ja_JP.lang index dd4ab0d3..6c76d356 100644 --- a/src/main/resources/assets/orespawn/lang/ja_JP.lang +++ b/src/main/resources/assets/orespawn/lang/ja_JP.lang @@ -443,6 +443,7 @@ tooltip.orespawn.ore_source.reset_group=この既定グループの名前と鉱 tooltip.orespawn.ore_source.delete_group=生成済みチャンクを書き換えずに、このカスタムグループを削除します。 error.orespawn.ore_source.alias=oreSulfur のような正確な鉱石辞書名を使用してください。 error.orespawn.ore_source.alias_owned=%s がこのエントリを既に所有しています。再割り当てするには「移動」を押してください。 +error.orespawn.ore_source.manage_vanilla_first=バニラ鉱石を別のグループへ移動する前に、「バニラ鉱石を管理」を有効にしてください。 error.orespawn.ore_source.defaults_write=素材グループの既定値を保存できませんでした。 screen.orespawn.ore_source.group_settings=鉱石ソースグループ設定 label.orespawn.ore_source.groups=グループ diff --git a/src/main/resources/assets/orespawn/lang/ko_KR.lang b/src/main/resources/assets/orespawn/lang/ko_KR.lang index 99f26d93..3d278508 100644 --- a/src/main/resources/assets/orespawn/lang/ko_KR.lang +++ b/src/main/resources/assets/orespawn/lang/ko_KR.lang @@ -443,6 +443,7 @@ tooltip.orespawn.ore_source.reset_group=이 선별 그룹의 이름과 광석 tooltip.orespawn.ore_source.delete_group=생성된 청크를 다시 쓰지 않고 이 사용자 지정 그룹을 삭제합니다. error.orespawn.ore_source.alias=oreSulfur 같은 정확한 광석 사전 이름을 사용하세요. error.orespawn.ore_source.alias_owned=%s에서 이미 이 항목을 소유합니다. 이동을 눌러 재할당하세요. +error.orespawn.ore_source.manage_vanilla_first=바닐라 광석을 다른 그룹으로 이동하기 전에 바닐라 광석 관리를 활성화하세요. error.orespawn.ore_source.defaults_write=재료 그룹 기본값을 저장하지 못했습니다. screen.orespawn.ore_source.group_settings=광석 소스 그룹 설정 label.orespawn.ore_source.groups=그룹 diff --git a/src/main/resources/assets/orespawn/lang/pt_BR.lang b/src/main/resources/assets/orespawn/lang/pt_BR.lang index 15401dbc..363d0064 100644 --- a/src/main/resources/assets/orespawn/lang/pt_BR.lang +++ b/src/main/resources/assets/orespawn/lang/pt_BR.lang @@ -443,6 +443,7 @@ tooltip.orespawn.ore_source.reset_group=Restaura o nome e as entradas do dicion tooltip.orespawn.ore_source.delete_group=Exclui este grupo personalizado sem reescrever os chunks gerados. error.orespawn.ore_source.alias=Use um nome exato do dicionário de minérios, como oreSulfur. error.orespawn.ore_source.alias_owned=%s já possui esta entrada. Pressione Mover para transferi-la. +error.orespawn.ore_source.manage_vanilla_first=Ative Gerenciar minérios vanilla antes de mover um minério vanilla nativo para outro grupo. error.orespawn.ore_source.defaults_write=Não foi possível salvar os padrões dos grupos de materiais. screen.orespawn.ore_source.group_settings=Configurações do grupo de fontes de minério label.orespawn.ore_source.groups=Grupos diff --git a/src/main/resources/assets/orespawn/lang/ru_RU.lang b/src/main/resources/assets/orespawn/lang/ru_RU.lang index ca5e3cb2..d52c9f00 100644 --- a/src/main/resources/assets/orespawn/lang/ru_RU.lang +++ b/src/main/resources/assets/orespawn/lang/ru_RU.lang @@ -443,6 +443,7 @@ tooltip.orespawn.ore_source.reset_group=Восстанавливает назв tooltip.orespawn.ore_source.delete_group=Удаляет эту настраиваемую группу без перезаписи созданных чанков. error.orespawn.ore_source.alias=Используйте точное имя из словаря руд, например oreSulfur. error.orespawn.ore_source.alias_owned=%s уже владеет этой записью. Нажмите Переместить для переназначения. +error.orespawn.ore_source.manage_vanilla_first=Включите управление ванильными рудами, прежде чем перемещать ванильную руду в другую группу. error.orespawn.ore_source.defaults_write=Не удалось сохранить значения групп материалов по умолчанию. screen.orespawn.ore_source.group_settings=Настройки группы источников руды label.orespawn.ore_source.groups=Группы diff --git a/src/main/resources/assets/orespawn/lang/zh_CN.lang b/src/main/resources/assets/orespawn/lang/zh_CN.lang index de481a5e..054998f2 100644 --- a/src/main/resources/assets/orespawn/lang/zh_CN.lang +++ b/src/main/resources/assets/orespawn/lang/zh_CN.lang @@ -443,6 +443,7 @@ tooltip.orespawn.ore_source.reset_group=恢复此预设分组的名称和矿物 tooltip.orespawn.ore_source.delete_group=删除此自定义分组,不重写已生成的区块。 error.orespawn.ore_source.alias=请使用准确的矿物词典名称,例如 oreSulfur。 error.orespawn.ore_source.alias_owned=%s 已拥有此条目。按“移动”可重新分配。 +error.orespawn.ore_source.manage_vanilla_first=将原版矿石移入其他分组前,请先启用“管理原版矿石”。 error.orespawn.ore_source.defaults_write=无法保存材料分组默认值。 screen.orespawn.ore_source.group_settings=矿石来源分组设置 label.orespawn.ore_source.groups=分组 diff --git a/src/test/java/zone/moddev/mc/orespawn/LocalizationParityTest.java b/src/test/java/zone/moddev/mc/orespawn/LocalizationParityTest.java index f8aceb07..d342214c 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(463, englishKeys.size(), + assertEquals(464, 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/client/GeologyEditorSessionTest.java b/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java index 9aeb5d91..fcbe580c 100644 --- a/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java @@ -16,12 +16,22 @@ import com.google.gson.JsonPrimitive; import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfile; +import net.minecraft.init.Blocks; +import net.minecraft.item.ItemStack; +import net.minecraftforge.oredict.OreDictionary; + import zone.moddev.mc.orespawn.test.Forge12TestBootstrap; class GeologyEditorSessionTest { @BeforeAll static void bootstrapMinecraft() { Forge12TestBootstrap.registerVanilla(); + ItemStack diamond = new ItemStack(Blocks.DIAMOND_ORE); + boolean registered = false; + for (int id : OreDictionary.getOreIDs(diamond)) { + registered |= "oreDiamond".equals(OreDictionary.getOreName(id)); + } + if (!registered) OreDictionary.registerOre("oreDiamond", diamond); } @Test @@ -268,6 +278,44 @@ void customMaterialGroupsKeepStableIdsAndRequireConfirmedAliasMoves() { assertEquals("orespawn:sulfur", session.oreDictionaryOwner("oreSulfur")); } + @Test + void manageVanillaToggleRebakesNativePlacementSourcesInsideTheEditor() { + GeologyEditorSession session = new GeologyEditorSession(profileWithNativeDiamondOre()); + GeologyEditorSession.OreSourceGroup disabled = group(session, "orespawn:diamond"); + assertFalse(session.profile().manageVanillaOres()); + assertFalse(disabled.hasManagedPlacementSource()); + assertTrue(disabled.placements.isEmpty()); + + session.setManageVanillaOres(true); + GeologyEditorSession.OreSourceGroup enabled = group(session, "orespawn:diamond"); + assertTrue(session.profile().manageVanillaOres()); + assertTrue(enabled.hasManagedPlacementSource()); + assertEquals("minecraft:diamond_ore", enabled.placements.get("orespawn:standard")); + + session.setManageVanillaOres(false); + GeologyEditorSession.OreSourceGroup disabledAgain = group(session, "orespawn:diamond"); + assertFalse(disabledAgain.hasManagedPlacementSource()); + assertTrue(disabledAgain.placements.isEmpty()); + } + + @Test + void vanillaAliasMoveRequiresVanillaManagementAndNeverChangesOwnershipWhenBlocked() { + GeologyEditorSession session = new GeologyEditorSession(profileWithNativeDiamondOre()); + String customKey = session.addOreMaterialGroup(); + String customMaterial = customKey.substring(0, customKey.indexOf('|')); + + assertEquals("orespawn:diamond", session.oreDictionaryOwner("oreDiamond")); + assertTrue(session.vanillaOreManagementRequiredForAliasMove(customMaterial, "oreDiamond")); + assertFalse(session.addOreMaterialAlias(customMaterial, "oreDiamond", true)); + assertEquals("orespawn:diamond", session.oreDictionaryOwner("oreDiamond")); + + session.setManageVanillaOres(true); + assertFalse(session.vanillaOreManagementRequiredForAliasMove(customMaterial, "oreDiamond")); + assertTrue(session.addOreMaterialAlias(customMaterial, "oreDiamond", true)); + assertEquals(customMaterial, session.oreDictionaryOwner("oreDiamond")); + assertTrue(group(session, customMaterial).hasManagedPlacementSource()); + } + @Test void balancedSingleAndCustomModesKeepOneExplicitOutputPolicy() { GeologyEditorSession session = new GeologyEditorSession(profileWithTwoOreOutputs()); @@ -443,6 +491,36 @@ private static WorldGeologyProfile profileWithTwoOreOutputs() { return WorldGeologyProfile.fromJson(root, WorldGeologyProfile.recommended(false)); } + private static WorldGeologyProfile profileWithNativeDiamondOre() { + WorldGeologyProfile recommended = WorldGeologyProfile.recommended(false); + JsonObject root = recommended.rootCopy(); + root.addProperty("manage_vanilla_ores", false); + JsonObject ore = new JsonObject(); + ore.addProperty("enabled", true); + ore.addProperty("source_mod", "minecraft"); + ore.addProperty("native_generation", true); + ore.addProperty("block", "minecraft:diamond_ore"); + JsonObject rule = new JsonObject(); + rule.addProperty("enabled", true); + rule.addProperty("pattern", "vein"); + JsonObject dimensions = new JsonObject(); + dimensions.add("minecraft:overworld", rule); + ore.add("dimensions", dimensions); + JsonObject ores = new JsonObject(); + ores.add("minecraft:diamond_ore", ore); + root.add("ores", ores); + root.add("ore_source_policies", new JsonObject()); + return WorldGeologyProfile.fromGlobalConfig(root, + recommended.geologyMode(), recommended.placeFluidDeposits()); + } + + private static GeologyEditorSession.OreSourceGroup group( + GeologyEditorSession session, String material) { + return session.oreSourceGroups().stream() + .filter(group -> material.equals(group.material)) + .findFirst().orElseThrow(() -> new AssertionError("Missing group " + material)); + } + private static JsonObject candidate(String sourceId, String owner, String registryId, String channel, boolean reviewRequired) { JsonObject candidate = new JsonObject(); From c3ec17ed8d32a84ffcffb5d961da5202de169e0d Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Fri, 18 Sep 2026 12:21:05 +0100 Subject: [PATCH 21/31] Repair custom ore source group editing --- CHANGELOG.txt | 7 + README.md | 10 +- docs/CONFIGURATION.md | 16 +- docs/PLAYER_GUIDE.md | 9 +- .../orespawn/client/ClientProbeTestMod.java | 47 ++++- .../orespawn/client/GeologyEditorSession.java | 180 +++++++++++++++--- .../client/OreSourceGroupSettingsScreen.java | 77 ++++++-- .../resources/assets/orespawn/lang/de_AU.lang | 7 +- .../resources/assets/orespawn/lang/de_DE.lang | 7 +- .../resources/assets/orespawn/lang/en_CA.lang | 7 +- .../resources/assets/orespawn/lang/en_EN.lang | 7 +- .../resources/assets/orespawn/lang/en_GB.lang | 7 +- .../resources/assets/orespawn/lang/en_PT.lang | 7 +- .../resources/assets/orespawn/lang/en_US.lang | 7 +- .../resources/assets/orespawn/lang/es_ES.lang | 7 +- .../resources/assets/orespawn/lang/es_MX.lang | 7 +- .../resources/assets/orespawn/lang/fr_CA.lang | 7 +- .../resources/assets/orespawn/lang/fr_FR.lang | 7 +- .../resources/assets/orespawn/lang/ja_JP.lang | 7 +- .../resources/assets/orespawn/lang/ko_KR.lang | 7 +- .../resources/assets/orespawn/lang/pt_BR.lang | 7 +- .../resources/assets/orespawn/lang/ru_RU.lang | 7 +- .../resources/assets/orespawn/lang/zh_CN.lang | 7 +- .../mc/orespawn/LocalizationParityTest.java | 2 +- .../client/GeologyEditorSessionTest.java | 128 ++++++++++++- .../client/OreSourcePlacementScreenTest.java | 29 +++ .../client/OreSpawnScreenLayoutTest.java | 7 + 27 files changed, 546 insertions(+), 78 deletions(-) diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 6204a196..b17aedcb 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -44,6 +44,13 @@ Version 4.1.0.110021 vanilla rules become usable placement sources while it is enabled; while it is disabled, attempts to move their Ore Dictionary aliases are rejected with guidance instead of creating a policy OreSpawn cannot enforce. +* Reconcile output candidates, placement choices and review state immediately + after every group edit. Keep Original now exposes every moved candidate, + Accept clears only the edited group's review, and placement rows report their + available choice count while remaining inactive until consolidation. +* Restrict Delete to empty custom groups. Populated custom groups use a + confirmed Dissolve Group action that restores every alias to its inferred or + curated group; discovered groups remain non-deletable facts. * Add provider schema 5 material and placement-channel declarations, global schema 8 and world-profile schema 7 while retaining older readable schemas. * Add an extended, binary-compatible ore generation context exposing stable diff --git a/README.md b/README.md index 229b314b..67de6ddf 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,10 @@ one-output entries are hidden by default and available through **Show All**. Red groups need attention, yellow groups have a saved rule and green entries need no action. Each group's cog opens its friendly name, exact Ore Dictionary aliases and clearly labelled Placement Rules. Outputs choose the ore block; -Placement Rules choose the frequency, +empty custom groups can be deleted, while populated custom groups use a +confirmed **Dissolve Group** action that returns every alias to its inferred or +curated group. Automatically discovered groups are factual and cannot be +deleted. Placement Rules choose the frequency, shape, depth and host restrictions for each managed channel. New worlds balance all eligible outputs in reviewed MMD conflicts while running one placement budget, so sulfur/sulphur providers do not multiply abundance. **Single** @@ -73,8 +76,9 @@ chooses one output, **Custom** allows a weighted subset, and **Keep Original** preserves every active rule and restores the selections that were present when the editor was opened. A red Keep Original group exposes **Accept** so its unchanged policy can be marked reviewed; this remains pending until the main -editor's **Done** is used. The Placement Rules help and row tooltips state whether -a channel is fixed, selectable, missing, or inactive under Keep Original. +editor's **Done** is used. A Placement Rules row reports how many managed +sources are available, can cycle among them only in a consolidated mode, and +its help explains that the choice is inactive under Keep Original. Existing worlds start in Keep Original until their owner explicitly changes a group. Third-party generators remain outside OreSpawn's control and are clearly reported rather than disabled. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index d118729b..17558512 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -225,8 +225,12 @@ Each `ore_material_groups` entry has a persistent registry-style group ID, a 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 and delete custom groups, confirm moving an alias -from another group, or reset a curated group to its shipped defaults. +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`. @@ -254,9 +258,11 @@ frequency, shape, depth and host restrictions for each channel. `orespawn:standa 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. The compact help and row tooltips explain whether a rule is -fixed, selectable, missing or inactive under Keep Original without reducing the -alias-list height. +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 diff --git a/docs/PLAYER_GUIDE.md b/docs/PLAYER_GUIDE.md index a82a5c8d..6041cb76 100644 --- a/docs/PLAYER_GUIDE.md +++ b/docs/PLAYER_GUIDE.md @@ -63,9 +63,16 @@ safely instead of breaking world creation. 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; - multiple managed sources can be selected. Hover the Placement Rules help or + 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 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 bc4132d7..4ba8a455 100644 --- a/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java +++ b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java @@ -161,8 +161,10 @@ public void onClientTick(TickEvent.ClientTickEvent event) { } break; case 7: - if (!minecraft.isIntegratedServerRunning() && stateTicks >= 20) { - if (minecraft.world != null) minecraft.loadWorld(null); + 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); @@ -176,8 +178,10 @@ public void onClientTick(TickEvent.ClientTickEvent event) { } break; case 9: - if (!minecraft.isIntegratedServerRunning() && stateTicks >= 20) { - if (minecraft.world != null) minecraft.loadWorld(null); + if (stateTicks >= 20) { + if (minecraft.world != null || minecraft.isIntegratedServerRunning()) { + minecraft.loadWorld(null); + } writeMarker(); minecraft.shutdown(); nextState(10); @@ -364,7 +368,7 @@ private void validateOreSourcesSingleScreen(GuiScreen parent, GeologyEditorSessi } validateKeepOriginalAcceptance(minecraft, parent, session, sulfurGroup.key); validateStaleExternalClassification(minecraft, parent, session, sulfurGroup.key); - validateCustomGroupReassignmentLayout(minecraft, screen, session); + validateCustomGroupReassignmentLayout(minecraft, parent, session); oreSourcesLayoutValidated = true; } @@ -460,7 +464,10 @@ private void validateCustomGroupReassignmentLayout(Minecraft minecraft, GuiScree if (customKey.equals(group.key)) customGroup = group; } if (customGroup == null || !customGroup.hasManagedPlacementSource() - || !customGroup.needsReview() || customGroup.outputCandidates().size() != 2) { + || !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); @@ -471,6 +478,11 @@ private void validateCustomGroupReassignmentLayout(Minecraft minecraft, GuiScree 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()) { @@ -485,11 +497,34 @@ private void validateCustomGroupReassignmentLayout(Minecraft minecraft, GuiScree 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) { 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 57fcd1bf..4310eca3 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java @@ -544,17 +544,49 @@ void resetOreMaterialGroup(String material) { rebuildOreSourcePolicies(); } - void deleteOreMaterialGroup(String material) { - if (!validResource(material) || "orespawn:sulfur".equals(material) - || "orespawn:aluminum".equals(material)) return; - section("ore_material_groups").remove(material); + 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); } } - rebuildOreSourcePolicies(); } boolean oreMaterialGroupsChanged() { @@ -573,20 +605,22 @@ private void setOreMaterialGroup(String material, String displayName, List> 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 = string(candidate, "source_id", "") + '|' - + string(candidate, "placement_channel", "orespawn:standard") + '|' - + string(candidate, "domain", string(policy, "domain", "")); + 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"); @@ -617,28 +651,108 @@ private void rebuildOreSourcePolicies() { if (!policy.has("mode")) policy.addProperty("mode", "keep_separate"); if (!policy.has("output_mode")) policy.addProperty("output_mode", "custom"); JsonArray array = new JsonArray(); - for (JsonObject candidate : entry.getValue()) array.add(candidate); + Set currentMembers = new LinkedHashSet<>(); + for (JsonObject candidate : entry.getValue()) { + array.add(candidate); + currentMembers.add(candidateIdentity(candidate, domain)); + } policy.add("candidates", array); - JsonObject outputs = objectEntry(policy, "outputs"); - if (outputs.entrySet().isEmpty()) for (JsonObject candidate : outputCandidateJson(policy)) { - if (!bool(candidate, "external", false)) { - outputs.addProperty(string(candidate, "source_id", ""), 1.0D); + 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); } } - JsonObject placements = objectEntry(policy, "placement_sources"); - for (JsonObject candidate : entry.getValue()) { - if (bool(candidate, "placement_active", false) && !bool(candidate, "external", false)) { - String channel = string(candidate, "placement_channel", "orespawn:standard"); - if (!placements.has(channel)) placements.addProperty(channel, - string(candidate, "source_id", "")); + } else if ("single".equals(outputMode)) { + String selected = ""; + for (String sourceId : JsonCopies.keys(outputs)) { + if (candidateIds.contains(sourceId)) { + selected = sourceId; + break; } } - policy.addProperty("review_required", outputCandidateJson(policy).size() > 1); - refreshOreSourceStatus(policy); - policies.add(entry.getKey(), policy); + 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) { @@ -652,6 +766,16 @@ else if (validOreDictionaryName(alias)) { : 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"); @@ -935,6 +1059,18 @@ boolean isRoutineSingleSource() { && 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<>(); diff --git a/src/main/java/zone/moddev/mc/orespawn/client/OreSourceGroupSettingsScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceGroupSettingsScreen.java index 51d017dc..5f00dc9b 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/OreSourceGroupSettingsScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceGroupSettingsScreen.java @@ -27,6 +27,7 @@ final class OreSourceGroupSettingsScreen extends OreSpawnScreen { private final String groupKey; private String pendingAliasMove; private String pendingAliasValue; + private boolean pendingDissolve; private String error; private int aliasScroll; private int placementScroll; @@ -58,33 +59,31 @@ protected void init() { } contentWidth = Math.max(240, Math.min(600, width - (MARGIN * 2))); contentX = (width - contentWidth) / 2; - int actionWidth = Math.min(78, Math.max(55, contentWidth / 4)); + 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 - 5, 20, + contentWidth - nameLabelWidth - actionWidth - (hasGroupAction ? 5 : 0), 20, new TextComponentTranslation("option.orespawn.ore_source.group_name"))); nameField.setMaxLength(64); nameField.setValue(group.displayName); - Button groupAction = addButton(OreSpawnScreenLayout.button(this, font, - contentX + contentWidth - actionWidth, NAME_TOP, actionWidth, 20, - new TextComponentTranslation(group.curated ? "button.orespawn.reset" - : "button.orespawn.ore_source.delete_group"), button -> { - if (group.curated) { - session.resetOreMaterialGroup(group.material); - pendingAliasMove = null; - pendingAliasValue = null; - error = null; - rebuild(); - } else { - session.deleteOreMaterialGroup(group.material); - minecraft.displayGuiScreen(parent); - } - })); - OreSpawnScreenLayout.explain(this, groupAction, group.curated - ? "tooltip.orespawn.ore_source.reset_group" - : "tooltip.orespawn.ore_source.delete_group"); + 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; @@ -106,6 +105,7 @@ protected void init() { session.removeOreMaterialAlias(group.material, aliases.get(index)); pendingAliasMove = null; pendingAliasValue = null; + pendingDissolve = false; error = null; captureScroll(); rebuild(); @@ -157,7 +157,8 @@ protected void init() { String source = choice < 0 ? I18n.format("label.orespawn.ore_source.missing") : OreSourceListScreen.candidateLabel(choices.get(choice)); - return channelLabel(channel) + (placementSelectable(group, channel) ? " > " : " = ") + source; + return channelLabel(channel) + (placementSelectable(group, channel) ? " > " : " = ") + + placementSourceLabel(source, choices.size()); } @Override protected boolean rowEnabled(int index) { String channel = channels.get(index); @@ -206,12 +207,38 @@ protected void init() { 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"); @@ -219,6 +246,7 @@ private void addAlias(OreSourceGroup group) { return; } if (move != null && !move.equals(pendingAliasMove)) { + pendingDissolve = false; pendingAliasMove = move; pendingAliasValue = alias; error = I18n.format("error.orespawn.ore_source.alias_owned", @@ -232,6 +260,7 @@ private void addAlias(OreSourceGroup group) { } pendingAliasMove = null; pendingAliasValue = null; + pendingDissolve = false; error = null; rebuild(); } @@ -285,6 +314,12 @@ static String channelLabel(String channel) { : 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; diff --git a/src/main/resources/assets/orespawn/lang/de_AU.lang b/src/main/resources/assets/orespawn/lang/de_AU.lang index 2fe8cc74..39d6db89 100644 --- a/src/main/resources/assets/orespawn/lang/de_AU.lang +++ b/src/main/resources/assets/orespawn/lang/de_AU.lang @@ -422,6 +422,8 @@ button.orespawn.ore_source.add_group=Gruppe hinzufügen button.orespawn.ore_source.accept=Akzeptieren tooltip.orespawn.ore_source.accept=Behält „Original beibehalten“ unverändert bei und markiert die Gruppe als geprüft. Gespeichert wird dies erst mit „Fertig“ im Haupteditor. button.orespawn.ore_source.delete_group=Löschen +button.orespawn.ore_source.dissolve_group=Gruppe auflösen +button.orespawn.ore_source.confirm_dissolve=Auflösen bestätigen button.orespawn.ore_source.move=Verschieben button.orespawn.ore_source.advanced=Erweitert... button.orespawn.ore_source.hide_advanced=Erweitert ausblenden @@ -440,7 +442,9 @@ tooltip.orespawn.ore_source.output_only=Diese Anbieterausgabe ist verfügbar, ab tooltip.orespawn.ore_source.output_mode=Original beibehalten erhält jede Regel. Ausgewogen verteilt gleichmäßig, Einzeln wählt einen Block und Benutzerdefiniert erlaubt gewichtete Auswahlen. tooltip.orespawn.ore_source.alias=Füge einen exakten Ore-Dictionary-Erznamen hinzu. Drücke erneut Verschieben, um die Neuzuordnung aus einer anderen Gruppe zu bestätigen. tooltip.orespawn.ore_source.reset_group=Stelle den Namen und die Ore-Dictionary-Einträge dieser kuratierten Gruppe wieder her. -tooltip.orespawn.ore_source.delete_group=Lösche diese benutzerdefinierte Gruppe, ohne erzeugte Chunks neu zu schreiben. +tooltip.orespawn.ore_source.delete_group=Lösche diese leere benutzerdefinierte Gruppe, ohne erzeugte Chunks neu zu schreiben. +tooltip.orespawn.ore_source.dissolve_group=Ordnet jeden Eintrag wieder seiner ermittelten oder kuratierten Gruppe zu. Zum Bestätigen zweimal drücken. +error.orespawn.ore_source.confirm_dissolve=Drücke Auflösen bestätigen, um jeden Erzlexikon-Eintrag seiner ursprünglichen Gruppe zuzuordnen. error.orespawn.ore_source.alias=Verwende einen exakten Ore-Dictionary-Erznamen wie oreSulfur. error.orespawn.ore_source.alias_owned=%s besitzt diesen Eintrag bereits. Drücke Verschieben, um ihn neu zuzuweisen. error.orespawn.ore_source.manage_vanilla_first=Aktiviere „Vanilla-Erze verwalten“, bevor du ein natives Vanilla-Erz in eine andere Gruppe verschiebst. @@ -451,6 +455,7 @@ label.orespawn.ore_source.aliases=Ore-Dictionary-Einträge label.orespawn.ore_source.placement_rules=Platzierungsregeln label.orespawn.ore_source.placement_explanation=Ausgaben wählen den Block; Platzierungsregeln wählen Häufigkeit, Form, Tiefe und Wirte. label.orespawn.ore_source.standard_veins=Standardadern +label.orespawn.ore_source.placement_choice_count=%s (%s Möglichkeiten) label.orespawn.ore_source.no_placement_rules=Keine verwalteten Platzierungsregeln tooltip.orespawn.ore_source.group_settings=Name, Aliase und Platzierungsregeln dieser Gruppe bearbeiten. tooltip.orespawn.ore_source.remove_alias=%s aus dieser Gruppe entfernen. diff --git a/src/main/resources/assets/orespawn/lang/de_DE.lang b/src/main/resources/assets/orespawn/lang/de_DE.lang index 2fe8cc74..39d6db89 100644 --- a/src/main/resources/assets/orespawn/lang/de_DE.lang +++ b/src/main/resources/assets/orespawn/lang/de_DE.lang @@ -422,6 +422,8 @@ button.orespawn.ore_source.add_group=Gruppe hinzufügen button.orespawn.ore_source.accept=Akzeptieren tooltip.orespawn.ore_source.accept=Behält „Original beibehalten“ unverändert bei und markiert die Gruppe als geprüft. Gespeichert wird dies erst mit „Fertig“ im Haupteditor. button.orespawn.ore_source.delete_group=Löschen +button.orespawn.ore_source.dissolve_group=Gruppe auflösen +button.orespawn.ore_source.confirm_dissolve=Auflösen bestätigen button.orespawn.ore_source.move=Verschieben button.orespawn.ore_source.advanced=Erweitert... button.orespawn.ore_source.hide_advanced=Erweitert ausblenden @@ -440,7 +442,9 @@ tooltip.orespawn.ore_source.output_only=Diese Anbieterausgabe ist verfügbar, ab tooltip.orespawn.ore_source.output_mode=Original beibehalten erhält jede Regel. Ausgewogen verteilt gleichmäßig, Einzeln wählt einen Block und Benutzerdefiniert erlaubt gewichtete Auswahlen. tooltip.orespawn.ore_source.alias=Füge einen exakten Ore-Dictionary-Erznamen hinzu. Drücke erneut Verschieben, um die Neuzuordnung aus einer anderen Gruppe zu bestätigen. tooltip.orespawn.ore_source.reset_group=Stelle den Namen und die Ore-Dictionary-Einträge dieser kuratierten Gruppe wieder her. -tooltip.orespawn.ore_source.delete_group=Lösche diese benutzerdefinierte Gruppe, ohne erzeugte Chunks neu zu schreiben. +tooltip.orespawn.ore_source.delete_group=Lösche diese leere benutzerdefinierte Gruppe, ohne erzeugte Chunks neu zu schreiben. +tooltip.orespawn.ore_source.dissolve_group=Ordnet jeden Eintrag wieder seiner ermittelten oder kuratierten Gruppe zu. Zum Bestätigen zweimal drücken. +error.orespawn.ore_source.confirm_dissolve=Drücke Auflösen bestätigen, um jeden Erzlexikon-Eintrag seiner ursprünglichen Gruppe zuzuordnen. error.orespawn.ore_source.alias=Verwende einen exakten Ore-Dictionary-Erznamen wie oreSulfur. error.orespawn.ore_source.alias_owned=%s besitzt diesen Eintrag bereits. Drücke Verschieben, um ihn neu zuzuweisen. error.orespawn.ore_source.manage_vanilla_first=Aktiviere „Vanilla-Erze verwalten“, bevor du ein natives Vanilla-Erz in eine andere Gruppe verschiebst. @@ -451,6 +455,7 @@ label.orespawn.ore_source.aliases=Ore-Dictionary-Einträge label.orespawn.ore_source.placement_rules=Platzierungsregeln label.orespawn.ore_source.placement_explanation=Ausgaben wählen den Block; Platzierungsregeln wählen Häufigkeit, Form, Tiefe und Wirte. label.orespawn.ore_source.standard_veins=Standardadern +label.orespawn.ore_source.placement_choice_count=%s (%s Möglichkeiten) label.orespawn.ore_source.no_placement_rules=Keine verwalteten Platzierungsregeln tooltip.orespawn.ore_source.group_settings=Name, Aliase und Platzierungsregeln dieser Gruppe bearbeiten. tooltip.orespawn.ore_source.remove_alias=%s aus dieser Gruppe entfernen. diff --git a/src/main/resources/assets/orespawn/lang/en_CA.lang b/src/main/resources/assets/orespawn/lang/en_CA.lang index 59c08051..669dec9b 100644 --- a/src/main/resources/assets/orespawn/lang/en_CA.lang +++ b/src/main/resources/assets/orespawn/lang/en_CA.lang @@ -422,6 +422,8 @@ button.orespawn.ore_source.add_group=Add Group button.orespawn.ore_source.accept=Accept tooltip.orespawn.ore_source.accept=Keep the current Keep Original policy unchanged and mark this group as reviewed. It is saved only when the main editor's Done is used. button.orespawn.ore_source.delete_group=Delete +button.orespawn.ore_source.dissolve_group=Dissolve Group +button.orespawn.ore_source.confirm_dissolve=Confirm Dissolve button.orespawn.ore_source.move=Move button.orespawn.ore_source.advanced=Advanced... button.orespawn.ore_source.hide_advanced=Hide Advanced @@ -440,7 +442,9 @@ tooltip.orespawn.ore_source.output_only=This provider output is available, but i tooltip.orespawn.ore_source.output_mode=Keep Original preserves every rule. Balanced shares equally, Single chooses one block, and Custom enables weighted choices. tooltip.orespawn.ore_source.alias=Add an exact Ore Dictionary ore name. Press Move again to confirm reassignment from another group. tooltip.orespawn.ore_source.reset_group=Restore this curated group name and Ore Dictionary entries. -tooltip.orespawn.ore_source.delete_group=Delete this custom group without rewriting generated chunks. +tooltip.orespawn.ore_source.delete_group=Delete this empty custom group without rewriting generated chunks. +tooltip.orespawn.ore_source.dissolve_group=Return every entry to its inferred or curated group. Press twice to confirm. +error.orespawn.ore_source.confirm_dissolve=Press Confirm Dissolve to return every Ore Dictionary entry to its original group. error.orespawn.ore_source.alias=Use an exact Ore Dictionary ore name such as oreSulfur. error.orespawn.ore_source.alias_owned=%s already owns this entry. Press Move to reassign it. error.orespawn.ore_source.manage_vanilla_first=Enable Manage Vanilla Ores before moving a native vanilla ore into another group. @@ -451,6 +455,7 @@ label.orespawn.ore_source.aliases=Ore Dictionary entries label.orespawn.ore_source.placement_rules=Placement Rules label.orespawn.ore_source.placement_explanation=Outputs choose the block; placement rules choose frequency, shape, depth and hosts. label.orespawn.ore_source.standard_veins=Standard veins +label.orespawn.ore_source.placement_choice_count=%s (%s choices) label.orespawn.ore_source.no_placement_rules=No managed placement rules tooltip.orespawn.ore_source.group_settings=Edit this group's name, aliases and placement rules. tooltip.orespawn.ore_source.remove_alias=Remove %s from this group. diff --git a/src/main/resources/assets/orespawn/lang/en_EN.lang b/src/main/resources/assets/orespawn/lang/en_EN.lang index 59c08051..669dec9b 100644 --- a/src/main/resources/assets/orespawn/lang/en_EN.lang +++ b/src/main/resources/assets/orespawn/lang/en_EN.lang @@ -422,6 +422,8 @@ button.orespawn.ore_source.add_group=Add Group button.orespawn.ore_source.accept=Accept tooltip.orespawn.ore_source.accept=Keep the current Keep Original policy unchanged and mark this group as reviewed. It is saved only when the main editor's Done is used. button.orespawn.ore_source.delete_group=Delete +button.orespawn.ore_source.dissolve_group=Dissolve Group +button.orespawn.ore_source.confirm_dissolve=Confirm Dissolve button.orespawn.ore_source.move=Move button.orespawn.ore_source.advanced=Advanced... button.orespawn.ore_source.hide_advanced=Hide Advanced @@ -440,7 +442,9 @@ tooltip.orespawn.ore_source.output_only=This provider output is available, but i tooltip.orespawn.ore_source.output_mode=Keep Original preserves every rule. Balanced shares equally, Single chooses one block, and Custom enables weighted choices. tooltip.orespawn.ore_source.alias=Add an exact Ore Dictionary ore name. Press Move again to confirm reassignment from another group. tooltip.orespawn.ore_source.reset_group=Restore this curated group name and Ore Dictionary entries. -tooltip.orespawn.ore_source.delete_group=Delete this custom group without rewriting generated chunks. +tooltip.orespawn.ore_source.delete_group=Delete this empty custom group without rewriting generated chunks. +tooltip.orespawn.ore_source.dissolve_group=Return every entry to its inferred or curated group. Press twice to confirm. +error.orespawn.ore_source.confirm_dissolve=Press Confirm Dissolve to return every Ore Dictionary entry to its original group. error.orespawn.ore_source.alias=Use an exact Ore Dictionary ore name such as oreSulfur. error.orespawn.ore_source.alias_owned=%s already owns this entry. Press Move to reassign it. error.orespawn.ore_source.manage_vanilla_first=Enable Manage Vanilla Ores before moving a native vanilla ore into another group. @@ -451,6 +455,7 @@ label.orespawn.ore_source.aliases=Ore Dictionary entries label.orespawn.ore_source.placement_rules=Placement Rules label.orespawn.ore_source.placement_explanation=Outputs choose the block; placement rules choose frequency, shape, depth and hosts. label.orespawn.ore_source.standard_veins=Standard veins +label.orespawn.ore_source.placement_choice_count=%s (%s choices) label.orespawn.ore_source.no_placement_rules=No managed placement rules tooltip.orespawn.ore_source.group_settings=Edit this group's name, aliases and placement rules. tooltip.orespawn.ore_source.remove_alias=Remove %s from this group. diff --git a/src/main/resources/assets/orespawn/lang/en_GB.lang b/src/main/resources/assets/orespawn/lang/en_GB.lang index 59c08051..669dec9b 100644 --- a/src/main/resources/assets/orespawn/lang/en_GB.lang +++ b/src/main/resources/assets/orespawn/lang/en_GB.lang @@ -422,6 +422,8 @@ button.orespawn.ore_source.add_group=Add Group button.orespawn.ore_source.accept=Accept tooltip.orespawn.ore_source.accept=Keep the current Keep Original policy unchanged and mark this group as reviewed. It is saved only when the main editor's Done is used. button.orespawn.ore_source.delete_group=Delete +button.orespawn.ore_source.dissolve_group=Dissolve Group +button.orespawn.ore_source.confirm_dissolve=Confirm Dissolve button.orespawn.ore_source.move=Move button.orespawn.ore_source.advanced=Advanced... button.orespawn.ore_source.hide_advanced=Hide Advanced @@ -440,7 +442,9 @@ tooltip.orespawn.ore_source.output_only=This provider output is available, but i tooltip.orespawn.ore_source.output_mode=Keep Original preserves every rule. Balanced shares equally, Single chooses one block, and Custom enables weighted choices. tooltip.orespawn.ore_source.alias=Add an exact Ore Dictionary ore name. Press Move again to confirm reassignment from another group. tooltip.orespawn.ore_source.reset_group=Restore this curated group name and Ore Dictionary entries. -tooltip.orespawn.ore_source.delete_group=Delete this custom group without rewriting generated chunks. +tooltip.orespawn.ore_source.delete_group=Delete this empty custom group without rewriting generated chunks. +tooltip.orespawn.ore_source.dissolve_group=Return every entry to its inferred or curated group. Press twice to confirm. +error.orespawn.ore_source.confirm_dissolve=Press Confirm Dissolve to return every Ore Dictionary entry to its original group. error.orespawn.ore_source.alias=Use an exact Ore Dictionary ore name such as oreSulfur. error.orespawn.ore_source.alias_owned=%s already owns this entry. Press Move to reassign it. error.orespawn.ore_source.manage_vanilla_first=Enable Manage Vanilla Ores before moving a native vanilla ore into another group. @@ -451,6 +455,7 @@ label.orespawn.ore_source.aliases=Ore Dictionary entries label.orespawn.ore_source.placement_rules=Placement Rules label.orespawn.ore_source.placement_explanation=Outputs choose the block; placement rules choose frequency, shape, depth and hosts. label.orespawn.ore_source.standard_veins=Standard veins +label.orespawn.ore_source.placement_choice_count=%s (%s choices) label.orespawn.ore_source.no_placement_rules=No managed placement rules tooltip.orespawn.ore_source.group_settings=Edit this group's name, aliases and placement rules. tooltip.orespawn.ore_source.remove_alias=Remove %s from this group. diff --git a/src/main/resources/assets/orespawn/lang/en_PT.lang b/src/main/resources/assets/orespawn/lang/en_PT.lang index 59c08051..669dec9b 100644 --- a/src/main/resources/assets/orespawn/lang/en_PT.lang +++ b/src/main/resources/assets/orespawn/lang/en_PT.lang @@ -422,6 +422,8 @@ button.orespawn.ore_source.add_group=Add Group button.orespawn.ore_source.accept=Accept tooltip.orespawn.ore_source.accept=Keep the current Keep Original policy unchanged and mark this group as reviewed. It is saved only when the main editor's Done is used. button.orespawn.ore_source.delete_group=Delete +button.orespawn.ore_source.dissolve_group=Dissolve Group +button.orespawn.ore_source.confirm_dissolve=Confirm Dissolve button.orespawn.ore_source.move=Move button.orespawn.ore_source.advanced=Advanced... button.orespawn.ore_source.hide_advanced=Hide Advanced @@ -440,7 +442,9 @@ tooltip.orespawn.ore_source.output_only=This provider output is available, but i tooltip.orespawn.ore_source.output_mode=Keep Original preserves every rule. Balanced shares equally, Single chooses one block, and Custom enables weighted choices. tooltip.orespawn.ore_source.alias=Add an exact Ore Dictionary ore name. Press Move again to confirm reassignment from another group. tooltip.orespawn.ore_source.reset_group=Restore this curated group name and Ore Dictionary entries. -tooltip.orespawn.ore_source.delete_group=Delete this custom group without rewriting generated chunks. +tooltip.orespawn.ore_source.delete_group=Delete this empty custom group without rewriting generated chunks. +tooltip.orespawn.ore_source.dissolve_group=Return every entry to its inferred or curated group. Press twice to confirm. +error.orespawn.ore_source.confirm_dissolve=Press Confirm Dissolve to return every Ore Dictionary entry to its original group. error.orespawn.ore_source.alias=Use an exact Ore Dictionary ore name such as oreSulfur. error.orespawn.ore_source.alias_owned=%s already owns this entry. Press Move to reassign it. error.orespawn.ore_source.manage_vanilla_first=Enable Manage Vanilla Ores before moving a native vanilla ore into another group. @@ -451,6 +455,7 @@ label.orespawn.ore_source.aliases=Ore Dictionary entries label.orespawn.ore_source.placement_rules=Placement Rules label.orespawn.ore_source.placement_explanation=Outputs choose the block; placement rules choose frequency, shape, depth and hosts. label.orespawn.ore_source.standard_veins=Standard veins +label.orespawn.ore_source.placement_choice_count=%s (%s choices) label.orespawn.ore_source.no_placement_rules=No managed placement rules tooltip.orespawn.ore_source.group_settings=Edit this group's name, aliases and placement rules. tooltip.orespawn.ore_source.remove_alias=Remove %s from this group. diff --git a/src/main/resources/assets/orespawn/lang/en_US.lang b/src/main/resources/assets/orespawn/lang/en_US.lang index c409c68e..8ad633e9 100644 --- a/src/main/resources/assets/orespawn/lang/en_US.lang +++ b/src/main/resources/assets/orespawn/lang/en_US.lang @@ -422,6 +422,8 @@ button.orespawn.ore_source.add_group=Add Group button.orespawn.ore_source.accept=Accept tooltip.orespawn.ore_source.accept=Keep the current Keep Original policy unchanged and mark this group as reviewed. It is saved only when the main editor's Done is used. button.orespawn.ore_source.delete_group=Delete +button.orespawn.ore_source.dissolve_group=Dissolve Group +button.orespawn.ore_source.confirm_dissolve=Confirm Dissolve button.orespawn.ore_source.move=Move button.orespawn.ore_source.advanced=Advanced... button.orespawn.ore_source.hide_advanced=Hide Advanced @@ -440,7 +442,9 @@ tooltip.orespawn.ore_source.output_only=This provider output is available, but i tooltip.orespawn.ore_source.output_mode=Keep Original preserves every rule. Balanced shares equally, Single chooses one block, and Custom enables weighted choices. tooltip.orespawn.ore_source.alias=Add an exact Ore Dictionary ore name. Press Move again to confirm reassignment from another group. tooltip.orespawn.ore_source.reset_group=Restore this curated group name and Ore Dictionary entries. -tooltip.orespawn.ore_source.delete_group=Delete this custom group without rewriting generated chunks. +tooltip.orespawn.ore_source.delete_group=Delete this empty custom group without rewriting generated chunks. +tooltip.orespawn.ore_source.dissolve_group=Return every entry to its inferred or curated group. Press twice to confirm. +error.orespawn.ore_source.confirm_dissolve=Press Confirm Dissolve to return every Ore Dictionary entry to its original group. error.orespawn.ore_source.alias=Use an exact Ore Dictionary ore name such as oreSulfur. error.orespawn.ore_source.alias_owned=%s already owns this entry. Press Move to reassign it. error.orespawn.ore_source.manage_vanilla_first=Enable Manage Vanilla Ores before moving a native vanilla ore into another group. @@ -451,6 +455,7 @@ label.orespawn.ore_source.aliases=Ore Dictionary entries label.orespawn.ore_source.placement_rules=Placement Rules label.orespawn.ore_source.placement_explanation=Outputs choose the block; placement rules choose frequency, shape, depth and hosts. label.orespawn.ore_source.standard_veins=Standard veins +label.orespawn.ore_source.placement_choice_count=%s (%s choices) label.orespawn.ore_source.no_placement_rules=No managed placement rules tooltip.orespawn.ore_source.group_settings=Edit this group's name, aliases and placement rules. tooltip.orespawn.ore_source.remove_alias=Remove %s from this group. diff --git a/src/main/resources/assets/orespawn/lang/es_ES.lang b/src/main/resources/assets/orespawn/lang/es_ES.lang index bf3350ba..44ba941d 100644 --- a/src/main/resources/assets/orespawn/lang/es_ES.lang +++ b/src/main/resources/assets/orespawn/lang/es_ES.lang @@ -422,6 +422,8 @@ button.orespawn.ore_source.add_group=Añadir grupo button.orespawn.ore_source.accept=Aceptar tooltip.orespawn.ore_source.accept=Conserva sin cambios «Mantener original» y marca el grupo como revisado. Solo se guarda al pulsar Hecho en el editor principal. button.orespawn.ore_source.delete_group=Eliminar +button.orespawn.ore_source.dissolve_group=Disolver grupo +button.orespawn.ore_source.confirm_dissolve=Confirmar disolución button.orespawn.ore_source.move=Mover button.orespawn.ore_source.advanced=Avanzado... button.orespawn.ore_source.hide_advanced=Ocultar avanzado @@ -440,7 +442,9 @@ tooltip.orespawn.ore_source.output_only=Esta salida del proveedor está disponib tooltip.orespawn.ore_source.output_mode=Mantener original conserva cada regla. Equilibrado reparte por igual, Único elige un bloque y Personalizado permite elecciones ponderadas. tooltip.orespawn.ore_source.alias=Añade un nombre exacto de mineral del diccionario. Pulsa Mover otra vez para confirmar la reasignación desde otro grupo. tooltip.orespawn.ore_source.reset_group=Restaura el nombre y las entradas del diccionario de este grupo seleccionado. -tooltip.orespawn.ore_source.delete_group=Elimina este grupo personalizado sin reescribir los chunks generados. +tooltip.orespawn.ore_source.delete_group=Elimina este grupo personalizado vacío sin reescribir los chunks generados. +tooltip.orespawn.ore_source.dissolve_group=Devuelve cada entrada a su grupo inferido o revisado. Pulsa dos veces para confirmar. +error.orespawn.ore_source.confirm_dissolve=Pulsa Confirmar disolución para devolver cada entrada del diccionario a su grupo original. error.orespawn.ore_source.alias=Usa un nombre exacto del diccionario de minerales, como oreSulfur. error.orespawn.ore_source.alias_owned=%s ya posee esta entrada. Pulsa Mover para reasignarla. error.orespawn.ore_source.manage_vanilla_first=Activa «Gestionar minerales de Vanilla» antes de mover un mineral nativo de Vanilla a otro grupo. @@ -451,6 +455,7 @@ label.orespawn.ore_source.aliases=Entradas del diccionario de menas label.orespawn.ore_source.placement_rules=Reglas de colocación label.orespawn.ore_source.placement_explanation=Las salidas eligen el bloque; las reglas eligen frecuencia, forma, profundidad y roca anfitriona. label.orespawn.ore_source.standard_veins=Vetas estándar +label.orespawn.ore_source.placement_choice_count=%s (%s opciones) label.orespawn.ore_source.no_placement_rules=Sin reglas de colocación administradas tooltip.orespawn.ore_source.group_settings=Edita el nombre, los alias y las reglas de colocación de este grupo. tooltip.orespawn.ore_source.remove_alias=Eliminar %s de este grupo. diff --git a/src/main/resources/assets/orespawn/lang/es_MX.lang b/src/main/resources/assets/orespawn/lang/es_MX.lang index bf3350ba..44ba941d 100644 --- a/src/main/resources/assets/orespawn/lang/es_MX.lang +++ b/src/main/resources/assets/orespawn/lang/es_MX.lang @@ -422,6 +422,8 @@ button.orespawn.ore_source.add_group=Añadir grupo button.orespawn.ore_source.accept=Aceptar tooltip.orespawn.ore_source.accept=Conserva sin cambios «Mantener original» y marca el grupo como revisado. Solo se guarda al pulsar Hecho en el editor principal. button.orespawn.ore_source.delete_group=Eliminar +button.orespawn.ore_source.dissolve_group=Disolver grupo +button.orespawn.ore_source.confirm_dissolve=Confirmar disolución button.orespawn.ore_source.move=Mover button.orespawn.ore_source.advanced=Avanzado... button.orespawn.ore_source.hide_advanced=Ocultar avanzado @@ -440,7 +442,9 @@ tooltip.orespawn.ore_source.output_only=Esta salida del proveedor está disponib tooltip.orespawn.ore_source.output_mode=Mantener original conserva cada regla. Equilibrado reparte por igual, Único elige un bloque y Personalizado permite elecciones ponderadas. tooltip.orespawn.ore_source.alias=Añade un nombre exacto de mineral del diccionario. Pulsa Mover otra vez para confirmar la reasignación desde otro grupo. tooltip.orespawn.ore_source.reset_group=Restaura el nombre y las entradas del diccionario de este grupo seleccionado. -tooltip.orespawn.ore_source.delete_group=Elimina este grupo personalizado sin reescribir los chunks generados. +tooltip.orespawn.ore_source.delete_group=Elimina este grupo personalizado vacío sin reescribir los chunks generados. +tooltip.orespawn.ore_source.dissolve_group=Devuelve cada entrada a su grupo inferido o revisado. Pulsa dos veces para confirmar. +error.orespawn.ore_source.confirm_dissolve=Pulsa Confirmar disolución para devolver cada entrada del diccionario a su grupo original. error.orespawn.ore_source.alias=Usa un nombre exacto del diccionario de minerales, como oreSulfur. error.orespawn.ore_source.alias_owned=%s ya posee esta entrada. Pulsa Mover para reasignarla. error.orespawn.ore_source.manage_vanilla_first=Activa «Gestionar minerales de Vanilla» antes de mover un mineral nativo de Vanilla a otro grupo. @@ -451,6 +455,7 @@ label.orespawn.ore_source.aliases=Entradas del diccionario de menas label.orespawn.ore_source.placement_rules=Reglas de colocación label.orespawn.ore_source.placement_explanation=Las salidas eligen el bloque; las reglas eligen frecuencia, forma, profundidad y roca anfitriona. label.orespawn.ore_source.standard_veins=Vetas estándar +label.orespawn.ore_source.placement_choice_count=%s (%s opciones) label.orespawn.ore_source.no_placement_rules=Sin reglas de colocación administradas tooltip.orespawn.ore_source.group_settings=Edita el nombre, los alias y las reglas de colocación de este grupo. tooltip.orespawn.ore_source.remove_alias=Eliminar %s de este grupo. diff --git a/src/main/resources/assets/orespawn/lang/fr_CA.lang b/src/main/resources/assets/orespawn/lang/fr_CA.lang index adf70b44..81a319d0 100644 --- a/src/main/resources/assets/orespawn/lang/fr_CA.lang +++ b/src/main/resources/assets/orespawn/lang/fr_CA.lang @@ -422,6 +422,8 @@ button.orespawn.ore_source.add_group=Ajouter un groupe button.orespawn.ore_source.accept=Accepter tooltip.orespawn.ore_source.accept=Conserve « Garder l'original » sans modification et marque le groupe comme vérifié. L'enregistrement se fait seulement avec Terminé dans l'éditeur principal. button.orespawn.ore_source.delete_group=Supprimer +button.orespawn.ore_source.dissolve_group=Dissoudre le groupe +button.orespawn.ore_source.confirm_dissolve=Confirmer la dissolution button.orespawn.ore_source.move=Déplacer button.orespawn.ore_source.advanced=Avancé... button.orespawn.ore_source.hide_advanced=Masquer les options avancées @@ -440,7 +442,9 @@ tooltip.orespawn.ore_source.output_only=Cette sortie de fournisseur est disponib tooltip.orespawn.ore_source.output_mode=Conserver l'origine préserve chaque règle. Équilibré partage également, Unique choisit un bloc et Personnalisé permet des choix pondérés. tooltip.orespawn.ore_source.alias=Ajoutez un nom exact de minerai du dictionnaire. Appuyez à nouveau sur Déplacer pour confirmer sa réaffectation depuis un autre groupe. tooltip.orespawn.ore_source.reset_group=Restaure le nom et les entrées du dictionnaire de ce groupe sélectionné. -tooltip.orespawn.ore_source.delete_group=Supprime ce groupe personnalisé sans réécrire les chunks générés. +tooltip.orespawn.ore_source.delete_group=Supprime ce groupe personnalisé vide sans réécrire les chunks générés. +tooltip.orespawn.ore_source.dissolve_group=Replace chaque entrée dans son groupe déduit ou prédéfini. Appuyez deux fois pour confirmer. +error.orespawn.ore_source.confirm_dissolve=Appuyez sur Confirmer la dissolution pour replacer chaque entrée du dictionnaire dans son groupe d'origine. error.orespawn.ore_source.alias=Utilisez un nom exact du dictionnaire de minerais, comme oreSulfur. error.orespawn.ore_source.alias_owned=%s possède déjà cette entrée. Appuyez sur Déplacer pour la réaffecter. error.orespawn.ore_source.manage_vanilla_first=Activez « Gérer les minerais vanilla » avant de déplacer un minerai vanilla natif vers un autre groupe. @@ -451,6 +455,7 @@ label.orespawn.ore_source.aliases=Entrées du dictionnaire de minerais label.orespawn.ore_source.placement_rules=Règles de placement label.orespawn.ore_source.placement_explanation=Les sorties choisissent le bloc; les règles choisissent fréquence, forme, profondeur et hôtes. label.orespawn.ore_source.standard_veins=Veines standard +label.orespawn.ore_source.placement_choice_count=%s (%s choix) label.orespawn.ore_source.no_placement_rules=Aucune règle de placement gérée tooltip.orespawn.ore_source.group_settings=Modifier le nom, les alias et les règles de placement de ce groupe. tooltip.orespawn.ore_source.remove_alias=Retirer %s de ce groupe. diff --git a/src/main/resources/assets/orespawn/lang/fr_FR.lang b/src/main/resources/assets/orespawn/lang/fr_FR.lang index adf70b44..81a319d0 100644 --- a/src/main/resources/assets/orespawn/lang/fr_FR.lang +++ b/src/main/resources/assets/orespawn/lang/fr_FR.lang @@ -422,6 +422,8 @@ button.orespawn.ore_source.add_group=Ajouter un groupe button.orespawn.ore_source.accept=Accepter tooltip.orespawn.ore_source.accept=Conserve « Garder l'original » sans modification et marque le groupe comme vérifié. L'enregistrement se fait seulement avec Terminé dans l'éditeur principal. button.orespawn.ore_source.delete_group=Supprimer +button.orespawn.ore_source.dissolve_group=Dissoudre le groupe +button.orespawn.ore_source.confirm_dissolve=Confirmer la dissolution button.orespawn.ore_source.move=Déplacer button.orespawn.ore_source.advanced=Avancé... button.orespawn.ore_source.hide_advanced=Masquer les options avancées @@ -440,7 +442,9 @@ tooltip.orespawn.ore_source.output_only=Cette sortie de fournisseur est disponib tooltip.orespawn.ore_source.output_mode=Conserver l'origine préserve chaque règle. Équilibré partage également, Unique choisit un bloc et Personnalisé permet des choix pondérés. tooltip.orespawn.ore_source.alias=Ajoutez un nom exact de minerai du dictionnaire. Appuyez à nouveau sur Déplacer pour confirmer sa réaffectation depuis un autre groupe. tooltip.orespawn.ore_source.reset_group=Restaure le nom et les entrées du dictionnaire de ce groupe sélectionné. -tooltip.orespawn.ore_source.delete_group=Supprime ce groupe personnalisé sans réécrire les chunks générés. +tooltip.orespawn.ore_source.delete_group=Supprime ce groupe personnalisé vide sans réécrire les chunks générés. +tooltip.orespawn.ore_source.dissolve_group=Replace chaque entrée dans son groupe déduit ou prédéfini. Appuyez deux fois pour confirmer. +error.orespawn.ore_source.confirm_dissolve=Appuyez sur Confirmer la dissolution pour replacer chaque entrée du dictionnaire dans son groupe d'origine. error.orespawn.ore_source.alias=Utilisez un nom exact du dictionnaire de minerais, comme oreSulfur. error.orespawn.ore_source.alias_owned=%s possède déjà cette entrée. Appuyez sur Déplacer pour la réaffecter. error.orespawn.ore_source.manage_vanilla_first=Activez « Gérer les minerais vanilla » avant de déplacer un minerai vanilla natif vers un autre groupe. @@ -451,6 +455,7 @@ label.orespawn.ore_source.aliases=Entrées du dictionnaire de minerais label.orespawn.ore_source.placement_rules=Règles de placement label.orespawn.ore_source.placement_explanation=Les sorties choisissent le bloc; les règles choisissent fréquence, forme, profondeur et hôtes. label.orespawn.ore_source.standard_veins=Veines standard +label.orespawn.ore_source.placement_choice_count=%s (%s choix) label.orespawn.ore_source.no_placement_rules=Aucune règle de placement gérée tooltip.orespawn.ore_source.group_settings=Modifier le nom, les alias et les règles de placement de ce groupe. tooltip.orespawn.ore_source.remove_alias=Retirer %s de ce groupe. diff --git a/src/main/resources/assets/orespawn/lang/ja_JP.lang b/src/main/resources/assets/orespawn/lang/ja_JP.lang index 6c76d356..c83d0991 100644 --- a/src/main/resources/assets/orespawn/lang/ja_JP.lang +++ b/src/main/resources/assets/orespawn/lang/ja_JP.lang @@ -422,6 +422,8 @@ button.orespawn.ore_source.add_group=グループを追加 button.orespawn.ore_source.accept=承認 tooltip.orespawn.ore_source.accept=「元のまま」を変更せず、このグループを確認済みにします。保存されるのはメインエディターで「完了」を押したときだけです。 button.orespawn.ore_source.delete_group=削除 +button.orespawn.ore_source.dissolve_group=グループを解散 +button.orespawn.ore_source.confirm_dissolve=解散を確認 button.orespawn.ore_source.move=移動 button.orespawn.ore_source.advanced=詳細... button.orespawn.ore_source.hide_advanced=詳細を隠す @@ -440,7 +442,9 @@ tooltip.orespawn.ore_source.output_only=このプロバイダー出力は利用 tooltip.orespawn.ore_source.output_mode=元の設定を維持は各ルールを保持します。均等は同じ比率、単一は1ブロック、カスタムは重み付き選択を使用します。 tooltip.orespawn.ore_source.alias=鉱石辞書の正確な鉱石名を追加します。別グループからの再割り当てを確定するには、もう一度「移動」を押します。 tooltip.orespawn.ore_source.reset_group=この既定グループの名前と鉱石辞書エントリを復元します。 -tooltip.orespawn.ore_source.delete_group=生成済みチャンクを書き換えずに、このカスタムグループを削除します。 +tooltip.orespawn.ore_source.delete_group=生成済みチャンクを書き換えずに、この空のカスタムグループを削除します。 +tooltip.orespawn.ore_source.dissolve_group=各項目を推定または既定のグループへ戻します。2回押すと確定します。 +error.orespawn.ore_source.confirm_dissolve=「解散を確認」を押すと、すべての鉱石辞書項目を元のグループへ戻します。 error.orespawn.ore_source.alias=oreSulfur のような正確な鉱石辞書名を使用してください。 error.orespawn.ore_source.alias_owned=%s がこのエントリを既に所有しています。再割り当てするには「移動」を押してください。 error.orespawn.ore_source.manage_vanilla_first=バニラ鉱石を別のグループへ移動する前に、「バニラ鉱石を管理」を有効にしてください。 @@ -451,6 +455,7 @@ label.orespawn.ore_source.aliases=鉱石辞書エントリ label.orespawn.ore_source.placement_rules=配置ルール label.orespawn.ore_source.placement_explanation=出力はブロックを、配置ルールは頻度、形状、深さ、母岩を選びます。 label.orespawn.ore_source.standard_veins=標準鉱脈 +label.orespawn.ore_source.placement_choice_count=%s(%s 個の候補) label.orespawn.ore_source.no_placement_rules=管理対象の配置ルールはありません tooltip.orespawn.ore_source.group_settings=このグループの名前、別名、配置ルールを編集します。 tooltip.orespawn.ore_source.remove_alias=%s をこのグループから削除します。 diff --git a/src/main/resources/assets/orespawn/lang/ko_KR.lang b/src/main/resources/assets/orespawn/lang/ko_KR.lang index 3d278508..9981067b 100644 --- a/src/main/resources/assets/orespawn/lang/ko_KR.lang +++ b/src/main/resources/assets/orespawn/lang/ko_KR.lang @@ -422,6 +422,8 @@ button.orespawn.ore_source.add_group=그룹 추가 button.orespawn.ore_source.accept=승인 tooltip.orespawn.ore_source.accept='원래대로 유지' 설정은 바꾸지 않고 이 그룹을 검토 완료로 표시합니다. 기본 편집기에서 완료를 눌러야 저장됩니다. button.orespawn.ore_source.delete_group=삭제 +button.orespawn.ore_source.dissolve_group=그룹 해제 +button.orespawn.ore_source.confirm_dissolve=해제 확인 button.orespawn.ore_source.move=이동 button.orespawn.ore_source.advanced=고급... button.orespawn.ore_source.hide_advanced=고급 숨기기 @@ -440,7 +442,9 @@ tooltip.orespawn.ore_source.output_only=이 공급자 출력은 사용할 수 tooltip.orespawn.ore_source.output_mode=원본 유지는 각 규칙을 보존합니다. 균등은 같은 비율로, 단일은 한 블록을, 사용자 지정은 가중 선택을 사용합니다. tooltip.orespawn.ore_source.alias=정확한 광석 사전 이름을 추가합니다. 다른 그룹에서 재할당하려면 이동을 다시 누르세요. tooltip.orespawn.ore_source.reset_group=이 선별 그룹의 이름과 광석 사전 항목을 복원합니다. -tooltip.orespawn.ore_source.delete_group=생성된 청크를 다시 쓰지 않고 이 사용자 지정 그룹을 삭제합니다. +tooltip.orespawn.ore_source.delete_group=생성된 청크를 다시 쓰지 않고 비어 있는 사용자 지정 그룹을 삭제합니다. +tooltip.orespawn.ore_source.dissolve_group=각 항목을 추론되었거나 선별된 그룹으로 되돌립니다. 두 번 눌러 확인합니다. +error.orespawn.ore_source.confirm_dissolve=해제 확인을 눌러 모든 광석 사전 항목을 원래 그룹으로 되돌립니다. error.orespawn.ore_source.alias=oreSulfur 같은 정확한 광석 사전 이름을 사용하세요. error.orespawn.ore_source.alias_owned=%s에서 이미 이 항목을 소유합니다. 이동을 눌러 재할당하세요. error.orespawn.ore_source.manage_vanilla_first=바닐라 광석을 다른 그룹으로 이동하기 전에 바닐라 광석 관리를 활성화하세요. @@ -451,6 +455,7 @@ label.orespawn.ore_source.aliases=광석 사전 항목 label.orespawn.ore_source.placement_rules=배치 규칙 label.orespawn.ore_source.placement_explanation=출력은 블록을, 배치 규칙은 빈도, 모양, 깊이와 모암을 선택합니다. label.orespawn.ore_source.standard_veins=표준 광맥 +label.orespawn.ore_source.placement_choice_count=%s (선택지 %s개) label.orespawn.ore_source.no_placement_rules=관리되는 배치 규칙 없음 tooltip.orespawn.ore_source.group_settings=이 그룹의 이름, 별칭과 배치 규칙을 편집합니다. tooltip.orespawn.ore_source.remove_alias=이 그룹에서 %s 항목을 제거합니다. diff --git a/src/main/resources/assets/orespawn/lang/pt_BR.lang b/src/main/resources/assets/orespawn/lang/pt_BR.lang index 363d0064..2cb19176 100644 --- a/src/main/resources/assets/orespawn/lang/pt_BR.lang +++ b/src/main/resources/assets/orespawn/lang/pt_BR.lang @@ -422,6 +422,8 @@ button.orespawn.ore_source.add_group=Adicionar grupo button.orespawn.ore_source.accept=Aceitar tooltip.orespawn.ore_source.accept=Mantém Conservar original sem alterações e marca o grupo como revisado. Só é salvo ao usar Concluído no editor principal. button.orespawn.ore_source.delete_group=Excluir +button.orespawn.ore_source.dissolve_group=Dissolver grupo +button.orespawn.ore_source.confirm_dissolve=Confirmar dissolução button.orespawn.ore_source.move=Mover button.orespawn.ore_source.advanced=Avançado... button.orespawn.ore_source.hide_advanced=Ocultar avançado @@ -440,7 +442,9 @@ tooltip.orespawn.ore_source.output_only=Esta saída do provedor está disponíve tooltip.orespawn.ore_source.output_mode=Manter original preserva cada regra. Equilibrado divide igualmente, Único escolhe um bloco e Personalizado permite escolhas ponderadas. tooltip.orespawn.ore_source.alias=Adicione um nome exato de minério do dicionário. Pressione Mover novamente para confirmar a transferência de outro grupo. tooltip.orespawn.ore_source.reset_group=Restaura o nome e as entradas do dicionário deste grupo selecionado. -tooltip.orespawn.ore_source.delete_group=Exclui este grupo personalizado sem reescrever os chunks gerados. +tooltip.orespawn.ore_source.delete_group=Exclui este grupo personalizado vazio sem reescrever os chunks gerados. +tooltip.orespawn.ore_source.dissolve_group=Devolve cada entrada ao grupo inferido ou revisado. Pressione duas vezes para confirmar. +error.orespawn.ore_source.confirm_dissolve=Pressione Confirmar dissolução para devolver cada entrada do dicionário ao grupo original. error.orespawn.ore_source.alias=Use um nome exato do dicionário de minérios, como oreSulfur. error.orespawn.ore_source.alias_owned=%s já possui esta entrada. Pressione Mover para transferi-la. error.orespawn.ore_source.manage_vanilla_first=Ative Gerenciar minérios vanilla antes de mover um minério vanilla nativo para outro grupo. @@ -451,6 +455,7 @@ label.orespawn.ore_source.aliases=Entradas do Dicionário de Minérios label.orespawn.ore_source.placement_rules=Regras de colocação label.orespawn.ore_source.placement_explanation=Saídas escolhem o bloco; regras escolhem frequência, forma, profundidade e hospedeiros. label.orespawn.ore_source.standard_veins=Veios padrão +label.orespawn.ore_source.placement_choice_count=%s (%s opções) label.orespawn.ore_source.no_placement_rules=Nenhuma regra de colocação gerenciada tooltip.orespawn.ore_source.group_settings=Edite o nome, aliases e regras de colocação deste grupo. tooltip.orespawn.ore_source.remove_alias=Remover %s deste grupo. diff --git a/src/main/resources/assets/orespawn/lang/ru_RU.lang b/src/main/resources/assets/orespawn/lang/ru_RU.lang index d52c9f00..ce2a0966 100644 --- a/src/main/resources/assets/orespawn/lang/ru_RU.lang +++ b/src/main/resources/assets/orespawn/lang/ru_RU.lang @@ -422,6 +422,8 @@ button.orespawn.ore_source.add_group=Добавить группу button.orespawn.ore_source.accept=Принять tooltip.orespawn.ore_source.accept=Сохраняет режим «Оставить исходное» без изменений и отмечает группу как проверенную. Изменение сохраняется только кнопкой «Готово» в главном редакторе. button.orespawn.ore_source.delete_group=Удалить +button.orespawn.ore_source.dissolve_group=Расформировать группу +button.orespawn.ore_source.confirm_dissolve=Подтвердить расформирование button.orespawn.ore_source.move=Переместить button.orespawn.ore_source.advanced=Расширенные... button.orespawn.ore_source.hide_advanced=Скрыть расширенные @@ -440,7 +442,9 @@ tooltip.orespawn.ore_source.output_only=Результат этого поста tooltip.orespawn.ore_source.output_mode=Сохранить исходное оставляет каждое правило. Равномерно делит поровну, Один выбирает один блок, а Настраиваемый использует веса. tooltip.orespawn.ore_source.alias=Добавьте точное имя руды из словаря. Нажмите Переместить ещё раз, чтобы подтвердить перенос из другой группы. tooltip.orespawn.ore_source.reset_group=Восстанавливает название и записи словаря этой подготовленной группы. -tooltip.orespawn.ore_source.delete_group=Удаляет эту настраиваемую группу без перезаписи созданных чанков. +tooltip.orespawn.ore_source.delete_group=Удаляет эту пустую настраиваемую группу без перезаписи созданных чанков. +tooltip.orespawn.ore_source.dissolve_group=Возвращает каждую запись в вычисленную или подготовленную группу. Нажмите дважды для подтверждения. +error.orespawn.ore_source.confirm_dissolve=Нажмите «Подтвердить расформирование», чтобы вернуть все записи словаря в исходные группы. error.orespawn.ore_source.alias=Используйте точное имя из словаря руд, например oreSulfur. error.orespawn.ore_source.alias_owned=%s уже владеет этой записью. Нажмите Переместить для переназначения. error.orespawn.ore_source.manage_vanilla_first=Включите управление ванильными рудами, прежде чем перемещать ванильную руду в другую группу. @@ -451,6 +455,7 @@ label.orespawn.ore_source.aliases=Записи словаря руд label.orespawn.ore_source.placement_rules=Правила размещения label.orespawn.ore_source.placement_explanation=Выходы выбирают блок; правила выбирают частоту, форму, глубину и вмещающие породы. label.orespawn.ore_source.standard_veins=Стандартные жилы +label.orespawn.ore_source.placement_choice_count=%s (вариантов: %s) label.orespawn.ore_source.no_placement_rules=Нет управляемых правил размещения tooltip.orespawn.ore_source.group_settings=Изменить имя, псевдонимы и правила размещения этой группы. tooltip.orespawn.ore_source.remove_alias=Удалить %s из этой группы. diff --git a/src/main/resources/assets/orespawn/lang/zh_CN.lang b/src/main/resources/assets/orespawn/lang/zh_CN.lang index 054998f2..92ce5897 100644 --- a/src/main/resources/assets/orespawn/lang/zh_CN.lang +++ b/src/main/resources/assets/orespawn/lang/zh_CN.lang @@ -422,6 +422,8 @@ button.orespawn.ore_source.add_group=添加分组 button.orespawn.ore_source.accept=接受 tooltip.orespawn.ore_source.accept=保持“保留原始设置”不变,并将此分组标记为已审核。只有在主编辑器中点击“完成”后才会保存。 button.orespawn.ore_source.delete_group=删除 +button.orespawn.ore_source.dissolve_group=解散分组 +button.orespawn.ore_source.confirm_dissolve=确认解散 button.orespawn.ore_source.move=移动 button.orespawn.ore_source.advanced=高级... button.orespawn.ore_source.hide_advanced=隐藏高级选项 @@ -440,7 +442,9 @@ tooltip.orespawn.ore_source.output_only=此提供方输出可用,但其放置 tooltip.orespawn.ore_source.output_mode=保持原样会保留每条规则;均衡会平均选择;单一只选择一个方块;自定义允许加权选择。 tooltip.orespawn.ore_source.alias=添加准确的矿物词典名称。再次按“移动”以确认从其他分组重新分配。 tooltip.orespawn.ore_source.reset_group=恢复此预设分组的名称和矿物词典条目。 -tooltip.orespawn.ore_source.delete_group=删除此自定义分组,不重写已生成的区块。 +tooltip.orespawn.ore_source.delete_group=删除此空的自定义分组,不重写已生成的区块。 +tooltip.orespawn.ore_source.dissolve_group=将每个条目还原到推断或预设的分组。按两次以确认。 +error.orespawn.ore_source.confirm_dissolve=按“确认解散”可将每个矿物词典条目还原到原分组。 error.orespawn.ore_source.alias=请使用准确的矿物词典名称,例如 oreSulfur。 error.orespawn.ore_source.alias_owned=%s 已拥有此条目。按“移动”可重新分配。 error.orespawn.ore_source.manage_vanilla_first=将原版矿石移入其他分组前,请先启用“管理原版矿石”。 @@ -451,6 +455,7 @@ label.orespawn.ore_source.aliases=矿物词典条目 label.orespawn.ore_source.placement_rules=放置规则 label.orespawn.ore_source.placement_explanation=输出选择方块;放置规则选择频率、形状、深度和宿主。 label.orespawn.ore_source.standard_veins=标准矿脉 +label.orespawn.ore_source.placement_choice_count=%s(%s 个选项) label.orespawn.ore_source.no_placement_rules=没有受管理的放置规则 tooltip.orespawn.ore_source.group_settings=编辑此分组的名称、别名和放置规则。 tooltip.orespawn.ore_source.remove_alias=从此分组移除 %s。 diff --git a/src/test/java/zone/moddev/mc/orespawn/LocalizationParityTest.java b/src/test/java/zone/moddev/mc/orespawn/LocalizationParityTest.java index d342214c..3a0932b9 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(464, englishKeys.size(), + assertEquals(469, 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/client/GeologyEditorSessionTest.java b/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java index fcbe580c..d37f3b28 100644 --- a/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java @@ -32,6 +32,12 @@ static void bootstrapMinecraft() { registered |= "oreDiamond".equals(OreDictionary.getOreName(id)); } if (!registered) OreDictionary.registerOre("oreDiamond", diamond); + ItemStack emerald = new ItemStack(Blocks.EMERALD_ORE); + registered = false; + for (int id : OreDictionary.getOreIDs(emerald)) { + registered |= "oreEmerald".equals(OreDictionary.getOreName(id)); + } + if (!registered) OreDictionary.registerOre("oreEmerald", emerald); } @Test @@ -270,10 +276,11 @@ void customMaterialGroupsKeepStableIdsAndRequireConfirmedAliasMoves() { assertTrue(session.oreMaterialGroupsChanged()); assertEquals(before, original.rootCopy(), "pending group edits must not persist before main Done"); - session.deleteOreMaterialGroup(material); - assertEquals(null, session.oreDictionaryOwner("oreSulfur")); + assertTrue(session.dissolveOreMaterialGroup(material)); + assertEquals("orespawn:sulfur", session.oreDictionaryOwner("oreSulfur")); + assertEquals("orespawn:copperzinc", session.oreDictionaryOwner("oreCopperZinc")); assertFalse(key.equals(session.addOreMaterialGroup()), - "deleted group IDs remain reserved by their dormant policy"); + "dissolved group IDs remain reserved by their dormant policy"); session.resetOreMaterialGroup("orespawn:sulfur"); assertEquals("orespawn:sulfur", session.oreDictionaryOwner("oreSulfur")); } @@ -316,6 +323,101 @@ void vanillaAliasMoveRequiresVanillaManagementAndNeverChangesOwnershipWhenBlocke assertTrue(group(session, customMaterial).hasManagedPlacementSource()); } + @Test + void managedVanillaAliasesBuildOneLiveReviewWithoutEmptyOrStaleOutputs() { + GeologyEditorSession session = new GeologyEditorSession(profileWithNativePreciousOres()); + session.setManageVanillaOres(true); + String key = session.addOreMaterialGroup(); + String material = key.substring(0, key.indexOf('|')); + session.renameOreMaterialGroup(material, "Precious Stones"); + + assertTrue(session.addOreMaterialAlias(material, "oreDiamond", true)); + assertTrue(session.addOreMaterialAlias(material, "oreEmerald", true)); + + GeologyEditorSession.OreSourceGroup review = group(session, material); + assertEquals(Arrays.asList("oreDiamond", "oreEmerald"), review.oreDictionaryEntries); + assertEquals(2, review.outputCandidates().size()); + assertEquals(2, review.outputs.size(), "Keep Original must expose both moved outputs immediately"); + assertEquals("minecraft:diamond_ore", review.placements.get("orespawn:standard")); + assertEquals(2, OreSourceListScreen.placementCandidates(review, "orespawn:standard").size()); + assertTrue(review.needsReview()); + assertEquals(0xFF5555, OreSourceListScreen.groupRowColor(review)); + + session.acceptOreSourcePolicy(key); + GeologyEditorSession.OreSourceGroup accepted = group(session, material); + assertFalse(accepted.needsAttention()); + assertEquals(0xFFFF55, OreSourceListScreen.groupRowColor(accepted)); + + String unrelated = session.addOreMaterialGroup(); + String unrelatedMaterial = unrelated.substring(0, unrelated.indexOf('|')); + assertTrue(session.addOreMaterialAlias(unrelatedMaterial, "oreCopperZinc", false)); + assertFalse(group(session, material).needsReview(), + "editing another group must not reopen an accepted Precious Stones policy"); + + session.setOreSourceOutputMode(key, "balanced"); + GeologyEditorSession.OreSourceGroup balanced = group(session, material); + assertEquals("consolidated", balanced.mode); + assertEquals(2, balanced.outputs.size()); + assertEquals(1, balanced.placements.size()); + assertFalse(balanced.needsAttention()); + } + + @Test + void customGroupRemovalDeletesOnlyEmptyGroupsAndDissolvesPopulatedGroups() { + GeologyEditorSession session = new GeologyEditorSession(profileWithNativePreciousOres()); + session.setManageVanillaOres(true); + + String emptyKey = session.addOreMaterialGroup(); + String emptyMaterial = emptyKey.substring(0, emptyKey.indexOf('|')); + assertTrue(session.deleteEmptyOreMaterialGroup(emptyMaterial)); + assertFalse(session.oreSourceGroups().stream() + .anyMatch(group -> emptyMaterial.equals(group.material))); + + String key = session.addOreMaterialGroup(); + String material = key.substring(0, key.indexOf('|')); + session.renameOreMaterialGroup(material, "Precious Stones"); + assertTrue(session.addOreMaterialAlias(material, "oreDiamond", true)); + assertTrue(session.addOreMaterialAlias(material, "oreEmerald", true)); + assertFalse(session.deleteEmptyOreMaterialGroup(material)); + assertFalse(session.deleteEmptyOreMaterialGroup("orespawn:diamond"), + "discovered groups are facts rather than deletable custom records"); + + assertTrue(session.dissolveOreMaterialGroup(material)); + assertEquals("orespawn:diamond", session.oreDictionaryOwner("oreDiamond")); + assertEquals("orespawn:emerald", session.oreDictionaryOwner("oreEmerald")); + assertTrue(group(session, "orespawn:diamond").hasManagedPlacementSource()); + assertTrue(group(session, "orespawn:emerald").hasManagedPlacementSource()); + assertFalse(session.oreSourceGroups().stream() + .anyMatch(group -> material.equals(group.material))); + assertFalse(key.equals(session.addOreMaterialGroup()), + "dissolved group IDs remain reserved by their dormant policy"); + } + + @Test + void liveGroupReconciliationRetainsSelectedMissingSourcesForDeterministicRestoration() { + JsonObject root = profileWithTwoOreOutputs().rootCopy(); + JsonObject policy = root.getAsJsonObject("ore_source_policies") + .getAsJsonObject("orespawn:sulfur|minecraft:overworld"); + JsonObject missing = policy.getAsJsonArray("candidates").get(1).getAsJsonObject(); + missing.addProperty("loaded", false); + missing.addProperty("placement_active", false); + policy.getAsJsonObject("outputs").entrySet().clear(); + policy.getAsJsonObject("outputs").addProperty("baseminerals:sulfur", 2.0D); + policy.getAsJsonObject("placement_sources") + .addProperty("orespawn:standard", "baseminerals:sulfur"); + + GeologyEditorSession session = new GeologyEditorSession(WorldGeologyProfile.fromJson(root, + WorldGeologyProfile.recommended(false))); + String customKey = session.addOreMaterialGroup(); + String customMaterial = customKey.substring(0, customKey.indexOf('|')); + session.renameOreMaterialGroup(customMaterial, "Unrelated Group"); + + GeologyEditorSession.OreSourceGroup sulfur = group(session, "orespawn:sulfur"); + assertEquals(2.0D, sulfur.outputs.get("baseminerals:sulfur").doubleValue()); + assertEquals("baseminerals:sulfur", sulfur.placements.get("orespawn:standard")); + assertEquals("missing_source", sulfur.status); + } + @Test void balancedSingleAndCustomModesKeepOneExplicitOutputPolicy() { GeologyEditorSession session = new GeologyEditorSession(profileWithTwoOreOutputs()); @@ -514,6 +616,26 @@ private static WorldGeologyProfile profileWithNativeDiamondOre() { recommended.geologyMode(), recommended.placeFluidDeposits()); } + private static WorldGeologyProfile profileWithNativePreciousOres() { + WorldGeologyProfile diamond = profileWithNativeDiamondOre(); + JsonObject root = diamond.rootCopy(); + JsonObject emerald = new JsonObject(); + emerald.addProperty("enabled", true); + emerald.addProperty("source_mod", "minecraft"); + emerald.addProperty("native_generation", true); + emerald.addProperty("block", "minecraft:emerald_ore"); + JsonObject rule = new JsonObject(); + rule.addProperty("enabled", true); + rule.addProperty("pattern", "clusters"); + JsonObject dimensions = new JsonObject(); + dimensions.add("minecraft:overworld", rule); + emerald.add("dimensions", dimensions); + root.getAsJsonObject("ores").add("minecraft:emerald_ore", emerald); + root.add("ore_source_policies", new JsonObject()); + return WorldGeologyProfile.fromGlobalConfig(root, + diamond.geologyMode(), diamond.placeFluidDeposits()); + } + private static GeologyEditorSession.OreSourceGroup group( GeologyEditorSession session, String material) { return session.oreSourceGroups().stream() diff --git a/src/test/java/zone/moddev/mc/orespawn/client/OreSourcePlacementScreenTest.java b/src/test/java/zone/moddev/mc/orespawn/client/OreSourcePlacementScreenTest.java index f894bb77..ce8636c0 100644 --- a/src/test/java/zone/moddev/mc/orespawn/client/OreSourcePlacementScreenTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/client/OreSourcePlacementScreenTest.java @@ -54,6 +54,27 @@ void keepOriginalAndSingleManagedSourcesRemainReadOnly() { Collections.emptyMap()).hasManagedPlacementSource()); } + @Test + void customGroupActionsAndPlacementChoiceCountsDescribeTheCurrentState() { + OreSourceCandidate first = candidate("first", "orespawn:standard", true, true, false); + OreSourceCandidate second = candidate("second", "orespawn:standard", true, true, false); + OreSourceGroup empty = customGroup(Collections.emptyList(), Collections.emptyList()); + OreSourceGroup populated = customGroup(Collections.singletonList("oreTest"), + Arrays.asList(first, second)); + OreSourceGroup discovered = group("keep_separate", "review_required", + Collections.singletonList("oreTest"), Arrays.asList(first, second)); + + assertTrue(empty.isCustom()); + assertTrue(empty.canDeleteEmpty()); + assertFalse(empty.canDissolve()); + assertTrue(populated.isCustom()); + assertFalse(populated.canDeleteEmpty()); + assertTrue(populated.canDissolve()); + assertFalse(discovered.isCustom()); + assertFalse(discovered.canDeleteEmpty()); + assertFalse(discovered.canDissolve()); + } + @Test void groupColoursSeparateRoutineResolvedAndUnresolvedEntries() { OreSourceCandidate first = candidate("first", "first:ore", "orespawn:standard", @@ -108,4 +129,12 @@ private static OreSourceGroup group(String mode, String status, List ali candidates, Collections.unmodifiableMap(new LinkedHashMap<>()), Collections.emptyMap()); } + + private static OreSourceGroup customGroup(List aliases, + List candidates) { + return new OreSourceGroup("orespawn:custom/custom_group|minecraft:overworld", + "orespawn:custom/custom_group", "minecraft:overworld", "Custom Group", + aliases, false, "keep_separate", "custom", "review_required", candidates, + Collections.emptyMap(), Collections.emptyMap()); + } } 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 e490fe79..9af02f21 100644 --- a/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java @@ -150,6 +150,13 @@ void groupSettingsOwnsAliasesAndClearlyNamedPlacementRules() throws Exception { assertTrue(settings.contains("new TextComponentString(\"?\")")); assertTrue(settings.contains("label.orespawn.ore_source.standard_veins")); assertTrue(settings.contains("placementSelectable(group, channel)")); + assertTrue(settings.contains("placementSourceLabel(source, choices.size())")); + assertTrue(settings.contains("group.canDeleteEmpty()")); + assertTrue(settings.contains("group.canDissolve()")); + assertTrue(settings.contains("button.orespawn.ore_source.confirm_dissolve")); + assertTrue(settings.contains("session.deleteEmptyOreMaterialGroup(group.material)")); + assertTrue(settings.contains("session.dissolveOreMaterialGroup(group.material)")); + assertFalse(settings.contains("session.deleteOreMaterialGroup(group.material)")); assertTrue(settings.contains("contentX + contentWidth - doneWidth, height - 28")); assertTrue(settings.contains("contentX + 2, height - 22, 0xFF5555")); assertTrue(settings.contains("renderStringTooltip(java.util.Collections.singletonList(error)")); From 281dd182fb15b715423860294fef1096e263d9cb Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Tue, 22 Sep 2026 11:25:55 +0100 Subject: [PATCH 22/31] Persist and verify Ore Source defaults --- CHANGELOG.txt | 4 + README.md | 5 +- build.gradle | 5 + docs/CONFIGURATION.md | 6 + docs/PLAYER_GUIDE.md | 3 + .../client/OreSourceEditorProbeBridge.java | 116 ++++++ .../orespawn/testmod/SurfaceProbeTestMod.java | 368 +++++++++++++++++- .../orespawn/client/GeologyEditorSession.java | 17 +- .../orespawn/client/OreSourceListScreen.java | 31 +- .../orespawn/worldgen/OreSourcePolicies.java | 26 ++ .../resources/assets/orespawn/lang/de_AU.lang | 3 + .../resources/assets/orespawn/lang/de_DE.lang | 3 + .../resources/assets/orespawn/lang/en_CA.lang | 3 + .../resources/assets/orespawn/lang/en_EN.lang | 3 + .../resources/assets/orespawn/lang/en_GB.lang | 3 + .../resources/assets/orespawn/lang/en_PT.lang | 3 + .../resources/assets/orespawn/lang/en_US.lang | 3 + .../resources/assets/orespawn/lang/es_ES.lang | 3 + .../resources/assets/orespawn/lang/es_MX.lang | 3 + .../resources/assets/orespawn/lang/fr_CA.lang | 3 + .../resources/assets/orespawn/lang/fr_FR.lang | 3 + .../resources/assets/orespawn/lang/ja_JP.lang | 3 + .../resources/assets/orespawn/lang/ko_KR.lang | 3 + .../resources/assets/orespawn/lang/pt_BR.lang | 3 + .../resources/assets/orespawn/lang/ru_RU.lang | 3 + .../resources/assets/orespawn/lang/zh_CN.lang | 3 + .../mc/orespawn/LocalizationParityTest.java | 2 +- .../client/GeologyEditorSessionTest.java | 58 +++ .../client/OreSpawnScreenLayoutTest.java | 5 + 29 files changed, 689 insertions(+), 5 deletions(-) create mode 100644 src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/client/OreSourceEditorProbeBridge.java diff --git a/CHANGELOG.txt b/CHANGELOG.txt index b17aedcb..5d190a73 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -51,6 +51,10 @@ Version 4.1.0.110021 * Restrict Delete to empty custom groups. Populated custom groups use a confirmed Dissolve Group action that restores every alias to its inferred or curated group; discovered groups remain non-deletable facts. +* Keep reassigned source policies dormant when a saved custom group is reopened, + so their former inferred groups do not reappear as false conflicts. Add a + confirmed Reset All action that restores built-in and inferred Ore Sources + defaults inside the pending editor session without rewriting existing chunks. * Add provider schema 5 material and placement-channel declarations, global schema 8 and world-profile schema 7 while retaining older readable schemas. * Add an extended, binary-compatible ore generation context exposing stable diff --git a/README.md b/README.md index 67de6ddf..450c39ff 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,10 @@ chooses one output, **Custom** allows a weighted subset, and **Keep Original** preserves every active rule and restores the selections that were present when the editor was opened. A red Keep Original group exposes **Accept** so its unchanged policy can be marked reviewed; this remains pending until the main -editor's **Done** is used. A Placement Rules row reports how many managed +editor's **Done** is used. **Reset All** requires confirmation, removes custom +grouping and output choices from the pending edit, then rebuilds the built-in +and inferred groups from the currently loaded ores. It also remains pending +until the main editor's **Done** is used, while Cancel discards it. A Placement Rules row reports how many managed sources are available, can cycle among them only in a consolidated mode, and its help explains that the choice is inactive under Keep Original. Existing worlds start in diff --git a/build.gradle b/build.gradle index f309bc9e..28b9d797 100644 --- a/build.gradle +++ b/build.gradle @@ -396,6 +396,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}") @@ -1057,6 +1060,7 @@ tasks.register('verifyReleaseArtifacts') { ['src/test/', 'src/biomeIntegrationTest/', 'src/migrationIntegrationTest/', 'src/clientIntegrationTest/', 'src/os1AbiFixture/', 'src/os3AbiFixture/', 'agent-notes/', 'surfaceprobe', 'migrationprobe', 'clientprobe', + 'OreSourceEditorProbeBridge', 'org/junit/', 'org/mockito/', 'net/bytebuddy/'].each { forbidden -> if (candidateZip.entries().any { it.name.contains(forbidden) }) { throw new GradleException( @@ -1494,6 +1498,7 @@ tasks.register('verifyEclipseProductionClasspath') { 'src/test', 'bin/test', 'build/classes/java/test', 'biomeIntegrationTest', 'migrationIntegrationTest', 'clientIntegrationTest', 'os1AbiFixture', 'os3AbiFixture', 'surfaceprobe', 'migrationprobe', 'clientprobe', + 'OreSourceEditorProbeBridge', 'junit-', 'opentest4j-', 'junit-platform-', 'Mineralogy-1.10.2-3.3.8.26.jar' ] launchFiles.each { launch -> diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 17558512..5dd6bbf7 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -245,6 +245,12 @@ 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, diff --git a/docs/PLAYER_GUIDE.md b/docs/PLAYER_GUIDE.md index 6041cb76..2f92b111 100644 --- a/docs/PLAYER_GUIDE.md +++ b/docs/PLAYER_GUIDE.md @@ -59,6 +59,9 @@ safely instead of breaking world creation. 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 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 eef6b699..2e32bf6f 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; @@ -41,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; @@ -94,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(), @@ -159,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) + .setUnlocalizedName(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)); @@ -188,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 @@ -245,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); @@ -257,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)); @@ -289,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; @@ -303,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(new JsonPrimitive(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); } @@ -316,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, @@ -371,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); @@ -383,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")); @@ -395,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); } @@ -403,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.worldServerForDimension(dimension); if (world == null) { @@ -853,6 +1182,43 @@ int placements() { } } + 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/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java b/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java index 4310eca3..56824cfa 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java @@ -61,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) { @@ -73,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"); @@ -597,6 +600,18 @@ 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); @@ -791,7 +806,7 @@ void acceptOreSourcePolicy(String key) { void restoreOreSourceOriginalMode(String key) { JsonObject policy = objectEntry(section("ore_source_policies"), key); - JsonObject originalPolicies = object(original, "ore_source_policies"); + 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"); diff --git a/src/main/java/zone/moddev/mc/orespawn/client/OreSourceListScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceListScreen.java index bc40717a..281cb6e8 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/OreSourceListScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceListScreen.java @@ -32,6 +32,7 @@ final class OreSourceListScreen extends OreSpawnScreen { private String selectedKey; private String error; private boolean showAll; + private boolean resetArmed; private int groupScroll; private int outputScroll; private int leftPaneX; @@ -65,10 +66,36 @@ protected void init() { initGroups(groups); initOutputs(selected(groups)); - addButton(new Button(width / 2 - 75, height - 28, 150, 20, + 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, @@ -268,6 +295,7 @@ private void updateWeightBounds() { } 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"); @@ -299,6 +327,7 @@ private boolean syncWeights() { } private void captureScroll() { + resetArmed = false; if (groupList != null) groupScroll = groupList.firstIndex(); if (outputList != null) outputScroll = outputList.firstIndex(); } diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSourcePolicies.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSourcePolicies.java index b19f5a48..84036954 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSourcePolicies.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSourcePolicies.java @@ -67,6 +67,7 @@ static boolean initialize(JsonObject root, boolean existingWorld) { ? 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; @@ -82,6 +83,9 @@ static boolean initialize(JsonObject root, boolean existingWorld) { 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; @@ -91,6 +95,28 @@ static boolean initialize(JsonObject root, boolean existingWorld) { 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); diff --git a/src/main/resources/assets/orespawn/lang/de_AU.lang b/src/main/resources/assets/orespawn/lang/de_AU.lang index 39d6db89..c262e141 100644 --- a/src/main/resources/assets/orespawn/lang/de_AU.lang +++ b/src/main/resources/assets/orespawn/lang/de_AU.lang @@ -467,3 +467,6 @@ tooltip.orespawn.ore_source.placement_select=Klicken, um zwischen aktiven verwal tooltip.orespawn.ore_source.external_excluded=Unabhängige externe Generatoren werden gemeldet, können aber nicht als Platzierungsregeln gewählt werden. button.orespawn.ore_source.hide_single=Einzelne ausblenden tooltip.orespawn.ore_source.group_colours=Grüne Einträge haben einen Alias und eine Ausgabe und benötigen keine Aktion. Gelbe Gruppen haben eine gespeicherte Regel. Rote Gruppen benötigen Aufmerksamkeit. Alle anzeigen schließt grüne Einträge ein. +button.orespawn.ore_source.reset_all=Alles zurücksetzen +button.orespawn.ore_source.confirm_reset_all=Zurücksetzen bestätigen +tooltip.orespawn.ore_source.reset_all=Stellt die eingebauten Materialgruppen wieder her und erzeugt die Quellenregeln aus den aktuell geladenen Erzen neu. Die Änderung bleibt bis Fertig im Haupteditor ausstehend. diff --git a/src/main/resources/assets/orespawn/lang/de_DE.lang b/src/main/resources/assets/orespawn/lang/de_DE.lang index 39d6db89..c262e141 100644 --- a/src/main/resources/assets/orespawn/lang/de_DE.lang +++ b/src/main/resources/assets/orespawn/lang/de_DE.lang @@ -467,3 +467,6 @@ tooltip.orespawn.ore_source.placement_select=Klicken, um zwischen aktiven verwal tooltip.orespawn.ore_source.external_excluded=Unabhängige externe Generatoren werden gemeldet, können aber nicht als Platzierungsregeln gewählt werden. button.orespawn.ore_source.hide_single=Einzelne ausblenden tooltip.orespawn.ore_source.group_colours=Grüne Einträge haben einen Alias und eine Ausgabe und benötigen keine Aktion. Gelbe Gruppen haben eine gespeicherte Regel. Rote Gruppen benötigen Aufmerksamkeit. Alle anzeigen schließt grüne Einträge ein. +button.orespawn.ore_source.reset_all=Alles zurücksetzen +button.orespawn.ore_source.confirm_reset_all=Zurücksetzen bestätigen +tooltip.orespawn.ore_source.reset_all=Stellt die eingebauten Materialgruppen wieder her und erzeugt die Quellenregeln aus den aktuell geladenen Erzen neu. Die Änderung bleibt bis Fertig im Haupteditor ausstehend. diff --git a/src/main/resources/assets/orespawn/lang/en_CA.lang b/src/main/resources/assets/orespawn/lang/en_CA.lang index 669dec9b..d94b0fd0 100644 --- a/src/main/resources/assets/orespawn/lang/en_CA.lang +++ b/src/main/resources/assets/orespawn/lang/en_CA.lang @@ -467,3 +467,6 @@ tooltip.orespawn.ore_source.placement_select=Click to choose between active mana tooltip.orespawn.ore_source.external_excluded=Independent external generators are reported but cannot be selected as placement rules. button.orespawn.ore_source.hide_single=Hide Singles tooltip.orespawn.ore_source.group_colours=Green entries have one alias and one output and need no action. Yellow groups have a saved rule. Red groups need attention. Show All includes green entries. +button.orespawn.ore_source.reset_all=Reset All +button.orespawn.ore_source.confirm_reset_all=Confirm Reset +tooltip.orespawn.ore_source.reset_all=Restore built-in material groups and recreate source policies from the currently loaded ores. The change remains pending until the main editor's Done button is used. diff --git a/src/main/resources/assets/orespawn/lang/en_EN.lang b/src/main/resources/assets/orespawn/lang/en_EN.lang index 669dec9b..d94b0fd0 100644 --- a/src/main/resources/assets/orespawn/lang/en_EN.lang +++ b/src/main/resources/assets/orespawn/lang/en_EN.lang @@ -467,3 +467,6 @@ tooltip.orespawn.ore_source.placement_select=Click to choose between active mana tooltip.orespawn.ore_source.external_excluded=Independent external generators are reported but cannot be selected as placement rules. button.orespawn.ore_source.hide_single=Hide Singles tooltip.orespawn.ore_source.group_colours=Green entries have one alias and one output and need no action. Yellow groups have a saved rule. Red groups need attention. Show All includes green entries. +button.orespawn.ore_source.reset_all=Reset All +button.orespawn.ore_source.confirm_reset_all=Confirm Reset +tooltip.orespawn.ore_source.reset_all=Restore built-in material groups and recreate source policies from the currently loaded ores. The change remains pending until the main editor's Done button is used. diff --git a/src/main/resources/assets/orespawn/lang/en_GB.lang b/src/main/resources/assets/orespawn/lang/en_GB.lang index 669dec9b..d94b0fd0 100644 --- a/src/main/resources/assets/orespawn/lang/en_GB.lang +++ b/src/main/resources/assets/orespawn/lang/en_GB.lang @@ -467,3 +467,6 @@ tooltip.orespawn.ore_source.placement_select=Click to choose between active mana tooltip.orespawn.ore_source.external_excluded=Independent external generators are reported but cannot be selected as placement rules. button.orespawn.ore_source.hide_single=Hide Singles tooltip.orespawn.ore_source.group_colours=Green entries have one alias and one output and need no action. Yellow groups have a saved rule. Red groups need attention. Show All includes green entries. +button.orespawn.ore_source.reset_all=Reset All +button.orespawn.ore_source.confirm_reset_all=Confirm Reset +tooltip.orespawn.ore_source.reset_all=Restore built-in material groups and recreate source policies from the currently loaded ores. The change remains pending until the main editor's Done button is used. diff --git a/src/main/resources/assets/orespawn/lang/en_PT.lang b/src/main/resources/assets/orespawn/lang/en_PT.lang index 669dec9b..d94b0fd0 100644 --- a/src/main/resources/assets/orespawn/lang/en_PT.lang +++ b/src/main/resources/assets/orespawn/lang/en_PT.lang @@ -467,3 +467,6 @@ tooltip.orespawn.ore_source.placement_select=Click to choose between active mana tooltip.orespawn.ore_source.external_excluded=Independent external generators are reported but cannot be selected as placement rules. button.orespawn.ore_source.hide_single=Hide Singles tooltip.orespawn.ore_source.group_colours=Green entries have one alias and one output and need no action. Yellow groups have a saved rule. Red groups need attention. Show All includes green entries. +button.orespawn.ore_source.reset_all=Reset All +button.orespawn.ore_source.confirm_reset_all=Confirm Reset +tooltip.orespawn.ore_source.reset_all=Restore built-in material groups and recreate source policies from the currently loaded ores. The change remains pending until the main editor's Done button is used. diff --git a/src/main/resources/assets/orespawn/lang/en_US.lang b/src/main/resources/assets/orespawn/lang/en_US.lang index 8ad633e9..b2f12c24 100644 --- a/src/main/resources/assets/orespawn/lang/en_US.lang +++ b/src/main/resources/assets/orespawn/lang/en_US.lang @@ -467,3 +467,6 @@ tooltip.orespawn.ore_source.placement_select=Click to choose between active mana tooltip.orespawn.ore_source.external_excluded=Independent external generators are reported but cannot be selected as placement rules. button.orespawn.ore_source.hide_single=Hide Singles tooltip.orespawn.ore_source.group_colours=Green entries have one alias and one output and need no action. Yellow groups have a saved rule. Red groups need attention. Show All includes green entries. +button.orespawn.ore_source.reset_all=Reset All +button.orespawn.ore_source.confirm_reset_all=Confirm Reset +tooltip.orespawn.ore_source.reset_all=Restore built-in material groups and recreate source policies from the currently loaded ores. The change remains pending until the main editor's Done button is used. diff --git a/src/main/resources/assets/orespawn/lang/es_ES.lang b/src/main/resources/assets/orespawn/lang/es_ES.lang index 44ba941d..dcb749dd 100644 --- a/src/main/resources/assets/orespawn/lang/es_ES.lang +++ b/src/main/resources/assets/orespawn/lang/es_ES.lang @@ -467,3 +467,6 @@ tooltip.orespawn.ore_source.placement_select=Haz clic para elegir entre fuentes tooltip.orespawn.ore_source.external_excluded=Los generadores externos se informan, pero no pueden elegirse como reglas de colocación. button.orespawn.ore_source.hide_single=Ocultar individuales tooltip.orespawn.ore_source.group_colours=Las entradas verdes tienen un alias y una salida y no requieren acción. Los grupos amarillos tienen una regla guardada. Los grupos rojos requieren atención. Mostrar todo incluye las entradas verdes. +button.orespawn.ore_source.reset_all=Restablecer todo +button.orespawn.ore_source.confirm_reset_all=Confirmar restablecimiento +tooltip.orespawn.ore_source.reset_all=Restaura los grupos de materiales incorporados y vuelve a crear las políticas desde los minerales cargados. El cambio queda pendiente hasta pulsar Hecho en el editor principal. diff --git a/src/main/resources/assets/orespawn/lang/es_MX.lang b/src/main/resources/assets/orespawn/lang/es_MX.lang index 44ba941d..dcb749dd 100644 --- a/src/main/resources/assets/orespawn/lang/es_MX.lang +++ b/src/main/resources/assets/orespawn/lang/es_MX.lang @@ -467,3 +467,6 @@ tooltip.orespawn.ore_source.placement_select=Haz clic para elegir entre fuentes tooltip.orespawn.ore_source.external_excluded=Los generadores externos se informan, pero no pueden elegirse como reglas de colocación. button.orespawn.ore_source.hide_single=Ocultar individuales tooltip.orespawn.ore_source.group_colours=Las entradas verdes tienen un alias y una salida y no requieren acción. Los grupos amarillos tienen una regla guardada. Los grupos rojos requieren atención. Mostrar todo incluye las entradas verdes. +button.orespawn.ore_source.reset_all=Restablecer todo +button.orespawn.ore_source.confirm_reset_all=Confirmar restablecimiento +tooltip.orespawn.ore_source.reset_all=Restaura los grupos de materiales incorporados y vuelve a crear las políticas desde los minerales cargados. El cambio queda pendiente hasta pulsar Hecho en el editor principal. diff --git a/src/main/resources/assets/orespawn/lang/fr_CA.lang b/src/main/resources/assets/orespawn/lang/fr_CA.lang index 81a319d0..66fb6bd8 100644 --- a/src/main/resources/assets/orespawn/lang/fr_CA.lang +++ b/src/main/resources/assets/orespawn/lang/fr_CA.lang @@ -467,3 +467,6 @@ tooltip.orespawn.ore_source.placement_select=Cliquez pour choisir entre les sour tooltip.orespawn.ore_source.external_excluded=Les générateurs externes sont signalés mais ne peuvent pas être choisis comme règles de placement. button.orespawn.ore_source.hide_single=Masquer les entrées simples tooltip.orespawn.ore_source.group_colours=Les entrées vertes ont un alias et une sortie et ne demandent aucune action. Les groupes jaunes ont une règle enregistrée. Les groupes rouges demandent votre attention. Afficher tout inclut les entrées vertes. +button.orespawn.ore_source.reset_all=Tout réinitialiser +button.orespawn.ore_source.confirm_reset_all=Confirmer la réinitialisation +tooltip.orespawn.ore_source.reset_all=Restaure les groupes intégrés et recrée les règles depuis les minerais chargés. Le changement reste en attente jusqu'au bouton Terminé de l'éditeur principal. diff --git a/src/main/resources/assets/orespawn/lang/fr_FR.lang b/src/main/resources/assets/orespawn/lang/fr_FR.lang index 81a319d0..66fb6bd8 100644 --- a/src/main/resources/assets/orespawn/lang/fr_FR.lang +++ b/src/main/resources/assets/orespawn/lang/fr_FR.lang @@ -467,3 +467,6 @@ tooltip.orespawn.ore_source.placement_select=Cliquez pour choisir entre les sour tooltip.orespawn.ore_source.external_excluded=Les générateurs externes sont signalés mais ne peuvent pas être choisis comme règles de placement. button.orespawn.ore_source.hide_single=Masquer les entrées simples tooltip.orespawn.ore_source.group_colours=Les entrées vertes ont un alias et une sortie et ne demandent aucune action. Les groupes jaunes ont une règle enregistrée. Les groupes rouges demandent votre attention. Afficher tout inclut les entrées vertes. +button.orespawn.ore_source.reset_all=Tout réinitialiser +button.orespawn.ore_source.confirm_reset_all=Confirmer la réinitialisation +tooltip.orespawn.ore_source.reset_all=Restaure les groupes intégrés et recrée les règles depuis les minerais chargés. Le changement reste en attente jusqu'au bouton Terminé de l'éditeur principal. diff --git a/src/main/resources/assets/orespawn/lang/ja_JP.lang b/src/main/resources/assets/orespawn/lang/ja_JP.lang index c83d0991..490a603f 100644 --- a/src/main/resources/assets/orespawn/lang/ja_JP.lang +++ b/src/main/resources/assets/orespawn/lang/ja_JP.lang @@ -467,3 +467,6 @@ tooltip.orespawn.ore_source.placement_select=クリックして有効な管理 tooltip.orespawn.ore_source.external_excluded=独立した外部生成は報告されますが、配置ルールには選択できません。 button.orespawn.ore_source.hide_single=単一項目を隠す tooltip.orespawn.ore_source.group_colours=緑は別名と出力が各1つで操作不要です。黄は保存済みルールがあるグループです。赤は確認が必要です。すべて表示で緑の項目も表示します。 +button.orespawn.ore_source.reset_all=すべてリセット +button.orespawn.ore_source.confirm_reset_all=リセットを確認 +tooltip.orespawn.ore_source.reset_all=組み込みの素材グループを復元し、現在読み込まれている鉱石からソースポリシーを再作成します。変更はメイン画面で完了を押すまで保留されます。 diff --git a/src/main/resources/assets/orespawn/lang/ko_KR.lang b/src/main/resources/assets/orespawn/lang/ko_KR.lang index 9981067b..9859e618 100644 --- a/src/main/resources/assets/orespawn/lang/ko_KR.lang +++ b/src/main/resources/assets/orespawn/lang/ko_KR.lang @@ -467,3 +467,6 @@ tooltip.orespawn.ore_source.placement_select=활성 관리 배치 소스 중 하 tooltip.orespawn.ore_source.external_excluded=독립 외부 생성기는 보고되지만 배치 규칙으로 선택할 수 없습니다. button.orespawn.ore_source.hide_single=단일 항목 숨기기 tooltip.orespawn.ore_source.group_colours=녹색 항목은 별칭과 출력이 각각 하나라 조치가 필요 없습니다. 노란색 그룹에는 저장된 규칙이 있습니다. 빨간색 그룹은 확인이 필요합니다. 모두 표시를 누르면 녹색 항목도 표시됩니다. +button.orespawn.ore_source.reset_all=모두 초기화 +button.orespawn.ore_source.confirm_reset_all=초기화 확인 +tooltip.orespawn.ore_source.reset_all=기본 재료 그룹을 복원하고 현재 불러온 광석에서 소스 정책을 다시 만듭니다. 변경 사항은 기본 편집기에서 완료를 누를 때까지 보류됩니다. diff --git a/src/main/resources/assets/orespawn/lang/pt_BR.lang b/src/main/resources/assets/orespawn/lang/pt_BR.lang index 2cb19176..5eb6d436 100644 --- a/src/main/resources/assets/orespawn/lang/pt_BR.lang +++ b/src/main/resources/assets/orespawn/lang/pt_BR.lang @@ -467,3 +467,6 @@ tooltip.orespawn.ore_source.placement_select=Clique para escolher entre fontes d tooltip.orespawn.ore_source.external_excluded=Geradores externos independentes são informados, mas não podem ser escolhidos como regras de colocação. button.orespawn.ore_source.hide_single=Ocultar individuais tooltip.orespawn.ore_source.group_colours=Entradas verdes têm um alias e uma saída e não exigem ação. Grupos amarelos têm uma regra salva. Grupos vermelhos exigem atenção. Mostrar tudo inclui as entradas verdes. +button.orespawn.ore_source.reset_all=Redefinir tudo +button.orespawn.ore_source.confirm_reset_all=Confirmar redefinição +tooltip.orespawn.ore_source.reset_all=Restaura os grupos de materiais internos e recria as políticas a partir dos minérios carregados. A mudança fica pendente até usar Concluído no editor principal. diff --git a/src/main/resources/assets/orespawn/lang/ru_RU.lang b/src/main/resources/assets/orespawn/lang/ru_RU.lang index ce2a0966..3b12bf02 100644 --- a/src/main/resources/assets/orespawn/lang/ru_RU.lang +++ b/src/main/resources/assets/orespawn/lang/ru_RU.lang @@ -467,3 +467,6 @@ tooltip.orespawn.ore_source.placement_select=Нажмите для выбора tooltip.orespawn.ore_source.external_excluded=Независимые внешние генераторы отображаются, но не могут быть выбраны как правила размещения. button.orespawn.ore_source.hide_single=Скрыть одиночные tooltip.orespawn.ore_source.group_colours=Зелёные записи имеют один псевдоним и один выход и не требуют действий. Жёлтые группы имеют сохранённое правило. Красные группы требуют внимания. Показать все также показывает зелёные записи. +button.orespawn.ore_source.reset_all=Сбросить всё +button.orespawn.ore_source.confirm_reset_all=Подтвердить сброс +tooltip.orespawn.ore_source.reset_all=Восстанавливает встроенные группы материалов и заново создаёт правила по загруженным рудам. Изменение остаётся ожидающим до нажатия Готово в главном редакторе. diff --git a/src/main/resources/assets/orespawn/lang/zh_CN.lang b/src/main/resources/assets/orespawn/lang/zh_CN.lang index 92ce5897..f8292e05 100644 --- a/src/main/resources/assets/orespawn/lang/zh_CN.lang +++ b/src/main/resources/assets/orespawn/lang/zh_CN.lang @@ -467,3 +467,6 @@ tooltip.orespawn.ore_source.placement_select=点击以选择有效的受管理 tooltip.orespawn.ore_source.external_excluded=独立外部生成器会显示,但不能选作放置规则。 button.orespawn.ore_source.hide_single=隐藏单项 tooltip.orespawn.ore_source.group_colours=绿色条目只有一个别名和一个输出,无需处理。黄色分组已有保存的规则。红色分组需要处理。显示全部会包含绿色条目。 +button.orespawn.ore_source.reset_all=全部重置 +button.orespawn.ore_source.confirm_reset_all=确认重置 +tooltip.orespawn.ore_source.reset_all=恢复内置材料分组,并根据当前加载的矿石重新创建来源策略。更改会保持待定,直到在主编辑器中点击完成。 diff --git a/src/test/java/zone/moddev/mc/orespawn/LocalizationParityTest.java b/src/test/java/zone/moddev/mc/orespawn/LocalizationParityTest.java index 3a0932b9..2f1c1129 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(469, englishKeys.size(), + assertEquals(472, 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/client/GeologyEditorSessionTest.java b/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java index d37f3b28..28d120bc 100644 --- a/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java @@ -362,6 +362,64 @@ void managedVanillaAliasesBuildOneLiveReviewWithoutEmptyOrStaleOutputs() { assertFalse(balanced.needsAttention()); } + @Test + void customAliasOwnershipSurvivesGlobalReloadWithoutReopeningOldGroups() { + WorldGeologyProfile original = profileWithNativePreciousOres(); + GeologyEditorSession session = new GeologyEditorSession(original); + session.setManageVanillaOres(true); + String key = session.addOreMaterialGroup(); + String material = key.substring(0, key.indexOf('|')); + session.renameOreMaterialGroup(material, "Precious Stones"); + assertTrue(session.addOreMaterialAlias(material, "oreDiamond", true)); + assertTrue(session.addOreMaterialAlias(material, "oreEmerald", true)); + + WorldGeologyProfile reloaded = WorldGeologyProfile.fromGlobalConfig( + session.profile().rootCopy(), session.profile().geologyMode(), + session.profile().placeFluidDeposits()); + GeologyEditorSession reopened = new GeologyEditorSession(reloaded); + + assertFalse(reopened.oreSourceGroups().stream() + .anyMatch(group -> "orespawn:diamond".equals(group.material))); + assertFalse(reopened.oreSourceGroups().stream() + .anyMatch(group -> "orespawn:emerald".equals(group.material))); + GeologyEditorSession.OreSourceGroup restored = group(reopened, material); + assertEquals(2, restored.outputCandidates().size()); + assertEquals(Arrays.asList("oreDiamond", "oreEmerald"), restored.oreDictionaryEntries); + assertTrue(reloaded.rootCopy().getAsJsonObject("ore_source_policies") + .getAsJsonObject("orespawn:diamond|minecraft:overworld") + .get("dormant").getAsBoolean()); + assertTrue(reloaded.rootCopy().getAsJsonObject("ore_source_policies") + .getAsJsonObject("orespawn:emerald|minecraft:overworld") + .get("dormant").getAsBoolean()); + } + + @Test + void resetAllOreSourcesRestoresBuiltInGroupsAndKeepsTheChangePending() { + GeologyEditorSession setup = new GeologyEditorSession(profileWithNativePreciousOres()); + setup.setManageVanillaOres(true); + String key = setup.addOreMaterialGroup(); + String material = key.substring(0, key.indexOf('|')); + setup.renameOreMaterialGroup(material, "Precious Stones"); + assertTrue(setup.addOreMaterialAlias(material, "oreDiamond", true)); + assertTrue(setup.addOreMaterialAlias(material, "oreEmerald", true)); + WorldGeologyProfile savedCustomProfile = setup.profile(); + JsonObject savedCustomJson = savedCustomProfile.rootCopy(); + GeologyEditorSession session = new GeologyEditorSession(savedCustomProfile); + + session.resetOreSourcesToDefaults(); + + assertTrue(session.profile().manageVanillaOres()); + assertEquals("orespawn:diamond", session.oreDictionaryOwner("oreDiamond")); + assertEquals("orespawn:emerald", session.oreDictionaryOwner("oreEmerald")); + assertFalse(session.oreSourceGroups().stream() + .anyMatch(group -> material.equals(group.material))); + assertEquals(1, group(session, "orespawn:diamond").outputCandidates().size()); + assertEquals(1, group(session, "orespawn:emerald").outputCandidates().size()); + assertTrue(session.oreMaterialGroupsChanged()); + assertEquals(savedCustomJson, savedCustomProfile.rootCopy(), + "Reset All must remain pending until the main editor saves it"); + } + @Test void customGroupRemovalDeletesOnlyEmptyGroupsAndDissolvesPopulatedGroups() { GeologyEditorSession session = new GeologyEditorSession(profileWithNativePreciousOres()); 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 9af02f21..ece8f875 100644 --- a/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java @@ -109,6 +109,11 @@ void oreSourceEditorKeepsBothCompactScrollablePanesVisible() throws Exception { assertTrue(list.contains("compactFilterWidth")); assertTrue(list.contains("button.orespawn.ore_source.accept")); assertTrue(list.contains("acceptOreSourcePolicy(group.key)")); + assertTrue(list.contains("button.orespawn.ore_source.reset_all")); + assertTrue(list.contains("button.orespawn.ore_source.confirm_reset_all")); + assertTrue(list.contains("session.resetOreSourcesToDefaults()")); + assertTrue(list.contains("leftPaneX, height - 28")); + assertTrue(list.contains("rightPaneX + rightPaneWidth - doneWidth, height - 28")); assertEquals(58, OreSourceListScreen.compactFilterWidth(110, 48)); assertTrue(list.contains("groupRowColor")); assertTrue(list.contains("drawCompactCog")); From 51791dc3db4ab48f8894a87408074731092d1ad0 Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Tue, 22 Sep 2026 11:37:01 +0100 Subject: [PATCH 23/31] Model biome directory and placement overrides --- .../orespawn/client/BiomeDirectoryModel.java | 269 ++++++++++++++++++ .../client/BiomeReplacementRules.java | 153 ++++++++++ .../orespawn/client/GeologyEditorSession.java | 157 ++++++++++ .../WorldgenIntegrationManager.java | 45 +++ .../worldgen/BiomeWorldgenManager.java | 15 +- .../client/BiomeDirectoryModelTest.java | 117 ++++++++ .../client/BiomeReplacementRulesTest.java | 59 ++++ .../worldgen/BiomePaletteOrderingTest.java | 26 ++ 8 files changed, 840 insertions(+), 1 deletion(-) create mode 100644 src/main/java/zone/moddev/mc/orespawn/client/BiomeDirectoryModel.java create mode 100644 src/main/java/zone/moddev/mc/orespawn/client/BiomeReplacementRules.java create mode 100644 src/test/java/zone/moddev/mc/orespawn/client/BiomeDirectoryModelTest.java create mode 100644 src/test/java/zone/moddev/mc/orespawn/client/BiomeReplacementRulesTest.java create mode 100644 src/test/java/zone/moddev/mc/orespawn/worldgen/BiomePaletteOrderingTest.java diff --git a/src/main/java/zone/moddev/mc/orespawn/client/BiomeDirectoryModel.java b/src/main/java/zone/moddev/mc/orespawn/client/BiomeDirectoryModel.java new file mode 100644 index 00000000..d19b4ea1 --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/BiomeDirectoryModel.java @@ -0,0 +1,269 @@ +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.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import zone.moddev.mc.orespawn.integration.WorldgenIntegrationManager.BiomeProviderDefaultsSnapshot; +import zone.moddev.mc.orespawn.util.JsonCopies; + +import net.minecraft.util.ResourceLocation; +import net.minecraft.world.biome.Biome; +import net.minecraftforge.fml.common.Loader; +import net.minecraftforge.fml.common.ModContainer; +import net.minecraftforge.fml.common.registry.ForgeRegistries; + +/** Immutable projection of loaded biomes, profile palettes, and provider defaults. */ +final class BiomeDirectoryModel { + private BiomeDirectoryModel() { } + + static Snapshot snapshot(JsonObject profile, BiomeProviderDefaultsSnapshot defaults, + Iterable dimensions) { + List loaded = new ArrayList<>(); + Map mods = new LinkedHashMap<>(); + for (ModContainer mod : Loader.instance().getActiveModList()) { + mods.put(mod.getModId(), new ModInfo(mod.getModId(), mod.getName(), mod.getVersion())); + } + mods.putIfAbsent("minecraft", new ModInfo("minecraft", "Minecraft", "1.10.2")); + for (Entry entry : ForgeRegistries.BIOMES.getEntries()) { + ModInfo owner = mods.get(entry.getKey().getResourceDomain()); + loaded.add(new LoadedBiome(entry.getKey().toString(), entry.getValue().getBiomeName(), + owner == null ? entry.getKey().getResourceDomain() : owner.name, + owner == null ? "?" : owner.version)); + } + return assemble(profile, defaults.biomePalettesCopy(), + defaults.activeProviderIds(), loaded, dimensions); + } + + static Snapshot assemble(JsonObject profile, JsonObject providerPalettes, + Set activeProviders, List loadedBiomes, + Iterable requestedDimensions) { + Map loaded = new LinkedHashMap<>(); + for (LoadedBiome biome : loadedBiomes) loaded.put(biome.id, biome); + Set dimensions = new LinkedHashSet<>(); + for (String dimension : requestedDimensions) dimensions.add(dimension); + JsonObject profilePalettes = object(profile, "biome_palettes"); + List palettes = new ArrayList<>(); + int order = 0; + for (Entry entry : profilePalettes.entrySet()) { + if (!entry.getValue().isJsonObject()) continue; + JsonObject palette = entry.getValue().getAsJsonObject(); + String dimension = string(palette, "dimension", "minecraft:overworld"); + dimensions.add(dimension); + palettes.add(new Palette(entry.getKey(), dimension, + string(palette, "source_provider", owner(entry.getKey())), + order++, bool(palette, "enabled", true), + BiomeReplacementRules.isOverridePalette(entry.getKey()), palette)); + } + + Map> providerBiomes = providerBiomes(providerPalettes); + List entries = new ArrayList<>(); + for (String dimension : dimensions) { + Map> placements = placements(palettes, dimension); + Map replacements = BiomeReplacementRules.read(profile, dimension); + Set ids = new LinkedHashSet<>(loaded.keySet()); + ids.addAll(placements.keySet()); + ids.addAll(replacements.keySet()); + ids.addAll(replacements.values()); + for (String id : ids) { + LoadedBiome biome = loaded.get(id); + List rules = placements.getOrDefault(id, Collections.emptyList()); + boolean managed = providerBiomes.getOrDefault(dimension, Collections.emptySet()).contains(id); + boolean enabled = false; + for (Placement placement : rules) enabled |= placement.enabled; + Status status; + if (biome == null) status = Status.MISSING; + else if (replacements.containsKey(id)) status = Status.USER_REPLACED; + else if (rules.size() > 1) status = Status.LAYERED; + else if (!rules.isEmpty() && !enabled) status = Status.DISABLED; + else if (managed || !rules.isEmpty()) status = Status.PROVIDER_MANAGED; + else status = Status.UNMANAGED; + entries.add(new BiomeEntry(dimension, id, + biome == null ? friendly(id) : biome.name, + biome == null ? owner(id) : biome.ownerName, + biome == null ? "?" : biome.ownerVersion, + status, rules, replacements.get(id), managed)); + } + } + Comparator entryOrder = Comparator + .comparingInt((BiomeEntry entry) -> entry.status.priority) + .thenComparing(entry -> entry.name.toLowerCase(Locale.ROOT)) + .thenComparing(entry -> entry.id) + .thenComparing(entry -> entry.dimension); + entries.sort(entryOrder); + palettes.sort(Comparator.comparingInt(palette -> palette.order)); + return new Snapshot(entries, palettes, dimensions, activeProviders); + } + + private static Map> providerBiomes(JsonObject providerPalettes) { + Map> result = new LinkedHashMap<>(); + for (Entry entry : providerPalettes.entrySet()) { + if (!entry.getValue().isJsonObject()) continue; + JsonObject palette = entry.getValue().getAsJsonObject(); + String dimension = string(palette, "dimension", ""); + Set values = result.computeIfAbsent(dimension, ignored -> new LinkedHashSet<>()); + for (Entry biome : object(palette, "biomes").entrySet()) { + values.add(biome.getKey()); + } + } + return result; + } + + private static Map> placements(List palettes, String dimension) { + Map> result = new LinkedHashMap<>(); + Map lastSurface = new LinkedHashMap<>(); + for (Palette palette : palettes) { + if (!dimension.equals(palette.dimension) || palette.override) continue; + for (Entry entry : object(palette.definition, "biomes").entrySet()) { + if (!entry.getValue().isJsonObject()) continue; + JsonObject placement = entry.getValue().getAsJsonObject(); + List list = result.computeIfAbsent(entry.getKey(), ignored -> new ArrayList<>()); + boolean hasSurface = placement.has("surface") && placement.get("surface").isJsonObject() + && !placement.getAsJsonObject("surface").entrySet().isEmpty(); + list.add(new Placement(palette.id, palette.owner, palette.order, + palette.enabled && bool(placement, "enabled", true), hasSurface, false, placement)); + if (palette.enabled && bool(placement, "enabled", true) && hasSurface) { + lastSurface.put(entry.getKey(), list.size() - 1); + } + } + } + for (Entry> entry : result.entrySet()) { + Integer effective = lastSurface.get(entry.getKey()); + if (effective == null) continue; + List updated = new ArrayList<>(); + for (int i = 0; i < entry.getValue().size(); i++) { + Placement value = entry.getValue().get(i); + updated.add(value.withEffectiveSurface(i == effective)); + } + entry.setValue(updated); + } + return result; + } + + private static String owner(String id) { + try { return new ResourceLocation(id).getResourceDomain(); } + catch (RuntimeException ignored) { return "unknown"; } + } + + private static String friendly(String id) { + String value; + try { value = new ResourceLocation(id).getResourcePath(); } + catch (RuntimeException ignored) { value = id; } + StringBuilder result = new StringBuilder(); + for (String part : value.replace('/', '_').split("_")) { + if (part.isEmpty()) continue; + if (result.length() > 0) result.append(' '); + result.append(Character.toUpperCase(part.charAt(0))).append(part.substring(1)); + } + return result.length() == 0 ? id : result.toString(); + } + + private static JsonObject object(JsonObject root, String key) { + return root.has(key) && root.get(key).isJsonObject() ? root.getAsJsonObject(key) : new JsonObject(); + } + private static String string(JsonObject root, String key, String fallback) { + return root.has(key) ? root.get(key).getAsString() : fallback; + } + private static boolean bool(JsonObject root, String key, boolean fallback) { + return root.has(key) ? root.get(key).getAsBoolean() : fallback; + } + + enum Status { + USER_REPLACED(0), LAYERED(1), DISABLED(2), MISSING(3), PROVIDER_MANAGED(4), UNMANAGED(5); + final int priority; + Status(int priority) { this.priority = priority; } + } + + static final class Snapshot { + final List entries; + final List palettes; + final Set dimensions; + final Set activeProviders; + Snapshot(List entries, List palettes, Set dimensions, + Set activeProviders) { + this.entries = Collections.unmodifiableList(new ArrayList<>(entries)); + this.palettes = Collections.unmodifiableList(new ArrayList<>(palettes)); + this.dimensions = Collections.unmodifiableSet(new LinkedHashSet<>(dimensions)); + this.activeProviders = Collections.unmodifiableSet(new LinkedHashSet<>(activeProviders)); + } + List entries(String dimension, boolean showAll) { + List result = new ArrayList<>(); + for (BiomeEntry entry : entries) { + if (dimension.equals(entry.dimension) && (showAll || entry.status != Status.UNMANAGED)) result.add(entry); + } + return Collections.unmodifiableList(result); + } + List palettes(String dimension) { + List result = new ArrayList<>(); + for (Palette palette : palettes) if (dimension.equals(palette.dimension)) result.add(palette); + return Collections.unmodifiableList(result); + } + } + + static final class LoadedBiome { + final String id, name, ownerName, ownerVersion; + LoadedBiome(String id, String name, String ownerName, String ownerVersion) { + this.id = id; this.name = name; this.ownerName = ownerName; this.ownerVersion = ownerVersion; + } + } + + static final class BiomeEntry { + final String dimension, id, name, ownerName, ownerVersion; + final Status status; + final List placements; + final String replacementTarget; + final boolean providerDeclared; + BiomeEntry(String dimension, String id, String name, String ownerName, String ownerVersion, + Status status, List placements, String replacementTarget, + boolean providerDeclared) { + this.dimension = dimension; this.id = id; this.name = name; + this.ownerName = ownerName; this.ownerVersion = ownerVersion; this.status = status; + this.placements = Collections.unmodifiableList(new ArrayList<>(placements)); + this.replacementTarget = replacementTarget; this.providerDeclared = providerDeclared; + } + } + + static final class Palette { + final String id, dimension, owner; + final int order; + final boolean enabled, override; + final JsonObject definition; + Palette(String id, String dimension, String owner, int order, boolean enabled, + boolean override, JsonObject definition) { + this.id = id; this.dimension = dimension; this.owner = owner; this.order = order; + this.enabled = enabled; this.override = override; + this.definition = JsonCopies.copy(definition); + } + } + + static final class Placement { + final String paletteId, owner; + final int order; + final boolean enabled, hasSurface, effectiveSurface; + final JsonObject definition; + Placement(String paletteId, String owner, int order, boolean enabled, + boolean hasSurface, boolean effectiveSurface, JsonObject definition) { + this.paletteId = paletteId; this.owner = owner; this.order = order; + this.enabled = enabled; this.hasSurface = hasSurface; + this.effectiveSurface = effectiveSurface; + this.definition = JsonCopies.copy(definition); + } + Placement withEffectiveSurface(boolean value) { + return new Placement(paletteId, owner, order, enabled, hasSurface, value, definition); + } + } + + private static final class ModInfo { + final String id, name, version; + ModInfo(String id, String name, String version) { this.id = id; this.name = name; this.version = version; } + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/client/BiomeReplacementRules.java b/src/main/java/zone/moddev/mc/orespawn/client/BiomeReplacementRules.java new file mode 100644 index 00000000..eff26801 --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/BiomeReplacementRules.java @@ -0,0 +1,153 @@ +package zone.moddev.mc.orespawn.client; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; + +import net.minecraft.util.ResourceLocation; + +/** Encodes exact new-terrain biome replacements in a terminal UI palette. */ +final class BiomeReplacementRules { + static final String PREFIX = "orespawn:ui/biome_overrides/"; + + private BiomeReplacementRules() { } + + static String paletteId(String dimension) { + return PREFIX + dimension.toLowerCase(java.util.Locale.ROOT) + .replace(':', '_').replaceAll("[^a-z0-9_./-]", "_"); + } + + static boolean isOverridePalette(String id) { + return id != null && id.startsWith(PREFIX); + } + + static Map read(JsonObject root, String dimension) { + JsonObject palettes = object(root, "biome_palettes", false); + if (palettes == null) return Collections.emptyMap(); + JsonElement value = palettes.get(paletteId(dimension)); + if (value == null || !value.isJsonObject()) return Collections.emptyMap(); + JsonObject palette = value.getAsJsonObject(); + Map result = new LinkedHashMap<>(); + JsonObject biomes = object(palette, "biomes", false); + if (biomes == null) return Collections.emptyMap(); + for (Entry target : biomes.entrySet()) { + if (!target.getValue().isJsonObject()) continue; + JsonElement sources = target.getValue().getAsJsonObject().get("similar_biomes"); + if (sources == null || !sources.isJsonArray()) continue; + for (JsonElement source : sources.getAsJsonArray()) { + try { result.put(new ResourceLocation(source.getAsString()).toString(), + new ResourceLocation(target.getKey()).toString()); } + catch (RuntimeException ignored) { } + } + } + return Collections.unmodifiableMap(result); + } + + static void set(JsonObject root, String dimension, String source, String target) { + String normalizedSource = new ResourceLocation(source).toString(); + String normalizedTarget = new ResourceLocation(target).toString(); + if (normalizedSource.equals(normalizedTarget)) { + throw new IllegalArgumentException("A biome cannot replace itself"); + } + Map mappings = new LinkedHashMap<>(read(root, dimension)); + String terminal = terminal(mappings, normalizedTarget, normalizedSource); + for (Entry entry : new ArrayList<>(mappings.entrySet())) { + if (normalizedSource.equals(entry.getValue())) mappings.put(entry.getKey(), terminal); + } + mappings.put(normalizedSource, terminal); + flatten(mappings); + write(root, dimension, mappings); + } + + static void remove(JsonObject root, String dimension, String source) { + Map mappings = new LinkedHashMap<>(read(root, dimension)); + mappings.remove(source); + write(root, dimension, mappings); + } + + static void clearDimension(JsonObject root, String dimension) { + JsonObject palettes = object(root, "biome_palettes", false); + if (palettes != null) palettes.remove(paletteId(dimension)); + } + + private static String terminal(Map mappings, String start, String rejected) { + Set seen = new LinkedHashSet<>(); + String current = start; + while (mappings.containsKey(current)) { + if (!seen.add(current) || rejected.equals(current)) { + throw new IllegalArgumentException("Biome replacement cycle"); + } + current = mappings.get(current); + } + if (rejected.equals(current)) throw new IllegalArgumentException("Biome replacement cycle"); + return current; + } + + private static void flatten(Map mappings) { + for (String source : new ArrayList<>(mappings.keySet())) { + mappings.put(source, terminal(mappings, mappings.get(source), source)); + } + } + + private static void write(JsonObject root, String dimension, Map mappings) { + JsonObject palettes = object(root, "biome_palettes", true); + String id = paletteId(dimension); + if (mappings.isEmpty()) { + palettes.remove(id); + return; + } + JsonObject palette = new JsonObject(); + palette.addProperty("dimension", dimension); + palette.addProperty("enabled", true); + palette.addProperty("mode", "replace"); + palette.addProperty("scope", "all"); + palette.addProperty("region_size", "tiny"); + palette.addProperty("coverage", 1.0D); + palette.addProperty("fallback_weight", 0.0D); + palette.add("include_namespaces", new JsonArray()); + palette.add("exclude_namespaces", new JsonArray()); + JsonObject biomes = new JsonObject(); + Map> byTarget = new LinkedHashMap<>(); + List sources = new ArrayList<>(mappings.keySet()); + Collections.sort(sources); + for (String source : sources) { + byTarget.computeIfAbsent(mappings.get(source), ignored -> new ArrayList<>()).add(source); + } + List targets = new ArrayList<>(byTarget.keySet()); + Collections.sort(targets); + for (String target : targets) { + JsonObject placement = new JsonObject(); + placement.addProperty("enabled", true); + placement.addProperty("weight", 1.0D); + JsonArray similar = new JsonArray(); + for (String source : byTarget.get(target)) similar.add(new JsonPrimitive(source)); + placement.add("similar_biomes", similar); + placement.add("required_similar_biomes", new JsonArray()); + placement.addProperty("min_temperature", -2.0D); + placement.addProperty("max_temperature", 2.0D); + placement.addProperty("min_downfall", 0.0D); + placement.addProperty("max_downfall", 1.0D); + biomes.add(target, placement); + } + palette.add("biomes", biomes); + palettes.add(id, palette); + } + + private static JsonObject object(JsonObject root, String key, boolean create) { + if (root.has(key) && root.get(key).isJsonObject()) return root.getAsJsonObject(key); + if (!create) return null; + JsonObject value = new JsonObject(); + root.add(key, value); + return value; + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java b/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java index 56824cfa..18242f57 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java @@ -27,6 +27,8 @@ import zone.moddev.mc.orespawn.init.OreSpawnPatterns; import zone.moddev.mc.orespawn.api.OreDimensionSelector; import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfile; +import zone.moddev.mc.orespawn.integration.WorldgenIntegrationManager; +import zone.moddev.mc.orespawn.integration.WorldgenIntegrationManager.BiomeProviderDefaultsSnapshot; import net.minecraft.util.ResourceLocation; import net.minecraft.util.math.BlockPos; @@ -62,6 +64,7 @@ enum MaterialTab { private final JsonObject original; private final JsonObject root; private JsonObject oreSourceOriginal; + private final BiomeProviderDefaultsSnapshot biomeProviderDefaults; private final Set availableDimensionIds = new TreeSet<>(); GeologyEditorSession(WorldGeologyProfile profile) { @@ -70,6 +73,7 @@ enum MaterialTab { GeologyEditorSession(WorldGeologyProfile profile, Iterable dimensions) { originalProfile = profile; + biomeProviderDefaults = WorldgenIntegrationManager.biomeProviderDefaults(); original = profile.rootCopy(); root = profile.rootCopy(); normalizeRegistrySections(original); @@ -1184,6 +1188,159 @@ List installedBiomeIds() { return result; } + BiomeDirectoryModel.Snapshot biomeDirectory() { + return BiomeDirectoryModel.snapshot(root, biomeProviderDefaults, availableDimensionIds()); + } + + List biomePaletteIds(String dimensionId) { + List result = new ArrayList<>(); + for (Entry entry : section("biome_palettes").entrySet()) { + if (!entry.getValue().isJsonObject() + || BiomeReplacementRules.isOverridePalette(entry.getKey())) continue; + if (dimensionId.equals(string(entry.getValue().getAsJsonObject(), "dimension", ""))) { + result.add(entry.getKey()); + } + } + return Collections.unmodifiableList(result); + } + + JsonObject biomePaletteById(String paletteId) { + JsonObject palettes = section("biome_palettes"); + return palettes.has(paletteId) && palettes.get(paletteId).isJsonObject() + ? palettes.getAsJsonObject(paletteId) : null; + } + + List biomePlacementIdsForPalette(String paletteId) { + JsonObject palette = biomePaletteById(paletteId); + if (palette == null) return Collections.emptyList(); + List result = new ArrayList<>(JsonCopies.keys(object(palette, "biomes"))); + Collections.sort(result); + return Collections.unmodifiableList(result); + } + + JsonObject biomePlacementByPalette(String paletteId, String biomeId) { + JsonObject palette = biomePaletteById(paletteId); + if (palette == null) throw new IllegalArgumentException("Unknown biome palette " + paletteId); + return objectEntry(object(palette, "biomes"), biomeId); + } + + void removeBiomePlacementByPalette(String paletteId, String biomeId) { + JsonObject palette = biomePaletteById(paletteId); + if (palette != null) object(palette, "biomes").remove(biomeId); + } + + Map biomeReplacements(String dimensionId) { + return BiomeReplacementRules.read(root, dimensionId); + } + + void replaceBiome(String dimensionId, String sourceId, String targetId) { + if (!loadedBiome(sourceId) || !loadedBiome(targetId)) { + throw new IllegalArgumentException("Both replacement biomes must be loaded"); + } + BiomeReplacementRules.set(root, dimensionId, sourceId, targetId); + } + + void leaveBiomeOriginal(String dimensionId, String sourceId) { + BiomeReplacementRules.remove(root, dimensionId, sourceId); + } + + void resetBiome(String dimensionId, String biomeId) { + leaveBiomeOriginal(dimensionId, biomeId); + JsonObject defaults = biomeProviderDefaults.biomePalettesCopy(); + Set active = biomeProviderDefaults.activeProviderIds(); + JsonObject palettes = section("biome_palettes"); + for (String id : new ArrayList<>(JsonCopies.keys(palettes))) { + if (BiomeReplacementRules.isOverridePalette(id) || !palettes.get(id).isJsonObject()) continue; + JsonObject palette = palettes.getAsJsonObject(id); + if (!dimensionId.equals(string(palette, "dimension", ""))) continue; + String owner = string(palette, "source_provider", ""); + if (!owner.isEmpty() && !active.contains(owner)) continue; + JsonObject entries = object(palette, "biomes"); + JsonObject defaultPalette = defaults.has(id) && defaults.get(id).isJsonObject() + ? defaults.getAsJsonObject(id) : null; + JsonObject defaultEntries = defaultPalette == null ? new JsonObject() + : object(defaultPalette, "biomes"); + if (defaultEntries.has(biomeId)) { + entries.add(biomeId, JsonCopies.copy(defaultEntries.get(biomeId))); + } else { + entries.remove(biomeId); + } + } + for (Entry entry : defaults.entrySet()) { + if (!entry.getValue().isJsonObject()) continue; + JsonObject definition = entry.getValue().getAsJsonObject(); + if (!dimensionId.equals(string(definition, "dimension", "")) + || !object(definition, "biomes").has(biomeId)) continue; + if (!palettes.has(entry.getKey())) { + palettes.add(entry.getKey(), JsonCopies.copy(definition)); + } else if (palettes.get(entry.getKey()).isJsonObject()) { + object(palettes.getAsJsonObject(entry.getKey()), "biomes").add(biomeId, + JsonCopies.copy(object(definition, "biomes").get(biomeId))); + } + } + } + + void resetBiomePalette(String paletteId) { + JsonObject palettes = section("biome_palettes"); + if (!palettes.has(paletteId) || !palettes.get(paletteId).isJsonObject()) return; + JsonObject current = palettes.getAsJsonObject(paletteId); + String owner = string(current, "source_provider", ""); + if (!owner.isEmpty() && !biomeProviderDefaults.activeProviderIds().contains(owner)) return; + JsonObject defaults = biomeProviderDefaults.biomePalettesCopy(); + if (defaults.has(paletteId)) palettes.add(paletteId, JsonCopies.copy(defaults.get(paletteId))); + else palettes.remove(paletteId); + } + + void resetBiomeDimension(String dimensionId) { + resetBiomeSections(dimensionId); + BiomeReplacementRules.clearDimension(root, dimensionId); + } + + void resetAllBiomeManagement() { + resetBiomeSections(null); + for (String dimension : new ArrayList<>(availableDimensionIds())) { + BiomeReplacementRules.clearDimension(root, dimension); + } + } + + private void resetBiomeSections(String dimensionId) { + resetProviderSection("biome_palettes", biomeProviderDefaults.biomePalettesCopy(), dimensionId); + resetProviderSection("dimension_materials", biomeProviderDefaults.dimensionMaterialsCopy(), dimensionId); + } + + private void resetProviderSection(String sectionName, JsonObject defaults, String dimensionId) { + JsonObject current = section(sectionName); + Set active = biomeProviderDefaults.activeProviderIds(); + for (String id : new ArrayList<>(JsonCopies.keys(current))) { + if (!current.get(id).isJsonObject()) continue; + JsonObject definition = current.getAsJsonObject(id); + if (dimensionId != null && !dimensionId.equals(string(definition, "dimension", ""))) continue; + String owner = string(definition, "source_provider", ""); + if (!owner.isEmpty() && !active.contains(owner)) continue; + if (defaults.has(id)) current.add(id, JsonCopies.copy(defaults.get(id))); + else current.remove(id); + } + for (Entry entry : defaults.entrySet()) { + if (!entry.getValue().isJsonObject()) continue; + JsonObject definition = entry.getValue().getAsJsonObject(); + if (dimensionId == null || dimensionId.equals(string(definition, "dimension", ""))) { + current.add(entry.getKey(), JsonCopies.copy(definition)); + } + } + } + + boolean biomeTargetProviderDeclared(String dimensionId, String biomeId) { + for (BiomeDirectoryModel.BiomeEntry entry : biomeDirectory().entries(dimensionId, true)) { + if (biomeId.equals(entry.id)) return entry.providerDeclared; + } + return false; + } + + private static boolean loadedBiome(String id) { + try { return ForgeRegistries.BIOMES.getValue(new ResourceLocation(id)) != null; } + catch (RuntimeException ignored) { return false; } + } + String biomePaletteId(String dimensionId) { for (Entry entry : section("biome_palettes").entrySet()) { if (entry.getValue().isJsonObject() && dimensionId.equals(string( 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 72046d43..82cfb426 100644 --- a/src/main/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManager.java +++ b/src/main/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManager.java @@ -167,6 +167,32 @@ public static synchronized Set activeProviderIds() { return Collections.unmodifiableSet(new LinkedHashSet<>(ACTIVE_PROVIDERS.keySet())); } + /** + * Returns a detached snapshot of the currently loaded provider-owned biome + * defaults. This is an internal editor/reset contract; callers can mutate the + * returned JSON copies without changing registered providers. + */ + public static synchronized BiomeProviderDefaultsSnapshot biomeProviderDefaults() { + JsonObject palettes = new JsonObject(); + JsonObject materials = new JsonObject(); + for (ProviderDefinition provider : ACTIVE_PROVIDERS.values()) { + copyOwnedDefaults(provider, "biome_palettes", palettes); + copyOwnedDefaults(provider, "dimension_materials", materials); + } + return new BiomeProviderDefaultsSnapshot(palettes, materials, + new LinkedHashSet<>(ACTIVE_PROVIDERS.keySet())); + } + + private static void copyOwnedDefaults(ProviderDefinition provider, String section, + JsonObject target) { + for (Entry entry : provider.section(section).entrySet()) { + if (!entry.getValue().isJsonObject()) continue; + JsonObject value = JsonCopies.copy(entry.getValue().getAsJsonObject()); + value.addProperty("source_provider", provider.modId); + target.add(entry.getKey(), value); + } + } + /** 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() @@ -1172,6 +1198,25 @@ public ProviderIntegrationInfo(String modId, boolean nativeOs4, boolean provider public List legacyLineages() { return legacyLineages; } } + /** Immutable, copy-on-read provider defaults used only by OreSpawn's editor. */ + public static final class BiomeProviderDefaultsSnapshot { + private final JsonObject palettes; + private final JsonObject materials; + private final Set activeProviders; + + private BiomeProviderDefaultsSnapshot(JsonObject palettes, JsonObject materials, + Set activeProviders) { + this.palettes = JsonCopies.copy(palettes); + this.materials = JsonCopies.copy(materials); + this.activeProviders = Collections.unmodifiableSet( + new LinkedHashSet<>(activeProviders)); + } + + public JsonObject biomePalettesCopy() { return JsonCopies.copy(palettes); } + public JsonObject dimensionMaterialsCopy() { return JsonCopies.copy(materials); } + public Set activeProviderIds() { return activeProviders; } + } + public static final class TemplateDefinition { private final ResourceLocation id; private final String nameKey; diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeWorldgenManager.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeWorldgenManager.java index 5dfd8e8f..7aed4f43 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeWorldgenManager.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeWorldgenManager.java @@ -35,6 +35,7 @@ /** Bakes and installs optional biome and dimension-material integration. */ final class BiomeWorldgenManager { + private static final String UI_OVERRIDE_PREFIX = "orespawn:ui/biome_overrides/"; private static final Logger LOGGER = LogManager.getLogger(); private static volatile Map baked = Collections.emptyMap(); @@ -118,7 +119,8 @@ private static List bakePalettes(JsonObject root, ResourceLocation dimension, Map surfaces) { JsonObject section = object(root, "biome_palettes"); List result = new ArrayList<>(); - for (Entry paletteEntry : section.entrySet()) { + List> ordered = orderedPaletteEntries(section); + for (Entry paletteEntry : ordered) { if (!paletteEntry.getValue().isJsonObject()) continue; JsonObject json = paletteEntry.getValue().getAsJsonObject(); if (!bool(json, "enabled", true) @@ -178,6 +180,17 @@ private static List bakePalettes(JsonObject root, return result; } + static List> orderedPaletteEntries(JsonObject section) { + List> ordered = new ArrayList<>(); + for (Entry entry : section.entrySet()) { + if (!entry.getKey().startsWith(UI_OVERRIDE_PREFIX)) ordered.add(entry); + } + for (Entry entry : section.entrySet()) { + if (entry.getKey().startsWith(UI_OVERRIDE_PREFIX)) ordered.add(entry); + } + return ordered; + } + private static Map bakeChoices( JsonObject palette, BakedBiomeWorldgen.Entry[] entries) { boolean replace = "replace".equals(string(palette, "mode", "augment")); diff --git a/src/test/java/zone/moddev/mc/orespawn/client/BiomeDirectoryModelTest.java b/src/test/java/zone/moddev/mc/orespawn/client/BiomeDirectoryModelTest.java new file mode 100644 index 00000000..1d5987f6 --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/client/BiomeDirectoryModelTest.java @@ -0,0 +1,117 @@ +package zone.moddev.mc.orespawn.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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.LinkedHashSet; + +import org.junit.jupiter.api.Test; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +class BiomeDirectoryModelTest { + @Test + void classifiesManagedLayeredDisabledReplacedUnmanagedAndMissingBiomes() { + JsonObject profile = root(); + addPalette(profile, "provider:first", true, "example:managed", "example:layered", "example:disabled"); + profile.getAsJsonObject("biome_palettes").getAsJsonObject("provider:first") + .getAsJsonObject("biomes").getAsJsonObject("example:disabled") + .addProperty("enabled", false); + addPalette(profile, "provider:second", true, "example:layered", "missing:biome"); + BiomeReplacementRules.set(profile, "minecraft:overworld", "example:managed", "example:unmanaged"); + JsonObject defaults = new JsonObject(); + addPalette(defaultsRoot(defaults), "provider:first", true, "example:managed"); + + BiomeDirectoryModel.Snapshot snapshot = BiomeDirectoryModel.assemble(profile, defaults, + new LinkedHashSet<>(Collections.singletonList("provider")), Arrays.asList( + loaded("example:managed", "Managed"), loaded("example:layered", "Layered"), + loaded("example:disabled", "Disabled"), loaded("example:unmanaged", "Unmanaged")), + Collections.singletonList("minecraft:overworld")); + assertEquals(BiomeDirectoryModel.Status.USER_REPLACED, find(snapshot, "example:managed").status); + assertEquals(BiomeDirectoryModel.Status.LAYERED, find(snapshot, "example:layered").status); + assertEquals(BiomeDirectoryModel.Status.DISABLED, find(snapshot, "example:disabled").status); + assertEquals(BiomeDirectoryModel.Status.UNMANAGED, find(snapshot, "example:unmanaged").status); + assertEquals(BiomeDirectoryModel.Status.MISSING, find(snapshot, "missing:biome").status); + assertEquals(4, snapshot.entries("minecraft:overworld", false).size()); + assertEquals(5, snapshot.entries("minecraft:overworld", true).size()); + } + + @Test + void retainsProfilePaletteOrderAndMarksOnlyLastEnabledSurfaceEffective() { + JsonObject profile = root(); + addPalette(profile, "z:first", true, "example:managed"); + addPalette(profile, "a:second", true, "example:managed"); + for (String id : Arrays.asList("z:first", "a:second")) { + profile.getAsJsonObject("biome_palettes").getAsJsonObject(id) + .getAsJsonObject("biomes").getAsJsonObject("example:managed") + .add("surface", new JsonObject()); + profile.getAsJsonObject("biome_palettes").getAsJsonObject(id) + .getAsJsonObject("biomes").getAsJsonObject("example:managed") + .getAsJsonObject("surface").addProperty("top_block", "minecraft:stone"); + } + BiomeDirectoryModel.Snapshot snapshot = BiomeDirectoryModel.assemble(profile, + new JsonObject(), Collections.emptySet(), + Collections.singletonList(loaded("example:managed", "Managed")), + Collections.singletonList("minecraft:overworld")); + assertEquals("z:first", snapshot.palettes.get(0).id); + assertEquals("a:second", snapshot.palettes.get(1).id); + assertTrue(!find(snapshot, "example:managed").placements.get(0).effectiveSurface); + assertTrue(find(snapshot, "example:managed").placements.get(1).effectiveSurface); + } + + @Test + void returnedCollectionsAndDefinitionsAreDetached() { + JsonObject profile = root(); + addPalette(profile, "provider:first", true, "example:managed"); + BiomeDirectoryModel.Snapshot snapshot = BiomeDirectoryModel.assemble(profile, + new JsonObject(), Collections.emptySet(), + Collections.singletonList(loaded("example:managed", "Managed")), + Collections.singletonList("minecraft:overworld")); + assertThrows(UnsupportedOperationException.class, () -> snapshot.entries.clear()); + snapshot.palettes.get(0).definition.addProperty("enabled", false); + assertTrue(profile.getAsJsonObject("biome_palettes").getAsJsonObject("provider:first") + .get("enabled").getAsBoolean()); + } + + private static BiomeDirectoryModel.BiomeEntry find(BiomeDirectoryModel.Snapshot snapshot, String id) { + return snapshot.entries.stream().filter(entry -> id.equals(entry.id)).findFirst().orElseThrow(AssertionError::new); + } + + private static BiomeDirectoryModel.LoadedBiome loaded(String id, String name) { + return new BiomeDirectoryModel.LoadedBiome(id, name, "Example", "1"); + } + + private static JsonObject root() { + JsonObject root = new JsonObject(); + root.add("biome_palettes", new JsonObject()); + return root; + } + + private static JsonObject defaultsRoot(JsonObject palettes) { + JsonObject root = new JsonObject(); + root.add("biome_palettes", palettes); + return root; + } + + private static void addPalette(JsonObject root, String id, boolean enabled, String... biomes) { + JsonObject section = root.has("biome_palettes") ? root.getAsJsonObject("biome_palettes") : root; + JsonObject palette = new JsonObject(); + palette.addProperty("dimension", "minecraft:overworld"); + palette.addProperty("enabled", enabled); + palette.addProperty("source_provider", "provider"); + JsonObject entries = new JsonObject(); + for (String biome : biomes) { + JsonObject placement = new JsonObject(); + placement.addProperty("enabled", true); + placement.addProperty("weight", 1.0D); + placement.add("similar_biomes", new JsonArray()); + entries.add(biome, placement); + } + palette.add("biomes", entries); + section.add(id, palette); + } +} diff --git a/src/test/java/zone/moddev/mc/orespawn/client/BiomeReplacementRulesTest.java b/src/test/java/zone/moddev/mc/orespawn/client/BiomeReplacementRulesTest.java new file mode 100644 index 00000000..daa382d1 --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/client/BiomeReplacementRulesTest.java @@ -0,0 +1,59 @@ +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.Map; + +import org.junit.jupiter.api.Test; + +import com.google.gson.JsonObject; + +class BiomeReplacementRulesTest { + @Test + void storesExactMappingsInTheReservedTerminalPalette() { + JsonObject root = new JsonObject(); + BiomeReplacementRules.set(root, "minecraft:overworld", "minecraft:desert", "example:orchard"); + Map mappings = BiomeReplacementRules.read(root, "minecraft:overworld"); + assertEquals("example:orchard", mappings.get("minecraft:desert")); + JsonObject palette = root.getAsJsonObject("biome_palettes") + .getAsJsonObject(BiomeReplacementRules.paletteId("minecraft:overworld")); + assertEquals("replace", palette.get("mode").getAsString()); + assertEquals("all", palette.get("scope").getAsString()); + assertEquals(1.0D, palette.get("coverage").getAsDouble()); + assertEquals(0.0D, palette.get("fallback_weight").getAsDouble()); + } + + @Test + void flattensChainsAndRepointsInboundMappings() { + JsonObject root = new JsonObject(); + BiomeReplacementRules.set(root, "minecraft:overworld", "example:a", "example:b"); + BiomeReplacementRules.set(root, "minecraft:overworld", "example:b", "example:c"); + Map mappings = BiomeReplacementRules.read(root, "minecraft:overworld"); + assertEquals("example:c", mappings.get("example:a")); + assertEquals("example:c", mappings.get("example:b")); + } + + @Test + void rejectsSelfMappingsAndCycles() { + JsonObject root = new JsonObject(); + assertThrows(IllegalArgumentException.class, () -> BiomeReplacementRules.set(root, + "minecraft:overworld", "example:a", "example:a")); + BiomeReplacementRules.set(root, "minecraft:overworld", "example:a", "example:b"); + BiomeReplacementRules.set(root, "minecraft:overworld", "example:b", "example:c"); + assertThrows(IllegalArgumentException.class, () -> BiomeReplacementRules.set(root, + "minecraft:overworld", "example:c", "example:a")); + } + + @Test + void removingLastMappingRemovesOnlyTheReservedPalette() { + JsonObject root = new JsonObject(); + BiomeReplacementRules.set(root, "minecraft:overworld", "example:a", "missing:target"); + BiomeReplacementRules.remove(root, "minecraft:overworld", "example:a"); + assertTrue(BiomeReplacementRules.read(root, "minecraft:overworld").isEmpty()); + assertFalse(root.getAsJsonObject("biome_palettes") + .has(BiomeReplacementRules.paletteId("minecraft:overworld"))); + } +} diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/BiomePaletteOrderingTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/BiomePaletteOrderingTest.java new file mode 100644 index 00000000..def983e6 --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/BiomePaletteOrderingTest.java @@ -0,0 +1,26 @@ +package zone.moddev.mc.orespawn.worldgen; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; +import java.util.Map.Entry; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +class BiomePaletteOrderingTest { + @Test + void ordinaryOrderIsStableAndUiOverridesAlwaysRunLast() { + JsonObject section = new JsonObject(); + section.add("provider:first", new JsonObject()); + section.add("orespawn:ui/biome_overrides/minecraft_overworld", new JsonObject()); + section.add("provider:second", new JsonObject()); + List ids = BiomeWorldgenManager.orderedPaletteEntries(section).stream() + .map(Entry::getKey).collect(Collectors.toList()); + assertEquals(java.util.Arrays.asList("provider:first", "provider:second", + "orespawn:ui/biome_overrides/minecraft_overworld"), ids); + } +} From da6594f58171b9c301e458cf6d0e8650d9289372 Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Tue, 22 Sep 2026 12:03:09 +0100 Subject: [PATCH 24/31] Add biome management screens --- .../orespawn/client/BiomeDirectoryModel.java | 27 +- .../orespawn/client/BiomePaletteScreen.java | 136 +++++--- .../client/BiomePaletteSettingsScreen.java | 163 +++++++++ .../orespawn/client/BiomePlacementScreen.java | 18 +- .../client/BiomeWorldMaterialsScreen.java | 330 +++++++++++++----- .../orespawn/client/GeologyEditorSession.java | 11 +- .../resources/assets/orespawn/lang/de_AU.lang | 40 ++- .../resources/assets/orespawn/lang/de_DE.lang | 40 ++- .../resources/assets/orespawn/lang/en_CA.lang | 40 ++- .../resources/assets/orespawn/lang/en_EN.lang | 40 ++- .../resources/assets/orespawn/lang/en_GB.lang | 40 ++- .../resources/assets/orespawn/lang/en_PT.lang | 40 ++- .../resources/assets/orespawn/lang/en_US.lang | 40 ++- .../resources/assets/orespawn/lang/es_ES.lang | 40 ++- .../resources/assets/orespawn/lang/es_MX.lang | 40 ++- .../resources/assets/orespawn/lang/fr_CA.lang | 40 ++- .../resources/assets/orespawn/lang/fr_FR.lang | 40 ++- .../resources/assets/orespawn/lang/ja_JP.lang | 40 ++- .../resources/assets/orespawn/lang/ko_KR.lang | 40 ++- .../resources/assets/orespawn/lang/pt_BR.lang | 40 ++- .../resources/assets/orespawn/lang/ru_RU.lang | 40 ++- .../resources/assets/orespawn/lang/zh_CN.lang | 40 ++- .../mc/orespawn/LocalizationParityTest.java | 5 +- .../client/OreSpawnScreenLayoutTest.java | 40 ++- 24 files changed, 1201 insertions(+), 169 deletions(-) create mode 100644 src/main/java/zone/moddev/mc/orespawn/client/BiomePaletteSettingsScreen.java diff --git a/src/main/java/zone/moddev/mc/orespawn/client/BiomeDirectoryModel.java b/src/main/java/zone/moddev/mc/orespawn/client/BiomeDirectoryModel.java index d19b4ea1..6365d683 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/BiomeDirectoryModel.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/BiomeDirectoryModel.java @@ -62,7 +62,8 @@ static Snapshot assemble(JsonObject profile, JsonObject providerPalettes, palettes.add(new Palette(entry.getKey(), dimension, string(palette, "source_provider", owner(entry.getKey())), order++, bool(palette, "enabled", true), - BiomeReplacementRules.isOverridePalette(entry.getKey()), palette)); + BiomeReplacementRules.isOverridePalette(entry.getKey()), + palette.has("source_provider"), palette)); } Map> providerBiomes = providerBiomes(providerPalettes); @@ -79,9 +80,12 @@ static Snapshot assemble(JsonObject profile, JsonObject providerPalettes, List rules = placements.getOrDefault(id, Collections.emptyList()); boolean managed = providerBiomes.getOrDefault(dimension, Collections.emptySet()).contains(id); boolean enabled = false; + boolean missingProvider = false; for (Placement placement : rules) enabled |= placement.enabled; + for (Placement placement : rules) missingProvider |= placement.providerOwned + && !activeProviders.contains(placement.owner); Status status; - if (biome == null) status = Status.MISSING; + if (biome == null || missingProvider) status = Status.MISSING; else if (replacements.containsKey(id)) status = Status.USER_REPLACED; else if (rules.size() > 1) status = Status.LAYERED; else if (!rules.isEmpty() && !enabled) status = Status.DISABLED; @@ -130,7 +134,8 @@ private static Map> placements(List palettes, S boolean hasSurface = placement.has("surface") && placement.get("surface").isJsonObject() && !placement.getAsJsonObject("surface").entrySet().isEmpty(); list.add(new Placement(palette.id, palette.owner, palette.order, - palette.enabled && bool(placement, "enabled", true), hasSurface, false, placement)); + palette.enabled && bool(placement, "enabled", true), hasSurface, false, + palette.providerOwned, placement)); if (palette.enabled && bool(placement, "enabled", true) && hasSurface) { lastSurface.put(entry.getKey(), list.size() - 1); } @@ -235,12 +240,12 @@ static final class BiomeEntry { static final class Palette { final String id, dimension, owner; final int order; - final boolean enabled, override; + final boolean enabled, override, providerOwned; final JsonObject definition; Palette(String id, String dimension, String owner, int order, boolean enabled, - boolean override, JsonObject definition) { + boolean override, boolean providerOwned, JsonObject definition) { this.id = id; this.dimension = dimension; this.owner = owner; this.order = order; - this.enabled = enabled; this.override = override; + this.enabled = enabled; this.override = override; this.providerOwned = providerOwned; this.definition = JsonCopies.copy(definition); } } @@ -248,17 +253,19 @@ static final class Palette { static final class Placement { final String paletteId, owner; final int order; - final boolean enabled, hasSurface, effectiveSurface; + final boolean enabled, hasSurface, effectiveSurface, providerOwned; final JsonObject definition; Placement(String paletteId, String owner, int order, boolean enabled, - boolean hasSurface, boolean effectiveSurface, JsonObject definition) { + boolean hasSurface, boolean effectiveSurface, boolean providerOwned, + JsonObject definition) { this.paletteId = paletteId; this.owner = owner; this.order = order; this.enabled = enabled; this.hasSurface = hasSurface; - this.effectiveSurface = effectiveSurface; + this.effectiveSurface = effectiveSurface; this.providerOwned = providerOwned; this.definition = JsonCopies.copy(definition); } Placement withEffectiveSurface(boolean value) { - return new Placement(paletteId, owner, order, enabled, hasSurface, value, definition); + return new Placement(paletteId, owner, order, enabled, hasSurface, value, + providerOwned, definition); } } diff --git a/src/main/java/zone/moddev/mc/orespawn/client/BiomePaletteScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/BiomePaletteScreen.java index 3bea7644..e6ceea05 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/BiomePaletteScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/BiomePaletteScreen.java @@ -1,19 +1,29 @@ package zone.moddev.mc.orespawn.client; +import java.util.ArrayList; import java.util.List; +import org.lwjgl.input.Mouse; + +import zone.moddev.mc.orespawn.client.BiomeDirectoryModel.Palette; + +import net.minecraft.client.Minecraft; import net.minecraft.client.gui.GuiScreen; import net.minecraft.util.text.TextComponentString; import net.minecraft.util.text.TextComponentTranslation; +/** Lists every palette in effective profile order for one dimension. */ final class BiomePaletteScreen extends OreSpawnScreen { private final GuiScreen parent; private final GeologyEditorSession session; private final String dimension; - private int page; + private CompactScrollList list; + private int scroll; + private String armedPalette; + private int resetScope; BiomePaletteScreen(GuiScreen parent, GeologyEditorSession session, String dimension) { - super(new TextComponentTranslation("screen.orespawn.biome_palette")); + super(new TextComponentTranslation("screen.orespawn.biome_palettes")); this.parent = parent; this.session = session; this.dimension = dimension; @@ -21,56 +31,102 @@ final class BiomePaletteScreen extends OreSpawnScreen { @Override protected void init() { - int contentWidth = Math.min(390, Math.max(280, width - 24)); + OreSpawnScreenLayout.beginHelp(this); + int contentWidth = Math.min(520, Math.max(300, width - 20)); int left = (width - contentWidth) / 2; - int removeWidth = 70; - int listTop = 48; - int controlsY = height - 52; - List ids = session.biomePlacementIds(dimension); - int pageSize = Math.max(1, (controlsY - listTop) / 24); - int pageCount = Math.max(1, (ids.size() + pageSize - 1) / pageSize); - page = Math.max(0, Math.min(page, pageCount - 1)); - int start = page * pageSize; - for (int i = 0; i < pageSize && start + i < ids.size(); i++) { - String id = ids.get(start + i); - int y = listTop + i * 24; - addButton(OreSpawnScreenLayout.button(this, font, left, y, - contentWidth - removeWidth - 5, 20, new TextComponentString(id), - button -> minecraft.displayGuiScreen(new BiomePlacementScreen(this, session, - dimension, id)))); - addButton(new Button(left + contentWidth - removeWidth, y, removeWidth, 20, - new TextComponentTranslation("button.orespawn.remove"), - button -> { session.removeBiomePlacement(dimension, id); rebuildWidgets(); })); - } - Button previous = addButton(new Button(left, controlsY, 45, 20, - new TextComponentString("<"), button -> { page--; rebuildWidgets(); })); - Button next = addButton(new Button(left + 50, controlsY, 45, 20, - new TextComponentString(">"), button -> { page++; rebuildWidgets(); })); - previous.enabled = page > 0; - next.enabled = page + 1 < pageCount; - addButton(OreSpawnScreenLayout.button(this, font, - left + contentWidth - 150, controlsY, 150, 20, - new TextComponentTranslation("button.orespawn.add_biome"), - button -> minecraft.displayGuiScreen(new BiomePickerScreen(this, session, id -> { - session.addBiomePlacement(dimension, id); - minecraft.displayGuiScreen(new BiomePlacementScreen(this, session, dimension, id)); - })))); + List palettes = session.biomeDirectory().palettes(dimension); + int listTop = 38; + int listHeight = Math.max(32, height - listTop - 62); + list = addButton(new CompactScrollList(this, left, listTop, contentWidth, listHeight) { + @Override protected int size() { return palettes.size(); } + @Override protected String rowText(int index) { + Palette palette = palettes.get(index); + return (palette.enabled ? "[x] " : "[ ] ") + (palette.order + 1) + ". " + palette.id; + } + @Override protected boolean rowEnabled(int index) { return !palettes.get(index).override; } + @Override protected int rowColor(int index) { + return palettes.get(index).override ? 0x55FFFF : palettes.get(index).enabled ? 0xFFFFFF : 0x888888; + } + @Override protected void onRowPressed(int index) { + Palette palette = palettes.get(index); + if (!palette.override) minecraft.displayGuiScreen(new BiomePaletteSettingsScreen( + BiomePaletteScreen.this, session, palette.id)); + } + @Override protected int rowActionWidth(int index) { return 18; } + @Override protected void drawRowAction(Minecraft minecraft, int index, int x, int y, + int width, int height, boolean hovered) { + drawCenteredString(font, armedPalette != null && armedPalette.equals(palettes.get(index).id) + ? "!" : "R", x + width / 2, y + 4, hovered ? 0xFFFFFF : 0xAAAAAA); + } + @Override protected void onRowActionPressed(int index) { resetPalette(palettes.get(index)); } + @Override protected List rowTooltip(int index) { + Palette palette = palettes.get(index); + List lines = new ArrayList<>(); + lines.add(palette.id); + lines.add(net.minecraft.client.resources.I18n.format( + "label.orespawn.biome.palette_owner_order", palette.owner, palette.order + 1)); + if (palette.override) lines.add(net.minecraft.client.resources.I18n.format( + "tooltip.orespawn.biome.override_palette")); + return lines; + } + @Override protected List rowActionTooltip(int index) { + return java.util.Collections.singletonList(net.minecraft.client.resources.I18n.format( + armedPalette != null && armedPalette.equals(palettes.get(index).id) + ? "button.orespawn.biome.confirm_reset_palette" + : "button.orespawn.biome.reset_palette")); + } + }); + list.setFirstIndex(scroll); + int half = (contentWidth - 4) / 2; + int resetY = height - 52; + addButton(OreSpawnScreenLayout.button(this, font, left, resetY, half, 20, + new TextComponentTranslation(resetScope == 1 ? "button.orespawn.biome.confirm_reset_dimension" + : "button.orespawn.biome.reset_dimension"), button -> resetDimension())); + addButton(OreSpawnScreenLayout.button(this, font, left + half + 4, resetY, half, 20, + new TextComponentTranslation(resetScope == 2 ? "button.orespawn.biome.confirm_reset_all" + : "button.orespawn.biome.reset_all"), button -> resetAll())); addButton(new Button(width / 2 - 75, height - 28, 150, 20, DialogTexts.GUI_DONE, button -> onClose())); } - private void rebuildWidgets() { - buttons.clear(); children.clear(); - init(); + private void resetPalette(Palette palette) { + resetScope = 0; + if (!palette.id.equals(armedPalette)) { armedPalette = palette.id; rebuild(); return; } + session.resetBiomePalette(palette.id); armedPalette = null; rebuild(); + } + + private void resetDimension() { + armedPalette = null; + if (resetScope != 1) { resetScope = 1; rebuild(); return; } + session.resetBiomeDimension(dimension); resetScope = 0; rebuild(); + } + + private void resetAll() { + armedPalette = null; + if (resetScope != 2) { resetScope = 2; rebuild(); return; } + session.resetAllBiomeManagement(); resetScope = 0; rebuild(); } + private void rebuild() { if (list != null) scroll = list.firstIndex(); buttons.clear(); children.clear(); init(); } + @Override public void onClose() { minecraft.displayGuiScreen(parent); } + @Override + public void handleMouseInput() throws java.io.IOException { + super.handleMouseInput(); + int wheel = Mouse.getEventDWheel(); + if (wheel == 0 || list == null) return; + int mouseX = Mouse.getEventX() * width / minecraft.displayWidth; + int mouseY = height - Mouse.getEventY() * height / minecraft.displayHeight - 1; + if (list.mouseWheel(mouseX, mouseY, wheel)) scroll = list.firstIndex(); + } + @Override public void render(int mouseX, int mouseY, float partialTick) { renderBackground(); - drawCenteredString(font, title, width / 2, 12, 0xFFFFFF); - drawCenteredString(font, new TextComponentString(dimension), width / 2, 28, 0xCCCCCC); + drawCenteredString(font, title, width / 2, 9, 0xFFFFFF); + drawCenteredString(font, new TextComponentString(dimension), width / 2, 23, 0xAAAAAA); super.render(mouseX, mouseY, partialTick); + OreSpawnScreenLayout.renderExplanations(this, mouseX, mouseY); } } diff --git a/src/main/java/zone/moddev/mc/orespawn/client/BiomePaletteSettingsScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/BiomePaletteSettingsScreen.java new file mode 100644 index 00000000..25cf9082 --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/BiomePaletteSettingsScreen.java @@ -0,0 +1,163 @@ +package zone.moddev.mc.orespawn.client; + +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.Set; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; + +import net.minecraft.client.gui.GuiScreen; +import net.minecraft.util.text.ITextComponent; +import net.minecraft.util.text.TextComponentString; +import net.minecraft.util.text.TextComponentTranslation; + +/** Edits the complete set of controls already supported by one biome palette. */ +final class BiomePaletteSettingsScreen extends OreSpawnScreen { + private final GuiScreen parent; + private final GeologyEditorSession session; + private final String paletteId; + private TextFieldWidget coverage; + private TextFieldWidget fallback; + private TextFieldWidget included; + private TextFieldWidget excluded; + + BiomePaletteSettingsScreen(GuiScreen parent, GeologyEditorSession session, String paletteId) { + super(new TextComponentTranslation("screen.orespawn.biome_palette_settings")); + this.parent = parent; + this.session = session; + this.paletteId = paletteId; + } + + @Override + protected void init() { + OreSpawnScreenLayout.beginHelp(this); + JsonObject palette = session.biomePaletteById(paletteId); + if (palette == null) { onClose(); return; } + int contentWidth = Math.min(430, Math.max(300, width - 20)); + int left = (width - contentWidth) / 2; + int half = (contentWidth - 4) / 2; + int y = 38; + addButton(CycleButton.onOffBuilder(bool(palette, "enabled", true)).create( + left, y, contentWidth, 20, new TextComponentTranslation("option.orespawn.enabled"), + (button, value) -> palette.addProperty("enabled", value))); + y += 24; + addButton(CycleButton.builder(this::modeName) + .withValues(Arrays.asList("augment", "replace")) + .withInitialValue(string(palette, "mode", "augment")) + .create(left, y, half, 20, new TextComponentTranslation("option.orespawn.biome_mode"), + (button, value) -> palette.addProperty("mode", value))); + addButton(CycleButton.builder(this::scopeName) + .withValues(Arrays.asList("all", "minecraft_only", "selected_namespaces")) + .withInitialValue(string(palette, "scope", "minecraft_only")) + .create(left + half + 4, y, half, 20, + new TextComponentTranslation("option.orespawn.biome_scope"), + (button, value) -> palette.addProperty("scope", value))); + y += 24; + addButton(CycleButton.builder(this::regionName) + .withValues(Arrays.asList("tiny", "small", "average", "large", "huge")) + .withInitialValue(string(palette, "region_size", "average")) + .create(left, y, contentWidth, 20, + new TextComponentTranslation("option.orespawn.biome_region_size"), + (button, value) -> palette.addProperty("region_size", value))); + y += 24; + coverage = field(left, y, half, decimal(palette, "coverage", 1.0D), + "option.orespawn.biome.coverage"); + fallback = field(left + half + 4, y, half, decimal(palette, "fallback_weight", 1.0D), + "option.orespawn.biome.fallback_weight"); + y += 24; + included = addButton(new TextFieldWidget(font, left, y, contentWidth, 20, + new TextComponentTranslation("option.orespawn.biome.include_namespaces"))); + included.setValue(join(palette.get("include_namespaces"))); + y += 24; + excluded = addButton(new TextFieldWidget(font, left, y, contentWidth, 20, + new TextComponentTranslation("option.orespawn.biome.exclude_namespaces"))); + excluded.setValue(join(palette.get("exclude_namespaces"))); + y += 24; + Button info = addButton(OreSpawnScreenLayout.explainedButton(this, font, left, y, + contentWidth, 20, new TextComponentTranslation("label.orespawn.biome.palette_entries", + session.biomePlacementIdsForPalette(paletteId).size()), button -> { }, + "tooltip.orespawn.biome.palette_entries_read_only")); + info.enabled = false; + addButton(new Button(width / 2 - 75, height - 28, 150, 20, + DialogTexts.GUI_DONE, button -> onClose())); + } + + private TextFieldWidget field(int x, int y, int width, double value, String label) { + TextFieldWidget field = addButton(new TextFieldWidget(font, x, y, width, 20, + new TextComponentTranslation(label))); + field.setValue(Double.toString(value)); + return field; + } + + private void save() { + JsonObject palette = session.biomePaletteById(paletteId); + if (palette == null) return; + putDecimal(palette, "coverage", coverage, 0.0D, 1.0D); + putDecimal(palette, "fallback_weight", fallback, 0.0D, 1000.0D); + palette.add("include_namespaces", namespaces(included)); + palette.add("exclude_namespaces", namespaces(excluded)); + } + + private static void putDecimal(JsonObject root, String key, TextFieldWidget field, + double min, double max) { + try { + double value = Double.parseDouble(field.getValue().trim()); + if (Double.isFinite(value)) root.addProperty(key, Math.max(min, Math.min(max, value))); + } catch (RuntimeException ignored) { } + } + + private static JsonArray namespaces(TextFieldWidget field) { + Set values = new LinkedHashSet<>(); + if (field != null) for (String raw : field.getValue().split(",")) { + String value = raw.trim().toLowerCase(java.util.Locale.ROOT); + if (value.matches("[a-z][a-z0-9_.-]{0,63}")) values.add(value); + } + JsonArray result = new JsonArray(); + for (String value : values) result.add(new JsonPrimitive(value)); + return result; + } + + private static String join(JsonElement element) { + if (element == null || !element.isJsonArray()) return ""; + StringBuilder result = new StringBuilder(); + for (JsonElement value : element.getAsJsonArray()) { + if (result.length() > 0) result.append(", "); + result.append(value.getAsString()); + } + return result.toString(); + } + + private ITextComponent modeName(String value) { + return new TextComponentTranslation("value.orespawn.biome_mode." + value); + } + private ITextComponent scopeName(String value) { + return new TextComponentTranslation("value.orespawn.biome_scope." + value); + } + private ITextComponent regionName(String value) { + return new TextComponentTranslation("value.orespawn.preset." + value); + } + + @Override public void onClose() { save(); minecraft.displayGuiScreen(parent); } + + @Override + public void render(int mouseX, int mouseY, float partialTick) { + renderBackground(); + drawCenteredString(font, title, width / 2, 8, 0xFFFFFF); + drawCenteredString(font, new TextComponentString(paletteId), width / 2, 22, 0xAAAAAA); + super.render(mouseX, mouseY, partialTick); + OreSpawnScreenLayout.renderExplanations(this, mouseX, mouseY); + } + + private static String string(JsonObject root, String key, String fallback) { + return root.has(key) ? root.get(key).getAsString() : fallback; + } + private static boolean bool(JsonObject root, String key, boolean fallback) { + return root.has(key) ? root.get(key).getAsBoolean() : fallback; + } + private static double decimal(JsonObject root, String key, double fallback) { + return root.has(key) ? root.get(key).getAsDouble() : fallback; + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/client/BiomePlacementScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/BiomePlacementScreen.java index a2e085a3..50f1faec 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/BiomePlacementScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/BiomePlacementScreen.java @@ -15,6 +15,7 @@ private enum Tab { PLACEMENT, CLIMATE, SURFACE } private final GuiScreen parent; private final GeologyEditorSession session; private final String dimension; + private final String paletteId; private final String biomeId; private Tab tab = Tab.PLACEMENT; private TextFieldWidget weight; @@ -26,10 +27,21 @@ private enum Tab { PLACEMENT, CLIMATE, SURFACE } BiomePlacementScreen(GuiScreen parent, GeologyEditorSession session, String dimension, String biomeId) { + this(parent, session, dimension, paletteId(session, dimension), biomeId); + } + + private static String paletteId(GeologyEditorSession session, String dimension) { + if (session.biomePaletteId(dimension) == null) session.biomePalette(dimension, true); + return session.biomePaletteId(dimension); + } + + BiomePlacementScreen(GuiScreen parent, GeologyEditorSession session, + String dimension, String paletteId, String biomeId) { super(new TextComponentTranslation("screen.orespawn.biome_placement")); this.parent = parent; this.session = session; this.dimension = dimension; + this.paletteId = paletteId; this.biomeId = biomeId; } @@ -48,7 +60,7 @@ protected void init() { selected -> { saveFields(); tab = value; rebuildWidgets(); })); button.enabled = value != tab; } - JsonObject placement = session.biomePlacement(dimension, biomeId); + JsonObject placement = session.biomePlacementByPalette(paletteId, biomeId); OreSpawnScreenLayout.explain(this, addButton(CycleButton.onOffBuilder( bool(placement, "enabled", true)) .create(left, 64, contentWidth, 20, @@ -62,7 +74,7 @@ protected void init() { half, 20, DialogTexts.GUI_DONE, button -> { saveFields(); onClose(); })); addButton(new Button(left + half + 5, OreSpawnScreenLayout.footerY(height), half, 20, new TextComponentTranslation("button.orespawn.remove"), button -> { - session.removeBiomePlacement(dimension, biomeId); + session.removeBiomePlacementByPalette(paletteId, biomeId); onClose(); })); } @@ -152,7 +164,7 @@ private void label(int x, int y, int labelWidth, String key) { } private void saveFields() { - JsonObject placement = session.biomePlacement(dimension, biomeId); + JsonObject placement = session.biomePlacementByPalette(paletteId, biomeId); if (weight != null) putDouble(placement, "weight", weight, 0.0D, 1000.0D); if (minTemperature != null) putDouble(placement, "min_temperature", minTemperature, -2.0D, 2.0D); if (maxTemperature != null) putDouble(placement, "max_temperature", maxTemperature, -2.0D, 2.0D); diff --git a/src/main/java/zone/moddev/mc/orespawn/client/BiomeWorldMaterialsScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/BiomeWorldMaterialsScreen.java index 1d2969c8..a40e42fc 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/BiomeWorldMaterialsScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/BiomeWorldMaterialsScreen.java @@ -1,22 +1,43 @@ package zone.moddev.mc.orespawn.client; -import java.util.Arrays; +import java.util.ArrayList; import java.util.List; -import com.google.gson.JsonObject; +import org.lwjgl.input.Mouse; + +import zone.moddev.mc.orespawn.client.BiomeDirectoryModel.BiomeEntry; +import zone.moddev.mc.orespawn.client.BiomeDirectoryModel.Placement; +import zone.moddev.mc.orespawn.client.BiomeDirectoryModel.Snapshot; +import zone.moddev.mc.orespawn.client.BiomeDirectoryModel.Status; + import net.minecraft.client.gui.GuiScreen; +import net.minecraft.client.resources.I18n; import net.minecraft.util.text.ITextComponent; import net.minecraft.util.text.TextComponentString; import net.minecraft.util.text.TextComponentTranslation; -/** Dimension-level entry point for biome placement and world materials. */ +/** Responsive directory for biome placement, exact replacements, and diagnostics. */ final class BiomeWorldMaterialsScreen extends OreSpawnScreen { + private static final int TWO_PANE_MINIMUM = 400; + private static final int CONTENT_TOP = 48; private final GuiScreen parent; private final GeologyEditorSession session; private String dimension; + private String selectedId; + private boolean showAll; + private boolean compactDetail; + private boolean resetArmed; + private String message; + private int biomeScroll; + private int ruleScroll; + private CompactScrollList biomeList; + private CompactScrollList ruleList; + private int detailX; + private int detailWidth; + private int detailRuleTop; BiomeWorldMaterialsScreen(GuiScreen parent, GeologyEditorSession session) { - super(new TextComponentTranslation("screen.orespawn.biomes_world_materials")); + super(new TextComponentTranslation("screen.orespawn.biomes")); this.parent = parent; this.session = session; this.dimension = session.availableDimensionIds().get(0); @@ -25,126 +46,241 @@ final class BiomeWorldMaterialsScreen extends OreSpawnScreen { @Override protected void init() { OreSpawnScreenLayout.beginHelp(this); - int contentWidth = Math.min(390, Math.max(280, width - 24)); - int left = (width - contentWidth) / 2; - int half = (contentWidth - 5) / 2; - List dimensions = session.availableDimensionIds(); + Snapshot snapshot = session.biomeDirectory(); + List dimensions = new ArrayList<>(snapshot.dimensions); + if (dimensions.isEmpty()) dimensions.add("minecraft:overworld"); if (!dimensions.contains(dimension)) dimension = dimensions.get(0); - OreSpawnScreenLayout.explain(this, addButton(CycleButton.builder(this::dimensionName) - .withValues(dimensions).withInitialValue(dimension) - .create(left, 34, contentWidth, 20, - new TextComponentTranslation("option.orespawn.dimension"), - (button, value) -> { dimension = value; rebuildWidgets(); })), - "tooltip.orespawn.biome.dimension"); - - JsonObject palette = session.biomePalette(dimension, false); - boolean active = palette != null && bool(palette, "enabled", false); - String mode = palette == null ? "augment" : string(palette, "mode", "augment"); - String scope = palette == null ? "minecraft_only" : string(palette, "scope", "minecraft_only"); - String size = palette == null ? "average" : string(palette, "region_size", "average"); - int y = 64; - OreSpawnScreenLayout.explain(this, addButton(CycleButton.onOffBuilder(active) - .create(left, y, contentWidth, 20, - new TextComponentTranslation("option.orespawn.biome_palette"), - (button, value) -> setPaletteEnabled(value))), - "tooltip.orespawn.biome.palette_enabled"); - y += 26; - OreSpawnScreenLayout.explain(this, addButton(CycleButton.builder(this::modeName) - .withValues(Arrays.asList("augment", "replace")).withInitialValue(mode) - .create(left, y, half, 20, - new TextComponentTranslation("option.orespawn.biome_mode"), - (button, value) -> setPalette("mode", value))), - "tooltip.orespawn.biome.mode"); - OreSpawnScreenLayout.explain(this, addButton(CycleButton.builder(this::scopeName) - .withValues(Arrays.asList("all", "minecraft_only", "selected_namespaces")) - .withInitialValue(scope) - .create(left + half + 5, y, half, 20, - new TextComponentTranslation("option.orespawn.biome_scope"), - (button, value) -> setPalette("scope", value))), - "tooltip.orespawn.biome.scope"); - y += 26; - OreSpawnScreenLayout.explain(this, addButton(CycleButton.builder(this::regionName) - .withValues(Arrays.asList("tiny", "small", "average", "large", "huge")) - .withInitialValue(size) - .create(left, y, contentWidth, 20, - new TextComponentTranslation("option.orespawn.biome_region_size"), - (button, value) -> setPalette("region_size", value))), - "tooltip.orespawn.biome.region_size"); - y += 30; - addButton(OreSpawnScreenLayout.explainedButton(this, font, left, y, half, 20, - new TextComponentTranslation("button.orespawn.biome_palette_count", - session.biomePlacementIds(dimension).size()), - button -> minecraft.displayGuiScreen(new BiomePaletteScreen(this, session, dimension)), - "tooltip.orespawn.biome.entries")); - addButton(OreSpawnScreenLayout.explainedButton(this, font, left + half + 5, y, half, 20, - new TextComponentTranslation("button.orespawn.dimension_materials"), - button -> minecraft.displayGuiScreen(new DimensionMaterialsScreen(this, session, dimension)), - "tooltip.orespawn.biome.dimension_materials")); - y += 26; - addButton(OreSpawnScreenLayout.explainedButton(this, font, left, y, contentWidth, 20, - new TextComponentTranslation("button.orespawn.geome_influences"), - button -> minecraft.displayGuiScreen(new GeomeBiomeScreen(this, session)), - "tooltip.orespawn.biome.geome_influences")); - addButton(new Button(width / 2 - 75, OreSpawnScreenLayout.footerY(height), - 150, 20, DialogTexts.GUI_DONE, button -> onClose())); + List entries = snapshot.entries(dimension, showAll); + if (selectedId == null || find(entries, selectedId) == null) { + selectedId = entries.isEmpty() ? null : entries.get(0).id; + } + boolean compact = width < TWO_PANE_MINIMUM; + if (compact && compactDetail && selectedId != null) initDetail(snapshot, true); + else initDirectory(snapshot, entries, compact); } - private void setPaletteEnabled(boolean enabled) { - JsonObject palette = session.biomePalette(dimension, true); - if (enabled && session.biomePlacementIds(dimension).isEmpty()) { - minecraft.displayGuiScreen(new BiomePickerScreen(this, session, - id -> { - session.addBiomePlacement(dimension, id); - minecraft.displayGuiScreen(new BiomePlacementScreen(this, session, dimension, id)); + private void initDirectory(Snapshot snapshot, List entries, boolean compact) { + int contentWidth = contentWidth(width); + int left = (width - contentWidth) / 2; + int leftWidth = compact ? contentWidth : leftPaneWidth(width); + int headerWidth = Math.max(76, Math.min(leftWidth - 74, 150)); + addButton(OreSpawnScreenLayout.button(this, font, left, 24, headerWidth, 20, + new TextComponentString(dimension), button -> cycleDimension(snapshot))); + addButton(OreSpawnScreenLayout.button(this, font, left + leftWidth - 68, 24, 68, 20, + new TextComponentTranslation(showAll ? "button.orespawn.biome.hide_routine" + : "button.orespawn.show_all"), button -> { + showAll = !showAll; selectedId = null; biomeScroll = 0; rebuild(); + })); + int listHeight = listHeight(height); + biomeList = addButton(new CompactScrollList(this, left, CONTENT_TOP, leftWidth, listHeight) { + @Override protected int size() { return entries.size(); } + @Override protected String rowText(int index) { + BiomeEntry entry = entries.get(index); + return statusMarker(entry.status) + ' ' + entry.name; + } + @Override protected int rowColor(int index) { return statusColor(entries.get(index).status); } + @Override protected boolean rowSelected(int index) { return entries.get(index).id.equals(selectedId); } + @Override protected void onRowPressed(int index) { + selectedId = entries.get(index).id; + if (compact) compactDetail = true; + ruleScroll = 0; resetArmed = false; message = null; rebuild(); + } + @Override protected List rowTooltip(int index) { return biomeTooltip(entries.get(index)); } + }); + biomeList.setFirstIndex(biomeScroll); + if (!compact) initDetail(snapshot, false); + addButton(new Button(width / 2 - 75, height - 28, 150, 20, + DialogTexts.GUI_DONE, button -> onClose())); + } + + private void initDetail(Snapshot snapshot, boolean compact) { + BiomeEntry selected = find(snapshot.entries(dimension, true), selectedId); + if (selected == null) return; + int contentWidth = contentWidth(width); + int contentLeft = (width - contentWidth) / 2; + int leftWidth = compact ? 0 : leftPaneWidth(width); + int x = compact ? contentLeft : contentLeft + leftWidth + 10; + int paneWidth = compact ? contentWidth : contentWidth - leftWidth - 10; + detailX = x; + detailWidth = paneWidth; + int y = compact ? 34 : CONTENT_TOP; + if (compact) { + addButton(OreSpawnScreenLayout.button(this, font, contentLeft, 8, 48, 20, + new TextComponentTranslation("button.orespawn.back"), button -> { + captureScroll(); compactDetail = false; rebuild(); })); - return; } - palette.addProperty("enabled", enabled); + ITextComponent replacement = selected.replacementTarget == null + ? new TextComponentTranslation("button.orespawn.biome.replace_new_terrain") + : new TextComponentTranslation("button.orespawn.biome.replace_with", selected.replacementTarget); + addButton(OreSpawnScreenLayout.explainedButton(this, font, x, y + 35, + paneWidth, 20, replacement, button -> chooseReplacement(selected), + "tooltip.orespawn.biome.no_retrogen")); + if (selected.replacementTarget != null) { + addButton(OreSpawnScreenLayout.explainedButton(this, font, x, y + 59, + paneWidth, 20, + new TextComponentTranslation("button.orespawn.biome.leave_original"), + button -> { session.leaveBiomeOriginal(dimension, selected.id); message = null; rebuild(); }, + "tooltip.orespawn.biome.no_retrogen")); + } + int ruleTop = y + (selected.replacementTarget == null ? 61 : 85); + detailRuleTop = ruleTop; + int controlsTop = Math.max(ruleTop + 34, height - 76); + int ruleHeight = Math.max(16, controlsTop - ruleTop - 4); + List placements = selected.placements; + ruleList = addButton(new CompactScrollList(this, x, ruleTop, paneWidth, ruleHeight) { + @Override protected int size() { return placements.size(); } + @Override protected String rowText(int index) { + Placement rule = placements.get(index); + return (rule.enabled ? "[x] " : "[ ] ") + rule.paletteId + " #" + (rule.order + 1) + + (rule.effectiveSurface ? " *" : ""); + } + @Override protected int rowColor(int index) { return placements.get(index).enabled ? 0xFFFFFF : 0x888888; } + @Override protected void onRowPressed(int index) { + Placement rule = placements.get(index); + minecraft.displayGuiScreen(new BiomePlacementScreen(BiomeWorldMaterialsScreen.this, + session, dimension, rule.paletteId, selected.id)); + } + @Override protected List rowTooltip(int index) { return placementTooltip(placements.get(index)); } + }); + ruleList.setFirstIndex(ruleScroll); + int half = (paneWidth - 4) / 2; + addButton(OreSpawnScreenLayout.button(this, font, x, controlsTop, half, 20, + new TextComponentTranslation("button.orespawn.biome.palettes", snapshot.palettes(dimension).size()), + button -> minecraft.displayGuiScreen(new BiomePaletteScreen(this, session, dimension)))); + addButton(OreSpawnScreenLayout.button(this, font, x + half + 4, controlsTop, half, 20, + new TextComponentTranslation("button.orespawn.dimension_materials"), + button -> minecraft.displayGuiScreen(new DimensionMaterialsScreen(this, session, dimension)))); + addButton(OreSpawnScreenLayout.button(this, font, x, controlsTop + 24, half, 20, + new TextComponentTranslation("button.orespawn.geome_influences"), + button -> minecraft.displayGuiScreen(new GeomeBiomeScreen(this, session)))); + addButton(OreSpawnScreenLayout.button(this, font, x + half + 4, controlsTop + 24, half, 20, + new TextComponentTranslation(resetArmed ? "button.orespawn.biome.confirm_reset" + : "button.orespawn.biome.reset_selected"), button -> resetSelected(selected))); + } + + private void chooseReplacement(BiomeEntry selected) { + captureScroll(); + minecraft.displayGuiScreen(new BiomePickerScreen(this, session, target -> { + try { + session.replaceBiome(dimension, selected.id, target); + message = session.biomeTargetProviderDeclared(dimension, target) ? null + : I18n.format("warning.orespawn.biome.target_not_declared", target); + } catch (IllegalArgumentException failure) { + message = failure.getMessage(); + } + minecraft.displayGuiScreen(this); + })); + } + + private void resetSelected(BiomeEntry selected) { + if (!resetArmed) { resetArmed = true; rebuild(); return; } + session.resetBiome(dimension, selected.id); + resetArmed = false; message = null; rebuild(); } - private void setPalette(String key, String value) { - session.biomePalette(dimension, true).addProperty(key, value); + private void cycleDimension(Snapshot snapshot) { + List dimensions = new ArrayList<>(snapshot.dimensions); + int index = dimensions.indexOf(dimension); + dimension = dimensions.get((Math.max(0, index) + 1) % dimensions.size()); + selectedId = null; compactDetail = false; biomeScroll = 0; ruleScroll = 0; rebuild(); } - private ITextComponent dimensionName(String value) { - return new TextComponentString(value); + private static BiomeEntry find(List entries, String id) { + if (id != null) for (BiomeEntry entry : entries) if (id.equals(entry.id)) return entry; + return null; } - private ITextComponent modeName(String value) { - return new TextComponentTranslation("value.orespawn.biome_mode." + value); + private static String statusMarker(Status status) { + switch (status) { + case USER_REPLACED: return "R"; + case LAYERED: return "!"; + case DISABLED: return "-"; + case MISSING: return "?"; + case PROVIDER_MANAGED: return "+"; + default: return " "; + } + } + + private static int statusColor(Status status) { + switch (status) { + case MISSING: return 0xFF5555; + case USER_REPLACED: return 0x55FFFF; + case LAYERED: return 0xFFFF55; + case DISABLED: return 0xAAAAAA; + case PROVIDER_MANAGED: return 0x55FF55; + default: return 0xFFFFFF; + } } - private ITextComponent scopeName(String value) { - return new TextComponentTranslation("value.orespawn.biome_scope." + value); + private static List biomeTooltip(BiomeEntry entry) { + List lines = new ArrayList<>(); + lines.add(entry.name + " (" + entry.id + ")"); + lines.add(entry.ownerName + " " + entry.ownerVersion); + lines.add(I18n.format("value.orespawn.biome.status." + entry.status.name().toLowerCase(java.util.Locale.ROOT))); + lines.add(I18n.format("label.orespawn.biome.rule_count", entry.placements.size())); + if (entry.replacementTarget != null) lines.add(I18n.format("label.orespawn.biome.replaced_by", entry.replacementTarget)); + return lines; } - private ITextComponent regionName(String value) { - return new TextComponentTranslation("value.orespawn.preset." + value); + private static List placementTooltip(Placement rule) { + List lines = new ArrayList<>(); + lines.add(rule.paletteId); + lines.add(I18n.format("label.orespawn.biome.palette_owner_order", rule.owner, rule.order + 1)); + if (rule.hasSurface) lines.add(I18n.format(rule.effectiveSurface + ? "label.orespawn.biome.surface_effective" : "label.orespawn.biome.surface_overridden")); + return lines; } - private void rebuildWidgets() { - buttons.clear(); children.clear(); - init(); + private void captureScroll() { + if (biomeList != null) biomeScroll = biomeList.firstIndex(); + if (ruleList != null) ruleScroll = ruleList.firstIndex(); } + private void rebuild() { captureScroll(); buttons.clear(); children.clear(); init(); } + + @Override public void onClose() { minecraft.displayGuiScreen(parent); } + @Override - public void onClose() { - minecraft.displayGuiScreen(parent); + public void handleMouseInput() throws java.io.IOException { + super.handleMouseInput(); + int wheel = Mouse.getEventDWheel(); + if (wheel == 0) return; + int mouseX = Mouse.getEventX() * width / minecraft.displayWidth; + int mouseY = height - Mouse.getEventY() * height / minecraft.displayHeight - 1; + if (biomeList != null && biomeList.mouseWheel(mouseX, mouseY, wheel)) biomeScroll = biomeList.firstIndex(); + else if (ruleList != null && ruleList.mouseWheel(mouseX, mouseY, wheel)) ruleScroll = ruleList.firstIndex(); } @Override public void render(int mouseX, int mouseY, float partialTick) { renderBackground(); - drawCenteredString(font, title, width / 2, 14, 0xFFFFFF); + drawCenteredString(font, title, width / 2, 9, 0xFFFFFF); + BiomeEntry selected = find(session.biomeDirectory().entries(dimension, true), selectedId); + if (selected != null && (width >= TWO_PANE_MINIMUM || compactDetail)) { + int contentWidth = contentWidth(width); + int x = width < TWO_PANE_MINIMUM ? (width - contentWidth) / 2 + : (width - contentWidth) / 2 + leftPaneWidth(width) + 10; + int y = width < TWO_PANE_MINIMUM ? 34 : CONTENT_TOP; + drawString(font, new TextComponentString(selected.name), x + 3, y, 0xFFFFFF); + drawString(font, new TextComponentString(selected.id), x + 3, y + 11, 0xAAAAAA); + drawString(font, new TextComponentTranslation("value.orespawn.biome.status." + + selected.status.name().toLowerCase(java.util.Locale.ROOT)), x + 3, y + 22, + statusColor(selected.status)); + if (selected.replacementTarget != null) { + String notice = I18n.format("label.orespawn.biome.new_terrain_only"); + drawString(font, new TextComponentString(notice), + detailX + detailWidth - font.getStringWidth(notice) - 3, y + 22, 0xFFFF55); + } + drawString(font, new TextComponentTranslation("label.orespawn.biome.placement_rules"), + detailX + 3, detailRuleTop - 10, 0xCCCCCC); + } + if (message != null) drawCenteredString(font, new TextComponentString(message), width / 2, height - 39, 0xFF5555); super.render(mouseX, mouseY, partialTick); OreSpawnScreenLayout.renderExplanations(this, mouseX, mouseY); } - private static String string(JsonObject root, String key, String fallback) { - return root.has(key) ? root.get(key).getAsString() : fallback; - } - - private static boolean bool(JsonObject root, String key, boolean fallback) { - return root.has(key) ? root.get(key).getAsBoolean() : fallback; - } + static int contentWidth(int width) { return Math.min(520, Math.max(300, width - 20)); } + static int leftPaneWidth(int width) { return Math.max(132, (contentWidth(width) * 43) / 100); } + static int listHeight(int height) { return Math.max(32, height - CONTENT_TOP - 36); } } 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 18242f57..71ae6c1b 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java @@ -170,6 +170,13 @@ List availableDimensionIds() { if (validResource(id)) result.add(new ResourceLocation(id).toString()); } } + for (String sectionName : Arrays.asList("biome_palettes", "dimension_materials")) { + for (Entry entry : section(sectionName).entrySet()) { + if (!entry.getValue().isJsonObject()) continue; + String id = string(entry.getValue().getAsJsonObject(), "dimension", ""); + if (validResource(id)) result.add(new ResourceLocation(id).toString()); + } + } List ordered = new ArrayList<>(); for (String vanilla : Arrays.asList("minecraft:overworld", "minecraft:the_nether", "minecraft:the_end")) { ordered.add(vanilla); @@ -1854,6 +1861,7 @@ List validate() { continue; } JsonObject palette = entry.getValue().getAsJsonObject(); + boolean replacementPalette = BiomeReplacementRules.isOverridePalette(entry.getKey()); if (!validResource(string(palette, "dimension", ""))) { errors.add("Invalid biome palette dimension: " + entry.getKey()); } @@ -1863,7 +1871,8 @@ List validate() { if (biomes.entrySet().size() == 0) errors.add("Enabled biome palette has no biomes: " + entry.getKey()); for (Entry biome : biomes.entrySet()) { if (!validResource(biome.getKey()) - || ForgeRegistries.BIOMES.getValue(new ResourceLocation(biome.getKey())) == null + || (!replacementPalette + && ForgeRegistries.BIOMES.getValue(new ResourceLocation(biome.getKey())) == null) || !biome.getValue().isJsonObject()) { errors.add("Invalid biome placement: " + biome.getKey()); continue; diff --git a/src/main/resources/assets/orespawn/lang/de_AU.lang b/src/main/resources/assets/orespawn/lang/de_AU.lang index c262e141..54d33399 100644 --- a/src/main/resources/assets/orespawn/lang/de_AU.lang +++ b/src/main/resources/assets/orespawn/lang/de_AU.lang @@ -302,7 +302,7 @@ screen.orespawn.choose_biome=Installiertes Biom auswählen screen.orespawn.biome_placement=Biomeinstellungen screen.orespawn.biome_references=Ähnliche Biome screen.orespawn.dimension_materials=Weltmaterialien -button.orespawn.biomes_world_materials=Biome und Weltmaterialien... +button.orespawn.biomes_world_materials=Biome... button.orespawn.biome_palette_count=Biome: %s button.orespawn.dimension_materials=Weltmaterialien... button.orespawn.geome_influences=Geomeinflüsse... @@ -470,3 +470,41 @@ tooltip.orespawn.ore_source.group_colours=Grüne Einträge haben einen Alias und button.orespawn.ore_source.reset_all=Alles zurücksetzen button.orespawn.ore_source.confirm_reset_all=Zurücksetzen bestätigen tooltip.orespawn.ore_source.reset_all=Stellt die eingebauten Materialgruppen wieder her und erzeugt die Quellenregeln aus den aktuell geladenen Erzen neu. Die Änderung bleibt bis Fertig im Haupteditor ausstehend. +screen.orespawn.biomes=Biome +screen.orespawn.biome_palettes=Biompaletten +screen.orespawn.biome_palette_settings=Biompaletten-Einstellungen +button.orespawn.biome.hide_routine=Gewöhnliche ausblenden +button.orespawn.biome.replace_new_terrain=In neuem Gelände ersetzen durch... +button.orespawn.biome.replace_with=In neuem Gelände ersetzen durch %s... +button.orespawn.biome.leave_original=Ursprüngliches Verhalten beibehalten +button.orespawn.biome.palettes=Paletten: %s +button.orespawn.biome.reset_selected=Biom zurücksetzen +button.orespawn.biome.confirm_reset=Zurücksetzen bestätigen +button.orespawn.biome.reset_palette=Palette zurücksetzen +button.orespawn.biome.confirm_reset_palette=Paletten-Reset bestätigen +button.orespawn.biome.reset_dimension=Dimension zurücksetzen +button.orespawn.biome.confirm_reset_dimension=Dimensions-Reset bestätigen +button.orespawn.biome.reset_all=Alle Biome zurücksetzen +button.orespawn.biome.confirm_reset_all=Gesamten Reset bestätigen +warning.orespawn.biome.target_not_declared=%s wurde für diese Dimension von keinem Anbieter deklariert. +value.orespawn.biome.status.user_replaced=In neuem Gelände ersetzt +value.orespawn.biome.status.layered=Mehrere Platzierungsregeln +value.orespawn.biome.status.disabled=Platzierung deaktiviert +value.orespawn.biome.status.missing=Biom oder Anbieter fehlt +value.orespawn.biome.status.provider_managed=Vom Anbieter verwaltet +value.orespawn.biome.status.unmanaged=Registriert, nicht verwaltet +label.orespawn.biome.rule_count=Platzierungsregeln: %s +label.orespawn.biome.replaced_by=Ersatz in neuem Gelände: %s +label.orespawn.biome.palette_owner_order=Besitzer: %s; wirksame Reihenfolge: %s +label.orespawn.biome.surface_effective=Diese Regel liefert die wirksame Oberfläche. +label.orespawn.biome.surface_overridden=Eine spätere aktive Regel überschreibt diese Oberfläche. +label.orespawn.biome.new_terrain_only=Nur neues Gelände +label.orespawn.biome.placement_rules=Platzierungsregeln +label.orespawn.biome.palette_entries=Platzierungseinträge: %s +tooltip.orespawn.biome.no_retrogen=Biomersetzungen gelten nur beim Erzeugen neuen Geländes. Bestehende Chunks werden nie umgeschrieben. +tooltip.orespawn.biome.override_palette=Reservierte abschließende Ersatzebene. Bearbeitung über die Ersatzsteuerung des Bioms. +tooltip.orespawn.biome.palette_entries_read_only=Die Palettenzugehörigkeit wird vom Anbieter oder Profil festgelegt. Wähle ein Biom im Verzeichnis, um eine vorhandene Regel zu bearbeiten. +option.orespawn.biome.coverage=Abdeckung +option.orespawn.biome.fallback_weight=Gewicht des ursprünglichen Bioms +option.orespawn.biome.include_namespaces=Eingeschlossene Namensräume (kommagetrennt) +option.orespawn.biome.exclude_namespaces=Ausgeschlossene Namensräume (kommagetrennt) diff --git a/src/main/resources/assets/orespawn/lang/de_DE.lang b/src/main/resources/assets/orespawn/lang/de_DE.lang index c262e141..54d33399 100644 --- a/src/main/resources/assets/orespawn/lang/de_DE.lang +++ b/src/main/resources/assets/orespawn/lang/de_DE.lang @@ -302,7 +302,7 @@ screen.orespawn.choose_biome=Installiertes Biom auswählen screen.orespawn.biome_placement=Biomeinstellungen screen.orespawn.biome_references=Ähnliche Biome screen.orespawn.dimension_materials=Weltmaterialien -button.orespawn.biomes_world_materials=Biome und Weltmaterialien... +button.orespawn.biomes_world_materials=Biome... button.orespawn.biome_palette_count=Biome: %s button.orespawn.dimension_materials=Weltmaterialien... button.orespawn.geome_influences=Geomeinflüsse... @@ -470,3 +470,41 @@ tooltip.orespawn.ore_source.group_colours=Grüne Einträge haben einen Alias und button.orespawn.ore_source.reset_all=Alles zurücksetzen button.orespawn.ore_source.confirm_reset_all=Zurücksetzen bestätigen tooltip.orespawn.ore_source.reset_all=Stellt die eingebauten Materialgruppen wieder her und erzeugt die Quellenregeln aus den aktuell geladenen Erzen neu. Die Änderung bleibt bis Fertig im Haupteditor ausstehend. +screen.orespawn.biomes=Biome +screen.orespawn.biome_palettes=Biompaletten +screen.orespawn.biome_palette_settings=Biompaletten-Einstellungen +button.orespawn.biome.hide_routine=Gewöhnliche ausblenden +button.orespawn.biome.replace_new_terrain=In neuem Gelände ersetzen durch... +button.orespawn.biome.replace_with=In neuem Gelände ersetzen durch %s... +button.orespawn.biome.leave_original=Ursprüngliches Verhalten beibehalten +button.orespawn.biome.palettes=Paletten: %s +button.orespawn.biome.reset_selected=Biom zurücksetzen +button.orespawn.biome.confirm_reset=Zurücksetzen bestätigen +button.orespawn.biome.reset_palette=Palette zurücksetzen +button.orespawn.biome.confirm_reset_palette=Paletten-Reset bestätigen +button.orespawn.biome.reset_dimension=Dimension zurücksetzen +button.orespawn.biome.confirm_reset_dimension=Dimensions-Reset bestätigen +button.orespawn.biome.reset_all=Alle Biome zurücksetzen +button.orespawn.biome.confirm_reset_all=Gesamten Reset bestätigen +warning.orespawn.biome.target_not_declared=%s wurde für diese Dimension von keinem Anbieter deklariert. +value.orespawn.biome.status.user_replaced=In neuem Gelände ersetzt +value.orespawn.biome.status.layered=Mehrere Platzierungsregeln +value.orespawn.biome.status.disabled=Platzierung deaktiviert +value.orespawn.biome.status.missing=Biom oder Anbieter fehlt +value.orespawn.biome.status.provider_managed=Vom Anbieter verwaltet +value.orespawn.biome.status.unmanaged=Registriert, nicht verwaltet +label.orespawn.biome.rule_count=Platzierungsregeln: %s +label.orespawn.biome.replaced_by=Ersatz in neuem Gelände: %s +label.orespawn.biome.palette_owner_order=Besitzer: %s; wirksame Reihenfolge: %s +label.orespawn.biome.surface_effective=Diese Regel liefert die wirksame Oberfläche. +label.orespawn.biome.surface_overridden=Eine spätere aktive Regel überschreibt diese Oberfläche. +label.orespawn.biome.new_terrain_only=Nur neues Gelände +label.orespawn.biome.placement_rules=Platzierungsregeln +label.orespawn.biome.palette_entries=Platzierungseinträge: %s +tooltip.orespawn.biome.no_retrogen=Biomersetzungen gelten nur beim Erzeugen neuen Geländes. Bestehende Chunks werden nie umgeschrieben. +tooltip.orespawn.biome.override_palette=Reservierte abschließende Ersatzebene. Bearbeitung über die Ersatzsteuerung des Bioms. +tooltip.orespawn.biome.palette_entries_read_only=Die Palettenzugehörigkeit wird vom Anbieter oder Profil festgelegt. Wähle ein Biom im Verzeichnis, um eine vorhandene Regel zu bearbeiten. +option.orespawn.biome.coverage=Abdeckung +option.orespawn.biome.fallback_weight=Gewicht des ursprünglichen Bioms +option.orespawn.biome.include_namespaces=Eingeschlossene Namensräume (kommagetrennt) +option.orespawn.biome.exclude_namespaces=Ausgeschlossene Namensräume (kommagetrennt) diff --git a/src/main/resources/assets/orespawn/lang/en_CA.lang b/src/main/resources/assets/orespawn/lang/en_CA.lang index d94b0fd0..53e5ce7f 100644 --- a/src/main/resources/assets/orespawn/lang/en_CA.lang +++ b/src/main/resources/assets/orespawn/lang/en_CA.lang @@ -302,7 +302,7 @@ screen.orespawn.choose_biome=Choose Installed Biome screen.orespawn.biome_placement=Biome Settings screen.orespawn.biome_references=Similar Biomes screen.orespawn.dimension_materials=World Materials -button.orespawn.biomes_world_materials=Biomes & World Materials... +button.orespawn.biomes_world_materials=Biomes... button.orespawn.biome_palette_count=Biomes: %s button.orespawn.dimension_materials=World Materials... button.orespawn.geome_influences=Geome Influences... @@ -470,3 +470,41 @@ tooltip.orespawn.ore_source.group_colours=Green entries have one alias and one o button.orespawn.ore_source.reset_all=Reset All button.orespawn.ore_source.confirm_reset_all=Confirm Reset tooltip.orespawn.ore_source.reset_all=Restore built-in material groups and recreate source policies from the currently loaded ores. The change remains pending until the main editor's Done button is used. +screen.orespawn.biomes=Biomes +screen.orespawn.biome_palettes=Biome Palettes +screen.orespawn.biome_palette_settings=Biome Palette Settings +button.orespawn.biome.hide_routine=Hide Routine +button.orespawn.biome.replace_new_terrain=Replace in new terrain with... +button.orespawn.biome.replace_with=Replace in new terrain with %s... +button.orespawn.biome.leave_original=Leave original behaviour +button.orespawn.biome.palettes=Palettes: %s +button.orespawn.biome.reset_selected=Reset Biome +button.orespawn.biome.confirm_reset=Confirm Reset +button.orespawn.biome.reset_palette=Reset Palette +button.orespawn.biome.confirm_reset_palette=Confirm Palette Reset +button.orespawn.biome.reset_dimension=Reset Dimension +button.orespawn.biome.confirm_reset_dimension=Confirm Dimension Reset +button.orespawn.biome.reset_all=Reset All Biomes +button.orespawn.biome.confirm_reset_all=Confirm All-Biome Reset +warning.orespawn.biome.target_not_declared=%s is not declared by a provider for this dimension. +value.orespawn.biome.status.user_replaced=Replaced in new terrain +value.orespawn.biome.status.layered=Several placement rules +value.orespawn.biome.status.disabled=Placement disabled +value.orespawn.biome.status.missing=Missing biome or provider +value.orespawn.biome.status.provider_managed=Provider-managed +value.orespawn.biome.status.unmanaged=Registered, unmanaged +label.orespawn.biome.rule_count=Placement rules: %s +label.orespawn.biome.replaced_by=New-terrain replacement: %s +label.orespawn.biome.palette_owner_order=Owner: %s; effective order: %s +label.orespawn.biome.surface_effective=This rule supplies the effective surface. +label.orespawn.biome.surface_overridden=A later enabled rule overrides this surface. +label.orespawn.biome.new_terrain_only=New terrain only +label.orespawn.biome.placement_rules=Placement rules +label.orespawn.biome.palette_entries=Placement entries: %s +tooltip.orespawn.biome.no_retrogen=Biome replacements apply only while new terrain is generated. Existing chunks are never rewritten. +tooltip.orespawn.biome.override_palette=Reserved final replacement layer. Edit it through each biome's replacement control. +tooltip.orespawn.biome.palette_entries_read_only=Palette membership is provider/profile-defined. Select a biome in the directory to edit an existing placement rule. +option.orespawn.biome.coverage=Coverage +option.orespawn.biome.fallback_weight=Original-biome weight +option.orespawn.biome.include_namespaces=Included namespaces (comma-separated) +option.orespawn.biome.exclude_namespaces=Excluded namespaces (comma-separated) diff --git a/src/main/resources/assets/orespawn/lang/en_EN.lang b/src/main/resources/assets/orespawn/lang/en_EN.lang index d94b0fd0..53e5ce7f 100644 --- a/src/main/resources/assets/orespawn/lang/en_EN.lang +++ b/src/main/resources/assets/orespawn/lang/en_EN.lang @@ -302,7 +302,7 @@ screen.orespawn.choose_biome=Choose Installed Biome screen.orespawn.biome_placement=Biome Settings screen.orespawn.biome_references=Similar Biomes screen.orespawn.dimension_materials=World Materials -button.orespawn.biomes_world_materials=Biomes & World Materials... +button.orespawn.biomes_world_materials=Biomes... button.orespawn.biome_palette_count=Biomes: %s button.orespawn.dimension_materials=World Materials... button.orespawn.geome_influences=Geome Influences... @@ -470,3 +470,41 @@ tooltip.orespawn.ore_source.group_colours=Green entries have one alias and one o button.orespawn.ore_source.reset_all=Reset All button.orespawn.ore_source.confirm_reset_all=Confirm Reset tooltip.orespawn.ore_source.reset_all=Restore built-in material groups and recreate source policies from the currently loaded ores. The change remains pending until the main editor's Done button is used. +screen.orespawn.biomes=Biomes +screen.orespawn.biome_palettes=Biome Palettes +screen.orespawn.biome_palette_settings=Biome Palette Settings +button.orespawn.biome.hide_routine=Hide Routine +button.orespawn.biome.replace_new_terrain=Replace in new terrain with... +button.orespawn.biome.replace_with=Replace in new terrain with %s... +button.orespawn.biome.leave_original=Leave original behaviour +button.orespawn.biome.palettes=Palettes: %s +button.orespawn.biome.reset_selected=Reset Biome +button.orespawn.biome.confirm_reset=Confirm Reset +button.orespawn.biome.reset_palette=Reset Palette +button.orespawn.biome.confirm_reset_palette=Confirm Palette Reset +button.orespawn.biome.reset_dimension=Reset Dimension +button.orespawn.biome.confirm_reset_dimension=Confirm Dimension Reset +button.orespawn.biome.reset_all=Reset All Biomes +button.orespawn.biome.confirm_reset_all=Confirm All-Biome Reset +warning.orespawn.biome.target_not_declared=%s is not declared by a provider for this dimension. +value.orespawn.biome.status.user_replaced=Replaced in new terrain +value.orespawn.biome.status.layered=Several placement rules +value.orespawn.biome.status.disabled=Placement disabled +value.orespawn.biome.status.missing=Missing biome or provider +value.orespawn.biome.status.provider_managed=Provider-managed +value.orespawn.biome.status.unmanaged=Registered, unmanaged +label.orespawn.biome.rule_count=Placement rules: %s +label.orespawn.biome.replaced_by=New-terrain replacement: %s +label.orespawn.biome.palette_owner_order=Owner: %s; effective order: %s +label.orespawn.biome.surface_effective=This rule supplies the effective surface. +label.orespawn.biome.surface_overridden=A later enabled rule overrides this surface. +label.orespawn.biome.new_terrain_only=New terrain only +label.orespawn.biome.placement_rules=Placement rules +label.orespawn.biome.palette_entries=Placement entries: %s +tooltip.orespawn.biome.no_retrogen=Biome replacements apply only while new terrain is generated. Existing chunks are never rewritten. +tooltip.orespawn.biome.override_palette=Reserved final replacement layer. Edit it through each biome's replacement control. +tooltip.orespawn.biome.palette_entries_read_only=Palette membership is provider/profile-defined. Select a biome in the directory to edit an existing placement rule. +option.orespawn.biome.coverage=Coverage +option.orespawn.biome.fallback_weight=Original-biome weight +option.orespawn.biome.include_namespaces=Included namespaces (comma-separated) +option.orespawn.biome.exclude_namespaces=Excluded namespaces (comma-separated) diff --git a/src/main/resources/assets/orespawn/lang/en_GB.lang b/src/main/resources/assets/orespawn/lang/en_GB.lang index d94b0fd0..53e5ce7f 100644 --- a/src/main/resources/assets/orespawn/lang/en_GB.lang +++ b/src/main/resources/assets/orespawn/lang/en_GB.lang @@ -302,7 +302,7 @@ screen.orespawn.choose_biome=Choose Installed Biome screen.orespawn.biome_placement=Biome Settings screen.orespawn.biome_references=Similar Biomes screen.orespawn.dimension_materials=World Materials -button.orespawn.biomes_world_materials=Biomes & World Materials... +button.orespawn.biomes_world_materials=Biomes... button.orespawn.biome_palette_count=Biomes: %s button.orespawn.dimension_materials=World Materials... button.orespawn.geome_influences=Geome Influences... @@ -470,3 +470,41 @@ tooltip.orespawn.ore_source.group_colours=Green entries have one alias and one o button.orespawn.ore_source.reset_all=Reset All button.orespawn.ore_source.confirm_reset_all=Confirm Reset tooltip.orespawn.ore_source.reset_all=Restore built-in material groups and recreate source policies from the currently loaded ores. The change remains pending until the main editor's Done button is used. +screen.orespawn.biomes=Biomes +screen.orespawn.biome_palettes=Biome Palettes +screen.orespawn.biome_palette_settings=Biome Palette Settings +button.orespawn.biome.hide_routine=Hide Routine +button.orespawn.biome.replace_new_terrain=Replace in new terrain with... +button.orespawn.biome.replace_with=Replace in new terrain with %s... +button.orespawn.biome.leave_original=Leave original behaviour +button.orespawn.biome.palettes=Palettes: %s +button.orespawn.biome.reset_selected=Reset Biome +button.orespawn.biome.confirm_reset=Confirm Reset +button.orespawn.biome.reset_palette=Reset Palette +button.orespawn.biome.confirm_reset_palette=Confirm Palette Reset +button.orespawn.biome.reset_dimension=Reset Dimension +button.orespawn.biome.confirm_reset_dimension=Confirm Dimension Reset +button.orespawn.biome.reset_all=Reset All Biomes +button.orespawn.biome.confirm_reset_all=Confirm All-Biome Reset +warning.orespawn.biome.target_not_declared=%s is not declared by a provider for this dimension. +value.orespawn.biome.status.user_replaced=Replaced in new terrain +value.orespawn.biome.status.layered=Several placement rules +value.orespawn.biome.status.disabled=Placement disabled +value.orespawn.biome.status.missing=Missing biome or provider +value.orespawn.biome.status.provider_managed=Provider-managed +value.orespawn.biome.status.unmanaged=Registered, unmanaged +label.orespawn.biome.rule_count=Placement rules: %s +label.orespawn.biome.replaced_by=New-terrain replacement: %s +label.orespawn.biome.palette_owner_order=Owner: %s; effective order: %s +label.orespawn.biome.surface_effective=This rule supplies the effective surface. +label.orespawn.biome.surface_overridden=A later enabled rule overrides this surface. +label.orespawn.biome.new_terrain_only=New terrain only +label.orespawn.biome.placement_rules=Placement rules +label.orespawn.biome.palette_entries=Placement entries: %s +tooltip.orespawn.biome.no_retrogen=Biome replacements apply only while new terrain is generated. Existing chunks are never rewritten. +tooltip.orespawn.biome.override_palette=Reserved final replacement layer. Edit it through each biome's replacement control. +tooltip.orespawn.biome.palette_entries_read_only=Palette membership is provider/profile-defined. Select a biome in the directory to edit an existing placement rule. +option.orespawn.biome.coverage=Coverage +option.orespawn.biome.fallback_weight=Original-biome weight +option.orespawn.biome.include_namespaces=Included namespaces (comma-separated) +option.orespawn.biome.exclude_namespaces=Excluded namespaces (comma-separated) diff --git a/src/main/resources/assets/orespawn/lang/en_PT.lang b/src/main/resources/assets/orespawn/lang/en_PT.lang index d94b0fd0..53e5ce7f 100644 --- a/src/main/resources/assets/orespawn/lang/en_PT.lang +++ b/src/main/resources/assets/orespawn/lang/en_PT.lang @@ -302,7 +302,7 @@ screen.orespawn.choose_biome=Choose Installed Biome screen.orespawn.biome_placement=Biome Settings screen.orespawn.biome_references=Similar Biomes screen.orespawn.dimension_materials=World Materials -button.orespawn.biomes_world_materials=Biomes & World Materials... +button.orespawn.biomes_world_materials=Biomes... button.orespawn.biome_palette_count=Biomes: %s button.orespawn.dimension_materials=World Materials... button.orespawn.geome_influences=Geome Influences... @@ -470,3 +470,41 @@ tooltip.orespawn.ore_source.group_colours=Green entries have one alias and one o button.orespawn.ore_source.reset_all=Reset All button.orespawn.ore_source.confirm_reset_all=Confirm Reset tooltip.orespawn.ore_source.reset_all=Restore built-in material groups and recreate source policies from the currently loaded ores. The change remains pending until the main editor's Done button is used. +screen.orespawn.biomes=Biomes +screen.orespawn.biome_palettes=Biome Palettes +screen.orespawn.biome_palette_settings=Biome Palette Settings +button.orespawn.biome.hide_routine=Hide Routine +button.orespawn.biome.replace_new_terrain=Replace in new terrain with... +button.orespawn.biome.replace_with=Replace in new terrain with %s... +button.orespawn.biome.leave_original=Leave original behaviour +button.orespawn.biome.palettes=Palettes: %s +button.orespawn.biome.reset_selected=Reset Biome +button.orespawn.biome.confirm_reset=Confirm Reset +button.orespawn.biome.reset_palette=Reset Palette +button.orespawn.biome.confirm_reset_palette=Confirm Palette Reset +button.orespawn.biome.reset_dimension=Reset Dimension +button.orespawn.biome.confirm_reset_dimension=Confirm Dimension Reset +button.orespawn.biome.reset_all=Reset All Biomes +button.orespawn.biome.confirm_reset_all=Confirm All-Biome Reset +warning.orespawn.biome.target_not_declared=%s is not declared by a provider for this dimension. +value.orespawn.biome.status.user_replaced=Replaced in new terrain +value.orespawn.biome.status.layered=Several placement rules +value.orespawn.biome.status.disabled=Placement disabled +value.orespawn.biome.status.missing=Missing biome or provider +value.orespawn.biome.status.provider_managed=Provider-managed +value.orespawn.biome.status.unmanaged=Registered, unmanaged +label.orespawn.biome.rule_count=Placement rules: %s +label.orespawn.biome.replaced_by=New-terrain replacement: %s +label.orespawn.biome.palette_owner_order=Owner: %s; effective order: %s +label.orespawn.biome.surface_effective=This rule supplies the effective surface. +label.orespawn.biome.surface_overridden=A later enabled rule overrides this surface. +label.orespawn.biome.new_terrain_only=New terrain only +label.orespawn.biome.placement_rules=Placement rules +label.orespawn.biome.palette_entries=Placement entries: %s +tooltip.orespawn.biome.no_retrogen=Biome replacements apply only while new terrain is generated. Existing chunks are never rewritten. +tooltip.orespawn.biome.override_palette=Reserved final replacement layer. Edit it through each biome's replacement control. +tooltip.orespawn.biome.palette_entries_read_only=Palette membership is provider/profile-defined. Select a biome in the directory to edit an existing placement rule. +option.orespawn.biome.coverage=Coverage +option.orespawn.biome.fallback_weight=Original-biome weight +option.orespawn.biome.include_namespaces=Included namespaces (comma-separated) +option.orespawn.biome.exclude_namespaces=Excluded namespaces (comma-separated) diff --git a/src/main/resources/assets/orespawn/lang/en_US.lang b/src/main/resources/assets/orespawn/lang/en_US.lang index b2f12c24..3d4029c4 100644 --- a/src/main/resources/assets/orespawn/lang/en_US.lang +++ b/src/main/resources/assets/orespawn/lang/en_US.lang @@ -302,7 +302,7 @@ screen.orespawn.choose_biome=Choose Installed Biome screen.orespawn.biome_placement=Biome Settings screen.orespawn.biome_references=Similar Biomes screen.orespawn.dimension_materials=World Materials -button.orespawn.biomes_world_materials=Biomes & World Materials... +button.orespawn.biomes_world_materials=Biomes... button.orespawn.biome_palette_count=Biomes: %s button.orespawn.dimension_materials=World Materials... button.orespawn.geome_influences=Geome Influences... @@ -470,3 +470,41 @@ tooltip.orespawn.ore_source.group_colours=Green entries have one alias and one o button.orespawn.ore_source.reset_all=Reset All button.orespawn.ore_source.confirm_reset_all=Confirm Reset tooltip.orespawn.ore_source.reset_all=Restore built-in material groups and recreate source policies from the currently loaded ores. The change remains pending until the main editor's Done button is used. +screen.orespawn.biomes=Biomes +screen.orespawn.biome_palettes=Biome Palettes +screen.orespawn.biome_palette_settings=Biome Palette Settings +button.orespawn.biome.hide_routine=Hide Routine +button.orespawn.biome.replace_new_terrain=Replace in new terrain with... +button.orespawn.biome.replace_with=Replace in new terrain with %s... +button.orespawn.biome.leave_original=Leave original behaviour +button.orespawn.biome.palettes=Palettes: %s +button.orespawn.biome.reset_selected=Reset Biome +button.orespawn.biome.confirm_reset=Confirm Reset +button.orespawn.biome.reset_palette=Reset Palette +button.orespawn.biome.confirm_reset_palette=Confirm Palette Reset +button.orespawn.biome.reset_dimension=Reset Dimension +button.orespawn.biome.confirm_reset_dimension=Confirm Dimension Reset +button.orespawn.biome.reset_all=Reset All Biomes +button.orespawn.biome.confirm_reset_all=Confirm All-Biome Reset +warning.orespawn.biome.target_not_declared=%s is not declared by a provider for this dimension. +value.orespawn.biome.status.user_replaced=Replaced in new terrain +value.orespawn.biome.status.layered=Several placement rules +value.orespawn.biome.status.disabled=Placement disabled +value.orespawn.biome.status.missing=Missing biome or provider +value.orespawn.biome.status.provider_managed=Provider-managed +value.orespawn.biome.status.unmanaged=Registered, unmanaged +label.orespawn.biome.rule_count=Placement rules: %s +label.orespawn.biome.replaced_by=New-terrain replacement: %s +label.orespawn.biome.palette_owner_order=Owner: %s; effective order: %s +label.orespawn.biome.surface_effective=This rule supplies the effective surface. +label.orespawn.biome.surface_overridden=A later enabled rule overrides this surface. +label.orespawn.biome.new_terrain_only=New terrain only +label.orespawn.biome.placement_rules=Placement rules +label.orespawn.biome.palette_entries=Placement entries: %s +tooltip.orespawn.biome.no_retrogen=Biome replacements apply only while new terrain is generated. Existing chunks are never rewritten. +tooltip.orespawn.biome.override_palette=Reserved final replacement layer. Edit it through each biome's replacement control. +tooltip.orespawn.biome.palette_entries_read_only=Palette membership is provider/profile-defined. Select a biome in the directory to edit an existing placement rule. +option.orespawn.biome.coverage=Coverage +option.orespawn.biome.fallback_weight=Original-biome weight +option.orespawn.biome.include_namespaces=Included namespaces (comma-separated) +option.orespawn.biome.exclude_namespaces=Excluded namespaces (comma-separated) diff --git a/src/main/resources/assets/orespawn/lang/es_ES.lang b/src/main/resources/assets/orespawn/lang/es_ES.lang index dcb749dd..cd7a02de 100644 --- a/src/main/resources/assets/orespawn/lang/es_ES.lang +++ b/src/main/resources/assets/orespawn/lang/es_ES.lang @@ -302,7 +302,7 @@ screen.orespawn.choose_biome=Elegir bioma instalado screen.orespawn.biome_placement=Ajustes del bioma screen.orespawn.biome_references=Biomas similares screen.orespawn.dimension_materials=Materiales del mundo -button.orespawn.biomes_world_materials=Biomas y materiales del mundo... +button.orespawn.biomes_world_materials=Biomas... button.orespawn.biome_palette_count=Biomas: %s button.orespawn.dimension_materials=Materiales del mundo... button.orespawn.geome_influences=Influencias de geomas... @@ -470,3 +470,41 @@ tooltip.orespawn.ore_source.group_colours=Las entradas verdes tienen un alias y button.orespawn.ore_source.reset_all=Restablecer todo button.orespawn.ore_source.confirm_reset_all=Confirmar restablecimiento tooltip.orespawn.ore_source.reset_all=Restaura los grupos de materiales incorporados y vuelve a crear las políticas desde los minerales cargados. El cambio queda pendiente hasta pulsar Hecho en el editor principal. +screen.orespawn.biomes=Biomas +screen.orespawn.biome_palettes=Paletas de biomas +screen.orespawn.biome_palette_settings=Ajustes de paleta de biomas +button.orespawn.biome.hide_routine=Ocultar normales +button.orespawn.biome.replace_new_terrain=Reemplazar en terreno nuevo por... +button.orespawn.biome.replace_with=Reemplazar en terreno nuevo por %s... +button.orespawn.biome.leave_original=Mantener el comportamiento original +button.orespawn.biome.palettes=Paletas: %s +button.orespawn.biome.reset_selected=Restablecer bioma +button.orespawn.biome.confirm_reset=Confirmar restablecimiento +button.orespawn.biome.reset_palette=Restablecer paleta +button.orespawn.biome.confirm_reset_palette=Confirmar paleta +button.orespawn.biome.reset_dimension=Restablecer dimensión +button.orespawn.biome.confirm_reset_dimension=Confirmar dimensión +button.orespawn.biome.reset_all=Restablecer todos los biomas +button.orespawn.biome.confirm_reset_all=Confirmar todo +warning.orespawn.biome.target_not_declared=%s no está declarado por un proveedor para esta dimensión. +value.orespawn.biome.status.user_replaced=Reemplazado en terreno nuevo +value.orespawn.biome.status.layered=Varias reglas de colocación +value.orespawn.biome.status.disabled=Colocación desactivada +value.orespawn.biome.status.missing=Falta el bioma o proveedor +value.orespawn.biome.status.provider_managed=Gestionado por proveedor +value.orespawn.biome.status.unmanaged=Registrado, no gestionado +label.orespawn.biome.rule_count=Reglas de colocación: %s +label.orespawn.biome.replaced_by=Reemplazo en terreno nuevo: %s +label.orespawn.biome.palette_owner_order=Propietario: %s; orden efectivo: %s +label.orespawn.biome.surface_effective=Esta regla aporta la superficie efectiva. +label.orespawn.biome.surface_overridden=Una regla activa posterior reemplaza esta superficie. +label.orespawn.biome.new_terrain_only=Solo terreno nuevo +label.orespawn.biome.placement_rules=Reglas de colocación +label.orespawn.biome.palette_entries=Entradas de colocación: %s +tooltip.orespawn.biome.no_retrogen=Los reemplazos de bioma solo se aplican al generar terreno nuevo. Los chunks existentes nunca se reescriben. +tooltip.orespawn.biome.override_palette=Capa final reservada para reemplazos. Edítala desde el control de reemplazo de cada bioma. +tooltip.orespawn.biome.palette_entries_read_only=La pertenencia a la paleta la define el proveedor o perfil. Selecciona un bioma para editar una regla existente. +option.orespawn.biome.coverage=Cobertura +option.orespawn.biome.fallback_weight=Peso del bioma original +option.orespawn.biome.include_namespaces=Espacios de nombres incluidos (separados por comas) +option.orespawn.biome.exclude_namespaces=Espacios de nombres excluidos (separados por comas) diff --git a/src/main/resources/assets/orespawn/lang/es_MX.lang b/src/main/resources/assets/orespawn/lang/es_MX.lang index dcb749dd..cd7a02de 100644 --- a/src/main/resources/assets/orespawn/lang/es_MX.lang +++ b/src/main/resources/assets/orespawn/lang/es_MX.lang @@ -302,7 +302,7 @@ screen.orespawn.choose_biome=Elegir bioma instalado screen.orespawn.biome_placement=Ajustes del bioma screen.orespawn.biome_references=Biomas similares screen.orespawn.dimension_materials=Materiales del mundo -button.orespawn.biomes_world_materials=Biomas y materiales del mundo... +button.orespawn.biomes_world_materials=Biomas... button.orespawn.biome_palette_count=Biomas: %s button.orespawn.dimension_materials=Materiales del mundo... button.orespawn.geome_influences=Influencias de geomas... @@ -470,3 +470,41 @@ tooltip.orespawn.ore_source.group_colours=Las entradas verdes tienen un alias y button.orespawn.ore_source.reset_all=Restablecer todo button.orespawn.ore_source.confirm_reset_all=Confirmar restablecimiento tooltip.orespawn.ore_source.reset_all=Restaura los grupos de materiales incorporados y vuelve a crear las políticas desde los minerales cargados. El cambio queda pendiente hasta pulsar Hecho en el editor principal. +screen.orespawn.biomes=Biomas +screen.orespawn.biome_palettes=Paletas de biomas +screen.orespawn.biome_palette_settings=Ajustes de paleta de biomas +button.orespawn.biome.hide_routine=Ocultar normales +button.orespawn.biome.replace_new_terrain=Reemplazar en terreno nuevo por... +button.orespawn.biome.replace_with=Reemplazar en terreno nuevo por %s... +button.orespawn.biome.leave_original=Mantener el comportamiento original +button.orespawn.biome.palettes=Paletas: %s +button.orespawn.biome.reset_selected=Restablecer bioma +button.orespawn.biome.confirm_reset=Confirmar restablecimiento +button.orespawn.biome.reset_palette=Restablecer paleta +button.orespawn.biome.confirm_reset_palette=Confirmar paleta +button.orespawn.biome.reset_dimension=Restablecer dimensión +button.orespawn.biome.confirm_reset_dimension=Confirmar dimensión +button.orespawn.biome.reset_all=Restablecer todos los biomas +button.orespawn.biome.confirm_reset_all=Confirmar todo +warning.orespawn.biome.target_not_declared=%s no está declarado por un proveedor para esta dimensión. +value.orespawn.biome.status.user_replaced=Reemplazado en terreno nuevo +value.orespawn.biome.status.layered=Varias reglas de colocación +value.orespawn.biome.status.disabled=Colocación desactivada +value.orespawn.biome.status.missing=Falta el bioma o proveedor +value.orespawn.biome.status.provider_managed=Gestionado por proveedor +value.orespawn.biome.status.unmanaged=Registrado, no gestionado +label.orespawn.biome.rule_count=Reglas de colocación: %s +label.orespawn.biome.replaced_by=Reemplazo en terreno nuevo: %s +label.orespawn.biome.palette_owner_order=Propietario: %s; orden efectivo: %s +label.orespawn.biome.surface_effective=Esta regla aporta la superficie efectiva. +label.orespawn.biome.surface_overridden=Una regla activa posterior reemplaza esta superficie. +label.orespawn.biome.new_terrain_only=Solo terreno nuevo +label.orespawn.biome.placement_rules=Reglas de colocación +label.orespawn.biome.palette_entries=Entradas de colocación: %s +tooltip.orespawn.biome.no_retrogen=Los reemplazos de bioma solo se aplican al generar terreno nuevo. Los chunks existentes nunca se reescriben. +tooltip.orespawn.biome.override_palette=Capa final reservada para reemplazos. Edítala desde el control de reemplazo de cada bioma. +tooltip.orespawn.biome.palette_entries_read_only=La pertenencia a la paleta la define el proveedor o perfil. Selecciona un bioma para editar una regla existente. +option.orespawn.biome.coverage=Cobertura +option.orespawn.biome.fallback_weight=Peso del bioma original +option.orespawn.biome.include_namespaces=Espacios de nombres incluidos (separados por comas) +option.orespawn.biome.exclude_namespaces=Espacios de nombres excluidos (separados por comas) diff --git a/src/main/resources/assets/orespawn/lang/fr_CA.lang b/src/main/resources/assets/orespawn/lang/fr_CA.lang index 66fb6bd8..f3ae4c90 100644 --- a/src/main/resources/assets/orespawn/lang/fr_CA.lang +++ b/src/main/resources/assets/orespawn/lang/fr_CA.lang @@ -302,7 +302,7 @@ screen.orespawn.choose_biome=Choisir un biome installé screen.orespawn.biome_placement=Paramètres du biome screen.orespawn.biome_references=Biomes similaires screen.orespawn.dimension_materials=Matériaux du monde -button.orespawn.biomes_world_materials=Biomes et matériaux du monde... +button.orespawn.biomes_world_materials=Biomes... button.orespawn.biome_palette_count=Biomes : %s button.orespawn.dimension_materials=Matériaux du monde... button.orespawn.geome_influences=Influences des géomes... @@ -470,3 +470,41 @@ tooltip.orespawn.ore_source.group_colours=Les entrées vertes ont un alias et un button.orespawn.ore_source.reset_all=Tout réinitialiser button.orespawn.ore_source.confirm_reset_all=Confirmer la réinitialisation tooltip.orespawn.ore_source.reset_all=Restaure les groupes intégrés et recrée les règles depuis les minerais chargés. Le changement reste en attente jusqu'au bouton Terminé de l'éditeur principal. +screen.orespawn.biomes=Biomes +screen.orespawn.biome_palettes=Palettes de biomes +screen.orespawn.biome_palette_settings=Paramètres de palette de biomes +button.orespawn.biome.hide_routine=Masquer les ordinaires +button.orespawn.biome.replace_new_terrain=Remplacer dans le nouveau terrain par... +button.orespawn.biome.replace_with=Remplacer dans le nouveau terrain par %s... +button.orespawn.biome.leave_original=Conserver le comportement d'origine +button.orespawn.biome.palettes=Palettes : %s +button.orespawn.biome.reset_selected=Réinitialiser le biome +button.orespawn.biome.confirm_reset=Confirmer la réinitialisation +button.orespawn.biome.reset_palette=Réinitialiser la palette +button.orespawn.biome.confirm_reset_palette=Confirmer la palette +button.orespawn.biome.reset_dimension=Réinitialiser la dimension +button.orespawn.biome.confirm_reset_dimension=Confirmer la dimension +button.orespawn.biome.reset_all=Réinitialiser tous les biomes +button.orespawn.biome.confirm_reset_all=Tout confirmer +warning.orespawn.biome.target_not_declared=%s n'est déclaré par aucun fournisseur pour cette dimension. +value.orespawn.biome.status.user_replaced=Remplacé dans le nouveau terrain +value.orespawn.biome.status.layered=Plusieurs règles de placement +value.orespawn.biome.status.disabled=Placement désactivé +value.orespawn.biome.status.missing=Biome ou fournisseur manquant +value.orespawn.biome.status.provider_managed=Géré par le fournisseur +value.orespawn.biome.status.unmanaged=Enregistré, non géré +label.orespawn.biome.rule_count=Règles de placement : %s +label.orespawn.biome.replaced_by=Remplacement du nouveau terrain : %s +label.orespawn.biome.palette_owner_order=Propriétaire : %s ; ordre effectif : %s +label.orespawn.biome.surface_effective=Cette règle fournit la surface effective. +label.orespawn.biome.surface_overridden=Une règle active ultérieure remplace cette surface. +label.orespawn.biome.new_terrain_only=Nouveau terrain uniquement +label.orespawn.biome.placement_rules=Règles de placement +label.orespawn.biome.palette_entries=Entrées de placement : %s +tooltip.orespawn.biome.no_retrogen=Les remplacements de biome ne s'appliquent qu'à la génération de nouveau terrain. Les chunks existants ne sont jamais réécrits. +tooltip.orespawn.biome.override_palette=Couche finale réservée aux remplacements. Modifiez-la avec le contrôle de remplacement de chaque biome. +tooltip.orespawn.biome.palette_entries_read_only=L'appartenance à la palette est définie par le fournisseur ou le profil. Sélectionnez un biome pour modifier une règle existante. +option.orespawn.biome.coverage=Couverture +option.orespawn.biome.fallback_weight=Poids du biome d'origine +option.orespawn.biome.include_namespaces=Espaces de noms inclus (séparés par des virgules) +option.orespawn.biome.exclude_namespaces=Espaces de noms exclus (séparés par des virgules) diff --git a/src/main/resources/assets/orespawn/lang/fr_FR.lang b/src/main/resources/assets/orespawn/lang/fr_FR.lang index 66fb6bd8..f3ae4c90 100644 --- a/src/main/resources/assets/orespawn/lang/fr_FR.lang +++ b/src/main/resources/assets/orespawn/lang/fr_FR.lang @@ -302,7 +302,7 @@ screen.orespawn.choose_biome=Choisir un biome installé screen.orespawn.biome_placement=Paramètres du biome screen.orespawn.biome_references=Biomes similaires screen.orespawn.dimension_materials=Matériaux du monde -button.orespawn.biomes_world_materials=Biomes et matériaux du monde... +button.orespawn.biomes_world_materials=Biomes... button.orespawn.biome_palette_count=Biomes : %s button.orespawn.dimension_materials=Matériaux du monde... button.orespawn.geome_influences=Influences des géomes... @@ -470,3 +470,41 @@ tooltip.orespawn.ore_source.group_colours=Les entrées vertes ont un alias et un button.orespawn.ore_source.reset_all=Tout réinitialiser button.orespawn.ore_source.confirm_reset_all=Confirmer la réinitialisation tooltip.orespawn.ore_source.reset_all=Restaure les groupes intégrés et recrée les règles depuis les minerais chargés. Le changement reste en attente jusqu'au bouton Terminé de l'éditeur principal. +screen.orespawn.biomes=Biomes +screen.orespawn.biome_palettes=Palettes de biomes +screen.orespawn.biome_palette_settings=Paramètres de palette de biomes +button.orespawn.biome.hide_routine=Masquer les ordinaires +button.orespawn.biome.replace_new_terrain=Remplacer dans le nouveau terrain par... +button.orespawn.biome.replace_with=Remplacer dans le nouveau terrain par %s... +button.orespawn.biome.leave_original=Conserver le comportement d'origine +button.orespawn.biome.palettes=Palettes : %s +button.orespawn.biome.reset_selected=Réinitialiser le biome +button.orespawn.biome.confirm_reset=Confirmer la réinitialisation +button.orespawn.biome.reset_palette=Réinitialiser la palette +button.orespawn.biome.confirm_reset_palette=Confirmer la palette +button.orespawn.biome.reset_dimension=Réinitialiser la dimension +button.orespawn.biome.confirm_reset_dimension=Confirmer la dimension +button.orespawn.biome.reset_all=Réinitialiser tous les biomes +button.orespawn.biome.confirm_reset_all=Tout confirmer +warning.orespawn.biome.target_not_declared=%s n'est déclaré par aucun fournisseur pour cette dimension. +value.orespawn.biome.status.user_replaced=Remplacé dans le nouveau terrain +value.orespawn.biome.status.layered=Plusieurs règles de placement +value.orespawn.biome.status.disabled=Placement désactivé +value.orespawn.biome.status.missing=Biome ou fournisseur manquant +value.orespawn.biome.status.provider_managed=Géré par le fournisseur +value.orespawn.biome.status.unmanaged=Enregistré, non géré +label.orespawn.biome.rule_count=Règles de placement : %s +label.orespawn.biome.replaced_by=Remplacement du nouveau terrain : %s +label.orespawn.biome.palette_owner_order=Propriétaire : %s ; ordre effectif : %s +label.orespawn.biome.surface_effective=Cette règle fournit la surface effective. +label.orespawn.biome.surface_overridden=Une règle active ultérieure remplace cette surface. +label.orespawn.biome.new_terrain_only=Nouveau terrain uniquement +label.orespawn.biome.placement_rules=Règles de placement +label.orespawn.biome.palette_entries=Entrées de placement : %s +tooltip.orespawn.biome.no_retrogen=Les remplacements de biome ne s'appliquent qu'à la génération de nouveau terrain. Les chunks existants ne sont jamais réécrits. +tooltip.orespawn.biome.override_palette=Couche finale réservée aux remplacements. Modifiez-la avec le contrôle de remplacement de chaque biome. +tooltip.orespawn.biome.palette_entries_read_only=L'appartenance à la palette est définie par le fournisseur ou le profil. Sélectionnez un biome pour modifier une règle existante. +option.orespawn.biome.coverage=Couverture +option.orespawn.biome.fallback_weight=Poids du biome d'origine +option.orespawn.biome.include_namespaces=Espaces de noms inclus (séparés par des virgules) +option.orespawn.biome.exclude_namespaces=Espaces de noms exclus (séparés par des virgules) diff --git a/src/main/resources/assets/orespawn/lang/ja_JP.lang b/src/main/resources/assets/orespawn/lang/ja_JP.lang index 490a603f..62ffa98f 100644 --- a/src/main/resources/assets/orespawn/lang/ja_JP.lang +++ b/src/main/resources/assets/orespawn/lang/ja_JP.lang @@ -302,7 +302,7 @@ screen.orespawn.choose_biome=導入済みバイオームを選択 screen.orespawn.biome_placement=バイオーム設定 screen.orespawn.biome_references=類似バイオーム screen.orespawn.dimension_materials=ワールド素材 -button.orespawn.biomes_world_materials=バイオームとワールド素材... +button.orespawn.biomes_world_materials=バイオーム... button.orespawn.biome_palette_count=バイオーム: %s button.orespawn.dimension_materials=ワールド素材... button.orespawn.geome_influences=ジオームの影響... @@ -470,3 +470,41 @@ tooltip.orespawn.ore_source.group_colours=緑は別名と出力が各1つで操 button.orespawn.ore_source.reset_all=すべてリセット button.orespawn.ore_source.confirm_reset_all=リセットを確認 tooltip.orespawn.ore_source.reset_all=組み込みの素材グループを復元し、現在読み込まれている鉱石からソースポリシーを再作成します。変更はメイン画面で完了を押すまで保留されます。 +screen.orespawn.biomes=バイオーム +screen.orespawn.biome_palettes=バイオームパレット +screen.orespawn.biome_palette_settings=バイオームパレット設定 +button.orespawn.biome.hide_routine=通常項目を隠す +button.orespawn.biome.replace_new_terrain=新しい地形で置き換える... +button.orespawn.biome.replace_with=新しい地形で %s に置き換える... +button.orespawn.biome.leave_original=元の動作を維持 +button.orespawn.biome.palettes=パレット: %s +button.orespawn.biome.reset_selected=バイオームをリセット +button.orespawn.biome.confirm_reset=リセットを確認 +button.orespawn.biome.reset_palette=パレットをリセット +button.orespawn.biome.confirm_reset_palette=パレットのリセットを確認 +button.orespawn.biome.reset_dimension=ディメンションをリセット +button.orespawn.biome.confirm_reset_dimension=ディメンションのリセットを確認 +button.orespawn.biome.reset_all=全バイオームをリセット +button.orespawn.biome.confirm_reset_all=全リセットを確認 +warning.orespawn.biome.target_not_declared=%s はこのディメンションのプロバイダーで宣言されていません。 +value.orespawn.biome.status.user_replaced=新しい地形で置換 +value.orespawn.biome.status.layered=複数の配置ルール +value.orespawn.biome.status.disabled=配置は無効 +value.orespawn.biome.status.missing=バイオームまたはプロバイダーが不足 +value.orespawn.biome.status.provider_managed=プロバイダー管理 +value.orespawn.biome.status.unmanaged=登録済み、未管理 +label.orespawn.biome.rule_count=配置ルール: %s +label.orespawn.biome.replaced_by=新しい地形での置換: %s +label.orespawn.biome.palette_owner_order=所有者: %s、適用順: %s +label.orespawn.biome.surface_effective=このルールが有効な表面を提供します。 +label.orespawn.biome.surface_overridden=後の有効なルールがこの表面を上書きします。 +label.orespawn.biome.new_terrain_only=新しい地形のみ +label.orespawn.biome.placement_rules=配置ルール +label.orespawn.biome.palette_entries=配置項目: %s +tooltip.orespawn.biome.no_retrogen=バイオーム置換は新しい地形の生成時のみ適用されます。既存チャンクは書き換えません。 +tooltip.orespawn.biome.override_palette=予約済みの最終置換レイヤーです。各バイオームの置換操作から編集します。 +tooltip.orespawn.biome.palette_entries_read_only=パレットの所属はプロバイダーまたはプロファイルで定義されます。既存ルールを編集するにはバイオームを選択してください。 +option.orespawn.biome.coverage=適用範囲 +option.orespawn.biome.fallback_weight=元のバイオームの重み +option.orespawn.biome.include_namespaces=含める名前空間(カンマ区切り) +option.orespawn.biome.exclude_namespaces=除外する名前空間(カンマ区切り) diff --git a/src/main/resources/assets/orespawn/lang/ko_KR.lang b/src/main/resources/assets/orespawn/lang/ko_KR.lang index 9859e618..43f6e859 100644 --- a/src/main/resources/assets/orespawn/lang/ko_KR.lang +++ b/src/main/resources/assets/orespawn/lang/ko_KR.lang @@ -302,7 +302,7 @@ screen.orespawn.choose_biome=설치된 생물군계 선택 screen.orespawn.biome_placement=생물군계 설정 screen.orespawn.biome_references=유사 생물군계 screen.orespawn.dimension_materials=월드 재료 -button.orespawn.biomes_world_materials=생물군계 및 월드 재료... +button.orespawn.biomes_world_materials=생물군계... button.orespawn.biome_palette_count=생물군계: %s button.orespawn.dimension_materials=월드 재료... button.orespawn.geome_influences=지옴 영향... @@ -470,3 +470,41 @@ tooltip.orespawn.ore_source.group_colours=녹색 항목은 별칭과 출력이 button.orespawn.ore_source.reset_all=모두 초기화 button.orespawn.ore_source.confirm_reset_all=초기화 확인 tooltip.orespawn.ore_source.reset_all=기본 재료 그룹을 복원하고 현재 불러온 광석에서 소스 정책을 다시 만듭니다. 변경 사항은 기본 편집기에서 완료를 누를 때까지 보류됩니다. +screen.orespawn.biomes=생물군계 +screen.orespawn.biome_palettes=생물군계 팔레트 +screen.orespawn.biome_palette_settings=생물군계 팔레트 설정 +button.orespawn.biome.hide_routine=일반 항목 숨기기 +button.orespawn.biome.replace_new_terrain=새 지형에서 다음으로 교체... +button.orespawn.biome.replace_with=새 지형에서 %s(으)로 교체... +button.orespawn.biome.leave_original=원래 동작 유지 +button.orespawn.biome.palettes=팔레트: %s +button.orespawn.biome.reset_selected=생물군계 초기화 +button.orespawn.biome.confirm_reset=초기화 확인 +button.orespawn.biome.reset_palette=팔레트 초기화 +button.orespawn.biome.confirm_reset_palette=팔레트 초기화 확인 +button.orespawn.biome.reset_dimension=차원 초기화 +button.orespawn.biome.confirm_reset_dimension=차원 초기화 확인 +button.orespawn.biome.reset_all=모든 생물군계 초기화 +button.orespawn.biome.confirm_reset_all=전체 초기화 확인 +warning.orespawn.biome.target_not_declared=%s은(는) 이 차원의 제공자가 선언하지 않았습니다. +value.orespawn.biome.status.user_replaced=새 지형에서 교체됨 +value.orespawn.biome.status.layered=여러 배치 규칙 +value.orespawn.biome.status.disabled=배치 비활성화 +value.orespawn.biome.status.missing=생물군계 또는 제공자 누락 +value.orespawn.biome.status.provider_managed=제공자 관리 +value.orespawn.biome.status.unmanaged=등록됨, 관리되지 않음 +label.orespawn.biome.rule_count=배치 규칙: %s +label.orespawn.biome.replaced_by=새 지형 교체: %s +label.orespawn.biome.palette_owner_order=소유자: %s; 적용 순서: %s +label.orespawn.biome.surface_effective=이 규칙이 적용되는 표면을 제공합니다. +label.orespawn.biome.surface_overridden=뒤의 활성 규칙이 이 표면을 덮어씁니다. +label.orespawn.biome.new_terrain_only=새 지형에만 적용 +label.orespawn.biome.placement_rules=배치 규칙 +label.orespawn.biome.palette_entries=배치 항목: %s +tooltip.orespawn.biome.no_retrogen=생물군계 교체는 새 지형 생성 시에만 적용됩니다. 기존 청크는 다시 쓰지 않습니다. +tooltip.orespawn.biome.override_palette=예약된 최종 교체 레이어입니다. 각 생물군계의 교체 컨트롤에서 편집하세요. +tooltip.orespawn.biome.palette_entries_read_only=팔레트 구성은 제공자 또는 프로필에서 정의합니다. 기존 규칙을 편집하려면 생물군계를 선택하세요. +option.orespawn.biome.coverage=적용 범위 +option.orespawn.biome.fallback_weight=원래 생물군계 가중치 +option.orespawn.biome.include_namespaces=포함할 네임스페이스(쉼표로 구분) +option.orespawn.biome.exclude_namespaces=제외할 네임스페이스(쉼표로 구분) diff --git a/src/main/resources/assets/orespawn/lang/pt_BR.lang b/src/main/resources/assets/orespawn/lang/pt_BR.lang index 5eb6d436..850b2993 100644 --- a/src/main/resources/assets/orespawn/lang/pt_BR.lang +++ b/src/main/resources/assets/orespawn/lang/pt_BR.lang @@ -302,7 +302,7 @@ screen.orespawn.choose_biome=Escolher bioma instalado screen.orespawn.biome_placement=Configurações do bioma screen.orespawn.biome_references=Biomas semelhantes screen.orespawn.dimension_materials=Materiais do mundo -button.orespawn.biomes_world_materials=Biomas e materiais do mundo... +button.orespawn.biomes_world_materials=Biomas... button.orespawn.biome_palette_count=Biomas: %s button.orespawn.dimension_materials=Materiais do mundo... button.orespawn.geome_influences=Influências de geomas... @@ -470,3 +470,41 @@ tooltip.orespawn.ore_source.group_colours=Entradas verdes têm um alias e uma sa button.orespawn.ore_source.reset_all=Redefinir tudo button.orespawn.ore_source.confirm_reset_all=Confirmar redefinição tooltip.orespawn.ore_source.reset_all=Restaura os grupos de materiais internos e recria as políticas a partir dos minérios carregados. A mudança fica pendente até usar Concluído no editor principal. +screen.orespawn.biomes=Biomas +screen.orespawn.biome_palettes=Paletas de biomas +screen.orespawn.biome_palette_settings=Configurações da paleta de biomas +button.orespawn.biome.hide_routine=Ocultar comuns +button.orespawn.biome.replace_new_terrain=Substituir em terreno novo por... +button.orespawn.biome.replace_with=Substituir em terreno novo por %s... +button.orespawn.biome.leave_original=Manter o comportamento original +button.orespawn.biome.palettes=Paletas: %s +button.orespawn.biome.reset_selected=Redefinir bioma +button.orespawn.biome.confirm_reset=Confirmar redefinição +button.orespawn.biome.reset_palette=Redefinir paleta +button.orespawn.biome.confirm_reset_palette=Confirmar paleta +button.orespawn.biome.reset_dimension=Redefinir dimensão +button.orespawn.biome.confirm_reset_dimension=Confirmar dimensão +button.orespawn.biome.reset_all=Redefinir todos os biomas +button.orespawn.biome.confirm_reset_all=Confirmar tudo +warning.orespawn.biome.target_not_declared=%s não foi declarado por um provedor para esta dimensão. +value.orespawn.biome.status.user_replaced=Substituído em terreno novo +value.orespawn.biome.status.layered=Várias regras de colocação +value.orespawn.biome.status.disabled=Colocação desativada +value.orespawn.biome.status.missing=Bioma ou provedor ausente +value.orespawn.biome.status.provider_managed=Gerenciado pelo provedor +value.orespawn.biome.status.unmanaged=Registrado, não gerenciado +label.orespawn.biome.rule_count=Regras de colocação: %s +label.orespawn.biome.replaced_by=Substituição em terreno novo: %s +label.orespawn.biome.palette_owner_order=Proprietário: %s; ordem efetiva: %s +label.orespawn.biome.surface_effective=Esta regra fornece a superfície efetiva. +label.orespawn.biome.surface_overridden=Uma regra ativa posterior substitui esta superfície. +label.orespawn.biome.new_terrain_only=Apenas terreno novo +label.orespawn.biome.placement_rules=Regras de colocação +label.orespawn.biome.palette_entries=Entradas de colocação: %s +tooltip.orespawn.biome.no_retrogen=As substituições de bioma só se aplicam ao gerar terreno novo. Chunks existentes nunca são reescritos. +tooltip.orespawn.biome.override_palette=Camada final reservada de substituição. Edite-a pelo controle de substituição de cada bioma. +tooltip.orespawn.biome.palette_entries_read_only=A associação à paleta é definida pelo provedor ou perfil. Selecione um bioma para editar uma regra existente. +option.orespawn.biome.coverage=Cobertura +option.orespawn.biome.fallback_weight=Peso do bioma original +option.orespawn.biome.include_namespaces=Namespaces incluídos (separados por vírgulas) +option.orespawn.biome.exclude_namespaces=Namespaces excluídos (separados por vírgulas) diff --git a/src/main/resources/assets/orespawn/lang/ru_RU.lang b/src/main/resources/assets/orespawn/lang/ru_RU.lang index 3b12bf02..526014b4 100644 --- a/src/main/resources/assets/orespawn/lang/ru_RU.lang +++ b/src/main/resources/assets/orespawn/lang/ru_RU.lang @@ -302,7 +302,7 @@ screen.orespawn.choose_biome=Выбрать установленный биом screen.orespawn.biome_placement=Настройки биома screen.orespawn.biome_references=Похожие биомы screen.orespawn.dimension_materials=Материалы мира -button.orespawn.biomes_world_materials=Биомы и материалы мира... +button.orespawn.biomes_world_materials=Биомы... button.orespawn.biome_palette_count=Биомы: %s button.orespawn.dimension_materials=Материалы мира... button.orespawn.geome_influences=Влияние геомов... @@ -470,3 +470,41 @@ tooltip.orespawn.ore_source.group_colours=Зелёные записи имеют button.orespawn.ore_source.reset_all=Сбросить всё button.orespawn.ore_source.confirm_reset_all=Подтвердить сброс tooltip.orespawn.ore_source.reset_all=Восстанавливает встроенные группы материалов и заново создаёт правила по загруженным рудам. Изменение остаётся ожидающим до нажатия Готово в главном редакторе. +screen.orespawn.biomes=Биомы +screen.orespawn.biome_palettes=Палитры биомов +screen.orespawn.biome_palette_settings=Настройки палитры биомов +button.orespawn.biome.hide_routine=Скрыть обычные +button.orespawn.biome.replace_new_terrain=Заменять в новой местности на... +button.orespawn.biome.replace_with=Заменять в новой местности на %s... +button.orespawn.biome.leave_original=Оставить исходное поведение +button.orespawn.biome.palettes=Палитры: %s +button.orespawn.biome.reset_selected=Сбросить биом +button.orespawn.biome.confirm_reset=Подтвердить сброс +button.orespawn.biome.reset_palette=Сбросить палитру +button.orespawn.biome.confirm_reset_palette=Подтвердить палитру +button.orespawn.biome.reset_dimension=Сбросить измерение +button.orespawn.biome.confirm_reset_dimension=Подтвердить измерение +button.orespawn.biome.reset_all=Сбросить все биомы +button.orespawn.biome.confirm_reset_all=Подтвердить всё +warning.orespawn.biome.target_not_declared=%s не объявлен поставщиком для этого измерения. +value.orespawn.biome.status.user_replaced=Заменён в новой местности +value.orespawn.biome.status.layered=Несколько правил размещения +value.orespawn.biome.status.disabled=Размещение отключено +value.orespawn.biome.status.missing=Биом или поставщик отсутствует +value.orespawn.biome.status.provider_managed=Управляется поставщиком +value.orespawn.biome.status.unmanaged=Зарегистрирован, не управляется +label.orespawn.biome.rule_count=Правила размещения: %s +label.orespawn.biome.replaced_by=Замена в новой местности: %s +label.orespawn.biome.palette_owner_order=Владелец: %s; итоговый порядок: %s +label.orespawn.biome.surface_effective=Эта правило задаёт итоговую поверхность. +label.orespawn.biome.surface_overridden=Более позднее активное правило заменяет эту поверхность. +label.orespawn.biome.new_terrain_only=Только новая местность +label.orespawn.biome.placement_rules=Правила размещения +label.orespawn.biome.palette_entries=Записи размещения: %s +tooltip.orespawn.biome.no_retrogen=Замены биомов применяются только при создании новой местности. Существующие чанки не переписываются. +tooltip.orespawn.biome.override_palette=Зарезервированный финальный слой замен. Изменяйте его через управление заменой каждого биома. +tooltip.orespawn.biome.palette_entries_read_only=Состав палитры задаётся поставщиком или профилем. Выберите биом для изменения существующего правила. +option.orespawn.biome.coverage=Покрытие +option.orespawn.biome.fallback_weight=Вес исходного биома +option.orespawn.biome.include_namespaces=Включённые пространства имён (через запятую) +option.orespawn.biome.exclude_namespaces=Исключённые пространства имён (через запятую) diff --git a/src/main/resources/assets/orespawn/lang/zh_CN.lang b/src/main/resources/assets/orespawn/lang/zh_CN.lang index f8292e05..7e927b9b 100644 --- a/src/main/resources/assets/orespawn/lang/zh_CN.lang +++ b/src/main/resources/assets/orespawn/lang/zh_CN.lang @@ -302,7 +302,7 @@ screen.orespawn.choose_biome=选择已安装的生物群系 screen.orespawn.biome_placement=生物群系设置 screen.orespawn.biome_references=相似生物群系 screen.orespawn.dimension_materials=世界材料 -button.orespawn.biomes_world_materials=生物群系和世界材料... +button.orespawn.biomes_world_materials=生物群系... button.orespawn.biome_palette_count=生物群系: %s button.orespawn.dimension_materials=世界材料... button.orespawn.geome_influences=地质域影响... @@ -470,3 +470,41 @@ tooltip.orespawn.ore_source.group_colours=绿色条目只有一个别名和一 button.orespawn.ore_source.reset_all=全部重置 button.orespawn.ore_source.confirm_reset_all=确认重置 tooltip.orespawn.ore_source.reset_all=恢复内置材料分组,并根据当前加载的矿石重新创建来源策略。更改会保持待定,直到在主编辑器中点击完成。 +screen.orespawn.biomes=生物群系 +screen.orespawn.biome_palettes=生物群系调色板 +screen.orespawn.biome_palette_settings=生物群系调色板设置 +button.orespawn.biome.hide_routine=隐藏常规项 +button.orespawn.biome.replace_new_terrain=在新地形中替换为... +button.orespawn.biome.replace_with=在新地形中替换为 %s... +button.orespawn.biome.leave_original=保留原始行为 +button.orespawn.biome.palettes=调色板:%s +button.orespawn.biome.reset_selected=重置生物群系 +button.orespawn.biome.confirm_reset=确认重置 +button.orespawn.biome.reset_palette=重置调色板 +button.orespawn.biome.confirm_reset_palette=确认调色板重置 +button.orespawn.biome.reset_dimension=重置维度 +button.orespawn.biome.confirm_reset_dimension=确认维度重置 +button.orespawn.biome.reset_all=重置全部生物群系 +button.orespawn.biome.confirm_reset_all=确认全部重置 +warning.orespawn.biome.target_not_declared=%s 未由此维度的提供者声明。 +value.orespawn.biome.status.user_replaced=已在新地形中替换 +value.orespawn.biome.status.layered=多个放置规则 +value.orespawn.biome.status.disabled=放置已禁用 +value.orespawn.biome.status.missing=缺少生物群系或提供者 +value.orespawn.biome.status.provider_managed=由提供者管理 +value.orespawn.biome.status.unmanaged=已注册,未管理 +label.orespawn.biome.rule_count=放置规则:%s +label.orespawn.biome.replaced_by=新地形替换:%s +label.orespawn.biome.palette_owner_order=所有者:%s;生效顺序:%s +label.orespawn.biome.surface_effective=此规则提供最终生效的表面。 +label.orespawn.biome.surface_overridden=后续启用的规则会覆盖此表面。 +label.orespawn.biome.new_terrain_only=仅限新地形 +label.orespawn.biome.placement_rules=放置规则 +label.orespawn.biome.palette_entries=放置项:%s +tooltip.orespawn.biome.no_retrogen=生物群系替换仅在生成新地形时应用。现有区块绝不会被重写。 +tooltip.orespawn.biome.override_palette=保留的最终替换层。请通过各生物群系的替换控件进行编辑。 +tooltip.orespawn.biome.palette_entries_read_only=调色板成员由提供者或配置定义。请在目录中选择生物群系以编辑现有规则。 +option.orespawn.biome.coverage=覆盖率 +option.orespawn.biome.fallback_weight=原始生物群系权重 +option.orespawn.biome.include_namespaces=包含的命名空间(逗号分隔) +option.orespawn.biome.exclude_namespaces=排除的命名空间(逗号分隔) diff --git a/src/test/java/zone/moddev/mc/orespawn/LocalizationParityTest.java b/src/test/java/zone/moddev/mc/orespawn/LocalizationParityTest.java index 2f1c1129..1a85f3ea 100644 --- a/src/test/java/zone/moddev/mc/orespawn/LocalizationParityTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/LocalizationParityTest.java @@ -44,6 +44,9 @@ class LocalizationParityTest { "de_au.lang", "de_de.lang", "es_es.lang", "es_mx.lang", "fr_ca.lang", "fr_fr.lang", "ja_jp.lang", "ko_kr.lang", "pt_br.lang", "ru_ru.lang", "zh_cn.lang")) + .put("button.orespawn.biomes_world_materials", ImmutableSet.of( + "fr_ca.lang", "fr_fr.lang")) + .put("screen.orespawn.biomes", ImmutableSet.of("fr_ca.lang", "fr_fr.lang")) .put("option.orespawn.mod_filter", ImmutableSet.of( "de_au.lang", "de_de.lang", "fr_ca.lang", "fr_fr.lang")) .put("tab.orespawn.biomes", ImmutableSet.of("fr_ca.lang", "fr_fr.lang")) @@ -84,7 +87,7 @@ class LocalizationParityTest { void everyLocaleMatchesEnglishKeysAndFormatting() throws Exception { JsonObject english = read(LANG_DIR.resolve("en_US.lang")); Set englishKeys = JsonCopies.keys(english); - assertEquals(472, englishKeys.size(), + assertEquals(510, 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/client/OreSpawnScreenLayoutTest.java b/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java index ece8f875..c17b9e39 100644 --- a/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java @@ -27,7 +27,7 @@ void everyConcreteScreenClearsThePreviousFrameBeforeDrawingWidgets() throws Exce .sorted() .collect(Collectors.toList()); } - assertEquals(27, screens.size(), "Review this render-order gate when screens are added or removed"); + assertEquals(28, 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( @@ -194,6 +194,44 @@ void materialGroupDefaultsPersistOnlyThroughMainDone() throws Exception { "Done and Escape return to the pending ORES editor session"); } + @Test + void biomeDirectoryUsesTwoPanesAt426AndResponsivePagesAt320() throws Exception { + assertEquals(406, BiomeWorldMaterialsScreen.contentWidth(426)); + assertEquals(174, BiomeWorldMaterialsScreen.leftPaneWidth(426)); + assertEquals(181, BiomeWorldMaterialsScreen.listHeight(265)); + assertEquals(300, BiomeWorldMaterialsScreen.contentWidth(320)); + assertEquals(132, BiomeWorldMaterialsScreen.leftPaneWidth(320)); + assertEquals(156, BiomeWorldMaterialsScreen.listHeight(240)); + String source = screenSource("BiomeWorldMaterialsScreen.java"); + assertTrue(source.contains("TWO_PANE_MINIMUM = 400")); + assertTrue(source.contains("new CompactScrollList(this, left, CONTENT_TOP")); + assertTrue(source.contains("if (compact) compactDetail = true")); + assertTrue(source.contains("button.orespawn.show_all")); + assertTrue(source.contains("button.orespawn.biome.hide_routine")); + assertTrue(source.contains("button.orespawn.biome.replace_new_terrain")); + assertTrue(source.contains("button.orespawn.biome.leave_original")); + assertTrue(source.contains("tooltip.orespawn.biome.no_retrogen")); + assertTrue(source.contains("new BiomePlacementScreen")); + assertTrue(source.contains("new BiomePaletteScreen")); + assertTrue(source.contains("new DimensionMaterialsScreen")); + assertTrue(source.contains("new GeomeBiomeScreen")); + } + + @Test + void paletteSettingsExposeEveryExistingPaletteControl() throws Exception { + String settings = screenSource("BiomePaletteSettingsScreen.java"); + for (String field : new String[] { "enabled", "mode", "scope", "region_size", + "coverage", "fallback_weight", "include_namespaces", "exclude_namespaces" }) { + assertTrue(settings.contains("\"" + field + "\""), field); + } + String palettes = screenSource("BiomePaletteScreen.java"); + assertTrue(palettes.contains("session.biomeDirectory().palettes(dimension)")); + assertTrue(palettes.contains("resetBiomePalette")); + assertTrue(palettes.contains("resetBiomeDimension")); + assertTrue(palettes.contains("resetAllBiomeManagement")); + assertFalse(palettes.contains("button.orespawn.add_biome")); + } + private static String screenSource(String name) throws Exception { Path screen = Paths.get("src", "main", "java", "zone", "moddev", "mc", "orespawn", "client", name); From 03eb7fc11a89e088f19d9b0befeeb70ddd16c1ee Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Tue, 22 Sep 2026 12:17:26 +0100 Subject: [PATCH 25/31] Validate biome override persistence and generation --- build.gradle | 3 + .../client/BiomeEditorProbeBridge.java | 43 +++++ .../orespawn/testmod/SurfaceProbeTestMod.java | 90 +++++++++++ .../orespawn/client/GeologyEditorSession.java | 7 +- .../worldgen/BiomeWorldgenManager.java | 2 +- .../client/GeologyEditorSessionTest.java | 130 +++++++++++++++ .../worldgen/BiomePaletteOrderingTest.java | 148 ++++++++++++++++++ 7 files changed, 421 insertions(+), 2 deletions(-) create mode 100644 src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/client/BiomeEditorProbeBridge.java diff --git a/build.gradle b/build.gradle index 28b9d797..7b5c8e12 100644 --- a/build.gradle +++ b/build.gradle @@ -399,6 +399,9 @@ task surfaceIntegrationTest(dependsOn: surfaceIntegrationReloadProcess) { if (result.getProperty('ore_sources_reset_verified') != 'true') { throw new GradleException("Ore-source editor/profile/generation integration was not verified: ${marker}") } + if (result.getProperty('biome_override_verified') != 'true') { + throw new GradleException("Biome editor/profile/new-terrain 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}") diff --git a/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/client/BiomeEditorProbeBridge.java b/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/client/BiomeEditorProbeBridge.java new file mode 100644 index 00000000..53eb9fd8 --- /dev/null +++ b/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/client/BiomeEditorProbeBridge.java @@ -0,0 +1,43 @@ +package zone.moddev.mc.orespawn.client; + +import com.google.gson.JsonObject; + +import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfile; + +/** Test-fixture bridge that persists an exact replacement through the real editor model. */ +public final class BiomeEditorProbeBridge { + private BiomeEditorProbeBridge() { } + + public static JsonObject replace(JsonObject root, String dimension, + String source, String target, String unmanaged) { + WorldGeologyProfile profile = WorldGeologyProfile.fromJson(root, + WorldGeologyProfile.recommended(false)); + GeologyEditorSession session = new GeologyEditorSession(profile); + BiomeDirectoryModel.Snapshot before = session.biomeDirectory(); + long providerPalettes = before.palettes(dimension).stream() + .filter(palette -> palette.providerOwned).count(); + BiomeDirectoryModel.BiomeEntry external = before.entries(dimension, true).stream() + .filter(entry -> unmanaged.equals(entry.id)).findFirst() + .orElseThrow(() -> new IllegalStateException("Missing unmanaged fixture biome " + unmanaged)); + if (providerPalettes != 2 || external.status != BiomeDirectoryModel.Status.UNMANAGED) { + throw new IllegalStateException("Biome directory did not expose two provider palettes " + + "and one unmanaged biome"); + } + session.replaceBiome(dimension, source, target); + JsonObject saved = session.profile().rootCopy(); + String actual = new GeologyEditorSession(WorldGeologyProfile.fromJson(saved, profile)) + .biomeReplacements(dimension).get(source); + if (!target.equals(actual)) { + throw new IllegalStateException("Biome editor replacement did not round-trip: " + + source + " -> " + actual); + } + BiomeDirectoryModel.BiomeEntry replaced = new GeologyEditorSession( + WorldGeologyProfile.fromJson(saved, profile)).biomeDirectory() + .entries(dimension, true).stream().filter(entry -> source.equals(entry.id)) + .findFirst().orElseThrow(() -> new IllegalStateException("Missing replaced biome " + source)); + if (replaced.status != BiomeDirectoryModel.Status.USER_REPLACED) { + throw new IllegalStateException("Biome directory did not classify the replacement"); + } + return saved; + } +} 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 2e32bf6f..1e3a70bf 100644 --- a/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java +++ b/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java @@ -49,6 +49,7 @@ 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.client.BiomeEditorProbeBridge; import zone.moddev.mc.orespawn.worldgen.SurfaceProbeSpringBridge; import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfileManager; @@ -100,6 +101,7 @@ public final class SurfaceProbeTestMod { private static final ResourceLocation OVERWORLD = new ResourceLocation("minecraft", "overworld"); private static final ResourceLocation BIOME_A = new ResourceLocation(MODID, "surface_a"); private static final ResourceLocation BIOME_B = new ResourceLocation(MODID, "surface_b"); + private static final ResourceLocation BIOME_UNMANAGED = new ResourceLocation(MODID, "unmanaged"); 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"); @@ -108,6 +110,8 @@ public final class SurfaceProbeTestMod { 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 int OVERRIDE_MIN_CHUNK = 68; + private static final int OVERRIDE_MAX_CHUNK = 70; private static final ProbeLiquid DEPOSIT_FLUID = new ProbeLiquid(); private static final Map SOURCE_BLOCKS = sourceBlocks(); private static final Map SOURCE_PATTERNS = sourcePatterns(); @@ -242,6 +246,8 @@ private static boolean insideSquare(int chunkX, int chunkZ, int minimum, int max "surface_a", properties -> configure(properties, 1.35F, 0.15F)); private final BiomeReference surfaceB = OreSpawnBiomes.blankAndRegister(registrar, "surface_b", properties -> configure(properties, 0.7F, 0.8F)); + private final BiomeReference unmanaged = OreSpawnBiomes.blankAndRegister(registrar, + "unmanaged", properties -> configure(properties, 0.8F, 0.4F)); private final Set preparedTerrain = new LinkedHashSet<>(); public SurfaceProbeTestMod() { @@ -261,6 +267,7 @@ public void registerBiomes(RegistryEvent.Register event) { surfaceB.get().theBiomeDecorator = new ProbeDecorator(); BiomeDictionary.registerBiomeType(surfaceA.get(), BiomeDictionary.Type.HOT, BiomeDictionary.Type.DRY); BiomeDictionary.registerBiomeType(surfaceB.get(), BiomeDictionary.Type.HOT, BiomeDictionary.Type.WET); + BiomeDictionary.registerBiomeType(unmanaged.get(), BiomeDictionary.Type.PLAINS); } @SubscribeEvent @@ -329,6 +336,7 @@ public void init(FMLInitializationEvent event) { // 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); + addNoOpPalette(provider, "end_palette_2", END); addPalette(provider, "nether_palette_1", NETHER, true); provider.dimensionMaterials(new ResourceLocation(MODID, "materials/end"), END, materials -> materials.snowBlock(id(WEATHER_SNOW_REPLACEMENT)) @@ -443,6 +451,17 @@ private static void addPalette(WorldgenProvider.Builder provider, String name, .temperature(-2.0D, 2.0D).downfall(0.0D, 1.0D).surface(b))); } + private static void addNoOpPalette(WorldgenProvider.Builder provider, String name, + ResourceLocation dimension) { + provider.biomePalette(new ResourceLocation(MODID, name), dimension, + palette -> palette.mode(BiomePlacementMode.AUGMENT) + .scope(BiomeReplacementScope.SELECTED_NAMESPACES) + .includeNamespace("unmatchedfixture") + .regionSize(BiomeRegionSize.TINY).coverage(1.0D).fallbackWeight(1.0D) + .biome(BIOME_A, biome -> biome.weight(1.0D)) + .biome(BIOME_B, biome -> biome.weight(1.0D))); + } + private static BiomeSurfaceDefinition surface(Block top, Block filler, Block underwater, Block ceiling) { BiomeSurfaceDefinition.Builder builder = BiomeSurfaceDefinition.builder() @@ -492,6 +511,7 @@ public void serverStarted(FMLServerStartedEvent event) { } results.put("end", audit(requireWorld(server, 1), false)); results.put("nether", audit(requireWorld(server, -1), true)); + BiomeCount override = verifyBiomeOverride(server, phase, results.get("end")); ResourceLocation spring = auditSpring(overworld, phase); int dynamicFluidPlacements = DEPOSIT_FLUID.placements(); if ("fresh".equals(phase) && dynamicFluidPlacements <= 0) { @@ -508,6 +528,10 @@ public void serverStarted(FMLServerStartedEvent event) { current.setProperty("dynamic_fluid_placements", "fresh".equals(phase) ? Integer.toString(dynamicFluidPlacements) : previous.getProperty("dynamic_fluid_placements")); + current.setProperty("biome_override.biome_a", Integer.toString(override.biomeA)); + current.setProperty("biome_override.biome_b", Integer.toString(override.biomeB)); + current.setProperty("biome_override_verified", Boolean.toString( + override.biomeA == 0 && override.biomeB == override.total)); if (previous == null) { write(marker, current); } else { @@ -532,6 +556,60 @@ public void serverStarted(FMLServerStartedEvent event) { server.initiateShutdown(); } + private static BiomeCount verifyBiomeOverride(MinecraftServer server, String phase, + Audit original) { + WorldServer end = requireWorld(server, 1); + if ("fresh".equals(phase)) { + Path profile = worldRoot(server).resolve("serverconfig").resolve("orespawn-worldgen.json"); + JsonObject edited = BiomeEditorProbeBridge.replace( + WorldGeologyProfileManager.activeProfile().rootCopy(), END.toString(), + BIOME_A.toString(), BIOME_B.toString(), BIOME_UNMANAGED.toString()); + try (BufferedWriter writer = Files.newBufferedWriter(profile)) { + new GsonBuilder().setPrettyPrinting().create().toJson(edited, writer); + } catch (IOException exception) { + throw new IllegalStateException("Could not persist biome replacement", exception); + } + if (!WorldGeologyProfileManager.reloadActiveProfile()) { + throw new IllegalStateException("Could not reload biome replacement"); + } + BiomeCount unchanged = countBiomes(end, MIN_CHUNK, MAX_CHUNK, false); + if (unchanged.biomeA != original.biomeA || unchanged.biomeB != original.biomeB) { + throw new IllegalStateException("Biome replacement rewrote existing chunks: " + unchanged); + } + generateSquare(end, OVERRIDE_MIN_CHUNK, OVERRIDE_MAX_CHUNK, false); + } + BiomeCount generated = countBiomes(end, OVERRIDE_MIN_CHUNK, OVERRIDE_MAX_CHUNK, true); + if (generated.biomeA != 0 || generated.biomeB != generated.total) { + throw new IllegalStateException("Terminal biome override did not control new terrain: " + + generated); + } + return generated; + } + + private static BiomeCount countBiomes(WorldServer world, int minimum, int maximum, + boolean requireGenerated) { + int a = 0, b = 0, total = 0; + 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); + if (requireGenerated && !chunk.isLoaded()) { + throw new IllegalStateException("Biome override chunk was not generated at " + + chunkX + "," + chunkZ); + } + for (int z = 0; z < 16; z++) for (int x = 0; x < 16; x++) { + ResourceLocation id = world.getBiome(cursor.setPos( + (chunkX << 4) + x, 64, (chunkZ << 4) + z)).getRegistryName(); + if (BIOME_A.equals(id)) a++; + else if (BIOME_B.equals(id)) b++; + else throw new IllegalStateException("Unexpected biome in override audit: " + id); + total++; + } + } + } + return new BiomeCount(a, b, total); + } + 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"); @@ -1219,6 +1297,18 @@ void write(Properties properties, String prefix) { } } + private static final class BiomeCount { + final int biomeA, biomeB, total; + BiomeCount(int biomeA, int biomeB, int total) { + this.biomeA = biomeA; + this.biomeB = biomeB; + this.total = total; + } + @Override public String toString() { + return "BiomeCount{a=" + biomeA + ", b=" + biomeB + ", total=" + total + "}"; + } + } + private static final class Audit { final long dry, wet, filler, geology, ceiling, roof; final int biomeA, biomeB, edges, sentinels; 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 71ae6c1b..d9a553b0 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java @@ -72,8 +72,13 @@ enum MaterialTab { } GeologyEditorSession(WorldGeologyProfile profile, Iterable dimensions) { + this(profile, dimensions, WorldgenIntegrationManager.biomeProviderDefaults()); + } + + GeologyEditorSession(WorldGeologyProfile profile, Iterable dimensions, + BiomeProviderDefaultsSnapshot defaults) { originalProfile = profile; - biomeProviderDefaults = WorldgenIntegrationManager.biomeProviderDefaults(); + biomeProviderDefaults = defaults; original = profile.rootCopy(); root = profile.rootCopy(); normalizeRegistrySections(original); diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeWorldgenManager.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeWorldgenManager.java index 7aed4f43..df21ac6a 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeWorldgenManager.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeWorldgenManager.java @@ -115,7 +115,7 @@ private static BakedBiomeWorldgen bake(WorldServer level, JsonObject root) { return new BakedBiomeWorldgen(palettes, surfaces, materials); } - private static List bakePalettes(JsonObject root, + static List bakePalettes(JsonObject root, ResourceLocation dimension, Map surfaces) { JsonObject section = object(root, "biome_palettes"); List result = new ArrayList<>(); diff --git a/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java b/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java index 28d120bc..82dc3586 100644 --- a/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java @@ -15,6 +15,7 @@ import com.google.gson.JsonObject; import com.google.gson.JsonPrimitive; import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfile; +import zone.moddev.mc.orespawn.integration.WorldgenIntegrationManager.BiomeProviderDefaultsSnapshot; import net.minecraft.init.Blocks; import net.minecraft.item.ItemStack; @@ -603,6 +604,135 @@ void missingExternalDuplicateCannotOverrideItsLoadedManagedOutput() { assertEquals(before, profile.rootCopy(), "Deriving the visible status must not rewrite the profile"); } + @Test + void dimensionResetRestoresActiveProvidersRemovesLocalRulesAndPreservesMissingProviders() + throws Exception { + JsonObject defaults = new JsonObject(); + JsonObject defaultPalettes = new JsonObject(); + defaultPalettes.add("active:palette", biomePalette("active", "minecraft:plains", 1.0D)); + JsonObject defaultMaterials = new JsonObject(); + defaultMaterials.add("active:materials", dimensionMaterials("active", "minecraft:water")); + BiomeProviderDefaultsSnapshot snapshot = providerDefaults(defaultPalettes, defaultMaterials, + java.util.Collections.singleton("active")); + + JsonObject root = WorldGeologyProfile.recommended(false).rootCopy(); + JsonObject palettes = new JsonObject(); + palettes.add("active:palette", biomePalette("active", "minecraft:plains", 9.0D)); + palettes.add("user:palette", biomePalette("", "minecraft:desert", 1.0D)); + palettes.add("missing:palette", biomePalette("missing", "minecraft:forest", 3.0D)); + root.add("biome_palettes", palettes); + JsonObject materials = new JsonObject(); + materials.add("active:materials", dimensionMaterials("active", "minecraft:lava")); + materials.add("user:materials", dimensionMaterials("", "minecraft:lava")); + materials.add("missing:materials", dimensionMaterials("missing", "minecraft:lava")); + root.add("dimension_materials", materials); + BiomeReplacementRules.set(root, "minecraft:overworld", "minecraft:plains", "minecraft:desert"); + WorldGeologyProfile original = WorldGeologyProfile.fromJson(root, + WorldGeologyProfile.recommended(false)); + JsonObject originalBytes = original.rootCopy(); + GeologyEditorSession session = new GeologyEditorSession(original, + java.util.Collections.singleton("minecraft:overworld"), snapshot); + + session.resetBiomeDimension("minecraft:overworld"); + + assertEquals(defaultPalettes.get("active:palette"), + session.section("biome_palettes").get("active:palette")); + assertFalse(session.section("biome_palettes").has("user:palette")); + assertTrue(session.section("biome_palettes").has("missing:palette")); + assertTrue(session.biomeReplacements("minecraft:overworld").isEmpty()); + assertEquals(defaultMaterials.get("active:materials"), + session.section("dimension_materials").get("active:materials")); + assertFalse(session.section("dimension_materials").has("user:materials")); + assertTrue(session.section("dimension_materials").has("missing:materials")); + assertEquals(originalBytes, original.rootCopy(), + "reset must stay pending until the main editor saves"); + } + + @Test + void selectedBiomeResetRestoresOnlyItsProviderPlacementsAndClearsItsOverride() + throws Exception { + JsonObject defaultPalettes = new JsonObject(); + defaultPalettes.add("active:palette", biomePalette("active", "minecraft:plains", 1.0D)); + BiomeProviderDefaultsSnapshot snapshot = providerDefaults(defaultPalettes, + new JsonObject(), java.util.Collections.singleton("active")); + JsonObject root = WorldGeologyProfile.recommended(false).rootCopy(); + JsonObject palettes = new JsonObject(); + palettes.add("active:palette", biomePalette("active", "minecraft:plains", 8.0D)); + JsonObject local = biomePalette("", "minecraft:plains", 4.0D); + local.getAsJsonObject("biomes").add("minecraft:desert", + biomePlacement(2.0D)); + palettes.add("user:palette", local); + root.add("biome_palettes", palettes); + BiomeReplacementRules.set(root, "minecraft:overworld", "minecraft:plains", "minecraft:desert"); + GeologyEditorSession session = new GeologyEditorSession( + WorldGeologyProfile.fromJson(root, WorldGeologyProfile.recommended(false)), + java.util.Collections.singleton("minecraft:overworld"), snapshot); + + session.resetBiome("minecraft:overworld", "minecraft:plains"); + + assertEquals(1.0D, session.biomePlacementByPalette("active:palette", "minecraft:plains") + .get("weight").getAsDouble()); + assertFalse(session.biomePaletteById("user:palette").getAsJsonObject("biomes") + .has("minecraft:plains")); + assertTrue(session.biomePaletteById("user:palette").getAsJsonObject("biomes") + .has("minecraft:desert")); + assertFalse(session.biomeReplacements("minecraft:overworld").containsKey("minecraft:plains")); + } + + @Test + void replacementRoundTripIsExactAndDoesNotMutateTheUnsavedProfile() { + WorldGeologyProfile original = WorldGeologyProfile.recommended(false); + JsonObject before = original.rootCopy(); + GeologyEditorSession session = new GeologyEditorSession(original); + + session.replaceBiome("minecraft:overworld", "minecraft:plains", "minecraft:desert"); + WorldGeologyProfile saved = session.profile(); + WorldGeologyProfile reopened = WorldGeologyProfile.fromJson(saved.toJson(), + WorldGeologyProfile.recommended(false)); + + assertEquals("minecraft:desert", new GeologyEditorSession(reopened) + .biomeReplacements("minecraft:overworld").get("minecraft:plains")); + assertEquals(saved.toJson(), reopened.toJson()); + assertEquals(before, original.rootCopy(), "Cancel must retain the original bytes"); + } + + private static BiomeProviderDefaultsSnapshot providerDefaults(JsonObject palettes, + JsonObject materials, java.util.Set active) throws Exception { + java.lang.reflect.Constructor constructor = + BiomeProviderDefaultsSnapshot.class.getDeclaredConstructor( + JsonObject.class, JsonObject.class, java.util.Set.class); + constructor.setAccessible(true); + return constructor.newInstance(palettes, materials, active); + } + + private static JsonObject biomePalette(String owner, String biome, double weight) { + JsonObject palette = new JsonObject(); + palette.addProperty("dimension", "minecraft:overworld"); + palette.addProperty("enabled", true); + if (!owner.isEmpty()) palette.addProperty("source_provider", owner); + JsonObject biomes = new JsonObject(); + biomes.add(biome, biomePlacement(weight)); + palette.add("biomes", biomes); + return palette; + } + + private static JsonObject biomePlacement(double weight) { + JsonObject placement = new JsonObject(); + placement.addProperty("enabled", true); + placement.addProperty("weight", weight); + placement.add("similar_biomes", new JsonArray()); + placement.add("required_similar_biomes", new JsonArray()); + return placement; + } + + private static JsonObject dimensionMaterials(String owner, String fluid) { + JsonObject materials = new JsonObject(); + materials.addProperty("dimension", "minecraft:overworld"); + if (!owner.isEmpty()) materials.addProperty("source_provider", owner); + materials.addProperty("default_fluid", fluid); + return materials; + } + private static WorldGeologyProfile profileWithOreSourcePolicy() { JsonObject root = WorldGeologyProfile.recommended(false).toJson(); JsonObject policy = new JsonObject(); diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/BiomePaletteOrderingTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/BiomePaletteOrderingTest.java index def983e6..a007a1b4 100644 --- a/src/test/java/zone/moddev/mc/orespawn/worldgen/BiomePaletteOrderingTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/BiomePaletteOrderingTest.java @@ -1,17 +1,37 @@ package zone.moddev.mc.orespawn.worldgen; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.Arrays; +import java.util.Collections; +import java.util.IdentityHashMap; import java.util.List; import java.util.Map.Entry; import java.util.stream.Collectors; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; +import net.minecraft.util.ResourceLocation; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.biome.Biome; +import net.minecraft.world.biome.BiomeProvider; +import net.minecraftforge.fml.common.registry.ForgeRegistries; +import zone.moddev.mc.orespawn.test.Forge12TestBootstrap; +import zone.moddev.mc.orespawn.worldgen.BakedBiomeWorldgen.Palette; + class BiomePaletteOrderingTest { + @BeforeAll + static void bootstrapMinecraft() { + Forge12TestBootstrap.registerVanilla(); + } + @Test void ordinaryOrderIsStableAndUiOverridesAlwaysRunLast() { JsonObject section = new JsonObject(); @@ -23,4 +43,132 @@ void ordinaryOrderIsStableAndUiOverridesAlwaysRunLast() { assertEquals(java.util.Arrays.asList("provider:first", "provider:second", "orespawn:ui/biome_overrides/minecraft_overworld"), ids); } + + @Test + void terminalOverrideRunsAfterOrdinaryPalettesEvenWhenStoredFirst() { + JsonObject root = root(); + JsonObject palettes = root.getAsJsonObject("biome_palettes"); + palettes.add("orespawn:ui/biome_overrides/minecraft_overworld", + palette("minecraft:forest", "minecraft:desert")); + palettes.add("provider:ordinary", palette("minecraft:desert", "minecraft:plains")); + + List baked = BiomeWorldgenManager.bakePalettes(root, + new ResourceLocation("minecraft:overworld"), new IdentityHashMap<>()); + Biome plains = biome("minecraft:plains"); + Biome forest = biome("minecraft:forest"); + BiomeOverlaySource overlay = new BiomeOverlaySource( + new ConstantBiomeProvider(plains), baked, 78431L); + + assertEquals(2, baked.size()); + assertSame(forest, overlay.getBiome(new BlockPos(0, 64, 0))); + assertSame(forest, overlay.getBiome(new BlockPos(-257, 64, 389))); + } + + @Test + void missingTargetLeavesSourceUnchangedAndReactivatesWhenTargetReturns() { + JsonObject missingRoot = root(); + missingRoot.getAsJsonObject("biome_palettes").add( + "orespawn:ui/biome_overrides/minecraft_overworld", + palette("missing:orchard", "minecraft:plains")); + List missing = BiomeWorldgenManager.bakePalettes(missingRoot, + new ResourceLocation("minecraft:overworld"), new IdentityHashMap<>()); + Biome plains = biome("minecraft:plains"); + + assertTrue(missing.isEmpty()); + assertTrue(missingRoot.toString().contains("missing:orchard"), + "dormant target must remain persisted"); + JsonObject restoredRoot = root(); + restoredRoot.getAsJsonObject("biome_palettes").add( + "orespawn:ui/biome_overrides/minecraft_overworld", + palette("minecraft:desert", "minecraft:plains")); + List restored = BiomeWorldgenManager.bakePalettes(restoredRoot, + new ResourceLocation("minecraft:overworld"), new IdentityHashMap<>()); + assertSame(biome("minecraft:desert"), new BiomeOverlaySource( + new ConstantBiomeProvider(plains), restored, 11L) + .getBiome(new BlockPos(64, 70, 64))); + } + + @Test + void absentOrDisabledOverrideProducesIdenticalBiomeCoordinates() { + JsonObject untouched = root(); + untouched.getAsJsonObject("biome_palettes").add("provider:first", + palette("minecraft:desert", "minecraft:plains")); + JsonObject disabled = new com.google.gson.JsonParser().parse(untouched.toString()) + .getAsJsonObject(); + JsonObject disabledOverride = palette("minecraft:forest", "minecraft:desert"); + disabledOverride.addProperty("enabled", false); + disabled.getAsJsonObject("biome_palettes").add( + "orespawn:ui/biome_overrides/minecraft_overworld", disabledOverride); + Biome plains = biome("minecraft:plains"); + BiomeOverlaySource baseline = overlay(untouched, plains, 918273L); + BiomeOverlaySource candidate = overlay(disabled, plains, 918273L); + for (int z = -96; z <= 96; z += 3) { + for (int x = -96; x <= 96; x += 3) { + assertSame(baseline.getBiome(new BlockPos(x, 63, z)), + candidate.getBiome(new BlockPos(x, 63, z)), x + "," + z); + } + } + } + + private static BiomeOverlaySource overlay(JsonObject root, Biome source, long seed) { + return new BiomeOverlaySource(new ConstantBiomeProvider(source), + BiomeWorldgenManager.bakePalettes(root, + new ResourceLocation("minecraft:overworld"), new IdentityHashMap<>()), seed); + } + + private static JsonObject root() { + JsonObject root = new JsonObject(); + root.add("biome_palettes", new JsonObject()); + return root; + } + + private static JsonObject palette(String target, String source) { + JsonObject palette = new JsonObject(); + palette.addProperty("dimension", "minecraft:overworld"); + palette.addProperty("enabled", true); + palette.addProperty("mode", "replace"); + palette.addProperty("scope", "all"); + palette.addProperty("region_size", "tiny"); + palette.addProperty("coverage", 1.0D); + palette.addProperty("fallback_weight", 0.0D); + palette.add("include_namespaces", new JsonArray()); + palette.add("exclude_namespaces", new JsonArray()); + JsonObject placement = new JsonObject(); + placement.addProperty("enabled", true); + placement.addProperty("weight", 1.0D); + JsonArray sources = new JsonArray(); + sources.add(new com.google.gson.JsonPrimitive(source)); + placement.add("similar_biomes", sources); + placement.add("required_similar_biomes", new JsonArray()); + JsonObject biomes = new JsonObject(); + biomes.add(target, placement); + palette.add("biomes", biomes); + return palette; + } + + private static Biome biome(String id) { + Biome biome = ForgeRegistries.BIOMES.getValue(new ResourceLocation(id)); + if (biome == null) throw new AssertionError("Missing test biome " + id); + return biome; + } + + private static final class ConstantBiomeProvider extends BiomeProvider { + private final Biome biome; + + ConstantBiomeProvider(Biome biome) { this.biome = biome; } + + @Override public Biome getBiome(BlockPos pos, Biome fallback) { return biome; } + @Override public Biome[] getBiomesForGeneration(Biome[] reuse, int x, int z, + int width, int length) { return filled(reuse, width, length); } + @Override public Biome[] getBiomes(Biome[] reuse, int x, int z, + int width, int length, boolean cacheFlag) { return filled(reuse, width, length); } + @Override public List getBiomesToSpawnIn() { return Collections.singletonList(biome); } + + private Biome[] filled(Biome[] reuse, int width, int length) { + Biome[] result = reuse != null && reuse.length >= width * length + ? reuse : new Biome[width * length]; + Arrays.fill(result, 0, width * length, biome); + return result; + } + } } From 52030df29ca4f6bfdb1a8cde86d0779a9ad4da5c Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Tue, 22 Sep 2026 12:19:29 +0100 Subject: [PATCH 26/31] Document OreSpawn 4.1 biome management --- CHANGELOG.txt | 14 +++++++++++++ README.md | 17 +++++++++++++-- docs/API.md | 7 +++++++ docs/BIOMES.md | 48 +++++++++++++++++++++++++++++++++++++------ docs/CONFIGURATION.md | 25 ++++++++++++++++++++++ docs/PLAYER_GUIDE.md | 24 ++++++++++++++-------- 6 files changed, 118 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 5d190a73..c2ff0643 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,5 +1,19 @@ Version 4.1.0.110021 +* Replace the first-palette-only biome editor with a responsive biome directory + that reports every loaded, referenced and missing biome once, together with + palette ownership, effective order, placement count and surface conflicts. +* Add exact one-to-one biome replacements for newly generated terrain through a + reserved terminal palette which always runs after ordinary provider/profile + palettes. Replacement chains flatten, cycles are rejected and missing targets + remain dormant until their owning mod returns. +* Expose palette enablement, placement weights, climate and dependency limits, + surfaces, namespace scope, region size, coverage, fallback weight, dimension + materials and geome influences without adding arbitrary weighted biome output. +* Add confirmed biome, palette, dimension and all-biome resets. Active providers + return to their shipped defaults, user-only palettes are removed and entries + owned by missing providers are preserved; all changes retain Done/Cancel + editor-session semantics and never rewrite existing chunks. * Add Ore Sources policies that group equivalent configured outputs by material and dimension while keeping ordinary and custom placement channels independent. * Consolidate only reviewed high-confidence MMD conflicts for new worlds; diff --git a/README.md b/README.md index 450c39ff..4bc105cd 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,8 @@ This branch builds target-qualified version `4.1.0.110021`: the OreSpawn 4.1.0 feature set for Minecraft 1.10.2 and Forge. It adds stable generation identity, non-loading geology sampling, per-resource background control, existing-world provider merge policy, an Ore Sources arbiter for equivalent provider ores and -a client world-settings extension point for add-ons such as Realistic Deposits +a biome directory with exact new-terrain replacement overrides, plus a client +world-settings extension point for add-ons such as Realistic Deposits while retaining API major 1. See the [versioning policy](docs/VERSIONS.md) for the encoding and release convention. @@ -90,6 +91,16 @@ Native vanilla outputs become controllable placement sources only while option changes, and Group Settings prevents moving a vanilla ore alias while management is off, with a prompt to enable it first. +**Biomes** opens a directory rather than editing only the first palette. It +shows every provider/profile palette in its stored order, the effective surface +rule, owner, climate constraints, dimension materials and geome influences. +Routine unmanaged biomes are hidden behind **Show All**. A loaded biome may be +left unchanged or replaced exactly in newly generated terrain; missing targets +remain dormant and resume if their mod returns. The final user replacement +layer always runs after ordinary palettes, while existing chunks are never +rewritten. Provider, palette, dimension and all-biome resets remain pending +until the main editor's **Done** action; **Cancel** discards them. + Important files: | Location | Purpose | @@ -169,7 +180,9 @@ underwater, immediate filler, and ceiling-underside behavior. It also proves later vegetation, structures, and block entities survive, validates provider-rock vanilla springs and an external ore-pattern registration, then reopens and checks the exact saved world. The fixture is -not included in OreSpawn's published jars. +not included in OreSpawn's published jars. It also exercises multiple palettes +in one dimension, an unmanaged loaded biome, exact terminal replacement in new +chunks, unchanged old chunks, and fresh/reload profile and surface parity. Import or refresh this ForgeGradle 7 project through Eclipse Buildship, then run `genEclipseRuns`; do not run the removed ForgeGradle 2 diff --git a/docs/API.md b/docs/API.md index accc84f0..c45a3a0e 100644 --- a/docs/API.md +++ b/docs/API.md @@ -56,6 +56,13 @@ The builder emits provider schema 5. Legacy provider schemas 1-4 remain readable. Schema 4 is required for biome palettes and dimension materials; schema 5 adds ore material identity and independent placement channels. +OreSpawn 4.1's biome directory and exact replacement layer add no public Java +API methods or descriptors. Providers continue to declare ordinary schema-4 +biome palettes and dimension materials through the existing builders below. +The editor's provider-default snapshot and terminal override palette are +implementation details; add-ons must not depend on their client classes or +reserved `orespawn:ui/biome_overrides/` IDs. + Provider-owned fluid deposits are declarative and may target several dimensions: ```java diff --git a/docs/BIOMES.md b/docs/BIOMES.md index 796a80ae..1c1226b6 100644 --- a/docs/BIOMES.md +++ b/docs/BIOMES.md @@ -175,12 +175,48 @@ Automatic selection occurs only for fresh worlds when no explicit global If several providers request automatic selection, the highest priority wins, then lexical template ID order. -## World-Creation Editor - -**Biomes & World Materials** is visible even when rock strata are disabled. -It lists palettes and materials by dimension, uses installed-registry pickers, -and validates IDs before world creation. The editor is creation-only in 4.0.0; -existing worlds remain editable through their self-contained server profile. +## Biome Directory And Exact Overrides + +**Biomes** is visible even when rock strata are disabled. At normal window +sizes it keeps a compact biome list and the selected biome's details together; +at the minimum supported width it uses list and detail pages without losing the +selection or pending edits. The default list contains provider-managed, +modified, disabled and missing entries. **Show All** also displays routine +registered biomes which OreSpawn does not otherwise manage. + +The detail pane reports the friendly name, registry ID, mod owner, status and +effective placement-rule count. Every profile palette is shown in its stored +sequential order instead of only the first palette for the dimension. Placement +details expose enabled state, weight, source-biome and required-biome limits, +climate range, top/filler/underwater/ceiling blocks, filler depth, owner and +effective order. When several enabled palettes define a surface for one biome, +the directory identifies the effective last rule without silently reordering +the profile. + +**Leave original behaviour** makes no exact replacement. **Replace in new +terrain with...** accepts any loaded source and target biome, including an +external biome, but warns when the target was not declared by a provider for +that dimension. OreSpawn stores these choices in the reserved +`orespawn:ui/biome_overrides/` palette. It is a 100% `replace/all` +palette with zero fallback and always bakes after every ordinary palette, +regardless of JSON insertion order. Each source has one terminal target; +chains are flattened, inbound mappings follow a subsequently replaced target, +and cycles or self-replacements are rejected. A missing target remains in the +profile but leaves its source unchanged until the target mod returns. + +Exact replacements affect only chunks generated after the edit. They do not +unregister a biome, suppress another mod's decorators, alter spawn lists or +rewrite existing chunks. Arbitrary weighted injection of loaded biomes is not +offered; only provider-declared palette outputs retain weighted placement. + +The palette-settings page exposes mode, scope, region size, coverage, fallback +weight and namespace include/exclude lists. Dimension materials and geome +influences remain reachable from the directory. Reset Biome clears its exact +replacement and restores active provider placements for that biome; palette, +dimension and all-biome resets restore loaded-provider defaults and remove +user/profile-only palettes while preserving entries owned by missing providers. +All edits and resets remain pending until the main editor's **Done** action; +**Cancel** discards them byte-for-byte. ## Performance Boundaries diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 5dd6bbf7..f44a7bc5 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -161,6 +161,31 @@ portability, but the editor keeps those controls disabled and generation does not use a distinct deep fluid. See `BIOMES.md` for complete examples and practical guidance. +### Biome directory overrides + +The editor reads all `biome_palettes` entries for a dimension in their stored +order. It does not rewrite or publicly prioritize ordinary palettes. Exact +one-to-one user replacements are encoded as +`orespawn:ui/biome_overrides/` using the existing palette schema: +`mode: replace`, `scope: all`, `coverage: 1.0`, `fallback_weight: 0.0`, with +each target's exact sources in `similar_biomes`. OreSpawn always bakes this +reserved palette after the ordinary entries regardless of JSON order. + +Only loaded biomes can be selected in the GUI, although an already saved rule +whose target becomes unavailable is retained. That dormant rule leaves the +source unchanged and automatically becomes active when the target is loaded +again. Replacement chains are stored as terminal mappings; self-replacements +and cycles are invalid. These rules control new terrain only and do not disable +external biome generators, decorators, structures, mobs or registry entries. + +Biome reset operations use an internal immutable snapshot of currently loaded +provider defaults. A biome reset restores that biome's active provider entries; +a palette reset restores the matching active provider palette; dimension/all +resets remove user/profile-only biome palettes and restore active provider +palettes and materials. Definitions owned by providers which are currently +missing remain untouched. The pending editor copy is written to global defaults +or the world profile only when the main editor's **Done** action succeeds. + ## Ore Fields An ore has `enabled`, one output `block` or weighted `outputs`, and at least one diff --git a/docs/PLAYER_GUIDE.md b/docs/PLAYER_GUIDE.md index 2f92b111..dd3d6d4d 100644 --- a/docs/PLAYER_GUIDE.md +++ b/docs/PLAYER_GUIDE.md @@ -28,10 +28,10 @@ adds them. You can remove the starter rocks or add blocks from installed mods before creating the world. Mods can also offer new biomes and world materials without enabling strata. -Use **Biomes & World Materials** to inspect installed dimension palettes, -surface blocks, aquifer fluids, snow, and ice. The picker only accepts real -installed registry entries. Missing optional compatibility biomes are skipped -safely instead of breaking world creation. +Use **Biomes** to inspect every installed or referenced biome, every palette in +its effective order, surface blocks, aquifer fluids, snow and ice. The picker +only accepts real installed registry entries. Saved missing targets remain +dormant instead of breaking world creation. ## What The Main Controls Mean @@ -91,11 +91,17 @@ safely instead of breaking world creation. installed mod. These are covered underground deposits, not exposed vanilla lakes. **Solid Cover** controls the roof thickness, while **Solid Shell** prevents a deposit from opening into a cave at its sides or underside. -- **Biomes & World Materials** controls broad biome regions and what their - surfaces, underground water, snow, and ice are made from. **Augment** mixes - new biomes into the existing source; **Replace** creates a complete provider - style. Namespace scope protects other biome mods unless a pack deliberately - opts them in. +- **Biomes** opens a compact directory. Managed, changed and missing entries + appear first; **Show All** includes ordinary registered biomes. Select a + biome to inspect every placement rule, owner, order, climate limit and + effective surface. **Leave original behaviour** keeps it unchanged, while + **Replace in new terrain with...** creates an exact terminal replacement. + This affects only newly generated chunks and never unregisters the source or + disables another mod's decoration. Use **Palettes** for mode, scope, region + size, coverage, fallback and namespace controls. World Materials and Geome + Influences remain available from the same directory. Confirmed biome, + palette, dimension and all-biome resets stay pending until the main editor's + **Done** action; **Cancel** discards them. **World Materials** applies across an entire dimension. **Aquifer Fluid** changes the normal below-sea-level fluid. Minecraft 1.10.2 exposes only that From 58bc15716d08ec01967eb769ba1a84d0e1096ffa Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Wed, 23 Sep 2026 14:45:58 +0100 Subject: [PATCH 27/31] Clarify and optimize dimension materials --- CHANGELOG.txt | 9 +- README.md | 11 +- build.gradle | 10 +- docs/BIOMES.md | 36 ++- docs/CONFIGURATION.md | 18 +- docs/PLAYER_GUIDE.md | 25 +- .../orespawn/testmod/SurfaceProbeTestMod.java | 3 +- .../orespawn/client/BiomeDirectoryModel.java | 46 +++- .../client/BiomeWorldMaterialsScreen.java | 111 ++++++--- .../client/DimensionMaterialsScreen.java | 11 +- .../worldgen/AquiferMaterialSubstitution.java | 226 ++++++++++++++++++ .../worldgen/BiomeWorldgenManager.java | 25 +- .../worldgen/OreSpawnWorldGenerator.java | 8 + .../worldgen/VanillaSpringCompatibility.java | 8 +- .../orespawn/worldgen/WorldgenBenchmark.java | 125 +++++++++- .../resources/assets/orespawn/lang/de_AU.lang | 10 +- .../resources/assets/orespawn/lang/de_DE.lang | 10 +- .../resources/assets/orespawn/lang/en_CA.lang | 10 +- .../resources/assets/orespawn/lang/en_EN.lang | 10 +- .../resources/assets/orespawn/lang/en_GB.lang | 10 +- .../resources/assets/orespawn/lang/en_PT.lang | 10 +- .../resources/assets/orespawn/lang/en_US.lang | 12 +- .../resources/assets/orespawn/lang/es_ES.lang | 10 +- .../resources/assets/orespawn/lang/es_MX.lang | 10 +- .../resources/assets/orespawn/lang/fr_CA.lang | 10 +- .../resources/assets/orespawn/lang/fr_FR.lang | 10 +- .../resources/assets/orespawn/lang/ja_JP.lang | 10 +- .../resources/assets/orespawn/lang/ko_KR.lang | 10 +- .../resources/assets/orespawn/lang/pt_BR.lang | 10 +- .../resources/assets/orespawn/lang/ru_RU.lang | 10 +- .../resources/assets/orespawn/lang/zh_CN.lang | 10 +- .../client/BiomeDirectoryModelTest.java | 50 ++++ .../client/OreSpawnScreenLayoutTest.java | 52 +++- .../AquiferMaterialSubstitutionTest.java | 162 +++++++++++++ .../VanillaSpringCompatibilityTest.java | 9 + .../worldgen/WorldgenBenchmarkTest.java | 15 ++ 36 files changed, 947 insertions(+), 175 deletions(-) create mode 100644 src/main/java/zone/moddev/mc/orespawn/worldgen/AquiferMaterialSubstitution.java create mode 100644 src/test/java/zone/moddev/mc/orespawn/worldgen/AquiferMaterialSubstitutionTest.java diff --git a/CHANGELOG.txt b/CHANGELOG.txt index c2ff0643..adeac8d3 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,12 +1,19 @@ Version 4.1.0.110021 +* Clarify dimension-wide material controls and replace light-equivalent custom + aquifer-fluid injection with an exact one-shot generation mask, preserving + surfaces, caves and later decorator fluids while keeping generation near the + native path. Differently lit fluids retain the compatible generator path. * Replace the first-palette-only biome editor with a responsive biome directory that reports every loaded, referenced and missing biome once, together with palette ownership, effective order, placement count and surface conflicts. + Its dimension selector filters routine entries through Forge biome domains + while retaining explicit placement, replacement and missing-provider rules. * Add exact one-to-one biome replacements for newly generated terrain through a reserved terminal palette which always runs after ordinary provider/profile palettes. Replacement chains flatten, cycles are rejected and missing targets - remain dormant until their owning mod returns. + remain dormant until their owning mod returns. Valid unmanaged targets no + longer produce a misleading warning or duplicate new-terrain notice. * Expose palette enablement, placement weights, climate and dependency limits, surfaces, namespace scope, region size, coverage, fallback weight, dimension materials and geome influences without adding arbitrary weighted biome output. diff --git a/README.md b/README.md index 4bc105cd..f8ee7f3d 100644 --- a/README.md +++ b/README.md @@ -93,8 +93,15 @@ management is off, with a prompt to enable it first. **Biomes** opens a directory rather than editing only the first palette. It shows every provider/profile palette in its stored order, the effective surface -rule, owner, climate constraints, dimension materials and geome influences. -Routine unmanaged biomes are hidden behind **Show All**. A loaded biome may be +rule, owner, climate constraints and geome influences. Dimension materials sit +beside the dimension selector because they apply to every biome in that +dimension's newly generated terrain, not to the selected biome. Light-equivalent +aquifer fluids use a provenance mask and fast pre-decoration substitution; +unusual fluids with different lighting retain a compatible, potentially slower +generator path. +Routine unmanaged biomes are hidden behind **Show All**, which remains filtered +to the selected dimension using Forge's Nether/End biome types and explicit +provider placement declarations. A loaded biome may be left unchanged or replaced exactly in newly generated terrain; missing targets remain dormant and resume if their mod returns. The final user replacement layer always runs after ordinary palettes, while existing chunks are never diff --git a/build.gradle b/build.gradle index 7b5c8e12..86b8ac76 100644 --- a/build.gradle +++ b/build.gradle @@ -764,7 +764,7 @@ if (project.hasProperty('benchmarkRunDir')) { benchmarkRunDirectory.mkdirs() new File(benchmarkRunDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') new File(benchmarkRunDirectory, 'server.properties').setText( - 'online-mode=false\nlevel-name=world\nlevel-type=default\n' + + "online-mode=false\nlevel-name=world\nlevel-type=${project.findProperty('benchmarkLevelType') ?: 'default'}\n" + "level-seed=${project.findProperty('benchmarkSeed') ?: '-4965128775892001975'}\n" + 'generate-structures=false\nspawn-animals=false\nspawn-monsters=false\n' + 'max-tick-time=-1\n', 'UTF-8') @@ -787,6 +787,14 @@ task benchmarkIntegrationProcess(type: JavaExec, dependsOn: prepareWorldgenBench if (project.hasProperty('benchmarkBlockAudit')) { systemProperty 'orespawn.worldgenBenchmarkBlockAudit', project.property('benchmarkBlockAudit') } + ['benchmarkBiomeType': 'orespawn.worldgenBenchmarkBiomeType', + 'benchmarkBiome': 'orespawn.worldgenBenchmarkBiome', + 'benchmarkNormalizeBlock': 'orespawn.worldgenBenchmarkNormalizeBlock', + 'benchmarkNormalizeBiome': 'orespawn.worldgenBenchmarkNormalizeBiome'].each { propertyName, systemName -> + if (project.hasProperty(propertyName)) { + systemProperty systemName, project.property(propertyName) + } + } configureFromForgeRun(delegate, 'runServer') doLast { File latest = new File(benchmarkRunDirectory, 'logs/fml-server-latest.log') diff --git a/docs/BIOMES.md b/docs/BIOMES.md index 1c1226b6..05df5168 100644 --- a/docs/BIOMES.md +++ b/docs/BIOMES.md @@ -144,14 +144,24 @@ in base terrain are eligible for geology; matching blocks authored later by structures or vegetation are not. Air, liquids, bedrock, and block-entity states remain protected even if a provider mistakenly lists their block IDs. -Dimension materials support the ordinary aquifer fluid and replacements for -vanilla snow and ice. Minecraft 1.10.2 has one exposed generator-fluid field, -so `default_fluid` is fully supported. Later-format `deep_aquifer_fluid` and -`deep_aquifer_max_y` values remain readable and are preserved in saved profiles, -but this branch disables their editor controls, warns when a distinct deep -fluid was requested, and uses the ordinary fluid for generation. OreSpawn converts -weather products in loaded chunks and around players; it does not replace every -water or lava block after generation. +Dimension materials apply to every biome in one dimension and only to newly +generated terrain. They support the ordinary aquifer fluid and replacements +for vanilla snow and ice. For a fluid with the same opacity and emitted light +as the native generator fluid, OreSpawn records only native aquifer cells while +the chunk is being built, lets Minecraft generate and light the terrain with +its native fluid, then substitutes exactly those cells before decoration. This +does not touch later lakes, springs or decorator fluids and creates no reload +retrogen. A fluid with different lighting uses the compatible direct-generator +path so its light remains correct; that path can be slower. + +Minecraft 1.10.2 has one exposed generator-fluid field, so `default_fluid` is +fully supported. Later-format `deep_aquifer_fluid` and +`deep_aquifer_max_y` values remain readable and are preserved in saved +profiles, but this branch disables their editor controls, warns when a distinct +deep fluid was requested, and uses the ordinary fluid for generation. OreSpawn +converts weather products in loaded chunks and around players; it does not +replace every water or lava block after generation. Unsupported independent +chunk generators are reported and left unchanged. ## Templates And Total Conversions @@ -182,7 +192,10 @@ sizes it keeps a compact biome list and the selected biome's details together; at the minimum supported width it uses list and detail pages without losing the selection or pending edits. The default list contains provider-managed, modified, disabled and missing entries. **Show All** also displays routine -registered biomes which OreSpawn does not otherwise manage. +registered biomes associated with the selected dimension. OreSpawn uses Forge's +Nether/End biome types for ordinary loaded entries and exact provider placement +declarations when they exist. Explicit replacements and missing entries remain +visible in their configured dimension. The detail pane reports the friendly name, registry ID, mod owner, status and effective placement-rule count. Every profile palette is shown in its stored @@ -195,8 +208,9 @@ the profile. **Leave original behaviour** makes no exact replacement. **Replace in new terrain with...** accepts any loaded source and target biome, including an -external biome, but warns when the target was not declared by a provider for -that dimension. OreSpawn stores these choices in the reserved +external biome. Provider ownership remains available in the biome details but +does not produce a warning for a valid replacement. OreSpawn stores these +choices in the reserved `orespawn:ui/biome_overrides/` palette. It is a 100% `replace/all` palette with zero fallback and always bakes after every ordinary palette, regardless of JSON insertion order. Each source has one terminal target; diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index f44a7bc5..62462bcf 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -150,10 +150,20 @@ ignored when absent. If a required similar biome is absent, that output entry is disabled with one setup warning. Surface fields are `top_block`, `filler_block`, `underwater_block`, `ceiling_block`, and `filler_depth`. -Dimension-material rules may set `default_fluid`, `deep_aquifer_fluid`, -`deep_aquifer_max_y`, `snow_block`, and `ice_block`. Fluid IDs must resolve to -blocks with non-empty fluid states. These substitutions are opt-in; a dimension -with no matching rule retains its native generator and weather materials. +Dimension-material rules apply to every biome in their selected dimension and +may set `default_fluid`, `deep_aquifer_fluid`, `deep_aquifer_max_y`, +`snow_block`, and `ice_block`. Fluid IDs must resolve to blocks with non-empty +fluid states. These substitutions are opt-in, affect only newly generated +terrain and perform no retrogen; a dimension with no matching rule retains its +native generator and weather materials. + +For a `default_fluid` with the native fluid's opacity and emitted light, +OreSpawn records the exact native aquifer cells during terrain construction and +substitutes only those cells before decoration. Later lakes, springs and fluids +placed by decorators are not included. A fluid with different lighting uses a +slower direct-generator compatibility path so lighting remains correct. +Unsupported independent chunk generators are detected and left unchanged. + Minecraft 1.10.2 exposes one generator fluid, so this branch applies `default_fluid` only. It retains `deep_aquifer_fluid` and `deep_aquifer_max_y` in provider and world profiles for cross-version diff --git a/docs/PLAYER_GUIDE.md b/docs/PLAYER_GUIDE.md index dd3d6d4d..bcc6829f 100644 --- a/docs/PLAYER_GUIDE.md +++ b/docs/PLAYER_GUIDE.md @@ -92,23 +92,30 @@ dormant instead of breaking world creation. lakes. **Solid Cover** controls the roof thickness, while **Solid Shell** prevents a deposit from opening into a cave at its sides or underside. - **Biomes** opens a compact directory. Managed, changed and missing entries - appear first; **Show All** includes ordinary registered biomes. Select a + appear first; **Show All** includes ordinary registered biomes belonging to + the selected dimension. Select a biome to inspect every placement rule, owner, order, climate limit and effective surface. **Leave original behaviour** keeps it unchanged, while **Replace in new terrain with...** creates an exact terminal replacement. This affects only newly generated chunks and never unregisters the source or disables another mod's decoration. Use **Palettes** for mode, scope, region - size, coverage, fallback and namespace controls. World Materials and Geome - Influences remain available from the same directory. Confirmed biome, + size, coverage, fallback and namespace controls. The dimension-named + Materials control sits with the dimension selector because it is not a + property of the selected biome. Geome Influences remains available from the + selected-biome side. Confirmed biome, palette, dimension and all-biome resets stay pending until the main editor's **Done** action; **Cancel** discards them. -**World Materials** applies across an entire dimension. **Aquifer Fluid** -changes the normal below-sea-level fluid. Minecraft 1.10.2 exposes only that -single generator fluid, so the later-format **Deep Aquifer** values remain -stored but their controls are disabled on this branch. Snow and ordinary ice -can also be replaced. Use **Fluid Deposits**, not World Materials, for -occasional underground lakes or pockets. +**Overworld Materials**, or the corresponding dimension-named button, applies +to every biome in that dimension's newly generated terrain. It is not limited +to the biome selected on the right and never rewrites existing chunks. +**Aquifer Fluid** changes the normal below-sea-level terrain fluid. Fluids with +native-equivalent lighting use the fast substitution path; an unusual luminous +or differently opaque fluid uses the compatible generator path and may be +slower. Minecraft 1.10.2 exposes only one generator fluid, so the later-format +**Deep Aquifer** values remain stored but their controls are disabled on this +branch. Snow and ordinary ice can also be replaced. Use **Fluid Deposits**, not +dimension materials, for occasional underground lakes or pockets. ## Rocks, Ores, And Other Mods 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 1e3a70bf..394c1986 100644 --- a/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java +++ b/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java @@ -267,7 +267,8 @@ public void registerBiomes(RegistryEvent.Register event) { surfaceB.get().theBiomeDecorator = new ProbeDecorator(); BiomeDictionary.registerBiomeType(surfaceA.get(), BiomeDictionary.Type.HOT, BiomeDictionary.Type.DRY); BiomeDictionary.registerBiomeType(surfaceB.get(), BiomeDictionary.Type.HOT, BiomeDictionary.Type.WET); - BiomeDictionary.registerBiomeType(unmanaged.get(), BiomeDictionary.Type.PLAINS); + // This fixture is the unmanaged entry for the End directory exercised below. + BiomeDictionary.registerBiomeType(unmanaged.get(), BiomeDictionary.Type.END); } @SubscribeEvent diff --git a/src/main/java/zone/moddev/mc/orespawn/client/BiomeDirectoryModel.java b/src/main/java/zone/moddev/mc/orespawn/client/BiomeDirectoryModel.java index 6365d683..33300ccd 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/BiomeDirectoryModel.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/BiomeDirectoryModel.java @@ -18,12 +18,17 @@ import net.minecraft.util.ResourceLocation; import net.minecraft.world.biome.Biome; +import net.minecraftforge.common.BiomeDictionary; import net.minecraftforge.fml.common.Loader; import net.minecraftforge.fml.common.ModContainer; import net.minecraftforge.fml.common.registry.ForgeRegistries; /** Immutable projection of loaded biomes, profile palettes, and provider defaults. */ final class BiomeDirectoryModel { + private static final String OVERWORLD = "minecraft:overworld"; + private static final String NETHER = "minecraft:the_nether"; + private static final String END = "minecraft:the_end"; + private BiomeDirectoryModel() { } static Snapshot snapshot(JsonObject profile, BiomeProviderDefaultsSnapshot defaults, @@ -38,7 +43,7 @@ static Snapshot snapshot(JsonObject profile, BiomeProviderDefaultsSnapshot defau ModInfo owner = mods.get(entry.getKey().getResourceDomain()); loaded.add(new LoadedBiome(entry.getKey().toString(), entry.getValue().getBiomeName(), owner == null ? entry.getKey().getResourceDomain() : owner.name, - owner == null ? "?" : owner.version)); + owner == null ? "?" : owner.version, routineDimension(entry.getValue()))); } return assemble(profile, defaults.biomePalettesCopy(), defaults.activeProviderIds(), loaded, dimensions); @@ -67,12 +72,19 @@ static Snapshot assemble(JsonObject profile, JsonObject providerPalettes, } Map> providerBiomes = providerBiomes(providerPalettes); + Map> declaredDimensions = declaredDimensions(palettes, providerBiomes); List entries = new ArrayList<>(); for (String dimension : dimensions) { Map> placements = placements(palettes, dimension); Map replacements = BiomeReplacementRules.read(profile, dimension); - Set ids = new LinkedHashSet<>(loaded.keySet()); + Set ids = new LinkedHashSet<>(); + for (LoadedBiome biome : loaded.values()) { + Set declared = declaredDimensions.getOrDefault(biome.id, Collections.emptySet()); + if (declared.isEmpty() ? dimension.equals(biome.routineDimension) + : declared.contains(dimension)) ids.add(biome.id); + } ids.addAll(placements.keySet()); + ids.addAll(providerBiomes.getOrDefault(dimension, Collections.emptySet())); ids.addAll(replacements.keySet()); ids.addAll(replacements.values()); for (String id : ids) { @@ -108,6 +120,29 @@ static Snapshot assemble(JsonObject profile, JsonObject providerPalettes, return new Snapshot(entries, palettes, dimensions, activeProviders); } + static String routineDimension(Biome biome) { + if (BiomeDictionary.isBiomeOfType(biome, BiomeDictionary.Type.NETHER)) return NETHER; + if (BiomeDictionary.isBiomeOfType(biome, BiomeDictionary.Type.END)) return END; + return OVERWORLD; + } + + private static Map> declaredDimensions(List palettes, + Map> providerBiomes) { + Map> result = new LinkedHashMap<>(); + for (Palette palette : palettes) { + if (palette.override) continue; + for (String id : JsonCopies.keys(object(palette.definition, "biomes"))) { + result.computeIfAbsent(id, ignored -> new LinkedHashSet<>()).add(palette.dimension); + } + } + for (Entry> dimension : providerBiomes.entrySet()) { + for (String id : dimension.getValue()) { + result.computeIfAbsent(id, ignored -> new LinkedHashSet<>()).add(dimension.getKey()); + } + } + return result; + } + private static Map> providerBiomes(JsonObject providerPalettes) { Map> result = new LinkedHashMap<>(); for (Entry entry : providerPalettes.entrySet()) { @@ -215,9 +250,14 @@ List palettes(String dimension) { } static final class LoadedBiome { - final String id, name, ownerName, ownerVersion; + final String id, name, ownerName, ownerVersion, routineDimension; LoadedBiome(String id, String name, String ownerName, String ownerVersion) { + this(id, name, ownerName, ownerVersion, OVERWORLD); + } + LoadedBiome(String id, String name, String ownerName, String ownerVersion, + String routineDimension) { this.id = id; this.name = name; this.ownerName = ownerName; this.ownerVersion = ownerVersion; + this.routineDimension = routineDimension; } } diff --git a/src/main/java/zone/moddev/mc/orespawn/client/BiomeWorldMaterialsScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/BiomeWorldMaterialsScreen.java index a40e42fc..4b0b15db 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/BiomeWorldMaterialsScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/BiomeWorldMaterialsScreen.java @@ -35,6 +35,8 @@ final class BiomeWorldMaterialsScreen extends OreSpawnScreen { private int detailX; private int detailWidth; private int detailRuleTop; + private int detailMessageTop; + private List detailMessageLines = java.util.Collections.emptyList(); BiomeWorldMaterialsScreen(GuiScreen parent, GeologyEditorSession session) { super(new TextComponentTranslation("screen.orespawn.biomes")); @@ -64,8 +66,9 @@ private void initDirectory(Snapshot snapshot, List entries, boolean int left = (width - contentWidth) / 2; int leftWidth = compact ? contentWidth : leftPaneWidth(width); int headerWidth = Math.max(76, Math.min(leftWidth - 74, 150)); - addButton(OreSpawnScreenLayout.button(this, font, left, 24, headerWidth, 20, - new TextComponentString(dimension), button -> cycleDimension(snapshot))); + addButton(OreSpawnScreenLayout.explainedButton(this, font, left, 24, headerWidth, 20, + dimensionSelectorLabel(dimension), button -> cycleDimension(snapshot), + "tooltip.orespawn.biome.dimension")); addButton(OreSpawnScreenLayout.button(this, font, left + leftWidth - 68, 24, 68, 20, new TextComponentTranslation(showAll ? "button.orespawn.biome.hide_routine" : "button.orespawn.show_all"), button -> { @@ -83,11 +86,18 @@ private void initDirectory(Snapshot snapshot, List entries, boolean @Override protected void onRowPressed(int index) { selectedId = entries.get(index).id; if (compact) compactDetail = true; - ruleScroll = 0; resetArmed = false; message = null; rebuild(); + ruleScroll = 0; resetArmed = false; clearMessage(); rebuild(); } @Override protected List rowTooltip(int index) { return biomeTooltip(entries.get(index)); } }); biomeList.setFirstIndex(biomeScroll); + addButton(OreSpawnScreenLayout.explainedButton(this, font, left, + materialsButtonY(height), leftWidth, 20, + new TextComponentTranslation("button.orespawn.dimension_materials_named", + dimensionName(dimension)), + button -> minecraft.displayGuiScreen( + new DimensionMaterialsScreen(this, session, dimension)), + "tooltip.orespawn.biome.dimension_materials")); if (!compact) initDetail(snapshot, false); addButton(new Button(width / 2 - 75, height - 28, 150, 20, DialogTexts.GUI_DONE, button -> onClose())); @@ -103,7 +113,7 @@ private void initDetail(Snapshot snapshot, boolean compact) { int paneWidth = compact ? contentWidth : contentWidth - leftWidth - 10; detailX = x; detailWidth = paneWidth; - int y = compact ? 34 : CONTENT_TOP; + int y = detailTop(compact); if (compact) { addButton(OreSpawnScreenLayout.button(this, font, contentLeft, 8, 48, 20, new TextComponentTranslation("button.orespawn.back"), button -> { @@ -120,12 +130,15 @@ private void initDetail(Snapshot snapshot, boolean compact) { addButton(OreSpawnScreenLayout.explainedButton(this, font, x, y + 59, paneWidth, 20, new TextComponentTranslation("button.orespawn.biome.leave_original"), - button -> { session.leaveBiomeOriginal(dimension, selected.id); message = null; rebuild(); }, + button -> { session.leaveBiomeOriginal(dimension, selected.id); clearMessage(); rebuild(); }, "tooltip.orespawn.biome.no_retrogen")); } - int ruleTop = y + (selected.replacementTarget == null ? 61 : 85); + detailMessageLines = wrapMessage(message, paneWidth - 6); + detailMessageTop = detailMessageTop(compact, selected.replacementTarget != null); + int ruleTop = detailRuleTop(compact, selected.replacementTarget != null, + detailMessageLines.size()); detailRuleTop = ruleTop; - int controlsTop = Math.max(ruleTop + 34, height - 76); + int controlsTop = controlsTop(height, ruleTop); int ruleHeight = Math.max(16, controlsTop - ruleTop - 4); List placements = selected.placements; ruleList = addButton(new CompactScrollList(this, x, ruleTop, paneWidth, ruleHeight) { @@ -149,14 +162,11 @@ private void initDetail(Snapshot snapshot, boolean compact) { new TextComponentTranslation("button.orespawn.biome.palettes", snapshot.palettes(dimension).size()), button -> minecraft.displayGuiScreen(new BiomePaletteScreen(this, session, dimension)))); addButton(OreSpawnScreenLayout.button(this, font, x + half + 4, controlsTop, half, 20, - new TextComponentTranslation("button.orespawn.dimension_materials"), - button -> minecraft.displayGuiScreen(new DimensionMaterialsScreen(this, session, dimension)))); - addButton(OreSpawnScreenLayout.button(this, font, x, controlsTop + 24, half, 20, - new TextComponentTranslation("button.orespawn.geome_influences"), - button -> minecraft.displayGuiScreen(new GeomeBiomeScreen(this, session)))); - addButton(OreSpawnScreenLayout.button(this, font, x + half + 4, controlsTop + 24, half, 20, new TextComponentTranslation(resetArmed ? "button.orespawn.biome.confirm_reset" : "button.orespawn.biome.reset_selected"), button -> resetSelected(selected))); + addButton(OreSpawnScreenLayout.button(this, font, x, controlsTop + 24, paneWidth, 20, + new TextComponentTranslation("button.orespawn.geome_influences"), + button -> minecraft.displayGuiScreen(new GeomeBiomeScreen(this, session)))); } private void chooseReplacement(BiomeEntry selected) { @@ -164,8 +174,7 @@ private void chooseReplacement(BiomeEntry selected) { minecraft.displayGuiScreen(new BiomePickerScreen(this, session, target -> { try { session.replaceBiome(dimension, selected.id, target); - message = session.biomeTargetProviderDeclared(dimension, target) ? null - : I18n.format("warning.orespawn.biome.target_not_declared", target); + clearMessage(); } catch (IllegalArgumentException failure) { message = failure.getMessage(); } @@ -176,14 +185,34 @@ private void chooseReplacement(BiomeEntry selected) { private void resetSelected(BiomeEntry selected) { if (!resetArmed) { resetArmed = true; rebuild(); return; } session.resetBiome(dimension, selected.id); - resetArmed = false; message = null; rebuild(); + resetArmed = false; clearMessage(); rebuild(); } private void cycleDimension(Snapshot snapshot) { - List dimensions = new ArrayList<>(snapshot.dimensions); - int index = dimensions.indexOf(dimension); - dimension = dimensions.get((Math.max(0, index) + 1) % dimensions.size()); - selectedId = null; compactDetail = false; biomeScroll = 0; ruleScroll = 0; rebuild(); + dimension = nextDimension(snapshot.dimensions, dimension); + selectedId = null; compactDetail = false; biomeScroll = 0; ruleScroll = 0; clearMessage(); rebuild(); + } + + private void clearMessage() { + message = null; + } + + private List wrapMessage(String text, int maximumWidth) { + if (text == null || text.isEmpty()) return java.util.Collections.emptyList(); + List wrapped = font.listFormattedStringToWidth(text, Math.max(40, maximumWidth)); + if (wrapped.size() <= 2) return wrapped; + List result = new ArrayList<>(wrapped.subList(0, 2)); + result.set(1, OreSpawnScreenLayout.fit(font, + new TextComponentString(result.get(1) + "..."), Math.max(40, maximumWidth))); + return result; + } + + static String nextDimension(Iterable available, String current) { + List dimensions = new ArrayList<>(); + for (String candidate : available) dimensions.add(candidate); + if (dimensions.isEmpty()) return "minecraft:overworld"; + int index = dimensions.indexOf(current); + return dimensions.get((Math.max(-1, index) + 1) % dimensions.size()); } private static BiomeEntry find(List entries, String id) { @@ -191,6 +220,19 @@ private static BiomeEntry find(List entries, String id) { return null; } + static ITextComponent dimensionName(String dimension) { + switch (dimension) { + case "minecraft:overworld": return new TextComponentTranslation("value.orespawn.dimension.overworld"); + case "minecraft:the_nether": return new TextComponentTranslation("value.orespawn.dimension.the_nether"); + case "minecraft:the_end": return new TextComponentTranslation("value.orespawn.dimension.the_end"); + default: return new TextComponentString(dimension); + } + } + + private static ITextComponent dimensionSelectorLabel(String dimension) { + return new TextComponentString("< " + dimensionName(dimension).getUnformattedText() + " >"); + } + private static String statusMarker(Status status) { switch (status) { case USER_REPLACED: return "R"; @@ -261,26 +303,41 @@ public void render(int mouseX, int mouseY, float partialTick) { int contentWidth = contentWidth(width); int x = width < TWO_PANE_MINIMUM ? (width - contentWidth) / 2 : (width - contentWidth) / 2 + leftPaneWidth(width) + 10; - int y = width < TWO_PANE_MINIMUM ? 34 : CONTENT_TOP; + int y = detailTop(width < TWO_PANE_MINIMUM); drawString(font, new TextComponentString(selected.name), x + 3, y, 0xFFFFFF); drawString(font, new TextComponentString(selected.id), x + 3, y + 11, 0xAAAAAA); drawString(font, new TextComponentTranslation("value.orespawn.biome.status." + selected.status.name().toLowerCase(java.util.Locale.ROOT)), x + 3, y + 22, statusColor(selected.status)); - if (selected.replacementTarget != null) { - String notice = I18n.format("label.orespawn.biome.new_terrain_only"); - drawString(font, new TextComponentString(notice), - detailX + detailWidth - font.getStringWidth(notice) - 3, y + 22, 0xFFFF55); + for (int line = 0; line < detailMessageLines.size(); line++) { + drawString(font, new TextComponentString(detailMessageLines.get(line)), + detailX + 3, detailMessageTop + (line * 10), 0xFF5555); } drawString(font, new TextComponentTranslation("label.orespawn.biome.placement_rules"), detailX + 3, detailRuleTop - 10, 0xCCCCCC); } - if (message != null) drawCenteredString(font, new TextComponentString(message), width / 2, height - 39, 0xFF5555); super.render(mouseX, mouseY, partialTick); OreSpawnScreenLayout.renderExplanations(this, mouseX, mouseY); } static int contentWidth(int width) { return Math.min(520, Math.max(300, width - 20)); } static int leftPaneWidth(int width) { return Math.max(132, (contentWidth(width) * 43) / 100); } - static int listHeight(int height) { return Math.max(32, height - CONTENT_TOP - 36); } + static int listHeight(int height) { return Math.max(32, materialsButtonY(height) - CONTENT_TOP - 4); } + static int materialsButtonY(int height) { return height - 52; } + static int detailTop(boolean compact) { return compact ? 34 : 24; } + static int detailRuleTop(boolean compact, boolean hasReplacement) { + return detailTop(compact) + (hasReplacement ? 95 : 71); + } + + static int detailMessageTop(boolean compact, boolean hasReplacement) { + return detailRuleTop(compact, hasReplacement) - 12; + } + + static int detailRuleTop(boolean compact, boolean hasReplacement, int messageLines) { + return detailRuleTop(compact, hasReplacement) + (Math.max(0, Math.min(2, messageLines)) * 10); + } + + static int controlsTop(int height, int ruleTop) { + return Math.max(ruleTop + 34, height - 76); + } } diff --git a/src/main/java/zone/moddev/mc/orespawn/client/DimensionMaterialsScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/DimensionMaterialsScreen.java index 6aa212c0..5ea5cc53 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/DimensionMaterialsScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/DimensionMaterialsScreen.java @@ -13,7 +13,8 @@ final class DimensionMaterialsScreen extends OreSpawnScreen { private TextFieldWidget deepY; DimensionMaterialsScreen(GuiScreen parent, GeologyEditorSession session, String dimension) { - super(new TextComponentTranslation("screen.orespawn.dimension_materials")); + super(new TextComponentTranslation("screen.orespawn.dimension_materials", + BiomeWorldMaterialsScreen.dimensionName(dimension))); this.parent = parent; this.session = session; this.dimension = dimension; @@ -83,8 +84,12 @@ private void save() { @Override public void render(int mouseX, int mouseY, float partialTick) { renderBackground(); - drawCenteredString(font, title, width / 2, 12, 0xFFFFFF); - drawCenteredString(font, new TextComponentString(dimension), width / 2, 30, 0xCCCCCC); + drawCenteredString(font, title, width / 2, 8, 0xFFFFFF); + drawCenteredString(font, new TextComponentString(dimension), width / 2, 23, 0xAAAAAA); + drawCenteredString(font, new TextComponentString(OreSpawnScreenLayout.fit(font, + new TextComponentTranslation("label.orespawn.dimension_materials.scope", + BiomeWorldMaterialsScreen.dimensionName(dimension)), width - 16)), + width / 2, 38, 0xFFFF55); super.render(mouseX, mouseY, partialTick); OreSpawnScreenLayout.renderExplanations(this, mouseX, mouseY); } diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/AquiferMaterialSubstitution.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/AquiferMaterialSubstitution.java new file mode 100644 index 00000000..ae359d4f --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/AquiferMaterialSubstitution.java @@ -0,0 +1,226 @@ +package zone.moddev.mc.orespawn.worldgen; + +import java.util.BitSet; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import net.minecraft.block.state.IBlockState; +import net.minecraft.world.World; +import net.minecraft.world.WorldServer; +import net.minecraft.world.chunk.Chunk; +import net.minecraft.world.chunk.ChunkPrimer; +import net.minecraft.world.chunk.IChunkGenerator; +import net.minecraft.world.chunk.storage.ExtendedBlockStorage; +import net.minecraft.world.gen.ChunkProviderOverworld; +import net.minecraftforge.event.terraingen.ChunkGeneratorEvent; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Replaces the native Overworld aquifer fluid after vanilla has built and lit + * the chunk. The primer mask keeps the substitution limited to terrain fluid; + * lakes, springs, and later decorators are deliberately outside it. + */ +final class AquiferMaterialSubstitution { + private static final Logger LOGGER = LogManager.getLogger(); + private static final Map NATIVE_FLUIDS = + Collections.synchronizedMap(new IdentityHashMap()); + private static final Map PLANS = + Collections.synchronizedMap(new IdentityHashMap()); + private static final PendingMasks PENDING = new PendingMasks(); + + private AquiferMaterialSubstitution() { + } + + static void configure(WorldServer world, IBlockState selected) { + IChunkGenerator raw = world.getChunkProvider().chunkGenerator; + if (!(raw instanceof ChunkProviderOverworld)) { + PLANS.remove(raw); + PENDING.remove(raw); + return; + } + + ChunkProviderOverworld generator = (ChunkProviderOverworld) raw; + IBlockState nativeFluid; + synchronized (NATIVE_FLUIDS) { + nativeFluid = NATIVE_FLUIDS.get(generator); + if (nativeFluid == null) { + nativeFluid = generator.oceanBlock; + NATIVE_FLUIDS.put(generator, nativeFluid); + } + } + generator.oceanBlock = nativeFluid; + PLANS.remove(raw); + PENDING.remove(raw); + + if (selected == null || sameState(selected, nativeFluid)) return; + if (lightCompatible(nativeFluid, selected)) { + PLANS.put(raw, new Plan(nativeFluid, selected, true)); + return; + } + + generator.oceanBlock = selected; + PLANS.put(raw, new Plan(nativeFluid, selected, false)); + LOGGER.warn("Dimension '{}' uses aquifer fluid '{}' with opacity/light {}/{} instead of " + + "native '{}' at {}/{}; using the compatible generator path, which may generate more slowly", + WorldIds.dimension(world), selected, selected.getLightOpacity(), selected.getLightValue(), + nativeFluid, nativeFluid.getLightOpacity(), nativeFluid.getLightValue()); + } + + static void capture(ChunkGeneratorEvent.ReplaceBiomeBlocks event) { + if (WorldgenBenchmark.isVanillaBaseline() || event.getWorld() == null) return; + Plan plan = PLANS.get(event.getGenerator()); + if (plan == null || !plan.deferred) return; + BitSet mask = captureMask(event.getPrimer(), plan.nativeFluid, + event.getWorld().getSeaLevel()); + if (!mask.isEmpty()) { + PENDING.put(event.getGenerator(), event.getX(), event.getZ(), + new Pending(plan.nativeFluid, plan.selectedFluid, mask)); + } + } + + static boolean apply(World world, Chunk chunk) { + if (!(world instanceof WorldServer)) return false; + IChunkGenerator generator = ((WorldServer) world).getChunkProvider().chunkGenerator; + Pending pending = PENDING.take(generator, chunk.xPosition, chunk.zPosition); + if (pending == null) return false; + + ExtendedBlockStorage[] sections = chunk.getBlockStorageArray(); + int changed = applyToSections(pending, sections); + if (changed > 0) ChunkAccessCompat.markChanged(chunk); + return changed > 0; + } + + static int applyToSections(Pending pending, ExtendedBlockStorage[] sections) { + int changed = 0; + for (int index = pending.mask.nextSetBit(0); index >= 0; + index = pending.mask.nextSetBit(index + 1)) { + int y = localY(index); + ExtendedBlockStorage section = sections[y >> 4]; + if (section == null) continue; + int x = localX(index); + int z = localZ(index); + IBlockState current = section.get(x, y & 15, z); + if (!sameState(current, pending.nativeFluid)) continue; + section.set(x, y & 15, z, pending.selectedFluid); + changed++; + } + return changed; + } + + static BitSet captureMask(ChunkPrimer primer, IBlockState nativeFluid, int seaLevel) { + BitSet result = new BitSet(16 * 16 * 256); + int maximumY = Math.max(0, Math.min(256, seaLevel)); + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + for (int y = 0; y < maximumY; y++) { + if (sameState(primer.getBlockState(x, y, z), nativeFluid)) { + result.set(index(x, y, z)); + } + } + } + } + return result; + } + + static boolean lightCompatible(IBlockState nativeFluid, IBlockState selectedFluid) { + return nativeFluid.getLightOpacity() == selectedFluid.getLightOpacity() + && nativeFluid.getLightValue() == selectedFluid.getLightValue(); + } + + static int index(int x, int y, int z) { + return (x << 12) | (z << 8) | y; + } + + static int localX(int index) { return (index >> 12) & 15; } + static int localY(int index) { return index & 255; } + static int localZ(int index) { return (index >> 8) & 15; } + + static void clear() { + synchronized (NATIVE_FLUIDS) { + for (Map.Entry entry : NATIVE_FLUIDS.entrySet()) { + entry.getKey().oceanBlock = entry.getValue(); + } + NATIVE_FLUIDS.clear(); + } + PLANS.clear(); + PENDING.clear(); + } + + static int pendingCount() { return PENDING.size(); } + + private static boolean sameState(IBlockState first, IBlockState second) { + return first == second || (first != null && first.equals(second)); + } + + private static final class Plan { + final IBlockState nativeFluid; + final IBlockState selectedFluid; + final boolean deferred; + + Plan(IBlockState nativeFluid, IBlockState selectedFluid, boolean deferred) { + this.nativeFluid = nativeFluid; + this.selectedFluid = selectedFluid; + this.deferred = deferred; + } + } + + static final class Pending { + final IBlockState nativeFluid; + final IBlockState selectedFluid; + final BitSet mask; + + Pending(IBlockState nativeFluid, IBlockState selectedFluid, BitSet mask) { + this.nativeFluid = nativeFluid; + this.selectedFluid = selectedFluid; + this.mask = (BitSet) mask.clone(); + } + } + + static final class PendingMasks { + private final Map masks = new ConcurrentHashMap<>(); + + void put(IChunkGenerator generator, int x, int z, Pending pending) { + masks.put(new ChunkKey(generator, x, z), pending); + } + + Pending take(IChunkGenerator generator, int x, int z) { + return masks.remove(new ChunkKey(generator, x, z)); + } + + void remove(IChunkGenerator generator) { + for (ChunkKey key : masks.keySet()) { + if (key.generator == generator) masks.remove(key); + } + } + + void clear() { masks.clear(); } + int size() { return masks.size(); } + } + + private static final class ChunkKey { + final IChunkGenerator generator; + final int x; + final int z; + + ChunkKey(IChunkGenerator generator, int x, int z) { + this.generator = generator; + this.x = x; + this.z = z; + } + + @Override public int hashCode() { + return ((31 * System.identityHashCode(generator)) + x) * 31 + z; + } + + @Override public boolean equals(Object value) { + if (this == value) return true; + if (!(value instanceof ChunkKey)) return false; + ChunkKey other = (ChunkKey) value; + return generator == other.generator && x == other.x && z == other.z; + } + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeWorldgenManager.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeWorldgenManager.java index df21ac6a..a94d2396 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeWorldgenManager.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeWorldgenManager.java @@ -25,7 +25,6 @@ import net.minecraft.block.Block; import net.minecraft.init.Blocks; import net.minecraft.block.state.IBlockState; -import net.minecraft.world.gen.ChunkProviderOverworld; import net.minecraft.world.chunk.IChunkGenerator; import net.minecraftforge.fluids.IFluidBlock; import net.minecraftforge.fml.common.registry.ForgeRegistries; @@ -39,8 +38,6 @@ final class BiomeWorldgenManager { private static final Logger LOGGER = LogManager.getLogger(); private static volatile Map baked = Collections.emptyMap(); - private static final Map ORIGINAL_DEFAULT_FLUIDS = - new IdentityHashMap<>(); private static final Set WARNED_DEEP_FLUID_DIMENSIONS = new LinkedHashSet<>(); @@ -92,10 +89,7 @@ static synchronized void apply(WorldServer level, WorldGeologyProfile profile) { static synchronized void clear() { BiomeFeatureInstaller.restoreAll(); - for (Entry entry : ORIGINAL_DEFAULT_FLUIDS.entrySet()) { - setDefaultFluid(entry.getKey(), entry.getValue()); - } - ORIGINAL_DEFAULT_FLUIDS.clear(); + AquiferMaterialSubstitution.clear(); WARNED_DEEP_FLUID_DIMENSIONS.clear(); baked = Collections.emptyMap(); } @@ -287,7 +281,8 @@ private static void installBiomeSource(WorldServer level, BakedBiomeWorldgen con private static void installAquiferMaterials(WorldServer level, DimensionMaterials materials) { IChunkGenerator raw = level.getChunkProvider().chunkGenerator; - if (!(raw instanceof ChunkProviderOverworld)) { + if (!(raw instanceof net.minecraft.world.gen.ChunkProviderOverworld)) { + AquiferMaterialSubstitution.configure(level, null); if (materials != null && materials.defaultFluid != null && WARNED_DEEP_FLUID_DIMENSIONS.add(WorldIds.dimension(level))) { LOGGER.warn("Dimension '{}' requests a generator fluid override that Forge 1.10 does not expose; preserving the profile value", @@ -295,14 +290,10 @@ private static void installAquiferMaterials(WorldServer level, DimensionMaterial } return; } - ChunkProviderOverworld generator = (ChunkProviderOverworld) raw; - IBlockState original = ORIGINAL_DEFAULT_FLUIDS.computeIfAbsent(generator, - ignored -> generator.oceanBlock); - IBlockState selected = materials != null && materials.defaultFluid != null - ? materials.defaultFluid : original; - setDefaultFluid(generator, selected); + IBlockState selected = materials == null ? null : materials.defaultFluid; + AquiferMaterialSubstitution.configure(level, selected); if (materials != null && materials.deepFluid != null - && !materials.deepFluid.equals(selected) + && (selected == null || !materials.deepFluid.equals(selected)) && materials.deepFluidMaxY >= 0 && WARNED_DEEP_FLUID_DIMENSIONS.add(WorldIds.dimension(level))) { LOGGER.warn("Dimension '{}' requests a distinct deep aquifer fluid below Y {}, but Minecraft 1.10.2 only exposes one generator fluid; preserving the profile value and using default_fluid for generation", @@ -310,10 +301,6 @@ private static void installAquiferMaterials(WorldServer level, DimensionMaterial } } - private static void setDefaultFluid(ChunkProviderOverworld generator, IBlockState fluid) { - generator.oceanBlock = fluid; - } - private static Surface surface(JsonObject json) { IBlockState top = state(json, "top_block", false); IBlockState filler = state(json, "filler_block", false); 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 bbd7de17..bb99cba1 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnWorldGenerator.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnWorldGenerator.java @@ -14,6 +14,7 @@ import net.minecraft.world.chunk.IChunkProvider; import net.minecraft.world.chunk.IChunkGenerator; import net.minecraftforge.event.terraingen.DecorateBiomeEvent; +import net.minecraftforge.event.terraingen.ChunkGeneratorEvent; import net.minecraftforge.event.terraingen.OreGenEvent; import net.minecraftforge.fml.common.IWorldGenerator; import net.minecraftforge.fml.common.eventhandler.Event; @@ -36,6 +37,11 @@ public final class OreSpawnWorldGenerator implements IWorldGenerator { private OreSpawnWorldGenerator() { } + @SubscribeEvent(priority = EventPriority.LOWEST) + public void captureNativeAquifer(ChunkGeneratorEvent.ReplaceBiomeBlocks event) { + AquiferMaterialSubstitution.capture(event); + } + @SubscribeEvent public void beforeBiomeDecoration(DecorateBiomeEvent.Pre event) { if (WorldgenBenchmark.isVanillaBaseline()) return; @@ -91,6 +97,7 @@ private void generateEarly(World world, int chunkX, int chunkZ, Random random) { if (!earlyComplete.add(key)) return; Chunk chunk = world.getChunkProvider().provideChunk(chunkX, chunkZ); // Explicit order is the 1.12 equivalent of LOCAL_MODIFICATIONS. + AquiferMaterialSubstitution.apply(world, chunk); StoneReplacer.FEATURE.generate(world, chunk, random); BiomeSurfaceFeature.FEATURE.generate(world, chunk, random); FluidDepositFeature.FEATURE.generate(world, chunk, random); @@ -107,6 +114,7 @@ private void generateOres(World world, int chunkX, int chunkZ, Random random) { } public void clear() { + AquiferMaterialSubstitution.clear(); earlyComplete.clear(); oreComplete.clear(); legacyComplete.clear(); diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/VanillaSpringCompatibility.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/VanillaSpringCompatibility.java index a0fee4c1..45dd3c79 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/VanillaSpringCompatibility.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/VanillaSpringCompatibility.java @@ -12,6 +12,8 @@ import net.minecraft.init.Blocks; import net.minecraft.util.math.BlockPos; import net.minecraft.world.World; +import net.minecraft.world.WorldServer; +import net.minecraft.world.gen.ChunkProviderOverworld; import net.minecraftforge.event.terraingen.DecorateBiomeEvent; import net.minecraftforge.fml.common.eventhandler.Event; @@ -47,6 +49,10 @@ static boolean accepts(World world, BlockPos pos, IBlockState state) { static void replaceVanillaSpringPass(DecorateBiomeEvent.Decorate event) { if (providerRocks.isEmpty() || event.getResult() == Event.Result.DENY) return; + World world = event.getWorld(); + if (!(world instanceof WorldServer) + || !(((WorldServer) world).getChunkProvider().chunkGenerator + instanceof ChunkProviderOverworld)) return; Block fluid; int attempts; if (event.getType() == DecorateBiomeEvent.Decorate.EventType.LAKE_WATER) { @@ -56,7 +62,7 @@ static void replaceVanillaSpringPass(DecorateBiomeEvent.Decorate event) { } else { return; } - World world = event.getWorld(); java.util.Random random = event.getRand(); + java.util.Random random = event.getRand(); BlockPos origin = event.getPos(); for (int attempt = 0; attempt < attempts; attempt++) { int x = random.nextInt(16) + 8; diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldgenBenchmark.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldgenBenchmark.java index 8c93bea0..42cab62f 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldgenBenchmark.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldgenBenchmark.java @@ -1,5 +1,6 @@ package zone.moddev.mc.orespawn.worldgen; +import java.security.MessageDigest; import java.util.Arrays; import java.util.LinkedHashMap; import java.util.Locale; @@ -109,6 +110,7 @@ public static void onServerStarted(FMLServerStartingEvent event) { + "median_ms={} median_ms_per_chunk={} min_ms={} max_ms={}", MODE, chunks, repetitions, format(median), format(median / chunks), format(sorted[0]), format(sorted[sorted.length - 1])); + auditNormalizedHashes(level, baseCenterX, baseCenterZ, centerStep, radius, repetitions); if (Boolean.getBoolean("orespawn.worldgenBenchmarkStopServer")) { LOGGER.info("ORESPAWN_BENCHMARK stopping server after completed benchmark"); event.getServer().initiateShutdown(); @@ -151,24 +153,35 @@ private static int squareDiameter(int radius) { } private static int[] locateBiomeType(WorldServer level, int centerX, int centerZ) { + String exact = System.getProperty("orespawn.worldgenBenchmarkBiome", "").trim(); + if (!exact.isEmpty()) { + ResourceLocation id = resourceLocation(exact); + Biome biome = id == null ? null + : net.minecraftforge.fml.common.registry.ForgeRegistries.BIOMES.getValue(id); + if (biome == null) throw new IllegalArgumentException("Unknown benchmark biome " + exact); + BlockPos origin = new BlockPos(centerX << 4, level.getSeaLevel(), centerZ << 4); + BlockPos located = level.provider.getBiomeProvider().findBiomePosition( + origin.getX(), origin.getZ(), 16384, + java.util.Collections.singletonList(biome), level.rand); + if (located == null) throw new IllegalStateException("Benchmark could not locate biome " + exact); + int locatedX = located.getX() >> 4; + int locatedZ = located.getZ() >> 4; + LOGGER.info("ORESPAWN_BENCHMARK located biome={} center_chunk_x={} center_chunk_z={}", + exact, locatedX, locatedZ); + return new int[] { locatedX, locatedZ }; + } String configured = System.getProperty("orespawn.worldgenBenchmarkBiomeType", "").trim(); if (configured.isEmpty()) { return new int[] { centerX, centerZ }; } BiomeDictionary.Type type = BiomeDictionary.Type.getType(configured); BlockPos origin = new BlockPos(centerX << 4, level.getSeaLevel(), centerZ << 4); - BlockPos located = null; - double bestDistance = Double.POSITIVE_INFINITY; + java.util.List matchingBiomes = new java.util.ArrayList<>(); for (Biome biome : net.minecraftforge.fml.common.registry.ForgeRegistries.BIOMES.getValues()) { - if (!BiomeDictionary.isBiomeOfType(biome, type)) continue; - BlockPos candidate = level.provider.getBiomeProvider() - .findBiomePosition(origin.getX(), origin.getZ(), 16384, - java.util.Collections.singletonList(biome), level.rand); - if (candidate != null && candidate.distanceSq(origin) < bestDistance) { - located = candidate; - bestDistance = candidate.distanceSq(origin); - } + if (BiomeDictionary.isBiomeOfType(biome, type)) matchingBiomes.add(biome); } + BlockPos located = level.provider.getBiomeProvider().findBiomePosition( + origin.getX(), origin.getZ(), 16384, matchingBiomes, level.rand); if (located == null) { throw new IllegalStateException("Benchmark could not locate biome dictionary type " + configured); } @@ -179,6 +192,98 @@ private static int[] locateBiomeType(WorldServer level, int centerX, int centerZ return new int[] { locatedX, locatedZ }; } + private static void auditNormalizedHashes(WorldServer level, int baseCenterX, int baseCenterZ, + int centerStep, int radius, int repetitions) { + Block[] blocks = blockPair("orespawn.worldgenBenchmarkNormalizeBlock"); + Biome[] biomes = biomePair("orespawn.worldgenBenchmarkNormalizeBiome"); + if (blocks == null && biomes == null) return; + try { + for (int repetition = 0; repetition < repetitions; repetition++) { + MessageDigest blockHash = MessageDigest.getInstance("SHA-256"); + MessageDigest biomeHash = MessageDigest.getInstance("SHA-256"); + long normalizedBlocks = 0L; + long normalizedBiomes = 0L; + int centerX = baseCenterX + (repetition * centerStep); + for (int chunkZ = baseCenterZ - radius; chunkZ <= baseCenterZ + radius; chunkZ++) { + for (int chunkX = centerX - radius; chunkX <= centerX + radius; chunkX++) { + Chunk chunk = level.getChunkProvider().provideChunk(chunkX, chunkZ); + for (int z = 0; z < 16; z++) { + for (int x = 0; x < 16; x++) { + Biome biome = Biome.getBiome(chunk.getBiomeArray()[(z << 4) | x] & 255); + if (biomes != null && biome == biomes[0]) { + biome = biomes[1]; + normalizedBiomes++; + } + update(biomeHash, Biome.getIdForBiome(biome)); + for (int y = 0; y < 256; y++) { + IBlockState state = chunk.getBlockState(x, y, z); + if (blocks != null && state.getBlock() == blocks[0]) { + state = blocks[1].getDefaultState(); + normalizedBlocks++; + } + update(blockHash, Block.getIdFromBlock(state.getBlock())); + update(blockHash, state.getBlock().getMetaFromState(state)); + } + } + } + } + } + LOGGER.info("ORESPAWN_BENCHMARK_HASH repetition={} normalized_blocks={} block_sha256={} " + + "normalized_biomes={} biome_sha256={}", repetition + 1, + normalizedBlocks, hex(blockHash.digest()), normalizedBiomes, hex(biomeHash.digest())); + } + } catch (Exception failure) { + throw new IllegalStateException("Could not calculate normalized benchmark hashes", failure); + } + } + + private static Block[] blockPair(String property) { + String configured = System.getProperty(property, "").trim(); + if (configured.isEmpty()) return null; + String[] values = configured.split(","); + if (values.length != 2) throw new IllegalArgumentException( + "Benchmark block normalization requires source,target: " + configured); + Block[] result = new Block[2]; + for (int i = 0; i < 2; i++) { + ResourceLocation id = resourceLocation(values[i].trim()); + result[i] = id == null ? null + : net.minecraftforge.fml.common.registry.ForgeRegistries.BLOCKS.getValue(id); + if (result[i] == null) throw new IllegalArgumentException( + "Unknown benchmark normalization block " + values[i].trim()); + } + return result; + } + + private static Biome[] biomePair(String property) { + String configured = System.getProperty(property, "").trim(); + if (configured.isEmpty()) return null; + String[] values = configured.split(","); + if (values.length != 2) throw new IllegalArgumentException( + "Benchmark biome normalization requires source,target: " + configured); + Biome[] result = new Biome[2]; + for (int i = 0; i < 2; i++) { + ResourceLocation id = resourceLocation(values[i].trim()); + result[i] = id == null ? null + : net.minecraftforge.fml.common.registry.ForgeRegistries.BIOMES.getValue(id); + if (result[i] == null) throw new IllegalArgumentException( + "Unknown benchmark normalization biome " + values[i].trim()); + } + return result; + } + + private static void update(MessageDigest digest, int value) { + digest.update((byte) (value >>> 24)); + digest.update((byte) (value >>> 16)); + digest.update((byte) (value >>> 8)); + digest.update((byte) value); + } + + private static String hex(byte[] bytes) { + StringBuilder result = new StringBuilder(bytes.length * 2); + for (byte value : bytes) result.append(String.format(Locale.ROOT, "%02x", value & 255)); + return result.toString(); + } + private static void auditOres(WorldServer level, int centerX, int centerZ, int radius, int repetition) { Map audits = new LinkedHashMap<>(); diff --git a/src/main/resources/assets/orespawn/lang/de_AU.lang b/src/main/resources/assets/orespawn/lang/de_AU.lang index 54d33399..ddf85f92 100644 --- a/src/main/resources/assets/orespawn/lang/de_AU.lang +++ b/src/main/resources/assets/orespawn/lang/de_AU.lang @@ -87,7 +87,7 @@ tooltip.orespawn.biome.mode=Augment mischt konfigurierte Biome mit dem ursprüng tooltip.orespawn.biome.scope=Wählen Sie aus, welche vorhandenen Biom-Namespaces ersetzt werden können: alle Biome, nur Minecraft oder ausgewählte Mod-Namespaces. tooltip.orespawn.biome.region_size=Steuert die horizontale Größe von Biom-Platzierungsregionen. Größere Werte erzeugen breitere, weniger häufige Grenzen. tooltip.orespawn.biome.entries=Öffnen Sie die Biomeinträge dieser Dimension, um Gewichte, Klimagrenzen, Ähnlichkeitsregeln und Oberflächenmaterialien zu konfigurieren. -tooltip.orespawn.biome.dimension_materials=Konfigurieren Sie dimensionsweite Grundwasserleiterflüssigkeiten sowie Schnee- und Eisersatz. +tooltip.orespawn.biome.dimension_materials=Konfigurieren Sie Grundwasserleiterflüssigkeit, Schnee und Eis in jedem Biom des neu erzeugten Terrains dieser Dimension. Bestehende Chunks werden nie geändert. tooltip.orespawn.biome.geome_influences=Ordnen Sie installierte Biome relativen Geome-Gewichten zu, die von Sky Geology verwendet werden. tooltip.orespawn.biome.similar_biomes=Diese Ausgabe nur zulassen, wenn das ursprüngliche Biom mit einer dieser IDs übereinstimmt. Eine leere Liste erlaubt jedes Biom innerhalb der Klimagrenzen. tooltip.orespawn.biome.required_similar_biomes=Wie ähnliche Biome, aber diese Ausgabe ist deaktiviert, wenn eines der aufgelisteten Biome nicht installiert ist. @@ -100,7 +100,7 @@ tooltip.orespawn.biome.filler_block=Ersetzen Sie die Blöcke direkt unter der ob tooltip.orespawn.biome.underwater_block=Ersetzen Sie den freiliegenden Unterwasseroberflächenblock des Bioms. Nicht gesetzt behält den normalen Block bei. tooltip.orespawn.biome.ceiling_block=Ersetzen Sie den Deckenflächenblock des Bioms in Abmessungen, die Decken erzeugen. Nicht festgelegt behält den normalen Block bei. tooltip.orespawn.biome.filler_depth=Anzahl der Ebenen unter dem oberen Block, die den Füllblock verwenden. Gültiger Bereich: 0 bis 16. -tooltip.orespawn.material.default_fluid=Wählen Sie die normale Grundwasserleiterflüssigkeit, die unterhalb des Meeresspiegels verwendet wird. Nicht festgelegt behält die ursprüngliche Flüssigkeit von Minecraft bei. +tooltip.orespawn.material.default_fluid=Ersetzt die normale Grundwasserleiterflüssigkeit unter dem Meeresspiegel in jedem Biom dieser Dimension. Nicht festgelegt behält die ursprüngliche Generatorflüssigkeit bei. Bestehende Chunks bleiben unverändert; Flüssigkeiten mit ungewöhnlicher Beleuchtung können langsamer generieren. tooltip.orespawn.material.deep_aquifer_fluid=In Minecraft 1.10.2 nicht verfügbar; der gespeicherte Wert bleibt für spätere Versionen erhalten. tooltip.orespawn.material.deep_aquifer_y=In Minecraft 1.10.2 nicht verfügbar; der gespeicherte Wert bleibt für spätere Versionen erhalten. tooltip.orespawn.material.snow_block=Ersetzen Sie Vanilleschnee, der in dieser Dimension in der Nähe der Oberfläche platziert wird. Nicht gesetzt hält normalen Schnee. @@ -301,10 +301,12 @@ screen.orespawn.biome_palette=Biompalette screen.orespawn.choose_biome=Installiertes Biom auswählen screen.orespawn.biome_placement=Biomeinstellungen screen.orespawn.biome_references=Ähnliche Biome -screen.orespawn.dimension_materials=Weltmaterialien +screen.orespawn.dimension_materials=%s-Materialien button.orespawn.biomes_world_materials=Biome... button.orespawn.biome_palette_count=Biome: %s button.orespawn.dimension_materials=Weltmaterialien... +button.orespawn.dimension_materials_named=%s-Materialien... +label.orespawn.dimension_materials.scope=Gilt für jedes Biom im neu erzeugten Terrain von %s. button.orespawn.geome_influences=Geomeinflüsse... button.orespawn.add_biome=Biom hinzufügen... button.orespawn.similar_biomes=Ähnlich: %s @@ -486,7 +488,6 @@ button.orespawn.biome.reset_dimension=Dimension zurücksetzen button.orespawn.biome.confirm_reset_dimension=Dimensions-Reset bestätigen button.orespawn.biome.reset_all=Alle Biome zurücksetzen button.orespawn.biome.confirm_reset_all=Gesamten Reset bestätigen -warning.orespawn.biome.target_not_declared=%s wurde für diese Dimension von keinem Anbieter deklariert. value.orespawn.biome.status.user_replaced=In neuem Gelände ersetzt value.orespawn.biome.status.layered=Mehrere Platzierungsregeln value.orespawn.biome.status.disabled=Platzierung deaktiviert @@ -498,7 +499,6 @@ label.orespawn.biome.replaced_by=Ersatz in neuem Gelände: %s label.orespawn.biome.palette_owner_order=Besitzer: %s; wirksame Reihenfolge: %s label.orespawn.biome.surface_effective=Diese Regel liefert die wirksame Oberfläche. label.orespawn.biome.surface_overridden=Eine spätere aktive Regel überschreibt diese Oberfläche. -label.orespawn.biome.new_terrain_only=Nur neues Gelände label.orespawn.biome.placement_rules=Platzierungsregeln label.orespawn.biome.palette_entries=Platzierungseinträge: %s tooltip.orespawn.biome.no_retrogen=Biomersetzungen gelten nur beim Erzeugen neuen Geländes. Bestehende Chunks werden nie umgeschrieben. diff --git a/src/main/resources/assets/orespawn/lang/de_DE.lang b/src/main/resources/assets/orespawn/lang/de_DE.lang index 54d33399..ddf85f92 100644 --- a/src/main/resources/assets/orespawn/lang/de_DE.lang +++ b/src/main/resources/assets/orespawn/lang/de_DE.lang @@ -87,7 +87,7 @@ tooltip.orespawn.biome.mode=Augment mischt konfigurierte Biome mit dem ursprüng tooltip.orespawn.biome.scope=Wählen Sie aus, welche vorhandenen Biom-Namespaces ersetzt werden können: alle Biome, nur Minecraft oder ausgewählte Mod-Namespaces. tooltip.orespawn.biome.region_size=Steuert die horizontale Größe von Biom-Platzierungsregionen. Größere Werte erzeugen breitere, weniger häufige Grenzen. tooltip.orespawn.biome.entries=Öffnen Sie die Biomeinträge dieser Dimension, um Gewichte, Klimagrenzen, Ähnlichkeitsregeln und Oberflächenmaterialien zu konfigurieren. -tooltip.orespawn.biome.dimension_materials=Konfigurieren Sie dimensionsweite Grundwasserleiterflüssigkeiten sowie Schnee- und Eisersatz. +tooltip.orespawn.biome.dimension_materials=Konfigurieren Sie Grundwasserleiterflüssigkeit, Schnee und Eis in jedem Biom des neu erzeugten Terrains dieser Dimension. Bestehende Chunks werden nie geändert. tooltip.orespawn.biome.geome_influences=Ordnen Sie installierte Biome relativen Geome-Gewichten zu, die von Sky Geology verwendet werden. tooltip.orespawn.biome.similar_biomes=Diese Ausgabe nur zulassen, wenn das ursprüngliche Biom mit einer dieser IDs übereinstimmt. Eine leere Liste erlaubt jedes Biom innerhalb der Klimagrenzen. tooltip.orespawn.biome.required_similar_biomes=Wie ähnliche Biome, aber diese Ausgabe ist deaktiviert, wenn eines der aufgelisteten Biome nicht installiert ist. @@ -100,7 +100,7 @@ tooltip.orespawn.biome.filler_block=Ersetzen Sie die Blöcke direkt unter der ob tooltip.orespawn.biome.underwater_block=Ersetzen Sie den freiliegenden Unterwasseroberflächenblock des Bioms. Nicht gesetzt behält den normalen Block bei. tooltip.orespawn.biome.ceiling_block=Ersetzen Sie den Deckenflächenblock des Bioms in Abmessungen, die Decken erzeugen. Nicht festgelegt behält den normalen Block bei. tooltip.orespawn.biome.filler_depth=Anzahl der Ebenen unter dem oberen Block, die den Füllblock verwenden. Gültiger Bereich: 0 bis 16. -tooltip.orespawn.material.default_fluid=Wählen Sie die normale Grundwasserleiterflüssigkeit, die unterhalb des Meeresspiegels verwendet wird. Nicht festgelegt behält die ursprüngliche Flüssigkeit von Minecraft bei. +tooltip.orespawn.material.default_fluid=Ersetzt die normale Grundwasserleiterflüssigkeit unter dem Meeresspiegel in jedem Biom dieser Dimension. Nicht festgelegt behält die ursprüngliche Generatorflüssigkeit bei. Bestehende Chunks bleiben unverändert; Flüssigkeiten mit ungewöhnlicher Beleuchtung können langsamer generieren. tooltip.orespawn.material.deep_aquifer_fluid=In Minecraft 1.10.2 nicht verfügbar; der gespeicherte Wert bleibt für spätere Versionen erhalten. tooltip.orespawn.material.deep_aquifer_y=In Minecraft 1.10.2 nicht verfügbar; der gespeicherte Wert bleibt für spätere Versionen erhalten. tooltip.orespawn.material.snow_block=Ersetzen Sie Vanilleschnee, der in dieser Dimension in der Nähe der Oberfläche platziert wird. Nicht gesetzt hält normalen Schnee. @@ -301,10 +301,12 @@ screen.orespawn.biome_palette=Biompalette screen.orespawn.choose_biome=Installiertes Biom auswählen screen.orespawn.biome_placement=Biomeinstellungen screen.orespawn.biome_references=Ähnliche Biome -screen.orespawn.dimension_materials=Weltmaterialien +screen.orespawn.dimension_materials=%s-Materialien button.orespawn.biomes_world_materials=Biome... button.orespawn.biome_palette_count=Biome: %s button.orespawn.dimension_materials=Weltmaterialien... +button.orespawn.dimension_materials_named=%s-Materialien... +label.orespawn.dimension_materials.scope=Gilt für jedes Biom im neu erzeugten Terrain von %s. button.orespawn.geome_influences=Geomeinflüsse... button.orespawn.add_biome=Biom hinzufügen... button.orespawn.similar_biomes=Ähnlich: %s @@ -486,7 +488,6 @@ button.orespawn.biome.reset_dimension=Dimension zurücksetzen button.orespawn.biome.confirm_reset_dimension=Dimensions-Reset bestätigen button.orespawn.biome.reset_all=Alle Biome zurücksetzen button.orespawn.biome.confirm_reset_all=Gesamten Reset bestätigen -warning.orespawn.biome.target_not_declared=%s wurde für diese Dimension von keinem Anbieter deklariert. value.orespawn.biome.status.user_replaced=In neuem Gelände ersetzt value.orespawn.biome.status.layered=Mehrere Platzierungsregeln value.orespawn.biome.status.disabled=Platzierung deaktiviert @@ -498,7 +499,6 @@ label.orespawn.biome.replaced_by=Ersatz in neuem Gelände: %s label.orespawn.biome.palette_owner_order=Besitzer: %s; wirksame Reihenfolge: %s label.orespawn.biome.surface_effective=Diese Regel liefert die wirksame Oberfläche. label.orespawn.biome.surface_overridden=Eine spätere aktive Regel überschreibt diese Oberfläche. -label.orespawn.biome.new_terrain_only=Nur neues Gelände label.orespawn.biome.placement_rules=Platzierungsregeln label.orespawn.biome.palette_entries=Platzierungseinträge: %s tooltip.orespawn.biome.no_retrogen=Biomersetzungen gelten nur beim Erzeugen neuen Geländes. Bestehende Chunks werden nie umgeschrieben. diff --git a/src/main/resources/assets/orespawn/lang/en_CA.lang b/src/main/resources/assets/orespawn/lang/en_CA.lang index 53e5ce7f..d4da74c0 100644 --- a/src/main/resources/assets/orespawn/lang/en_CA.lang +++ b/src/main/resources/assets/orespawn/lang/en_CA.lang @@ -87,7 +87,7 @@ tooltip.orespawn.biome.mode=Augment mixes configured biomes with the original bi tooltip.orespawn.biome.scope=Choose which existing biome namespaces may be replaced: all biomes, Minecraft only, or selected mod namespaces. tooltip.orespawn.biome.region_size=Controls the horizontal size of biome-placement regions. Larger values create broader, less frequent boundaries. tooltip.orespawn.biome.entries=Open this dimension's biome entries to configure weights, climate limits, similarity rules, and surface materials. -tooltip.orespawn.biome.dimension_materials=Configure dimension-wide aquifer fluids plus snow and ice replacements. +tooltip.orespawn.biome.dimension_materials=Configure aquifer fluid, snow, and ice across every biome in newly generated terrain for this dimension. Existing chunks are never rewritten. tooltip.orespawn.biome.geome_influences=Map installed biomes to relative geome weights used by Sky geology. tooltip.orespawn.biome.similar_biomes=Allow this output only when the original biome matches one of these IDs. An empty list allows any biome within the climate limits. tooltip.orespawn.biome.required_similar_biomes=Like Similar Biomes, but this output is disabled if any listed biome is not installed. @@ -100,7 +100,7 @@ tooltip.orespawn.biome.filler_block=Replace the blocks immediately below the top tooltip.orespawn.biome.underwater_block=Replace the biome's exposed underwater surface block. Not set keeps the normal block. tooltip.orespawn.biome.ceiling_block=Replace the biome's ceiling surface block in dimensions that generate ceilings. Not set keeps the normal block. tooltip.orespawn.biome.filler_depth=Number of layers below the top block that use Filler Block. Valid range: 0 to 16. -tooltip.orespawn.material.default_fluid=Choose the normal aquifer fluid used below sea level. Not set keeps Minecraft's original fluid. +tooltip.orespawn.material.default_fluid=Replace the normal aquifer fluid below sea level across every biome in this dimension. Not set keeps the generator's original fluid. Existing chunks are unchanged; fluids with unusual lighting may generate more slowly. tooltip.orespawn.material.deep_aquifer_fluid=Unavailable in Minecraft 1.10.2; the stored value is retained for later versions. tooltip.orespawn.material.deep_aquifer_y=Unavailable in Minecraft 1.10.2; the stored value is retained for later versions. tooltip.orespawn.material.snow_block=Replace vanilla snow placed near the surface in this dimension. Not set keeps normal snow. @@ -301,10 +301,12 @@ screen.orespawn.biome_palette=Biome Palette screen.orespawn.choose_biome=Choose Installed Biome screen.orespawn.biome_placement=Biome Settings screen.orespawn.biome_references=Similar Biomes -screen.orespawn.dimension_materials=World Materials +screen.orespawn.dimension_materials=%s Materials button.orespawn.biomes_world_materials=Biomes... button.orespawn.biome_palette_count=Biomes: %s button.orespawn.dimension_materials=World Materials... +button.orespawn.dimension_materials_named=%s Materials... +label.orespawn.dimension_materials.scope=Applies to every biome in newly generated %s terrain. button.orespawn.geome_influences=Geome Influences... button.orespawn.add_biome=Add Biome... button.orespawn.similar_biomes=Similar: %s @@ -486,7 +488,6 @@ button.orespawn.biome.reset_dimension=Reset Dimension button.orespawn.biome.confirm_reset_dimension=Confirm Dimension Reset button.orespawn.biome.reset_all=Reset All Biomes button.orespawn.biome.confirm_reset_all=Confirm All-Biome Reset -warning.orespawn.biome.target_not_declared=%s is not declared by a provider for this dimension. value.orespawn.biome.status.user_replaced=Replaced in new terrain value.orespawn.biome.status.layered=Several placement rules value.orespawn.biome.status.disabled=Placement disabled @@ -498,7 +499,6 @@ label.orespawn.biome.replaced_by=New-terrain replacement: %s label.orespawn.biome.palette_owner_order=Owner: %s; effective order: %s label.orespawn.biome.surface_effective=This rule supplies the effective surface. label.orespawn.biome.surface_overridden=A later enabled rule overrides this surface. -label.orespawn.biome.new_terrain_only=New terrain only label.orespawn.biome.placement_rules=Placement rules label.orespawn.biome.palette_entries=Placement entries: %s tooltip.orespawn.biome.no_retrogen=Biome replacements apply only while new terrain is generated. Existing chunks are never rewritten. diff --git a/src/main/resources/assets/orespawn/lang/en_EN.lang b/src/main/resources/assets/orespawn/lang/en_EN.lang index 53e5ce7f..d4da74c0 100644 --- a/src/main/resources/assets/orespawn/lang/en_EN.lang +++ b/src/main/resources/assets/orespawn/lang/en_EN.lang @@ -87,7 +87,7 @@ tooltip.orespawn.biome.mode=Augment mixes configured biomes with the original bi tooltip.orespawn.biome.scope=Choose which existing biome namespaces may be replaced: all biomes, Minecraft only, or selected mod namespaces. tooltip.orespawn.biome.region_size=Controls the horizontal size of biome-placement regions. Larger values create broader, less frequent boundaries. tooltip.orespawn.biome.entries=Open this dimension's biome entries to configure weights, climate limits, similarity rules, and surface materials. -tooltip.orespawn.biome.dimension_materials=Configure dimension-wide aquifer fluids plus snow and ice replacements. +tooltip.orespawn.biome.dimension_materials=Configure aquifer fluid, snow, and ice across every biome in newly generated terrain for this dimension. Existing chunks are never rewritten. tooltip.orespawn.biome.geome_influences=Map installed biomes to relative geome weights used by Sky geology. tooltip.orespawn.biome.similar_biomes=Allow this output only when the original biome matches one of these IDs. An empty list allows any biome within the climate limits. tooltip.orespawn.biome.required_similar_biomes=Like Similar Biomes, but this output is disabled if any listed biome is not installed. @@ -100,7 +100,7 @@ tooltip.orespawn.biome.filler_block=Replace the blocks immediately below the top tooltip.orespawn.biome.underwater_block=Replace the biome's exposed underwater surface block. Not set keeps the normal block. tooltip.orespawn.biome.ceiling_block=Replace the biome's ceiling surface block in dimensions that generate ceilings. Not set keeps the normal block. tooltip.orespawn.biome.filler_depth=Number of layers below the top block that use Filler Block. Valid range: 0 to 16. -tooltip.orespawn.material.default_fluid=Choose the normal aquifer fluid used below sea level. Not set keeps Minecraft's original fluid. +tooltip.orespawn.material.default_fluid=Replace the normal aquifer fluid below sea level across every biome in this dimension. Not set keeps the generator's original fluid. Existing chunks are unchanged; fluids with unusual lighting may generate more slowly. tooltip.orespawn.material.deep_aquifer_fluid=Unavailable in Minecraft 1.10.2; the stored value is retained for later versions. tooltip.orespawn.material.deep_aquifer_y=Unavailable in Minecraft 1.10.2; the stored value is retained for later versions. tooltip.orespawn.material.snow_block=Replace vanilla snow placed near the surface in this dimension. Not set keeps normal snow. @@ -301,10 +301,12 @@ screen.orespawn.biome_palette=Biome Palette screen.orespawn.choose_biome=Choose Installed Biome screen.orespawn.biome_placement=Biome Settings screen.orespawn.biome_references=Similar Biomes -screen.orespawn.dimension_materials=World Materials +screen.orespawn.dimension_materials=%s Materials button.orespawn.biomes_world_materials=Biomes... button.orespawn.biome_palette_count=Biomes: %s button.orespawn.dimension_materials=World Materials... +button.orespawn.dimension_materials_named=%s Materials... +label.orespawn.dimension_materials.scope=Applies to every biome in newly generated %s terrain. button.orespawn.geome_influences=Geome Influences... button.orespawn.add_biome=Add Biome... button.orespawn.similar_biomes=Similar: %s @@ -486,7 +488,6 @@ button.orespawn.biome.reset_dimension=Reset Dimension button.orespawn.biome.confirm_reset_dimension=Confirm Dimension Reset button.orespawn.biome.reset_all=Reset All Biomes button.orespawn.biome.confirm_reset_all=Confirm All-Biome Reset -warning.orespawn.biome.target_not_declared=%s is not declared by a provider for this dimension. value.orespawn.biome.status.user_replaced=Replaced in new terrain value.orespawn.biome.status.layered=Several placement rules value.orespawn.biome.status.disabled=Placement disabled @@ -498,7 +499,6 @@ label.orespawn.biome.replaced_by=New-terrain replacement: %s label.orespawn.biome.palette_owner_order=Owner: %s; effective order: %s label.orespawn.biome.surface_effective=This rule supplies the effective surface. label.orespawn.biome.surface_overridden=A later enabled rule overrides this surface. -label.orespawn.biome.new_terrain_only=New terrain only label.orespawn.biome.placement_rules=Placement rules label.orespawn.biome.palette_entries=Placement entries: %s tooltip.orespawn.biome.no_retrogen=Biome replacements apply only while new terrain is generated. Existing chunks are never rewritten. diff --git a/src/main/resources/assets/orespawn/lang/en_GB.lang b/src/main/resources/assets/orespawn/lang/en_GB.lang index 53e5ce7f..d4da74c0 100644 --- a/src/main/resources/assets/orespawn/lang/en_GB.lang +++ b/src/main/resources/assets/orespawn/lang/en_GB.lang @@ -87,7 +87,7 @@ tooltip.orespawn.biome.mode=Augment mixes configured biomes with the original bi tooltip.orespawn.biome.scope=Choose which existing biome namespaces may be replaced: all biomes, Minecraft only, or selected mod namespaces. tooltip.orespawn.biome.region_size=Controls the horizontal size of biome-placement regions. Larger values create broader, less frequent boundaries. tooltip.orespawn.biome.entries=Open this dimension's biome entries to configure weights, climate limits, similarity rules, and surface materials. -tooltip.orespawn.biome.dimension_materials=Configure dimension-wide aquifer fluids plus snow and ice replacements. +tooltip.orespawn.biome.dimension_materials=Configure aquifer fluid, snow, and ice across every biome in newly generated terrain for this dimension. Existing chunks are never rewritten. tooltip.orespawn.biome.geome_influences=Map installed biomes to relative geome weights used by Sky geology. tooltip.orespawn.biome.similar_biomes=Allow this output only when the original biome matches one of these IDs. An empty list allows any biome within the climate limits. tooltip.orespawn.biome.required_similar_biomes=Like Similar Biomes, but this output is disabled if any listed biome is not installed. @@ -100,7 +100,7 @@ tooltip.orespawn.biome.filler_block=Replace the blocks immediately below the top tooltip.orespawn.biome.underwater_block=Replace the biome's exposed underwater surface block. Not set keeps the normal block. tooltip.orespawn.biome.ceiling_block=Replace the biome's ceiling surface block in dimensions that generate ceilings. Not set keeps the normal block. tooltip.orespawn.biome.filler_depth=Number of layers below the top block that use Filler Block. Valid range: 0 to 16. -tooltip.orespawn.material.default_fluid=Choose the normal aquifer fluid used below sea level. Not set keeps Minecraft's original fluid. +tooltip.orespawn.material.default_fluid=Replace the normal aquifer fluid below sea level across every biome in this dimension. Not set keeps the generator's original fluid. Existing chunks are unchanged; fluids with unusual lighting may generate more slowly. tooltip.orespawn.material.deep_aquifer_fluid=Unavailable in Minecraft 1.10.2; the stored value is retained for later versions. tooltip.orespawn.material.deep_aquifer_y=Unavailable in Minecraft 1.10.2; the stored value is retained for later versions. tooltip.orespawn.material.snow_block=Replace vanilla snow placed near the surface in this dimension. Not set keeps normal snow. @@ -301,10 +301,12 @@ screen.orespawn.biome_palette=Biome Palette screen.orespawn.choose_biome=Choose Installed Biome screen.orespawn.biome_placement=Biome Settings screen.orespawn.biome_references=Similar Biomes -screen.orespawn.dimension_materials=World Materials +screen.orespawn.dimension_materials=%s Materials button.orespawn.biomes_world_materials=Biomes... button.orespawn.biome_palette_count=Biomes: %s button.orespawn.dimension_materials=World Materials... +button.orespawn.dimension_materials_named=%s Materials... +label.orespawn.dimension_materials.scope=Applies to every biome in newly generated %s terrain. button.orespawn.geome_influences=Geome Influences... button.orespawn.add_biome=Add Biome... button.orespawn.similar_biomes=Similar: %s @@ -486,7 +488,6 @@ button.orespawn.biome.reset_dimension=Reset Dimension button.orespawn.biome.confirm_reset_dimension=Confirm Dimension Reset button.orespawn.biome.reset_all=Reset All Biomes button.orespawn.biome.confirm_reset_all=Confirm All-Biome Reset -warning.orespawn.biome.target_not_declared=%s is not declared by a provider for this dimension. value.orespawn.biome.status.user_replaced=Replaced in new terrain value.orespawn.biome.status.layered=Several placement rules value.orespawn.biome.status.disabled=Placement disabled @@ -498,7 +499,6 @@ label.orespawn.biome.replaced_by=New-terrain replacement: %s label.orespawn.biome.palette_owner_order=Owner: %s; effective order: %s label.orespawn.biome.surface_effective=This rule supplies the effective surface. label.orespawn.biome.surface_overridden=A later enabled rule overrides this surface. -label.orespawn.biome.new_terrain_only=New terrain only label.orespawn.biome.placement_rules=Placement rules label.orespawn.biome.palette_entries=Placement entries: %s tooltip.orespawn.biome.no_retrogen=Biome replacements apply only while new terrain is generated. Existing chunks are never rewritten. diff --git a/src/main/resources/assets/orespawn/lang/en_PT.lang b/src/main/resources/assets/orespawn/lang/en_PT.lang index 53e5ce7f..d4da74c0 100644 --- a/src/main/resources/assets/orespawn/lang/en_PT.lang +++ b/src/main/resources/assets/orespawn/lang/en_PT.lang @@ -87,7 +87,7 @@ tooltip.orespawn.biome.mode=Augment mixes configured biomes with the original bi tooltip.orespawn.biome.scope=Choose which existing biome namespaces may be replaced: all biomes, Minecraft only, or selected mod namespaces. tooltip.orespawn.biome.region_size=Controls the horizontal size of biome-placement regions. Larger values create broader, less frequent boundaries. tooltip.orespawn.biome.entries=Open this dimension's biome entries to configure weights, climate limits, similarity rules, and surface materials. -tooltip.orespawn.biome.dimension_materials=Configure dimension-wide aquifer fluids plus snow and ice replacements. +tooltip.orespawn.biome.dimension_materials=Configure aquifer fluid, snow, and ice across every biome in newly generated terrain for this dimension. Existing chunks are never rewritten. tooltip.orespawn.biome.geome_influences=Map installed biomes to relative geome weights used by Sky geology. tooltip.orespawn.biome.similar_biomes=Allow this output only when the original biome matches one of these IDs. An empty list allows any biome within the climate limits. tooltip.orespawn.biome.required_similar_biomes=Like Similar Biomes, but this output is disabled if any listed biome is not installed. @@ -100,7 +100,7 @@ tooltip.orespawn.biome.filler_block=Replace the blocks immediately below the top tooltip.orespawn.biome.underwater_block=Replace the biome's exposed underwater surface block. Not set keeps the normal block. tooltip.orespawn.biome.ceiling_block=Replace the biome's ceiling surface block in dimensions that generate ceilings. Not set keeps the normal block. tooltip.orespawn.biome.filler_depth=Number of layers below the top block that use Filler Block. Valid range: 0 to 16. -tooltip.orespawn.material.default_fluid=Choose the normal aquifer fluid used below sea level. Not set keeps Minecraft's original fluid. +tooltip.orespawn.material.default_fluid=Replace the normal aquifer fluid below sea level across every biome in this dimension. Not set keeps the generator's original fluid. Existing chunks are unchanged; fluids with unusual lighting may generate more slowly. tooltip.orespawn.material.deep_aquifer_fluid=Unavailable in Minecraft 1.10.2; the stored value is retained for later versions. tooltip.orespawn.material.deep_aquifer_y=Unavailable in Minecraft 1.10.2; the stored value is retained for later versions. tooltip.orespawn.material.snow_block=Replace vanilla snow placed near the surface in this dimension. Not set keeps normal snow. @@ -301,10 +301,12 @@ screen.orespawn.biome_palette=Biome Palette screen.orespawn.choose_biome=Choose Installed Biome screen.orespawn.biome_placement=Biome Settings screen.orespawn.biome_references=Similar Biomes -screen.orespawn.dimension_materials=World Materials +screen.orespawn.dimension_materials=%s Materials button.orespawn.biomes_world_materials=Biomes... button.orespawn.biome_palette_count=Biomes: %s button.orespawn.dimension_materials=World Materials... +button.orespawn.dimension_materials_named=%s Materials... +label.orespawn.dimension_materials.scope=Applies to every biome in newly generated %s terrain. button.orespawn.geome_influences=Geome Influences... button.orespawn.add_biome=Add Biome... button.orespawn.similar_biomes=Similar: %s @@ -486,7 +488,6 @@ button.orespawn.biome.reset_dimension=Reset Dimension button.orespawn.biome.confirm_reset_dimension=Confirm Dimension Reset button.orespawn.biome.reset_all=Reset All Biomes button.orespawn.biome.confirm_reset_all=Confirm All-Biome Reset -warning.orespawn.biome.target_not_declared=%s is not declared by a provider for this dimension. value.orespawn.biome.status.user_replaced=Replaced in new terrain value.orespawn.biome.status.layered=Several placement rules value.orespawn.biome.status.disabled=Placement disabled @@ -498,7 +499,6 @@ label.orespawn.biome.replaced_by=New-terrain replacement: %s label.orespawn.biome.palette_owner_order=Owner: %s; effective order: %s label.orespawn.biome.surface_effective=This rule supplies the effective surface. label.orespawn.biome.surface_overridden=A later enabled rule overrides this surface. -label.orespawn.biome.new_terrain_only=New terrain only label.orespawn.biome.placement_rules=Placement rules label.orespawn.biome.palette_entries=Placement entries: %s tooltip.orespawn.biome.no_retrogen=Biome replacements apply only while new terrain is generated. Existing chunks are never rewritten. diff --git a/src/main/resources/assets/orespawn/lang/en_US.lang b/src/main/resources/assets/orespawn/lang/en_US.lang index 3d4029c4..ea25544d 100644 --- a/src/main/resources/assets/orespawn/lang/en_US.lang +++ b/src/main/resources/assets/orespawn/lang/en_US.lang @@ -37,13 +37,13 @@ tooltip.orespawn.rock.depth_spread=How gradually the rock's depth preference fal tooltip.orespawn.rock.min_y=Lowest Y where this rock may replace terrain. Valid range: 0 to 255; it must not exceed Maximum Y. tooltip.orespawn.rock.max_y=Highest Y where this rock may replace terrain. Valid range: 0 to 255; it must not be below Minimum Y. tooltip.orespawn.rock.ore_replaceable=Allow OreSpawn-managed ores to replace this rock when it is selected as a host family. -tooltip.orespawn.biome.dimension=Select the dimension whose biome-placement and world-material settings are shown. +tooltip.orespawn.biome.dimension=Select the dimension whose biomes, placement rules, replacements, and world materials are shown. Show All remains limited to biomes associated with this dimension. tooltip.orespawn.biome.palette_enabled=Enable provider-supplied biome placement in this dimension. Turning it off preserves the saved biome entries. tooltip.orespawn.biome.mode=Augment mixes configured biomes with the original biome. Replace chooses only from eligible configured biomes. tooltip.orespawn.biome.scope=Choose which existing biome namespaces may be replaced: all biomes, Minecraft only, or selected mod namespaces. tooltip.orespawn.biome.region_size=Controls the horizontal size of biome-placement regions. Larger values create broader, less frequent boundaries. tooltip.orespawn.biome.entries=Open this dimension's biome entries to configure weights, climate limits, similarity rules, and surface materials. -tooltip.orespawn.biome.dimension_materials=Configure dimension-wide aquifer fluids plus snow and ice replacements. +tooltip.orespawn.biome.dimension_materials=Configure aquifer fluid, snow, and ice across every biome in newly generated terrain for this dimension. Existing chunks are never rewritten. tooltip.orespawn.biome.geome_influences=Map installed biomes to relative geome weights used by Sky geology. tooltip.orespawn.biome.similar_biomes=Allow this output only when the original biome matches one of these IDs. An empty list allows any biome within the climate limits. tooltip.orespawn.biome.required_similar_biomes=Like Similar Biomes, but this output is disabled if any listed biome is not installed. @@ -56,7 +56,7 @@ tooltip.orespawn.biome.filler_block=Replace the blocks immediately below the top tooltip.orespawn.biome.underwater_block=Replace the biome's exposed underwater surface block. Not set keeps the normal block. tooltip.orespawn.biome.ceiling_block=Replace the biome's ceiling surface block in dimensions that generate ceilings. Not set keeps the normal block. tooltip.orespawn.biome.filler_depth=Number of layers below the top block that use Filler Block. Valid range: 0 to 16. -tooltip.orespawn.material.default_fluid=Choose the normal aquifer fluid used below sea level. Not set keeps Minecraft's original fluid. +tooltip.orespawn.material.default_fluid=Replace the normal aquifer fluid below sea level across every biome in this dimension. Not set keeps the generator's original fluid. Existing chunks are unchanged; fluids with unusual lighting may generate more slowly. tooltip.orespawn.material.deep_aquifer_fluid=Unavailable in Minecraft 1.10.2; the stored value is retained for later versions. tooltip.orespawn.material.deep_aquifer_y=Unavailable in Minecraft 1.10.2; the stored value is retained for later versions. tooltip.orespawn.material.snow_block=Replace vanilla snow placed near the surface in this dimension. Not set keeps normal snow. @@ -301,10 +301,12 @@ screen.orespawn.biome_palette=Biome Palette screen.orespawn.choose_biome=Choose Installed Biome screen.orespawn.biome_placement=Biome Settings screen.orespawn.biome_references=Similar Biomes -screen.orespawn.dimension_materials=World Materials +screen.orespawn.dimension_materials=%s Materials button.orespawn.biomes_world_materials=Biomes... button.orespawn.biome_palette_count=Biomes: %s button.orespawn.dimension_materials=World Materials... +button.orespawn.dimension_materials_named=%s Materials... +label.orespawn.dimension_materials.scope=Applies to every biome in newly generated %s terrain. button.orespawn.geome_influences=Geome Influences... button.orespawn.add_biome=Add Biome... button.orespawn.similar_biomes=Similar: %s @@ -486,7 +488,6 @@ button.orespawn.biome.reset_dimension=Reset Dimension button.orespawn.biome.confirm_reset_dimension=Confirm Dimension Reset button.orespawn.biome.reset_all=Reset All Biomes button.orespawn.biome.confirm_reset_all=Confirm All-Biome Reset -warning.orespawn.biome.target_not_declared=%s is not declared by a provider for this dimension. value.orespawn.biome.status.user_replaced=Replaced in new terrain value.orespawn.biome.status.layered=Several placement rules value.orespawn.biome.status.disabled=Placement disabled @@ -498,7 +499,6 @@ label.orespawn.biome.replaced_by=New-terrain replacement: %s label.orespawn.biome.palette_owner_order=Owner: %s; effective order: %s label.orespawn.biome.surface_effective=This rule supplies the effective surface. label.orespawn.biome.surface_overridden=A later enabled rule overrides this surface. -label.orespawn.biome.new_terrain_only=New terrain only label.orespawn.biome.placement_rules=Placement rules label.orespawn.biome.palette_entries=Placement entries: %s tooltip.orespawn.biome.no_retrogen=Biome replacements apply only while new terrain is generated. Existing chunks are never rewritten. diff --git a/src/main/resources/assets/orespawn/lang/es_ES.lang b/src/main/resources/assets/orespawn/lang/es_ES.lang index cd7a02de..1954b63f 100644 --- a/src/main/resources/assets/orespawn/lang/es_ES.lang +++ b/src/main/resources/assets/orespawn/lang/es_ES.lang @@ -87,7 +87,7 @@ tooltip.orespawn.biome.mode=Augment mezcla los biomas configurados con el bioma tooltip.orespawn.biome.scope=Elija qué espacios de nombres de biomas existentes pueden reemplazarse: todos los biomas, solo Minecraft o espacios de nombres mod seleccionados. tooltip.orespawn.biome.region_size=Controla el tamaño horizontal de las regiones de ubicación de biomas. Los valores más grandes crean límites más amplios y menos frecuentes. tooltip.orespawn.biome.entries=Abra las entradas del bioma de esta dimensión para configurar pesos, límites climáticos, reglas de similitud y materiales de superficie. -tooltip.orespawn.biome.dimension_materials=Configure fluidos acuíferos en toda la dimensión además de reemplazos de nieve y hielo. +tooltip.orespawn.biome.dimension_materials=Configure el fluido acuífero, la nieve y el hielo en cada bioma del terreno nuevo de esta dimensión. Los chunks existentes nunca se reescriben. tooltip.orespawn.biome.geome_influences=Asigne biomas instalados a pesos relativos de geomas utilizados por Sky geology. tooltip.orespawn.biome.similar_biomes=Permita esta salida solo cuando el bioma original coincida con una de estas ID. Una lista vacía permite cualquier bioma dentro de los límites climáticos. tooltip.orespawn.biome.required_similar_biomes=Como biomas similares, pero esta salida se desactiva si algún bioma listado no está instalado. @@ -100,7 +100,7 @@ tooltip.orespawn.biome.filler_block=Reemplace los bloques inmediatamente debajo tooltip.orespawn.biome.underwater_block=Reemplaza el bloque de superficie submarina expuesto del bioma. No establecido mantiene el bloqueo normal. tooltip.orespawn.biome.ceiling_block=Reemplace el bloque de superficie del techo del bioma en dimensiones que generen techos. No establecido mantiene el bloque normal. tooltip.orespawn.biome.filler_depth=Número de capas debajo del bloque superior que utilizan el bloque de relleno. Rango válido: 0 a 16. -tooltip.orespawn.material.default_fluid=Elija el fluido normal del acuífero utilizado debajo del nivel del mar. No establecido mantiene el fluido original de Minecraft. +tooltip.orespawn.material.default_fluid=Reemplaza el fluido acuífero normal bajo el nivel del mar en cada bioma de esta dimensión. Sin definir conserva el fluido original del generador. Los chunks existentes no cambian; los fluidos con iluminación inusual pueden generarse más lentamente. tooltip.orespawn.material.deep_aquifer_fluid=No disponible en Minecraft 1.10.2; el valor guardado se conserva para versiones posteriores. tooltip.orespawn.material.deep_aquifer_y=No disponible en Minecraft 1.10.2; el valor guardado se conserva para versiones posteriores. tooltip.orespawn.material.snow_block=Reemplace la nieve vainilla colocada cerca de la superficie en esta dimensión. No fijado mantiene la nieve normal. @@ -301,10 +301,12 @@ screen.orespawn.biome_palette=Paleta de biomas screen.orespawn.choose_biome=Elegir bioma instalado screen.orespawn.biome_placement=Ajustes del bioma screen.orespawn.biome_references=Biomas similares -screen.orespawn.dimension_materials=Materiales del mundo +screen.orespawn.dimension_materials=Materiales de %s button.orespawn.biomes_world_materials=Biomas... button.orespawn.biome_palette_count=Biomas: %s button.orespawn.dimension_materials=Materiales del mundo... +button.orespawn.dimension_materials_named=Materiales de %s... +label.orespawn.dimension_materials.scope=Se aplica a cada bioma del terreno nuevo de %s. button.orespawn.geome_influences=Influencias de geomas... button.orespawn.add_biome=Añadir bioma... button.orespawn.similar_biomes=Similares: %s @@ -486,7 +488,6 @@ button.orespawn.biome.reset_dimension=Restablecer dimensión button.orespawn.biome.confirm_reset_dimension=Confirmar dimensión button.orespawn.biome.reset_all=Restablecer todos los biomas button.orespawn.biome.confirm_reset_all=Confirmar todo -warning.orespawn.biome.target_not_declared=%s no está declarado por un proveedor para esta dimensión. value.orespawn.biome.status.user_replaced=Reemplazado en terreno nuevo value.orespawn.biome.status.layered=Varias reglas de colocación value.orespawn.biome.status.disabled=Colocación desactivada @@ -498,7 +499,6 @@ label.orespawn.biome.replaced_by=Reemplazo en terreno nuevo: %s label.orespawn.biome.palette_owner_order=Propietario: %s; orden efectivo: %s label.orespawn.biome.surface_effective=Esta regla aporta la superficie efectiva. label.orespawn.biome.surface_overridden=Una regla activa posterior reemplaza esta superficie. -label.orespawn.biome.new_terrain_only=Solo terreno nuevo label.orespawn.biome.placement_rules=Reglas de colocación label.orespawn.biome.palette_entries=Entradas de colocación: %s tooltip.orespawn.biome.no_retrogen=Los reemplazos de bioma solo se aplican al generar terreno nuevo. Los chunks existentes nunca se reescriben. diff --git a/src/main/resources/assets/orespawn/lang/es_MX.lang b/src/main/resources/assets/orespawn/lang/es_MX.lang index cd7a02de..1954b63f 100644 --- a/src/main/resources/assets/orespawn/lang/es_MX.lang +++ b/src/main/resources/assets/orespawn/lang/es_MX.lang @@ -87,7 +87,7 @@ tooltip.orespawn.biome.mode=Augment mezcla los biomas configurados con el bioma tooltip.orespawn.biome.scope=Elija qué espacios de nombres de biomas existentes pueden reemplazarse: todos los biomas, solo Minecraft o espacios de nombres mod seleccionados. tooltip.orespawn.biome.region_size=Controla el tamaño horizontal de las regiones de ubicación de biomas. Los valores más grandes crean límites más amplios y menos frecuentes. tooltip.orespawn.biome.entries=Abra las entradas del bioma de esta dimensión para configurar pesos, límites climáticos, reglas de similitud y materiales de superficie. -tooltip.orespawn.biome.dimension_materials=Configure fluidos acuíferos en toda la dimensión además de reemplazos de nieve y hielo. +tooltip.orespawn.biome.dimension_materials=Configure el fluido acuífero, la nieve y el hielo en cada bioma del terreno nuevo de esta dimensión. Los chunks existentes nunca se reescriben. tooltip.orespawn.biome.geome_influences=Asigne biomas instalados a pesos relativos de geomas utilizados por Sky geology. tooltip.orespawn.biome.similar_biomes=Permita esta salida solo cuando el bioma original coincida con una de estas ID. Una lista vacía permite cualquier bioma dentro de los límites climáticos. tooltip.orespawn.biome.required_similar_biomes=Como biomas similares, pero esta salida se desactiva si algún bioma listado no está instalado. @@ -100,7 +100,7 @@ tooltip.orespawn.biome.filler_block=Reemplace los bloques inmediatamente debajo tooltip.orespawn.biome.underwater_block=Reemplaza el bloque de superficie submarina expuesto del bioma. No establecido mantiene el bloqueo normal. tooltip.orespawn.biome.ceiling_block=Reemplace el bloque de superficie del techo del bioma en dimensiones que generen techos. No establecido mantiene el bloque normal. tooltip.orespawn.biome.filler_depth=Número de capas debajo del bloque superior que utilizan el bloque de relleno. Rango válido: 0 a 16. -tooltip.orespawn.material.default_fluid=Elija el fluido normal del acuífero utilizado debajo del nivel del mar. No establecido mantiene el fluido original de Minecraft. +tooltip.orespawn.material.default_fluid=Reemplaza el fluido acuífero normal bajo el nivel del mar en cada bioma de esta dimensión. Sin definir conserva el fluido original del generador. Los chunks existentes no cambian; los fluidos con iluminación inusual pueden generarse más lentamente. tooltip.orespawn.material.deep_aquifer_fluid=No disponible en Minecraft 1.10.2; el valor guardado se conserva para versiones posteriores. tooltip.orespawn.material.deep_aquifer_y=No disponible en Minecraft 1.10.2; el valor guardado se conserva para versiones posteriores. tooltip.orespawn.material.snow_block=Reemplace la nieve vainilla colocada cerca de la superficie en esta dimensión. No fijado mantiene la nieve normal. @@ -301,10 +301,12 @@ screen.orespawn.biome_palette=Paleta de biomas screen.orespawn.choose_biome=Elegir bioma instalado screen.orespawn.biome_placement=Ajustes del bioma screen.orespawn.biome_references=Biomas similares -screen.orespawn.dimension_materials=Materiales del mundo +screen.orespawn.dimension_materials=Materiales de %s button.orespawn.biomes_world_materials=Biomas... button.orespawn.biome_palette_count=Biomas: %s button.orespawn.dimension_materials=Materiales del mundo... +button.orespawn.dimension_materials_named=Materiales de %s... +label.orespawn.dimension_materials.scope=Se aplica a cada bioma del terreno nuevo de %s. button.orespawn.geome_influences=Influencias de geomas... button.orespawn.add_biome=Añadir bioma... button.orespawn.similar_biomes=Similares: %s @@ -486,7 +488,6 @@ button.orespawn.biome.reset_dimension=Restablecer dimensión button.orespawn.biome.confirm_reset_dimension=Confirmar dimensión button.orespawn.biome.reset_all=Restablecer todos los biomas button.orespawn.biome.confirm_reset_all=Confirmar todo -warning.orespawn.biome.target_not_declared=%s no está declarado por un proveedor para esta dimensión. value.orespawn.biome.status.user_replaced=Reemplazado en terreno nuevo value.orespawn.biome.status.layered=Varias reglas de colocación value.orespawn.biome.status.disabled=Colocación desactivada @@ -498,7 +499,6 @@ label.orespawn.biome.replaced_by=Reemplazo en terreno nuevo: %s label.orespawn.biome.palette_owner_order=Propietario: %s; orden efectivo: %s label.orespawn.biome.surface_effective=Esta regla aporta la superficie efectiva. label.orespawn.biome.surface_overridden=Una regla activa posterior reemplaza esta superficie. -label.orespawn.biome.new_terrain_only=Solo terreno nuevo label.orespawn.biome.placement_rules=Reglas de colocación label.orespawn.biome.palette_entries=Entradas de colocación: %s tooltip.orespawn.biome.no_retrogen=Los reemplazos de bioma solo se aplican al generar terreno nuevo. Los chunks existentes nunca se reescriben. diff --git a/src/main/resources/assets/orespawn/lang/fr_CA.lang b/src/main/resources/assets/orespawn/lang/fr_CA.lang index f3ae4c90..72a6cb4d 100644 --- a/src/main/resources/assets/orespawn/lang/fr_CA.lang +++ b/src/main/resources/assets/orespawn/lang/fr_CA.lang @@ -87,7 +87,7 @@ tooltip.orespawn.biome.mode=Augment mélange les biomes configurés avec le biom tooltip.orespawn.biome.scope=Choisissez les espaces de noms de biomes existants qui peuvent être remplacés : tous les biomes, Minecraft uniquement ou les espaces de noms de mod sélectionnés. tooltip.orespawn.biome.region_size=Contrôle la taille horizontale des régions de placement de biome. Des valeurs plus élevées créent des limites plus larges et moins fréquentes. tooltip.orespawn.biome.entries=Ouvrez les entrées du biome de cette dimension pour configurer les poids, les limites climatiques, les règles de similarité et les matériaux de surface. -tooltip.orespawn.biome.dimension_materials=Configurez les fluides aquifères à l'échelle de la dimension ainsi que les remplacements de neige et de glace. +tooltip.orespawn.biome.dimension_materials=Configurez le fluide aquifère, la neige et la glace dans chaque biome du nouveau terrain de cette dimension. Les chunks existants ne sont jamais réécrits. tooltip.orespawn.biome.geome_influences=Mapper les biomes installés avec les poids géographiques relatifs utilisés par la géologie du ciel. tooltip.orespawn.biome.similar_biomes=Autoriser cette sortie uniquement lorsque le biome d'origine correspond à l'un de ces identifiants. Une liste vide autorise n'importe quel biome dans les limites climatiques. tooltip.orespawn.biome.required_similar_biomes=Comme Biomes similaires, mais cette sortie est désactivée si au moins un biome répertorié n'est pas installé. @@ -100,7 +100,7 @@ tooltip.orespawn.biome.filler_block=Remplacez les blocs immédiatement sous la s tooltip.orespawn.biome.underwater_block=Remplacez le bloc de surface sous-marine exposé du biome. Non défini conserve le bloc normal. tooltip.orespawn.biome.ceiling_block=Remplacez le bloc de surface de plafond du biome dans les dimensions qui génèrent des plafonds. Non défini conserve le bloc normal. tooltip.orespawn.biome.filler_depth=Nombre de calques sous le bloc supérieur qui utilisent le bloc de remplissage. Plage valide : 0 à 16. -tooltip.orespawn.material.default_fluid=Choisissez le fluide aquifère normal utilisé sous le niveau de la mer. Non défini, conserve le fluide d'origine de Minecraft. +tooltip.orespawn.material.default_fluid=Remplace le fluide aquifère normal sous le niveau de la mer dans chaque biome de cette dimension. Non défini conserve le fluide original du générateur. Les chunks existants restent inchangés; les fluides à éclairage inhabituel peuvent être générés plus lentement. tooltip.orespawn.material.deep_aquifer_fluid=Indisponible dans Minecraft 1.10.2 ; la valeur enregistrée est conservée pour les versions ultérieures. tooltip.orespawn.material.deep_aquifer_y=Indisponible dans Minecraft 1.10.2 ; la valeur enregistrée est conservée pour les versions ultérieures. tooltip.orespawn.material.snow_block=Remplacez la neige vanille placée près de la surface dans cette dimension. Non pris, garde la neige normale. @@ -301,10 +301,12 @@ screen.orespawn.biome_palette=Palette de biomes screen.orespawn.choose_biome=Choisir un biome installé screen.orespawn.biome_placement=Paramètres du biome screen.orespawn.biome_references=Biomes similaires -screen.orespawn.dimension_materials=Matériaux du monde +screen.orespawn.dimension_materials=Matériaux de %s button.orespawn.biomes_world_materials=Biomes... button.orespawn.biome_palette_count=Biomes : %s button.orespawn.dimension_materials=Matériaux du monde... +button.orespawn.dimension_materials_named=Matériaux de %s... +label.orespawn.dimension_materials.scope=S'applique à chaque biome du nouveau terrain de %s. button.orespawn.geome_influences=Influences des géomes... button.orespawn.add_biome=Ajouter un biome... button.orespawn.similar_biomes=Similaires : %s @@ -486,7 +488,6 @@ button.orespawn.biome.reset_dimension=Réinitialiser la dimension button.orespawn.biome.confirm_reset_dimension=Confirmer la dimension button.orespawn.biome.reset_all=Réinitialiser tous les biomes button.orespawn.biome.confirm_reset_all=Tout confirmer -warning.orespawn.biome.target_not_declared=%s n'est déclaré par aucun fournisseur pour cette dimension. value.orespawn.biome.status.user_replaced=Remplacé dans le nouveau terrain value.orespawn.biome.status.layered=Plusieurs règles de placement value.orespawn.biome.status.disabled=Placement désactivé @@ -498,7 +499,6 @@ label.orespawn.biome.replaced_by=Remplacement du nouveau terrain : %s label.orespawn.biome.palette_owner_order=Propriétaire : %s ; ordre effectif : %s label.orespawn.biome.surface_effective=Cette règle fournit la surface effective. label.orespawn.biome.surface_overridden=Une règle active ultérieure remplace cette surface. -label.orespawn.biome.new_terrain_only=Nouveau terrain uniquement label.orespawn.biome.placement_rules=Règles de placement label.orespawn.biome.palette_entries=Entrées de placement : %s tooltip.orespawn.biome.no_retrogen=Les remplacements de biome ne s'appliquent qu'à la génération de nouveau terrain. Les chunks existants ne sont jamais réécrits. diff --git a/src/main/resources/assets/orespawn/lang/fr_FR.lang b/src/main/resources/assets/orespawn/lang/fr_FR.lang index f3ae4c90..72a6cb4d 100644 --- a/src/main/resources/assets/orespawn/lang/fr_FR.lang +++ b/src/main/resources/assets/orespawn/lang/fr_FR.lang @@ -87,7 +87,7 @@ tooltip.orespawn.biome.mode=Augment mélange les biomes configurés avec le biom tooltip.orespawn.biome.scope=Choisissez les espaces de noms de biomes existants qui peuvent être remplacés : tous les biomes, Minecraft uniquement ou les espaces de noms de mod sélectionnés. tooltip.orespawn.biome.region_size=Contrôle la taille horizontale des régions de placement de biome. Des valeurs plus élevées créent des limites plus larges et moins fréquentes. tooltip.orespawn.biome.entries=Ouvrez les entrées du biome de cette dimension pour configurer les poids, les limites climatiques, les règles de similarité et les matériaux de surface. -tooltip.orespawn.biome.dimension_materials=Configurez les fluides aquifères à l'échelle de la dimension ainsi que les remplacements de neige et de glace. +tooltip.orespawn.biome.dimension_materials=Configurez le fluide aquifère, la neige et la glace dans chaque biome du nouveau terrain de cette dimension. Les chunks existants ne sont jamais réécrits. tooltip.orespawn.biome.geome_influences=Mapper les biomes installés avec les poids géographiques relatifs utilisés par la géologie du ciel. tooltip.orespawn.biome.similar_biomes=Autoriser cette sortie uniquement lorsque le biome d'origine correspond à l'un de ces identifiants. Une liste vide autorise n'importe quel biome dans les limites climatiques. tooltip.orespawn.biome.required_similar_biomes=Comme Biomes similaires, mais cette sortie est désactivée si au moins un biome répertorié n'est pas installé. @@ -100,7 +100,7 @@ tooltip.orespawn.biome.filler_block=Remplacez les blocs immédiatement sous la s tooltip.orespawn.biome.underwater_block=Remplacez le bloc de surface sous-marine exposé du biome. Non défini conserve le bloc normal. tooltip.orespawn.biome.ceiling_block=Remplacez le bloc de surface de plafond du biome dans les dimensions qui génèrent des plafonds. Non défini conserve le bloc normal. tooltip.orespawn.biome.filler_depth=Nombre de calques sous le bloc supérieur qui utilisent le bloc de remplissage. Plage valide : 0 à 16. -tooltip.orespawn.material.default_fluid=Choisissez le fluide aquifère normal utilisé sous le niveau de la mer. Non défini, conserve le fluide d'origine de Minecraft. +tooltip.orespawn.material.default_fluid=Remplace le fluide aquifère normal sous le niveau de la mer dans chaque biome de cette dimension. Non défini conserve le fluide original du générateur. Les chunks existants restent inchangés; les fluides à éclairage inhabituel peuvent être générés plus lentement. tooltip.orespawn.material.deep_aquifer_fluid=Indisponible dans Minecraft 1.10.2 ; la valeur enregistrée est conservée pour les versions ultérieures. tooltip.orespawn.material.deep_aquifer_y=Indisponible dans Minecraft 1.10.2 ; la valeur enregistrée est conservée pour les versions ultérieures. tooltip.orespawn.material.snow_block=Remplacez la neige vanille placée près de la surface dans cette dimension. Non pris, garde la neige normale. @@ -301,10 +301,12 @@ screen.orespawn.biome_palette=Palette de biomes screen.orespawn.choose_biome=Choisir un biome installé screen.orespawn.biome_placement=Paramètres du biome screen.orespawn.biome_references=Biomes similaires -screen.orespawn.dimension_materials=Matériaux du monde +screen.orespawn.dimension_materials=Matériaux de %s button.orespawn.biomes_world_materials=Biomes... button.orespawn.biome_palette_count=Biomes : %s button.orespawn.dimension_materials=Matériaux du monde... +button.orespawn.dimension_materials_named=Matériaux de %s... +label.orespawn.dimension_materials.scope=S'applique à chaque biome du nouveau terrain de %s. button.orespawn.geome_influences=Influences des géomes... button.orespawn.add_biome=Ajouter un biome... button.orespawn.similar_biomes=Similaires : %s @@ -486,7 +488,6 @@ button.orespawn.biome.reset_dimension=Réinitialiser la dimension button.orespawn.biome.confirm_reset_dimension=Confirmer la dimension button.orespawn.biome.reset_all=Réinitialiser tous les biomes button.orespawn.biome.confirm_reset_all=Tout confirmer -warning.orespawn.biome.target_not_declared=%s n'est déclaré par aucun fournisseur pour cette dimension. value.orespawn.biome.status.user_replaced=Remplacé dans le nouveau terrain value.orespawn.biome.status.layered=Plusieurs règles de placement value.orespawn.biome.status.disabled=Placement désactivé @@ -498,7 +499,6 @@ label.orespawn.biome.replaced_by=Remplacement du nouveau terrain : %s label.orespawn.biome.palette_owner_order=Propriétaire : %s ; ordre effectif : %s label.orespawn.biome.surface_effective=Cette règle fournit la surface effective. label.orespawn.biome.surface_overridden=Une règle active ultérieure remplace cette surface. -label.orespawn.biome.new_terrain_only=Nouveau terrain uniquement label.orespawn.biome.placement_rules=Règles de placement label.orespawn.biome.palette_entries=Entrées de placement : %s tooltip.orespawn.biome.no_retrogen=Les remplacements de biome ne s'appliquent qu'à la génération de nouveau terrain. Les chunks existants ne sont jamais réécrits. diff --git a/src/main/resources/assets/orespawn/lang/ja_JP.lang b/src/main/resources/assets/orespawn/lang/ja_JP.lang index 62ffa98f..1e5ac494 100644 --- a/src/main/resources/assets/orespawn/lang/ja_JP.lang +++ b/src/main/resources/assets/orespawn/lang/ja_JP.lang @@ -87,7 +87,7 @@ tooltip.orespawn.biome.mode=Augment は、構成されたバイオームを元 tooltip.orespawn.biome.scope=置換できる既存のバイオーム名前空間を選択します: すべてのバイオーム、Minecraft のみ、または選択した MOD 名前空間。 tooltip.orespawn.biome.region_size=バイオーム配置領域の水平サイズを制御します。値が大きいほど、より広く、頻度の少ない境界が作成されます。 tooltip.orespawn.biome.entries=このディメンションのバイオーム エントリを開いて、重み、気候制限、類似性ルール、および表面マテリアルを構成します。 -tooltip.orespawn.biome.dimension_materials=ディメンション全体の帯水層流体と雪と氷の代替を構成します。 +tooltip.orespawn.biome.dimension_materials=このディメンションで新しく生成される地形のすべてのバイオームに帯水層流体、雪、氷を設定します。既存チャンクは変更されません。 tooltip.orespawn.biome.geome_influences=導入済みバイオームを、Sky 地質で使用するジオムの相対ウェイトに対応付けます。 tooltip.orespawn.biome.similar_biomes=元のバイオームがこれらの ID のいずれかに一致する場合にのみ、この出力を許可します。空のリストでは、気候制限内の任意のバイオームが許可されます。 tooltip.orespawn.biome.required_similar_biomes=類似のバイオームと同様ですが、リストされたバイオームがインストールされていない場合、この出力は無効になります。 @@ -100,7 +100,7 @@ tooltip.orespawn.biome.filler_block=上面のすぐ下のブロックを置き tooltip.orespawn.biome.underwater_block=バイオームの露出した水中表面ブロックを交換します。設定しない場合は、通常のブロックが維持されます。 tooltip.orespawn.biome.ceiling_block=バイオームの天井面ブロックを天井を生成する寸法に置き換えます。設定しない場合は、通常のブロックが保持されます。 tooltip.orespawn.biome.filler_depth=フィラー ブロックを使用する最上位ブロックの下のレイヤーの数。有効な範囲: 0 ~ 16。 -tooltip.orespawn.material.default_fluid=海面以下で使用される通常の帯水層流体を選択します。設定しない場合は、Minecraft の元の流体が保持されます。 +tooltip.orespawn.material.default_fluid=このディメンションの全バイオームで海面下の通常の帯水層流体を置換します。未設定ではジェネレーター本来の流体を維持します。既存チャンクは変更されず、特殊な照明の流体は生成が遅くなる場合があります。 tooltip.orespawn.material.deep_aquifer_fluid=Minecraft 1.10.2 では使用できません。保存済みの値は新しいバージョン向けに保持されます。 tooltip.orespawn.material.deep_aquifer_y=Minecraft 1.10.2 では使用できません。保存済みの値は新しいバージョン向けに保持されます。 tooltip.orespawn.material.snow_block=このディメンションのサーフェス近くに配置されたバニラ スノーを置き換えます。設定しない場合は通常の雪のままです。 @@ -301,10 +301,12 @@ screen.orespawn.biome_palette=バイオームパレット screen.orespawn.choose_biome=導入済みバイオームを選択 screen.orespawn.biome_placement=バイオーム設定 screen.orespawn.biome_references=類似バイオーム -screen.orespawn.dimension_materials=ワールド素材 +screen.orespawn.dimension_materials=%s の素材 button.orespawn.biomes_world_materials=バイオーム... button.orespawn.biome_palette_count=バイオーム: %s button.orespawn.dimension_materials=ワールド素材... +button.orespawn.dimension_materials_named=%s の素材... +label.orespawn.dimension_materials.scope=新しく生成される %s 地形のすべてのバイオームに適用されます。 button.orespawn.geome_influences=ジオームの影響... button.orespawn.add_biome=バイオームを追加... button.orespawn.similar_biomes=類似: %s @@ -486,7 +488,6 @@ button.orespawn.biome.reset_dimension=ディメンションをリセット button.orespawn.biome.confirm_reset_dimension=ディメンションのリセットを確認 button.orespawn.biome.reset_all=全バイオームをリセット button.orespawn.biome.confirm_reset_all=全リセットを確認 -warning.orespawn.biome.target_not_declared=%s はこのディメンションのプロバイダーで宣言されていません。 value.orespawn.biome.status.user_replaced=新しい地形で置換 value.orespawn.biome.status.layered=複数の配置ルール value.orespawn.biome.status.disabled=配置は無効 @@ -498,7 +499,6 @@ label.orespawn.biome.replaced_by=新しい地形での置換: %s label.orespawn.biome.palette_owner_order=所有者: %s、適用順: %s label.orespawn.biome.surface_effective=このルールが有効な表面を提供します。 label.orespawn.biome.surface_overridden=後の有効なルールがこの表面を上書きします。 -label.orespawn.biome.new_terrain_only=新しい地形のみ label.orespawn.biome.placement_rules=配置ルール label.orespawn.biome.palette_entries=配置項目: %s tooltip.orespawn.biome.no_retrogen=バイオーム置換は新しい地形の生成時のみ適用されます。既存チャンクは書き換えません。 diff --git a/src/main/resources/assets/orespawn/lang/ko_KR.lang b/src/main/resources/assets/orespawn/lang/ko_KR.lang index 43f6e859..21b43106 100644 --- a/src/main/resources/assets/orespawn/lang/ko_KR.lang +++ b/src/main/resources/assets/orespawn/lang/ko_KR.lang @@ -87,7 +87,7 @@ tooltip.orespawn.biome.mode=Augment는 구성된 생물 군계와 원래 생물 tooltip.orespawn.biome.scope=교체할 수 있는 기존 생물 군계 네임스페이스를 선택합니다(모든 생물 군계, Minecraft만 또는 선택한 모드 네임스페이스). tooltip.orespawn.biome.region_size=생물 군계 배치 영역의 수평 크기를 제어합니다. 값이 클수록 더 넓고 덜 빈번한 경계가 생성됩니다. tooltip.orespawn.biome.entries=이 차원의 생물 군계 항목을 열어 가중치, 기후 한계, 유사성 규칙 및 표면 물질을 구성합니다. -tooltip.orespawn.biome.dimension_materials=차원 전체 대수층 유체와 눈 및 얼음 대체를 구성합니다. +tooltip.orespawn.biome.dimension_materials=이 차원에서 새로 생성되는 지형의 모든 생물 군계에 대수층 유체, 눈, 얼음을 설정합니다. 기존 청크는 변경되지 않습니다. tooltip.orespawn.biome.geome_influences=설치된 생물군계를 Sky 지질에서 사용하는 상대적 지옴 가중치에 연결합니다. tooltip.orespawn.biome.similar_biomes=원래 생물 군계가 이러한 ID 중 하나와 일치하는 경우에만 이 출력을 허용합니다. 빈 목록은 기후 한계 내의 모든 생물 군계를 허용합니다. tooltip.orespawn.biome.required_similar_biomes=유사한 생물 군계와 유사하지만 나열된 생물 군계가 설치되지 않은 경우 이 출력은 비활성화됩니다. @@ -100,7 +100,7 @@ tooltip.orespawn.biome.filler_block=상단 표면 바로 아래의 블록을 교 tooltip.orespawn.biome.underwater_block=생물 군계의 노출된 수중 표면 블록을 교체합니다. 설정되지 않으면 일반 블록이 유지됩니다. tooltip.orespawn.biome.ceiling_block=천장을 생성하는 차원에서 생물 군계의 천장 표면 블록을 교체합니다. 설정하지 않으면 일반 블록을 유지합니다. tooltip.orespawn.biome.filler_depth=채우기 블록을 사용하는 상단 블록 아래의 레이어 수입니다. 유효 범위: 0 ~ 16. -tooltip.orespawn.material.default_fluid=해수면 아래에서 사용되는 일반 대수층 유체를 선택합니다. 설정하지 않으면 Minecraft의 원래 유체가 유지됩니다. +tooltip.orespawn.material.default_fluid=이 차원의 모든 생물 군계에서 해수면 아래의 일반 대수층 유체를 교체합니다. 설정하지 않으면 생성기의 원래 유체를 유지합니다. 기존 청크는 변경되지 않으며 특수한 조명의 유체는 더 느리게 생성될 수 있습니다. tooltip.orespawn.material.deep_aquifer_fluid=Minecraft 1.10.2에서는 사용할 수 없습니다. 저장된 값은 이후 버전용으로 유지됩니다. tooltip.orespawn.material.deep_aquifer_y=Minecraft 1.10.2에서는 사용할 수 없습니다. 저장된 값은 이후 버전용으로 유지됩니다. tooltip.orespawn.material.snow_block=이 차원의 표면 근처에 배치된 바닐라 눈을 바꿉니다. 설정하지 않으면 정상적인 눈이 유지됩니다. @@ -301,10 +301,12 @@ screen.orespawn.biome_palette=생물군계 팔레트 screen.orespawn.choose_biome=설치된 생물군계 선택 screen.orespawn.biome_placement=생물군계 설정 screen.orespawn.biome_references=유사 생물군계 -screen.orespawn.dimension_materials=월드 재료 +screen.orespawn.dimension_materials=%s 재료 button.orespawn.biomes_world_materials=생물군계... button.orespawn.biome_palette_count=생물군계: %s button.orespawn.dimension_materials=월드 재료... +button.orespawn.dimension_materials_named=%s 재료... +label.orespawn.dimension_materials.scope=새로 생성되는 %s 지형의 모든 생물 군계에 적용됩니다. button.orespawn.geome_influences=지옴 영향... button.orespawn.add_biome=생물군계 추가... button.orespawn.similar_biomes=유사: %s @@ -486,7 +488,6 @@ button.orespawn.biome.reset_dimension=차원 초기화 button.orespawn.biome.confirm_reset_dimension=차원 초기화 확인 button.orespawn.biome.reset_all=모든 생물군계 초기화 button.orespawn.biome.confirm_reset_all=전체 초기화 확인 -warning.orespawn.biome.target_not_declared=%s은(는) 이 차원의 제공자가 선언하지 않았습니다. value.orespawn.biome.status.user_replaced=새 지형에서 교체됨 value.orespawn.biome.status.layered=여러 배치 규칙 value.orespawn.biome.status.disabled=배치 비활성화 @@ -498,7 +499,6 @@ label.orespawn.biome.replaced_by=새 지형 교체: %s label.orespawn.biome.palette_owner_order=소유자: %s; 적용 순서: %s label.orespawn.biome.surface_effective=이 규칙이 적용되는 표면을 제공합니다. label.orespawn.biome.surface_overridden=뒤의 활성 규칙이 이 표면을 덮어씁니다. -label.orespawn.biome.new_terrain_only=새 지형에만 적용 label.orespawn.biome.placement_rules=배치 규칙 label.orespawn.biome.palette_entries=배치 항목: %s tooltip.orespawn.biome.no_retrogen=생물군계 교체는 새 지형 생성 시에만 적용됩니다. 기존 청크는 다시 쓰지 않습니다. diff --git a/src/main/resources/assets/orespawn/lang/pt_BR.lang b/src/main/resources/assets/orespawn/lang/pt_BR.lang index 850b2993..5ae787d8 100644 --- a/src/main/resources/assets/orespawn/lang/pt_BR.lang +++ b/src/main/resources/assets/orespawn/lang/pt_BR.lang @@ -87,7 +87,7 @@ tooltip.orespawn.biome.mode=Aumentar mistura biomas configurados com o bioma ori tooltip.orespawn.biome.scope=Escolha quais namespaces de biomas existentes podem ser substituídos: todos os biomas, apenas Minecraft ou namespaces de mod selecionados. tooltip.orespawn.biome.region_size=Controla o tamanho horizontal das regiões de posicionamento de biomas. Valores maiores criam limites mais amplos e menos frequentes. tooltip.orespawn.biome.entries=Abra as entradas do bioma desta dimensão para configurar pesos, limites climáticos, regras de similaridade e materiais de superfície. -tooltip.orespawn.biome.dimension_materials=Configure fluidos de aqüíferos em toda a dimensão, além de substituições de neve e gelo. +tooltip.orespawn.biome.dimension_materials=Configure o fluido do aquífero, neve e gelo em cada bioma do novo terreno desta dimensão. Chunks existentes nunca são reescritos. tooltip.orespawn.biome.geome_influences=Mapeie os biomas instalados para os pesos relativos do geoma usados ​​pela geologia Sky. tooltip.orespawn.biome.similar_biomes=Permita esta saída somente quando o bioma original corresponder a um desses IDs. Uma lista vazia permite qualquer bioma dentro dos limites climáticos. tooltip.orespawn.biome.required_similar_biomes=Como Biomas Semelhantes, mas esta saída é desativada se algum bioma listado não estiver instalado. @@ -100,7 +100,7 @@ tooltip.orespawn.biome.filler_block=Substitua os blocos imediatamente abaixo da tooltip.orespawn.biome.underwater_block=Substitua o bloco de superfície subaquático exposto do bioma. Not set mantém o bloco normal. tooltip.orespawn.biome.ceiling_block=Substitua o bloco de superfície do teto do bioma em dimensões que gerem tetos. Não definido mantém o bloco normal. tooltip.orespawn.biome.filler_depth=Número de camadas abaixo do bloco superior que usam Bloco de Preenchimento. Faixa válida: 0 a 16. -tooltip.orespawn.material.default_fluid=Escolha o fluido aquífero normal usado abaixo do nível do mar. Não definido mantém o fluido original de Minecraft. +tooltip.orespawn.material.default_fluid=Substitui o fluido normal do aquífero abaixo do nível do mar em cada bioma desta dimensão. Não definido mantém o fluido original do gerador. Chunks existentes permanecem inalterados; fluidos com iluminação incomum podem gerar mais lentamente. tooltip.orespawn.material.deep_aquifer_fluid=Indisponível no Minecraft 1.10.2; o valor salvo é mantido para versões posteriores. tooltip.orespawn.material.deep_aquifer_y=Indisponível no Minecraft 1.10.2; o valor salvo é mantido para versões posteriores. tooltip.orespawn.material.snow_block=Substitua a neve baunilha colocada perto da superfície nesta dimensão. Não definido mantém a neve normal. @@ -301,10 +301,12 @@ screen.orespawn.biome_palette=Paleta de biomas screen.orespawn.choose_biome=Escolher bioma instalado screen.orespawn.biome_placement=Configurações do bioma screen.orespawn.biome_references=Biomas semelhantes -screen.orespawn.dimension_materials=Materiais do mundo +screen.orespawn.dimension_materials=Materiais de %s button.orespawn.biomes_world_materials=Biomas... button.orespawn.biome_palette_count=Biomas: %s button.orespawn.dimension_materials=Materiais do mundo... +button.orespawn.dimension_materials_named=Materiais de %s... +label.orespawn.dimension_materials.scope=Aplica-se a cada bioma no novo terreno de %s. button.orespawn.geome_influences=Influências de geomas... button.orespawn.add_biome=Adicionar bioma... button.orespawn.similar_biomes=Semelhantes: %s @@ -486,7 +488,6 @@ button.orespawn.biome.reset_dimension=Redefinir dimensão button.orespawn.biome.confirm_reset_dimension=Confirmar dimensão button.orespawn.biome.reset_all=Redefinir todos os biomas button.orespawn.biome.confirm_reset_all=Confirmar tudo -warning.orespawn.biome.target_not_declared=%s não foi declarado por um provedor para esta dimensão. value.orespawn.biome.status.user_replaced=Substituído em terreno novo value.orespawn.biome.status.layered=Várias regras de colocação value.orespawn.biome.status.disabled=Colocação desativada @@ -498,7 +499,6 @@ label.orespawn.biome.replaced_by=Substituição em terreno novo: %s label.orespawn.biome.palette_owner_order=Proprietário: %s; ordem efetiva: %s label.orespawn.biome.surface_effective=Esta regra fornece a superfície efetiva. label.orespawn.biome.surface_overridden=Uma regra ativa posterior substitui esta superfície. -label.orespawn.biome.new_terrain_only=Apenas terreno novo label.orespawn.biome.placement_rules=Regras de colocação label.orespawn.biome.palette_entries=Entradas de colocação: %s tooltip.orespawn.biome.no_retrogen=As substituições de bioma só se aplicam ao gerar terreno novo. Chunks existentes nunca são reescritos. diff --git a/src/main/resources/assets/orespawn/lang/ru_RU.lang b/src/main/resources/assets/orespawn/lang/ru_RU.lang index 526014b4..0cb3aaae 100644 --- a/src/main/resources/assets/orespawn/lang/ru_RU.lang +++ b/src/main/resources/assets/orespawn/lang/ru_RU.lang @@ -87,7 +87,7 @@ tooltip.orespawn.biome.mode=Augment смешивает настроенные б tooltip.orespawn.biome.scope=Выберите, какие существующие пространства имен биомов могут быть заменены: все биомы, только Minecraft или выбранные пространства имен модов. tooltip.orespawn.biome.region_size=Управляет горизонтальным размером областей размещения биомов. Большие значения создают более широкие и менее частые границы. tooltip.orespawn.biome.entries=Откройте записи биома этого измерения, чтобы настроить веса, климатические ограничения, правила сходства и материалы поверхности. -tooltip.orespawn.biome.dimension_materials=Настройте жидкости водоносного горизонта по всему измерению, а также заменители снега и льда. +tooltip.orespawn.biome.dimension_materials=Настройте жидкость водоносного горизонта, снег и лёд во всех биомах новой местности этого измерения. Существующие чанки не изменяются. tooltip.orespawn.biome.geome_influences=Сопоставьте установленные биомы с относительными весами геомов, используемыми Sky Geology. tooltip.orespawn.biome.similar_biomes=Разрешить этот вывод только в том случае, если исходный биом соответствует одному из этих идентификаторов. Пустой список позволяет использовать любой биом в пределах климатических ограничений. tooltip.orespawn.biome.required_similar_biomes=Как подобные биомы, но этот вывод отключен, если какой-либо указанный биом не установлен. @@ -100,7 +100,7 @@ tooltip.orespawn.biome.filler_block=Замените блоки сразу по tooltip.orespawn.biome.underwater_block=Замените блок обнаженной подводной поверхности биома. Если не установлено, сохраняется обычный блок. tooltip.orespawn.biome.ceiling_block=Замените блок поверхности потолка биома в размерах, которые создают потолки. Если не установлено, сохраняется обычный блок. tooltip.orespawn.biome.filler_depth=Количество слоев под верхним блоком, в которых используется блок-заполнитель. Допустимый диапазон: от 0 до 16. -tooltip.orespawn.material.default_fluid=Выберите обычную жидкость водоносного горизонта, используемую ниже уровня моря. Не установлено, сохраняет исходную жидкость Minecraft. +tooltip.orespawn.material.default_fluid=Заменяет обычную жидкость водоносного горизонта ниже уровня моря во всех биомах этого измерения. Если не задано, сохраняется исходная жидкость генератора. Существующие чанки не меняются; жидкости с необычным освещением могут генерироваться медленнее. tooltip.orespawn.material.deep_aquifer_fluid=Недоступно в Minecraft 1.10.2; сохранённое значение остаётся для более новых версий. tooltip.orespawn.material.deep_aquifer_y=Недоступно в Minecraft 1.10.2; сохранённое значение остаётся для более новых версий. tooltip.orespawn.material.snow_block=Замените ванильный снег, расположенный рядом с поверхностью в этом измерении. Не установлено, сохраняет обычный снег. @@ -301,10 +301,12 @@ screen.orespawn.biome_palette=Палитра биомов screen.orespawn.choose_biome=Выбрать установленный биом screen.orespawn.biome_placement=Настройки биома screen.orespawn.biome_references=Похожие биомы -screen.orespawn.dimension_materials=Материалы мира +screen.orespawn.dimension_materials=Материалы: %s button.orespawn.biomes_world_materials=Биомы... button.orespawn.biome_palette_count=Биомы: %s button.orespawn.dimension_materials=Материалы мира... +button.orespawn.dimension_materials_named=Материалы: %s... +label.orespawn.dimension_materials.scope=Применяется ко всем биомам новой местности %s. button.orespawn.geome_influences=Влияние геомов... button.orespawn.add_biome=Добавить биом... button.orespawn.similar_biomes=Похожие: %s @@ -486,7 +488,6 @@ button.orespawn.biome.reset_dimension=Сбросить измерение button.orespawn.biome.confirm_reset_dimension=Подтвердить измерение button.orespawn.biome.reset_all=Сбросить все биомы button.orespawn.biome.confirm_reset_all=Подтвердить всё -warning.orespawn.biome.target_not_declared=%s не объявлен поставщиком для этого измерения. value.orespawn.biome.status.user_replaced=Заменён в новой местности value.orespawn.biome.status.layered=Несколько правил размещения value.orespawn.biome.status.disabled=Размещение отключено @@ -498,7 +499,6 @@ label.orespawn.biome.replaced_by=Замена в новой местности: label.orespawn.biome.palette_owner_order=Владелец: %s; итоговый порядок: %s label.orespawn.biome.surface_effective=Эта правило задаёт итоговую поверхность. label.orespawn.biome.surface_overridden=Более позднее активное правило заменяет эту поверхность. -label.orespawn.biome.new_terrain_only=Только новая местность label.orespawn.biome.placement_rules=Правила размещения label.orespawn.biome.palette_entries=Записи размещения: %s tooltip.orespawn.biome.no_retrogen=Замены биомов применяются только при создании новой местности. Существующие чанки не переписываются. diff --git a/src/main/resources/assets/orespawn/lang/zh_CN.lang b/src/main/resources/assets/orespawn/lang/zh_CN.lang index 7e927b9b..83b2c1aa 100644 --- a/src/main/resources/assets/orespawn/lang/zh_CN.lang +++ b/src/main/resources/assets/orespawn/lang/zh_CN.lang @@ -87,7 +87,7 @@ tooltip.orespawn.biome.mode=增强将配置的生物群落与原始生物群落 tooltip.orespawn.biome.scope=选择可以替换哪些现有生物群落命名空间:所有生物群落、仅限 Minecraft 或选定的 mod 命名空间。 tooltip.orespawn.biome.region_size=控制生物群落放置区域的水平大小。值越大,边界越宽,频率越低。 tooltip.orespawn.biome.entries=打开该维度的生物群系条目以配置权重、气候限制、相似性规则和表面材料。 -tooltip.orespawn.biome.dimension_materials=配置维度范围内的含水层流体以及冰雪替代品。 +tooltip.orespawn.biome.dimension_materials=为此维度新生成地形中的每个生物群系配置含水层流体、雪和冰。现有区块永远不会被重写。 tooltip.orespawn.biome.geome_influences=将已安装的生物群系映射到 Sky 地质所使用的相对地质域权重。 tooltip.orespawn.biome.similar_biomes=仅当原始生物群系与这些 ID 之一匹配时才允许此输出。空列表允许气候限制内的任何生物群落。 tooltip.orespawn.biome.required_similar_biomes=与类似生物群落类似,但如果未安装任何列出的生物群落,则此输出将被禁用。 @@ -100,7 +100,7 @@ tooltip.orespawn.biome.filler_block=替换紧邻顶部表面下方的方块。 tooltip.orespawn.biome.underwater_block=替换生物群系暴露的水下表面块。未设置保留正常块。 tooltip.orespawn.biome.ceiling_block=将生物群落的天花板表面块替换为生成天花板的尺寸。未设置保留正常块。 tooltip.orespawn.biome.filler_depth=使用填充块的顶部块下方的层数。有效范围:0 到 16。 -tooltip.orespawn.material.default_fluid=选择海平面以下使用的正常含水层流体。未设置会保留 Minecraft 的原始流体。 +tooltip.orespawn.material.default_fluid=替换此维度每个生物群系中海平面以下的普通含水层流体。未设置时保留生成器原始流体。现有区块不变;具有特殊光照的流体生成速度可能较慢。 tooltip.orespawn.material.deep_aquifer_fluid=Minecraft 1.10.2 中不可用;已保存的值会保留供更高版本使用。 tooltip.orespawn.material.deep_aquifer_y=Minecraft 1.10.2 中不可用;已保存的值会保留供更高版本使用。 tooltip.orespawn.material.snow_block=替换放置在该维度表面附近的香草雪。未设置保留正常雪。 @@ -301,10 +301,12 @@ screen.orespawn.biome_palette=生物群系调色板 screen.orespawn.choose_biome=选择已安装的生物群系 screen.orespawn.biome_placement=生物群系设置 screen.orespawn.biome_references=相似生物群系 -screen.orespawn.dimension_materials=世界材料 +screen.orespawn.dimension_materials=%s 材料 button.orespawn.biomes_world_materials=生物群系... button.orespawn.biome_palette_count=生物群系: %s button.orespawn.dimension_materials=世界材料... +button.orespawn.dimension_materials_named=%s 材料... +label.orespawn.dimension_materials.scope=应用于新生成的 %s 地形中的每个生物群系。 button.orespawn.geome_influences=地质域影响... button.orespawn.add_biome=添加生物群系... button.orespawn.similar_biomes=相似: %s @@ -486,7 +488,6 @@ button.orespawn.biome.reset_dimension=重置维度 button.orespawn.biome.confirm_reset_dimension=确认维度重置 button.orespawn.biome.reset_all=重置全部生物群系 button.orespawn.biome.confirm_reset_all=确认全部重置 -warning.orespawn.biome.target_not_declared=%s 未由此维度的提供者声明。 value.orespawn.biome.status.user_replaced=已在新地形中替换 value.orespawn.biome.status.layered=多个放置规则 value.orespawn.biome.status.disabled=放置已禁用 @@ -498,7 +499,6 @@ label.orespawn.biome.replaced_by=新地形替换:%s label.orespawn.biome.palette_owner_order=所有者:%s;生效顺序:%s label.orespawn.biome.surface_effective=此规则提供最终生效的表面。 label.orespawn.biome.surface_overridden=后续启用的规则会覆盖此表面。 -label.orespawn.biome.new_terrain_only=仅限新地形 label.orespawn.biome.placement_rules=放置规则 label.orespawn.biome.palette_entries=放置项:%s tooltip.orespawn.biome.no_retrogen=生物群系替换仅在生成新地形时应用。现有区块绝不会被重写。 diff --git a/src/test/java/zone/moddev/mc/orespawn/client/BiomeDirectoryModelTest.java b/src/test/java/zone/moddev/mc/orespawn/client/BiomeDirectoryModelTest.java index 1d5987f6..5a3b23a7 100644 --- a/src/test/java/zone/moddev/mc/orespawn/client/BiomeDirectoryModelTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/client/BiomeDirectoryModelTest.java @@ -1,6 +1,7 @@ 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; @@ -8,12 +9,28 @@ import java.util.Collections; import java.util.LinkedHashSet; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import com.google.gson.JsonArray; import com.google.gson.JsonObject; +import zone.moddev.mc.orespawn.test.Forge12TestBootstrap; + +import net.minecraft.init.Biomes; class BiomeDirectoryModelTest { + @BeforeAll + static void registerVanilla() { + Forge12TestBootstrap.registerVanilla(); + } + + @Test + void derivesVanillaRoutineDimensionsFromForgeBiomeTypes() { + assertEquals("minecraft:overworld", BiomeDirectoryModel.routineDimension(Biomes.BEACH)); + assertEquals("minecraft:the_nether", BiomeDirectoryModel.routineDimension(Biomes.HELL)); + assertEquals("minecraft:the_end", BiomeDirectoryModel.routineDimension(Biomes.SKY)); + } + @Test void classifiesManagedLayeredDisabledReplacedUnmanagedAndMissingBiomes() { JsonObject profile = root(); @@ -77,6 +94,31 @@ void returnedCollectionsAndDefinitionsAreDetached() { .get("enabled").getAsBoolean()); } + @Test + void showAllFiltersRoutineBiomesByDimensionAndRetainsExplicitPlacements() { + JsonObject profile = root(); + addPalette(profile, "provider:nether", true, "example:declared_nether"); + profile.getAsJsonObject("biome_palettes").getAsJsonObject("provider:nether") + .addProperty("dimension", "minecraft:the_nether"); + BiomeDirectoryModel.Snapshot snapshot = BiomeDirectoryModel.assemble(profile, + new JsonObject(), Collections.singleton("provider"), Arrays.asList( + loaded("minecraft:beaches", "Beach", "minecraft:overworld"), + loaded("minecraft:hell", "Hell", "minecraft:the_nether"), + loaded("minecraft:sky", "The End", "minecraft:the_end"), + loaded("example:declared_nether", "Declared Nether", "minecraft:overworld")), + Arrays.asList("minecraft:overworld", "minecraft:the_nether", "minecraft:the_end")); + + assertTrue(contains(snapshot, "minecraft:overworld", "minecraft:beaches")); + assertFalse(contains(snapshot, "minecraft:overworld", "minecraft:hell")); + assertFalse(contains(snapshot, "minecraft:overworld", "minecraft:sky")); + assertFalse(contains(snapshot, "minecraft:overworld", "example:declared_nether")); + assertTrue(contains(snapshot, "minecraft:the_nether", "minecraft:hell")); + assertTrue(contains(snapshot, "minecraft:the_nether", "example:declared_nether")); + assertFalse(contains(snapshot, "minecraft:the_nether", "minecraft:beaches")); + assertTrue(contains(snapshot, "minecraft:the_end", "minecraft:sky")); + assertFalse(contains(snapshot, "minecraft:the_end", "minecraft:beaches")); + } + private static BiomeDirectoryModel.BiomeEntry find(BiomeDirectoryModel.Snapshot snapshot, String id) { return snapshot.entries.stream().filter(entry -> id.equals(entry.id)).findFirst().orElseThrow(AssertionError::new); } @@ -85,6 +127,14 @@ private static BiomeDirectoryModel.LoadedBiome loaded(String id, String name) { return new BiomeDirectoryModel.LoadedBiome(id, name, "Example", "1"); } + private static BiomeDirectoryModel.LoadedBiome loaded(String id, String name, String dimension) { + return new BiomeDirectoryModel.LoadedBiome(id, name, "Example", "1", dimension); + } + + private static boolean contains(BiomeDirectoryModel.Snapshot snapshot, String dimension, String id) { + return snapshot.entries(dimension, true).stream().anyMatch(entry -> id.equals(entry.id)); + } + private static JsonObject root() { JsonObject root = new JsonObject(); root.add("biome_palettes", new JsonObject()); 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 c17b9e39..32b7cccd 100644 --- a/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java @@ -8,6 +8,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.Arrays; import java.util.List; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -196,25 +197,72 @@ void materialGroupDefaultsPersistOnlyThroughMainDone() throws Exception { @Test void biomeDirectoryUsesTwoPanesAt426AndResponsivePagesAt320() throws Exception { + List dimensions = Arrays.asList( + "minecraft:overworld", "minecraft:the_nether", "minecraft:the_end"); + assertEquals("minecraft:the_nether", + BiomeWorldMaterialsScreen.nextDimension(dimensions, "minecraft:overworld")); + assertEquals("minecraft:the_end", + BiomeWorldMaterialsScreen.nextDimension(dimensions, "minecraft:the_nether")); + assertEquals("minecraft:overworld", + BiomeWorldMaterialsScreen.nextDimension(dimensions, "minecraft:the_end")); assertEquals(406, BiomeWorldMaterialsScreen.contentWidth(426)); assertEquals(174, BiomeWorldMaterialsScreen.leftPaneWidth(426)); - assertEquals(181, BiomeWorldMaterialsScreen.listHeight(265)); + assertEquals(161, BiomeWorldMaterialsScreen.listHeight(265)); + assertEquals(213, BiomeWorldMaterialsScreen.materialsButtonY(265)); assertEquals(300, BiomeWorldMaterialsScreen.contentWidth(320)); assertEquals(132, BiomeWorldMaterialsScreen.leftPaneWidth(320)); - assertEquals(156, BiomeWorldMaterialsScreen.listHeight(240)); + assertEquals(136, BiomeWorldMaterialsScreen.listHeight(240)); + assertEquals(188, BiomeWorldMaterialsScreen.materialsButtonY(240)); + assertEquals(24, BiomeWorldMaterialsScreen.detailTop(false), + "the two-pane detail must align with the top controls"); + assertEquals(34, BiomeWorldMaterialsScreen.detailTop(true), + "the compact detail must remain below its Back button"); + assertEquals(95, BiomeWorldMaterialsScreen.detailRuleTop(false, false)); + assertEquals(119, BiomeWorldMaterialsScreen.detailRuleTop(false, true)); + assertEquals(139, BiomeWorldMaterialsScreen.detailRuleTop(false, true, 2), + "a two-line replacement validation error must reserve space above Placement Rules"); + assertEquals(107, BiomeWorldMaterialsScreen.detailMessageTop(false, true)); + assertTrue(BiomeWorldMaterialsScreen.detailRuleTop(false, true, 2) - 10 + >= BiomeWorldMaterialsScreen.detailMessageTop(false, true) + 20, + "the Placement Rules label must clear both warning lines"); + assertEquals(189, BiomeWorldMaterialsScreen.controlsTop(265, + BiomeWorldMaterialsScreen.detailRuleTop(false, true, 2)), + "validation errors must reduce the rule list rather than push footer controls down"); + assertTrue(BiomeWorldMaterialsScreen.controlsTop(265, + BiomeWorldMaterialsScreen.detailRuleTop(false, true, 2)) + 44 < 265 - 28, + "detail controls must remain above Done at the tested window height"); + assertTrue(BiomeWorldMaterialsScreen.detailRuleTop(false, false) - 10 + >= BiomeWorldMaterialsScreen.detailTop(false) + 59, + "the Placement Rules label must clear the replacement button"); String source = screenSource("BiomeWorldMaterialsScreen.java"); assertTrue(source.contains("TWO_PANE_MINIMUM = 400")); assertTrue(source.contains("new CompactScrollList(this, left, CONTENT_TOP")); assertTrue(source.contains("if (compact) compactDetail = true")); assertTrue(source.contains("button.orespawn.show_all")); assertTrue(source.contains("button.orespawn.biome.hide_routine")); + assertTrue(source.contains("button.orespawn.dimension_materials_named")); + assertTrue(source.contains("materialsButtonY(height), leftWidth, 20"), + "dimension materials belong to the dimension/list pane"); + assertTrue(source.contains("tooltip.orespawn.biome.dimension")); + assertTrue(source.contains("value.orespawn.dimension.the_nether")); + assertTrue(source.contains("\"< \" + dimensionName(dimension).getUnformattedText() + \" >\"")); assertTrue(source.contains("button.orespawn.biome.replace_new_terrain")); assertTrue(source.contains("button.orespawn.biome.leave_original")); assertTrue(source.contains("tooltip.orespawn.biome.no_retrogen")); + assertTrue(source.contains("detailMessageLines")); + assertFalse(source.contains("warning.orespawn.biome.target_not_declared"), + "a working replacement must not display a provider-ownership warning"); + assertFalse(source.contains("label.orespawn.biome.new_terrain_only"), + "the replacement status already explains that the change affects new terrain"); + assertFalse(source.contains("height - 39, 0xFF5555"), + "replacement feedback must not be drawn behind the footer controls"); assertTrue(source.contains("new BiomePlacementScreen")); assertTrue(source.contains("new BiomePaletteScreen")); assertTrue(source.contains("new DimensionMaterialsScreen")); assertTrue(source.contains("new GeomeBiomeScreen")); + String materials = screenSource("DimensionMaterialsScreen.java"); + assertTrue(materials.contains("label.orespawn.dimension_materials.scope")); + assertTrue(materials.contains("BiomeWorldMaterialsScreen.dimensionName(dimension)")); } @Test diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/AquiferMaterialSubstitutionTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/AquiferMaterialSubstitutionTest.java new file mode 100644 index 00000000..be7857e0 --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/AquiferMaterialSubstitutionTest.java @@ -0,0 +1,162 @@ +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.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.Proxy; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.BitSet; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import net.minecraft.init.Blocks; +import net.minecraft.world.chunk.ChunkPrimer; +import net.minecraft.world.chunk.IChunkGenerator; +import net.minecraft.world.chunk.storage.ExtendedBlockStorage; +import zone.moddev.mc.orespawn.test.Forge12TestBootstrap; + +class AquiferMaterialSubstitutionTest { + @BeforeAll + static void bootstrapMinecraftRegistries() { + Forge12TestBootstrap.registerVanilla(); + } + + @Test + void capturesOnlyExactNativeFluidBelowSeaLevel() { + ChunkPrimer primer = new ChunkPrimer(); + primer.setBlockState(1, 10, 2, Blocks.WATER.getDefaultState()); + primer.setBlockState(3, 63, 4, Blocks.WATER.getDefaultState()); + primer.setBlockState(5, 64, 6, Blocks.WATER.getDefaultState()); + primer.setBlockState(7, 20, 8, Blocks.LAVA.getDefaultState()); + + BitSet mask = AquiferMaterialSubstitution.captureMask( + primer, Blocks.WATER.getDefaultState(), 64); + + assertEquals(2, mask.cardinality()); + assertTrue(mask.get(AquiferMaterialSubstitution.index(1, 10, 2))); + assertTrue(mask.get(AquiferMaterialSubstitution.index(3, 63, 4))); + assertFalse(mask.get(AquiferMaterialSubstitution.index(5, 64, 6))); + assertFalse(mask.get(AquiferMaterialSubstitution.index(7, 20, 8))); + } + + @Test + void primerIndexRoundTripsEveryLocalCoordinate() { + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + for (int y : new int[] { 0, 1, 63, 127, 255 }) { + int index = AquiferMaterialSubstitution.index(x, y, z); + assertEquals(x, AquiferMaterialSubstitution.localX(index)); + assertEquals(y, AquiferMaterialSubstitution.localY(index)); + assertEquals(z, AquiferMaterialSubstitution.localZ(index)); + } + } + } + } + + @Test + void pendingMasksAreIdentityScopedAndConsumedOnceAtNegativeCoordinates() { + AquiferMaterialSubstitution.PendingMasks masks = + new AquiferMaterialSubstitution.PendingMasks(); + IChunkGenerator overworld = generator(); + IChunkGenerator otherDimension = generator(); + BitSet bits = new BitSet(); + bits.set(AquiferMaterialSubstitution.index(2, 30, 9)); + AquiferMaterialSubstitution.Pending pending = new AquiferMaterialSubstitution.Pending( + Blocks.WATER.getDefaultState(), Blocks.FLOWING_WATER.getDefaultState(), bits); + + masks.put(overworld, -12, -34, pending); + assertNull(masks.take(otherDimension, -12, -34)); + assertNotNull(masks.take(overworld, -12, -34)); + assertNull(masks.take(overworld, -12, -34)); + assertEquals(0, masks.size()); + } + + @Test + void pendingMasksCanBeRemovedPerDimensionOrClearedAtShutdown() { + AquiferMaterialSubstitution.PendingMasks masks = + new AquiferMaterialSubstitution.PendingMasks(); + IChunkGenerator overworld = generator(); + IChunkGenerator nether = generator(); + AquiferMaterialSubstitution.Pending pending = new AquiferMaterialSubstitution.Pending( + Blocks.WATER.getDefaultState(), Blocks.FLOWING_WATER.getDefaultState(), new BitSet()); + masks.put(overworld, 0, 0, pending); + masks.put(nether, 0, 0, pending); + + masks.remove(overworld); + assertNull(masks.take(overworld, 0, 0)); + assertNotNull(masks.take(nether, 0, 0)); + masks.put(nether, 1, 1, pending); + masks.clear(); + assertEquals(0, masks.size()); + } + + @Test + void lightingCompatibilitySelectsTheSafeFastPath() { + assertTrue(AquiferMaterialSubstitution.lightCompatible( + Blocks.WATER.getDefaultState(), Blocks.FLOWING_WATER.getDefaultState())); + assertFalse(AquiferMaterialSubstitution.lightCompatible( + Blocks.WATER.getDefaultState(), Blocks.LAVA.getDefaultState())); + } + + @Test + void substitutionTouchesOnlyRecordedCellsThatStillContainNativeFluid() { + ExtendedBlockStorage[] sections = new ExtendedBlockStorage[16]; + sections[1] = new ExtendedBlockStorage(16, true); + sections[1].set(1, 4, 2, Blocks.WATER.getDefaultState()); + sections[1].set(3, 5, 4, Blocks.LAVA.getDefaultState()); + sections[1].set(5, 6, 6, Blocks.WATER.getDefaultState()); + + BitSet bits = new BitSet(); + bits.set(AquiferMaterialSubstitution.index(1, 20, 2)); + bits.set(AquiferMaterialSubstitution.index(3, 21, 4)); + AquiferMaterialSubstitution.Pending pending = new AquiferMaterialSubstitution.Pending( + Blocks.WATER.getDefaultState(), Blocks.FLOWING_WATER.getDefaultState(), bits); + + assertEquals(1, AquiferMaterialSubstitution.applyToSections(pending, sections)); + assertEquals(Blocks.FLOWING_WATER.getDefaultState(), sections[1].get(1, 4, 2)); + assertEquals(Blocks.LAVA.getDefaultState(), sections[1].get(3, 5, 4), + "a recorded cell changed by caves or another pass must be preserved"); + assertEquals(Blocks.WATER.getDefaultState(), sections[1].get(5, 6, 6), + "a later lake or spring outside the mask must be preserved"); + } + + @Test + void terrainHookRunsBeforeEveryOtherOreSpawnLocalModification() throws Exception { + String source = new String(Files.readAllBytes(Paths.get("src", "main", "java", "zone", + "moddev", "mc", "orespawn", "worldgen", "OreSpawnWorldGenerator.java")), + StandardCharsets.UTF_8); + assertTrue(source.contains("captureNativeAquifer(ChunkGeneratorEvent.ReplaceBiomeBlocks event)")); + assertTrue(source.indexOf("AquiferMaterialSubstitution.apply(world, chunk)") + < source.indexOf("StoneReplacer.FEATURE.generate(world, chunk, random)")); + assertTrue(source.contains("priority = EventPriority.LOWEST")); + String substitution = new String(Files.readAllBytes(Paths.get("src", "main", "java", "zone", + "moddev", "mc", "orespawn", "worldgen", "AquiferMaterialSubstitution.java")), + StandardCharsets.UTF_8); + assertTrue(substitution.contains("section.set(x, y & 15, z, pending.selectedFluid)")); + assertFalse(substitution.contains("world.setBlockState")); + assertFalse(substitution.contains("scheduleUpdate")); + assertTrue(substitution.indexOf("generator.oceanBlock = nativeFluid") + < substitution.indexOf("if (lightCompatible(nativeFluid, selected))"), + "the fast path must leave the generator on its native fluid"); + assertTrue(substitution.contains("entry.getKey().oceanBlock = entry.getValue()"), + "shutdown must restore compatibility-path generator state"); + } + + private static IChunkGenerator generator() { + return (IChunkGenerator) Proxy.newProxyInstance( + AquiferMaterialSubstitutionTest.class.getClassLoader(), + new Class[] { IChunkGenerator.class }, + (proxy, method, arguments) -> { + Class type = method.getReturnType(); + if (type == boolean.class) return false; + if (type == int.class) return 0; + return null; + }); + } +} diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/VanillaSpringCompatibilityTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/VanillaSpringCompatibilityTest.java index 578e6f1b..1f4e13df 100644 --- a/src/test/java/zone/moddev/mc/orespawn/worldgen/VanillaSpringCompatibilityTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/VanillaSpringCompatibilityTest.java @@ -49,6 +49,15 @@ void compatibilityDoesNotRewriteStaticBiomeFeatureGraphs() throws Exception { assertFalse(source.contains("BiomeDecorator")); } + @Test + void independentChunkGeneratorsKeepTheirOwnSpringPass() throws Exception { + String source = source(); + assertTrue(source.contains("instanceof ChunkProviderOverworld")); + assertTrue(source.indexOf("instanceof ChunkProviderOverworld") + < source.indexOf("event.getRand()"), + "an unsupported generator must be rejected before OreSpawn consumes its random stream"); + } + @Test void springChecksEveryNeighbourIsLoadedBeforeReadingItsState() throws Exception { String source = source(); diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/WorldgenBenchmarkTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/WorldgenBenchmarkTest.java index d772b36f..e933d46c 100644 --- a/src/test/java/zone/moddev/mc/orespawn/worldgen/WorldgenBenchmarkTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/WorldgenBenchmarkTest.java @@ -1,6 +1,7 @@ package zone.moddev.mc.orespawn.worldgen; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertThrows; import java.nio.charset.StandardCharsets; @@ -32,6 +33,20 @@ void vanillaControlBypassesEveryForge112GenerationEntryPoint() throws Exception "terrain, springs, ores, ore filtering, and IWorldGenerator must all be bypassed"); } + @Test + void integrationBenchmarkCanProveNormalizedBlockAndBiomeParity() throws Exception { + String source = new String(Files.readAllBytes(Paths.get("src", "main", "java", "zone", + "moddev", "mc", "orespawn", "worldgen", "WorldgenBenchmark.java")), + StandardCharsets.UTF_8); + assertTrue(source.contains("orespawn.worldgenBenchmarkNormalizeBlock")); + assertTrue(source.contains("orespawn.worldgenBenchmarkNormalizeBiome")); + assertTrue(source.contains("ORESPAWN_BENCHMARK_HASH")); + assertTrue(source.contains("orespawn.worldgenBenchmarkBiome")); + assertTrue(source.indexOf("ORESPAWN_BENCHMARK summary") + < source.indexOf("auditNormalizedHashes(level"), + "hashing must happen outside the measured generation interval"); + } + private static int occurrences(String value, String needle) { int result = 0; for (int index = 0; (index = value.indexOf(needle, index)) >= 0; index += needle.length()) { From 11ad3ca12ab427bb8b83c22af0d2c1c22b162cd0 Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Wed, 23 Sep 2026 15:47:02 +0100 Subject: [PATCH 28/31] Repair modded fluid selection labels --- .../mc/orespawn/client/DialogTexts.java | 51 ++++++++++++++++++- .../client/FluidDepositListScreen.java | 6 +-- .../orespawn/client/GeologyEditorSession.java | 5 +- .../client/OreSpawnWorldSettingsScreen.java | 2 +- .../client/GeologyEditorSessionTest.java | 10 ++++ .../client/LegacyBlockTranslationTest.java | 16 ++++++ 6 files changed, 83 insertions(+), 7 deletions(-) diff --git a/src/main/java/zone/moddev/mc/orespawn/client/DialogTexts.java b/src/main/java/zone/moddev/mc/orespawn/client/DialogTexts.java index 595364bb..b6d29424 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/DialogTexts.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/DialogTexts.java @@ -1,9 +1,14 @@ package zone.moddev.mc.orespawn.client; +import java.util.function.Predicate; + import net.minecraft.block.Block; +import net.minecraft.client.resources.I18n; import net.minecraft.util.text.ITextComponent; import net.minecraft.util.text.TextComponentString; import net.minecraft.util.text.TextComponentTranslation; +import net.minecraftforge.fluids.Fluid; +import net.minecraftforge.fluids.FluidRegistry; /** Shared translated labels for Minecraft 1.10 screens. */ final class DialogTexts { @@ -16,7 +21,51 @@ static TextComponentTranslation blockName(Block block) { } static ITextComponent blockName(Block block, String fallback) { - return block == null ? new TextComponentString(fallback) : blockName(block); + if (block == null) return new TextComponentString(fallback); + return blockName(block, fallback, I18n::hasKey); + } + + static ITextComponent blockName(Block block, String fallback, + Predicate translationExists) { + if (block == null) return new TextComponentString(fallback); + String key = block.getUnlocalizedName() + ".name"; + if (!"tile.null.name".equals(key) && translationExists.test(key)) { + return new TextComponentTranslation(key); + } + Fluid fluid = FluidRegistry.lookupFluidForBlock(block); + String fluidKey = fluid == null ? null : fluid.getUnlocalizedName(); + if (fluidKey != null && translationExists.test(fluidKey)) { + return new TextComponentTranslation(fluidKey); + } + return new TextComponentString(friendlyRegistryName(fallback)); + } + + private static String friendlyRegistryName(String registryId) { + if (registryId == null || registryId.trim().isEmpty()) return "Unknown"; + String path = registryId.trim(); + int colon = path.indexOf(':'); + if (colon >= 0 && colon + 1 < path.length()) path = path.substring(colon + 1); + int slash = path.lastIndexOf('/'); + if (slash >= 0 && slash + 1 < path.length()) path = path.substring(slash + 1); + path = path.replace('_', ' ').replace('-', ' ').replace('.', ' ').trim(); + if (path.isEmpty()) return registryId; + + StringBuilder friendly = new StringBuilder(path.length()); + boolean capitalize = true; + for (int i = 0; i < path.length(); i++) { + char current = path.charAt(i); + if (Character.isWhitespace(current)) { + if (friendly.length() > 0 && friendly.charAt(friendly.length() - 1) != ' ') { + friendly.append(' '); + } + capitalize = true; + } else { + friendly.append(capitalize ? Character.toUpperCase(current) + : Character.toLowerCase(current)); + capitalize = false; + } + } + return friendly.toString(); } private DialogTexts() { diff --git a/src/main/java/zone/moddev/mc/orespawn/client/FluidDepositListScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/FluidDepositListScreen.java index 303fc78f..8f9fcc79 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/FluidDepositListScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/FluidDepositListScreen.java @@ -67,9 +67,9 @@ protected void init() { private ITextComponent depositName(String id) { JsonObject deposit = session.fluidDeposit(id); try { - Block block = ForgeRegistries.BLOCKS.getValue(new ResourceLocation( - GeologyEditorSession.string(deposit, "block", ""))); - if (block != null) return DialogTexts.blockName(block); + String blockId = GeologyEditorSession.string(deposit, "block", ""); + Block block = ForgeRegistries.BLOCKS.getValue(new ResourceLocation(blockId)); + if (block != null) return DialogTexts.blockName(block, blockId); } catch (RuntimeException ignored) { } return new TextComponentString(id); } 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 d9a553b0..bb9a0f60 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java @@ -38,6 +38,7 @@ import net.minecraft.init.Items; import net.minecraft.item.Item; import net.minecraftforge.fluids.IFluidBlock; +import net.minecraftforge.fluids.FluidRegistry; import net.minecraftforge.fml.common.registry.ForgeRegistries; /** Mutable client-side copy used until the Create World settings are accepted. */ @@ -2186,9 +2187,9 @@ private static boolean validFluidBlock(String id) { return block != null && block != Blocks.AIR && isFluidBlock(block); } - private static boolean isFluidBlock(Block block) { + static boolean isFluidBlock(Block block) { return block instanceof BlockLiquid || block instanceof IFluidBlock - || block.getDefaultState().getMaterial().isLiquid(); + || FluidRegistry.lookupFluidForBlock(block) != null; } private static boolean validResource(String id) { 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 0e0ff0e5..f664ab6f 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnWorldSettingsScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnWorldSettingsScreen.java @@ -370,7 +370,7 @@ private ITextComponent fluidControlLabel() { JsonObjectAccess access = new JsonObjectAccess(session.fluidDeposit(ids.get(0))); ResourceLocation blockId = access.resource("block"); Block block = blockId == null ? null : ForgeRegistries.BLOCKS.getValue(blockId); - if (block != null) return DialogTexts.blockName(block); + if (block != null) return DialogTexts.blockName(block, blockId.toString()); } return new TextComponentTranslation("option.orespawn.fluid_deposits"); } diff --git a/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java b/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java index 82dc3586..82d07752 100644 --- a/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java @@ -17,6 +17,8 @@ import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfile; import zone.moddev.mc.orespawn.integration.WorldgenIntegrationManager.BiomeProviderDefaultsSnapshot; +import net.minecraft.block.Block; +import net.minecraft.block.material.Material; import net.minecraft.init.Blocks; import net.minecraft.item.ItemStack; import net.minecraftforge.oredict.OreDictionary; @@ -166,6 +168,14 @@ void standaloneFluidPickerCreatesAUsableCoveredOverworldRule() { assertTrue(errors.isEmpty(), errors.toString()); } + @Test + void fluidPickerRejectsDecorativeBlocksThatOnlyUseWaterMaterial() { + Block decorativeWaterBlock = new Block(Material.WATER) { }; + + assertFalse(GeologyEditorSession.isFluidBlock(decorativeWaterBlock)); + assertTrue(GeologyEditorSession.isFluidBlock(Blocks.WATER)); + } + @Test void namespacedGeomesCanBeAddedValidatedAndRoundTripped() { String geomeId = "cakeworld:cocoa_basin"; diff --git a/src/test/java/zone/moddev/mc/orespawn/client/LegacyBlockTranslationTest.java b/src/test/java/zone/moddev/mc/orespawn/client/LegacyBlockTranslationTest.java index a2f1f792..400abaf6 100644 --- a/src/test/java/zone/moddev/mc/orespawn/client/LegacyBlockTranslationTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/client/LegacyBlockTranslationTest.java @@ -1,6 +1,7 @@ package zone.moddev.mc.orespawn.client; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -13,7 +14,10 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import net.minecraft.block.Block; +import net.minecraft.block.material.Material; import net.minecraft.init.Blocks; +import net.minecraft.util.text.ITextComponent; import net.minecraft.util.text.TextComponentTranslation; import zone.moddev.mc.orespawn.test.Forge12TestBootstrap; @@ -56,4 +60,16 @@ void vanillaFluidBlocksResolveThroughLegacyNameKeys() { assertEquals("tile.water.name", water.getKey()); assertEquals("missing:fluid", DialogTexts.blockName(null, "missing:fluid").getUnformattedText()); } + + @Test + void missingModBlockTranslationsFallBackToFriendlyRegistryNames() { + Block unnamedFluid = new Block(Material.WATER) { } + .setUnlocalizedName("null"); + ITextComponent label = DialogTexts.blockName(unnamedFluid, + "biomesoplenty:hot_spring_water", key -> false); + + assertEquals("Hot Spring Water", label.getUnformattedText()); + assertTrue(DialogTexts.blockName(Blocks.WATER, "minecraft:water", key -> true) + instanceof TextComponentTranslation); + } } From 82f698c498472f40cc2c5bc0ce74e5675a303ce7 Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Thu, 24 Sep 2026 12:01:42 +0100 Subject: [PATCH 29/31] Separate ambiguous Ore Dictionary review groups --- CHANGELOG.txt | 3 + docs/CONFIGURATION.md | 5 +- .../orespawn/client/GeologyEditorSession.java | 24 ++++++- .../orespawn/worldgen/OreMaterialGroups.java | 66 ++++++++++++++++--- .../orespawn/worldgen/OreSourcePolicies.java | 1 - .../worldgen/OreSourcePoliciesTest.java | 61 ++++++++++++++++- 6 files changed, 146 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.txt b/CHANGELOG.txt index adeac8d3..981ddf3f 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -52,6 +52,9 @@ Version 4.1.0.110021 Saltpeter distinct, deduplicate identical registry states, and allow confirmed alias reassignment plus stable custom groups. Group edits are saved to the world and future-world defaults only when the main editor's Done is accepted. +* Keep unrelated ambiguous Ore Dictionary alias families in separate stable + review groups. Legacy shared Review required data is rediscovered into those + groups, so Adamantine aliases can no longer appear in Mercury settings. * Ignore missing historical external-candidate records when the same registry state is now supplied by a loaded managed provider, preventing resolved groups from remaining falsely red while retaining the dormant record. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 62462bcf..8ce98181 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -252,7 +252,10 @@ 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 +instead of being guessed. Each ambiguous alias family receives its own stable +provisional group ID, so unrelated families cannot be combined merely because +both need review. Old shared `orespawn:review_required` data is split by fresh +discovery on load. 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 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 bb9a0f60..24e248e9 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java @@ -795,7 +795,29 @@ else if (validOreDictionaryName(alias)) { } } return materials.size() == 1 ? materials.iterator().next() - : materials.size() > 1 ? "orespawn:review_required" : ""; + : materials.size() > 1 ? provisionalMaterialForAliases(aliases) : ""; + } + + private static String provisionalMaterialForAliases(List aliases) { + Set unique = new LinkedHashSet<>(); + for (String alias : aliases) if (validOreDictionaryName(alias)) unique.add(alias); + List exact = new ArrayList<>(unique); + Collections.sort(exact); + String token = exact.isEmpty() ? "unknown" + : exact.get(0).substring(3).toLowerCase(Locale.ROOT); + long hash = 0xcbf29ce484222325L; + for (String name : exact) { + for (int index = 0; index < name.length(); index++) { + hash ^= name.charAt(index); + hash *= 0x100000001b3L; + } + hash ^= 0xffL; + hash *= 0x100000001b3L; + } + String value = Long.toHexString(hash); + StringBuilder padded = new StringBuilder(16); + for (int index = value.length(); index < 16; index++) padded.append('0'); + return "orespawn:review/" + token + '-' + padded.append(value).toString(); } private static boolean isCustomMaterial(String material) { diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreMaterialGroups.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreMaterialGroups.java index cb46674c..16034505 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreMaterialGroups.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreMaterialGroups.java @@ -22,7 +22,7 @@ /** 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 LEGACY_REVIEW = new ResourceLocation("orespawn", "review_required"); private static final ResourceLocation SULFUR = new ResourceLocation("orespawn", "sulfur"); private static final ResourceLocation ALUMINUM = new ResourceLocation("orespawn", "aluminum"); @@ -49,24 +49,26 @@ static JsonObject defaults() { static Inference infer(JsonObject root, Iterable oreNames) { Map owners = aliases(root); Set materials = new LinkedHashSet<>(); - List exact = new ArrayList<>(); + Set exactNames = new LinkedHashSet<>(); for (String name : oreNames) { if (!validOreName(name)) continue; - exact.add(name); + exactNames.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; } + catch (RuntimeException invalid) { material = null; } } - materials.add(material); + if (material != null) materials.add(material); } + List exact = new ArrayList<>(exactNames); Collections.sort(exact); if (materials.size() == 1) { ResourceLocation material = materials.iterator().next(); - return new Inference(material, REVIEW.equals(material), exact); + return new Inference(material, false, exact); } - return new Inference(materials.size() > 1 ? REVIEW : null, materials.size() > 1, exact); + return new Inference(materials.size() > 1 ? provisionalMaterial(exact) : null, + materials.size() > 1, exact); } static List definitions(JsonObject root) { @@ -101,6 +103,7 @@ static Definition definition(JsonObject root, ResourceLocation id) { } static boolean ensureDefinition(JsonObject root, ResourceLocation id, Iterable oreNames) { + List exact = exactOreNames(oreNames); JsonObject groups = root.has(SECTION) && root.get(SECTION).isJsonObject() ? root.getAsJsonObject(SECTION) : defaults(); boolean changed = !root.has(SECTION) || !root.get(SECTION).isJsonObject(); @@ -109,7 +112,7 @@ static boolean ensureDefinition(JsonObject root, ResourceLocation id, Iterable owners = aliases(root); - for (String name : oreNames) { + for (String name : exact) { if (!validOreName(name) || present.contains(name)) continue; ResourceLocation owner = owners.get(name); if (owner != null && !owner.equals(id)) continue; @@ -152,7 +155,7 @@ 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; + if (id == null || LEGACY_REVIEW.equals(id) || !entry.getValue().isJsonObject()) continue; JsonObject source = entry.getValue().getAsJsonObject(); JsonObject value = new JsonObject(); value.addProperty("display_name", string(source, "display_name", humanize(id.getResourcePath()))); @@ -223,6 +226,49 @@ private static String humanize(String path) { return value.isEmpty() ? value : Character.toUpperCase(value.charAt(0)) + value.substring(1); } + private static ResourceLocation provisionalMaterial(List exact) { + String token = exact.isEmpty() ? "unknown" + : exact.get(0).substring(3).toLowerCase(Locale.ROOT); + return new ResourceLocation("orespawn", "review/" + token + '-' + stableAliasHash(exact)); + } + + private static String stableAliasHash(List exact) { + long hash = 0xcbf29ce484222325L; + for (String name : exact) { + for (int index = 0; index < name.length(); index++) { + hash ^= name.charAt(index); + hash *= 0x100000001b3L; + } + hash ^= 0xffL; + hash *= 0x100000001b3L; + } + String value = Long.toHexString(hash); + StringBuilder padded = new StringBuilder(16); + for (int index = value.length(); index < 16; index++) padded.append('0'); + return padded.append(value).toString(); + } + + private static List exactOreNames(Iterable oreNames) { + Set unique = new LinkedHashSet<>(); + for (String name : oreNames) if (validOreName(name)) unique.add(name); + List result = new ArrayList<>(unique); + Collections.sort(result); + return result; + } + + private static String defaultDisplayName(ResourceLocation id, List exact) { + if (!"orespawn".equals(id.getResourceDomain()) + || !id.getResourcePath().startsWith("review/")) { + return humanize(id.getResourcePath()); + } + StringBuilder display = new StringBuilder(); + for (String name : exact) { + if (display.length() > 0) display.append(", "); + display.append(humanize(name.substring(3))); + } + return display.length() == 0 ? "Review required" : display.toString(); + } + static final class Definition { final ResourceLocation id; final String displayName; diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSourcePolicies.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSourcePolicies.java index 84036954..08932753 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSourcePolicies.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSourcePolicies.java @@ -37,7 +37,6 @@ final class OreSourcePolicies { 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", diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/OreSourcePoliciesTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/OreSourcePoliciesTest.java index 38756fa5..4240a42a 100644 --- a/src/test/java/zone/moddev/mc/orespawn/worldgen/OreSourcePoliciesTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/OreSourcePoliciesTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -44,7 +45,7 @@ void niterAndSaltpeterRemainDistinctAndAmbiguousMembershipRequiresReview() { Collections.singletonList("oreSaltpeter")).material.toString()); OreSourcePolicies.Inference ambiguous = OreSourcePolicies.inferMaterial( Arrays.asList("oreNiter", "oreSaltpeter")); - assertEquals("orespawn:review_required", ambiguous.material.toString()); + assertTrue(ambiguous.material.toString().startsWith("orespawn:review/")); assertTrue(ambiguous.reviewRequired); assertEquals(null, OreSourcePolicies.inferMaterial( Arrays.asList("dustSulfur", "crushedSulfur")).material); @@ -54,6 +55,64 @@ void niterAndSaltpeterRemainDistinctAndAmbiguousMembershipRequiresReview() { assertFalse(invalid.reviewRequired); } + @Test + void unrelatedAmbiguousAliasFamiliesReceiveDistinctStableReviewGroups() { + JsonObject root = root(); + OreMaterialGroups.initialize(root); + java.util.List adamantineAliases = Arrays.asList( + "oreAdamantine", "oreAdamantite", "oreAdamantium", "oreAdamant"); + java.util.List reorderedAdamantineAliases = Arrays.asList( + "oreAdamantium", "oreAdamant", "oreAdamantite", "oreAdamantine"); + java.util.List mercuryAliases = Arrays.asList("oreMercury", "oreQuicksilver"); + + OreSourcePolicies.Inference adamantine = OreSourcePolicies.inferMaterial(root, + adamantineAliases); + OreSourcePolicies.Inference reordered = OreSourcePolicies.inferMaterial(root, + reorderedAdamantineAliases); + OreSourcePolicies.Inference mercury = OreSourcePolicies.inferMaterial(root, mercuryAliases); + + assertTrue(adamantine.reviewRequired); + assertTrue(mercury.reviewRequired); + assertEquals(adamantine.material, reordered.material, + "Alias order must not change the provisional material identity"); + assertNotEquals(adamantine.material, mercury.material, + "Unrelated ambiguous alias families must never share a review group"); + + OreMaterialGroups.ensureDefinition(root, adamantine.material, adamantineAliases); + OreMaterialGroups.ensureDefinition(root, mercury.material, mercuryAliases); + OreMaterialGroups.Definition adamantineDefinition = OreMaterialGroups.definition(root, + adamantine.material); + OreMaterialGroups.Definition mercuryDefinition = OreMaterialGroups.definition(root, + mercury.material); + assertEquals(Arrays.asList("oreAdamant", "oreAdamantine", "oreAdamantite", + "oreAdamantium"), adamantineDefinition.oreDictionaryEntries); + assertEquals(Arrays.asList("oreMercury", "oreQuicksilver"), + mercuryDefinition.oreDictionaryEntries); + assertEquals("Adamant, Adamantine, Adamantite, Adamantium", + adamantineDefinition.displayName); + assertEquals("Mercury, Quicksilver", mercuryDefinition.displayName); + assertFalse(OreSourcePolicies.inferMaterial(root, adamantineAliases).reviewRequired, + "Once the alias family exists, accepting it must survive a later rebuild"); + } + + @Test + void legacySharedReviewGroupIsRemovedSoItsAliasFamiliesCanBeRediscovered() { + JsonObject root = root(); + JsonObject groups = new JsonObject(); + JsonObject legacy = new JsonObject(); + legacy.addProperty("display_name", "Review required"); + JsonArray aliases = new JsonArray(); + aliases.add(new JsonPrimitive("oreAdamantine")); + aliases.add(new JsonPrimitive("oreMercury")); + legacy.add("ore_dictionary_entries", aliases); + groups.add("orespawn:review_required", legacy); + root.add(OreMaterialGroups.SECTION, groups); + + assertTrue(OreMaterialGroups.initialize(root)); + assertFalse(root.getAsJsonObject(OreMaterialGroups.SECTION) + .has("orespawn:review_required")); + } + @Test void freshCataloguedMmdConflictConsolidatesUsingReviewedPriority() { JsonObject root = rootWithSulfurConflict(); From c31e7867da3a5cd717f83f3e08812eab9510e303 Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Tue, 29 Sep 2026 10:46:22 +0100 Subject: [PATCH 30/31] Keep missing mod settings editable --- .../orespawn/client/GeologyEditorSession.java | 59 +++++++++++---- .../client/GeologyEditorSessionTest.java | 71 +++++++++++++++++++ .../client/OreSourcePlacementScreenTest.java | 10 +++ 3 files changed, 127 insertions(+), 13 deletions(-) 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 24e248e9..5bf6f88b 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java @@ -1093,7 +1093,7 @@ List outputCandidates() { boolean needsAttention() { return needsReview() || "missing_source".equals(status) - || "external_generation".equals(status) || outputCandidates().isEmpty(); + || outputCandidates().isEmpty(); } boolean needsReview() { @@ -1698,8 +1698,10 @@ List validate() { continue; } JsonObject rock = entry.getValue().getAsJsonObject(); - if (!validBlock(string(rock, "block", entry.getKey()))) { - errors.add("Invalid rock block: " + string(rock, "block", entry.getKey())); + String rockBlock = string(rock, "block", entry.getKey()); + if (!validBlock(rockBlock)) { + if (allowsMissingProviderAsset(rock, rockBlock)) continue; + errors.add("Invalid rock block: " + rockBlock); continue; } if (!bool(rock, "enabled", true) || decimal(rock, "weight", 1.0D) <= 0.0D) { @@ -1735,14 +1737,17 @@ List validate() { continue; } JsonObject ore = entry.getValue().getAsJsonObject(); - if (!validBlock(string(ore, "block", entry.getKey()))) { - errors.add("Invalid ore block: " + string(ore, "block", entry.getKey())); + String oreBlock = string(ore, "block", entry.getKey()); + if (!validBlock(oreBlock)) { + if (allowsMissingProviderAsset(ore, oreBlock)) continue; + errors.add("Invalid ore block: " + oreBlock); continue; } if (!bool(ore, "enabled", true)) { continue; } - if (ore.has("deep_output") && !validBlock(string(ore, "deep_output", ""))) { + if (ore.has("deep_output") && !validBlock(string(ore, "deep_output", "")) + && !allowsMissingProviderAsset(ore, string(ore, "deep_output", ""))) { errors.add("Invalid deep ore block: " + string(ore, "deep_output", "")); } JsonObject dimensions = ore.has("dimensions") && ore.get("dimensions").isJsonObject() @@ -1794,7 +1799,8 @@ List validate() { errors.add("Invalid ore pattern for " + entry.getKey() + " in " + dimension.getKey()); } if (bool(rule, "enabled", true)) { - boolean hosts = validBlockArray(rule.get("host_blocks"), errors, entry.getKey()) + boolean hosts = validBlockArray(rule.get("host_blocks"), errors, entry.getKey(), + orphanedProviderDefinition(ore)) || validIdArray(rule.get("host_tags")); if (rule.has("host_families") && rule.get("host_families").isJsonArray()) { for (JsonElement family : rule.getAsJsonArray("host_families")) { @@ -1818,7 +1824,8 @@ List validate() { } JsonObject dimension = entry.getValue().getAsJsonObject(); if (!bool(dimension, "enabled", true)) continue; - boolean hosts = validBlockArray(dimension.get("host_blocks"), errors, entry.getKey()) + boolean hosts = validBlockArray(dimension.get("host_blocks"), errors, entry.getKey(), + orphanedProviderDefinition(dimension)) || validIdArray(dimension.get("host_tags")); if (!hosts) errors.add("Enabled terrain dimension has no valid hosts: " + entry.getKey()); if (dimension.has("biome_ids")) { @@ -1835,7 +1842,9 @@ List validate() { continue; } JsonObject deposit = entry.getValue().getAsJsonObject(); - if (!validFluidBlock(string(deposit, "block", ""))) { + String depositBlock = string(deposit, "block", ""); + if (!validFluidBlock(depositBlock)) { + if (allowsMissingProviderAsset(deposit, depositBlock)) continue; errors.add("Fluid deposit output is not a fluid block: " + entry.getKey()); continue; } @@ -1871,7 +1880,8 @@ List validate() { errors.add("Invalid fluid placement values for " + entry.getKey() + " in " + dimension.getKey()); } - boolean hosts = validBlockArray(rule.get("host_blocks"), errors, entry.getKey()) + boolean hosts = validBlockArray(rule.get("host_blocks"), errors, entry.getKey(), + orphanedProviderDefinition(deposit)) || validIdArray(rule.get("host_tags")); if (rule.has("host_families") && rule.get("host_families").isJsonArray()) { for (JsonElement family : rule.getAsJsonArray("host_families")) { @@ -1890,6 +1900,7 @@ List validate() { } JsonObject palette = entry.getValue().getAsJsonObject(); boolean replacementPalette = BiomeReplacementRules.isOverridePalette(entry.getKey()); + boolean orphanedProvider = orphanedProviderDefinition(palette); if (!validResource(string(palette, "dimension", ""))) { errors.add("Invalid biome palette dimension: " + entry.getKey()); } @@ -1900,7 +1911,8 @@ List validate() { for (Entry biome : biomes.entrySet()) { if (!validResource(biome.getKey()) || (!replacementPalette - && ForgeRegistries.BIOMES.getValue(new ResourceLocation(biome.getKey())) == null) + && ForgeRegistries.BIOMES.getValue(new ResourceLocation(biome.getKey())) == null + && !orphanedProvider) || !biome.getValue().isJsonObject()) { errors.add("Invalid biome placement: " + biome.getKey()); continue; @@ -1921,16 +1933,19 @@ > decimal(placement, "max_downfall", 1.0D)) { continue; } JsonObject materials = entry.getValue().getAsJsonObject(); + boolean orphanedProvider = orphanedProviderDefinition(materials); if (!validResource(string(materials, "dimension", ""))) { errors.add("Invalid dimension materials target: " + entry.getKey()); } for (String key : new String[] { "default_fluid", "deep_aquifer_fluid" }) { - if (materials.has(key) && !validFluidBlock(string(materials, key, ""))) { + if (materials.has(key) && !validFluidBlock(string(materials, key, "")) + && !(orphanedProvider && validResource(string(materials, key, "")))) { errors.add("Invalid fluid material in " + entry.getKey()); } } for (String key : new String[] { "snow_block", "ice_block" }) { - if (materials.has(key) && !validBlock(string(materials, key, ""))) { + if (materials.has(key) && !validBlock(string(materials, key, "")) + && !(orphanedProvider && validResource(string(materials, key, "")))) { errors.add("Invalid weather material in " + entry.getKey()); } } @@ -2052,6 +2067,11 @@ private static boolean validIdArray(JsonElement element) { } private static boolean validBlockArray(JsonElement element, List errors, String oreId) { + return validBlockArray(element, errors, oreId, false); + } + + private static boolean validBlockArray(JsonElement element, List errors, String oreId, + boolean allowMissingProviderBlocks) { if (element == null || !element.isJsonArray() || element.getAsJsonArray().size() == 0) return false; boolean found = false; for (JsonElement value : element.getAsJsonArray()) { @@ -2063,6 +2083,10 @@ private static boolean validBlockArray(JsonElement element, List errors, continue; } if (!validBlock(id)) { + if (allowMissingProviderBlocks && validResource(id)) { + found = true; + continue; + } errors.add("Unknown host block '" + id + "' for " + oreId); continue; } @@ -2071,6 +2095,15 @@ private static boolean validBlockArray(JsonElement element, List errors, return found; } + private static boolean allowsMissingProviderAsset(JsonObject definition, String id) { + return orphanedProviderDefinition(definition) && validResource(id); + } + + private static boolean orphanedProviderDefinition(JsonObject definition) { + return bool(definition, "orphaned_provider", false) + && !string(definition, "source_provider", "").isEmpty(); + } + static JsonObject defaultOreDimension() { JsonObject dimension = new JsonObject(); dimension.addProperty("enabled", true); diff --git a/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java b/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java index 82d07752..223ff6da 100644 --- a/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java @@ -125,6 +125,72 @@ void providerFluidRemovalLeavesATombstoneButLocalRulesAreDeleted() { assertFalse(session.section("fluid_deposits").has("pack:fluid_deposit/test")); } + @Test + void missingProviderAssetsRemainDormantWithoutBlockingTheEditor() { + GeologyEditorSession session = new GeologyEditorSession(WorldGeologyProfile.recommended(false)); + session.configureDefaultVanillaStrata(); + + JsonObject rock = new JsonObject(); + rock.addProperty("family", "igneous_volcanic"); + rock.addProperty("weight", 1.0D); + rock.addProperty("enabled", true); + rock.addProperty("block", "missingmod:basalt"); + markOrphanedProvider(rock); + session.section("rocks").add("missingmod:rock/basalt", rock); + + JsonObject ore = new JsonObject(); + ore.addProperty("block", "missingmod:ore"); + ore.addProperty("enabled", true); + JsonObject oreRule = GeologyEditorSession.defaultOreDimension(); + JsonArray oreHosts = new JsonArray(); + oreHosts.add(new JsonPrimitive("minecraft:stone")); + oreRule.add("host_blocks", oreHosts); + JsonObject oreDimensions = new JsonObject(); + oreDimensions.add("minecraft:overworld", oreRule); + ore.add("dimensions", oreDimensions); + markOrphanedProvider(ore); + session.section("ores").add("missingmod:ore/test", ore); + + String depositId = session.assignFluidDeposit("minecraft:water"); + JsonObject deposit = session.section("fluid_deposits").remove(depositId).getAsJsonObject(); + deposit.addProperty("block", "missingmod:fluid"); + markOrphanedProvider(deposit); + session.section("fluid_deposits").add("missingmod:fluid_deposit/test", deposit); + + JsonObject terrain = new JsonObject(); + terrain.addProperty("enabled", true); + JsonArray terrainHosts = new JsonArray(); + terrainHosts.add(new JsonPrimitive("missingmod:host")); + terrain.add("host_blocks", terrainHosts); + markOrphanedProvider(terrain); + session.section("terrain_dimensions").add("missingmod:dimension", terrain); + + JsonObject palette = new JsonObject(); + palette.addProperty("dimension", "minecraft:overworld"); + palette.addProperty("enabled", true); + JsonObject paletteBiomes = new JsonObject(); + JsonObject placement = new JsonObject(); + placement.addProperty("weight", 1.0D); + paletteBiomes.add("missingmod:biome", placement); + palette.add("biomes", paletteBiomes); + markOrphanedProvider(palette); + session.section("biome_palettes").add("missingmod:palette", palette); + + JsonObject materials = new JsonObject(); + materials.addProperty("dimension", "minecraft:overworld"); + materials.addProperty("default_fluid", "missingmod:fluid"); + materials.addProperty("snow_block", "missingmod:snow"); + markOrphanedProvider(materials); + session.section("dimension_materials").add("missingmod:materials", materials); + + java.util.List errors = session.validate(); + assertTrue(errors.isEmpty(), errors.toString()); + + rock.remove("orphaned_provider"); + assertTrue(session.validate().contains("Invalid rock block: missingmod:basalt"), + "The same unresolved local definition must still block saving"); + } + @Test void rangedSelectorOreIsValidWithoutAnExplicitDimension() { GeologyEditorSession session = new GeologyEditorSession(WorldGeologyProfile.recommended(false)); @@ -841,6 +907,11 @@ private static GeologyEditorSession.OreSourceGroup group( .findFirst().orElseThrow(() -> new AssertionError("Missing group " + material)); } + private static void markOrphanedProvider(JsonObject definition) { + definition.addProperty("source_provider", "missingmod"); + definition.addProperty("orphaned_provider", true); + } + private static JsonObject candidate(String sourceId, String owner, String registryId, String channel, boolean reviewRequired) { JsonObject candidate = new JsonObject(); diff --git a/src/test/java/zone/moddev/mc/orespawn/client/OreSourcePlacementScreenTest.java b/src/test/java/zone/moddev/mc/orespawn/client/OreSourcePlacementScreenTest.java index ce8636c0..0bc15e16 100644 --- a/src/test/java/zone/moddev/mc/orespawn/client/OreSourcePlacementScreenTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/client/OreSourcePlacementScreenTest.java @@ -87,6 +87,11 @@ void groupColoursSeparateRoutineResolvedAndUnresolvedEntries() { Arrays.asList("oreSulfur", "oreSulphur"), Arrays.asList(first, second)); OreSourceGroup unresolved = group("keep_separate", "review_required", Arrays.asList("oreSulfur", "oreSulphur"), Arrays.asList(first, second)); + OreSourceCandidate externalCandidate = candidate("external", "external:ore", + "external:uncontrolled", true, false, true); + OreSourceGroup external = group("keep_separate", "external_generation", + Arrays.asList("oreSulfur", "oreSulphur"), + Arrays.asList(first, externalCandidate)); assertTrue(routine.isRoutineSingleSource()); assertFalse(OreSourceListScreen.showByDefault(routine)); @@ -99,6 +104,11 @@ void groupColoursSeparateRoutineResolvedAndUnresolvedEntries() { assertFalse(resolved.needsReview()); assertTrue(OreSourceListScreen.showByDefault(unresolved)); assertEquals(0xFF5555, OreSourceListScreen.groupRowColor(unresolved)); + assertFalse(external.needsAttention(), + "An informational external generator must not look like an unresolved decision"); + assertFalse(external.needsReview()); + assertTrue(OreSourceListScreen.showByDefault(external)); + assertEquals(0xFFFF55, OreSourceListScreen.groupRowColor(external)); } private static OreSourceCandidate candidate(String source, String channel, From 1019e07f7a701e8fc5901b3be60939b38324304b Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Tue, 29 Sep 2026 10:46:42 +0100 Subject: [PATCH 31/31] Tidy up the 1.10 build --- build.gradle | 1306 +---------------- gradle.properties | 7 + gradle/README.md | 36 + gradle/ide/eclipse.gradle | 230 +++ gradle/release/artifacts.gradle | 370 +++++ gradle/release/publishing.gradle | 118 ++ .../verification/client-and-benchmark.gradle | 146 ++ .../verification/legacy-and-migration.gradle | 311 ++++ gradle/verification/support.gradle | 167 +++ .../orespawn/ReleaseWorkflowContractTest.java | 23 +- 10 files changed, 1438 insertions(+), 1276 deletions(-) create mode 100644 gradle/README.md create mode 100644 gradle/ide/eclipse.gradle create mode 100644 gradle/release/artifacts.gradle create mode 100644 gradle/release/publishing.gradle create mode 100644 gradle/verification/client-and-benchmark.gradle create mode 100644 gradle/verification/legacy-and-migration.gradle create mode 100644 gradle/verification/support.gradle diff --git a/build.gradle b/build.gradle index 86b8ac76..7721fde1 100644 --- a/build.gradle +++ b/build.gradle @@ -1,11 +1,6 @@ -import groovy.json.JsonSlurper -import groovy.xml.XmlSlurper -import java.nio.charset.StandardCharsets -import java.security.MessageDigest -import java.util.jar.Manifest -import java.util.zip.ZipFile import org.apache.tools.ant.filters.FixCrLfFilter + plugins { id 'java' id 'eclipse' @@ -18,8 +13,10 @@ plugins { group = project.mod_group version = project.mod_version -base.archivesName = 'OreSpawn' +base.archivesName = project.mod_artifact +// The final version component names the Minecraft target and loader. Reject a +// mismatch here before a jar can accidentally be labelled for another game. def versionParts = project.mod_version.toString().tokenize('.') if (versionParts.size() != 4 || !versionParts.every { it ==~ /\d+/ }) { throw new GradleException("mod_version must use Major.Minor.Bug.Target numeric form: ${project.mod_version}") @@ -48,6 +45,7 @@ def expectedMavenGroup = 'zone.moddev.mc.orespawn' def expectedMavenArtifact = 'OreSpawn' def expectedMavenCoordinate = "${expectedMavenGroup}:${expectedMavenArtifact}:${project.version}" +// Gradle runs on a modern JDK, but Forge 1.10 consumers need Java 8 classes. java { toolchain.languageVersion = JavaLanguageVersion.of(8) withSourcesJar() @@ -85,6 +83,8 @@ tasks.withType(AbstractArchiveTask).configureEach { reproducibleFileOrder = true } +// Canonical text and zip ordering make release jars comparable by hash on +// Windows and Linux, including the guides bundled into the mod. def archiveTextSuffixes = [ '.cfg', '.css', '.html', '.info', '.java', '.js', '.json', '.lang', '.mcmeta', '.md', '.properties', '.txt', '.xml' @@ -162,50 +162,8 @@ repositories { } } -def legacyFixtureRoot = file("${rootDir}/ci-fixtures") -def legacyFixtureArtifacts = new File(legacyFixtureRoot, 'artifacts') -def legacyFixtureWorlds = new File(legacyFixtureRoot, 'worlds') -def legacyMineralogyOracleJar = new File( - legacyFixtureArtifacts, 'Mineralogy-1.10.2-3.3.8.26.jar') -def legacyFixtureHashes = [ - 'artifacts/OreSpawn_1.10.2-1.1.0.jar': - '91345E4B825AA06E05F470066F467AABA0C7A2804A8DBCA4D3CD1905ED53607C', - 'artifacts/OreSpawn-1.10.2-3.2.2.104.jar': - 'D964148C20175E16CCEFAB02AE43A181A50DC08A1D636A77E59CFE8C8EA8D41A', - 'artifacts/BaseMetals_1.10.2-2.4.0.11.jar': - 'F919CEF794C73F02758BBD967346DBA0B53F6383153C7E29BD79F211CDA8C319', - 'artifacts/Mineralogy-1.10.2-3.3.8.26.jar': - '88A6237C9A0E2C8891718B68C373E741C78B8494F5E68D8093CA9339F3BC4D87', - 'worlds/sylvester-era-trio-source-v2.zip': - '11FC2FA44C43520AA9B21C29B43203EBFE0A84773D2BDCBB4F3C48C490559B07' -] - -task verifyLegacyFixtures { - group = 'verification' - description = 'Verifies the sealed ABI and migration corpus used only by isolated tests.' - inputs.files legacyFixtureHashes.keySet().collect { new File(legacyFixtureRoot, it) } - doLast { - legacyFixtureHashes.each { String relativePath, String expectedHash -> - File fixture = new File(legacyFixtureRoot, relativePath) - if (!fixture.isFile()) { - throw new GradleException("Missing sealed legacy fixture: ${fixture}") - } - MessageDigest digest = MessageDigest.getInstance('SHA-256') - fixture.withInputStream { input -> - byte[] buffer = new byte[8192] - for (int read = input.read(buffer); read >= 0; read = input.read(buffer)) { - if (read > 0) digest.update(buffer, 0, read) - } - } - String actualHash = digest.digest().encodeHex().toString().toUpperCase() - if (actualHash != expectedHash) { - throw new GradleException("Legacy fixture hash mismatch for ${relativePath}: " - + "expected ${expectedHash}, found ${actualHash}") - } - } - } -} - +// Keep ordinary dependencies here. The sealed Mineralogy oracle belongs to +// the verification script, where its test-only scope is checked. dependencies { implementation minecraft.dependency( "net.minecraftforge:forge:${project.minecraft_version}-${project.forge_version}") @@ -215,599 +173,6 @@ dependencies { testImplementation 'org.junit.jupiter:junit-jupiter-params:5.10.2' testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.10.2' testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.10.2' - // Buildship must see this as a test-scoped dependency so Eclipse can - // compile the parity test. The Eclipse launch guard excludes all - // test-scoped dependencies from ordinary client/server runs. - testImplementation files(legacyMineralogyOracleJar) -} - -tasks.named('compileTestJava', JavaCompile) { - dependsOn verifyLegacyFixtures -} -tasks.named('test', Test) { - dependsOn verifyLegacyFixtures -} - -tasks.register('verifyLegacyOracleIsolation') { - group = 'verification' - description = 'Keeps the sealed Mineralogy oracle test-visible but production-invisible.' - dependsOn verifyLegacyFixtures - - doLast { - File oracle = legacyMineralogyOracleJar.canonicalFile - def canonicalFiles = { FileCollection classpath -> - classpath.files.collect { it.canonicalFile } as Set - } - Set testCompileFiles = canonicalFiles(sourceSets.test.compileClasspath) - Set testRuntimeFiles = canonicalFiles(sourceSets.test.runtimeClasspath) - if (!testCompileFiles.contains(oracle) || !testRuntimeFiles.contains(oracle)) { - throw new GradleException('The sealed Mineralogy oracle is missing from the test classpath model') - } - - Map productionClasspaths = [ - mainCompile: sourceSets.main.compileClasspath, - mainRuntime: sourceSets.main.runtimeClasspath - ] - productionClasspaths.each { String name, FileCollection classpath -> - if (canonicalFiles(classpath).contains(oracle)) { - throw new GradleException("The sealed Mineralogy oracle leaked into ${name}") - } - } - } -} -tasks.named('check') { - dependsOn tasks.named('verifyLegacyOracleIsolation') -} - -def java8Launcher = javaToolchains.launcherFor { - languageVersion = JavaLanguageVersion.of(8) -} -def configureFromForgeRun = { JavaExec process, String runTaskName -> - process.actions.clear() - process.dependsOn { - JavaExec run = tasks.getByName(runTaskName) as JavaExec - run.taskDependencies.getDependencies(run) - } - process.doLast { - JavaExec run = tasks.getByName(runTaskName) as JavaExec - String runConfigurationName = runTaskName.substring(3).uncapitalize() - def runOptions = minecraft.runs.getByName(runConfigurationName) - File originalConfiguredWorkingDir = runOptions.workingDir.get().asFile - File originalWorkingDir = run.workingDir - List originalArgs = new ArrayList<>(run.args) - List originalJvmArgs = new ArrayList<>(run.jvmArgs) - Map originalSystemProperties = new LinkedHashMap<>(run.systemProperties) - Map originalEnvironment = new LinkedHashMap<>(run.environment) - FileCollection originalClasspath = files(run.classpath.files) - String originalMinHeap = run.minHeapSize - String originalMaxHeap = run.maxHeapSize - String originalMainClass = run.mainClass.orNull - try { - File forgeConfig = new File(process.workingDir, 'config/forge.cfg') - if (!forgeConfig.isFile()) { - forgeConfig.parentFile.mkdirs() - forgeConfig.setText('''general { - B:disableVersionCheck=true -} - -version_checking { - B:Global=false -} -''', 'UTF-8') - } - runOptions.workingDir.set(process.workingDir) - run.workingDir(process.workingDir) - run.args(process.args) - run.jvmArgs(process.jvmArgs) - run.systemProperties(process.systemProperties) - run.environment(process.environment) - Set splitMainOutputs = [ - tasks.named('compileJava', JavaCompile).get().destinationDirectory.get().asFile, - sourceSets.main.output.resourcesDir - ] as Set - run.setClasspath(files(forge10DevelopmentOutput, run.classpath.filter { - !splitMainOutputs.contains(it) - })) - run.exec() - } finally { - runOptions.workingDir.set(originalConfiguredWorkingDir) - run.workingDir(originalWorkingDir) - run.setArgs(originalArgs) - run.setJvmArgs(originalJvmArgs) - run.setSystemProperties(originalSystemProperties) - run.setEnvironment(originalEnvironment) - run.setClasspath(originalClasspath) - run.minHeapSize = originalMinHeap - run.maxHeapSize = originalMaxHeap - if (originalMainClass == null) { - run.mainClass.unset() - } else { - run.mainClass.set(originalMainClass) - } - } - } -} - -def surfaceIntegrationClasses = file("${buildDir}/surface-integration-fixture/classes") -task compileSurfaceIntegrationTestMod(type: JavaCompile, dependsOn: classes) { - source fileTree('src/biomeIntegrationTest/java') - classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) - destinationDirectory = surfaceIntegrationClasses - options.encoding = 'UTF-8' -} - -task surfaceIntegrationTestModJar(type: Jar, dependsOn: compileSurfaceIntegrationTestMod) { - archiveFileName = 'surfaceprobe.jar' - destinationDirectory = file("${buildDir}/surface-integration-fixture") - from surfaceIntegrationClasses - from 'src/biomeIntegrationTest/resources' -} - -def surfaceIntegrationRunDirectory = file("${buildDir}/surface-integration-run") -task prepareSurfaceIntegrationTest(dependsOn: surfaceIntegrationTestModJar) { - doLast { - delete surfaceIntegrationRunDirectory - surfaceIntegrationRunDirectory.mkdirs() - copy { - from surfaceIntegrationTestModJar.archiveFile - into new File(surfaceIntegrationRunDirectory, 'mods') - } - new File(surfaceIntegrationRunDirectory, 'server.properties').setText('''\ -level-name=surface-integration-world -level-seed=zsjpxah -level-type=default -online-mode=false -server-port=0 -allow-nether=true -generate-structures=false -spawn-protection=0 -max-tick-time=-1 -''', 'UTF-8') - new File(surfaceIntegrationRunDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') - } -} - -def createSurfaceProcess = { String phase, Object dependency -> - task("surfaceIntegration${phase}Process", type: JavaExec, dependsOn: dependency) { - group = 'verification' - workingDir surfaceIntegrationRunDirectory - systemProperty 'surfaceprobe.integrationPhase', phase.toLowerCase() - configureFromForgeRun(delegate, 'runServer') - } -} - -def surfaceIntegrationFreshProcess = createSurfaceProcess('Fresh', prepareSurfaceIntegrationTest) -surfaceIntegrationFreshProcess.doLast { - File marker = new File(surfaceIntegrationRunDirectory, - 'surface-integration-world/surfaceprobe-integration.properties') - if (!marker.isFile()) { - throw new GradleException("Fresh surface integration completion marker is missing: ${marker}") - } -} -def surfaceIntegrationReloadProcess = createSurfaceProcess('Reload', surfaceIntegrationFreshProcess) - -task surfaceIntegrationTest(dependsOn: surfaceIntegrationReloadProcess) { - group = 'verification' - doLast { - File marker = new File(surfaceIntegrationRunDirectory, - 'surface-integration-world/surfaceprobe-integration.properties') - Properties result = new Properties() - marker.withInputStream { result.load(it) } - 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('biome_override_verified') != 'true') { - throw new GradleException("Biome editor/profile/new-terrain 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}") - } - [ - 'end.raw_natural_sources': '108', - 'end.structure_natural_sources': '108', - 'end.vegetation_natural_sources': '108', - 'end.cave_pockets': '54', - 'end.underwater_pockets': '54', - 'end.raw_bedrock': '9', - 'end.raw_block_entities': '9', - 'end.exposed_snow_converted': '9', - 'end.surface_ice_converted': '9', - 'end.buried_snow_preserved': '9', - 'end.buried_ice_preserved': '9', - 'nether.unconfigured_snow_preserved': '9', - 'nether.unconfigured_ice_preserved': '9' - ].each { key, expected -> - if (result.getProperty(key) != expected) { - throw new GradleException("Unexpected surface integration result ${key}: " - + "expected ${expected}, found ${result.getProperty(key)}") - } - } - File latest = new File(surfaceIntegrationRunDirectory, 'logs/latest.log') - if (!latest.isFile() || latest.text.contains('Tried to assign a mutable BlockPos')) { - throw new GradleException("Surface integration leaked a mutable position into tick data: ${latest}") - } - logger.lifecycle('Provider surfaces and exact-biome geology verified: {} dimensions, {} columns each, fresh + reload', - result.getProperty('dimensions'), result.getProperty('columns_per_dimension')) - } -} - -check.dependsOn surfaceIntegrationTest - -def migrationIntegrationClasses = file("${buildDir}/migration-integration-fixture/classes") -task compileMigrationIntegrationTestMod(type: JavaCompile, dependsOn: classes) { - source fileTree('src/migrationIntegrationTest/java') - classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) - destinationDirectory = migrationIntegrationClasses - options.encoding = 'UTF-8' -} - -task migrationIntegrationTestModJar(type: Jar, dependsOn: compileMigrationIntegrationTestMod) { - archiveFileName = 'migrationprobe.jar' - destinationDirectory = file("${buildDir}/migration-integration-fixture") - from migrationIntegrationClasses - from 'src/migrationIntegrationTest/resources' -} - -if (project.hasProperty('migrationRunDir')) { - def migrationRunDirectory = file(project.property('migrationRunDir')) - task prepareMigrationIntegrationRun(dependsOn: migrationIntegrationTestModJar) { - doLast { - copy { from migrationIntegrationTestModJar.archiveFile; into new File(migrationRunDirectory, 'mods') } - new File(migrationRunDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') - File serverProperties = new File(migrationRunDirectory, 'server.properties') - Properties values = new Properties() - if (serverProperties.isFile()) serverProperties.withInputStream { values.load(it) } - values.setProperty('spawn-animals', 'false') - values.setProperty('spawn-monsters', 'false') - serverProperties.withOutputStream { values.store(it, 'OreSpawn migration qualification') } - } - } - task migrationIntegrationProcess(type: JavaExec, dependsOn: prepareMigrationIntegrationRun) { - group = 'verification' - workingDir migrationRunDirectory - systemProperty 'orespawn.migrationFamily', project.findProperty('migrationFamily') ?: 'unspecified' - systemProperty 'orespawn.migrationPhase', project.findProperty('migrationPhase') ?: 'fresh' - configureFromForgeRun(delegate, 'runServer') - doLast { - String phase = project.findProperty('migrationPhase') ?: 'fresh' - File marker = new File(migrationRunDirectory, 'world/orespawn4-migration-probe.properties') - if (!marker.isFile()) throw new GradleException("Migration probe did not produce ${marker}") - Properties values = new Properties(); marker.withInputStream { values.load(it) } - if (values.getProperty("${phase}_complete") != 'true') { - throw new GradleException("Migration ${phase} phase did not complete: ${marker}") - } - File latest = new File(migrationRunDirectory, 'logs/latest.log') - if (latest.isFile() && (latest.text.contains('Migration audit failed') - || latest.text.contains('Encountered an unexpected exception'))) { - throw new GradleException("Migration ${phase} phase logged a server failure: ${latest}") - } - } - } -} - -def legacyMineralogyMigrationRunDirectory = file("${buildDir}/legacy-mineralogy-migration-run") -def legacyMineralogyMigrationArchive = new File(legacyFixtureWorlds, - 'sylvester-era-trio-source-v2.zip') - -task prepareLegacyMineralogyMigrationRun(dependsOn: [migrationIntegrationTestModJar, - verifyLegacyFixtures]) { - doLast { - delete legacyMineralogyMigrationRunDirectory - copy { from zipTree(legacyMineralogyMigrationArchive); into legacyMineralogyMigrationRunDirectory } - File mods = new File(legacyMineralogyMigrationRunDirectory, 'mods') - mods.mkdirs() - copy { - from new File(legacyFixtureArtifacts, 'BaseMetals_1.10.2-2.4.0.11.jar') - from new File(legacyFixtureArtifacts, 'Mineralogy-1.10.2-3.3.8.26.jar') - from migrationIntegrationTestModJar.archiveFile - into mods - } - new File(legacyMineralogyMigrationRunDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') - } -} - -def configureLegacyMineralogyMigrationProcess = { JavaExec process, String phase -> - process.group = 'verification' - process.workingDir legacyMineralogyMigrationRunDirectory - process.systemProperty 'orespawn.migrationFamily', 'legacy-mineralogy-cyano' - process.systemProperty 'orespawn.migrationPhase', phase - configureFromForgeRun(process, 'runServer') - process.doLast { - File marker = new File(legacyMineralogyMigrationRunDirectory, - 'world/orespawn4-migration-probe.properties') - if (!marker.isFile()) throw new GradleException("Legacy Mineralogy migration did not produce ${marker}") - Properties values = new Properties(); marker.withInputStream { values.load(it) } - if (values.getProperty("${phase}_complete") != 'true') { - throw new GradleException("Legacy Mineralogy migration ${phase} phase did not complete") - } - } -} - -task legacyMineralogyMigrationFresh(type: JavaExec, dependsOn: prepareLegacyMineralogyMigrationRun) -configureLegacyMineralogyMigrationProcess(legacyMineralogyMigrationFresh, 'fresh') - -task legacyMineralogyMigrationReload(type: JavaExec, dependsOn: legacyMineralogyMigrationFresh) -configureLegacyMineralogyMigrationProcess(legacyMineralogyMigrationReload, 'reload') - -task legacyMineralogyMigrationTest(dependsOn: legacyMineralogyMigrationReload) { - group = 'verification' - description = 'Proves an existing Mineralogy 3 world is pinned to Cyano settings across reload.' - doLast { - File marker = new File(legacyMineralogyMigrationRunDirectory, - 'world/orespawn4-migration-probe.properties') - Properties values = new Properties(); marker.withInputStream { values.load(it) } - ['fresh_complete', 'reload_complete', 'legacy_mineralogy_config_sha256', - 'legacy_mineralogy_world_profile_sha256'].each { key -> - if (!values.getProperty(key)) throw new GradleException("Missing legacy Mineralogy evidence ${key}") - } - logger.lifecycle('Existing Mineralogy 3 world retained Cyano profile and exact config/profile hashes across reload') - } -} - -check.dependsOn legacyMineralogyMigrationTest - -def os1AbiFixtureClasses = file("${buildDir}/legacy-abi/os1/classes") -def os3AbiFixtureClasses = file("${buildDir}/legacy-abi/os3/classes") - -task compileOs1AbiFixture(type: JavaCompile, dependsOn: verifyLegacyFixtures) { - source fileTree('src/os1AbiFixture/java') - classpath = files(sourceSets.main.compileClasspath, - new File(legacyFixtureArtifacts, 'OreSpawn_1.10.2-1.1.0.jar')) - destinationDirectory = os1AbiFixtureClasses - options.encoding = 'UTF-8' -} - -task os1AbiFixtureJar(type: Jar, dependsOn: compileOs1AbiFixture) { - archiveFileName = 'os1abiprobe.jar' - destinationDirectory = file("${buildDir}/legacy-abi/os1") - from os1AbiFixtureClasses - from 'src/os1AbiFixture/resources' -} - -task compileOs3AbiFixture(type: JavaCompile, dependsOn: verifyLegacyFixtures) { - source fileTree('src/os3AbiFixture/java') - classpath = files(sourceSets.main.compileClasspath, - new File(legacyFixtureArtifacts, 'OreSpawn-1.10.2-3.2.2.104.jar')) - destinationDirectory = os3AbiFixtureClasses - options.encoding = 'UTF-8' -} - -task os3AbiFixtureJar(type: Jar, dependsOn: compileOs3AbiFixture) { - archiveFileName = 'os3abiprobe.jar' - destinationDirectory = file("${buildDir}/legacy-abi/os3") - from os3AbiFixtureClasses - from 'src/os3AbiFixture/resources' -} - -def configureLegacyAbiProcess = { String generation, Task fixtureJar, String markerName -> - File runDirectory = file("${buildDir}/legacy-abi/${generation}/run") - Task prepare = tasks.create("prepare${generation.capitalize()}AbiRun", Copy) { - dependsOn fixtureJar - into new File(runDirectory, 'mods') - from fixtureJar.archiveFile - doFirst { project.delete(runDirectory) } - doLast { - new File(runDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') - new File(runDirectory, 'server.properties').setText( - 'eula=true\nonline-mode=false\nlevel-name=world\nlevel-seed=zsjpxah\n' + - 'spawn-animals=false\nspawn-monsters=false\n', 'UTF-8') - } - } - tasks.create("${generation}AbiIntegrationProcess", JavaExec) { - group = 'verification' - dependsOn prepare - workingDir runDirectory - configureFromForgeRun(delegate, 'runServer') - doLast { - File marker = new File(runDirectory, "world/${markerName}") - if (!marker.isFile() || !marker.text.contains('registered=true')) { - throw new GradleException("${generation.toUpperCase()} binary ABI probe did not complete: ${marker}") - } - File latest = new File(runDirectory, 'logs/latest.log') - if (latest.isFile() && latest.text.contains('Encountered an unexpected exception')) { - throw new GradleException("${generation.toUpperCase()} binary ABI probe logged a server failure") - } - } - } -} - -configureLegacyAbiProcess('os1', os1AbiFixtureJar, 'os1-abi-probe.properties') -configureLegacyAbiProcess('os3', os3AbiFixtureJar, 'os3-abi-probe.properties') - -task legacyAbiIntegrationTest { - group = 'verification' - dependsOn os1AbiIntegrationProcess, os3AbiIntegrationProcess -} - -check.dependsOn legacyAbiIntegrationTest - -def clientIntegrationClasses = file("${buildDir}/client-integration-fixture/classes") -task compileClientIntegrationTestMod(type: JavaCompile, dependsOn: classes) { - source fileTree('src/clientIntegrationTest/java') - classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) - destinationDirectory = clientIntegrationClasses - options.encoding = 'UTF-8' -} - -task clientIntegrationTestModJar(type: Jar, dependsOn: compileClientIntegrationTestMod) { - archiveFileName = 'clientprobe.jar' - destinationDirectory = file("${buildDir}/client-integration-fixture") - from clientIntegrationClasses - from 'src/clientIntegrationTest/resources' -} - -def packagedClientProbeJar = renamer.classes(tasks.named('clientIntegrationTestModJar', Jar)) { - map.from minecraft.dependency.toSrgFile - output = layout.buildDirectory.file('client-integration-fixture/clientprobe-reobf.jar') -} - -tasks.register('preparePackagedClientProbe') { - group = 'verification' - description = 'Builds the reobfuscated client probe used only with a disposable packaged Forge runtime.' - dependsOn packagedClientProbeJar -} - -def clientIntegrationRunDirectory = file("${buildDir}/client-integration-run") -task prepareClientIntegrationTest(dependsOn: clientIntegrationTestModJar) { - doLast { - delete clientIntegrationRunDirectory - clientIntegrationRunDirectory.mkdirs() - copy { - from clientIntegrationTestModJar.archiveFile - into new File(clientIntegrationRunDirectory, 'mods') - } - new File(clientIntegrationRunDirectory, 'options.txt').setText( - 'fullscreen:false\nforceUnicodeFont:false\nguiScale:2\n' + - 'renderDistance:4\nshowSubtitles:false\n', 'UTF-8') - } -} - -task clientIntegrationProcess(type: JavaExec, dependsOn: prepareClientIntegrationTest) { - group = 'verification' - workingDir clientIntegrationRunDirectory - systemProperty 'clientprobe.enabled', 'true' - configureFromForgeRun(delegate, 'runClient') - doLast { - File marker = new File(clientIntegrationRunDirectory, 'client-smoke-pass.properties') - if (!marker.isFile()) { - throw new GradleException("Client integration completion marker is missing: ${marker}") - } - 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('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}") - } - File latest = new File(clientIntegrationRunDirectory, 'logs/fml-client-latest.log') - if (latest.isFile() && (latest.text.contains('Encountered an unexpected exception') - || latest.text.contains('Reported exception thrown!'))) { - throw new GradleException("Client integration logged a runtime failure: ${latest}") - } - List allowedDevelopmentErrors = [ - 'The binary patch set is missing', - 'FML appears to be missing any signature data', - 'Unable to read a class file correctly', - 'problem reading the entry META-INF/versions/11/', - 'Trying to activate the universal filled bucket too late. Call it statically in your Mods class. Mod: Base Metals' - ] - List unaccountedErrors = latest.readLines('UTF-8').findAll { line -> - (line.contains('/ERROR]') || line.contains('/FATAL]')) - && !allowedDevelopmentErrors.any { line.contains(it) } - } - if (!unaccountedErrors.isEmpty()) { - throw new GradleException("Client integration logged unaccounted errors: ${unaccountedErrors}") - } - File crashDirectory = new File(clientIntegrationRunDirectory, 'crash-reports') - if (crashDirectory.isDirectory() && crashDirectory.listFiles().length > 0) { - throw new GradleException("Client integration produced crash reports: ${crashDirectory}") - } - } -} - -task syncForge12EclipseIntegrationLaunches { - group = 'ide' - doLast { - def writeGradleLaunch = { String fileName, String displayName, String arguments -> - File launch = file(fileName) - String escapedArguments = arguments - .replace('&', '&') - .replace('"', '"') - launch.setText(""" - - - - - - - - -""", 'UTF-8') - } - - writeGradleLaunch('OreSpawn_Surface_FreshReload.launch', - 'OreSpawn surface fresh/reload gate', - 'surfaceIntegrationTest --offline --no-daemon') - writeGradleLaunch('OreSpawn_Migration_Fresh.launch', - 'OreSpawn migration fresh gate', - 'migrationIntegrationProcess --offline --no-daemon ' + - '-PmigrationRunDir=build/eclipse-migration-run ' + - '-PmigrationFamily=eclipse-manual -PmigrationPhase=fresh') - writeGradleLaunch('OreSpawn_Migration_Reload.launch', - 'OreSpawn migration reload gate', - 'migrationIntegrationProcess --offline --no-daemon ' + - '-PmigrationRunDir=build/eclipse-migration-run ' + - '-PmigrationFamily=eclipse-manual -PmigrationPhase=reload') - writeGradleLaunch('OreSpawn_Client_FreshReload.launch', - 'OreSpawn client world and editor fresh/reload gate', - 'clientIntegrationProcess --offline --no-daemon') - } -} - -tasks.named('genEclipseRuns') { - finalizedBy syncForge12EclipseIntegrationLaunches -} - -if (project.hasProperty('benchmarkRunDir')) { - def benchmarkRunDirectory = file(project.property('benchmarkRunDir')) - task prepareWorldgenBenchmark { - doLast { - delete benchmarkRunDirectory - benchmarkRunDirectory.mkdirs() - new File(benchmarkRunDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') - new File(benchmarkRunDirectory, 'server.properties').setText( - "online-mode=false\nlevel-name=world\nlevel-type=${project.findProperty('benchmarkLevelType') ?: 'default'}\n" + - "level-seed=${project.findProperty('benchmarkSeed') ?: '-4965128775892001975'}\n" + - 'generate-structures=false\nspawn-animals=false\nspawn-monsters=false\n' + - 'max-tick-time=-1\n', 'UTF-8') - } - } -task benchmarkIntegrationProcess(type: JavaExec, dependsOn: prepareWorldgenBenchmark) { - group = 'verification' - workingDir benchmarkRunDirectory - systemProperty 'orespawn.worldgenBenchmarkMode', project.findProperty('benchmarkMode') ?: 'sky' - systemProperty 'orespawn.worldgenBenchmarkRadius', project.findProperty('benchmarkRadius') ?: '4' - systemProperty 'orespawn.worldgenBenchmarkRepetitions', project.findProperty('benchmarkRepetitions') ?: '3' - systemProperty 'orespawn.worldgenBenchmarkCenterX', project.findProperty('benchmarkCenterX') ?: '256' - systemProperty 'orespawn.worldgenBenchmarkCenterZ', project.findProperty('benchmarkCenterZ') ?: '256' - systemProperty 'orespawn.worldgenBenchmarkCenterStep', project.findProperty('benchmarkCenterStep') ?: '64' - systemProperty 'orespawn.worldgenBenchmarkStopServer', 'true' - systemProperty 'orespawn.worldgenBenchmarkVanillaOres', - project.findProperty('benchmarkVanillaOres') ?: 'false' - systemProperty 'orespawn.worldgenBenchmarkOreAudit', - project.findProperty('benchmarkOreAudit') ?: 'false' - if (project.hasProperty('benchmarkBlockAudit')) { - systemProperty 'orespawn.worldgenBenchmarkBlockAudit', project.property('benchmarkBlockAudit') - } - ['benchmarkBiomeType': 'orespawn.worldgenBenchmarkBiomeType', - 'benchmarkBiome': 'orespawn.worldgenBenchmarkBiome', - 'benchmarkNormalizeBlock': 'orespawn.worldgenBenchmarkNormalizeBlock', - 'benchmarkNormalizeBiome': 'orespawn.worldgenBenchmarkNormalizeBiome'].each { propertyName, systemName -> - if (project.hasProperty(propertyName)) { - systemProperty systemName, project.property(propertyName) - } - } - configureFromForgeRun(delegate, 'runServer') - doLast { - File latest = new File(benchmarkRunDirectory, 'logs/fml-server-latest.log') - String mode = project.findProperty('benchmarkMode') ?: 'sky' - if (!latest.isFile() || !latest.text.contains("ORESPAWN_BENCHMARK summary mode=${mode}")) { - throw new GradleException("Missing ${mode} worldgen benchmark summary: ${latest}") - } - if (latest.text.contains('Encountered an unexpected exception') - || latest.text.contains('Tried to assign a mutable BlockPos')) { - throw new GradleException("Worldgen benchmark logged a runtime failure: ${latest}") - } - } - } } def trackedDocumentationFiles = { @@ -838,6 +203,8 @@ tasks.register('verifyDocumentationParity') { } } +// Bundle the player guides and resolve mcmod.info's version placeholders. +// The parity task checks every guide against its processed copy. tasks.named('processResources', ProcessResources) { filteringCharset = 'UTF-8' inputs.property('version', project.version) @@ -856,31 +223,31 @@ tasks.named('processResources', ProcessResources) { filesMatching(archiveTextPatterns, normalizeArchiveLineEndings) } -def prepareEclipseResources = tasks.register('prepareEclipseResources') { - group = 'ide' - description = 'Copies Gradle-processed production resources into Eclipse merged output.' - dependsOn tasks.named('processResources') - doLast { - // Eclipse owns bin/main, so copy into it without declaring the - // directory as a Gradle output or deleting compiled classes. - project.copy { - from(layout.buildDirectory.dir('resources/main')) - into(layout.projectDirectory.dir('bin/main')) - } - } -} - +// Scripts have their own local variables. Share only the project values they +// actually need; release identity remains checked in the audit tasks. +ext.oreSpawnBuild = [ + mavenGroup: expectedMavenGroup, + mavenArtifact: expectedMavenArtifact, + mavenCoordinate: expectedMavenCoordinate, + archiveTextSuffixes: archiveTextSuffixes, + developmentOutput: forge10DevelopmentOutput, + trackedDocumentationFiles: trackedDocumentationFiles, + documentationRelativePath: documentationRelativePath +].asImmutable() + +// The development jar is mapped; the release script creates the reobfuscated +// jar that a player installs. Keep these source and documentation jars stable. tasks.named('jar', Jar) { archiveClassifier = 'deobf' destinationDirectory = layout.buildDirectory.dir('libs-dev') manifest { attributes([ - 'Specification-Title' : 'OreSpawn', - 'Specification-Vendor' : 'SkyBlade1978', + 'Specification-Title' : base.archivesName.get(), + 'Specification-Vendor' : project.mod_vendor, 'Specification-Version' : '1', 'Implementation-Title' : base.archivesName.get(), 'Implementation-Version' : project.version, - 'Implementation-Vendor' : 'SkyBlade1978', + 'Implementation-Vendor' : project.mod_vendor, 'OreSpawn-API-Version' : '1', 'FMLAT' : 'accesstransformer.cfg', 'Maven-Artifact' : expectedMavenCoordinate, @@ -890,37 +257,6 @@ tasks.named('jar', Jar) { } } -def releaseJar = renamer.classes(tasks.named('jar', Jar)) { - map.from minecraft.dependency.toSrgFile - archiveClassifier = null - accessTransformers = true - output = layout.buildDirectory.file("libs/OreSpawn-${project.version}.jar") -} - -def apiJar = tasks.register('apiJar', Jar) { - dependsOn tasks.named('classes') - archiveClassifier = 'api' - destinationDirectory = layout.buildDirectory.dir('libs-dev') - from sourceSets.main.output - include 'zone/moddev/mc/orespawn/api/**' - include 'com/mcmoddev/orespawn/api/**' - include 'com/mojang/serialization/**' - include 'cyano/orespawn/**' - manifest { - attributes([ - 'Implementation-Title' : 'OreSpawn-api', - 'Implementation-Version': project.version, - 'OreSpawn-API-Version' : '1' - ]) - } -} - -tasks.register('deobfJar') { - group = 'build' - description = 'Builds the local deobfuscated development jar under build/libs-dev.' - dependsOn tasks.named('jar') -} - tasks.named('sourcesJar', Jar) { filteringCharset = 'UTF-8' includeEmptyDirs = false @@ -948,587 +284,17 @@ tasks.named('javadocJar', Jar) { } } -['apiElements', 'runtimeElements'].each { configurationName -> - configurations.named(configurationName) { - artifacts.clear() - } - artifacts { - add(configurationName, releaseJar) - } -} - -tasks.named('assemble') { - dependsOn releaseJar - dependsOn apiJar - dependsOn tasks.named('sourcesJar') - dependsOn tasks.named('javadocJar') -} - -def expectedReleaseFiles = providers.provider { - def prefix = "${base.archivesName.get()}-${project.version}" - [ - "${prefix}.jar", - "${prefix}-sources.jar", - "${prefix}-javadoc.jar" - ] -} -def preparedReleaseDir = providers.gradleProperty('preparedReleaseDir') - -tasks.register('verifyReleaseConfiguration') { - group = 'verification' - description = 'Validates the target-qualified release, API, schemas, reports, and publishing identity.' - - doLast { - if (project.mod_version != '4.1.0.110021' - || project.mod_group != expectedMavenGroup) { - throw new GradleException("Unexpected OreSpawn release version: ${project.mod_version}") - } - if (project.minecraft_version != '1.10.2' - || project.forge_version != '12.18.3.2511' - || project.mapping_channel != 'stable' - || project.mapping_version != '29-1.10.2') { - throw new GradleException('Unexpected Minecraft, Forge, or mappings target') - } - if (project.loader_name != 'forge' || project.loader_code != '1' - || project.java_version != '8' || project.gradle_java_version != '17') { - throw new GradleException('Unexpected dispatcher target metadata') - } - if (project.group.toString() != expectedMavenGroup - || base.archivesName.get() != expectedMavenArtifact - || project.curseforge_project_id != '245586') { - throw new GradleException('Unexpected Maven or CurseForge publication identity') - } - - [ - 'src/main/java/zone/moddev/mc/orespawn/OreSpawn.java', - 'src/main/java/cyano/orespawn/OreSpawn.java', - 'src/main/java/com/mcmoddev/orespawn/compat/LegacyOs3Bridge.java', - 'src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java', - 'src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyOs3ProfileMigration.java', - 'README.md', 'CHANGELOG.txt' - ].each { path -> - if (!file(path).getText('UTF-8').contains('4.1.0.110021')) { - throw new GradleException("Authoritative release location does not contain 4.1.0.110021: ${path}") - } - } - if (!file('docs/API.md').getText('UTF-8').contains('orespawn@[4.0.6,5.0.0)')) { - throw new GradleException('Consumer compatibility floor must remain [4.0.6,5.0.0)') - } - if (!file('src/main/java/zone/moddev/mc/orespawn/api/OreSpawnApi.java') - .getText('UTF-8').contains('API_VERSION = 1')) { - 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 = 8') - || !file('src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java') - .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).containsAll([4, 5])) { - throw new GradleException('Provider schema must support schema versions 4 and 5') - } - } -} - -tasks.register('verifyReleaseArtifacts') { - group = 'verification' - description = 'Audits the exact three distributable jars and their release-critical contents.' - dependsOn tasks.named('verifyReleaseConfiguration') - dependsOn tasks.named('assemble') - - doLast { - File libs = layout.buildDirectory.dir('libs').get().asFile - List jars = (libs.listFiles() ?: [] as File[]) - .findAll { it.name.endsWith('.jar') } - .sort { it.name } - List actual = jars.collect { it.name } - List expected = expectedReleaseFiles.get().sort() - if (actual != expected) { - throw new GradleException("Expected exactly ${expected}, found ${actual}") - } - - jars.each { candidate -> - if (candidate.length() == 0L) { - throw new GradleException("Empty release artifact: ${candidate.name}") - } - ZipFile candidateZip = new ZipFile(candidate) - try { - candidateZip.entries().findAll { entry -> - !entry.isDirectory() - && (archiveTextSuffixes.any { entry.name.endsWith(it) } - || entry.name.endsWith('/element-list') - || entry.name.endsWith('/package-list')) - }.each { entry -> - boolean containsCarriageReturn = candidateZip.getInputStream(entry).withCloseable { input -> - input.bytes.any { value -> value == 13 } - } - if (containsCarriageReturn) { - throw new GradleException( - "${candidate.name}!/${entry.name} does not use canonical LF line endings") - } - } - ['src/test/', 'src/biomeIntegrationTest/', 'src/migrationIntegrationTest/', - 'src/clientIntegrationTest/', 'src/os1AbiFixture/', 'src/os3AbiFixture/', - 'agent-notes/', 'surfaceprobe', 'migrationprobe', 'clientprobe', - 'OreSourceEditorProbeBridge', - 'org/junit/', 'org/mockito/', 'net/bytebuddy/'].each { forbidden -> - if (candidateZip.entries().any { it.name.contains(forbidden) }) { - throw new GradleException( - "${candidate.name} contains forbidden entry matching ${forbidden}") - } - } - } finally { - candidateZip.close() - } - } - - File mainJar = new File(libs, expectedReleaseFiles.get()[0]) - ZipFile zip = new ZipFile(mainJar) - try { - List names = zip.entries().collect { it.name } - [ - 'mcmod.info', - 'META-INF/accesstransformer.cfg', - 'zone/moddev/mc/orespawn/api/OreSpawnApi.class', - 'zone/moddev/mc/orespawn/api/OreGenerationContext.class', - 'zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistry.class', - 'META-INF/orespawn/docs/VERSIONS.md', - 'META-INF/orespawn/docs/schemas/orespawn-provider.schema.json', - 'META-INF/orespawn/docs/schemas/orespawn-global.schema.json', - 'META-INF/orespawn/docs/schemas/orespawn-world.schema.json', - 'AGENTS.md' - ].each { required -> - if (!names.contains(required)) { - throw new GradleException("Release jar is missing ${required}") - } - } - List trackedDocs = trackedDocumentationFiles() - if (trackedDocs.size() != 21) { - throw new GradleException("Expected 21 tracked documentation files, found ${trackedDocs.size()}") - } - trackedDocs.each { File source -> - String relative = documentationRelativePath(source) - def entry = zip.getEntry("META-INF/orespawn/docs/${relative}") - if (entry == null || !java.util.Arrays.equals(source.bytes, - zip.getInputStream(entry).withCloseable { it.bytes })) { - throw new GradleException("Packaged documentation differs for ${relative}") - } - } - - String metadata = zip.getInputStream(zip.getEntry('mcmod.info')) - .getText(StandardCharsets.UTF_8.name()) - def parsed = new JsonSlurper().parseText(metadata) - def mod = parsed instanceof List ? parsed.first() : parsed - if (mod.modid != 'orespawn' || mod.version != project.mod_version - || mod.mcversion != project.minecraft_version) { - throw new GradleException('Packaged mcmod.info version or target is incorrect') - } - - String packagedAccessTransformer = zip.getInputStream( - zip.getEntry('META-INF/accesstransformer.cfg')) - .getText(StandardCharsets.UTF_8.name()) - List packagedAccessTransformerRules = packagedAccessTransformer.readLines() - .collect { it.replaceFirst(/\s*#.*/, '').trim() } - .findAll { !it.isEmpty() } - List expectedRuntimeAccessTransformerRules = [ - 'public-f net.minecraft.world.WorldProvider field_76578_c', - 'public-f net.minecraft.world.gen.ChunkProviderOverworld field_186001_t' - ] - if (packagedAccessTransformerRules != expectedRuntimeAccessTransformerRules) { - throw new GradleException('Packaged access transformer was not remapped to the runtime SRG rules') - } - - def manifestEntry = zip.getEntry('META-INF/MANIFEST.MF') - def manifest = manifestEntry == null ? null - : new Manifest(zip.getInputStream(manifestEntry)).mainAttributes - if (manifest == null - || manifest.getValue('Implementation-Version') != project.mod_version - || manifest.getValue('OreSpawn-API-Version') != '1' - || manifest.getValue('FMLAT') != 'accesstransformer.cfg' - || manifest.getValue('Maven-Artifact') != expectedMavenCoordinate - || manifest.getValue('Implementation-Timestamp') != null - || manifest.getValue('Timestamp') != null) { - throw new GradleException('Release manifest identity/API/FMLAT is incorrect or volatile') - } - - zip.entries().findAll { it.name.endsWith('.class') }.each { entry -> - byte[] header = new byte[8] - zip.getInputStream(entry).withCloseable { input -> - if (input.read(header) != header.length) { - throw new GradleException("Cannot inspect bytecode header for ${entry.name}") - } - } - int major = ((header[6] & 0xff) << 8) | (header[7] & 0xff) - if (major != 52) { - throw new GradleException("${entry.name} uses Java class major ${major}, expected 52") - } - } - } finally { - zip.close() - } - - File sources = new File(libs, expectedReleaseFiles.get()[1]) - new ZipFile(sources).withCloseable { sourceZip -> - if (sourceZip.getEntry('zone/moddev/mc/orespawn/OreSpawn.java') == null) { - throw new GradleException('Sources jar is missing OreSpawn.java') - } - } - File javadocs = new File(libs, expectedReleaseFiles.get()[2]) - new ZipFile(javadocs).withCloseable { javadocZip -> - if (javadocZip.getEntry('index.html') == null) { - throw new GradleException('Javadoc jar is missing index.html') - } - } - } -} - -tasks.register('writeReleaseChecksums') { - group = 'verification' - description = 'Writes SHA-256 checksums for the audited release jars.' - dependsOn tasks.named('verifyReleaseArtifacts') - def outputFile = layout.buildDirectory.file('release/SHA256SUMS') - inputs.files(providers.provider { - expectedReleaseFiles.get().collect { name -> - layout.buildDirectory.file("libs/${name}").get().asFile - } - }) - outputs.file(outputFile) - - doLast { - File output = outputFile.get().asFile - output.parentFile.mkdirs() - File libs = layout.buildDirectory.dir('libs').get().asFile - String contents = expectedReleaseFiles.get().sort().collect { name -> - MessageDigest digest = MessageDigest.getInstance('SHA-256') - new File(libs, name).withInputStream { input -> - byte[] buffer = new byte[8192] - for (int read = input.read(buffer); read >= 0; read = input.read(buffer)) { - if (read > 0) digest.update(buffer, 0, read) - } - } - "${digest.digest().encodeHex().toString().toUpperCase()} ${name}" - }.join('\n') + '\n' - output.setText(contents, 'UTF-8') - } -} - -tasks.register('verifyPreparedReleaseArtifacts') { - group = 'verification' - description = 'Audits a previously built immutable release bundle before remote Maven publication.' - - doLast { - if (!preparedReleaseDir.isPresent()) { - throw new GradleException('preparedReleaseDir is required for prepared artifact publication') - } - File prepared = file(preparedReleaseDir.get()) - if (!prepared.isDirectory()) { - throw new GradleException("Prepared release directory does not exist: ${prepared}") - } - List expected = expectedReleaseFiles.get().sort() - List jars = (prepared.listFiles() ?: [] as File[]) - .findAll { it.name.endsWith('.jar') } - .sort { it.name } - if (jars.collect { it.name } != expected || jars.any { it.length() == 0L }) { - throw new GradleException("Prepared release must contain exactly the non-empty jars ${expected}") - } - File checksums = new File(prepared, 'SHA256SUMS') - File changelog = new File(prepared, 'CHANGELOG.txt') - if (!checksums.isFile() || !changelog.isFile()) { - throw new GradleException('Prepared release is missing SHA256SUMS or CHANGELOG.txt') - } - List actualChecksums = jars.collect { candidate -> - MessageDigest digest = MessageDigest.getInstance('SHA-256') - candidate.withInputStream { input -> - byte[] buffer = new byte[8192] - for (int read = input.read(buffer); read >= 0; read = input.read(buffer)) { - if (read > 0) digest.update(buffer, 0, read) - } - } - "${digest.digest().encodeHex().toString().toUpperCase()} ${candidate.name}" - }.sort() - List declaredChecksums = checksums.readLines('UTF-8') - .findAll { !it.trim().isEmpty() } - .sort() - if (actualChecksums != declaredChecksums) { - throw new GradleException('Prepared release checksums do not match the immutable jars') - } - } -} - -def mavenUploadUrl = providers.environmentVariable('MAVEN_UPLOAD_URL') - .orElse('https://invalid.invalid/missing-maven-upload-url') -def mavenUploadUsername = providers.environmentVariable('MAVEN_UPLOAD_USERNAME') -def mavenUploadPassword = providers.environmentVariable('MAVEN_UPLOAD_PASSWORD') - -publishing { - publications { - mavenJava(MavenPublication) { - groupId = expectedMavenGroup - artifactId = expectedMavenArtifact - version = project.version.toString() - if (preparedReleaseDir.isPresent()) { - File prepared = file(preparedReleaseDir.get()) - artifact(new File(prepared, expectedReleaseFiles.get()[0])) - artifact(new File(prepared, expectedReleaseFiles.get()[1])) { - classifier = 'sources' - } - artifact(new File(prepared, expectedReleaseFiles.get()[2])) { - classifier = 'javadoc' - } - } else { - artifact(releaseJar) - artifact(tasks.named('sourcesJar')) - artifact(tasks.named('javadocJar')) - } - pom { - name = 'MMD OreSpawn' - description = project.mod_description - url = 'https://github.com/MinecraftModDevelopmentMods/OreSpawn' - licenses { - license { - name = 'GNU Lesser General Public License, Version 2.1' - url = 'https://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt' - } - } - scm { - connection = 'scm:git:https://github.com/MinecraftModDevelopmentMods/OreSpawn.git' - developerConnection = 'scm:git:ssh://git@github.com/MinecraftModDevelopmentMods/OreSpawn.git' - url = 'https://github.com/MinecraftModDevelopmentMods/OreSpawn' - } - } - } - } - repositories { - maven { - name = 'release' - url = uri(mavenUploadUrl.get()) - credentials { - username = mavenUploadUsername.orNull ?: '' - password = mavenUploadPassword.orNull ?: '' - } - } - } -} - -tasks.register('verifyMavenCoordinates') { - group = 'verification' - description = 'Verifies the generated POM uses OreSpawn\'s mod-specific Maven namespace.' - dependsOn tasks.named('generatePomFileForMavenJavaPublication') - doLast { - File pomFile = layout.buildDirectory.file( - 'publications/mavenJava/pom-default.xml').get().asFile - if (!pomFile.isFile()) { - throw new GradleException("Generated Maven POM does not exist: ${pomFile}") - } - def pom = new XmlSlurper(false, false).parse(pomFile) - def actual = [pom.groupId.text(), pom.artifactId.text(), pom.version.text()] - def expected = [expectedMavenGroup, expectedMavenArtifact, project.version.toString()] - if (project.group.toString() != expectedMavenGroup || actual != expected) { - throw new GradleException("Expected Maven coordinate ${expected.join(':')}, " - + "found ${actual.join(':')}") - } - } -} - -tasks.register('validateMavenReleaseCredentials') { - group = 'publishing' - description = 'Prevents Maven publication from targeting a local or incomplete repository.' - doLast { - if (!providers.environmentVariable('MAVEN_UPLOAD_URL').isPresent() - || !mavenUploadUsername.isPresent() - || !mavenUploadPassword.isPresent()) { - throw new GradleException( - 'MAVEN_UPLOAD_URL, MAVEN_UPLOAD_USERNAME, and MAVEN_UPLOAD_PASSWORD are required') - } - String target = providers.environmentVariable('MAVEN_UPLOAD_URL').get() - if (target.startsWith('file:')) { - throw new GradleException('Maven release publication must use a remote repository') - } - } -} - -tasks.withType(PublishToMavenRepository).configureEach { - dependsOn tasks.named('validateMavenReleaseCredentials') - dependsOn tasks.named('verifyMavenCoordinates') - if (preparedReleaseDir.isPresent()) { - dependsOn tasks.named('verifyPreparedReleaseArtifacts') - } else { - dependsOn tasks.named('verifyReleaseArtifacts') - } -} - -tasks.named('check') { - dependsOn tasks.named('verifyMavenCoordinates') - dependsOn tasks.named('verifyDocumentationParity') -} - idea { module { downloadSources = true downloadJavadoc = true } } - -// ForgeGradle 2 generated these launch names with the removed GradleStart -// bootstrap. They can survive an in-place Buildship migration and remain the -// last-run Eclipse configuration even after ForgeGradle 7 generates runClient -// and runServer. Remove only the exact obsolete launch types; leave any -// independently maintained launch with the same file name untouched. -def obsoleteForgeGradle2EclipseLaunches = [ - 'OreSpawn_Client.launch': 'GradleStart', - 'OreSpawn_Server.launch': 'GradleStartServer' -] - -eclipse { - classpath { - downloadSources = true - downloadJavadoc = true - } - synchronizationTasks 'isolateEclipseProductionRuns' -} - -tasks.register('configureEclipseBuildship') { - group = 'ide' - description = 'Creates the Buildship project preferences used by ForgeGradle 7 imports.' - doLast { - File preferencesFile = file('.settings/org.eclipse.buildship.core.prefs') - Properties preferences = new Properties() - Map requiredPreferences = [ - 'eclipse.preferences.version' : '1', - 'connection.gradle.distribution': 'GRADLE_DISTRIBUTION(WRAPPER)', - 'connection.gradle.user.home' : gradle.gradleUserHomeDir.canonicalPath, - 'connection.project.dir' : '', - 'gradle.user.home' : gradle.gradleUserHomeDir.canonicalPath, - 'override.workspace.settings' : 'true' - ] - requiredPreferences.each { key, value -> preferences.setProperty(key, value) } - preferencesFile.parentFile.mkdirs() - preferencesFile.withOutputStream { - preferences.store(it, - 'Generated by configureEclipseBuildship; keep Eclipse and command-line caches aligned.') - } - } -} - -tasks.register('isolateEclipseProductionRuns') { - group = 'ide' - description = 'Marks ordinary ForgeGradle 7 Eclipse launches as production-only.' - dependsOn tasks.named('genEclipseRuns') - dependsOn tasks.named('configureEclipseBuildship') - dependsOn prepareEclipseResources - - doLast { - obsoleteForgeGradle2EclipseLaunches.each { String name, String mainClass -> - File launch = file(name) - if (launch.isFile()) { - String contents = launch.getText('UTF-8') - String obsoleteMainType = - "org.eclipse.jdt.launching.MAIN_TYPE\" value=\"${mainClass}\"" - if (contents.contains(obsoleteMainType) && !launch.delete()) { - throw new GradleException("Could not remove obsolete ForgeGradle 2 launch ${name}") - } - } - } - - ['runClient.launch', 'runServer.launch'].each { name -> - File launch = file(name) - if (!launch.isFile()) { - throw new GradleException("ForgeGradle did not generate ${name}") - } - String contents = launch.getText('UTF-8') - if (!contents.contains('org.eclipse.jdt.launching.ATTR_EXCLUDE_TEST_CODE')) { - String marker = '' - String attribute = - ' ' - contents = contents.replace(marker, attribute + '\n' + marker) - } - launch.setText(contents.replace('\r\n', '\n'), 'UTF-8') - } - } -} - -tasks.register('verifyEclipseProductionClasspath') { - group = 'verification' - description = 'Verifies that ordinary generated Eclipse launches exclude tests, fixtures, and probe mods.' - dependsOn tasks.named('isolateEclipseProductionRuns') - dependsOn tasks.named('verifyLegacyOracleIsolation') - - doLast { - File buildshipPreferences = file('.settings/org.eclipse.buildship.core.prefs') - if (!buildshipPreferences.isFile()) { - throw new GradleException('Missing Eclipse Buildship project preferences') - } - File eclipseResources = file('bin/main') - List trackedDocs = trackedDocumentationFiles() - if (trackedDocs.size() != 21) { - throw new GradleException("Expected 21 tracked documentation files, found ${trackedDocs.size()}") - } - trackedDocs.each { File source -> - String relative = documentationRelativePath(source) - File eclipseCopy = new File(eclipseResources, "META-INF/orespawn/docs/${relative}") - if (!eclipseCopy.isFile() - || !java.util.Arrays.equals(source.bytes, eclipseCopy.bytes)) { - throw new GradleException("Eclipse documentation differs for ${relative}") - } - } - [ - 'mcmod.info', - 'META-INF/orespawn/docs/README.md', - 'META-INF/orespawn/docs/VERSIONS.md' - ].each { relative -> - File required = new File(eclipseResources, relative) - if (!required.isFile()) { - throw new GradleException( - "Eclipse production output is missing processed resource ${relative}") - } - } - String eclipseMetadata = new File(eclipseResources, 'mcmod.info').getText('UTF-8') - if (!eclipseMetadata.contains("\"version\": \"${project.version}\"") - || !eclipseMetadata.contains("\"mcversion\": \"${minecraft_version}\"")) { - throw new GradleException('Eclipse mcmod.info retains unexpanded build placeholders') - } - List launchFiles = fileTree(project.projectDir) { - include 'runClient.launch' - include 'runServer.launch' - include '.eclipse/runClient.launch' - include '.eclipse/runServer.launch' - }.files as List - if (launchFiles.size() < 2) { - throw new GradleException('ForgeGradle did not generate ordinary client/server Eclipse launches') - } - obsoleteForgeGradle2EclipseLaunches.each { String name, String mainClass -> - File launch = file(name) - if (launch.isFile() && launch.getText('UTF-8').contains( - "org.eclipse.jdt.launching.MAIN_TYPE\" value=\"${mainClass}\"")) { - throw new GradleException( - "Obsolete ForgeGradle 2 ${mainClass} launch remains at ${name}") - } - } - List forbidden = [ - 'src/test', 'bin/test', 'build/classes/java/test', - 'biomeIntegrationTest', 'migrationIntegrationTest', 'clientIntegrationTest', - 'os1AbiFixture', 'os3AbiFixture', 'surfaceprobe', 'migrationprobe', 'clientprobe', - 'OreSourceEditorProbeBridge', - 'junit-', 'opentest4j-', 'junit-platform-', 'Mineralogy-1.10.2-3.3.8.26.jar' - ] - launchFiles.each { launch -> - String contents = launch.getText('UTF-8').replace('\\', '/') - List present = forbidden.findAll { contents.contains(it) } - if (!present.isEmpty()) { - throw new GradleException("${launch.name} exposes test code/dependencies: ${present}") - } - if (!contents.contains('org.eclipse.jdt.launching.ATTR_EXCLUDE_TEST_CODE') - || !contents.contains('value="true"')) { - throw new GradleException("${launch.name} does not exclude test code") - } - if (!contents.contains('PROJECT_ATTR" value="OreSpawn"')) { - throw new GradleException("${launch.name} targets the wrong Eclipse project") - } - if (launch.name.toLowerCase().contains('server') - && (!contents.contains('nogui') || contents.contains('--nogui'))) { - throw new GradleException("${launch.name} does not use Forge 1.10's bare nogui argument") - } - } - } -} +// Apply in dependency order: fixtures and run helpers first, then their gates, +// release artifacts, publishing, and finally Eclipse integration. +apply from: 'gradle/verification/support.gradle' +apply from: 'gradle/verification/legacy-and-migration.gradle' +apply from: 'gradle/verification/client-and-benchmark.gradle' +apply from: 'gradle/release/artifacts.gradle' +apply from: 'gradle/release/publishing.gradle' +apply from: 'gradle/ide/eclipse.gradle' diff --git a/gradle.properties b/gradle.properties index e4ae0128..f16c8ae7 100644 --- a/gradle.properties +++ b/gradle.properties @@ -25,8 +25,15 @@ curseforge_project_id=245586 mod_id=orespawn mod_name=MMD OreSpawn +mod_artifact=OreSpawn +mod_vendor=Minecraft Mod Development mod_license=LGPL-2.1 +mod_license_name=GNU Lesser General Public License, Version 2.1 +mod_license_url=https://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt mod_version=4.1.0.110021 mod_group=zone.moddev.mc.orespawn mod_authors=SkyBlade1978, dshadowwolf, the MMD Team mod_description=Configurable, provider-driven terrain, ore, and deposit generation. +mod_url=https://github.com/MinecraftModDevelopmentMods/OreSpawn +mod_scm_connection=scm:git:https://github.com/MinecraftModDevelopmentMods/OreSpawn.git +mod_scm_developer_connection=scm:git:ssh://git@github.com/MinecraftModDevelopmentMods/OreSpawn.git diff --git a/gradle/README.md b/gradle/README.md new file mode 100644 index 00000000..8b42eda8 --- /dev/null +++ b/gradle/README.md @@ -0,0 +1,36 @@ +# Build logic + +`build.gradle` describes the Forge 1.10 project: its dependencies, Java 8 +output, development runs, processed resources and ordinary jars. The applied +scripts keep the longer test, release and Eclipse workflows close to the tasks +they own. They are part of this repository, not a separate Gradle plugin. +Editable release identity (name, vendor, artifact name, license and project +links) lives in `gradle.properties`; compatibility-sensitive group and artifact +values are still checked by the release audit. + +| Script | What belongs here | +| --- | --- | +| `verification/support.gradle` | Sealed old-mod fixtures, test-only Mineralogy oracle and the Forge-run helper. | +| `verification/legacy-and-migration.gradle` | Fresh/reload surface worlds, Mineralogy 3 migration and opt-in OS1/OS3 binary checks. | +| `verification/client-and-benchmark.gradle` | Rendered client check and opt-in worldgen benchmark. | +| `release/artifacts.gradle` | Reobfuscated jar, release audits and SHA-256 checksums. | +| `release/publishing.gradle` | Maven coordinates, POM and credential guards. | +| `ide/eclipse.gradle` | Processed Eclipse resources, integration launches and production-classpath checks. | + +The root applies the scripts in dependency order. `oreSpawnBuild` shares a few +project values with release and Eclipse tasks; `oreSpawnVerification` shares +fixture paths and the Forge-run helper; `oreSpawnRelease` shares the audited +jar and file names with publishing. Keep a new task in its owning script and +keep existing task names stable for CI and documented commands. + +The routine offline gate is `clean check build javadoc verifyReleaseArtifacts +writeReleaseChecksums`. Run `clientIntegrationProcess` separately to exercise +the editor and a rendered fresh/reloaded world, and `genEclipseRuns +verifyEclipseProductionClasspath` for normal Eclipse launches. The OS1/OS3 +ABI check and worldgen benchmark are opt-in. The benchmark needs +`-PbenchmarkRunDir=...`; it is not part of every build. + +Forge 1.10's Mineralogy 3 migration gate is **not** obsolete: it runs under +`check` and protects the Cyano world-profile handoff. Unlike the 1.12 build, +this branch has no Gradle packaged-Forge runtime task. The exact reobfuscated +jar is tested separately in a disposable official Forge 12 installation. diff --git a/gradle/ide/eclipse.gradle b/gradle/ide/eclipse.gradle new file mode 100644 index 00000000..2b24379d --- /dev/null +++ b/gradle/ide/eclipse.gradle @@ -0,0 +1,230 @@ +def buildValues = project.ext.oreSpawnBuild +def trackedDocumentationFiles = buildValues.trackedDocumentationFiles +def documentationRelativePath = buildValues.documentationRelativePath + +// The named launches are the human-facing integration gates in Eclipse. +// Keep the older task name for anyone invoking it directly; the launches it +// writes below all point at this Forge 1.10 checkout. +task syncForge12EclipseIntegrationLaunches { + group = 'ide' + doLast { + def writeGradleLaunch = { String fileName, String displayName, String arguments -> + File launch = file(fileName) + String escapedArguments = arguments + .replace('&', '&') + .replace('"', '"') + launch.setText(""" + + + + + + + + +""", 'UTF-8') + } + + writeGradleLaunch('OreSpawn_Surface_FreshReload.launch', + 'OreSpawn surface fresh/reload gate', + 'surfaceIntegrationTest --offline --no-daemon') + writeGradleLaunch('OreSpawn_Migration_Fresh.launch', + 'OreSpawn migration fresh gate', + 'migrationIntegrationProcess --offline --no-daemon ' + + '-PmigrationRunDir=build/eclipse-migration-run ' + + '-PmigrationFamily=eclipse-manual -PmigrationPhase=fresh') + writeGradleLaunch('OreSpawn_Migration_Reload.launch', + 'OreSpawn migration reload gate', + 'migrationIntegrationProcess --offline --no-daemon ' + + '-PmigrationRunDir=build/eclipse-migration-run ' + + '-PmigrationFamily=eclipse-manual -PmigrationPhase=reload') + writeGradleLaunch('OreSpawn_Client_FreshReload.launch', + 'OreSpawn client world and editor fresh/reload gate', + 'clientIntegrationProcess --offline --no-daemon') + } +} + +tasks.named('genEclipseRuns') { + finalizedBy syncForge12EclipseIntegrationLaunches +} + +// Eclipse owns bin/main, so copy processed resources into it without deleting +// Eclipse's compiled classes or exposing test resources to normal launches. +def prepareEclipseResources = tasks.register('prepareEclipseResources') { + group = 'ide' + description = 'Copies Gradle-processed production resources into Eclipse merged output.' + dependsOn tasks.named('processResources') + doLast { + // Eclipse owns bin/main, so copy into it without declaring the + // directory as a Gradle output or deleting compiled classes. + project.copy { + from(layout.buildDirectory.dir('resources/main')) + into(layout.projectDirectory.dir('bin/main')) + } + } +} + +// ForgeGradle 2 generated these launch names with the removed GradleStart +// bootstrap. They can survive an in-place Buildship migration and remain the +// last-run Eclipse configuration even after ForgeGradle 7 generates runClient +// and runServer. Remove only the exact obsolete launch types; leave any +// independently maintained launch with the same file name untouched. +def obsoleteForgeGradle2EclipseLaunches = [ + 'OreSpawn_Client.launch': 'GradleStart', + 'OreSpawn_Server.launch': 'GradleStartServer' +] + +eclipse { + classpath { + downloadSources = true + downloadJavadoc = true + } + synchronizationTasks 'isolateEclipseProductionRuns' +} + +tasks.register('configureEclipseBuildship') { + group = 'ide' + description = 'Creates the Buildship project preferences used by ForgeGradle 7 imports.' + doLast { + File preferencesFile = file('.settings/org.eclipse.buildship.core.prefs') + Properties preferences = new Properties() + Map requiredPreferences = [ + 'eclipse.preferences.version' : '1', + 'connection.gradle.distribution': 'GRADLE_DISTRIBUTION(WRAPPER)', + 'connection.gradle.user.home' : gradle.gradleUserHomeDir.canonicalPath, + 'connection.project.dir' : '', + 'gradle.user.home' : gradle.gradleUserHomeDir.canonicalPath, + 'override.workspace.settings' : 'true' + ] + requiredPreferences.each { key, value -> preferences.setProperty(key, value) } + preferencesFile.parentFile.mkdirs() + preferencesFile.withOutputStream { + preferences.store(it, + 'Generated by configureEclipseBuildship; keep Eclipse and command-line caches aligned.') + } + } +} + +tasks.register('isolateEclipseProductionRuns') { + group = 'ide' + description = 'Marks ordinary ForgeGradle 7 Eclipse launches as production-only.' + dependsOn tasks.named('genEclipseRuns') + dependsOn tasks.named('configureEclipseBuildship') + dependsOn prepareEclipseResources + + doLast { + obsoleteForgeGradle2EclipseLaunches.each { String name, String mainClass -> + File launch = file(name) + if (launch.isFile()) { + String contents = launch.getText('UTF-8') + String obsoleteMainType = + "org.eclipse.jdt.launching.MAIN_TYPE\" value=\"${mainClass}\"" + if (contents.contains(obsoleteMainType) && !launch.delete()) { + throw new GradleException("Could not remove obsolete ForgeGradle 2 launch ${name}") + } + } + } + + ['runClient.launch', 'runServer.launch'].each { name -> + File launch = file(name) + if (!launch.isFile()) { + throw new GradleException("ForgeGradle did not generate ${name}") + } + String contents = launch.getText('UTF-8') + if (!contents.contains('org.eclipse.jdt.launching.ATTR_EXCLUDE_TEST_CODE')) { + String marker = '' + String attribute = + ' ' + contents = contents.replace(marker, attribute + '\n' + marker) + } + launch.setText(contents.replace('\r\n', '\n'), 'UTF-8') + } + } +} + +// A menu opening is not enough: ordinary launches must use production code +// only, while the named integration launches remain available for testing. +tasks.register('verifyEclipseProductionClasspath') { + group = 'verification' + description = 'Verifies that ordinary generated Eclipse launches exclude tests, fixtures, and probe mods.' + dependsOn tasks.named('isolateEclipseProductionRuns') + dependsOn tasks.named('verifyLegacyOracleIsolation') + + doLast { + File buildshipPreferences = file('.settings/org.eclipse.buildship.core.prefs') + if (!buildshipPreferences.isFile()) { + throw new GradleException('Missing Eclipse Buildship project preferences') + } + File eclipseResources = file('bin/main') + List trackedDocs = trackedDocumentationFiles() + if (trackedDocs.size() != 21) { + throw new GradleException("Expected 21 tracked documentation files, found ${trackedDocs.size()}") + } + trackedDocs.each { File source -> + String relative = documentationRelativePath(source) + File eclipseCopy = new File(eclipseResources, "META-INF/orespawn/docs/${relative}") + if (!eclipseCopy.isFile() + || !java.util.Arrays.equals(source.bytes, eclipseCopy.bytes)) { + throw new GradleException("Eclipse documentation differs for ${relative}") + } + } + [ + 'mcmod.info', + 'META-INF/orespawn/docs/README.md', + 'META-INF/orespawn/docs/VERSIONS.md' + ].each { relative -> + File required = new File(eclipseResources, relative) + if (!required.isFile()) { + throw new GradleException( + "Eclipse production output is missing processed resource ${relative}") + } + } + String eclipseMetadata = new File(eclipseResources, 'mcmod.info').getText('UTF-8') + if (!eclipseMetadata.contains("\"version\": \"${project.version}\"") + || !eclipseMetadata.contains("\"mcversion\": \"${minecraft_version}\"")) { + throw new GradleException('Eclipse mcmod.info retains unexpanded build placeholders') + } + List launchFiles = fileTree(project.projectDir) { + include 'runClient.launch' + include 'runServer.launch' + include '.eclipse/runClient.launch' + include '.eclipse/runServer.launch' + }.files as List + if (launchFiles.size() < 2) { + throw new GradleException('ForgeGradle did not generate ordinary client/server Eclipse launches') + } + obsoleteForgeGradle2EclipseLaunches.each { String name, String mainClass -> + File launch = file(name) + if (launch.isFile() && launch.getText('UTF-8').contains( + "org.eclipse.jdt.launching.MAIN_TYPE\" value=\"${mainClass}\"")) { + throw new GradleException( + "Obsolete ForgeGradle 2 ${mainClass} launch remains at ${name}") + } + } + List forbidden = [ + 'src/test', 'bin/test', 'build/classes/java/test', + 'biomeIntegrationTest', 'migrationIntegrationTest', 'clientIntegrationTest', + 'os1AbiFixture', 'os3AbiFixture', 'surfaceprobe', 'migrationprobe', 'clientprobe', + 'OreSourceEditorProbeBridge', + 'junit-', 'opentest4j-', 'junit-platform-', 'Mineralogy-1.10.2-3.3.8.26.jar' + ] + launchFiles.each { launch -> + String contents = launch.getText('UTF-8').replace('\\', '/') + List present = forbidden.findAll { contents.contains(it) } + if (!present.isEmpty()) { + throw new GradleException("${launch.name} exposes test code/dependencies: ${present}") + } + if (!contents.contains('org.eclipse.jdt.launching.ATTR_EXCLUDE_TEST_CODE') + || !contents.contains('value="true"')) { + throw new GradleException("${launch.name} does not exclude test code") + } + if (!contents.contains('PROJECT_ATTR" value="OreSpawn"')) { + throw new GradleException("${launch.name} targets the wrong Eclipse project") + } + if (launch.name.toLowerCase().contains('server') + && (!contents.contains('nogui') || contents.contains('--nogui'))) { + throw new GradleException("${launch.name} does not use Forge 1.10's bare nogui argument") + } + } + } +} diff --git a/gradle/release/artifacts.gradle b/gradle/release/artifacts.gradle new file mode 100644 index 00000000..092780cd --- /dev/null +++ b/gradle/release/artifacts.gradle @@ -0,0 +1,370 @@ +import groovy.json.JsonSlurper +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.util.jar.Manifest +import java.util.zip.ZipFile + +def buildValues = project.ext.oreSpawnBuild +def expectedMavenGroup = buildValues.mavenGroup +def expectedMavenArtifact = buildValues.mavenArtifact +def expectedMavenCoordinate = buildValues.mavenCoordinate +def archiveTextSuffixes = buildValues.archiveTextSuffixes +def trackedDocumentationFiles = buildValues.trackedDocumentationFiles +def documentationRelativePath = buildValues.documentationRelativePath + +// Forge needs a reobfuscated jar; the mapped jar in libs-dev is only for +// local development. All release audits below inspect the shipped copy. +def releaseJar = renamer.classes(tasks.named('jar', Jar)) { + map.from minecraft.dependency.toSrgFile + archiveClassifier = null + accessTransformers = true + output = layout.buildDirectory.file("libs/${base.archivesName.get()}-${project.version}.jar") +} + +def apiJar = tasks.register('apiJar', Jar) { + dependsOn tasks.named('classes') + archiveClassifier = 'api' + destinationDirectory = layout.buildDirectory.dir('libs-dev') + from sourceSets.main.output + include 'zone/moddev/mc/orespawn/api/**' + include 'com/mcmoddev/orespawn/api/**' + include 'com/mojang/serialization/**' + include 'cyano/orespawn/**' + manifest { + attributes([ + 'Implementation-Title' : 'OreSpawn-api', + 'Implementation-Version': project.version, + 'OreSpawn-API-Version' : '1' + ]) + } +} + +tasks.register('deobfJar') { + group = 'build' + description = 'Builds the local deobfuscated development jar under build/libs-dev.' + dependsOn tasks.named('jar') +} + +['apiElements', 'runtimeElements'].each { configurationName -> + configurations.named(configurationName) { + artifacts.clear() + } + artifacts { + add(configurationName, releaseJar) + } +} + +tasks.named('assemble') { + dependsOn releaseJar + dependsOn apiJar + dependsOn tasks.named('sourcesJar') + dependsOn tasks.named('javadocJar') +} + +def expectedReleaseFiles = providers.provider { + def prefix = "${base.archivesName.get()}-${project.version}" + [ + "${prefix}.jar", + "${prefix}-sources.jar", + "${prefix}-javadoc.jar" + ] +} +def preparedReleaseDir = providers.gradleProperty('preparedReleaseDir') + +// Catch wrong target numbers, API or schemas before checking any jar contents. +tasks.register('verifyReleaseConfiguration') { + group = 'verification' + description = 'Validates the target-qualified release, API, schemas, reports, and publishing identity.' + + doLast { + if (project.mod_version != '4.1.0.110021' + || project.mod_group != expectedMavenGroup) { + throw new GradleException("Unexpected OreSpawn release version: ${project.mod_version}") + } + if (project.minecraft_version != '1.10.2' + || project.forge_version != '12.18.3.2511' + || project.mapping_channel != 'stable' + || project.mapping_version != '29-1.10.2') { + throw new GradleException('Unexpected Minecraft, Forge, or mappings target') + } + if (project.loader_name != 'forge' || project.loader_code != '1' + || project.java_version != '8' || project.gradle_java_version != '17') { + throw new GradleException('Unexpected dispatcher target metadata') + } + if (project.group.toString() != expectedMavenGroup + || project.mod_artifact != expectedMavenArtifact + || base.archivesName.get() != expectedMavenArtifact + || project.curseforge_project_id != '245586') { + throw new GradleException('Unexpected Maven or CurseForge publication identity') + } + + [ + 'src/main/java/zone/moddev/mc/orespawn/OreSpawn.java', + 'src/main/java/cyano/orespawn/OreSpawn.java', + 'src/main/java/com/mcmoddev/orespawn/compat/LegacyOs3Bridge.java', + 'src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java', + 'src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyOs3ProfileMigration.java', + 'README.md', 'CHANGELOG.txt' + ].each { path -> + if (!file(path).getText('UTF-8').contains('4.1.0.110021')) { + throw new GradleException("Authoritative release location does not contain 4.1.0.110021: ${path}") + } + } + if (!file('docs/API.md').getText('UTF-8').contains('orespawn@[4.0.6,5.0.0)')) { + throw new GradleException('Consumer compatibility floor must remain [4.0.6,5.0.0)') + } + if (!file('src/main/java/zone/moddev/mc/orespawn/api/OreSpawnApi.java') + .getText('UTF-8').contains('API_VERSION = 1')) { + 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 = 8') + || !file('src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java') + .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).containsAll([4, 5])) { + throw new GradleException('Provider schema must support schema versions 4 and 5') + } + } +} + +// Read the files that would actually ship: names, manifests, bytecode, +// bundled guides and any accidentally included test fixture. +tasks.register('verifyReleaseArtifacts') { + group = 'verification' + description = 'Audits the exact three distributable jars and their release-critical contents.' + dependsOn tasks.named('verifyReleaseConfiguration') + dependsOn tasks.named('assemble') + + doLast { + File libs = layout.buildDirectory.dir('libs').get().asFile + List jars = (libs.listFiles() ?: [] as File[]) + .findAll { it.name.endsWith('.jar') } + .sort { it.name } + List actual = jars.collect { it.name } + List expected = expectedReleaseFiles.get().sort() + if (actual != expected) { + throw new GradleException("Expected exactly ${expected}, found ${actual}") + } + + jars.each { candidate -> + if (candidate.length() == 0L) { + throw new GradleException("Empty release artifact: ${candidate.name}") + } + ZipFile candidateZip = new ZipFile(candidate) + try { + candidateZip.entries().findAll { entry -> + !entry.isDirectory() + && (archiveTextSuffixes.any { entry.name.endsWith(it) } + || entry.name.endsWith('/element-list') + || entry.name.endsWith('/package-list')) + }.each { entry -> + boolean containsCarriageReturn = candidateZip.getInputStream(entry).withCloseable { input -> + input.bytes.any { value -> value == 13 } + } + if (containsCarriageReturn) { + throw new GradleException( + "${candidate.name}!/${entry.name} does not use canonical LF line endings") + } + } + ['src/test/', 'src/biomeIntegrationTest/', 'src/migrationIntegrationTest/', + 'src/clientIntegrationTest/', 'src/os1AbiFixture/', 'src/os3AbiFixture/', + 'agent-notes/', 'surfaceprobe', 'migrationprobe', 'clientprobe', + 'OreSourceEditorProbeBridge', + 'org/junit/', 'org/mockito/', 'net/bytebuddy/'].each { forbidden -> + if (candidateZip.entries().any { it.name.contains(forbidden) }) { + throw new GradleException( + "${candidate.name} contains forbidden entry matching ${forbidden}") + } + } + } finally { + candidateZip.close() + } + } + + File mainJar = new File(libs, expectedReleaseFiles.get()[0]) + ZipFile zip = new ZipFile(mainJar) + try { + List names = zip.entries().collect { it.name } + [ + 'mcmod.info', + 'META-INF/accesstransformer.cfg', + 'zone/moddev/mc/orespawn/api/OreSpawnApi.class', + 'zone/moddev/mc/orespawn/api/OreGenerationContext.class', + 'zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistry.class', + 'META-INF/orespawn/docs/VERSIONS.md', + 'META-INF/orespawn/docs/schemas/orespawn-provider.schema.json', + 'META-INF/orespawn/docs/schemas/orespawn-global.schema.json', + 'META-INF/orespawn/docs/schemas/orespawn-world.schema.json', + 'AGENTS.md' + ].each { required -> + if (!names.contains(required)) { + throw new GradleException("Release jar is missing ${required}") + } + } + List trackedDocs = trackedDocumentationFiles() + if (trackedDocs.size() != 21) { + throw new GradleException("Expected 21 tracked documentation files, found ${trackedDocs.size()}") + } + trackedDocs.each { File source -> + String relative = documentationRelativePath(source) + def entry = zip.getEntry("META-INF/orespawn/docs/${relative}") + if (entry == null || !java.util.Arrays.equals(source.bytes, + zip.getInputStream(entry).withCloseable { it.bytes })) { + throw new GradleException("Packaged documentation differs for ${relative}") + } + } + + String metadata = zip.getInputStream(zip.getEntry('mcmod.info')) + .getText(StandardCharsets.UTF_8.name()) + def parsed = new JsonSlurper().parseText(metadata) + def mod = parsed instanceof List ? parsed.first() : parsed + if (mod.modid != 'orespawn' || mod.version != project.mod_version + || mod.mcversion != project.minecraft_version) { + throw new GradleException('Packaged mcmod.info version or target is incorrect') + } + + String packagedAccessTransformer = zip.getInputStream( + zip.getEntry('META-INF/accesstransformer.cfg')) + .getText(StandardCharsets.UTF_8.name()) + List packagedAccessTransformerRules = packagedAccessTransformer.readLines() + .collect { it.replaceFirst(/\s*#.*/, '').trim() } + .findAll { !it.isEmpty() } + List expectedRuntimeAccessTransformerRules = [ + 'public-f net.minecraft.world.WorldProvider field_76578_c', + 'public-f net.minecraft.world.gen.ChunkProviderOverworld field_186001_t' + ] + if (packagedAccessTransformerRules != expectedRuntimeAccessTransformerRules) { + throw new GradleException('Packaged access transformer was not remapped to the runtime SRG rules') + } + + def manifestEntry = zip.getEntry('META-INF/MANIFEST.MF') + def manifest = manifestEntry == null ? null + : new Manifest(zip.getInputStream(manifestEntry)).mainAttributes + if (manifest == null + || manifest.getValue('Implementation-Version') != project.mod_version + || manifest.getValue('Specification-Vendor') != project.mod_vendor + || manifest.getValue('Implementation-Vendor') != project.mod_vendor + || manifest.getValue('OreSpawn-API-Version') != '1' + || manifest.getValue('FMLAT') != 'accesstransformer.cfg' + || manifest.getValue('Maven-Artifact') != expectedMavenCoordinate + || manifest.getValue('Implementation-Timestamp') != null + || manifest.getValue('Timestamp') != null) { + throw new GradleException('Release manifest identity/API/FMLAT is incorrect or volatile') + } + + zip.entries().findAll { it.name.endsWith('.class') }.each { entry -> + byte[] header = new byte[8] + zip.getInputStream(entry).withCloseable { input -> + if (input.read(header) != header.length) { + throw new GradleException("Cannot inspect bytecode header for ${entry.name}") + } + } + int major = ((header[6] & 0xff) << 8) | (header[7] & 0xff) + if (major != 52) { + throw new GradleException("${entry.name} uses Java class major ${major}, expected 52") + } + } + } finally { + zip.close() + } + + File sources = new File(libs, expectedReleaseFiles.get()[1]) + new ZipFile(sources).withCloseable { sourceZip -> + if (sourceZip.getEntry('zone/moddev/mc/orespawn/OreSpawn.java') == null) { + throw new GradleException('Sources jar is missing OreSpawn.java') + } + } + File javadocs = new File(libs, expectedReleaseFiles.get()[2]) + new ZipFile(javadocs).withCloseable { javadocZip -> + if (javadocZip.getEntry('index.html') == null) { + throw new GradleException('Javadoc jar is missing index.html') + } + } + } +} + +// The release dispatcher uses this small, deterministic manifest to identify +// the exact three files validated above. +tasks.register('writeReleaseChecksums') { + group = 'verification' + description = 'Writes SHA-256 checksums for the audited release jars.' + dependsOn tasks.named('verifyReleaseArtifacts') + def outputFile = layout.buildDirectory.file('release/SHA256SUMS') + inputs.files(providers.provider { + expectedReleaseFiles.get().collect { name -> + layout.buildDirectory.file("libs/${name}").get().asFile + } + }) + outputs.file(outputFile) + + doLast { + File output = outputFile.get().asFile + output.parentFile.mkdirs() + File libs = layout.buildDirectory.dir('libs').get().asFile + String contents = expectedReleaseFiles.get().sort().collect { name -> + MessageDigest digest = MessageDigest.getInstance('SHA-256') + new File(libs, name).withInputStream { input -> + byte[] buffer = new byte[8192] + for (int read = input.read(buffer); read >= 0; read = input.read(buffer)) { + if (read > 0) digest.update(buffer, 0, read) + } + } + "${digest.digest().encodeHex().toString().toUpperCase()} ${name}" + }.join('\n') + '\n' + output.setText(contents, 'UTF-8') + } +} + +// CI can hand publication an immutable prepared bundle. Recheck it instead of +// silently rebuilding different jars while uploading. +tasks.register('verifyPreparedReleaseArtifacts') { + group = 'verification' + description = 'Audits a previously built immutable release bundle before remote Maven publication.' + + doLast { + if (!preparedReleaseDir.isPresent()) { + throw new GradleException('preparedReleaseDir is required for prepared artifact publication') + } + File prepared = file(preparedReleaseDir.get()) + if (!prepared.isDirectory()) { + throw new GradleException("Prepared release directory does not exist: ${prepared}") + } + List expected = expectedReleaseFiles.get().sort() + List jars = (prepared.listFiles() ?: [] as File[]) + .findAll { it.name.endsWith('.jar') } + .sort { it.name } + if (jars.collect { it.name } != expected || jars.any { it.length() == 0L }) { + throw new GradleException("Prepared release must contain exactly the non-empty jars ${expected}") + } + File checksums = new File(prepared, 'SHA256SUMS') + File changelog = new File(prepared, 'CHANGELOG.txt') + if (!checksums.isFile() || !changelog.isFile()) { + throw new GradleException('Prepared release is missing SHA256SUMS or CHANGELOG.txt') + } + List actualChecksums = jars.collect { candidate -> + MessageDigest digest = MessageDigest.getInstance('SHA-256') + candidate.withInputStream { input -> + byte[] buffer = new byte[8192] + for (int read = input.read(buffer); read >= 0; read = input.read(buffer)) { + if (read > 0) digest.update(buffer, 0, read) + } + } + "${digest.digest().encodeHex().toString().toUpperCase()} ${candidate.name}" + }.sort() + List declaredChecksums = checksums.readLines('UTF-8') + .findAll { !it.trim().isEmpty() } + .sort() + if (actualChecksums != declaredChecksums) { + throw new GradleException('Prepared release checksums do not match the immutable jars') + } + } +} + +ext.oreSpawnRelease = [ + jar: releaseJar, + expectedFiles: expectedReleaseFiles, + preparedDirectory: preparedReleaseDir +].asImmutable() diff --git a/gradle/release/publishing.gradle b/gradle/release/publishing.gradle new file mode 100644 index 00000000..4529097a --- /dev/null +++ b/gradle/release/publishing.gradle @@ -0,0 +1,118 @@ +import groovy.xml.XmlSlurper + +def buildValues = project.ext.oreSpawnBuild +def releaseValues = project.ext.oreSpawnRelease +def expectedMavenGroup = buildValues.mavenGroup +def expectedMavenArtifact = buildValues.mavenArtifact +def expectedReleaseFiles = releaseValues.expectedFiles +def preparedReleaseDir = releaseValues.preparedDirectory +def releaseJar = releaseValues.jar + +// Local builds may configure publication without credentials. The publish +// tasks themselves still reject a missing or local upload destination. +def mavenUploadUrl = providers.environmentVariable('MAVEN_UPLOAD_URL') + .orElse('https://invalid.invalid/missing-maven-upload-url') +def mavenUploadUsername = providers.environmentVariable('MAVEN_UPLOAD_USERNAME') +def mavenUploadPassword = providers.environmentVariable('MAVEN_UPLOAD_PASSWORD') + +publishing { + publications { + mavenJava(MavenPublication) { + groupId = project.mod_group + artifactId = project.mod_artifact + version = project.version.toString() + if (preparedReleaseDir.isPresent()) { + File prepared = file(preparedReleaseDir.get()) + artifact(new File(prepared, expectedReleaseFiles.get()[0])) + artifact(new File(prepared, expectedReleaseFiles.get()[1])) { + classifier = 'sources' + } + artifact(new File(prepared, expectedReleaseFiles.get()[2])) { + classifier = 'javadoc' + } + } else { + artifact(releaseJar) + artifact(tasks.named('sourcesJar')) + artifact(tasks.named('javadocJar')) + } + pom { + name = project.mod_name + description = project.mod_description + url = project.mod_url + licenses { + license { + name = project.mod_license_name + url = project.mod_license_url + } + } + scm { + connection = project.mod_scm_connection + developerConnection = project.mod_scm_developer_connection + url = project.mod_url + } + } + } + } + repositories { + maven { + name = 'release' + url = uri(mavenUploadUrl.get()) + credentials { + username = mavenUploadUsername.orNull ?: '' + password = mavenUploadPassword.orNull ?: '' + } + } + } +} + +tasks.register('verifyMavenCoordinates') { + group = 'verification' + description = 'Verifies the generated POM uses OreSpawn\'s mod-specific Maven namespace.' + dependsOn tasks.named('generatePomFileForMavenJavaPublication') + doLast { + File pomFile = layout.buildDirectory.file( + 'publications/mavenJava/pom-default.xml').get().asFile + if (!pomFile.isFile()) { + throw new GradleException("Generated Maven POM does not exist: ${pomFile}") + } + def pom = new XmlSlurper(false, false).parse(pomFile) + def actual = [pom.groupId.text(), pom.artifactId.text(), pom.version.text()] + def expected = [expectedMavenGroup, expectedMavenArtifact, project.version.toString()] + if (project.group.toString() != expectedMavenGroup || actual != expected) { + throw new GradleException("Expected Maven coordinate ${expected.join(':')}, " + + "found ${actual.join(':')}") + } + } +} + +tasks.register('validateMavenReleaseCredentials') { + group = 'publishing' + description = 'Prevents Maven publication from targeting a local or incomplete repository.' + doLast { + if (!providers.environmentVariable('MAVEN_UPLOAD_URL').isPresent() + || !mavenUploadUsername.isPresent() + || !mavenUploadPassword.isPresent()) { + throw new GradleException( + 'MAVEN_UPLOAD_URL, MAVEN_UPLOAD_USERNAME, and MAVEN_UPLOAD_PASSWORD are required') + } + String target = providers.environmentVariable('MAVEN_UPLOAD_URL').get() + if (target.startsWith('file:')) { + throw new GradleException('Maven release publication must use a remote repository') + } + } +} + +tasks.withType(PublishToMavenRepository).configureEach { + dependsOn tasks.named('validateMavenReleaseCredentials') + dependsOn tasks.named('verifyMavenCoordinates') + if (preparedReleaseDir.isPresent()) { + dependsOn tasks.named('verifyPreparedReleaseArtifacts') + } else { + dependsOn tasks.named('verifyReleaseArtifacts') + } +} + +tasks.named('check') { + dependsOn tasks.named('verifyMavenCoordinates') + dependsOn tasks.named('verifyDocumentationParity') +} diff --git a/gradle/verification/client-and-benchmark.gradle b/gradle/verification/client-and-benchmark.gradle new file mode 100644 index 00000000..2af7c6d0 --- /dev/null +++ b/gradle/verification/client-and-benchmark.gradle @@ -0,0 +1,146 @@ +def configureFromForgeRun = project.ext.oreSpawnVerification.configureFromForgeRun + +// The client probe exercises the editor and an actual fresh/save/reload world. +// It is built separately so none of its classes enter a public OreSpawn jar. +def clientIntegrationClasses = file("${buildDir}/client-integration-fixture/classes") +task compileClientIntegrationTestMod(type: JavaCompile, dependsOn: classes) { + source fileTree('src/clientIntegrationTest/java') + classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) + destinationDirectory = clientIntegrationClasses + options.encoding = 'UTF-8' +} + +task clientIntegrationTestModJar(type: Jar, dependsOn: compileClientIntegrationTestMod) { + archiveFileName = 'clientprobe.jar' + destinationDirectory = file("${buildDir}/client-integration-fixture") + from clientIntegrationClasses + from 'src/clientIntegrationTest/resources' +} + +def packagedClientProbeJar = renamer.classes(tasks.named('clientIntegrationTestModJar', Jar)) { + map.from minecraft.dependency.toSrgFile + output = layout.buildDirectory.file('client-integration-fixture/clientprobe-reobf.jar') +} + +tasks.register('preparePackagedClientProbe') { + group = 'verification' + description = 'Builds the reobfuscated client probe used only with a disposable packaged Forge runtime.' + dependsOn packagedClientProbeJar +} + +def clientIntegrationRunDirectory = file("${buildDir}/client-integration-run") +task prepareClientIntegrationTest(dependsOn: clientIntegrationTestModJar) { + doLast { + delete clientIntegrationRunDirectory + clientIntegrationRunDirectory.mkdirs() + copy { + from clientIntegrationTestModJar.archiveFile + into new File(clientIntegrationRunDirectory, 'mods') + } + new File(clientIntegrationRunDirectory, 'options.txt').setText( + 'fullscreen:false\nforceUnicodeFont:false\nguiScale:2\n' + + 'renderDistance:4\nshowSubtitles:false\n', 'UTF-8') + } +} + +task clientIntegrationProcess(type: JavaExec, dependsOn: prepareClientIntegrationTest) { + group = 'verification' + workingDir clientIntegrationRunDirectory + systemProperty 'clientprobe.enabled', 'true' + configureFromForgeRun(delegate, 'runClient') + doLast { + File marker = new File(clientIntegrationRunDirectory, 'client-smoke-pass.properties') + if (!marker.isFile()) { + throw new GradleException("Client integration completion marker is missing: ${marker}") + } + 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('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}") + } + File latest = new File(clientIntegrationRunDirectory, 'logs/fml-client-latest.log') + if (latest.isFile() && (latest.text.contains('Encountered an unexpected exception') + || latest.text.contains('Reported exception thrown!'))) { + throw new GradleException("Client integration logged a runtime failure: ${latest}") + } + List allowedDevelopmentErrors = [ + 'The binary patch set is missing', + 'FML appears to be missing any signature data', + 'Unable to read a class file correctly', + 'problem reading the entry META-INF/versions/11/', + 'Trying to activate the universal filled bucket too late. Call it statically in your Mods class. Mod: Base Metals' + ] + List unaccountedErrors = latest.readLines('UTF-8').findAll { line -> + (line.contains('/ERROR]') || line.contains('/FATAL]')) + && !allowedDevelopmentErrors.any { line.contains(it) } + } + if (!unaccountedErrors.isEmpty()) { + throw new GradleException("Client integration logged unaccounted errors: ${unaccountedErrors}") + } + File crashDirectory = new File(clientIntegrationRunDirectory, 'crash-reports') + if (crashDirectory.isDirectory() && crashDirectory.listFiles().length > 0) { + throw new GradleException("Client integration produced crash reports: ${crashDirectory}") + } + } +} + +// Benchmarks are opt-in: routine builds do not generate large timed worlds. +if (project.hasProperty('benchmarkRunDir')) { + def benchmarkRunDirectory = file(project.property('benchmarkRunDir')) + task prepareWorldgenBenchmark { + doLast { + delete benchmarkRunDirectory + benchmarkRunDirectory.mkdirs() + new File(benchmarkRunDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') + new File(benchmarkRunDirectory, 'server.properties').setText( + "online-mode=false\nlevel-name=world\nlevel-type=${project.findProperty('benchmarkLevelType') ?: 'default'}\n" + + "level-seed=${project.findProperty('benchmarkSeed') ?: '-4965128775892001975'}\n" + + 'generate-structures=false\nspawn-animals=false\nspawn-monsters=false\n' + + 'max-tick-time=-1\n', 'UTF-8') + } + } + task benchmarkIntegrationProcess(type: JavaExec, dependsOn: prepareWorldgenBenchmark) { + group = 'verification' + workingDir benchmarkRunDirectory + systemProperty 'orespawn.worldgenBenchmarkMode', project.findProperty('benchmarkMode') ?: 'sky' + systemProperty 'orespawn.worldgenBenchmarkRadius', project.findProperty('benchmarkRadius') ?: '4' + systemProperty 'orespawn.worldgenBenchmarkRepetitions', project.findProperty('benchmarkRepetitions') ?: '3' + systemProperty 'orespawn.worldgenBenchmarkCenterX', project.findProperty('benchmarkCenterX') ?: '256' + systemProperty 'orespawn.worldgenBenchmarkCenterZ', project.findProperty('benchmarkCenterZ') ?: '256' + systemProperty 'orespawn.worldgenBenchmarkCenterStep', project.findProperty('benchmarkCenterStep') ?: '64' + systemProperty 'orespawn.worldgenBenchmarkStopServer', 'true' + systemProperty 'orespawn.worldgenBenchmarkVanillaOres', + project.findProperty('benchmarkVanillaOres') ?: 'false' + systemProperty 'orespawn.worldgenBenchmarkOreAudit', + project.findProperty('benchmarkOreAudit') ?: 'false' + if (project.hasProperty('benchmarkBlockAudit')) { + systemProperty 'orespawn.worldgenBenchmarkBlockAudit', project.property('benchmarkBlockAudit') + } + ['benchmarkBiomeType': 'orespawn.worldgenBenchmarkBiomeType', + 'benchmarkBiome': 'orespawn.worldgenBenchmarkBiome', + 'benchmarkNormalizeBlock': 'orespawn.worldgenBenchmarkNormalizeBlock', + 'benchmarkNormalizeBiome': 'orespawn.worldgenBenchmarkNormalizeBiome'].each { propertyName, systemName -> + if (project.hasProperty(propertyName)) { + systemProperty systemName, project.property(propertyName) + } + } + configureFromForgeRun(delegate, 'runServer') + doLast { + File latest = new File(benchmarkRunDirectory, 'logs/fml-server-latest.log') + String mode = project.findProperty('benchmarkMode') ?: 'sky' + if (!latest.isFile() || !latest.text.contains("ORESPAWN_BENCHMARK summary mode=${mode}")) { + throw new GradleException("Missing ${mode} worldgen benchmark summary: ${latest}") + } + if (latest.text.contains('Encountered an unexpected exception') + || latest.text.contains('Tried to assign a mutable BlockPos')) { + throw new GradleException("Worldgen benchmark logged a runtime failure: ${latest}") + } + } + } +} diff --git a/gradle/verification/legacy-and-migration.gradle b/gradle/verification/legacy-and-migration.gradle new file mode 100644 index 00000000..139c8001 --- /dev/null +++ b/gradle/verification/legacy-and-migration.gradle @@ -0,0 +1,311 @@ +def verification = project.ext.oreSpawnVerification +def legacyFixtureArtifacts = verification.fixtureArtifacts +def legacyFixtureWorlds = verification.fixtureWorlds +def verifyLegacyFixtures = tasks.named('verifyLegacyFixtures') +def configureFromForgeRun = verification.configureFromForgeRun + +// These disposable worlds prove that old saves and binary consumers still +// behave as they did before the build changed. +def surfaceIntegrationClasses = file("${buildDir}/surface-integration-fixture/classes") +task compileSurfaceIntegrationTestMod(type: JavaCompile, dependsOn: classes) { + source fileTree('src/biomeIntegrationTest/java') + classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) + destinationDirectory = surfaceIntegrationClasses + options.encoding = 'UTF-8' +} + +task surfaceIntegrationTestModJar(type: Jar, dependsOn: compileSurfaceIntegrationTestMod) { + archiveFileName = 'surfaceprobe.jar' + destinationDirectory = file("${buildDir}/surface-integration-fixture") + from surfaceIntegrationClasses + from 'src/biomeIntegrationTest/resources' +} + +def surfaceIntegrationRunDirectory = file("${buildDir}/surface-integration-run") +task prepareSurfaceIntegrationTest(dependsOn: surfaceIntegrationTestModJar) { + doLast { + delete surfaceIntegrationRunDirectory + surfaceIntegrationRunDirectory.mkdirs() + copy { + from surfaceIntegrationTestModJar.archiveFile + into new File(surfaceIntegrationRunDirectory, 'mods') + } + new File(surfaceIntegrationRunDirectory, 'server.properties').setText('''\ +level-name=surface-integration-world +level-seed=zsjpxah +level-type=default +online-mode=false +server-port=0 +allow-nether=true +generate-structures=false +spawn-protection=0 +max-tick-time=-1 +''', 'UTF-8') + new File(surfaceIntegrationRunDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') + } +} + +def createSurfaceProcess = { String phase, Object dependency -> + task("surfaceIntegration${phase}Process", type: JavaExec, dependsOn: dependency) { + group = 'verification' + workingDir surfaceIntegrationRunDirectory + systemProperty 'surfaceprobe.integrationPhase', phase.toLowerCase() + configureFromForgeRun(delegate, 'runServer') + } +} + +def surfaceIntegrationFreshProcess = createSurfaceProcess('Fresh', prepareSurfaceIntegrationTest) +surfaceIntegrationFreshProcess.doLast { + File marker = new File(surfaceIntegrationRunDirectory, + 'surface-integration-world/surfaceprobe-integration.properties') + if (!marker.isFile()) { + throw new GradleException("Fresh surface integration completion marker is missing: ${marker}") + } +} +def surfaceIntegrationReloadProcess = createSurfaceProcess('Reload', surfaceIntegrationFreshProcess) + +task surfaceIntegrationTest(dependsOn: surfaceIntegrationReloadProcess) { + group = 'verification' + doLast { + File marker = new File(surfaceIntegrationRunDirectory, + 'surface-integration-world/surfaceprobe-integration.properties') + Properties result = new Properties() + marker.withInputStream { result.load(it) } + 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('biome_override_verified') != 'true') { + throw new GradleException("Biome editor/profile/new-terrain 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}") + } + [ + 'end.raw_natural_sources': '108', + 'end.structure_natural_sources': '108', + 'end.vegetation_natural_sources': '108', + 'end.cave_pockets': '54', + 'end.underwater_pockets': '54', + 'end.raw_bedrock': '9', + 'end.raw_block_entities': '9', + 'end.exposed_snow_converted': '9', + 'end.surface_ice_converted': '9', + 'end.buried_snow_preserved': '9', + 'end.buried_ice_preserved': '9', + 'nether.unconfigured_snow_preserved': '9', + 'nether.unconfigured_ice_preserved': '9' + ].each { key, expected -> + if (result.getProperty(key) != expected) { + throw new GradleException("Unexpected surface integration result ${key}: " + + "expected ${expected}, found ${result.getProperty(key)}") + } + } + File latest = new File(surfaceIntegrationRunDirectory, 'logs/latest.log') + if (!latest.isFile() || latest.text.contains('Tried to assign a mutable BlockPos')) { + throw new GradleException("Surface integration leaked a mutable position into tick data: ${latest}") + } + logger.lifecycle('Provider surfaces and exact-biome geology verified: {} dimensions, {} columns each, fresh + reload', + result.getProperty('dimensions'), result.getProperty('columns_per_dimension')) + } +} + +check.dependsOn surfaceIntegrationTest + +// The migration probe starts from a copy of an old world, then reloads that +// exact copy to catch a second conversion or changed profile. +def migrationIntegrationClasses = file("${buildDir}/migration-integration-fixture/classes") +task compileMigrationIntegrationTestMod(type: JavaCompile, dependsOn: classes) { + source fileTree('src/migrationIntegrationTest/java') + classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) + destinationDirectory = migrationIntegrationClasses + options.encoding = 'UTF-8' +} + +task migrationIntegrationTestModJar(type: Jar, dependsOn: compileMigrationIntegrationTestMod) { + archiveFileName = 'migrationprobe.jar' + destinationDirectory = file("${buildDir}/migration-integration-fixture") + from migrationIntegrationClasses + from 'src/migrationIntegrationTest/resources' +} + +if (project.hasProperty('migrationRunDir')) { + def migrationRunDirectory = file(project.property('migrationRunDir')) + task prepareMigrationIntegrationRun(dependsOn: migrationIntegrationTestModJar) { + doLast { + copy { from migrationIntegrationTestModJar.archiveFile; into new File(migrationRunDirectory, 'mods') } + new File(migrationRunDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') + File serverProperties = new File(migrationRunDirectory, 'server.properties') + Properties values = new Properties() + if (serverProperties.isFile()) serverProperties.withInputStream { values.load(it) } + values.setProperty('spawn-animals', 'false') + values.setProperty('spawn-monsters', 'false') + serverProperties.withOutputStream { values.store(it, 'OreSpawn migration qualification') } + } + } + task migrationIntegrationProcess(type: JavaExec, dependsOn: prepareMigrationIntegrationRun) { + group = 'verification' + workingDir migrationRunDirectory + systemProperty 'orespawn.migrationFamily', project.findProperty('migrationFamily') ?: 'unspecified' + systemProperty 'orespawn.migrationPhase', project.findProperty('migrationPhase') ?: 'fresh' + configureFromForgeRun(delegate, 'runServer') + doLast { + String phase = project.findProperty('migrationPhase') ?: 'fresh' + File marker = new File(migrationRunDirectory, 'world/orespawn4-migration-probe.properties') + if (!marker.isFile()) throw new GradleException("Migration probe did not produce ${marker}") + Properties values = new Properties(); marker.withInputStream { values.load(it) } + if (values.getProperty("${phase}_complete") != 'true') { + throw new GradleException("Migration ${phase} phase did not complete: ${marker}") + } + File latest = new File(migrationRunDirectory, 'logs/latest.log') + if (latest.isFile() && (latest.text.contains('Migration audit failed') + || latest.text.contains('Encountered an unexpected exception'))) { + throw new GradleException("Migration ${phase} phase logged a server failure: ${latest}") + } + } + } +} + +// This 1.10-only gate is live code: existing Mineralogy 3 worlds must keep +// their Cyano settings even when modern provider defaults are installed. +def legacyMineralogyMigrationRunDirectory = file("${buildDir}/legacy-mineralogy-migration-run") +def legacyMineralogyMigrationArchive = new File(legacyFixtureWorlds, + 'sylvester-era-trio-source-v2.zip') + +task prepareLegacyMineralogyMigrationRun(dependsOn: [migrationIntegrationTestModJar, + verifyLegacyFixtures]) { + doLast { + delete legacyMineralogyMigrationRunDirectory + copy { from zipTree(legacyMineralogyMigrationArchive); into legacyMineralogyMigrationRunDirectory } + File mods = new File(legacyMineralogyMigrationRunDirectory, 'mods') + mods.mkdirs() + copy { + from new File(legacyFixtureArtifacts, 'BaseMetals_1.10.2-2.4.0.11.jar') + from new File(legacyFixtureArtifacts, 'Mineralogy-1.10.2-3.3.8.26.jar') + from migrationIntegrationTestModJar.archiveFile + into mods + } + new File(legacyMineralogyMigrationRunDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') + } +} + +def configureLegacyMineralogyMigrationProcess = { JavaExec process, String phase -> + process.group = 'verification' + process.workingDir legacyMineralogyMigrationRunDirectory + process.systemProperty 'orespawn.migrationFamily', 'legacy-mineralogy-cyano' + process.systemProperty 'orespawn.migrationPhase', phase + configureFromForgeRun(process, 'runServer') + process.doLast { + File marker = new File(legacyMineralogyMigrationRunDirectory, + 'world/orespawn4-migration-probe.properties') + if (!marker.isFile()) throw new GradleException("Legacy Mineralogy migration did not produce ${marker}") + Properties values = new Properties() + marker.withInputStream { values.load(it) } + if (values.getProperty("${phase}_complete") != 'true') { + throw new GradleException("Legacy Mineralogy migration ${phase} phase did not complete") + } + } +} + +task legacyMineralogyMigrationFresh(type: JavaExec, dependsOn: prepareLegacyMineralogyMigrationRun) +configureLegacyMineralogyMigrationProcess(legacyMineralogyMigrationFresh, 'fresh') + +task legacyMineralogyMigrationReload(type: JavaExec, dependsOn: legacyMineralogyMigrationFresh) +configureLegacyMineralogyMigrationProcess(legacyMineralogyMigrationReload, 'reload') + +task legacyMineralogyMigrationTest(dependsOn: legacyMineralogyMigrationReload) { + group = 'verification' + description = 'Proves an existing Mineralogy 3 world is pinned to Cyano settings across reload.' + doLast { + File marker = new File(legacyMineralogyMigrationRunDirectory, + 'world/orespawn4-migration-probe.properties') + Properties values = new Properties(); marker.withInputStream { values.load(it) } + ['fresh_complete', 'reload_complete', 'legacy_mineralogy_config_sha256', + 'legacy_mineralogy_world_profile_sha256'].each { key -> + if (!values.getProperty(key)) throw new GradleException("Missing legacy Mineralogy evidence ${key}") + } + logger.lifecycle('Existing Mineralogy 3 world retained Cyano profile and exact config/profile hashes across reload') + } +} + +check.dependsOn legacyMineralogyMigrationTest + +// Compile published OS1 and OS3 consumers unchanged. These manual checks +// protect old binary contracts without putting their classes in the release. +def os1AbiFixtureClasses = file("${buildDir}/legacy-abi/os1/classes") +def os3AbiFixtureClasses = file("${buildDir}/legacy-abi/os3/classes") + +task compileOs1AbiFixture(type: JavaCompile, dependsOn: verifyLegacyFixtures) { + source fileTree('src/os1AbiFixture/java') + classpath = files(sourceSets.main.compileClasspath, + new File(legacyFixtureArtifacts, 'OreSpawn_1.10.2-1.1.0.jar')) + destinationDirectory = os1AbiFixtureClasses + options.encoding = 'UTF-8' +} + +task os1AbiFixtureJar(type: Jar, dependsOn: compileOs1AbiFixture) { + archiveFileName = 'os1abiprobe.jar' + destinationDirectory = file("${buildDir}/legacy-abi/os1") + from os1AbiFixtureClasses + from 'src/os1AbiFixture/resources' +} + +task compileOs3AbiFixture(type: JavaCompile, dependsOn: verifyLegacyFixtures) { + source fileTree('src/os3AbiFixture/java') + classpath = files(sourceSets.main.compileClasspath, + new File(legacyFixtureArtifacts, 'OreSpawn-1.10.2-3.2.2.104.jar')) + destinationDirectory = os3AbiFixtureClasses + options.encoding = 'UTF-8' +} + +task os3AbiFixtureJar(type: Jar, dependsOn: compileOs3AbiFixture) { + archiveFileName = 'os3abiprobe.jar' + destinationDirectory = file("${buildDir}/legacy-abi/os3") + from os3AbiFixtureClasses + from 'src/os3AbiFixture/resources' +} + +def configureLegacyAbiProcess = { String generation, Task fixtureJar, String markerName -> + File runDirectory = file("${buildDir}/legacy-abi/${generation}/run") + Task prepare = tasks.create("prepare${generation.capitalize()}AbiRun", Copy) { + dependsOn fixtureJar + into new File(runDirectory, 'mods') + from fixtureJar.archiveFile + doFirst { project.delete(runDirectory) } + doLast { + new File(runDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') + new File(runDirectory, 'server.properties').setText( + 'eula=true\nonline-mode=false\nlevel-name=world\nlevel-seed=zsjpxah\n' + + 'spawn-animals=false\nspawn-monsters=false\n', 'UTF-8') + } + } + tasks.create("${generation}AbiIntegrationProcess", JavaExec) { + group = 'verification' + dependsOn prepare + workingDir runDirectory + configureFromForgeRun(delegate, 'runServer') + doLast { + File marker = new File(runDirectory, "world/${markerName}") + if (!marker.isFile() || !marker.text.contains('registered=true')) { + throw new GradleException("${generation.toUpperCase()} binary ABI probe did not complete: ${marker}") + } + File latest = new File(runDirectory, 'logs/latest.log') + if (latest.isFile() && latest.text.contains('Encountered an unexpected exception')) { + throw new GradleException("${generation.toUpperCase()} binary ABI probe logged a server failure") + } + } + } +} + +configureLegacyAbiProcess('os1', os1AbiFixtureJar, 'os1-abi-probe.properties') +configureLegacyAbiProcess('os3', os3AbiFixtureJar, 'os3-abi-probe.properties') + +task legacyAbiIntegrationTest { + group = 'verification' + dependsOn os1AbiIntegrationProcess, os3AbiIntegrationProcess +} + +check.dependsOn legacyAbiIntegrationTest diff --git a/gradle/verification/support.gradle b/gradle/verification/support.gradle new file mode 100644 index 00000000..cf89f0d1 --- /dev/null +++ b/gradle/verification/support.gradle @@ -0,0 +1,167 @@ +import java.security.MessageDigest + +def forge10DevelopmentOutput = project.ext.oreSpawnBuild.developmentOutput + +// These are sealed copies of old mods and saves, not dependencies of the +// shipped jar. Check their hashes before a test can use them. +def legacyFixtureRoot = file("${rootDir}/ci-fixtures") +def legacyFixtureArtifacts = new File(legacyFixtureRoot, 'artifacts') +def legacyFixtureWorlds = new File(legacyFixtureRoot, 'worlds') +def legacyMineralogyOracleJar = new File( + legacyFixtureArtifacts, 'Mineralogy-1.10.2-3.3.8.26.jar') +def legacyFixtureHashes = [ + 'artifacts/OreSpawn_1.10.2-1.1.0.jar': + '91345E4B825AA06E05F470066F467AABA0C7A2804A8DBCA4D3CD1905ED53607C', + 'artifacts/OreSpawn-1.10.2-3.2.2.104.jar': + 'D964148C20175E16CCEFAB02AE43A181A50DC08A1D636A77E59CFE8C8EA8D41A', + 'artifacts/BaseMetals_1.10.2-2.4.0.11.jar': + 'F919CEF794C73F02758BBD967346DBA0B53F6383153C7E29BD79F211CDA8C319', + 'artifacts/Mineralogy-1.10.2-3.3.8.26.jar': + '88A6237C9A0E2C8891718B68C373E741C78B8494F5E68D8093CA9339F3BC4D87', + 'worlds/sylvester-era-trio-source-v2.zip': + '11FC2FA44C43520AA9B21C29B43203EBFE0A84773D2BDCBB4F3C48C490559B07' +] + +task verifyLegacyFixtures { + group = 'verification' + description = 'Verifies the sealed ABI and migration corpus used only by isolated tests.' + inputs.files legacyFixtureHashes.keySet().collect { new File(legacyFixtureRoot, it) } + doLast { + legacyFixtureHashes.each { String relativePath, String expectedHash -> + File fixture = new File(legacyFixtureRoot, relativePath) + if (!fixture.isFile()) { + throw new GradleException("Missing sealed legacy fixture: ${fixture}") + } + MessageDigest digest = MessageDigest.getInstance('SHA-256') + fixture.withInputStream { input -> + byte[] buffer = new byte[8192] + for (int read = input.read(buffer); read >= 0; read = input.read(buffer)) { + if (read > 0) digest.update(buffer, 0, read) + } + } + String actualHash = digest.digest().encodeHex().toString().toUpperCase() + if (actualHash != expectedHash) { + throw new GradleException("Legacy fixture hash mismatch for ${relativePath}: " + + "expected ${expectedHash}, found ${actualHash}") + } + } + } +} + +// The old Mineralogy jar is needed by parity tests in Eclipse. Keep it on +// the test classpath and make the production-classpath guard prove it stays there. +dependencies { + testImplementation files(legacyMineralogyOracleJar) +} + +tasks.named('compileTestJava', JavaCompile) { + dependsOn verifyLegacyFixtures +} +tasks.named('test', Test) { + dependsOn verifyLegacyFixtures +} + +tasks.register('verifyLegacyOracleIsolation') { + group = 'verification' + description = 'Keeps the sealed Mineralogy oracle test-visible but production-invisible.' + dependsOn verifyLegacyFixtures + + doLast { + File oracle = legacyMineralogyOracleJar.canonicalFile + def canonicalFiles = { FileCollection classpath -> + classpath.files.collect { it.canonicalFile } as Set + } + Set testCompileFiles = canonicalFiles(sourceSets.test.compileClasspath) + Set testRuntimeFiles = canonicalFiles(sourceSets.test.runtimeClasspath) + if (!testCompileFiles.contains(oracle) || !testRuntimeFiles.contains(oracle)) { + throw new GradleException('The sealed Mineralogy oracle is missing from the test classpath model') + } + + Map productionClasspaths = [ + mainCompile: sourceSets.main.compileClasspath, + mainRuntime: sourceSets.main.runtimeClasspath + ] + productionClasspaths.each { String name, FileCollection classpath -> + if (canonicalFiles(classpath).contains(oracle)) { + throw new GradleException("The sealed Mineralogy oracle leaked into ${name}") + } + } + } +} +tasks.named('check') { + dependsOn tasks.named('verifyLegacyOracleIsolation') +} + +def configureFromForgeRun = { JavaExec process, String runTaskName -> + process.actions.clear() + process.dependsOn { + JavaExec run = tasks.getByName(runTaskName) as JavaExec + run.taskDependencies.getDependencies(run) + } + process.doLast { + JavaExec run = tasks.getByName(runTaskName) as JavaExec + String runConfigurationName = runTaskName.substring(3).uncapitalize() + def runOptions = minecraft.runs.getByName(runConfigurationName) + File originalConfiguredWorkingDir = runOptions.workingDir.get().asFile + File originalWorkingDir = run.workingDir + List originalArgs = new ArrayList<>(run.args) + List originalJvmArgs = new ArrayList<>(run.jvmArgs) + Map originalSystemProperties = new LinkedHashMap<>(run.systemProperties) + Map originalEnvironment = new LinkedHashMap<>(run.environment) + FileCollection originalClasspath = files(run.classpath.files) + String originalMinHeap = run.minHeapSize + String originalMaxHeap = run.maxHeapSize + String originalMainClass = run.mainClass.orNull + try { + File forgeConfig = new File(process.workingDir, 'config/forge.cfg') + if (!forgeConfig.isFile()) { + forgeConfig.parentFile.mkdirs() + forgeConfig.setText('''general { + B:disableVersionCheck=true +} + +version_checking { + B:Global=false +} +''', 'UTF-8') + } + runOptions.workingDir.set(process.workingDir) + run.workingDir(process.workingDir) + run.args(process.args) + run.jvmArgs(process.jvmArgs) + run.systemProperties(process.systemProperties) + run.environment(process.environment) + Set splitMainOutputs = [ + tasks.named('compileJava', JavaCompile).get().destinationDirectory.get().asFile, + sourceSets.main.output.resourcesDir + ] as Set + run.setClasspath(files(forge10DevelopmentOutput, run.classpath.filter { + !splitMainOutputs.contains(it) + })) + run.exec() + } finally { + runOptions.workingDir.set(originalConfiguredWorkingDir) + run.workingDir(originalWorkingDir) + run.setArgs(originalArgs) + run.setJvmArgs(originalJvmArgs) + run.setSystemProperties(originalSystemProperties) + run.setEnvironment(originalEnvironment) + run.setClasspath(originalClasspath) + run.minHeapSize = originalMinHeap + run.maxHeapSize = originalMaxHeap + if (originalMainClass == null) { + run.mainClass.unset() + } else { + run.mainClass.set(originalMainClass) + } + } + } +} + +// Applied scripts do not share their local variables. Expose only the few +// values used by the 1.10 migration and client gates. +ext.oreSpawnVerification = [ + fixtureArtifacts: legacyFixtureArtifacts, + fixtureWorlds: legacyFixtureWorlds, + configureFromForgeRun: configureFromForgeRun +].asImmutable() diff --git a/src/test/java/zone/moddev/mc/orespawn/ReleaseWorkflowContractTest.java b/src/test/java/zone/moddev/mc/orespawn/ReleaseWorkflowContractTest.java index 3d745cc3..d8788177 100644 --- a/src/test/java/zone/moddev/mc/orespawn/ReleaseWorkflowContractTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/ReleaseWorkflowContractTest.java @@ -20,11 +20,19 @@ void releaseAndHostedWorkflowContractsRemainTargetNative() throws Exception { properties.load(input); } assertEquals("zone.moddev.mc.orespawn", properties.getProperty("mod_group")); + assertEquals("OreSpawn", properties.getProperty("mod_artifact")); + assertEquals("Minecraft Mod Development", properties.getProperty("mod_vendor")); - String build = new String(Files.readAllBytes(Paths.get("build.gradle")), StandardCharsets.UTF_8); - assertTrue(build.contains("tasks.register('verifyMavenCoordinates')")); - assertTrue(build.contains("generatePomFileForMavenJavaPublication")); - assertTrue(build.contains("dependsOn tasks.named('verifyMavenCoordinates')")); + String build = readFile("build.gradle"); + String publishing = readFile("gradle/release/publishing.gradle"); + String artifacts = readFile("gradle/release/artifacts.gradle"); + assertTrue(build.contains("apply from: 'gradle/release/publishing.gradle'")); + assertTrue(build.contains("apply from: 'gradle/verification/legacy-and-migration.gradle'")); + assertTrue(publishing.contains("tasks.register('verifyMavenCoordinates')")); + assertTrue(publishing.contains("generatePomFileForMavenJavaPublication")); + assertTrue(publishing.contains("dependsOn tasks.named('verifyMavenCoordinates')")); + assertTrue(build.contains("'Implementation-Vendor' : project.mod_vendor")); + assertTrue(artifacts.contains("manifest.getValue('Implementation-Vendor') != project.mod_vendor")); assertTrue(build.contains("expectedMavenCoordinate")); assertFalse(build.contains("Mavenizer compatibility")); assertTrue(build.contains("args 'nogui'")); @@ -51,8 +59,11 @@ void releaseAndHostedWorkflowContractsRemainTargetNative() throws Exception { } private static String readWorkflow(String name) throws Exception { - return new String(Files.readAllBytes(Paths.get(".github", "workflows", name)), - StandardCharsets.UTF_8); + return readFile(".github/workflows/" + name); + } + + private static String readFile(String path) throws Exception { + return new String(Files.readAllBytes(Paths.get(path)), StandardCharsets.UTF_8); } private static void assertPinnedToolchains(String workflow, int expectedJobs, int expectedPathUses) {