From 43f6b52d4549b22b30e619454ae1d73f6f87403d Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Wed, 4 Nov 2026 15:24:25 +0000 Subject: [PATCH] Add marble-hosted lapis deposits --- .github/workflows/ci.yml | 6 +- .github/workflows/deploy-release.yml | 13 + CHANGELOG.md | 14 + README.md | 33 +- build.gradle | 1 + docs/LAPIS.md | 68 +++++ docs/RELEASING.md | 14 +- gradle.properties | 2 +- gradle/lapis-manual.gradle | 42 +++ gradle/release.gradle | 2 +- .../realisticdeposits/RealisticDeposits.java | 7 +- .../command/RealisticDepositsCommand.java | 10 + .../config/DepositCatalogLoader.java | 11 + .../config/RealisticDepositsRuntime.java | 4 +- .../generation/LapisGeology.java | 98 ++++++ .../generation/LapisPattern.java | 259 ++++++++++++++++ .../geometry/LapisDistrict.java | 284 ++++++++++++++++++ .../integration/OreSpawnIntegration.java | 17 +- .../model/DepositDefinition.java | 5 +- .../model/LapisLensSettings.java | 66 ++++ .../realisticdeposits/defaults/lapis.json | 32 ++ .../assets/realisticdeposits/lang/en_US.lang | 2 +- .../ProjectIdentityTest.java | 2 +- .../config/LapisCatalogTest.java | 79 +++++ .../config/MineDefaultsTest.java | 4 +- .../config/RealisticDepositsRuntimeTest.java | 2 +- .../generation/LapisGeologyTest.java | 114 +++++++ .../generation/LapisHarvestRuntimeChecks.java | 74 +++++ .../generation/LapisRuntimeProbe.java | 261 ++++++++++++++++ .../generation/LapisSurveyProbe.java | 212 +++++++++++++ .../LapisWorkingsRuntimeChecks.java | 255 ++++++++++++++++ .../generation/MineWorkingsRuntimeProbe.java | 6 + .../generation/PolicyQualificationProbe.java | 19 +- .../geometry/LapisDistrictTest.java | 191 ++++++++++++ tools/qualification/LAPIS_TESTING.md | 71 +++++ tools/qualification/README.md | 35 ++- tools/qualification/ci_runtime.py | 6 +- tools/qualification/gold_survey_110.py | 2 +- tools/qualification/lapis_110.py | 171 +++++++++++ tools/qualification/lapis_balance_110.py | 106 +++++++ tools/qualification/lapis_map_world_110.py | 50 +++ tools/qualification/lapis_maps.py | 56 ++++ tools/qualification/lapis_mines_110.py | 121 ++++++++ tools/qualification/lapis_survey_110.py | 97 ++++++ tools/qualification/lapis_upgrade_110.py | 104 +++++++ tools/qualification/probes.init.gradle | 20 ++ tools/qualification/qualify_110.py | 31 +- tools/qualification/test_ci_runtime.py | 16 +- tools/qualification/test_gold_survey.py | 2 +- tools/qualification/test_lapis.py | 66 ++++ tools/qualification/test_qualify_110.py | 12 + tools/release/test_release.py | 13 +- 52 files changed, 3139 insertions(+), 49 deletions(-) create mode 100644 docs/LAPIS.md create mode 100644 gradle/lapis-manual.gradle create mode 100644 src/main/java/zone/moddev/mc/realisticdeposits/generation/LapisGeology.java create mode 100644 src/main/java/zone/moddev/mc/realisticdeposits/generation/LapisPattern.java create mode 100644 src/main/java/zone/moddev/mc/realisticdeposits/geometry/LapisDistrict.java create mode 100644 src/main/java/zone/moddev/mc/realisticdeposits/model/LapisLensSettings.java create mode 100644 src/main/resources/assets/realisticdeposits/defaults/lapis.json create mode 100644 src/test/java/zone/moddev/mc/realisticdeposits/config/LapisCatalogTest.java create mode 100644 src/test/java/zone/moddev/mc/realisticdeposits/generation/LapisGeologyTest.java create mode 100644 src/test/java/zone/moddev/mc/realisticdeposits/generation/LapisHarvestRuntimeChecks.java create mode 100644 src/test/java/zone/moddev/mc/realisticdeposits/generation/LapisRuntimeProbe.java create mode 100644 src/test/java/zone/moddev/mc/realisticdeposits/generation/LapisSurveyProbe.java create mode 100644 src/test/java/zone/moddev/mc/realisticdeposits/generation/LapisWorkingsRuntimeChecks.java create mode 100644 src/test/java/zone/moddev/mc/realisticdeposits/geometry/LapisDistrictTest.java create mode 100644 tools/qualification/LAPIS_TESTING.md create mode 100644 tools/qualification/lapis_110.py create mode 100644 tools/qualification/lapis_balance_110.py create mode 100644 tools/qualification/lapis_map_world_110.py create mode 100644 tools/qualification/lapis_maps.py create mode 100644 tools/qualification/lapis_mines_110.py create mode 100644 tools/qualification/lapis_survey_110.py create mode 100644 tools/qualification/lapis_upgrade_110.py create mode 100644 tools/qualification/test_lapis.py 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 logo](src/main/resources/assets/realisticdeposits/textures/logo.png) 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()