diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9f89f73..09dac72 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -143,9 +143,9 @@ jobs:
if-no-files-found: error
retention-days: 30
path: |
- build/libs/RealisticDeposits-0.4.0.110021.jar
- build/libs/RealisticDeposits-0.4.0.110021-sources.jar
- build/libs/RealisticDeposits-0.4.0.110021-javadoc.jar
+ build/libs/RealisticDeposits-0.5.0.110021.jar
+ build/libs/RealisticDeposits-0.5.0.110021-sources.jar
+ build/libs/RealisticDeposits-0.5.0.110021-javadoc.jar
build/release/SHA256SUMS
CHANGELOG.md
diff --git a/.github/workflows/deploy-release.yml b/.github/workflows/deploy-release.yml
index edd4e4e..0bace85 100644
--- a/.github/workflows/deploy-release.yml
+++ b/.github/workflows/deploy-release.yml
@@ -31,6 +31,10 @@ on:
required: true
default: false
type: boolean
+ expected_checksums_sha256:
+ description: SHA-256 of the independently verified validation bundle's SHA256SUMS file.
+ required: false
+ type: string
permissions:
actions: read
@@ -382,6 +386,8 @@ jobs:
- name: Stage the immutable release artifact
id: artifact
+ env:
+ EXPECTED_CHECKSUMS_SHA256: ${{ inputs.expected_checksums_sha256 }}
run: |
set -euo pipefail
bundle="$RUNNER_TEMP/release-bundle"
@@ -392,6 +398,13 @@ jobs:
cp build/release/SHA256SUMS "$bundle/"
cp CHANGELOG.md "$bundle/"
(cd "$bundle" && sha256sum --check SHA256SUMS)
+ if [[ -n "$EXPECTED_CHECKSUMS_SHA256" ]]; then
+ if [[ ! "$EXPECTED_CHECKSUMS_SHA256" =~ ^[0-9a-fA-F]{64}$ ]]; then
+ echo "Expected checksum manifest SHA-256 must contain 64 hexadecimal digits" >&2
+ exit 1
+ fi
+ printf '%s %s\n' "${EXPECTED_CHECKSUMS_SHA256,,}" "$bundle/SHA256SUMS" | sha256sum --check -
+ fi
echo "name=RealisticDeposits-${{ needs.preflight.outputs.minecraft_version }}-${{ needs.preflight.outputs.loader_name }}-${{ needs.preflight.outputs.release_sha }}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT"
- name: Upload immutable release artifact
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f7262e2..649bfa0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,19 @@
# Changelog
+## 0.5.0.110021
+
+Alpha for Minecraft 1.10.2.
+
+- Add compact lapis lenses with pinching edges and connected richer pockets.
+ Keep 15% ordinary background lapis, with native drops and Fortune behaviour.
+- Fit lenses into natural marble when Mineralogy strata are active. Use natural
+ vanilla stone variants otherwise, without adding rocks or replacing terrain.
+- Attempt gradual entrances and short workings at suitable lapis districts,
+ leaving most of the deposit available to mine.
+- Keep lapis settings in a separate catalogue. Existing worlds retain their
+ captured rules, background supply and saved workings; no retrogen is added.
+- Add lapis previews, an isolated testing launch and packaged generation checks.
+
## 0.4.0.110021
Beta for Minecraft 1.10.2.
diff --git a/README.md b/README.md
index 46470e5..2990a0a 100644
--- a/README.md
+++ b/README.md
@@ -6,18 +6,20 @@

Realistic Deposits makes finding ore a reason to explore, then build a proper
-mine. Rare iron districts, layered coalfields, diamond pipes and gold veins
-give you deposits worth returning to, with old workings leading into some of them.
+mine. Rare iron districts, layered coalfields, diamond pipes, gold veins and
+lapis lenses give you deposits worth returning to, with old workings leading
+into some of them.
Small pockets of diamond- and gold-bearing river gravel offer another way to prospect.
It runs on [MMD OreSpawn](https://github.com/MinecraftModDevelopmentMods/OreSpawn).
The project starts with Minecraft 1.10.2 and is intended to follow the
maintained OreSpawn branches across Forge and NeoForge.
-Version 0.4.0.110021 is a beta for Minecraft 1.10.2. It adds gold
-veins and linked river placers to the existing deposits and stepped workings.
-Existing worlds keep their settings and saved mine layouts. Back up important
-worlds before updating.
+Version 0.5.0.110021 is an alpha for Minecraft 1.10.2. It adds rare, compact
+lapis lenses and short abandoned workings, with 15% of ordinary background
+lapis retained by default. Try a fresh world to see the new rules: existing
+worlds keep their settings, generated chunks and saved mine layouts. Back up
+important worlds before updating.
## Current features
@@ -39,15 +41,19 @@ worlds before updating.
or reshaping rivers. See [diamond deposits](docs/DIAMONDS.md).
- Steep gold veins with rich shoots, lean stretches and linked river gravel.
See [gold deposits](docs/GOLD.md).
+- Compact lapis lenses with richer pockets and short workings. Natural marble
+ hosts them when Mineralogy strata are active; ordinary stone is used otherwise.
+ See [lapis deposits](docs/LAPIS.md).
- Configurable support for vanilla and modded resources.
- Safe generation that avoids air, fluids, bedrock, block entities, structures,
and undeclared host blocks.
-- Live reload for iron and coal geometry. Gold, diamond and other structural
+- Live reload for iron and coal geometry. Lapis, gold, diamond and other structural
changes wait until restart.
- An entry in both Forge's Mods configuration and OreSpawn's World Settings.
- Abandoned workings with sloping, stepped entrances and short underground galleries.
New defaults attempt them at every iron formation, coalfield, diamond pipe
- and goldfield where safe. River placers stay untouched. See the [mine settings](docs/ABANDONED_MINES.md).
+ and goldfield, plus each lapis district, where safe. River placers stay untouched.
+ See the [mine settings](docs/ABANDONED_MINES.md).
Coalfields can already use Mineralogy's existing shale, siltstone and
conglomerate. Wider catalogues for Mineralogy, Base Metals, Modern Metals,
@@ -76,6 +82,7 @@ config/realisticdeposits/deposits/vanilla.json
config/realisticdeposits/deposits/coal.json
config/realisticdeposits/deposits/diamonds.json
config/realisticdeposits/deposits/gold.json
+config/realisticdeposits/deposits/lapis.json
```
The supplied definition describes one rare iron district using a 640–960 block
@@ -101,6 +108,13 @@ increases successful drops. The gold catalogue keeps 15% ordinary background
gold; the larger deposits are deliberately unevenly distributed. Their total
reward is not forced to match vanilla.
+Lapis occurs in smaller, irregular lenses rather than huge ore-filled layers.
+With physical Mineralogy strata, lenses fit into natural marble; limestone,
+dolomite and decorative marble are not substitutes. Without those strata,
+natural vanilla stone variants provide a simpler setting. The separate lapis
+catalogue keeps 15% background generation. A discovery can supply a lasting
+reserve because each vanilla lapis ore block drops several lapis pieces.
+
Deposits only affect newly generated chunks. Changing these settings does not
retrogen, replace, or rewrite an existing chunk.
@@ -146,7 +160,7 @@ formations:
`reload` requires operator permission. For iron and coal it applies `enabled`,
selection, shape, height, geome, family, and alignment changes. Diamond geometry
-and source links require a restart, as do gold settings. Resource,
+and source links require a restart, as do gold and lapis settings. Resource,
dimension, host, biome-filter, interaction-policy, scale, added, and removed
definition changes are structural and take effect after restart. Abandoned-mine
settings are also captured with the world's deposit definition. New provider
@@ -190,6 +204,7 @@ The [mine-testing walkthrough](tools/qualification/MINES_TESTING.md) uses a
separate Eclipse profile and disposable saves for the new workings.
For coalfields, use `prepareCoalMeasuresManual` and the
[coal-testing walkthrough](tools/qualification/COAL_TESTING.md).
+For lapis, see the [lapis walkthrough](tools/qualification/LAPIS_TESTING.md).
Hosted CI/CD keeps to builds, unit/helper tests, static checks, reproducible
jars, dependency hashes and ordinary Eclipse launch definitions. It does not
diff --git a/build.gradle b/build.gradle
index 1fb2c4b..84e6cc0 100644
--- a/build.gradle
+++ b/build.gradle
@@ -649,3 +649,4 @@ apply from: 'gradle/release.gradle'
apply from: 'gradle/coal-manual.gradle'
apply from: 'gradle/diamond-manual.gradle'
apply from: 'gradle/gold-manual.gradle'
+apply from: 'gradle/lapis-manual.gradle'
diff --git a/docs/LAPIS.md b/docs/LAPIS.md
new file mode 100644
index 0000000..8f35327
--- /dev/null
+++ b/docs/LAPIS.md
@@ -0,0 +1,68 @@
+# Finding lapis
+
+Lapis is a smaller discovery than an iron district or a goldfield. Look for
+compact lenses with pinching edges and richer pockets, rather than a solid
+blue wall. A short worked section can lead you into a useful reserve without
+excavating most of it for you.
+
+The inspiration is marble-hosted lapis such as
+[Sar-e-Sang in Afghanistan](https://iugs-geoheritage.org/geoheritage_sites/the-sar-e-sang-lapis-lazuli-deposit/).
+There, lapis occurs in lenses and layers associated with altered marble.
+This update represents that general arrangement, not the heat, fluids and
+chemical reactions that formed it.
+
+## With Mineralogy
+
+When physical strata are active, lenses are fitted into natural marble.
+Regions without a suitable marble interval are rejected. Ordinary limestone,
+dolomite, polished marble, bricks and other decorative blocks do not qualify.
+The ore replaces only the matching natural host; it does not manufacture a
+marble band or turn terrain replacement on.
+
+There is no new skarn rock or other carrier block in this update. Nor is every
+marble layer a lapis deposit: regional selection still makes discoveries rare.
+
+## Without physical strata
+
+Without Mineralogy, or with its physical strata disabled, the same lenses use
+natural vanilla stone, granite, diorite and andesite. Polished variants remain
+excluded. This is a simpler visual approximation, not a claim that lapis
+normally forms in every one of those rocks.
+
+OreSpawn's geological classification still helps choose districts without
+replacing terrain. Older geology that lacks modern classification uses a
+rarer, deterministic approximation instead. Its settings are left alone.
+
+Other terrain generators need to support safe sampling. If they do not,
+lapis lenses are skipped and a message is written to the log; the mod will
+not generate neighbouring chunks or replace unfamiliar rocks to find a site.
+
+## Mining and rewards
+
+The generated block is ordinary Minecraft lapis ore. In 1.10.2 it drops four
+to eight pieces of lapis lazuli, averaging six before Fortune. Fortune increases
+that yield; Silk Touch returns the ore block instead. No new processing
+recipes, lapis grades or river placers are added.
+
+The default catalogue retains 15% ordinary background lapis. New lenses are
+deliberately concentrated, and their reward is not forced to equal vanilla's
+overall supply. Structures, caves and water can interrupt a deposit or prevent
+a safe mine entrance. Not every planned lens will be fully recoverable.
+
+## Settings and existing worlds
+
+Settings live in `config/realisticdeposits/deposits/lapis.json`. Defaults use
+64-chunk regions, a 50% candidate chance before geological rejection and one
+to three lenses per accepted district. Lengths are 64-160 blocks, widths
+12-24 and thicknesses 1-4, within Y8-128 and below the natural surface.
+
+New files are copied only when absent. Existing catalogues are not overwritten,
+and existing world profiles do not silently receive lapis rules or reduced
+background generation. Previously generated chunks and saved mine layouts
+remain unchanged. Back up a world before editing its captured OreSpawn profile.
+
+Lapis geometry and policy settings are structural: restart after editing them.
+The additive, hybrid and replacement policies use the same OreSpawn controls
+as the other deposits, including imported settings and custom channels.
+
+For a separate test world, follow the [lapis walkthrough](../tools/qualification/LAPIS_TESTING.md).
diff --git a/docs/RELEASING.md b/docs/RELEASING.md
index 202c1a6..75976c0 100644
--- a/docs/RELEASING.md
+++ b/docs/RELEASING.md
@@ -5,7 +5,7 @@ The publication guard still rejects local OreSpawn candidates; a release must
use the public dependency, not an experimental jar.
The **Release Realistic Deposits** action runs from the repository's default
-branch. Enter the full numeric version, such as `0.4.0.110021`; the final
+branch. Enter the full numeric version, such as `0.5.0.110021`; the final
component identifies the Minecraft version and loader. CurseForge's alpha,
beta or release label is separate from the numeric version.
@@ -13,8 +13,11 @@ First run with **mode: validate**. This checks the branch, version, Java
settings and successful CI, then builds and checksums a release bundle. It
does not create a tag or upload anything.
-To publish, select **mode: publish**, choose **beta**, and tick the explicit
-publication confirmation. The workflow requires all four secrets:
+To publish, select **mode: publish**, choose the intended CurseForge channel
+(**alpha** for 0.5.0.110021), and tick the explicit publication confirmation.
+Supply the SHA-256 of the verified validation bundle's `SHA256SUMS` file as
+`expected_checksums_sha256` to require an exact match before publication.
+The workflow requires all four secrets:
- `CURSEFORGE_TOKEN`
- `MAVEN_UPLOAD_URL`
@@ -25,9 +28,8 @@ The upload order is Maven, CurseForge, then GitHub. Each job downloads the
same immutable bundle and checks its hashes. OreSpawn is a required
CurseForge dependency. The current project ID is `1689703`.
-Beta is the default CurseForge channel for this update. The numeric version
-stays unchanged, and the GitHub release remains a prerelease. Validation mode
-is still the safe default; changing the channel does not authorize an upload.
+The channel does not change the numeric version or GitHub prerelease status.
+Validation mode is the default; changing the channel does not authorize an upload.
Do not upload a second file because the first is awaiting moderation. If a
job fails after an upload, check its receipt and public destination before
diff --git a/gradle.properties b/gradle.properties
index 90c1bf1..bfa9725 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -22,7 +22,7 @@ gradle_java_version=17
mod_id=realisticdeposits
mod_name=Realistic Deposits
mod_license=LGPL-2.1
-mod_version=0.4.0.110021
+mod_version=0.5.0.110021
mod_group=zone.moddev.mc.realisticdeposits
mod_authors=SkyBlade1978, the MMD Team
mod_description=Rare, massive, and geologically aware ore deposits powered by MMD OreSpawn.
diff --git a/gradle/lapis-manual.gradle b/gradle/lapis-manual.gradle
new file mode 100644
index 0000000..ec39415
--- /dev/null
+++ b/gradle/lapis-manual.gradle
@@ -0,0 +1,42 @@
+// Lapis testing has its own settings and saves, separate from ordinary launches.
+tasks.named('isolateEclipseProductionRuns') {
+ doLast {
+ [['runClient.launch', 'runLapisDepositsClient.launch'],
+ ['runServer.launch', 'runLapisDepositsServer.launch']].each { pair ->
+ String contents = file(pair[0]).getText('UTF-8')
+ contents = contents.replaceAll(
+ /()/,
+ '$1' + file('run-lapis-deposits').absolutePath.replace('\\', '\\\\') + '$2')
+ file(pair[1]).setText(contents.replace('\r\n', '\n'), 'UTF-8')
+ }
+ }
+}
+
+tasks.named('verifyEclipseProductionClasspath') {
+ doLast {
+ ['runLapisDepositsClient.launch', 'runLapisDepositsServer.launch'].each { name ->
+ String contents = file(name).getText('UTF-8').replace('\\', '/')
+ if (!contents.contains('run-lapis-deposits') || !contents.contains('ATTR_EXCLUDE_TEST_CODE')) {
+ throw new GradleException("Lapis launch is not isolated: ${name}")
+ }
+ }
+ }
+}
+
+tasks.register('prepareLapisDepositsManual') {
+ group = 'verification'
+ description = 'Prepares separate lapis-testing settings and Eclipse launches.'
+ dependsOn tasks.named('verifyEclipseProductionClasspath')
+ doLast {
+ ['vanilla', 'coal', 'diamonds', 'gold', 'lapis'].each { name ->
+ File target = file("run-lapis-deposits/config/realisticdeposits/deposits/${name}.json")
+ if (!target.isFile()) {
+ target.parentFile.mkdirs()
+ target.setText(file("src/main/resources/assets/realisticdeposits/defaults/${name}.json")
+ .getText('UTF-8').replace('\r\n', '\n'), 'UTF-8')
+ }
+ }
+ logger.lifecycle('Use runLapisDepositsClient.launch and tools/qualification/LAPIS_TESTING.md.')
+ logger.lifecycle('Existing settings and saves were left alone. Create a fresh test world.')
+ }
+}
diff --git a/gradle/release.gradle b/gradle/release.gradle
index 33d0fc9..6e08c13 100644
--- a/gradle/release.gradle
+++ b/gradle/release.gradle
@@ -81,7 +81,7 @@ tasks.register('verifyJavadocOrdering') {
tasks.register('verifyReleaseConfiguration') {
group = 'verification'
doLast {
- if (project.mod_version != '0.4.0.110021'
+ if (project.mod_version != '0.5.0.110021'
|| project.curseforge_project_id != '1689703'
|| project.mod_artifact != 'RealisticDeposits'
|| project.java_version != '8' || project.gradle_java_version != '17'
diff --git a/src/main/java/zone/moddev/mc/realisticdeposits/RealisticDeposits.java b/src/main/java/zone/moddev/mc/realisticdeposits/RealisticDeposits.java
index 6b0a35c..d44c889 100644
--- a/src/main/java/zone/moddev/mc/realisticdeposits/RealisticDeposits.java
+++ b/src/main/java/zone/moddev/mc/realisticdeposits/RealisticDeposits.java
@@ -7,6 +7,7 @@
import zone.moddev.mc.realisticdeposits.generation.MineWorkingsGenerator;
import zone.moddev.mc.realisticdeposits.generation.DiamondPattern;
import zone.moddev.mc.realisticdeposits.generation.GoldPattern;
+import zone.moddev.mc.realisticdeposits.generation.LapisPattern;
import zone.moddev.mc.realisticdeposits.content.ModContent;
import zone.moddev.mc.realisticdeposits.integration.OreSpawnIntegration;
import zone.moddev.mc.realisticdeposits.proxy.CommonProxy;
@@ -38,7 +39,7 @@
public final class RealisticDeposits {
public static final String MOD_ID = "realisticdeposits";
public static final String NAME = "Realistic Deposits";
- public static final String VERSION = "0.4.0.110021";
+ public static final String VERSION = "0.5.0.110021";
private static final OrePatternType STRATIFORM_SEAM = StratiformSeamPattern.type()
.setRegistryName(OreSpawnIntegration.PATTERN_ID);
private static final OrePatternType COAL_MEASURES = CoalMeasuresPattern.type()
@@ -51,6 +52,8 @@ public final class RealisticDeposits {
.setRegistryName(OreSpawnIntegration.GOLD_LODE_ID);
private static final OrePatternType GOLD_PLACER = GoldPattern.type(true)
.setRegistryName(OreSpawnIntegration.GOLD_PLACER_ID);
+ private static final OrePatternType LAPIS_LENS = LapisPattern.type()
+ .setRegistryName(OreSpawnIntegration.LAPIS_LENS_ID);
@SidedProxy(clientSide = "zone.moddev.mc.realisticdeposits.proxy.ClientProxy",
serverSide = "zone.moddev.mc.realisticdeposits.proxy.CommonProxy")
@@ -69,6 +72,7 @@ public void registerPatterns(RegistryEvent.Register event) {
event.getRegistry().register(DIAMOND_PLACER);
event.getRegistry().register(GOLD_LODE);
event.getRegistry().register(GOLD_PLACER);
+ event.getRegistry().register(LAPIS_LENS);
}
@Mod.EventHandler
@@ -92,6 +96,7 @@ public void serverAboutToStart(FMLServerAboutToStartEvent event) {
CoalMeasuresPattern.newServerSession();
DiamondPattern.newServerSession();
GoldPattern.newServerSession();
+ LapisPattern.newServerSession();
}
@Mod.EventHandler
diff --git a/src/main/java/zone/moddev/mc/realisticdeposits/command/RealisticDepositsCommand.java b/src/main/java/zone/moddev/mc/realisticdeposits/command/RealisticDepositsCommand.java
index 4f14ffb..01dee49 100644
--- a/src/main/java/zone/moddev/mc/realisticdeposits/command/RealisticDepositsCommand.java
+++ b/src/main/java/zone/moddev/mc/realisticdeposits/command/RealisticDepositsCommand.java
@@ -69,6 +69,16 @@ private static void preview(ICommandSender sender, String[] args) throws Command
DepositDefinition definition = selectDefinition(state, args.length == 4 ? args[3] : null);
World world = sender.getEntityWorld();
String dimension = dimensionId(world.provider.getDimension());
+ if (definition.isLapis()) {
+ if (!(world instanceof net.minecraft.world.WorldServer)) {
+ throw new CommandException("Lapis previews require a server world");
+ }
+
+ sender.sendMessage(new TextComponentString(zone.moddev.mc.realisticdeposits.generation.LapisPattern
+ .preview(definition, (net.minecraft.world.WorldServer) world, regionX, regionZ)));
+ return;
+ }
+
if (definition.isGold()) {
if (!(world instanceof net.minecraft.world.WorldServer)) {
throw new CommandException("Gold previews require a server world");
diff --git a/src/main/java/zone/moddev/mc/realisticdeposits/config/DepositCatalogLoader.java b/src/main/java/zone/moddev/mc/realisticdeposits/config/DepositCatalogLoader.java
index 6f3592a..2a9a73d 100644
--- a/src/main/java/zone/moddev/mc/realisticdeposits/config/DepositCatalogLoader.java
+++ b/src/main/java/zone/moddev/mc/realisticdeposits/config/DepositCatalogLoader.java
@@ -24,6 +24,7 @@
import zone.moddev.mc.realisticdeposits.model.DiamondPlacerSettings;
import zone.moddev.mc.realisticdeposits.model.GoldLodeSettings;
import zone.moddev.mc.realisticdeposits.model.GoldPlacerSettings;
+import zone.moddev.mc.realisticdeposits.model.LapisLensSettings;
import zone.moddev.mc.realisticdeposits.model.IntRange;
import zone.moddev.mc.realisticdeposits.model.InteractionPolicy;
import zone.moddev.mc.realisticdeposits.model.StratiformSeamSettings;
@@ -113,6 +114,16 @@ private static DepositDefinition definition(JsonObject json, String path, List warnings) {
String type = string(shape, "type", path + " shape");
+ if ("lapis_lens".equals(type)) {
+ unknownKeys(shape, keys("type", "lenses", "strike_length", "width", "thickness",
+ "surface_cover", "legacy_selection_scale"), path + " shape", warnings);
+
+ return new LapisLensSettings(integerRange(shape, "lenses", path),
+ integerRange(shape, "strike_length", path), integerRange(shape, "width", path),
+ integerRange(shape, "thickness", path), integerRange(shape, "surface_cover", path),
+ number(shape, "legacy_selection_scale", path));
+ }
+
if ("gold_lode".equals(type)) {
unknownKeys(shape, keys("type", "veins", "strike_length", "thickness", "surface_cover",
"dip_degrees", "legacy_selection_scale"), path + " shape", warnings);
diff --git a/src/main/java/zone/moddev/mc/realisticdeposits/config/RealisticDepositsRuntime.java b/src/main/java/zone/moddev/mc/realisticdeposits/config/RealisticDepositsRuntime.java
index 19167ec..00c6798 100644
--- a/src/main/java/zone/moddev/mc/realisticdeposits/config/RealisticDepositsRuntime.java
+++ b/src/main/java/zone/moddev/mc/realisticdeposits/config/RealisticDepositsRuntime.java
@@ -80,6 +80,7 @@ private static void reloadCatalogs(boolean diagnostics) {
createDefault(new File(deposits, "coal.json"), "/assets/realisticdeposits/defaults/coal.json");
createDefault(new File(deposits, "diamonds.json"), "/assets/realisticdeposits/defaults/diamonds.json");
createDefault(new File(deposits, "gold.json"), "/assets/realisticdeposits/defaults/gold.json");
+ createDefault(new File(deposits, "lapis.json"), "/assets/realisticdeposits/defaults/lapis.json");
List files = jsonFiles(deposits);
List catalogs = new ArrayList<>();
List definitions = new ArrayList<>();
@@ -254,7 +255,8 @@ private static List jsonFiles(File directory) {
List result = new ArrayList<>();
collectJson(directory, result);
// Keep existing catalogue order; the new default must not become the first deposit.
- result.sort(Comparator.comparingInt((File file) -> "gold.json".equals(file.getName()) ? 3
+ result.sort(Comparator.comparingInt((File file) -> "lapis.json".equals(file.getName()) ? 4
+ : "gold.json".equals(file.getName()) ? 3
: "diamonds.json".equals(file.getName()) ? 2
: "coal.json".equals(file.getName()) ? 1 : 0)
.thenComparing(File::getPath));
diff --git a/src/main/java/zone/moddev/mc/realisticdeposits/generation/LapisGeology.java b/src/main/java/zone/moddev/mc/realisticdeposits/generation/LapisGeology.java
new file mode 100644
index 0000000..ed68177
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/realisticdeposits/generation/LapisGeology.java
@@ -0,0 +1,98 @@
+package zone.moddev.mc.realisticdeposits.generation;
+
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+
+import net.minecraft.block.Block;
+import net.minecraft.block.state.IBlockState;
+import net.minecraft.init.Blocks;
+import net.minecraft.util.ResourceLocation;
+
+import zone.moddev.mc.orespawn.api.GeologyColumn;
+import zone.moddev.mc.realisticdeposits.model.DepositDefinition;
+
+/** Exact natural hosts: marble with strata, ordinary stone variants without it. */
+final class LapisGeology {
+ static final ResourceLocation MARBLE = new ResourceLocation("mineralogy", "marble");
+
+ private LapisGeology() { }
+
+ static boolean physicalStrata(JsonObject profile) {
+ JsonObject dimensions = profile.getAsJsonObject("terrain_dimensions");
+ if (dimensions == null || !dimensions.has("minecraft:overworld")) return false;
+
+ JsonObject overworld = dimensions.getAsJsonObject("minecraft:overworld");
+ return overworld.has("enabled") && overworld.get("enabled").getAsBoolean();
+ }
+
+ static Set hosts(DepositDefinition definition, JsonObject profile, boolean physical) {
+ Set hosts = new HashSet<>();
+
+ if (!physical) {
+ if (definition.hosts().contains("minecraft:stone")) {
+ for (int metadata : new int[] {0, 1, 3, 5}) {
+ hosts.add(Blocks.STONE.getStateFromMeta(metadata));
+ }
+ }
+
+ return hosts;
+ }
+
+ JsonObject rocks = profile.getAsJsonObject("rocks");
+ if (rocks == null || !Block.REGISTRY.containsKey(MARBLE)) return hosts;
+
+ for (Map.Entry entry : rocks.entrySet()) {
+ JsonObject rock = entry.getValue().getAsJsonObject();
+ if (!rock.has("block") || (rock.has("enabled") && !rock.get("enabled").getAsBoolean())) continue;
+
+ String id = rock.get("block").getAsString();
+ int metadata = rock.has("metadata") ? rock.get("metadata").getAsInt() : 0;
+
+ if (permittedRock(id, metadata, true)) {
+ hosts.add(Block.REGISTRY.getObject(MARBLE).getStateFromMeta(0));
+ }
+ }
+
+ return hosts;
+ }
+
+ static boolean permittedRock(String id, int metadata, boolean physical) {
+ if (physical) return "mineralogy:marble".equals(id) && metadata == 0;
+
+ return "minecraft:stone".equals(id)
+ && (metadata == 0 || metadata == 1 || metadata == 3 || metadata == 5);
+ }
+
+ static double fitHeight(GeologyColumn column, double target, int thickness, int minimum,
+ int maximum, Set hosts) {
+ double best = Double.NaN;
+ double distance = Double.POSITIVE_INFINITY;
+ int start = -1;
+
+ for (int y = minimum; y <= maximum + 1; y++) {
+ boolean marble = y <= maximum && hosts.contains(column.rockAt(y));
+ if (marble && start < 0) start = y;
+ if (marble || start < 0) continue;
+
+ int end = y - 1;
+ if (end - start + 1 >= thickness) {
+ double candidate = Math.max(start + thickness * 0.5,
+ Math.min(end + 1 - thickness * 0.5, target));
+ double candidateDistance = Math.abs(candidate - target);
+
+ if (candidateDistance < distance) {
+ best = candidate;
+ distance = candidateDistance;
+ }
+ }
+
+ start = -1;
+ }
+
+ return best;
+ }
+}
diff --git a/src/main/java/zone/moddev/mc/realisticdeposits/generation/LapisPattern.java b/src/main/java/zone/moddev/mc/realisticdeposits/generation/LapisPattern.java
new file mode 100644
index 0000000..438f0bf
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/realisticdeposits/generation/LapisPattern.java
@@ -0,0 +1,259 @@
+package zone.moddev.mc.realisticdeposits.generation;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+
+import com.google.gson.JsonObject;
+import com.mojang.serialization.Codec;
+import org.apache.logging.log4j.LogManager;
+
+import net.minecraft.block.state.IBlockState;
+import net.minecraft.server.MinecraftServer;
+import net.minecraft.util.math.BlockPos;
+import net.minecraft.world.WorldServer;
+import net.minecraft.world.chunk.Chunk;
+import net.minecraftforge.fml.common.FMLCommonHandler;
+
+import zone.moddev.mc.orespawn.api.GeologyColumn;
+import zone.moddev.mc.orespawn.api.GeologySampler;
+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.realisticdeposits.config.DepositCatalogLoader;
+import zone.moddev.mc.realisticdeposits.config.RealisticDepositsRuntime;
+import zone.moddev.mc.realisticdeposits.geometry.BlockBounds;
+import zone.moddev.mc.realisticdeposits.geometry.LapisDistrict;
+import zone.moddev.mc.realisticdeposits.model.DepositDefinition;
+import zone.moddev.mc.realisticdeposits.model.AlignmentMode;
+
+/** Fits lapis to captured geology and writes only the current OreSpawn chunk. */
+public final class LapisPattern {
+ private static int epoch;
+ private static boolean warnedTerrain;
+ private static boolean warnedGeology;
+ private static boolean warnedMarble;
+
+ private LapisPattern() { }
+
+ public static OrePatternType type() {
+ return OrePatternType.create(Codec.of(input -> {
+ if (input == null || !input.isJsonObject()) {
+ throw new IllegalArgumentException("Lapis settings need a captured definition");
+ }
+
+ DepositDefinition definition = DepositCatalogLoader.loadDefinition(
+ input.getAsJsonObject().getAsJsonObject("definition"), "OreSpawn lapis profile");
+ if (!definition.isLapis()) throw new IllegalArgumentException("Lapis pattern and shape do not match");
+
+ // The enclosing saved rule has already passed OreSpawn's enabled check.
+ JsonObject enabled = definition.toJson();
+ enabled.addProperty("enabled", true);
+
+ return DepositCatalogLoader.loadDefinition(enabled, "enabled world lapis rule");
+ }), definition -> {
+ Renderer renderer = new Renderer(definition);
+
+ return base -> {
+ if (!(base instanceof OreGenerationContext)) {
+ throw new IllegalStateException("Lapis requires OreSpawn's extended generation context");
+ }
+
+ return renderer.place((OreGenerationContext) base);
+ };
+ });
+ }
+
+ public static void newServerSession() {
+ epoch++;
+ warnedTerrain = false;
+ warnedGeology = false;
+ warnedMarble = false;
+ }
+
+ static LapisDistrict.Site site(DepositDefinition definition, MineTerrainSampler terrain,
+ Optional sampler, Set hosts, boolean physical) {
+ return new LapisDistrict.Site() {
+ @Override
+ public int surfaceY(int x, int z) {
+ return terrain.naturalSurfaceY(x, z);
+ }
+
+ @Override
+ public String geome(int x, int z, int surfaceY) {
+ String id = sampler.isPresent()
+ ? sampler.get().sampleColumn(x, z, surfaceY).geome().toString() : null;
+
+ if ((id == null || "orespawn:cyano".equals(id)) && !warnedGeology) {
+ warnedGeology = true;
+ LogManager.getLogger("Realistic Deposits").warn("This world has no modern district classification. "
+ + "Lapis uses the configured rarity-only approximation; geology settings are unchanged.");
+ }
+
+ return id;
+ }
+
+ @Override
+ public double fitHeight(int x, int z, double targetY, int thickness) {
+ int surface = surfaceY(x, z);
+ if (surface < 0) return Double.NaN;
+
+ int maximum = Math.min(definition.maximumY(), surface - definition.lapis().surfaceCover().maximum());
+ if (!physical) return targetY;
+ if (!sampler.isPresent() || hosts.isEmpty()) return Double.NaN;
+
+ return LapisGeology.fitHeight(sampler.get().sampleColumn(x, z, surface), targetY,
+ thickness, definition.minimumY(), maximum, hosts);
+ }
+ };
+ }
+
+ private static final class Renderer {
+ private final DepositDefinition definition;
+ private final Map> districts =
+ new LinkedHashMap>() {
+ @Override
+ protected boolean removeEldestEntry(Map.Entry> entry) {
+ return size() > 128;
+ }
+ };
+ private int observedEpoch = -1;
+ private long observedSeed;
+ private boolean physical;
+ private Set hosts;
+ private boolean supportedTerrain;
+
+ Renderer(DepositDefinition definition) {
+ this.definition = definition;
+ }
+
+ boolean place(OreGenerationContext context) {
+ if (!definition.dimensions().contains(context.dimension().toString())
+ || !"minecraft:overworld".equals(context.dimension().toString())) return false;
+
+ MinecraftServer server = FMLCommonHandler.instance().getMinecraftServerInstance();
+ WorldServer world = server == null ? null : server.worldServerForDimension(0);
+ if (world == null) throw new IllegalStateException("Lapis placement has no active Overworld");
+
+ Chunk chunk = world.getChunkProvider().getLoadedChunk(context.chunkX(), context.chunkZ());
+ if (chunk == null) throw new IllegalStateException("Lapis may only inspect its loaded chunk");
+
+ if (observedEpoch != epoch || observedSeed != context.worldSeed()) {
+ districts.clear();
+ observedEpoch = epoch;
+ observedSeed = context.worldSeed();
+ JsonObject profile = OreSpawnApi.getActiveProfile(server).get().toJson();
+ physical = LapisGeology.physicalStrata(profile);
+ hosts = LapisGeology.hosts(definition, profile, physical);
+
+ try {
+ new MineTerrainSampler(world, world.getChunkProvider().chunkGenerator);
+ supportedTerrain = true;
+ } catch (IllegalArgumentException unsupported) {
+ supportedTerrain = false;
+ if (!warnedTerrain) {
+ warnedTerrain = true;
+ LogManager.getLogger("Realistic Deposits").warn("Lapis cannot safely sample this terrain generator. "
+ + "No neighbouring chunks will be generated to guess a site. "
+ + "Disable the lapis rule in OreSpawn to retain the ordinary supply.");
+ }
+ }
+
+ if (physical && hosts.isEmpty() && !warnedMarble) {
+ warnedMarble = true;
+ LogManager.getLogger("Realistic Deposits").warn("Lapis lenses need enabled natural marble in this "
+ + "world's strata. No suitable host is available; existing geology settings are unchanged.");
+ }
+ }
+
+ if (!supportedTerrain || hosts.isEmpty()) return false;
+
+ int minX = context.chunkX() << 4;
+ int minZ = context.chunkZ() << 4;
+ int radius = definition.lapis().strikeLength().maximum() / 2 + 96;
+ int size = definition.regionSpacingChunks() * 16;
+ int count = 0;
+ List structures = CoalStructureProtection.inChunk(context.chunkX(), context.chunkZ());
+ GeologyColumn[] columns = new GeologyColumn[256];
+ BlockPos.MutableBlockPos position = new BlockPos.MutableBlockPos();
+
+ for (int rx = Math.floorDiv(minX - radius, size); rx <= Math.floorDiv(minX + 15 + radius, size); rx++) {
+ for (int rz = Math.floorDiv(minZ - radius, size); rz <= Math.floorDiv(minZ + 15 + radius, size); rz++) {
+ long key = ((long) rx << 32) ^ (rz & 0xffffffffL);
+ Optional selected = districts.computeIfAbsent(key, ignored -> {
+ // Discard sampled terrain after fitting this district. Exploration must
+ // not leave every visited region's terrain cached for the whole session.
+ MineTerrainSampler terrain = new MineTerrainSampler(world, world.getChunkProvider().chunkGenerator);
+ LapisDistrict.Site candidateSite = site(definition, terrain, context.geologySampler(), hosts, physical);
+
+ return LapisDistrict.plan(definition, context.worldSeed(), context.dimension().toString(),
+ (int) (key >> 32), (int) key, candidateSite);
+ });
+ if (!selected.isPresent()) continue;
+
+ LapisDistrict district = selected.get();
+ if (definition.abandonedMine().enabled()) MineWorkingsGenerator.offer(context, definition, district);
+ BlockBounds bounds = district.bounds();
+ if (!bounds.intersectsChunk(context.chunkX(), context.chunkZ())) continue;
+
+ for (int x = Math.max(minX, bounds.minimumX()); x <= Math.min(minX + 15, bounds.maximumX()); x++) {
+ for (int z = Math.max(minZ, bounds.minimumZ()); z <= Math.min(minZ + 15, bounds.maximumZ()); z++) {
+ int index = (x & 15) + (z & 15) * 16;
+ int surface = chunk.getHeightValue(x & 15, z & 15);
+ int top = Math.min(district.maximumCandidateYAt(x, z), surface - district.surfaceCover());
+
+ for (int y = district.minimumCandidateYAt(x, z); y <= top; y++) {
+ if (!district.isOreAt(x, y, z) || !context.inside(x, y, z)
+ || CoalStructureProtection.contains(structures, x, y, z)) continue;
+
+ IBlockState state = chunk.getBlockState(position.setPos(x, y, z));
+ if (!hosts.contains(state)) continue;
+
+ if (physical || definition.alignment() == AlignmentMode.ROCK_ALIGNED) {
+ if (!context.geologySampler().isPresent()) continue;
+ if (columns[index] == null) columns[index] = context.geologySampler().get().sampleColumn(x, z, surface);
+ if (physical && !state.equals(columns[index].rockAt(y))) continue;
+ if (definition.alignment() == AlignmentMode.ROCK_ALIGNED
+ && !definition.rockFamilies().contains(columns[index].familyAt(y).orElse(null))) continue;
+ }
+
+ if (context.tryPlace(x, y, z, district.identity().seed())) count++;
+ }
+ }
+ }
+ }
+ }
+
+ if (count > 0) RealisticDepositsRuntime.reportFirstGeneration(definition.id(),
+ context.dimension().toString(), context.chunkX(), context.chunkZ(), count);
+
+ return count > 0;
+ }
+ }
+
+ public static String preview(DepositDefinition definition, WorldServer world, int rx, int rz) {
+ if (world.provider.getDimension() != 0) {
+ return "Lapis lens previews are available in the Overworld only.";
+ }
+
+ try {
+ JsonObject profile = OreSpawnApi.getActiveProfile(world.getMinecraftServer()).get().toJson();
+ boolean physical = LapisGeology.physicalStrata(profile);
+ LapisDistrict.Site site = site(definition, new MineTerrainSampler(world, world.getChunkProvider().chunkGenerator),
+ OreSpawnApi.createSampler(world), LapisGeology.hosts(definition, profile, physical), physical);
+ Optional planned = LapisDistrict.plan(definition, world.getSeed(),
+ "minecraft:overworld", rx, rz, site);
+
+ if (!planned.isPresent()) return "No suitable lapis lenses are selected in this region.";
+
+ LapisDistrict district = planned.get();
+ return "Lapis lenses near " + district.centerX() + "," + district.centerY() + "," + district.centerZ()
+ + "; lenses=" + district.lenses().size() + (physical ? ", fitted to natural marble."
+ : ", using the natural-stone approximation.") + " Preview changes no blocks.";
+ } catch (IllegalArgumentException unsupported) {
+ return "This terrain generator does not support safe lapis previews.";
+ }
+ }
+}
diff --git a/src/main/java/zone/moddev/mc/realisticdeposits/geometry/LapisDistrict.java b/src/main/java/zone/moddev/mc/realisticdeposits/geometry/LapisDistrict.java
new file mode 100644
index 0000000..604cc16
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/realisticdeposits/geometry/LapisDistrict.java
@@ -0,0 +1,284 @@
+package zone.moddev.mc.realisticdeposits.geometry;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import java.util.Random;
+
+import zone.moddev.mc.realisticdeposits.model.AlignmentMode;
+import zone.moddev.mc.realisticdeposits.model.DepositDefinition;
+import zone.moddev.mc.realisticdeposits.model.LapisLensSettings;
+
+/** Compact mineralised lenses, fitted before any chunk is written. */
+public final class LapisDistrict implements MineableDepositBody {
+ public interface Site {
+ int surfaceY(int x, int z);
+ String geome(int x, int z, int surfaceY);
+
+ /** Returns a suitable lens centre, or NaN when the host interval is too thin. */
+ double fitHeight(int x, int z, double targetY, int thickness);
+ }
+
+ private final DepositIdentity identity;
+ private final List lenses;
+ private final BlockBounds bounds;
+ private final int cover;
+
+ private LapisDistrict(DepositIdentity identity, List lenses, int cover) {
+ this.identity = identity;
+ this.lenses = Collections.unmodifiableList(new ArrayList<>(lenses));
+ this.cover = cover;
+
+ int minX = Integer.MAX_VALUE;
+ int minY = 255;
+ int minZ = Integer.MAX_VALUE;
+ int maxX = Integer.MIN_VALUE;
+ int maxY = 0;
+ int maxZ = Integer.MIN_VALUE;
+
+ for (Lens lens : lenses) {
+ minX = Math.min(minX, lens.bounds.minimumX());
+ minY = Math.min(minY, lens.bounds.minimumY());
+ minZ = Math.min(minZ, lens.bounds.minimumZ());
+ maxX = Math.max(maxX, lens.bounds.maximumX());
+ maxY = Math.max(maxY, lens.bounds.maximumY());
+ maxZ = Math.max(maxZ, lens.bounds.maximumZ());
+ }
+
+ bounds = new BlockBounds(minX, minY, minZ, maxX, maxY, maxZ);
+ }
+
+ public static Optional plan(DepositDefinition definition, long seed,
+ String dimension, int regionX, int regionZ, Site site) {
+ if (!definition.isLapis() || !definition.enabled() || !definition.dimensions().contains(dimension)) {
+ return Optional.empty();
+ }
+
+ DepositIdentity identity = new DepositIdentity(seed, dimension, definition.id(),
+ regionX, regionZ, definition.salt());
+
+ if (StableHash.unit(identity.seed() ^ 0x35718A42CE906B1DL) >= definition.selectionChance()) {
+ return Optional.empty();
+ }
+
+ Random random = new Random(identity.seed());
+ LapisLensSettings settings = definition.lapis();
+ int size = definition.regionSpacingChunks() * 16;
+ int x = Math.toIntExact((long) regionX * size + random.nextInt(size));
+ int z = Math.toIntExact((long) regionZ * size + random.nextInt(size));
+ int surface = site.surfaceY(x, z);
+ int cover = settings.surfaceCover().sample(random);
+ int maximum = Math.min(definition.maximumY(), surface - cover);
+
+ if (surface < 0 || maximum - definition.minimumY() < 6) {
+ return Optional.empty();
+ }
+
+ String geome = site.geome(x, z, surface);
+ if (definition.alignment() != AlignmentMode.MASSIVE_RARE) {
+ if (geome == null || "orespawn:cyano".equals(geome)) {
+ if (StableHash.unit(identity.seed() ^ 0x76DF1939812B072AL) >= settings.legacySelectionScale()) {
+ return Optional.empty();
+ }
+ } else if (!definition.geomes().contains(geome)) {
+ return Optional.empty();
+ }
+ }
+
+ double angle = random.nextDouble() * Math.PI * 2;
+ double target = definition.minimumY() + 3
+ + random.nextInt(maximum - definition.minimumY() - 5);
+ int count = settings.lensCount().sample(random);
+ List lenses = new ArrayList<>();
+
+ for (int index = 0; index < count; index++) {
+ int offset = index == 0 ? 0 : (index % 2 == 0 ? -1 : 1) * (14 + random.nextInt(16));
+ int lensX = x - (int) Math.round(Math.sin(angle) * offset);
+ int lensZ = z + (int) Math.round(Math.cos(angle) * offset);
+ int length = settings.strikeLength().sample(random);
+ int width = settings.width().sample(random);
+ int thickness = settings.thickness().sample(random);
+ double fitted = site.fitHeight(lensX, lensZ, target, thickness);
+
+ if (!Double.isFinite(fitted)) continue;
+
+ Lens lens = new Lens(lensX, lensZ, fitted, length, width, thickness,
+ angle, StableHash.combine(identity.seed(), index), definition, cover, site);
+ if (lens.productive()) lenses.add(lens);
+ }
+
+ return lenses.isEmpty() ? Optional.empty() : Optional.of(new LapisDistrict(identity, lenses, cover));
+ }
+
+ @Override public DepositIdentity identity() { return identity; }
+ @Override public int centerX() { return lenses.get(0).x; }
+ @Override public int centerY() { return lenses.get(0).routePoint(0, 0).y(); }
+ @Override public int centerZ() { return lenses.get(0).z; }
+ @Override public int strikeLength() { return lenses.get(0).length; }
+ @Override public BlockBounds bounds() { return bounds; }
+ public List lenses() { return lenses; }
+ public int surfaceCover() { return cover; }
+
+ @Override
+ public boolean isOreAt(int x, int y, int z) {
+ for (Lens lens : lenses) {
+ if (lens.isOreAt(x, y, z)) return true;
+ }
+
+ return false;
+ }
+
+ @Override
+ public int minimumCandidateYAt(int x, int z) {
+ int minimum = 256;
+
+ for (Lens lens : lenses) {
+ double height = lens.heightAt(x + 0.5, z + 0.5);
+ if (Double.isFinite(height)) minimum = Math.min(minimum, (int) Math.floor(height) - lens.thickness);
+ }
+
+ return Math.max(bounds.minimumY(), minimum);
+ }
+
+ @Override
+ public int maximumCandidateYAt(int x, int z) {
+ int maximum = -1;
+
+ for (Lens lens : lenses) {
+ double height = lens.heightAt(x + 0.5, z + 0.5);
+ if (Double.isFinite(height)) maximum = Math.max(maximum, (int) Math.ceil(height) + lens.thickness);
+ }
+
+ return Math.min(bounds.maximumY(), maximum);
+ }
+
+ @Override
+ public MinePoint miningRoutePoint(double along, double lateralOffset) {
+ // Work the lower edge of the lens, leaving the richer roof and side pods.
+ return lenses.get(0).routePoint(along, lateralOffset);
+ }
+
+ public static final class Lens {
+ private static final int GRID_STEP = 8;
+ private final int x;
+ private final int z;
+ private final int length;
+ private final int width;
+ private final int thickness;
+ private final double cosine;
+ private final double sine;
+ private final double phase;
+ private final int gridX;
+ private final int gridZ;
+ private final int columns;
+ private final int rows;
+ private final double[] heights;
+ private final BlockBounds bounds;
+
+ private Lens(int x, int z, double centreY, int length, int width, int thickness,
+ double angle, long seed, DepositDefinition definition, int cover, Site site) {
+ this.x = x;
+ this.z = z;
+ this.length = length;
+ this.width = width;
+ this.thickness = thickness;
+ cosine = Math.cos(angle);
+ sine = Math.sin(angle);
+ phase = StableHash.unit(seed) * Math.PI * 2;
+
+ int reachX = (int) Math.ceil(Math.abs(cosine) * length / 2 + Math.abs(sine) * (width / 2 + 3));
+ int reachZ = (int) Math.ceil(Math.abs(sine) * length / 2 + Math.abs(cosine) * (width / 2 + 3));
+ bounds = new BlockBounds(x - reachX, definition.minimumY(), z - reachZ,
+ x + reachX, definition.maximumY(), z + reachZ);
+ gridX = bounds.minimumX() - GRID_STEP;
+ gridZ = bounds.minimumZ() - GRID_STEP;
+ columns = (bounds.maximumX() - gridX) / GRID_STEP + 3;
+ rows = (bounds.maximumZ() - gridZ) / GRID_STEP + 3;
+ heights = new double[columns * rows];
+
+ for (int gz = 0; gz < rows; gz++) {
+ for (int gx = 0; gx < columns; gx++) {
+ int blockX = gridX + gx * GRID_STEP;
+ int blockZ = gridZ + gz * GRID_STEP;
+ double along = (blockX - x) * cosine + (blockZ - z) * sine;
+ double target = centreY + 1.5 * Math.sin(along / 37 + phase)
+ - 1.5 * Math.sin(phase);
+ double fitted = site.fitHeight(blockX, blockZ, target, thickness);
+ int top = Math.min(definition.maximumY(), site.surfaceY(blockX, blockZ) - cover);
+
+ if (Math.abs(fitted - centreY) > 12 || fitted - thickness / 2.0 < definition.minimumY()
+ || fitted + thickness / 2.0 > top) fitted = Double.NaN;
+
+ heights[gx + gz * columns] = fitted;
+ }
+ }
+ }
+
+ public int length() { return length; }
+ public int width() { return width; }
+ public int thickness() { return thickness; }
+
+ private boolean productive() {
+ for (int offset = -1; offset <= 1; offset++) {
+ double along = offset * length * 0.25;
+ if (!Double.isFinite(heightAt(x + along * cosine, z + along * sine))) return false;
+ }
+
+ return true;
+ }
+
+ private double heightAt(double blockX, double blockZ) {
+ double gx = (blockX - gridX) / GRID_STEP;
+ double gz = (blockZ - gridZ) / GRID_STEP;
+ int ix = (int) Math.floor(gx);
+ int iz = (int) Math.floor(gz);
+
+ if (ix < 0 || iz < 0 || ix + 1 >= columns || iz + 1 >= rows) return Double.NaN;
+
+ double dx = gx - ix;
+ double dz = gz - iz;
+
+ return (heights[ix + iz * columns] * (1 - dx) + heights[ix + 1 + iz * columns] * dx) * (1 - dz)
+ + (heights[ix + (iz + 1) * columns] * (1 - dx)
+ + heights[ix + 1 + (iz + 1) * columns] * dx) * dz;
+ }
+
+ private double bend(double along) {
+ return 1.6 * Math.sin(along / 23 + phase);
+ }
+
+ private boolean isOreAt(int blockX, int blockY, int blockZ) {
+ double along = (blockX + 0.5 - x) * cosine + (blockZ + 0.5 - z) * sine;
+ double across = -(blockX + 0.5 - x) * sine + (blockZ + 0.5 - z) * cosine - bend(along);
+ double a = along / (length * 0.5);
+ double b = across / (width * 0.5);
+ double edge = 1 - a * a - b * b;
+
+ if (edge <= 0) return false;
+
+ double height = heightAt(blockX + 0.5, blockZ + 0.5);
+ if (!Double.isFinite(height)) return false;
+
+ int depth = Math.max(1, (int) Math.ceil(thickness * Math.sqrt(edge)));
+ int bottom = (int) Math.floor(height - (depth - 1) / 2.0);
+ if (blockY < bottom || blockY >= bottom + depth) return false;
+
+ double rich = 0.5 + 0.5 * Math.cos(along / 13 + phase);
+ double halfWidth = 0.9 + width * 0.5 * Math.pow(rich, 3) * Math.sqrt(edge);
+
+ return Math.abs(across) <= halfWidth;
+ }
+
+ private MinePoint routePoint(double along, double lateralOffset) {
+ double forward = Math.max(-length * 0.35, Math.min(length * 0.35, along));
+ double across = bend(forward) - width * 0.3 + lateralOffset;
+ int blockX = (int) Math.floor(x + forward * cosine - across * sine);
+ int blockZ = (int) Math.floor(z + forward * sine + across * cosine);
+ double height = heightAt(blockX + 0.5, blockZ + 0.5);
+ if (!Double.isFinite(height)) height = heightAt(x, z);
+
+ return new MinePoint(blockX, (int) Math.floor(height) - 2, blockZ);
+ }
+ }
+}
diff --git a/src/main/java/zone/moddev/mc/realisticdeposits/integration/OreSpawnIntegration.java b/src/main/java/zone/moddev/mc/realisticdeposits/integration/OreSpawnIntegration.java
index 83ffa08..9ca0a9b 100644
--- a/src/main/java/zone/moddev/mc/realisticdeposits/integration/OreSpawnIntegration.java
+++ b/src/main/java/zone/moddev/mc/realisticdeposits/integration/OreSpawnIntegration.java
@@ -23,6 +23,8 @@ public final class OreSpawnIntegration {
new ResourceLocation("realisticdeposits", "gold_lode");
public static final ResourceLocation GOLD_PLACER_ID =
new ResourceLocation("realisticdeposits", "gold_placer");
+ public static final ResourceLocation LAPIS_LENS_ID =
+ new ResourceLocation("realisticdeposits", "lapis_lens");
private OreSpawnIntegration() { }
@@ -59,6 +61,7 @@ private static ResourceLocation patternId(DepositDefinition definition) {
case "diamond_placer": return DIAMOND_PLACER_ID;
case "gold_lode": return GOLD_LODE_ID;
case "gold_placer": return GOLD_PLACER_ID;
+ case "lapis_lens": return LAPIS_LENS_ID;
default: throw new IllegalArgumentException("Unsupported shape: " + definition.shape().type());
}
}
@@ -97,7 +100,7 @@ private static void add(WorldgenProvider.Builder provider, DepositDefinition def
final DepositDefinition linkedSource = source;
provider.ore(oreId, output, ore -> {
ore.retrogen(false).suppressVanilla(false);
- if (definition.isCoal() || definition.isDiamond() || definition.isGold()) {
+ if (definition.isCoal() || definition.isDiamond() || definition.isGold() || definition.isLapis()) {
ore.enabled(definition.enabled() && (linkedSource == null || linkedSource.enabled()));
}
if (definition.isDiamondPlacer()) {
@@ -118,17 +121,23 @@ private static void add(WorldgenProvider.Builder provider, DepositDefinition def
if (net.minecraft.block.Block.REGISTRY.containsKey(id)) dimension.hostState(id, 0);
if ((definition.isDiamondPlacer() || definition.isGoldPlacer())
&& "minecraft:sand".equals(host)) dimension.hostState(id, 1);
- } else if ((definition.isDiamondPipe() || definition.isGoldLode()) && "minecraft:stone".equals(host)) {
+ } else if ((definition.isDiamondPipe() || definition.isGoldLode() || definition.isLapis())
+ && "minecraft:stone".equals(host)) {
for (int metadata : new int[] {0, 1, 3, 5}) {
dimension.hostState(new ResourceLocation(host), metadata);
}
- } else {
+ } else if (!definition.isLapis()) {
dimension.hostBlock(new ResourceLocation(host));
}
}
+ if (definition.isLapis()) {
+ ResourceLocation marble = new ResourceLocation("mineralogy", "marble");
+ if (net.minecraft.block.Block.REGISTRY.containsKey(marble)) dimension.hostState(marble, 0);
+ }
// Every geology family is a safe outer host. ROCK_ALIGNED applies the
// current live family selection per output block inside the pattern.
- if (!definition.isCoal() && !definition.isDiamondPlacer() && !definition.isGoldPlacer()) {
+ if (!definition.isCoal() && !definition.isDiamondPlacer()
+ && !definition.isGoldPlacer() && !definition.isLapis()) {
for (GeologyFamily family : GeologyFamily.values()) dimension.hostFamily(family);
}
for (String biome : definition.biomeIds()) dimension.biome(new ResourceLocation(biome));
diff --git a/src/main/java/zone/moddev/mc/realisticdeposits/model/DepositDefinition.java b/src/main/java/zone/moddev/mc/realisticdeposits/model/DepositDefinition.java
index 5216cd4..528327e 100644
--- a/src/main/java/zone/moddev/mc/realisticdeposits/model/DepositDefinition.java
+++ b/src/main/java/zone/moddev/mc/realisticdeposits/model/DepositDefinition.java
@@ -182,6 +182,8 @@ public OptionalDouble effectiveBackgroundGenerationScale() {
public boolean isGoldLode() { return shape instanceof GoldLodeSettings; }
public boolean isGoldPlacer() { return shape instanceof GoldPlacerSettings; }
public boolean isGold() { return isGoldLode() || isGoldPlacer(); }
+ public boolean isLapis() { return shape instanceof LapisLensSettings; }
+ public LapisLensSettings lapis() { return (LapisLensSettings) shape; }
public GoldLodeSettings lode() { return (GoldLodeSettings) shape; }
public GoldPlacerSettings goldPlacer() { return (GoldPlacerSettings) shape; }
public DiamondPipeSettings pipe() { return (DiamondPipeSettings) shape; }
@@ -204,7 +206,8 @@ public String structuralFingerprint() {
+ (abandonedMine.enabled() ? "|abandoned_mine:" + abandonedMine.chance() : "")
+ (isCoal() ? "|shape:coal_measures" : "")
+ (isDiamond() ? "|diamond:" + toJson() : "")
- + (isGold() ? "|gold:" + toJson() : "");
+ + (isGold() ? "|gold:" + toJson() : "")
+ + (isLapis() ? "|lapis:" + toJson() : "");
}
/** Deterministic schema-1 representation embedded in OreSpawn pattern settings. */
diff --git a/src/main/java/zone/moddev/mc/realisticdeposits/model/LapisLensSettings.java b/src/main/java/zone/moddev/mc/realisticdeposits/model/LapisLensSettings.java
new file mode 100644
index 0000000..bbb1ee5
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/realisticdeposits/model/LapisLensSettings.java
@@ -0,0 +1,66 @@
+package zone.moddev.mc.realisticdeposits.model;
+
+import java.util.Objects;
+
+import com.google.gson.JsonObject;
+
+/** Dimensions of compact lapis lenses, rather than whole ore-filled rock layers. */
+public final class LapisLensSettings implements DepositShapeSettings {
+ private final IntRange lenses;
+ private final IntRange strike;
+ private final IntRange width;
+ private final IntRange thickness;
+ private final IntRange cover;
+ private final double legacyScale;
+
+ public LapisLensSettings(IntRange lenses, IntRange strike, IntRange width,
+ IntRange thickness, IntRange cover, double legacyScale) {
+ this.lenses = checked(lenses, 1, 6, "lens count");
+ this.strike = checked(strike, 32, 256, "strike length");
+ this.width = checked(width, 6, 48, "width");
+ this.thickness = checked(thickness, 1, 6, "thickness");
+ this.cover = checked(cover, 1, 64, "surface cover");
+
+ if (!Double.isFinite(legacyScale) || legacyScale <= 0 || legacyScale > 1) {
+ throw new IllegalArgumentException("Lapis legacy selection scale must be within (0,1]");
+ }
+
+ this.legacyScale = legacyScale;
+ }
+
+ @Override
+ public String type() {
+ return "lapis_lens";
+ }
+
+ public IntRange lensCount() { return lenses; }
+ public IntRange strikeLength() { return strike; }
+ public IntRange width() { return width; }
+ public IntRange thickness() { return thickness; }
+ public IntRange surfaceCover() { return cover; }
+ public double legacySelectionScale() { return legacyScale; }
+
+ @Override
+ public JsonObject toJson() {
+ JsonObject json = new JsonObject();
+ json.addProperty("type", type());
+ json.add("lenses", DiamondPipeSettings.array(lenses));
+ json.add("strike_length", DiamondPipeSettings.array(strike));
+ json.add("width", DiamondPipeSettings.array(width));
+ json.add("thickness", DiamondPipeSettings.array(thickness));
+ json.add("surface_cover", DiamondPipeSettings.array(cover));
+ json.addProperty("legacy_selection_scale", legacyScale);
+
+ return json;
+ }
+
+ private static IntRange checked(IntRange value, int minimum, int maximum, String name) {
+ Objects.requireNonNull(value, name);
+
+ if (value.minimum() < minimum || value.maximum() > maximum) {
+ throw new IllegalArgumentException("Invalid lapis " + name);
+ }
+
+ return value;
+ }
+}
diff --git a/src/main/resources/assets/realisticdeposits/defaults/lapis.json b/src/main/resources/assets/realisticdeposits/defaults/lapis.json
new file mode 100644
index 0000000..a0938e0
--- /dev/null
+++ b/src/main/resources/assets/realisticdeposits/defaults/lapis.json
@@ -0,0 +1,32 @@
+{
+ "schema_version": 1,
+ "catalog": "realisticdeposits:lapis",
+ "deposits": [
+ {
+ "id": "realisticdeposits:overworld_lapis_lenses",
+ "enabled": true,
+ "resource": "minecraft:lapis_ore",
+ "alignment": "geome_aligned",
+ "interaction": "hybrid",
+ "background_generation_scale": 0.15,
+ "dimensions": ["minecraft:overworld"],
+ "hosts": ["minecraft:stone"],
+ "geomes": ["orespawn:mountain_belt", "orespawn:stable_craton"],
+ "min_y": 8,
+ "max_y": 128,
+ "region_spacing_chunks": 64,
+ "selection_chance": 0.5,
+ "salt": 7264195380148617201,
+ "abandoned_mine": {"enabled": true, "chance": 1.0},
+ "shape": {
+ "type": "lapis_lens",
+ "lenses": [1, 3],
+ "strike_length": [64, 160],
+ "width": [12, 24],
+ "thickness": [1, 4],
+ "surface_cover": [4, 12],
+ "legacy_selection_scale": 0.25
+ }
+ }
+ ]
+}
diff --git a/src/main/resources/assets/realisticdeposits/lang/en_US.lang b/src/main/resources/assets/realisticdeposits/lang/en_US.lang
index 31f0cfb..f2b3174 100644
--- a/src/main/resources/assets/realisticdeposits/lang/en_US.lang
+++ b/src/main/resources/assets/realisticdeposits/lang/en_US.lang
@@ -2,7 +2,7 @@ realisticdeposits.name=Realistic Deposits
screen.realisticdeposits.title=Realistic Deposits
screen.realisticdeposits.loaded=%s catalog(s), %s deposit definition(s) loaded
screen.realisticdeposits.generation_status=World generation: %s
-screen.realisticdeposits.reload_policy=Iron and coal shapes reload live. Linked gold and diamond settings and other structural edits require a restart.
+screen.realisticdeposits.reload_policy=Iron and coal shapes reload live. Lapis, linked gold and diamond settings, and other structural edits require a restart.
screen.realisticdeposits.orespawn_ui=Also available from OreSpawn World Settings
button.realisticdeposits.settings=Realistic Deposits
button.realisticdeposits.reload=Reload Definitions
diff --git a/src/test/java/zone/moddev/mc/realisticdeposits/ProjectIdentityTest.java b/src/test/java/zone/moddev/mc/realisticdeposits/ProjectIdentityTest.java
index 2749e61..5cdb821 100644
--- a/src/test/java/zone/moddev/mc/realisticdeposits/ProjectIdentityTest.java
+++ b/src/test/java/zone/moddev/mc/realisticdeposits/ProjectIdentityTest.java
@@ -16,7 +16,7 @@ class ProjectIdentityTest {
void usesStablePublicIdentity() {
assertEquals("realisticdeposits", RealisticDeposits.MOD_ID);
assertEquals("Realistic Deposits", RealisticDeposits.NAME);
- assertEquals("0.4.0.110021", RealisticDeposits.VERSION);
+ assertEquals("0.5.0.110021", RealisticDeposits.VERSION);
net.minecraftforge.fml.common.Mod annotation = RealisticDeposits.class.getAnnotation(
net.minecraftforge.fml.common.Mod.class);
assertTrue(annotation.guiFactory().endsWith("RealisticDepositsGuiFactory"));
diff --git a/src/test/java/zone/moddev/mc/realisticdeposits/config/LapisCatalogTest.java b/src/test/java/zone/moddev/mc/realisticdeposits/config/LapisCatalogTest.java
new file mode 100644
index 0000000..822cccb
--- /dev/null
+++ b/src/test/java/zone/moddev/mc/realisticdeposits/config/LapisCatalogTest.java
@@ -0,0 +1,79 @@
+package zone.moddev.mc.realisticdeposits.config;
+
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+
+import com.google.gson.JsonObject;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.BeforeAll;
+
+import zone.moddev.mc.realisticdeposits.model.DepositDefinition;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public class LapisCatalogTest {
+ @BeforeAll
+ static void vanilla() {
+ net.minecraft.init.Bootstrap.register();
+ }
+
+ public static DepositDefinition defaults() {
+ InputStream source = LapisCatalogTest.class.getResourceAsStream(
+ "/assets/realisticdeposits/defaults/lapis.json");
+ assertNotNull(source, "Ship the lapis catalogue with the mod");
+
+ return DepositCatalogLoader.load(new InputStreamReader(source, StandardCharsets.UTF_8),
+ "lapis.json").definitions().get(0);
+ }
+
+ @Test
+ void shipsASeparateLapisShapeAndBackgroundController() {
+ DepositDefinition definition = defaults();
+
+ assertEquals("lapis_lens", definition.shape().type());
+ assertEquals("minecraft:lapis_ore", definition.resource());
+ assertEquals(0.15, definition.effectiveBackgroundGenerationScale().getAsDouble());
+ assertTrue(definition.abandonedMine().enabled());
+ assertEquals(1.0, definition.abandonedMine().chance());
+ }
+
+ @Test
+ void geometryIsCapturedAndRoundTripsWithoutLoss() {
+ DepositDefinition definition = defaults();
+ assertEquals(definition.toJson(), DepositCatalogLoader.loadDefinition(
+ definition.toJson(), "round trip").toJson());
+
+ JsonObject changed = definition.toJson();
+ changed.addProperty("salt", definition.salt() + 1);
+ assertNotEquals(definition.structuralFingerprint(), DepositCatalogLoader.loadDefinition(
+ changed, "changed lapis").structuralFingerprint());
+ }
+
+ @Test
+ void exactProviderHostsCannotBeBroadenedByRockFamilies() {
+ JsonObject ore = zone.moddev.mc.realisticdeposits.integration.OreSpawnIntegration
+ .provider(java.util.Collections.singleton(defaults())).toJson().getAsJsonObject("ores")
+ .getAsJsonObject("realisticdeposits:deposit/realisticdeposits/overworld_lapis_lenses");
+ JsonObject dimension = ore.getAsJsonObject("dimensions").getAsJsonObject("minecraft:overworld");
+
+ assertEquals(0, dimension.getAsJsonArray("host_families").size());
+ assertEquals(4, dimension.getAsJsonArray("host_blocks").size());
+ java.util.Set variants = new java.util.HashSet<>();
+ for (com.google.gson.JsonElement value : dimension.getAsJsonArray("host_blocks")) {
+ JsonObject host = value.getAsJsonObject();
+ assertEquals("minecraft:stone", host.get("block").getAsString());
+ variants.add(host.get("metadata").getAsInt());
+ }
+ assertEquals(new java.util.HashSet<>(java.util.Arrays.asList(0, 1, 3, 5)), variants);
+ assertFalse(ore.get("retrogen").getAsBoolean());
+ assertEquals("realisticdeposits:lapis_lens", dimension.getAsJsonObject("pattern").get("type").getAsString());
+ }
+
+ @Test
+ void unboundedOrInvalidSettingsFailWithAnActionableError() {
+ JsonObject json = defaults().toJson();
+ json.getAsJsonObject("shape").addProperty("legacy_selection_scale", 0);
+ assertThrows(IllegalArgumentException.class, () -> DepositCatalogLoader.loadDefinition(json, "lapis.json"));
+ }
+}
diff --git a/src/test/java/zone/moddev/mc/realisticdeposits/config/MineDefaultsTest.java b/src/test/java/zone/moddev/mc/realisticdeposits/config/MineDefaultsTest.java
index 6cd56b7..1d47301 100644
--- a/src/test/java/zone/moddev/mc/realisticdeposits/config/MineDefaultsTest.java
+++ b/src/test/java/zone/moddev/mc/realisticdeposits/config/MineDefaultsTest.java
@@ -16,7 +16,7 @@ class MineDefaultsTest {
@Test
void everyNewUndergroundDepositAttemptsWorkings() {
- for (String name : new String[] {"vanilla", "coal", "diamonds", "gold"}) {
+ for (String name : new String[] {"vanilla", "coal", "diamonds", "gold", "lapis"}) {
for (DepositDefinition definition : catalogue(name).definitions()) {
if (definition.isDiamondPlacer() || definition.isGoldPlacer()) {
assertFalse(definition.abandonedMine().enabled());
@@ -46,7 +46,7 @@ void diamondPipesCanCaptureWorkingsWithoutMovingTheDeposit() {
@Test
void oldProfilesAndExplicitOptOutsStayOff() {
- for (String name : new String[] {"vanilla", "coal", "diamonds", "gold"}) {
+ for (String name : new String[] {"vanilla", "coal", "diamonds", "gold", "lapis"}) {
for (DepositDefinition definition : catalogue(name).definitions()) {
JsonObject legacy = definition.toJson();
legacy.remove("abandoned_mine");
diff --git a/src/test/java/zone/moddev/mc/realisticdeposits/config/RealisticDepositsRuntimeTest.java b/src/test/java/zone/moddev/mc/realisticdeposits/config/RealisticDepositsRuntimeTest.java
index 93acaa5..c8e3d9b 100644
--- a/src/test/java/zone/moddev/mc/realisticdeposits/config/RealisticDepositsRuntimeTest.java
+++ b/src/test/java/zone/moddev/mc/realisticdeposits/config/RealisticDepositsRuntimeTest.java
@@ -36,7 +36,7 @@ void geometryReloadsLiveWhileStructuralChangesAreDeferred() throws IOException {
assertEquals(RuntimeState.IntegrationStatus.ACTIVE,
RealisticDepositsRuntime.state().integrationStatus());
- write(catalog, document("minecraft:lapis_ore", "additive", 0.5D));
+ write(catalog, document("minecraft:emerald_ore", "additive", 0.5D));
RealisticDepositsRuntime.reloadForTests();
assertSame(captured, handle.current());
assertEquals(RuntimeState.IntegrationStatus.ACTIVE_RESTART_REQUIRED,
diff --git a/src/test/java/zone/moddev/mc/realisticdeposits/generation/LapisGeologyTest.java b/src/test/java/zone/moddev/mc/realisticdeposits/generation/LapisGeologyTest.java
new file mode 100644
index 0000000..4b0f88b
--- /dev/null
+++ b/src/test/java/zone/moddev/mc/realisticdeposits/generation/LapisGeologyTest.java
@@ -0,0 +1,114 @@
+package zone.moddev.mc.realisticdeposits.generation;
+
+import java.util.Collections;
+import java.util.Optional;
+import java.util.Random;
+import java.util.Set;
+
+import com.google.gson.JsonObject;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import net.minecraft.block.state.IBlockState;
+import net.minecraft.init.Blocks;
+import net.minecraft.init.Bootstrap;
+import net.minecraft.init.Items;
+import net.minecraft.util.ResourceLocation;
+
+import zone.moddev.mc.orespawn.api.GeologyColumn;
+import zone.moddev.mc.orespawn.api.GeologyFamily;
+import zone.moddev.mc.realisticdeposits.config.LapisCatalogTest;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class LapisGeologyTest {
+ @BeforeAll
+ static void vanilla() {
+ Bootstrap.register();
+ }
+
+ @Test
+ void exactNaturalHostsRejectPolishedAndUnrelatedRocks() {
+ for (int metadata = 0; metadata <= 15; metadata++) {
+ boolean natural = metadata == 0 || metadata == 1 || metadata == 3 || metadata == 5;
+ assertEquals(natural, LapisGeology.permittedRock("minecraft:stone", metadata, false));
+ assertFalse(LapisGeology.permittedRock("minecraft:stone", metadata, true));
+ assertEquals(metadata == 0, LapisGeology.permittedRock("mineralogy:marble", metadata, true));
+ }
+
+ for (String rock : new String[] {"limestone", "dolomite", "schist", "slate", "quartzite",
+ "hornfels", "gneiss", "marble_brick", "marble_smooth", "shale", "gypsum", "basalt"}) {
+ assertFalse(LapisGeology.permittedRock("mineralogy:" + rock, 0, true));
+ }
+
+ Set hosts = LapisGeology.hosts(LapisCatalogTest.defaults(), new JsonObject(), false);
+ assertEquals(4, hosts.size());
+ assertFalse(hosts.contains(Blocks.STONE.getStateFromMeta(2)));
+ assertFalse(hosts.contains(Blocks.QUARTZ_BLOCK.getDefaultState()));
+ }
+
+ @Test
+ void fittingRequiresAnActualIntervalThickEnoughForTheLens() {
+ Set marble = Collections.singleton(Blocks.QUARTZ_BLOCK.getDefaultState());
+ assertTrue(Double.isNaN(LapisGeology.fitHeight(column(40, 40), 42, 2, 8, 128, marble)));
+ assertEquals(43.0, LapisGeology.fitHeight(column(40, 47), 43, 4, 8, 128, marble));
+ assertTrue(Double.isNaN(LapisGeology.fitHeight(column(140, 150), 42, 2, 8, 128, marble)));
+ assertTrue(Double.isNaN(LapisGeology.fitHeight(column(40, 47), 42, 2, 8, 128, Collections.emptySet())));
+ }
+
+ @Test
+ void explorationRetainsOnlyABoundedSetOfCompactPlans() throws Exception {
+ Class> renderer = Class.forName(LapisPattern.class.getName() + "$Renderer");
+ java.lang.reflect.Constructor> constructor = renderer.getDeclaredConstructors()[0];
+ constructor.setAccessible(true);
+ Object instance = constructor.newInstance(LapisCatalogTest.defaults());
+ java.lang.reflect.Field field = renderer.getDeclaredField("districts");
+ field.setAccessible(true);
+ @SuppressWarnings("unchecked")
+ java.util.Map> cache = (java.util.Map>) field.get(instance);
+
+ for (long region = 0; region < 1000; region++) cache.put(region, Optional.empty());
+ assertEquals(128, cache.size());
+
+ for (java.lang.reflect.Field member : renderer.getDeclaredFields()) {
+ assertNotEquals(MineTerrainSampler.class, member.getType());
+ assertNotEquals(zone.moddev.mc.realisticdeposits.geometry.LapisDistrict.Site.class, member.getType());
+ }
+ }
+
+ @Test
+ void nativeLapisDropsUseTheLegacyDyeItemAndFortuneMultiplier() {
+ IBlockState ore = Blocks.LAPIS_ORE.getDefaultState();
+ assertEquals(Items.DYE, Blocks.LAPIS_ORE.getItemDropped(ore, new Random(1), 0));
+ assertEquals(4, Blocks.LAPIS_ORE.damageDropped(ore));
+
+ for (int fortune = 0; fortune <= 3; fortune++) {
+ Random random = new Random(371 + fortune);
+ long total = 0;
+
+ for (int sample = 0; sample < 40000; sample++) {
+ int count = Blocks.LAPIS_ORE.quantityDroppedWithBonus(fortune, random);
+ assertTrue(count >= 4 && count <= 8 * (fortune + 1));
+ total += count;
+ }
+
+ double[] expected = {6, 8, 10.5, 13.2};
+ assertEquals(expected[fortune], total / 40000.0, 0.12);
+ }
+ }
+
+ private static GeologyColumn column(int minimum, int maximum) {
+ return new GeologyColumn() {
+ @Override public ResourceLocation dimension() { return new ResourceLocation("minecraft:overworld"); }
+ @Override public ResourceLocation biome() { return new ResourceLocation("minecraft:plains"); }
+ @Override public ResourceLocation geome() { return new ResourceLocation("orespawn:mountain_belt"); }
+ @Override public int blockX() { return 0; }
+ @Override public int blockZ() { return 0; }
+ @Override public int surfaceY() { return 90; }
+ @Override public IBlockState rockAt(int y) {
+ return y >= minimum && y <= maximum ? Blocks.QUARTZ_BLOCK.getDefaultState() : Blocks.STONE.getDefaultState();
+ }
+ @Override public Optional familyAt(int y) { return Optional.of(GeologyFamily.METAMORPHIC); }
+ };
+ }
+}
diff --git a/src/test/java/zone/moddev/mc/realisticdeposits/generation/LapisHarvestRuntimeChecks.java b/src/test/java/zone/moddev/mc/realisticdeposits/generation/LapisHarvestRuntimeChecks.java
new file mode 100644
index 0000000..320b4e0
--- /dev/null
+++ b/src/test/java/zone/moddev/mc/realisticdeposits/generation/LapisHarvestRuntimeChecks.java
@@ -0,0 +1,74 @@
+package zone.moddev.mc.realisticdeposits.generation;
+
+import com.google.gson.JsonObject;
+
+import net.minecraft.entity.item.EntityItem;
+import net.minecraft.init.Blocks;
+import net.minecraft.init.Enchantments;
+import net.minecraft.init.Items;
+import net.minecraft.item.Item;
+import net.minecraft.item.ItemStack;
+import net.minecraft.util.math.AxisAlignedBB;
+import net.minecraft.util.math.BlockPos;
+import net.minecraft.world.WorldServer;
+import net.minecraftforge.common.util.FakePlayer;
+import net.minecraftforge.common.util.FakePlayerFactory;
+
+/** Uses vanilla's real harvest path, including its legacy dye metadata. */
+final class LapisHarvestRuntimeChecks {
+ private LapisHarvestRuntimeChecks() { }
+
+ static JsonObject run(WorldServer world) {
+ BlockPos position = new BlockPos(8, 200, 8);
+ AxisAlignedBB area = new AxisAlignedBB(0, 190, 0, 16, 220, 16);
+ FakePlayer player = FakePlayerFactory.getMinecraft(world);
+ int harvests = 0;
+
+ for (int fortune = 0; fortune <= 3; fortune++) {
+ ItemStack tool = new ItemStack(Items.DIAMOND_PICKAXE);
+ if (fortune > 0) tool.addEnchantment(Enchantments.FORTUNE, fortune);
+
+ for (int attempt = 0; attempt < 64; attempt++) {
+ world.setBlockState(position, Blocks.LAPIS_ORE.getDefaultState(), 2);
+ world.setBlockToAir(position);
+ Blocks.LAPIS_ORE.harvestBlock(world, player, position, Blocks.LAPIS_ORE.getDefaultState(), null, tool);
+ int count = 0;
+
+ for (EntityItem entity : world.getEntitiesWithinAABB(EntityItem.class, area, item -> !item.isDead)) {
+ ItemStack drop = entity.getEntityItem();
+ if (drop.getItem() != Items.DYE || drop.getMetadata() != 4) {
+ throw new AssertionError("Lapis mining returned the wrong legacy item");
+ }
+ count += drop.stackSize;
+ entity.setDead();
+ }
+
+ if (count < 4 || count > 8 * (fortune + 1)) {
+ throw new AssertionError("Lapis mining returned an invalid Fortune yield: " + count);
+ }
+ harvests++;
+ }
+ }
+
+ ItemStack silk = new ItemStack(Items.DIAMOND_PICKAXE);
+ silk.addEnchantment(Enchantments.SILK_TOUCH, 1);
+ Blocks.LAPIS_ORE.harvestBlock(world, player, position, Blocks.LAPIS_ORE.getDefaultState(), null, silk);
+ int silkCount = 0;
+
+ for (EntityItem entity : world.getEntitiesWithinAABB(EntityItem.class, area, item -> !item.isDead)) {
+ if (entity.getEntityItem().getItem() != Item.getItemFromBlock(Blocks.LAPIS_ORE)) {
+ throw new AssertionError("Silk Touch extracted lapis instead of its ore block");
+ }
+ silkCount += entity.getEntityItem().stackSize;
+ entity.setDead();
+ }
+
+ if (silkCount != 1) throw new AssertionError("Silk Touch must return one lapis ore block");
+ JsonObject report = new JsonObject();
+ report.addProperty("native_harvests", harvests);
+ report.addProperty("silk_touch", true);
+ report.addProperty("legacy_dye_metadata", 4);
+
+ return report;
+ }
+}
diff --git a/src/test/java/zone/moddev/mc/realisticdeposits/generation/LapisRuntimeProbe.java b/src/test/java/zone/moddev/mc/realisticdeposits/generation/LapisRuntimeProbe.java
new file mode 100644
index 0000000..57bf66d
--- /dev/null
+++ b/src/test/java/zone/moddev/mc/realisticdeposits/generation/LapisRuntimeProbe.java
@@ -0,0 +1,261 @@
+package zone.moddev.mc.realisticdeposits.generation;
+
+import java.io.File;
+import java.io.FileReader;
+import java.io.FileWriter;
+import java.security.MessageDigest;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+
+import net.minecraft.block.Block;
+import net.minecraft.block.state.IBlockState;
+import net.minecraft.init.Blocks;
+import net.minecraft.server.MinecraftServer;
+import net.minecraft.util.math.BlockPos;
+import net.minecraft.world.WorldServer;
+import net.minecraft.world.chunk.Chunk;
+import net.minecraft.world.gen.ChunkProviderServer;
+import net.minecraftforge.fml.common.FMLCommonHandler;
+import net.minecraftforge.fml.common.Mod;
+
+import zone.moddev.mc.orespawn.api.OreSpawnApi;
+import zone.moddev.mc.realisticdeposits.config.DepositCatalogLoader;
+import zone.moddev.mc.realisticdeposits.config.RealisticDepositsRuntime;
+import zone.moddev.mc.realisticdeposits.geometry.BlockBounds;
+import zone.moddev.mc.realisticdeposits.geometry.LapisDistrict;
+import zone.moddev.mc.realisticdeposits.model.DepositDefinition;
+
+/** A separately packaged probe; ordinary launches never load it. */
+@Mod(modid = "rdlapisprobe", name = "Lapis Qualification", version = "1",
+ dependencies = "required-after:realisticdeposits")
+public final class LapisRuntimeProbe {
+ private static final int SIDE = 8;
+
+ @Mod.EventHandler
+ public void started(net.minecraftforge.fml.common.event.FMLServerStartedEvent event) throws Exception {
+ if (!Boolean.getBoolean("rd.lapis.enabled")) return;
+ MinecraftServer server = FMLCommonHandler.instance().getMinecraftServerInstance();
+
+ try {
+ WorldServer world = server.worldServerForDimension(0);
+ if (!"lapis-test-world".equals(world.getSaveHandler().getWorldDirectory().getName())) {
+ throw new IllegalStateException("Lapis checks require their disposable world");
+ }
+
+ world.getGameRules().setOrCreateGameRule("randomTickSpeed", "0");
+ DepositDefinition captured = RealisticDepositsRuntime.state().definitions().stream()
+ .filter(DepositDefinition::isLapis).findFirst().get();
+ JsonObject enabled = captured.toJson();
+ enabled.addProperty("enabled", true);
+ DepositDefinition definition = DepositCatalogLoader.loadDefinition(enabled, "lapis fixture geometry");
+ String phase = System.getProperty("rd.lapis.phase", "first");
+ File target = new File("lapis-target.json");
+ JsonObject coordinates;
+
+ if (target.isFile()) {
+ try (FileReader reader = new FileReader(target)) {
+ coordinates = new JsonParser().parse(reader).getAsJsonObject();
+ }
+ } else {
+ coordinates = new JsonObject();
+ int loaded = world.getChunkProvider().getLoadedChunkCount();
+ find(world, definition, 32, "first", coordinates);
+ find(world, definition, 96, "later", coordinates);
+ if (loaded != world.getChunkProvider().getLoadedChunkCount()) {
+ throw new AssertionError("Site selection loaded neighbouring chunks");
+ }
+ write(target, coordinates);
+ }
+
+ LapisDistrict first = resolve(world, definition, coordinates, "first");
+ LapisDistrict later = resolve(world, definition, coordinates, "later");
+ if ("first".equals(phase)) populate(world, first);
+ if ("later".equals(phase)) populate(world, later);
+
+ JsonObject report = new JsonObject();
+ report.addProperty("phase", phase);
+ if ("first".equals(phase)) report.add("harvesting", LapisHarvestRuntimeChecks.run(world));
+ report.add("first", sample(world, first));
+ if ("later".equals(phase) || "stable".equals(phase)) report.add("later", sample(world, later));
+ report.add("profile", OreSpawnApi.getActiveProfile(server).get().toJson());
+ write(new File("lapis-" + phase + ".json"), report);
+ System.out.println("RD_LAPIS_PASS " + phase);
+ } finally {
+ server.initiateShutdown();
+ }
+ }
+
+ static LapisDistrict.Site site(WorldServer world, DepositDefinition definition) {
+ JsonObject profile = OreSpawnApi.getActiveProfile(world.getMinecraftServer()).get().toJson();
+ boolean physical = LapisGeology.physicalStrata(profile);
+
+ return LapisPattern.site(definition, new MineTerrainSampler(world, world.getChunkProvider().chunkGenerator),
+ OreSpawnApi.createSampler(world), LapisGeology.hosts(definition, profile, physical), physical);
+ }
+
+ private static void find(WorldServer world, DepositDefinition definition, int start, String prefix, JsonObject output) {
+ LapisDistrict.Site site = site(world, definition);
+
+ for (int rx = start; rx < start + 48; rx++) {
+ for (int rz = start; rz < start + 48; rz++) {
+ Optional planned = LapisDistrict.plan(definition, world.getSeed(),
+ "minecraft:overworld", rx, rz, site);
+ if (!planned.isPresent()) continue;
+
+ output.addProperty(prefix + "_rx", rx);
+ output.addProperty(prefix + "_rz", rz);
+ return;
+ }
+ }
+
+ throw new AssertionError("No suitable lapis lenses in bounded fixture search");
+ }
+
+ private static LapisDistrict resolve(WorldServer world, DepositDefinition definition, JsonObject coordinates, String prefix) {
+ return LapisDistrict.plan(definition, world.getSeed(), "minecraft:overworld",
+ coordinates.get(prefix + "_rx").getAsInt(), coordinates.get(prefix + "_rz").getAsInt(),
+ site(world, definition)).get();
+ }
+
+ private static void populate(WorldServer world, LapisDistrict district) {
+ ChunkProviderServer provider = world.getChunkProvider();
+ int baseX = (district.centerX() >> 4) - SIDE / 2;
+ int baseZ = (district.centerZ() >> 4) - SIDE / 2;
+ List halo = new ArrayList<>();
+
+ for (int x = baseX - 2; x <= baseX + SIDE + 1; x++) {
+ for (int z = baseZ - 2; z <= baseZ + SIDE + 1; z++) halo.add(new int[] {x, z});
+ }
+
+ if (Boolean.getBoolean("rd.lapis.reverse")) Collections.reverse(halo);
+ for (int[] cell : halo) provider.provideChunk(cell[0], cell[1]);
+
+ List order = new ArrayList<>();
+ for (int x = baseX - 1; x <= baseX + SIDE; x++) {
+ for (int z = baseZ - 1; z <= baseZ + SIDE; z++) order.add(new int[] {x, z});
+ }
+
+ if (Boolean.getBoolean("rd.lapis.reverse")) Collections.reverse(order);
+ for (int[] cell : order) provider.provideChunk(cell[0], cell[1]).populateChunk(provider, provider.chunkGenerator);
+ }
+
+ private static JsonObject sample(WorldServer world, LapisDistrict district) throws Exception {
+ MessageDigest terrain = MessageDigest.getInstance("SHA-256");
+ MessageDigest lapis = MessageDigest.getInstance("SHA-256");
+ int baseX = (district.centerX() >> 4) - SIDE / 2;
+ int baseZ = (district.centerZ() >> 4) - SIDE / 2;
+ int ore = 0;
+ int outside = 0;
+ JsonArray section = new JsonArray();
+ BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos();
+ DepositDefinition definition = RealisticDepositsRuntime.state().definitions().stream()
+ .filter(DepositDefinition::isLapis).findFirst().get();
+ JsonObject profile = OreSpawnApi.getActiveProfile(world.getMinecraftServer()).get().toJson();
+ boolean physical = LapisGeology.physicalStrata(profile);
+ Set hosts = LapisGeology.hosts(definition, profile, physical);
+ BlockBounds bounds = district.bounds();
+ java.util.Optional sampler = OreSpawnApi.createSampler(world);
+
+ for (int cx = baseX; cx < baseX + SIDE; cx++) {
+ for (int cz = baseZ; cz < baseZ + SIDE; cz++) {
+ Chunk chunk = world.getChunkProvider().loadChunk(cx, cz);
+ if (chunk == null || !chunk.isTerrainPopulated()) throw new AssertionError("Unopened sample chunk");
+
+ for (int x = cx * 16; x < cx * 16 + 16; x++) {
+ for (int z = cz * 16; z < cz * 16 + 16; z++) {
+ for (int y = 0; y < 160; y++) {
+ IBlockState state = chunk.getBlockState(pos.setPos(x, y, z));
+ int id = Block.getStateId(state);
+ terrain.update((byte) (id >>> 8));
+ terrain.update((byte) id);
+
+ if (state.getBlock() == Blocks.LAPIS_ORE) {
+ if (district.isOreAt(x, y, z)) {
+ if (physical && (!sampler.isPresent() || !hosts.contains(sampler.get()
+ .sampleColumn(x, z, chunk.getHeightValue(x & 15, z & 15)).rockAt(y)))) {
+ throw new AssertionError("Lapis escaped its sampled natural marble host");
+ }
+ ore++;
+ }
+ else outside++;
+ lapis.update((byte) (x >>> 8));
+ lapis.update((byte) x);
+ lapis.update((byte) y);
+ lapis.update((byte) (z >>> 8));
+ lapis.update((byte) z);
+ }
+
+ if (z == district.centerZ() && x >= bounds.minimumX() && x <= bounds.maximumX()
+ && y >= 8 && y <= 128) {
+ JsonArray cell = new JsonArray();
+ cell.add(new com.google.gson.JsonPrimitive(x));
+ cell.add(new com.google.gson.JsonPrimitive(y));
+ cell.add(new com.google.gson.JsonPrimitive(state.getBlock().getRegistryName().toString()));
+ cell.add(new com.google.gson.JsonPrimitive(hosts.contains(state)));
+ section.add(cell);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ JsonObject result = new JsonObject();
+ result.addProperty("terrain_sha256", hex(terrain.digest()));
+ result.addProperty("lapis_sha256", hex(lapis.digest()));
+ result.addProperty("lens_ore", ore);
+ result.addProperty("outside_ore", outside);
+ result.addProperty("expected_pieces", ore * 6);
+ result.addProperty("physical_strata", physical);
+ result.addProperty("center_x", district.centerX());
+ result.addProperty("center_y", district.centerY());
+ result.addProperty("center_z", district.centerZ());
+ result.add("section", section);
+ JsonArray strikeSection = new JsonArray();
+ int reach = (int) (district.strikeLength() * 0.35);
+
+ for (int along = -reach; along <= reach; along++) {
+ zone.moddev.mc.realisticdeposits.geometry.MinePoint point = district.miningRoutePoint(
+ along, district.lenses().get(0).width() * 0.3);
+ Chunk chunk = world.getChunkProvider().getLoadedChunk(point.x() >> 4, point.z() >> 4);
+ if (chunk == null || !chunk.isTerrainPopulated()) continue;
+
+ for (int y = 8; y <= 128; y++) {
+ IBlockState state = chunk.getBlockState(pos.setPos(point.x(), y, point.z()));
+ JsonArray cell = new JsonArray();
+ cell.add(new com.google.gson.JsonPrimitive(along));
+ cell.add(new com.google.gson.JsonPrimitive(y));
+ cell.add(new com.google.gson.JsonPrimitive(state.getBlock().getRegistryName().toString()));
+ cell.add(new com.google.gson.JsonPrimitive(hosts.contains(state)));
+ cell.add(new com.google.gson.JsonPrimitive(point.x()));
+ cell.add(new com.google.gson.JsonPrimitive(point.z()));
+ strikeSection.add(cell);
+ }
+ }
+
+ result.add("strike_section", strikeSection);
+
+ return result;
+ }
+
+ private static String hex(byte[] bytes) {
+ StringBuilder result = new StringBuilder();
+ for (byte value : bytes) result.append(String.format("%02X", value & 255));
+
+ return result.toString();
+ }
+
+ private static void write(File file, JsonObject json) throws Exception {
+ try (FileWriter output = new FileWriter(file)) {
+ new GsonBuilder().setPrettyPrinting().create().toJson(json, output);
+ }
+ }
+}
diff --git a/src/test/java/zone/moddev/mc/realisticdeposits/generation/LapisSurveyProbe.java b/src/test/java/zone/moddev/mc/realisticdeposits/generation/LapisSurveyProbe.java
new file mode 100644
index 0000000..4a97db1
--- /dev/null
+++ b/src/test/java/zone/moddev/mc/realisticdeposits/generation/LapisSurveyProbe.java
@@ -0,0 +1,212 @@
+package zone.moddev.mc.realisticdeposits.generation;
+
+import java.io.File;
+import java.io.FileWriter;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Random;
+import java.util.Set;
+
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+
+import net.minecraft.block.Block;
+import net.minecraft.block.state.IBlockState;
+import net.minecraft.init.Blocks;
+import net.minecraft.server.MinecraftServer;
+import net.minecraft.util.math.BlockPos;
+import net.minecraft.world.WorldServer;
+import net.minecraft.world.chunk.Chunk;
+import net.minecraft.world.gen.ChunkProviderOverworld;
+import net.minecraftforge.fml.common.FMLCommonHandler;
+import net.minecraftforge.fml.common.Mod;
+import net.minecraftforge.fml.common.event.FMLServerStartedEvent;
+
+import zone.moddev.mc.orespawn.api.OreSpawnApi;
+import zone.moddev.mc.realisticdeposits.config.RealisticDepositsRuntime;
+import zone.moddev.mc.realisticdeposits.geometry.BlockBounds;
+import zone.moddev.mc.realisticdeposits.geometry.LapisDistrict;
+import zone.moddev.mc.realisticdeposits.model.DepositDefinition;
+
+/** Measures whole districts in memory and ordinary lapis in separate decorated controls. */
+@Mod(modid = "rdlapissurvey", name = "Lapis Reward Survey", version = "1",
+ dependencies = "required-after:realisticdeposits")
+public final class LapisSurveyProbe {
+ @Mod.EventHandler
+ public void started(FMLServerStartedEvent event) throws Exception {
+ if (!Boolean.getBoolean("rd.lapis.survey")) return;
+ MinecraftServer server = FMLCommonHandler.instance().getMinecraftServerInstance();
+
+ try {
+ WorldServer world = server.worldServerForDimension(0);
+ if (!"lapis-test-world".equals(world.getSaveHandler().getWorldDirectory().getName())) {
+ throw new AssertionError("The survey requires its disposable world");
+ }
+
+ JsonObject report = Boolean.getBoolean("rd.lapis.survey.control") ? control(world) : lenses(world);
+ report.addProperty("seed", world.getSeed());
+ try (FileWriter output = new FileWriter(new File("lapis-survey.json"))) {
+ new GsonBuilder().setPrettyPrinting().create().toJson(report, output);
+ }
+
+ System.out.println("RD_LAPIS_SURVEY_PASS");
+ } finally {
+ server.initiateShutdown();
+ }
+ }
+
+ private static JsonObject lenses(WorldServer world) {
+ DepositDefinition definition = RealisticDepositsRuntime.state().definitions().stream()
+ .filter(DepositDefinition::isLapis).findFirst().get();
+ ChunkProviderOverworld generator = new ChunkProviderOverworld(world, world.getSeed(), false,
+ world.getWorldInfo().getGeneratorOptions());
+ MineTerrainSampler terrain = new MineTerrainSampler(world, generator);
+ JsonObject profile = OreSpawnApi.getActiveProfile(world.getMinecraftServer()).get().toJson();
+ boolean physical = LapisGeology.physicalStrata(profile);
+ Set hosts = LapisGeology.hosts(definition, profile, physical);
+ LapisDistrict.Site site = LapisPattern.site(definition, terrain, OreSpawnApi.createSampler(world), hosts, physical);
+ int loaded = world.getChunkProvider().getLoadedChunkCount();
+ int side = Integer.getInteger("rd.lapis.survey.regions", 16);
+ int size = definition.regionSpacingChunks() * 16;
+ long total = 0;
+ JsonArray reserves = new JsonArray();
+ List productive = new ArrayList<>();
+ Set counted = new HashSet<>();
+ java.util.Map chunks = new java.util.LinkedHashMap() {
+ @Override
+ protected boolean removeEldestEntry(java.util.Map.Entry entry) {
+ return size() > 128;
+ }
+ };
+ BlockPos.MutableBlockPos position = new BlockPos.MutableBlockPos();
+ if (physical) throw new AssertionError("The reserve survey is a standalone control, not a strata simulation");
+
+ for (int rx = 256; rx < 256 + side; rx++) {
+ for (int rz = 256; rz < 256 + side; rz++) {
+ Optional selected = LapisDistrict.plan(definition, world.getSeed(),
+ "minecraft:overworld", rx, rz, site);
+ if (!selected.isPresent()) continue;
+
+ LapisDistrict body = selected.get();
+ BlockBounds bounds = body.bounds();
+ long ore = 0;
+
+ for (int x = bounds.minimumX(); x <= bounds.maximumX(); x++) {
+ for (int z = bounds.minimumZ(); z <= bounds.maximumZ(); z++) {
+ for (int y = body.minimumCandidateYAt(x, z); y <= body.maximumCandidateYAt(x, z); y++) {
+ if (!body.isOreAt(x, y, z)) continue;
+
+ int cx = Math.floorDiv(x, 16);
+ int cz = Math.floorDiv(z, 16);
+ long key = ((long) cx << 32) ^ (cz & 0xffffffffL);
+ Chunk chunk = chunks.computeIfAbsent(key, ignored -> generator.provideChunk(cx, cz));
+ IBlockState state = chunk.getBlockState(position.setPos(x, y, z));
+ if (hosts.contains(state) && counted.add(position.toLong())) ore++;
+ }
+ }
+ }
+
+ if (ore == 0) continue;
+ total += ore;
+ productive.add(body);
+ JsonObject reserve = new JsonObject();
+ reserve.addProperty("x", body.centerX());
+ reserve.addProperty("y", body.centerY());
+ reserve.addProperty("z", body.centerZ());
+ reserve.addProperty("ore", ore);
+ reserves.add(reserve);
+ }
+
+ System.out.println("Lapis reserve row " + (rx - 255) + "/" + side);
+ }
+
+ if (loaded != world.getChunkProvider().getLoadedChunkCount()) {
+ throw new AssertionError("The in-memory survey loaded real world chunks");
+ }
+
+ JsonArray distances = new JsonArray();
+ if (!productive.isEmpty() && side > 2) {
+ for (int gx = 0; gx < 32; gx++) {
+ for (int gz = 0; gz < 32; gz++) {
+ double x = 257.0 * size + (gx + 0.5) * (side - 2) * size / 32;
+ double z = 257.0 * size + (gz + 0.5) * (side - 2) * size / 32;
+ double distance = Double.POSITIVE_INFINITY;
+
+ for (LapisDistrict body : productive) {
+ double dx = x - body.centerX();
+ double dz = z - body.centerZ();
+ distance = Math.min(distance, Math.sqrt(dx * dx + dz * dz));
+ }
+
+ distances.add(new com.google.gson.JsonPrimitive(distance));
+ }
+ }
+ }
+
+ JsonObject result = new JsonObject();
+ result.addProperty("represented_chunks", (long) side * side * size * size / 256);
+ result.addProperty("lens_ore", total);
+ result.addProperty("productive_districts", productive.size());
+ result.addProperty("loaded_chunks_unchanged", true);
+ result.add("district_reserves", reserves);
+ result.add("prospecting_distances", distances);
+
+ return result;
+ }
+
+ private static JsonObject control(WorldServer world) {
+ if (RealisticDepositsRuntime.state().definitions().stream().anyMatch(DepositDefinition::enabled)) {
+ throw new AssertionError("Vanilla controls must disable all deposit definitions");
+ }
+
+ int side = 4;
+ int extent = Integer.getInteger("rd.lapis.survey.regions", 16) * 64;
+ int ore = 0;
+ Random random = new Random(world.getSeed() ^ 0x16E1C830A5572BL);
+ BlockPos.MutableBlockPos position = new BlockPos.MutableBlockPos();
+
+ for (int gx = 0; gx < 4; gx++) {
+ for (int gz = 0; gz < 4; gz++) {
+ int startX = 256 * 64 + gx * extent / 4 + random.nextInt(Math.max(1, extent / 4 - side));
+ int startZ = 256 * 64 + gz * extent / 4 + random.nextInt(Math.max(1, extent / 4 - side));
+
+ for (int cx = startX - 2; cx <= startX + side + 1; cx++) {
+ for (int cz = startZ - 2; cz <= startZ + side + 1; cz++) world.getChunkProvider().provideChunk(cx, cz);
+ }
+
+ for (int cx = startX - 1; cx <= startX + side; cx++) {
+ for (int cz = startZ - 1; cz <= startZ + side; cz++) {
+ world.getChunkProvider().provideChunk(cx, cz).populateChunk(world.getChunkProvider(),
+ world.getChunkProvider().chunkGenerator);
+ }
+ }
+
+ for (int cx = startX; cx < startX + side; cx++) {
+ for (int cz = startZ; cz < startZ + side; cz++) {
+ Chunk chunk = world.getChunkProvider().getLoadedChunk(cx, cz);
+ for (int x = 0; x < 16; x++) {
+ for (int z = 0; z < 16; z++) {
+ for (int y = 0; y < 128; y++) {
+ Block block = chunk.getBlockState(position.setPos(cx * 16 + x, y, cz * 16 + z)).getBlock();
+ if (block == Blocks.LAPIS_ORE) ore++;
+ }
+ }
+ }
+ }
+ }
+
+ world.getChunkProvider().unloadAllChunks();
+ for (int pass = 0; pass < 12; pass++) world.getChunkProvider().tick();
+ }
+ }
+
+ JsonObject result = new JsonObject();
+ result.addProperty("chunks", 16 * side * side);
+ result.addProperty("lapis_ore", ore);
+
+ return result;
+ }
+}
diff --git a/src/test/java/zone/moddev/mc/realisticdeposits/generation/LapisWorkingsRuntimeChecks.java b/src/test/java/zone/moddev/mc/realisticdeposits/generation/LapisWorkingsRuntimeChecks.java
new file mode 100644
index 0000000..0ed308e
--- /dev/null
+++ b/src/test/java/zone/moddev/mc/realisticdeposits/generation/LapisWorkingsRuntimeChecks.java
@@ -0,0 +1,255 @@
+package zone.moddev.mc.realisticdeposits.generation;
+
+import static zone.moddev.mc.realisticdeposits.generation.MineWorkingsRuntimeProbe.*;
+
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import java.util.Properties;
+
+import com.google.gson.JsonObject;
+
+import net.minecraft.init.Blocks;
+import net.minecraft.init.Items;
+import net.minecraft.item.ItemStack;
+import net.minecraft.tileentity.TileEntityChest;
+import net.minecraft.nbt.NBTTagCompound;
+import net.minecraft.util.math.BlockPos;
+import net.minecraft.world.WorldServer;
+import net.minecraft.world.gen.structure.MapGenStructureData;
+
+import zone.moddev.mc.orespawn.api.GeologySampler;
+import zone.moddev.mc.orespawn.api.OreSpawnApi;
+import zone.moddev.mc.realisticdeposits.config.DepositCatalogLoader;
+import zone.moddev.mc.realisticdeposits.config.RealisticDepositsRuntime;
+import zone.moddev.mc.realisticdeposits.geometry.AbandonedMinePlan;
+import zone.moddev.mc.realisticdeposits.geometry.AbandonedMinePlanner;
+import zone.moddev.mc.realisticdeposits.geometry.LapisDistrict;
+import zone.moddev.mc.realisticdeposits.geometry.MinePoint;
+import zone.moddev.mc.realisticdeposits.model.AbandonedMineSettings;
+import zone.moddev.mc.realisticdeposits.model.DepositDefinition;
+
+/** Lapis access checks share the iron suite's exact block and reload assertions. */
+final class LapisWorkingsRuntimeChecks {
+ private LapisWorkingsRuntimeChecks() { }
+
+ static void run(WorldServer world, String phase) throws Exception {
+ DepositDefinition captured = RealisticDepositsRuntime.state().definitions().stream()
+ .filter(DepositDefinition::isLapis).findFirst().get();
+ JsonObject enabled = captured.toJson();
+ enabled.addProperty("enabled", true);
+ DepositDefinition geometry = DepositCatalogLoader.loadDefinition(enabled, "lapis mine geometry");
+ String scenario = System.getProperty("rd.mines.scenario", "flat");
+ boolean obstructed = "blocked".equals(scenario);
+ boolean infested = "mineralogy-infested".equals(scenario);
+ if (scenario.startsWith("mineralogy")) {
+ require(CoalGeology.active().physicalStrata, "The Mineralogy fixture did not activate physical strata");
+ }
+ MineTerrainSampler terrain = new MineTerrainSampler(world, world.getChunkProvider().chunkGenerator);
+ Optional sampler = OreSpawnApi.createSampler(world);
+ Properties target = new Properties();
+ LapisDistrict body = null;
+ AbandonedMinePlan plan = null;
+
+ if ("first".equals(phase)) {
+ int loaded = world.getChunkProvider().getLoadedChunkCount();
+ int selected = 0;
+ int openGround = 0;
+ int planned = 0;
+ int dry = 0;
+ int start = Integer.getInteger("rd.mines.startRegion", 32);
+ search: for (int rx = start; rx < start + 32; rx++) {
+ for (int rz = start; rz < start + 32; rz++) {
+ Optional district = district(world, geometry, terrain, sampler, rx, rz);
+ if (!district.isPresent()) continue;
+ selected++;
+ // Keep the walking fixture on open ground. Obstructed sections
+ // have their own protection checks.
+ if (scenario.startsWith("normal")) {
+ MinePoint entrance = AbandonedMinePlanner.landing(district.get());
+ net.minecraft.world.biome.Biome biome = world.getBiomeProvider().getBiome(
+ new BlockPos(entrance.x(), entrance.y(), entrance.z()));
+ if (!net.minecraftforge.common.BiomeDictionary.isBiomeOfType(
+ biome, net.minecraftforge.common.BiomeDictionary.Type.PLAINS)) continue;
+ }
+ openGround++;
+
+ {
+ LapisDistrict candidate = district.get();
+ int surface = "wet".equals(scenario) ? 129 : terrain.surfaceY(AbandonedMinePlanner.landing(candidate));
+ Optional proposed = "wet".equals(scenario)
+ ? AbandonedMinePlanner.plan(candidate, new AbandonedMineSettings(true, 1.0D), surface)
+ : AbandonedMinePlanner.plan(candidate, new AbandonedMineSettings(true, 1.0D), terrain::naturalSurfaceY);
+ if (!proposed.isPresent()) continue;
+ planned++;
+ if (terrain.dry(proposed.get())) dry++;
+ if (!"wet".equals(scenario) && (terrain.surfaceY(proposed.get().entrance()) != proposed.get().entrance().y()
+ || !terrain.dry(proposed.get()) || !terrain.covered(proposed.get())
+ || !MineWorkingsGenerator.biomesAllowed(proposed.get(), geometry, world))) continue;
+
+ body = candidate;
+ plan = proposed.get();
+ target.setProperty("region_x", Integer.toString(rx));
+ target.setProperty("region_z", Integer.toString(rz));
+ break search;
+ }
+ }
+ }
+
+ require(loaded == world.getChunkProvider().getLoadedChunkCount(), "Lapis mine search loaded a neighbouring chunk");
+ System.out.println("Lapis mine search: selected=" + selected + ", open_ground=" + openGround
+ + ", planned=" + planned + ", dry=" + dry);
+ require(body != null, "No safe lapis mine in the bounded search");
+ target.setProperty("key", body.identity().toString());
+ target.setProperty("entrance", plan.entrance().toString());
+ target.setProperty("landing", plan.landing().toString());
+ target.setProperty("plan", planHash(plan));
+ store("lapis-target.properties", target);
+ } else {
+ try (FileInputStream input = new FileInputStream("lapis-target.properties")) {
+ target.load(input);
+ }
+
+ LapisDistrict district = district(world, geometry, terrain, sampler,
+ Integer.parseInt(target.getProperty("region_x")), Integer.parseInt(target.getProperty("region_z"))).get();
+ body = district;
+ int surface = "wet".equals(scenario) ? 129 : terrain.surfaceY(AbandonedMinePlanner.landing(body));
+ plan = "wet".equals(scenario) ? AbandonedMinePlanner.plan(body, new AbandonedMineSettings(true, 1.0D), surface).get()
+ : AbandonedMinePlanner.plan(body, new AbandonedMineSettings(true, 1.0D), terrain::naturalSurfaceY).get();
+ require(target.getProperty("plan").equals(planHash(plan)), "Lapis geometry moved on reload");
+ }
+
+ String key = target.getProperty("key");
+ boolean rejected = !captured.enabled() || !captured.abandonedMine().enabled()
+ || captured.abandonedMine().chance() == 0 || "wet".equals(scenario) || "structure".equals(scenario);
+ Properties report = new Properties();
+ report.putAll(target);
+ report.setProperty("phase", phase);
+ MineWorldData data = MineWorldData.get(world);
+
+ if ("first".equals(phase)) {
+ if (obstructed) {
+ BlockPos chestPosition = new BlockPos(plan.landing().x(), plan.landing().y() + 1, plan.landing().z());
+ net.minecraft.world.chunk.Chunk chunk = world.getChunkProvider().provideChunk(chestPosition.getX() >> 4, chestPosition.getZ() >> 4);
+ require(!chunk.isTerrainPopulated(), "The obstruction must start in an unpopulated chunk");
+ chunk.setBlockState(chestPosition, Blocks.CHEST.getDefaultState());
+ TileEntityChest chest = (TileEntityChest) world.getTileEntity(chestPosition);
+ chest.setCustomName("Protected decline chest");
+ chest.setInventorySlotContents(0, new ItemStack(Items.DIAMOND, 3));
+ }
+
+ if ("structure".equals(scenario)) {
+ MapGenStructureData structures = new MapGenStructureData("Mineshaft");
+ NBTTagCompound existing = new NBTTagCompound();
+ existing.setIntArray("BB", new int[] {plan.bounds().minimumX(), plan.bounds().minimumY(),
+ plan.bounds().minimumZ(), plan.bounds().maximumX(), plan.bounds().maximumY(), plan.bounds().maximumZ()});
+ structures.writeInstance(existing, plan.landing().x() >> 4, plan.landing().z() >> 4);
+ world.getMapStorage().setData("Mineshaft", structures);
+ }
+
+ if ("reverse".equals(scenario) || rejected || scenario.startsWith("normal") || "regular".equals(scenario)
+ || "mineralogy".equals(scenario) || infested || obstructed) {
+ List chunks = allChunks(plan);
+ if ("reverse".equals(scenario)) Collections.reverse(chunks);
+ for (long chunk : chunks) populate(world, (int) (chunk >> 32), (int) chunk);
+ } else {
+ populate(world, plan.landing().x() >> 4, plan.landing().z() >> 4);
+ }
+
+ if (!rejected) {
+ require(data.plan(key) != null, "Lapis mine rejected: " + data.rejectionReason(key));
+ require(plan.blocks().equals(data.plan(key).blocks()), "Native lapis mine differs from its planner");
+ if (!obstructed && !infested) {
+ // The marker can cross a chunk edge even when the landing does not.
+ // Finish that section before adding a player's block to it.
+ BlockPos marker = marker(plan);
+ populate(world, marker.getX() >> 4, marker.getZ() >> 4);
+ verify(world, plan, populatedChunks(world, plan), false);
+ require(world.getChunkFromBlockCoords(marker).isTerrainPopulated(), "The marker chunk opened too early");
+ world.setBlockState(marker, Blocks.DIAMOND_BLOCK.getDefaultState(), 2);
+ }
+ }
+
+ List old = populatedChunks(world, plan);
+ target.setProperty("old_chunks", join(old));
+ target.setProperty("terrain", terrainHash(world, old));
+ store("lapis-target.properties", target);
+ if ("flat".equals(scenario) || "mineralogy-flat".equals(scenario)) {
+ require(old.size() < allChunks(plan).size(), "No lapis mine sections left for later exploration");
+ }
+ } else {
+ List old = split(target.getProperty("old_chunks"));
+ require(target.getProperty("terrain").equals(terrainHash(world, old)), "Reload changed old lapis district terrain or a player marker");
+ if ("later".equals(phase)) {
+ for (long chunk : allChunks(plan)) populate(world, (int) (chunk >> 32), (int) chunk);
+ target.setProperty("completed_terrain", terrainHash(world, allChunks(plan)));
+ store("lapis-target.properties", target);
+ } else if ("stable".equals(phase)) {
+ require(target.getProperty("completed_terrain").equals(terrainHash(world, allChunks(plan))),
+ "Reload changed the later-generated lapis district sections");
+ }
+ require(target.getProperty("terrain").equals(terrainHash(world, old)), "Later workings excavated old chunks");
+ }
+
+ if (rejected) {
+ require(data.plan(key) == null, "Disabled or unsafe lapis district acquired a mine");
+ if ("structure".equals(scenario)) require("Another structure".equals(data.rejectionReason(key)), "Structure was not protected");
+ } else {
+ require(data.plan(key) != null && planHash(data.plan(key)).equals(target.getProperty("plan")), "Saved lapis mine changed");
+ if (obstructed) {
+ BlockPos chestPosition = new BlockPos(plan.landing().x(), plan.landing().y() + 1, plan.landing().z());
+ require(world.getBlockState(chestPosition).getBlock() == Blocks.CHEST, "The obstructing chest was removed");
+ TileEntityChest chest = (TileEntityChest) world.getTileEntity(chestPosition);
+ require(chest != null && "Protected decline chest".equals(chest.getName())
+ && chest.getStackInSlot(0).stackSize == 3, "Obstructing chest data changed");
+ require(world.getBlockState(chestPosition.down(2)).getBlock() != Blocks.PLANKS,
+ "The unsafe section was partly excavated");
+ report.setProperty("obstructed_section_preserved", "true");
+ } else if (infested) {
+ // This fixed natural site contains silverfish blocks, not a cleared walking fixture.
+ BlockPos egg = new BlockPos(105264, 53, 107994);
+ require(world.getBlockState(egg).getBlock() == Blocks.MONSTER_EGG, "The natural infested block was removed");
+ for (AbandonedMinePlan.Cell cell : plan.cellsInChunk(egg.getX() >> 4, egg.getZ() >> 4)) {
+ if (cell.block() == zone.moddev.mc.realisticdeposits.geometry.MineBlock.AIR) continue;
+ MinePoint point = cell.point();
+ require(!world.getBlockState(new BlockPos(point.x(), point.y(), point.z())).equals(expected(cell.block())),
+ "The infested section was partly excavated");
+ }
+ report.setProperty("natural_infested_section_preserved", "true");
+ } else {
+ List built = populatedChunks(world, plan);
+ report.setProperty("checked_cells", Integer.toString(verify(world, plan, built, true)));
+ if (built.size() == allChunks(plan).size()) verifyAccess(world, plan, true);
+ }
+ }
+
+ store("mine-" + phase + "-pass.properties", report);
+ }
+
+ private static Optional district(WorldServer world, DepositDefinition definition,
+ MineTerrainSampler terrain, Optional sampler, int rx, int rz) {
+ JsonObject profile = OreSpawnApi.getActiveProfile(world.getMinecraftServer()).get().toJson();
+ boolean physical = LapisGeology.physicalStrata(profile);
+ LapisDistrict.Site site = LapisPattern.site(definition, terrain, sampler,
+ LapisGeology.hosts(definition, profile, physical), physical);
+
+ if (Boolean.getBoolean("rd.mines.wet")) {
+ LapisDistrict.Site dry = site;
+ site = new LapisDistrict.Site() {
+ @Override public int surfaceY(int x, int z) { return 129; }
+ @Override public String geome(int x, int z, int y) { return dry.geome(x, z, y); }
+ @Override public double fitHeight(int x, int z, double target, int thickness) { return target; }
+ };
+ }
+
+ return LapisDistrict.plan(definition, world.getSeed(), "minecraft:overworld", rx, rz, site);
+ }
+
+ private static void store(String name, Properties properties) throws Exception {
+ try (FileOutputStream output = new FileOutputStream(name)) {
+ properties.store(output, "Disposable lapis district workings");
+ }
+ }
+}
diff --git a/src/test/java/zone/moddev/mc/realisticdeposits/generation/MineWorkingsRuntimeProbe.java b/src/test/java/zone/moddev/mc/realisticdeposits/generation/MineWorkingsRuntimeProbe.java
index a17f151..bf7e830 100644
--- a/src/test/java/zone/moddev/mc/realisticdeposits/generation/MineWorkingsRuntimeProbe.java
+++ b/src/test/java/zone/moddev/mc/realisticdeposits/generation/MineWorkingsRuntimeProbe.java
@@ -70,6 +70,12 @@ public void started(FMLServerStartedEvent event) {
WorldServer world = server.worldServerForDimension(0);
require(world.getSeed() == SEED, "This probe is restricted to its disposable fixed-seed world");
require(world.getSaveHandler().getWorldDirectory().getName().equals("mine-test-world"), "Unexpected world directory");
+ if (Boolean.getBoolean("rd.mines.lapis")) {
+ LapisWorkingsRuntimeChecks.run(world, phase);
+ LOG.info("RD_MINES_PASS {}", phase);
+ return;
+ }
+
if (Boolean.getBoolean("rd.mines.gold")) {
GoldWorkingsRuntimeChecks.run(world, phase);
LOG.info("RD_MINES_PASS {}", phase);
diff --git a/src/test/java/zone/moddev/mc/realisticdeposits/generation/PolicyQualificationProbe.java b/src/test/java/zone/moddev/mc/realisticdeposits/generation/PolicyQualificationProbe.java
index 55d6175..8d897ca 100644
--- a/src/test/java/zone/moddev/mc/realisticdeposits/generation/PolicyQualificationProbe.java
+++ b/src/test/java/zone/moddev/mc/realisticdeposits/generation/PolicyQualificationProbe.java
@@ -40,6 +40,7 @@
import zone.moddev.mc.realisticdeposits.geometry.MineableDepositBody;
import zone.moddev.mc.realisticdeposits.geometry.DiamondDistrict;
import zone.moddev.mc.realisticdeposits.geometry.GoldDistrict;
+import zone.moddev.mc.realisticdeposits.geometry.LapisDistrict;
import zone.moddev.mc.realisticdeposits.model.DepositDefinition;
/** Packaged-server qualification fixture, excluded from the public mod jars. */
@@ -61,7 +62,8 @@ public void initialize(FMLInitializationEvent event) {
@SubscribeEvent(priority = EventPriority.LOWEST, receiveCanceled = true)
public void observeIron(OreGenEvent.GenerateMinable event) {
- OreGenEvent.GenerateMinable.EventType observed = gold() ? OreGenEvent.GenerateMinable.EventType.GOLD
+ OreGenEvent.GenerateMinable.EventType observed = lapis() ? OreGenEvent.GenerateMinable.EventType.LAPIS
+ : gold() ? OreGenEvent.GenerateMinable.EventType.GOLD
: diamond() ? OreGenEvent.GenerateMinable.EventType.DIAMOND
: coal() ? OreGenEvent.GenerateMinable.EventType.COAL : OreGenEvent.GenerateMinable.EventType.IRON;
if (event.getType() != observed) {
@@ -243,7 +245,8 @@ private static Set plannedPositions(long worldSeed, int base) {
int min = base * 16;
int max = (base + SAMPLE_SIDE) * 16 - 1;
int spacing = definition.regionSpacingChunks() * 16;
- int radius = definition.isGoldLode() ? definition.lode().strikeLength().maximum() / 2 + 96
+ int radius = definition.isLapis() ? definition.lapis().strikeLength().maximum() / 2 + 96
+ : definition.isGoldLode() ? definition.lode().strikeLength().maximum() / 2 + 96
: definition.isCoal()
? (definition.coal().strikeLength().maximum() + definition.coal().downDipWidth().maximum()) / 2 + 3
: (int) Math.ceil(definition.seam().strikeLength().maximum() * 0.5
@@ -252,7 +255,13 @@ private static Set plannedPositions(long worldSeed, int base) {
for (int rx = Math.floorDiv(min - radius, spacing); rx <= Math.floorDiv(max + radius, spacing); rx++) {
for (int rz = Math.floorDiv(min - radius, spacing); rz <= Math.floorDiv(max + radius, spacing); rz++) {
- MineableDepositBody body = definition.isGoldLode()
+ MineableDepositBody body = definition.isLapis()
+ ? LapisDistrict.plan(definition, worldSeed, "minecraft:overworld", rx, rz, new LapisDistrict.Site() {
+ @Override public int surfaceY(int x, int z) { return 241; }
+ @Override public String geome(int x, int z, int surface) { return null; }
+ @Override public double fitHeight(int x, int z, double target, int thickness) { return target; }
+ }).orElse(null)
+ : definition.isGoldLode()
? GoldDistrict.plan(definition, worldSeed, "minecraft:overworld", rx, rz, new GoldDistrict.Site() {
@Override public int surfaceY(int x, int z) { return 241; }
@Override public String geome(int x, int z, int surface) { return null; }
@@ -288,7 +297,9 @@ private static Set plannedPositions(long worldSeed, int base) {
private static boolean gold() { return "gold".equals(System.getProperty("rd.qualification.resource")); }
- private static Block resource() { return gold() ? Blocks.GOLD_ORE : diamond() ? Blocks.DIAMOND_ORE : coal() ? Blocks.COAL_ORE : Blocks.IRON_ORE; }
+ private static boolean lapis() { return "lapis".equals(System.getProperty("rd.qualification.resource")); }
+
+ private static Block resource() { return lapis() ? Blocks.LAPIS_ORE : gold() ? Blocks.GOLD_ORE : diamond() ? Blocks.DIAMOND_ORE : coal() ? Blocks.COAL_ORE : Blocks.IRON_ORE; }
private static Set diamondPositions(DepositDefinition definition, long seed, int base) {
Set positions = new HashSet<>();
diff --git a/src/test/java/zone/moddev/mc/realisticdeposits/geometry/LapisDistrictTest.java b/src/test/java/zone/moddev/mc/realisticdeposits/geometry/LapisDistrictTest.java
new file mode 100644
index 0000000..a6974b0
--- /dev/null
+++ b/src/test/java/zone/moddev/mc/realisticdeposits/geometry/LapisDistrictTest.java
@@ -0,0 +1,191 @@
+package zone.moddev.mc.realisticdeposits.geometry;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+import org.junit.jupiter.api.Test;
+
+import zone.moddev.mc.realisticdeposits.config.LapisCatalogTest;
+import zone.moddev.mc.realisticdeposits.model.DepositDefinition;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public class LapisDistrictTest {
+
+ public static LapisDistrict.Site site(String geome, int surface) {
+ return new LapisDistrict.Site() {
+ @Override public int surfaceY(int x, int z) { return surface; }
+ @Override public String geome(int x, int z, int y) { return geome; }
+ @Override public double fitHeight(int x, int z, double target, int thickness) { return target; }
+ };
+ }
+
+ public static LapisDistrict body(long seed) {
+ DepositDefinition definition = LapisCatalogTest.defaults();
+
+ for (int region = -16; region <= 16; region++) {
+ Optional body = LapisDistrict.plan(definition, seed, "minecraft:overworld",
+ region, -2, site("orespawn:mountain_belt", 90));
+ if (body.isPresent()) return body.get();
+ }
+
+ throw new AssertionError("No lapis lens in fixture search");
+ }
+
+ @Test
+ void repeatableCompactLensesRespectTheirDimensionsAndHeightLimits() {
+ for (long seed = 10; seed < 30; seed++) {
+ LapisDistrict body = body(seed);
+ assertEquals(body.identity().seed(), body(seed).identity().seed());
+ assertEquals(body.centerY(), body(seed).centerY());
+ assertTrue(body.lenses().size() >= 1 && body.lenses().size() <= 3);
+ assertThrows(UnsupportedOperationException.class, () -> body.lenses().clear());
+
+ for (LapisDistrict.Lens lens : body.lenses()) {
+ assertTrue(lens.length() >= 64 && lens.length() <= 160);
+ assertTrue(lens.width() >= 12 && lens.width() <= 24);
+ assertTrue(lens.thickness() >= 1 && lens.thickness() <= 4);
+ }
+
+ Set ore = blocks(body, false);
+ assertTrue(ore.size() > 60 && ore.size() < 25000, "Compact but worthwhile lenses: " + ore.size());
+
+ for (long packed : ore) {
+ MinePoint point = MinePoint.unpack(packed);
+ assertTrue(point.y() >= 8 && point.y() <= 90 - body.surfaceCover());
+ assertTrue(point.y() >= body.minimumCandidateYAt(point.x(), point.z()));
+ assertTrue(point.y() <= body.maximumCandidateYAt(point.x(), point.z()));
+ }
+ }
+ }
+
+ @Test
+ void lensesStayIdenticalAcrossChunkOrdersAndFreshPlanners() {
+ LapisDistrict first = body(81);
+ assertEquals(blocks(first, false), blocks(body(81), true));
+ }
+
+ @Test
+ void richPocketsContainFollowableSectionsRatherThanScatteredSingleBlocks() {
+ LapisDistrict body = body(81);
+ Set remaining = blocks(body, false);
+ int largest = 0;
+ int singles = 0;
+ int total = remaining.size();
+
+ while (!remaining.isEmpty()) {
+ java.util.ArrayDeque queue = new java.util.ArrayDeque<>();
+ long first = remaining.iterator().next();
+ queue.add(first);
+ remaining.remove(first);
+ int connected = 0;
+
+ while (!queue.isEmpty()) {
+ MinePoint point = MinePoint.unpack(queue.remove());
+ connected++;
+
+ for (int[] offset : new int[][] {{1,0,0}, {-1,0,0}, {0,1,0}, {0,-1,0}, {0,0,1}, {0,0,-1}}) {
+ long next = new MinePoint(point.x() + offset[0], point.y() + offset[1], point.z() + offset[2]).packed();
+ if (remaining.remove(next)) queue.add(next);
+ }
+ }
+
+ largest = Math.max(largest, connected);
+ if (connected == 1) singles++;
+ }
+
+ assertTrue(largest > 100, "The lens needs a substantial connected mining section");
+ assertTrue(singles < total / 10, "The lens must not become a cloud of isolated blocks");
+ }
+
+ @Test
+ void unsuitableGeologyMissingMarbleAndWrongDimensionsFailClosed() {
+ DepositDefinition definition = LapisCatalogTest.defaults();
+ LapisDistrict.Site noMarble = new LapisDistrict.Site() {
+ @Override public int surfaceY(int x, int z) { return 90; }
+ @Override public String geome(int x, int z, int y) { return "orespawn:mountain_belt"; }
+ @Override public double fitHeight(int x, int z, double target, int thickness) { return Double.NaN; }
+ };
+
+ for (int region = 0; region < 32; region++) {
+ assertFalse(LapisDistrict.plan(definition, 7, "minecraft:overworld", region, 0,
+ site("orespawn:sedimentary_basin", 90)).isPresent());
+ assertFalse(LapisDistrict.plan(definition, 7, "minecraft:overworld", region, 0, noMarble).isPresent());
+ assertFalse(LapisDistrict.plan(definition, 7, "minecraft:the_nether", region, 0,
+ site("orespawn:mountain_belt", 90)).isPresent());
+ }
+ }
+
+ @Test
+ void legacyApproximationIsRarerAndIndependentOfMissingClassification() {
+ DepositDefinition definition = LapisCatalogTest.defaults();
+ int modern = 0;
+ int legacy = 0;
+
+ for (int region = 0; region < 256; region++) {
+ if (LapisDistrict.plan(definition, 19, "minecraft:overworld", region, 0,
+ site("orespawn:mountain_belt", 90)).isPresent()) modern++;
+
+ Optional cyano = LapisDistrict.plan(definition, 19, "minecraft:overworld", region, 0,
+ site("orespawn:cyano", 90));
+ Optional absent = LapisDistrict.plan(definition, 19, "minecraft:overworld", region, 0,
+ site(null, 90));
+ assertEquals(cyano.isPresent(), absent.isPresent());
+ if (cyano.isPresent()) legacy++;
+ }
+
+ assertTrue(legacy > 0 && legacy < modern / 2);
+ }
+
+ @Test
+ void workingsLeaveMostOfTheCompactReserveAndKeepTheirGradualAccess() {
+ int checked = 0;
+
+ for (long seed = 20; seed < 36; seed++) {
+ LapisDistrict body = body(seed);
+ Optional planned = AbandonedMinePlanner.plan(body,
+ LapisCatalogTest.defaults().abandonedMine(), 90);
+ if (!planned.isPresent()) continue;
+
+ Set ore = blocks(body, false);
+ long removed = ore.stream().filter(planned.get().blocks()::containsKey).count();
+ assertTrue(removed < ore.size() * 0.5, "Workings must leave a useful reserve: " + removed + "/" + ore.size());
+ assertNotEquals(planned.get().entrance().x(), planned.get().landing().x());
+ assertEquals(planned.get().blocks(), AbandonedMinePlanner.plan(body,
+ LapisCatalogTest.defaults().abandonedMine(), 90).get().blocks());
+ checked++;
+ }
+
+ assertTrue(checked > 4);
+ }
+
+ public static Set blocks(LapisDistrict body, boolean reverse) {
+ BlockBounds bounds = body.bounds();
+ List chunks = new ArrayList<>();
+
+ for (int x = Math.floorDiv(bounds.minimumX(), 16); x <= Math.floorDiv(bounds.maximumX(), 16); x++) {
+ for (int z = Math.floorDiv(bounds.minimumZ(), 16); z <= Math.floorDiv(bounds.maximumZ(), 16); z++) {
+ chunks.add(new int[] {x, z});
+ }
+ }
+
+ if (reverse) Collections.reverse(chunks);
+ Set ore = new HashSet<>();
+
+ for (int[] chunk : chunks) {
+ for (int x = chunk[0] * 16; x < chunk[0] * 16 + 16; x++) {
+ for (int z = chunk[1] * 16; z < chunk[1] * 16 + 16; z++) {
+ for (int y = 8; y <= 128; y++) {
+ if (body.isOreAt(x, y, z)) ore.add(new MinePoint(x, y, z).packed());
+ }
+ }
+ }
+ }
+
+ return ore;
+ }
+}
diff --git a/tools/qualification/LAPIS_TESTING.md b/tools/qualification/LAPIS_TESTING.md
new file mode 100644
index 0000000..77bcaf8
--- /dev/null
+++ b/tools/qualification/LAPIS_TESTING.md
@@ -0,0 +1,71 @@
+# Try the lapis update
+
+Use the **RealisticDeposits 110 Lapis** Eclipse workspace and import its nested
+`RealisticDeposits` Gradle project. Gold's workspace and saves are separate.
+
+Refresh Gradle, then use **runLapisDepositsClient** in Eclipse's launch list.
+The **prepareLapisDepositsManual** Gradle task prepares that launch and its
+separate settings if needed. It does not overwrite existing settings or saves.
+
+## A mine to explore
+
+Create a new Creative world with cheats, the normal world type, structures on,
+and seed **3346071746553592696**. Leave the supplied deposit settings unchanged.
+
+Use:
+
+```text
+/tp @p 99516 70 117923
+```
+
+The entrance is at 99516,69,117923. Follow the stepped access to the gallery
+near **99508,49,117931**. Look in the roof and sides for lapis, then follow the
+thin lens out into its richer pockets. The workings deliberately leave most
+of the ore for you to mine.
+
+This site was checked with ordinary structures enabled and through save/reload.
+Water, trees, infested stone and other protected blocks can seal sections at
+other sites. An attempted mine is not a promise of a clear route everywhere.
+
+For a closer look, switch to Spectator mode with `/gamemode 3` and use Night
+Vision. Try both a normal pickaxe and Fortune; Silk Touch should give the ore
+block instead. In this Minecraft version, normal mining yields four to eight
+lapis pieces, not just one.
+
+## Compare Mineralogy
+
+Use a separate new world with the compatible Mineralogy build installed.
+Do not add or remove it from your original saves for this test.
+For this launch, its jar goes in `run-lapis-deposits/mods`. Restart Minecraft
+after adding it, and make sure physical strata are enabled in OreSpawn for
+the new world before making the comparison.
+
+On the same seed, a checked marble-hosted lens is near **33201,44,39920**.
+Teleport above it with `/tp @p 33201 100 39920`, then use Spectator mode to look
+down through the rock. This is an ore-inspection stop, not a verified open
+mine entrance. The surrounding host should be natural marble, without a
+new carrier block or manufactured layer.
+
+With physical strata disabled, use another new world. The lenses should use
+natural vanilla stone variants instead. Polished stone and decorative marble
+must not become ore hosts.
+
+## Reload and settings
+
+Save and quit normally, then reload. Revisit mined blocks, the gallery and
+any blocks you placed yourself. They should remain as you left them. Explore
+new chunks afterwards; generation must not rewrite the earlier terrain.
+
+The settings file is `config/realisticdeposits/deposits/lapis.json` inside this
+launch's separate `run-lapis-deposits` directory. Restart after changing lapis
+settings. Existing worlds retain their captured OreSpawn profiles, so use a
+fresh world when comparing defaults rather than editing an important save.
+
+The default background supply is 15%. The automated standalone survey estimated
+about 16% of vanilla's total lapis rewards, with rare districts averaging about
+1,900 ore blocks before other-ore, structure and workings collisions. That is
+roughly 11,000 lapis pieces per average discovery before Fortune, not a promise
+for every deposit. The settings were not tuned to a vanilla-equivalent total.
+
+Automated checks and cross-sections help establish shape and quantities; your
+visual check is still useful for judging how the deposit feels to explore.
diff --git a/tools/qualification/README.md b/tools/qualification/README.md
index 77961fd..5e020b8 100644
--- a/tools/qualification/README.md
+++ b/tools/qualification/README.md
@@ -92,6 +92,35 @@ decoration; that is not permission to remove an obstruction or rewrite old terra
planner and the new stepped-access build. It compares their blocks, player
markers, remaining sections and reloads. The source fixture is never modified.
+## Lapis checks
+
+`prepareLapisQualification` builds the isolated placement, native-harvesting
+and reserve probes. `lapis_110.py` checks fresh worlds, reloads, later chunks,
+reversed chunk order and saved rule switches. It also accepts optional Mineralogy
+fixtures with active, inactive or legacy strata. Exact host checks reject polished
+stone and unrelated rocks; the provider has no broad family-host alternative.
+
+`qualify_110.py --resource lapis` runs the complete policy/import matrix, using
+controlled high-level lenses so ordinary lapis can be counted separately.
+Choose a free `--base-port` range: the helper checks it before starting servers.
+
+`lapis_mines_110.py` checks saved workings, gradual access, later sections and
+water, structure and chest protection. Its controlled Mineralogy flat world
+checks a complete route; a separate natural infested-stone fixture checks that
+unsafe sections remain sealed. Neither changes production safety rules.
+
+`lapis_survey_110.py` measures complete lenses against native terrain and caves,
+with separate decorated vanilla controls. Four fixed survey seeds and two
+holdouts are reported by `lapis_balance_110.py`. Rewards count lapis pieces,
+including native Fortune, rather than treating each ore block as one piece.
+There is no compulsory vanilla-equivalent reward threshold.
+
+`lapis_upgrade_110.py` compares old Gold worlds with an old-build control through
+reload and later generation. `lapis_map_world_110.py` captures sections on copies
+of completed fixtures without changing their source worlds.
+
+See [the lapis walkthrough](LAPIS_TESTING.md) for the isolated Eclipse launch.
+
## Running the policy matrix
For the imported-profile editor check, use the separate
@@ -177,9 +206,9 @@ terrain cases populate the complete mine footprint before checking reloads;
vanilla decoration in adjacent new chunks can otherwise change unworked ground.
The combined local pass runs the controlled cases.
-## Gold and the combined regression pass
+## Combined regression pass
-The combined local driver covers iron, coal, diamonds and gold, plus a separate
+The combined local driver covers iron, coal, diamonds, gold and lapis, plus a separate
Mesa gold policy matrix. It also runs source-disable, gravel-harvest and
workings checks. Each policy matrix includes imports, explicit overrides,
saved management toggles, reloads and later-generated chunks. Logged errors
@@ -190,7 +219,7 @@ the driver refuses execution when `CI` or `GITHUB_ACTIONS` is true. Prepare its
isolated probes locally first:
```powershell
-.\gradlew.bat preparePolicyQualification prepareCoalQualification prepareDiamondQualification prepareGoldQualification prepareMineQualification --init-script tools/qualification/probes.init.gradle
+.\gradlew.bat preparePolicyQualification prepareCoalQualification prepareDiamondQualification prepareGoldQualification prepareLapisQualification prepareMineQualification --init-script tools/qualification/probes.init.gradle
```
Then run `python tools/qualification/ci_runtime.py --java `
diff --git a/tools/qualification/ci_runtime.py b/tools/qualification/ci_runtime.py
index a2c3280..d9615f0 100644
--- a/tools/qualification/ci_runtime.py
+++ b/tools/qualification/ci_runtime.py
@@ -36,7 +36,7 @@ def download(url, target, expected):
def suite_plan():
suites = {}
- for resource in ("iron", "coal", "diamond", "gold"):
+ for resource in ("iron", "coal", "diamond", "gold", "lapis"):
suites["policy-" + resource] = {
"script": "qualify_110.py", "probe": "policy-probe.jar",
"arguments": ["--resource", resource], "ports": True,
@@ -54,6 +54,10 @@ def suite_plan():
("gold", "gold_110.py", "gold-probe.jar",
["standalone", "normal", "reverse", "source-off", "source-enable", "source-world-off",
"source-dimension-off", "placer-off"], True),
+ ("lapis", "lapis_110.py", "lapis-probe.jar",
+ ["standalone", "normal", "reverse", "off", "world-off", "world-enable"], True),
+ ("lapis-workings", "lapis_mines_110.py", "mine-probe.jar",
+ ["flat", "reverse", "off", "chance-zero", "source-off", "structure", "wet", "blocked"], True),
("workings", "pipe_mines_110.py", "mine-probe.jar",
["flat", "reverse", "off", "chance-zero", "source-off", "structure", "wet", "blocked"], False),
("gold-workings", "gold_mines_110.py", "mine-probe.jar",
diff --git a/tools/qualification/gold_survey_110.py b/tools/qualification/gold_survey_110.py
index be846e8..3109953 100644
--- a/tools/qualification/gold_survey_110.py
+++ b/tools/qualification/gold_survey_110.py
@@ -21,7 +21,7 @@ def load_catalogue(name):
def control_catalogues():
- catalogues = {name: load_catalogue(name) for name in ("vanilla", "coal", "diamonds", "gold")}
+ catalogues = {name: load_catalogue(name) for name in ("vanilla", "coal", "diamonds", "gold", "lapis")}
for catalogue in catalogues.values():
for definition in catalogue["deposits"]:
definition["enabled"] = False
diff --git a/tools/qualification/lapis_110.py b/tools/qualification/lapis_110.py
new file mode 100644
index 0000000..2c7d8c9
--- /dev/null
+++ b/tools/qualification/lapis_110.py
@@ -0,0 +1,171 @@
+"""Check lapis lenses on disposable, checksum-bound Forge servers."""
+
+import argparse
+import copy
+import json
+import shutil
+import subprocess
+from pathlib import Path
+
+from coal_110 import prepare
+from ci_runtime import FORGE_HASH, check_runtime_log
+from qualify_110 import FORGE_JAR, checked_orespawn_sha256, check_ports, digest, write_json
+
+
+ROOT = Path(__file__).resolve().parents[2]
+RULE_ID = "realisticdeposits:deposit/realisticdeposits/overworld_lapis_lenses"
+
+
+def run(args, root, phase, case):
+ command = [str(args.java), "-Xms256m", "-Xmx2G", "-XX:ActiveProcessorCount=2",
+ "-Drd.lapis.enabled=true", f"-Drd.lapis.phase={phase}",
+ f"-Drd.lapis.reverse={str(case == 'reverse').lower()}",
+ "-jar", FORGE_JAR, "nogui"]
+ with (root / f"{phase}.log").open("w", encoding="utf-8") as output:
+ process = subprocess.Popen(command, cwd=root, stdout=output, stderr=subprocess.STDOUT,
+ creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
+ try:
+ code = process.wait(timeout=420)
+ except subprocess.TimeoutExpired:
+ process.kill()
+ process.wait()
+ raise AssertionError(f"Timed out: {root}/{phase}; keep the evidence")
+
+ log = (root / f"{phase}.log").read_text(encoding="utf-8", errors="replace")
+ assert code == 0 and f"RD_LAPIS_PASS {phase}" in log, f"Failed: {root}/{phase}"
+ check_runtime_log(log)
+ assert not list((root / "crash-reports").glob("*")), "A crash report was produced"
+ full_log = root / "logs/fml-server-latest.log"
+ if full_log.is_file():
+ check_runtime_log(full_log.read_text(encoding="utf-8", errors="replace"))
+ shutil.copy2(full_log, root / f"{phase}.fml.log")
+
+ print(f"PASS {case}/{phase}", flush=True)
+ return json.loads((root / f"lapis-{phase}.json").read_text())
+
+
+def same(left, right):
+ for key in ("terrain_sha256", "lapis_sha256", "lens_ore", "outside_ore"):
+ assert left[key] == right[key], f"Saved sample changed: {key}"
+
+
+def configure(root, case):
+ natural = case in ("normal", "mineralogy", "inactive", "legacy")
+
+ for name in ("vanilla", "coal", "diamonds", "gold", "lapis"):
+ catalogue = json.loads((ROOT / f"src/main/resources/assets/realisticdeposits/defaults/{name}.json").read_text())
+ for definition in catalogue["deposits"]:
+ definition["enabled"] = name == "lapis" and case not in ("off", "world-enable")
+ if name != "lapis":
+ continue
+
+ definition["abandoned_mine"]["enabled"] = False
+ if not natural:
+ definition.update(alignment="massive_rare", min_y=80, max_y=120)
+ definition.pop("geomes")
+
+ write_json(root / f"config/realisticdeposits/deposits/{name}.json", catalogue)
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ for name in ("java", "runtime", "mod", "orespawn", "probe", "output"):
+ parser.add_argument("--" + name, required=True, type=Path)
+ parser.add_argument("--mineralogy", type=Path)
+ parser.add_argument("--geology-profile", type=Path, help="Read-only active-strata reference for inactive/legacy cases")
+ parser.add_argument("--base-port", type=int, default=27500)
+ parser.add_argument("--cases", nargs="+", default=("standalone", "normal", "reverse", "off", "world-off", "world-enable"),
+ choices=("standalone", "normal", "reverse", "off", "world-off", "world-enable",
+ "mineralogy", "inactive", "legacy"))
+ args = parser.parse_args()
+ check_ports(range(args.base_port, args.base_port + len(args.cases)))
+ assert not args.output.exists(), "Use a new evidence directory"
+ assert digest(args.runtime / FORGE_JAR) == FORGE_HASH
+ checked_orespawn_sha256(args.orespawn)
+ assert "eula=true" in (args.runtime / "eula.txt").read_text()
+ args.output.mkdir(parents=True)
+ inputs = args.output / "inputs"
+ inputs.mkdir()
+
+ for name in ("mod", "orespawn", "probe", "mineralogy", "geology_profile"):
+ source = getattr(args, name)
+ if source:
+ destination = inputs / source.name
+ shutil.copy2(source, destination)
+ setattr(args, name, destination)
+
+ write_json(args.output / "artifacts.json", {p.name: digest(p) for p in inputs.iterdir()})
+ results = []
+
+ for index, case in enumerate(args.cases):
+ args.port = args.base_port + index
+ natural = case in ("normal", "mineralogy", "inactive", "legacy")
+ root = prepare(args, args.output / case, mineralogy=case in ("mineralogy", "inactive", "legacy"), normal=natural)
+ properties = (root / "server.properties").read_text().replace("coal-test-world", "lapis-test-world")
+ if not natural:
+ properties = properties.replace(";1;decoration", ";7;")
+ (root / "server.properties").write_text(properties, encoding="utf-8", newline="\n")
+ configure(root, case)
+
+ if case in ("inactive", "legacy"):
+ assert args.geology_profile, "Supply a sealed active-strata reference"
+ profile = json.loads(args.geology_profile.read_text())
+ profile["ores"] = {key: value for key, value in profile["ores"].items()
+ if not key.startswith("realisticdeposits:")}
+ for directory in ("providers", "ore_providers"):
+ profile.get(directory, {}).pop("realisticdeposits", None)
+ profile.pop("ore_sources", None)
+ profile.pop("ore_source_policies", None)
+ if case == "inactive":
+ profile["terrain_dimensions"]["minecraft:overworld"]["enabled"] = False
+ else:
+ profile["geology_mode"] = "legacy"
+ write_json(root / "config/orespawn-worldgen.json", profile)
+
+ first = run(args, root, "first", case)
+ if case in ("off", "world-enable"):
+ assert first["first"]["lens_ore"] == 0, "A disabled rule generated lapis lenses"
+ else:
+ assert first["first"]["lens_ore"] > 0, "No lapis lens generated"
+
+ saved = root / "lapis-test-world/serverconfig/orespawn-worldgen.json"
+ previous = None
+ if case in ("world-off", "world-enable"):
+ previous = json.loads(saved.read_text())
+ previous["ores"][RULE_ID]["enabled"] = case == "world-enable"
+ write_json(saved, previous)
+
+ saved_hash = digest(saved)
+ later = None
+ for phase in ("reload", "later", "stable"):
+ report = run(args, root, phase, case)
+ same(first["first"], report["first"])
+ if previous is not None and phase == "reload":
+ refreshed = json.loads(saved.read_text())
+ for key in set(previous) | set(refreshed):
+ if key not in ("ore_sources", "ore_source_policies"):
+ assert previous.get(key) == refreshed.get(key), f"World setting changed: {key}"
+ saved_hash = digest(saved)
+ else:
+ assert digest(saved) == saved_hash, "Saved profile changed"
+
+ if phase == "later":
+ later = report["later"]
+ if case in ("off", "world-off"):
+ assert later["lens_ore"] == 0, "A disabled saved source still generated lenses"
+ else:
+ assert later["lens_ore"] > 0, "Later lapis lenses did not generate"
+ elif phase == "stable":
+ same(later, report["later"])
+
+ result = {"case": case, "passed": True, "sample": first["first"], "profile_sha256": saved_hash}
+ if case == "reverse":
+ forward = next((item for item in results if item["case"] == "standalone"), None)
+ if forward:
+ same(forward["sample"], result["sample"])
+ results.append(result)
+ write_json(args.output / "results.json", {"passed": True, "cases": results, "boots": len(results) * 4})
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tools/qualification/lapis_balance_110.py b/tools/qualification/lapis_balance_110.py
new file mode 100644
index 0000000..06eb15d
--- /dev/null
+++ b/tools/qualification/lapis_balance_110.py
@@ -0,0 +1,106 @@
+"""Report lapis-piece rewards and uncertainty without imposing a vanilla target."""
+
+import argparse
+import json
+import random
+import statistics
+from pathlib import Path
+
+from gold_survey_110 import SEEDS
+from qualify_110 import ORESPAWN_SHA256, digest, write_json
+
+
+def checked_inputs(records):
+ mods = set()
+ probes = set()
+ for record in records:
+ candidates = [value for name, value in record.items()
+ if name.startswith("RealisticDeposits-") and name.endswith(".jar")]
+ engines = [value for name, value in record.items() if name.startswith("OreSpawn-")]
+ assert len(candidates) == 1 and engines == [ORESPAWN_SHA256], "Use one mod and the exact public OreSpawn jar"
+ mods.update(candidates)
+ probes.add(record["lapis-probe.jar"])
+ assert len(mods) == len(probes) == 1, "Survey and control must use the same immutable mod and probe"
+ return mods.pop()
+
+
+def reward_row(lenses, control):
+ assert lenses["seed"] == control["seed"]
+ primary = lenses["lens_ore"] / lenses["represented_chunks"]
+ vanilla = control["lapis_ore"] / control["chunks"]
+ background = vanilla * 0.15
+ distances = sorted(lenses["prospecting_distances"])
+ return {
+ "seed": lenses["seed"], "set": lenses["set"], "lens_ore_per_chunk": primary,
+ "background_ore_per_chunk": background, "vanilla_ore_per_chunk": vanilla,
+ "pieces_per_chunk": 6 * (primary + background), "vanilla_pieces_per_chunk": 6 * vanilla,
+ "productive_districts": lenses["productive_districts"],
+ "reserves_ore": [row["ore"] for row in lenses["district_reserves"]],
+ "prospecting_distance_blocks": {
+ "median": statistics.median(distances), "p90": distances[int(0.9 * (len(distances) - 1))],
+ "maximum": distances[-1], "samples": len(distances),
+ } if distances else None,
+ }
+
+
+def interval(rows):
+ source = random.Random(512907)
+ ratios = []
+ for _ in range(10000):
+ sample = [source.choice(rows) for _ in rows]
+ ratios.append(sum(row["pieces_per_chunk"] for row in sample)
+ / sum(row["vanilla_pieces_per_chunk"] for row in sample))
+ ratios.sort()
+ return [ratios[249], ratios[9749]]
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ for name in ("lenses", "controls", "output"):
+ parser.add_argument("--" + name, required=True, type=Path)
+ args = parser.parse_args()
+ assert not args.output.exists(), "Keep earlier balance reports"
+ inputs = (args.lenses, args.controls)
+ candidate = checked_inputs([json.loads((path.parent / "artifacts.json").read_text()) for path in inputs])
+ lenses, controls = [{row["seed"]: row for row in json.loads(path.read_text())} for path in inputs]
+ assert set(lenses) == set(controls) == set(SEEDS), "Use all four survey and two holdout seeds"
+ rows = [reward_row(lenses[seed], controls[seed]) for seed in SEEDS]
+ report = {
+ "passed": True, "candidate_sha256": candidate, "seeds": rows, "groups": {},
+ "inputs": {str(path): digest(path) for path in inputs}, "limitations": [
+ "Lenses are estimates against native terrain and caves before other-ore, structure and workings collisions.",
+ "Background is the verified 15% policy applied to matched decorated vanilla controls.",
+ "Survey and holdouts describe this limited standalone sample, not every seed or Mineralogy strata.",
+ "Prospecting distances are horizontal distances to surveyed district centres, not walking routes or entrances.",
+ "The search is bounded; a nearer district outside the survey can shorten those distances.",
+ "Native 1.10 lapis drops four to eight dye pieces, mean six; Fortune multiplies that reward.",
+ "No vanilla-equivalent reward threshold was used to tune or accept the defaults.",
+ ],
+ }
+
+ for name in ("calibration", "holdout", "all"):
+ selected = [row for row in rows if name == "all" or row["set"] == name]
+ pieces = statistics.mean(row["pieces_per_chunk"] for row in selected)
+ vanilla = statistics.mean(row["vanilla_pieces_per_chunk"] for row in selected)
+ reserves = [reserve for row in selected for reserve in row["reserves_ore"]]
+ report["groups"][name] = {
+ "seeds": len(selected), "expected_pieces_per_chunk": pieces,
+ "vanilla_pieces_per_chunk": vanilla, "ratio": pieces / vanilla,
+ "seed_bootstrap_95_percent_ratio": interval(selected),
+ "mean_district_ore": statistics.mean(reserves) if reserves else None,
+ "district_ore_range": [min(reserves), max(reserves)] if reserves else None,
+ "source_share": {
+ key: statistics.mean(row[key + "_ore_per_chunk"] for row in selected) * 6 / pieces
+ for key in ("lens", "background")
+ },
+ "fortune_expected_pieces_per_chunk": {
+ str(level): pieces * multiplier for level, multiplier in enumerate((1, 4 / 3, 7 / 4, 11 / 5))
+ },
+ }
+
+ write_json(args.output, report)
+ print(json.dumps(report["groups"], indent=2))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tools/qualification/lapis_map_world_110.py b/tools/qualification/lapis_map_world_110.py
new file mode 100644
index 0000000..b5145db
--- /dev/null
+++ b/tools/qualification/lapis_map_world_110.py
@@ -0,0 +1,50 @@
+"""Read a finished lapis fixture on a disposable copy and capture a lengthwise section."""
+
+import argparse
+import json
+import shutil
+from pathlib import Path
+
+from lapis_110 import run, same
+from qualify_110 import check_ports, digest, world_fingerprint, write_json
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ for name in ("java", "source", "probe", "output"):
+ parser.add_argument("--" + name, required=True, type=Path)
+ parser.add_argument("--port", type=int, default=28870)
+ args = parser.parse_args()
+ check_ports([args.port])
+ assert not args.output.exists(), "Use a new evidence directory"
+ world = args.source / "lapis-test-world"
+ before = world_fingerprint(world)
+ reference = json.loads((args.source / "lapis-stable.json").read_text())
+ args.output.mkdir(parents=True)
+ for name in ("libraries", "mods", "config", "lapis-test-world"):
+ shutil.copytree(args.source / name, args.output / name)
+ for name in ("forge-1.10.2-12.18.3.2511-universal.jar", "minecraft_server.1.10.2.jar", "eula.txt",
+ "lapis-target.json", "server.properties", "ops.json", "whitelist.json", "banned-ips.json", "banned-players.json"):
+ shutil.copy2(args.source / name, args.output / name)
+ previous = list((args.output / "mods").glob("lapis-probe.jar"))
+ assert len(previous) == 1, "Use a finished fixture with its one isolated lapis probe"
+ previous[0].rename(args.output / "retained-previous-probe.jar")
+ shutil.copy2(args.probe, args.output / "mods/lapis-probe.jar")
+ settings = (args.output / "server.properties").read_text().splitlines()
+ (args.output / "server.properties").write_text("\n".join(
+ f"server-port={args.port}" if line.startswith("server-port=") else line for line in settings) + "\n",
+ encoding="utf-8", newline="\n")
+ profile = args.output / "lapis-test-world/serverconfig/orespawn-worldgen.json"
+ profile_before = digest(profile)
+ report = run(args, args.output, "stable", "map-copy")
+ same(reference["first"], report["first"])
+ same(reference["later"], report["later"])
+ assert digest(profile) == profile_before, "Map capture changed the saved profile"
+ assert world_fingerprint(world) == before, "The source fixture changed"
+ write_json(args.output / "artifacts.json", {p.name: digest(p) for p in (args.output / "mods").glob("*.jar")})
+ write_json(args.output / "results.json", {"passed": True, "boots": 1,
+ "source_world_unchanged": True, "terrain_unchanged": True})
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tools/qualification/lapis_maps.py b/tools/qualification/lapis_maps.py
new file mode 100644
index 0000000..c7d3008
--- /dev/null
+++ b/tools/qualification/lapis_maps.py
@@ -0,0 +1,56 @@
+"""Draw a labelled cross-section from measured lapis-test blocks."""
+
+import argparse
+import json
+from pathlib import Path
+
+from PIL import Image, ImageDraw
+from gold_maps import COLOURS
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("report", type=Path)
+ parser.add_argument("output", type=Path)
+ parser.add_argument("--sample", default="first")
+ parser.add_argument("--title", default="Lapis lenses and their surrounding rock")
+ args = parser.parse_args()
+ assert not args.output.exists(), "Keep previous maps"
+ sample = json.loads(args.report.read_text())[args.sample]
+ along_strike = "strike_section" in sample
+ cells = sample["strike_section" if along_strike else "section"]
+ assert cells, "The measured section is empty"
+ palette = dict(COLOURS, **{"minecraft:lapis_ore": "#255de0", "mineralogy:marble": "#e4e0d1"})
+ left, right = min(cell[0] for cell in cells), max(cell[0] for cell in cells)
+ bottom, top = min(cell[1] for cell in cells), max(cell[1] for cell in cells)
+ blocks = sorted({cell[2] for cell in cells})
+ scale = 5
+ width = max(840, (right - left + 1) * scale + 100)
+ height = (top - bottom + 1) * scale + 155 + 20 * ((len(blocks) + 2) // 3)
+ image = Image.new("RGB", (width, height), "#f9faf8")
+ draw = ImageDraw.Draw(image)
+ draw.text((60, 15), args.title, fill="#222b31")
+ caption = (f"Along the principal lens near {sample['center_x']}, {sample['center_z']}; distance in blocks"
+ if along_strike else f"Measured vertical section at Z {sample['center_z']}; world coordinates")
+ draw.text((60, 33), caption, fill="#3f4a50")
+
+ for x, y, block, _host, *_world in cells:
+ px, py = 60 + (x - left) * scale, 60 + (top - y) * scale
+ draw.rectangle((px, py, px + scale - 1, py + scale - 1), fill=palette.get(block, "#a09a93"))
+
+ for y in range((bottom // 20 + 1) * 20, top + 1, 20):
+ draw.text((10, 58 + (top - y) * scale), f"Y {y}", fill="#222b31")
+ axis = 65 + (top - bottom + 1) * scale
+ for x in (left, (left + right) // 2, right):
+ draw.text((50 + (x - left) * scale, axis), str(x), fill="#222b31")
+ for index, block in enumerate(blocks):
+ px, py = 15 + index % 3 * 270, axis + 30 + index // 3 * 20
+ draw.rectangle((px, py, px + 12, py + 12), fill=palette.get(block, "#a09a93"))
+ draw.text((px + 18, py), block.split(":")[1].replace("_", " "), fill="#222b31")
+ draw.text((15, height - 25), "Blue marks lapis ore. This is measured terrain, not a schematic.", fill="#3f4a50")
+ args.output.parent.mkdir(parents=True, exist_ok=True)
+ image.save(args.output)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tools/qualification/lapis_mines_110.py b/tools/qualification/lapis_mines_110.py
new file mode 100644
index 0000000..48a07c4
--- /dev/null
+++ b/tools/qualification/lapis_mines_110.py
@@ -0,0 +1,121 @@
+"""Check lapis workings, saved plans and safe opt-outs on disposable servers."""
+
+import argparse
+import copy
+import json
+import shutil
+from pathlib import Path
+
+from coal_110 import prepare
+from ci_runtime import FORGE_HASH
+from mines_110 import run
+from qualify_110 import FORGE_JAR, checked_orespawn_sha256, check_ports, digest, write_json
+
+
+ROOT = Path(__file__).resolve().parents[2]
+
+
+def strata_only(profile):
+ assert profile["schema_version"] == 7, "Use a profile from the pinned public OreSpawn release"
+ result = copy.deepcopy(profile)
+ for name, rule in result["ores"].items():
+ if name.startswith("mineralogy:"):
+ rule["enabled"] = False
+ return result
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ for name in ("java", "runtime", "mod", "orespawn", "probe", "output"):
+ parser.add_argument("--" + name, required=True, type=Path)
+ parser.add_argument("--mineralogy", type=Path)
+ parser.add_argument("--start-region", type=int, help="Choose another deterministic mine-search window")
+ parser.add_argument("--base-port", type=int, default=27920)
+ parser.add_argument("--strata-only-profile", type=Path,
+ help="Use this disposable Mineralogy profile with its own ore rules disabled")
+ parser.add_argument("--local-orespawn-sha256", help="Exact checksum of the deliberately selected local test candidate")
+ parser.add_argument("--snapshot-first", action="store_true", help="Keep a first-load world copy for terrain diagnostics")
+ parser.add_argument("--cases", nargs="+", default=("flat", "reverse", "off", "chance-zero", "source-off", "structure", "wet", "normal", "blocked"),
+ choices=("flat", "reverse", "off", "chance-zero", "source-off", "structure", "wet", "normal", "normal-structures", "blocked", "mineralogy", "mineralogy-flat", "mineralogy-infested", "regular"))
+ args = parser.parse_args()
+ check_ports(range(args.base_port, args.base_port + len(args.cases)))
+ assert not args.output.exists(), "Use a fresh evidence directory"
+ assert digest(args.runtime / FORGE_JAR) == FORGE_HASH
+ checked_orespawn_sha256(args.orespawn, args.local_orespawn_sha256)
+ assert (args.runtime / "eula.txt").read_text().strip() == "eula=true"
+ args.output.mkdir(parents=True)
+ sealed = args.output / "inputs"
+ sealed.mkdir()
+
+ for name in ("mod", "orespawn", "probe", "mineralogy", "strata_only_profile"):
+ source = getattr(args, name)
+ if source:
+ target = sealed / source.name
+ shutil.copy2(source, target)
+ setattr(args, name, target)
+ write_json(args.output / "artifacts.json", {p.name: digest(p) for p in sealed.iterdir()})
+
+ results = []
+ for index, case in enumerate(args.cases):
+ args.port = args.base_port + index
+ natural = case in ("normal", "normal-structures", "mineralogy", "mineralogy-infested", "regular")
+ root = prepare(args, args.output / case, mineralogy=case.startswith("mineralogy"), normal=natural)
+ if args.strata_only_profile:
+ assert case.startswith("mineralogy"), "The strata-only control is a separate Mineralogy check"
+ profile = strata_only(json.loads(args.strata_only_profile.read_text()))
+ write_json(root / "config/orespawn-worldgen.json", profile)
+ properties = (root / "server.properties").read_text().replace("coal-test-world", "mine-test-world")
+ if case in ("normal-structures", "regular"):
+ properties = properties.replace("generate-structures=false", "generate-structures=true")
+ if case == "wet":
+ properties = properties.replace("127*minecraft:stone,minecraft:grass;1;decoration",
+ "127*minecraft:stone,20*minecraft:water;1;")
+ elif not natural:
+ properties = properties.replace(";1;decoration", ";1;")
+ (root / "server.properties").write_text(properties, encoding="utf-8", newline="\n")
+
+ for name in ("vanilla", "coal", "diamonds", "gold", "lapis"):
+ location = ROOT / (f"run/config/realisticdeposits/deposits/{name}.json" if case == "regular"
+ else f"src/main/resources/assets/realisticdeposits/defaults/{name}.json")
+ catalogue = json.loads(location.read_text())
+ if not natural:
+ for definition in catalogue["deposits"]:
+ definition["enabled"] = definition["shape"]["type"] == "lapis_lens" and case != "source-off"
+ if definition["shape"]["type"] != "lapis_lens":
+ continue
+ definition["alignment"] = "massive_rare"
+ definition.pop("geomes")
+ if case == "off":
+ definition["abandoned_mine"]["enabled"] = False
+ if case == "chance-zero":
+ definition["abandoned_mine"]["chance"] = 0.0
+ write_json(root / f"config/realisticdeposits/deposits/{name}.json", catalogue)
+
+ saved_profile = root / "mine-test-world/serverconfig/orespawn-worldgen.json"
+ for phase in ("first", "reload", "later", "stable"):
+ run(args.java, root, phase, {"rd.mines.lapis": "true", "rd.mines.scenario": case,
+ "rd.mines.startRegion": str(args.start_region) if args.start_region is not None else
+ "96" if case.startswith("normal") or case in ("mineralogy", "mineralogy-infested", "regular") else "32",
+ "rd.mines.wet": str(case == "wet").lower()})
+ if phase == "first":
+ profile_hash = digest(saved_profile)
+ if args.snapshot_first:
+ shutil.copytree(root / "mine-test-world", root / "first-world-snapshot")
+ else:
+ assert digest(saved_profile) == profile_hash, "Workings changed the captured OreSpawn settings"
+
+ receipt = dict(line.split("=", 1) for line in (root / "lapis-target.properties").read_text().splitlines()
+ if "=" in line and not line.startswith("#"))
+ if case == "reverse":
+ forward = next((item for item in results if item["case"] == "flat"), None)
+ if forward:
+ assert receipt["plan"] == forward["plan"], "Chunk order changed the lapis workings layout"
+ assert receipt["completed_terrain"] == forward["terrain"], "Chunk order changed the completed workings"
+ results.append({"case": case, "passed": True, "profile_sha256": profile_hash,
+ "strata_only": args.strata_only_profile is not None,
+ "plan": receipt["plan"], "terrain": receipt["completed_terrain"]})
+ write_json(args.output / "results.json", {"passed": True, "boots": len(results) * 4, "cases": results})
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tools/qualification/lapis_survey_110.py b/tools/qualification/lapis_survey_110.py
new file mode 100644
index 0000000..c9f3cb0
--- /dev/null
+++ b/tools/qualification/lapis_survey_110.py
@@ -0,0 +1,97 @@
+"""Survey compact lapis reserves and matched vanilla rewards across fixed seeds."""
+
+import argparse
+import json
+import shutil
+import subprocess
+from pathlib import Path
+
+from coal_110 import prepare
+from ci_runtime import FORGE_HASH, check_runtime_log
+from gold_survey_110 import SEEDS, checked_indices
+from qualify_110 import FORGE_JAR, checked_orespawn_sha256, digest, write_json
+
+
+ROOT = Path(__file__).resolve().parents[2]
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ for name in ("java", "runtime", "mod", "orespawn", "probe", "output"):
+ parser.add_argument("--" + name, required=True, type=Path)
+ parser.add_argument("--regions", type=int, default=16)
+ parser.add_argument("--seed-index", type=int, nargs="+", default=list(range(len(SEEDS))))
+ parser.add_argument("--control", action="store_true")
+ parser.add_argument("--reload", action="store_true")
+ args = parser.parse_args()
+ checked_indices(args.seed_index)
+ assert args.regions >= 4 and not args.output.exists(), "Use a new evidence directory and at least four regions"
+ assert digest(args.runtime / FORGE_JAR) == FORGE_HASH
+ checked_orespawn_sha256(args.orespawn)
+ args.output.mkdir(parents=True)
+ inputs = args.output / "inputs"
+ inputs.mkdir()
+
+ for name in ("mod", "orespawn", "probe"):
+ source = getattr(args, name)
+ destination = inputs / source.name
+ shutil.copy2(source, destination)
+ setattr(args, name, destination)
+
+ write_json(args.output / "artifacts.json", {p.name: digest(p) for p in inputs.iterdir()})
+ results = []
+
+ for index in args.seed_index:
+ args.port = 49500 + index + (20 if args.control else 0)
+ root = prepare(args, args.output / str(index), normal=True)
+ settings = (root / "server.properties").read_text().replace("coal-test-world", "lapis-test-world")
+ settings = settings.replace("3346071746553592696", str(SEEDS[index]))
+ (root / "server.properties").write_text(settings, encoding="utf-8", newline="\n")
+
+ for name in ("vanilla", "coal", "diamonds", "gold", "lapis"):
+ catalogue = json.loads((ROOT / f"src/main/resources/assets/realisticdeposits/defaults/{name}.json").read_text())
+ for definition in catalogue["deposits"]:
+ definition["enabled"] = name == "lapis" and not args.control
+ if "abandoned_mine" in definition:
+ definition["abandoned_mine"]["enabled"] = False
+ write_json(root / f"config/realisticdeposits/deposits/{name}.json", catalogue)
+
+ command = [str(args.java), "-Xms256m", "-Xmx3G", "-XX:ActiveProcessorCount=2",
+ "-Drd.lapis.survey=true", f"-Drd.lapis.survey.regions={args.regions}",
+ f"-Drd.lapis.survey.control={str(args.control).lower()}", "-jar", FORGE_JAR, "nogui"]
+ report = None
+ profile_hash = None
+
+ for phase in (("survey", "reload") if args.reload else ("survey",)):
+ with (root / f"{phase}.log").open("w", encoding="utf-8") as output:
+ process = subprocess.Popen(command, cwd=root, stdout=output, stderr=subprocess.STDOUT,
+ creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
+ try:
+ code = process.wait(timeout=900)
+ except subprocess.TimeoutExpired:
+ process.kill()
+ process.wait()
+ raise AssertionError(f"Survey timed out; keep {root}")
+
+ text = (root / f"{phase}.log").read_text(encoding="utf-8", errors="replace")
+ assert code == 0 and "RD_LAPIS_SURVEY_PASS" in text, f"Survey failed: {root}/{phase}"
+ check_runtime_log(text)
+ assert not list((root / "crash-reports").glob("*"))
+ current = json.loads((root / "lapis-survey.json").read_text())
+ profile = root / "lapis-test-world/serverconfig/orespawn-worldgen.json"
+ if report is not None:
+ assert report == current and digest(profile) == profile_hash, "Reload changed the survey or profile"
+ else:
+ report = current
+ profile_hash = digest(profile)
+ shutil.copy2(root / "lapis-survey.json", root / "lapis-survey-first.json")
+
+ report["set"] = "calibration" if index < 4 else "holdout"
+ report["reload_checked"] = args.reload
+ results.append(report)
+ write_json(args.output / "results.json", results)
+ print(f"PASS seed {index} ({report['set']}): {report.get('lens_ore', report.get('lapis_ore'))} ore", flush=True)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tools/qualification/lapis_upgrade_110.py b/tools/qualification/lapis_upgrade_110.py
new file mode 100644
index 0000000..a6814c8
--- /dev/null
+++ b/tools/qualification/lapis_upgrade_110.py
@@ -0,0 +1,104 @@
+"""Compare old Gold worlds with and without the lapis update on disposable copies."""
+
+import argparse
+import json
+import shutil
+from pathlib import Path
+
+from coal_110 import prepare
+from frozen_mines_110 import check_saved_profile
+from gold_110 import run, same
+from qualify_110 import FORGE_JAR, checked_orespawn_sha256, digest, write_json, world_fingerprint
+from ci_runtime import FORGE_HASH
+
+
+ROOT = Path(__file__).resolve().parents[2]
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ for name in ("java", "runtime", "mod", "old-mod", "orespawn", "probe", "output"):
+ parser.add_argument("--" + name, required=True, type=Path)
+ parser.add_argument("--old-mod-sha256", required=True)
+ args = parser.parse_args()
+ assert not args.output.exists(), "Use a new evidence directory"
+ assert digest(args.runtime / FORGE_JAR) == FORGE_HASH
+ assert digest(args.old_mod) == args.old_mod_sha256.upper()
+ checked_orespawn_sha256(args.orespawn)
+ args.output.mkdir(parents=True)
+ inputs = args.output / "inputs"
+ inputs.mkdir()
+ for name in ("mod", "old_mod", "orespawn", "probe"):
+ source = getattr(args, name)
+ target = inputs / source.name
+ shutil.copy2(source, target)
+ setattr(args, name, target)
+ write_json(args.output / "artifacts.json", {p.name: digest(p) for p in inputs.iterdir()})
+
+ current = args.mod
+ args.mod = args.old_mod
+ args.port = 28100
+ baseline = prepare(args, args.output / "gold-first")
+ properties = (baseline / "server.properties").read_text().replace("coal-test-world", "gold-test-world")
+ properties = properties.replace(";1;decoration", ";7;")
+ (baseline / "server.properties").write_text(properties, encoding="utf-8", newline="\n")
+ for name in ("vanilla", "coal", "diamonds", "gold"):
+ catalogue = json.loads((ROOT / f"src/main/resources/assets/realisticdeposits/defaults/{name}.json").read_text())
+ for definition in catalogue["deposits"]:
+ definition["enabled"] = definition["shape"]["type"] == "gold_lode"
+ if "abandoned_mine" in definition:
+ definition["abandoned_mine"]["enabled"] = False
+ if definition["shape"]["type"] == "gold_lode":
+ definition["alignment"] = "massive_rare"
+ definition.pop("geomes")
+ write_json(baseline / f"config/realisticdeposits/deposits/{name}.json", catalogue)
+
+ before = run(args, baseline, "first", "old-gold")
+ baseline_world = world_fingerprint(baseline / "gold-test-world")
+ original_profile = before["profile"]
+ previous_files = {path.name: digest(path) for path in
+ (baseline / "config/realisticdeposits/deposits").glob("*.json")}
+ versions = []
+ import zipfile
+ with zipfile.ZipFile(current) as archive:
+ version = next(row["version"] for row in json.loads(archive.read("mcmod.info"))
+ if row["modid"] == "realisticdeposits")
+
+ for index, updated in enumerate((False, True)):
+ root = args.output / ("upgraded" if updated else "old-control")
+ shutil.copytree(baseline, root)
+ settings = (root / "server.properties").read_text().replace("server-port=28100", f"server-port={28101 + index}")
+ (root / "server.properties").write_text(settings, encoding="utf-8", newline="\n")
+ if updated:
+ old = root / "mods" / args.old_mod.name
+ old.rename(root / "retained-old-mod.jar")
+ shutil.copy2(current, root / "mods" / current.name)
+
+ later = None
+ for phase in ("reload", "later", "stable"):
+ report = run(args, root, phase, root.name)
+ same(before["first"], report["first"])
+ assert not any(key.startswith("realisticdeposits:") and "lapis" in key
+ for key in report["profile"]["ores"]), "An old world silently acquired lapis rules"
+ if updated:
+ check_saved_profile(original_profile, report["profile"], version)
+ else:
+ assert report["profile"] == original_profile
+ for name, expected in previous_files.items():
+ assert digest(root / f"config/realisticdeposits/deposits/{name}") == expected, "An old catalogue changed"
+ if phase == "later":
+ later = report["later"]
+ elif phase == "stable":
+ same(later, report["later"])
+
+ versions.append(later)
+
+ same(versions[0], versions[1])
+ assert world_fingerprint(baseline / "gold-test-world") == baseline_world, "The source fixture was modified"
+ write_json(args.output / "results.json", {"passed": True, "boots": 7,
+ "old_world_rules_unchanged": True,
+ "reload_and_later_terrain_equal_to_old_control": True})
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tools/qualification/probes.init.gradle b/tools/qualification/probes.init.gradle
index 87ffcef..961eee6 100644
--- a/tools/qualification/probes.init.gradle
+++ b/tools/qualification/probes.init.gradle
@@ -1,5 +1,24 @@
gradle.afterProject { project, state ->
if (project == project.rootProject && state.failure == null) {
+ def lapisInput = project.tasks.register('lapisQualificationProbeInput', Jar) {
+ dependsOn project.tasks.named('testClasses')
+ from project.sourceSets.test.output.classesDirs
+ include 'zone/moddev/mc/realisticdeposits/generation/LapisRuntimeProbe*.class'
+ include 'zone/moddev/mc/realisticdeposits/generation/LapisHarvestRuntimeChecks*.class'
+ include 'zone/moddev/mc/realisticdeposits/generation/LapisSurveyProbe*.class'
+ archiveFileName = 'lapis-probe-deobf.jar'
+ destinationDirectory = project.layout.buildDirectory.dir('qualification')
+ }
+ def lapisPackaged = project.renamer.classes(lapisInput) {
+ map.from project.minecraft.dependency.toSrgFile
+ output = project.layout.buildDirectory.file('qualification/lapis-probe.jar')
+ }
+ project.tasks.register('prepareLapisQualification') {
+ group = 'verification'
+ description = 'Builds the isolated lapis placement and survey probes.'
+ dependsOn lapisPackaged
+ }
+
def goldInput = project.tasks.register('goldQualificationProbeInput', Jar) {
dependsOn project.tasks.named('testClasses')
from project.sourceSets.test.output.classesDirs
@@ -144,6 +163,7 @@ gradle.afterProject { project, state ->
include 'zone/moddev/mc/realisticdeposits/generation/MineWorkingsRuntimeProbe*.class'
include 'zone/moddev/mc/realisticdeposits/generation/PipeWorkingsRuntimeChecks*.class'
include 'zone/moddev/mc/realisticdeposits/generation/GoldWorkingsRuntimeChecks*.class'
+ include 'zone/moddev/mc/realisticdeposits/generation/LapisWorkingsRuntimeChecks*.class'
archiveFileName = 'mine-probe-deobf.jar'
destinationDirectory = project.layout.buildDirectory.dir('qualification')
}
diff --git a/tools/qualification/qualify_110.py b/tools/qualification/qualify_110.py
index ddb6dfa..2d5370c 100644
--- a/tools/qualification/qualify_110.py
+++ b/tools/qualification/qualify_110.py
@@ -6,6 +6,7 @@
import json
import re
import shutil
+import socket
import subprocess
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
@@ -19,6 +20,17 @@
RESOURCE_KEY = "iron_ore"
+def check_ports(ports):
+ for port in ports:
+ if not 1 <= port <= 65535:
+ raise ValueError("Choose a base port whose full test range fits within 1-65535")
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener:
+ try:
+ listener.bind(("127.0.0.1", port))
+ except OSError as error:
+ raise ValueError(f"Test port {port} is busy or reserved; choose another --base-port") from error
+
+
def disabled_control_key():
return "lapis_ore" if RESOURCE_KEY == "gold_ore" else "gold_ore"
@@ -81,7 +93,7 @@ def prepare(args, name, origin, policy, managed, port, bootstrap=False):
settings = (
"level-name=world\nlevel-seed=3346071746553592696\nlevel-type=FLAT\n"
- f"generator-settings=3;minecraft:bedrock,{239 if RESOURCE_KEY in ('coal_ore', 'diamond_ore', 'gold_ore') else 127}*minecraft:stone,minecraft:grass;{37 if getattr(args, 'mesa', False) else 1};decoration\n"
+ f"generator-settings=3;minecraft:bedrock,{239 if RESOURCE_KEY in ('coal_ore', 'diamond_ore', 'gold_ore', 'lapis_ore') else 127}*minecraft:stone,minecraft:grass;{37 if getattr(args, 'mesa', False) else 1};decoration\n"
f"server-ip=127.0.0.1\nserver-port={port}\nonline-mode=false\n"
"generate-structures=false\nview-distance=3\nmax-tick-time=-1\nallow-nether=false\n"
"spawn-animals=false\nspawn-monsters=false\n"
@@ -107,6 +119,13 @@ def prepare(args, name, origin, policy, managed, port, bootstrap=False):
"beds": [2, 2], "thickness": [1, 1], "rock_interval": [2, 2],
"maximum_dip_degrees": 0, "fold_amplitude": 0, "maximum_fault_throw": 0,
}
+ elif RESOURCE_KEY == "lapis_ore":
+ definition.update(min_y=180, max_y=215)
+ definition["shape"] = {
+ "type": "lapis_lens", "lenses": [2, 2], "strike_length": [64, 96],
+ "width": [12, 24], "thickness": [2, 3], "surface_cover": [4, 12],
+ "legacy_selection_scale": 0.25,
+ }
elif RESOURCE_KEY == "gold_ore":
definition.update(min_y=180, max_y=215)
definition["shape"] = {
@@ -142,6 +161,10 @@ def prepare(args, name, origin, policy, managed, port, bootstrap=False):
for default in gold["deposits"]:
default["enabled"] = False
write_json(root / "config/realisticdeposits/deposits/gold.json", gold)
+ lapis = json.loads((Path(__file__).parents[2] / "src/main/resources/assets/realisticdeposits/defaults/lapis.json")
+ .read_text(encoding="utf-8"))
+ lapis["deposits"][0]["enabled"] = False
+ write_json(root / "config/realisticdeposits/deposits/lapis.json", lapis)
sources = {}
if origin != "native":
@@ -259,7 +282,8 @@ def check_profile(report, origin, policy, managed):
default_coal = owned.pop("realisticdeposits:deposit/realisticdeposits/overworld_coal_measures", None)
if default_coal is not None:
assert default_coal["enabled"] is False, "The unrelated default coalfield must remain disabled"
- for name in ("overworld_diamond_pipes", "overworld_diamond_placers", "overworld_gold_lodes", "overworld_gold_placers"):
+ for name in ("overworld_diamond_pipes", "overworld_diamond_placers", "overworld_gold_lodes", "overworld_gold_placers",
+ "overworld_lapis_lenses"):
default = owned.pop("realisticdeposits:deposit/realisticdeposits/" + name, None)
if default is not None:
assert default["enabled"] is False, "An unrelated default deposit rule became active"
@@ -438,12 +462,13 @@ def main():
parser.add_argument("--jobs", type=int, default=2)
parser.add_argument("--base-port", type=int, default=25900,
help="First loopback port; choose a separate range for concurrent matrices")
- parser.add_argument("--resource", choices=("iron", "coal", "diamond", "gold"), default="iron")
+ parser.add_argument("--resource", choices=("iron", "coal", "diamond", "gold", "lapis"), default="iron")
parser.add_argument("--mesa", action="store_true", help="Also exercise Mesa's separate gold generation")
parser.add_argument("--only", default="", help="Run matching case names for focused diagnostics")
parser.add_argument("--local-orespawn-sha256",
help="Explicit checksum for testing an unpublished local OreSpawn fix")
args = parser.parse_args()
+ check_ports(range(args.base_port - 1, args.base_port + len(ORIGINS) * len(POLICIES) * 2))
RESOURCE_KEY = args.resource + "_ore"
DEPOSIT_ID = "realisticdeposits:deposit/realisticdeposits/qualification_" + args.resource
diff --git a/tools/qualification/test_ci_runtime.py b/tools/qualification/test_ci_runtime.py
index 16d5dc8..0d08b77 100644
--- a/tools/qualification/test_ci_runtime.py
+++ b/tools/qualification/test_ci_runtime.py
@@ -19,7 +19,8 @@ def test_a_pass_marker_cannot_hide_a_runtime_error(self):
def test_local_init_script_prepares_every_probe_used_by_the_driver(self):
script = (ci_runtime.ROOT / "tools/qualification/probes.init.gradle").read_text()
for task in ("preparePolicyQualification", "prepareCoalQualification",
- "prepareDiamondQualification", "prepareGoldQualification", "prepareMineQualification"):
+ "prepareDiamondQualification", "prepareGoldQualification", "prepareLapisQualification",
+ "prepareMineQualification"):
self.assertIn(task, script)
def test_hosted_workflows_never_launch_packaged_minecraft_suites(self):
@@ -55,13 +56,24 @@ def test_main_rejects_ci_before_preparing_any_inputs(self):
with self.assertRaisesRegex(RuntimeError, "local-only"):
ci_runtime.main()
+ def test_uploaded_jars_match_the_current_candidate_version(self):
+ properties = dict(line.split("=", 1) for line in
+ (ci_runtime.ROOT / "gradle.properties").read_text().splitlines()
+ if "=" in line and not line.startswith("#"))
+ workflow = (ci_runtime.ROOT / ".github/workflows/ci.yml").read_text()
+
+ for suffix in ("", "-sources", "-javadoc"):
+ self.assertIn(f"build/libs/RealisticDeposits-{properties['mod_version']}{suffix}.jar", workflow)
+
def test_every_deposit_has_policy_and_gameplay_coverage(self):
plan = ci_runtime.suite_plan()
- for resource in ("iron", "coal", "diamond", "gold"):
+ for resource in ("iron", "coal", "diamond", "gold", "lapis"):
self.assertIn("policy-" + resource, plan)
self.assertIn("--mesa", plan["policy-gold-mesa"]["arguments"])
self.assertIn("gold", plan)
self.assertIn("gold-workings", plan)
+ self.assertIn("lapis", plan)
+ self.assertIn("lapis-workings", plan)
self.assertIn("workings", plan)
self.assertIn("iron-workings", plan)
diff --git a/tools/qualification/test_gold_survey.py b/tools/qualification/test_gold_survey.py
index ccf1b5a..b31c217 100644
--- a/tools/qualification/test_gold_survey.py
+++ b/tools/qualification/test_gold_survey.py
@@ -11,7 +11,7 @@
class GoldSurveyChecksTest(unittest.TestCase):
def test_controls_disable_every_catalogue_without_editing_the_defaults(self):
catalogues = survey.control_catalogues()
- self.assertEqual({"vanilla", "coal", "diamonds", "gold"}, set(catalogues))
+ self.assertEqual({"vanilla", "coal", "diamonds", "gold", "lapis"}, set(catalogues))
self.assertTrue(all(not definition["enabled"] for catalogue in catalogues.values()
for definition in catalogue["deposits"]))
self.assertTrue(any(definition["enabled"] for definition in
diff --git a/tools/qualification/test_lapis.py b/tools/qualification/test_lapis.py
new file mode 100644
index 0000000..f94303b
--- /dev/null
+++ b/tools/qualification/test_lapis.py
@@ -0,0 +1,66 @@
+"""Keep lapis rewards and immutable-input checks sensitive to mistakes."""
+
+import copy
+import tempfile
+import unittest
+from pathlib import Path
+
+import lapis_110
+import lapis_mines_110
+import lapis_balance_110 as balance
+from qualify_110 import ORESPAWN_SHA256
+
+
+class LapisChecksTest(unittest.TestCase):
+ def test_strata_only_fixture_preserves_the_public_profile_and_unrelated_rules(self):
+ profile = {"schema_version": 7, "custom_setting": 17, "ores": {
+ "mineralogy:gold": {"enabled": True, "custom": 3},
+ "custom:gold": {"enabled": True, "custom": 9},
+ }}
+ original = copy.deepcopy(profile)
+ result = lapis_mines_110.strata_only(profile)
+ self.assertEqual(original, profile)
+ self.assertFalse(result["ores"]["mineralogy:gold"]["enabled"])
+ self.assertEqual(profile["ores"]["custom:gold"], result["ores"]["custom:gold"])
+ self.assertEqual(17, result["custom_setting"])
+
+ def test_rewards_count_native_lapis_pieces_not_ore_blocks(self):
+ lenses = {"seed": 7, "set": "holdout", "lens_ore": 1000, "represented_chunks": 100,
+ "productive_districts": 2, "district_reserves": [{"ore": 400}, {"ore": 600}],
+ "prospecting_distances": [100, 200, 300]}
+ control = {"seed": 7, "lapis_ore": 400, "chunks": 100}
+ result = balance.reward_row(lenses, control)
+ self.assertAlmostEqual(63.6, result["pieces_per_chunk"])
+ self.assertEqual(24, result["vanilla_pieces_per_chunk"])
+
+ def test_surveys_require_matching_public_inputs(self):
+ record = {"RealisticDeposits-0.5.0.110021.jar": "A" * 64,
+ "OreSpawn-4.1.5.110021.jar": ORESPAWN_SHA256, "lapis-probe.jar": "B" * 64}
+ self.assertEqual("A" * 64, balance.checked_inputs([record, record]))
+ for key in record:
+ changed = copy.deepcopy(record)
+ changed[key] = "C" * 64
+ with self.assertRaises(AssertionError):
+ balance.checked_inputs([record, changed])
+
+ def test_reload_check_rejects_changed_ore_and_terrain(self):
+ sample = {"terrain_sha256": "A", "lapis_sha256": "B", "lens_ore": 300, "outside_ore": 20}
+ for key in sample:
+ changed = dict(sample)
+ changed[key] = "changed"
+ with self.assertRaises(AssertionError):
+ lapis_110.same(sample, changed)
+
+ def test_controlled_placement_disables_unrelated_catalogues(self):
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ lapis_110.configure(root, "standalone")
+ import json
+ for path in (root / "config/realisticdeposits/deposits").glob("*.json"):
+ catalogue = json.loads(path.read_text())
+ for definition in catalogue["deposits"]:
+ self.assertEqual(path.stem == "lapis", definition["enabled"])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tools/qualification/test_qualify_110.py b/tools/qualification/test_qualify_110.py
index 3cecffe..012f933 100644
--- a/tools/qualification/test_qualify_110.py
+++ b/tools/qualification/test_qualify_110.py
@@ -2,6 +2,7 @@
import copy
import tempfile
+import socket
import unittest
from pathlib import Path
from unittest.mock import Mock
@@ -30,6 +31,17 @@ def sample(ordinary, denied):
class QualificationChecksTest(unittest.TestCase):
+ def test_a_busy_test_port_fails_before_starting_any_servers(self):
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener:
+ listener.bind(("127.0.0.1", 0))
+ listener.listen()
+ with self.assertRaisesRegex(ValueError, "busy or reserved"):
+ qualify_110.check_ports([listener.getsockname()[1]])
+
+ for port in (-1, 0, 65536):
+ with self.assertRaises(ValueError):
+ qualify_110.check_ports([port])
+
def test_disabled_control_cannot_overwrite_the_active_gold_import(self):
for resource in ("iron_ore", "coal_ore", "diamond_ore", "gold_ore"):
with patch.object(qualify_110, "RESOURCE_KEY", resource):
diff --git a/tools/release/test_release.py b/tools/release/test_release.py
index d284f16..64dbf7d 100644
--- a/tools/release/test_release.py
+++ b/tools/release/test_release.py
@@ -9,11 +9,11 @@
class ReleaseContractTest(unittest.TestCase):
- def test_public_dependency_and_beta_candidate_version(self):
+ def test_public_dependency_and_candidate_version(self):
properties = dict(line.split("=", 1) for line in
(ROOT / "gradle.properties").read_text().splitlines()
if "=" in line and not line.startswith("#"))
- self.assertEqual("0.4.0.110021", properties["mod_version"])
+ self.assertEqual("0.5.0.110021", properties["mod_version"])
self.assertEqual("4.1.5.110021", properties["orespawn_version"])
self.assertEqual("9397156", properties["orespawn_curse_file_id"])
self.assertEqual("1E580EBB8D93B1256E2E81C7D0D815DEAAAC58A028CB5A77A52773B62048E010",
@@ -52,6 +52,15 @@ def test_codeql_observes_a_fresh_compilation(self):
self.assertIn("build-mode: manual", workflow)
self.assertIn("--no-build-cache --rerun-tasks", workflow)
+ def test_verified_manifest_is_checked_before_publication(self):
+ workflow = (ROOT / ".github/workflows/deploy-release.yml").read_text()
+ staging = workflow.split(" - name: Stage the immutable release artifact", 1)[1]
+ staging = staging.split(" - name: Upload immutable release artifact", 1)[0]
+ self.assertIn("inputs.expected_checksums_sha256", staging)
+ self.assertIn('(cd "$bundle" && sha256sum --check SHA256SUMS)', staging)
+ self.assertIn('"${EXPECTED_CHECKSUMS_SHA256,,}" "$bundle/SHA256SUMS" | sha256sum --check -',
+ staging)
+
if __name__ == "__main__":
unittest.main()