diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5de485e..1477731 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,7 +81,7 @@ jobs: build: name: Build, test, and audit runs-on: ubuntu-latest - timeout-minutes: 120 + timeout-minutes: 240 steps: - name: Check out source @@ -138,7 +138,8 @@ jobs: - name: Build the isolated packaged-server probe run: >- - ./gradlew preparePolicyQualification prepareDiamondQualification prepareMineQualification --init-script tools/qualification/probes.init.gradle + ./gradlew preparePolicyQualification prepareCoalQualification prepareDiamondQualification + prepareGoldQualification prepareMineQualification --init-script tools/qualification/probes.init.gradle --offline --no-daemon --max-workers=2 -Dorg.gradle.java.installations.paths="$JAVA_HOME,$JAVA_HOME_8_X64,$JAVA_HOME_25_X64" -Dorg.gradle.java.installations.auto-detect=false @@ -156,9 +157,9 @@ jobs: if-no-files-found: error retention-days: 30 path: | - build/libs/RealisticDeposits-0.3.0.110021.jar - build/libs/RealisticDeposits-0.3.0.110021-sources.jar - build/libs/RealisticDeposits-0.3.0.110021-javadoc.jar + 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/release/SHA256SUMS CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 2aca3f9..f7262e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## 0.4.0.110021 + +Beta for Minecraft 1.10.2. + +- Add steep gold veins with branches, pinching sections and richer ore shoots. + Keep 15% ordinary background gold and leave existing iron, coal and diamond + layouts unchanged. +- Use suitable existing rocks when Mineralogy strata are active, or natural + vanilla stone otherwise. Add no quartz carrier rock or terrain replacement. +- Add linked river pockets of gold-bearing gravel. Mining leaves spent gravel + and has a one-in-four nugget chance, with Fortune increasing successful drops. + Silk Touch and falling preserve the rich block. +- Attempt safe gradual workings at goldfields. Leave river placers untouched + and preserve previously saved mine plans. +- Keep gold settings in their own catalogue. Existing world profiles and + terrain do not gain gold rules or lose background gold automatically. +- Add gold previews and separate packaged-server and manual-testing profiles. +- Require OreSpawn 4.1.5.110021 so Mesa's extra gold follows the same background + controls as ordinary gold, without changing saved settings or existing chunks. + ## 0.3.0.110021 Beta for Minecraft 1.10.2. diff --git a/README.md b/README.md index 86a9e94..fa19391 100644 --- a/README.md +++ b/README.md @@ -6,16 +6,16 @@ ![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 and diamond pipes give you -deposits worth returning to, with old workings leading into some of them. -Small pockets of diamond-bearing river gravel offer another way to prospect. +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. +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.3.0.110021 is a beta for Minecraft 1.10.2. It adds diamond deposits -and replaces deep ladder shafts in new workings with stepped entrances. +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. @@ -37,15 +37,17 @@ worlds before updating. ordinary ore generation. - Rare diamond pipes and linked river placers, without adding new host rocks 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). - 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. Diamond geometry and other structural +- Live reload for iron and coal geometry. 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 and diamond - pipe where safe. River placers stay untouched. See the [mine settings](docs/ABANDONED_MINES.md). + 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). Coalfields can already use Mineralogy's existing shale, siltstone and conglomerate. Wider catalogues for Mineralogy, Base Metals, Modern Metals, @@ -57,7 +59,7 @@ The current branch requires: - Minecraft 1.10.2; - Forge 12.18.3.2511 or a compatible newer Forge 12 build; -- [MMD OreSpawn 4.1.4.110021](https://www.curseforge.com/minecraft/mc-mods/mmd-orespawn/files/9356289) +- [MMD OreSpawn 4.1.5.110021](https://www.curseforge.com/minecraft/mc-mods/mmd-orespawn/files/9397156) or a newer compatible 1.10.2 release, but earlier than OreSpawn 5; - Java 8 to run Minecraft. @@ -73,6 +75,7 @@ The first launch creates: config/realisticdeposits/deposits/vanilla.json config/realisticdeposits/deposits/coal.json config/realisticdeposits/deposits/diamonds.json +config/realisticdeposits/deposits/gold.json ``` The supplied definition describes one rare iron district using a 640–960 block @@ -91,6 +94,13 @@ use plain stone without physical strata, or suitable existing sedimentary rocks when those strata are active. Old catalogues are never overwritten, and old worlds do not receive new coal rules automatically. +Goldfields add narrow, steep veins with richer pockets rather than walls of +solid ore. Nearby river sediments can contain gold-bearing gravel: mining it +leaves spent gravel and has a one-in-four chance of a gold nugget. Fortune +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. + Deposits only affect newly generated chunks. Changing these settings does not retrogen, replace, or rewrite an existing chunk. @@ -136,7 +146,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. Resource, +and source links require a restart, as do gold 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 @@ -151,9 +161,9 @@ World Settings screen. The Gradle build runs on Java 17 and compiles the mod for Java 8 using the configured toolchain. ForgeGradle's Minecraft preparation utility uses Java 25. -OreSpawn 4.1.4.110021 is downloaded from Curse Maven, checked against its +OreSpawn 4.1.5.110021 is downloaded from Curse Maven, checked against its official checksum and mapped for development automatically. The exact dependency -is `curse.maven:mmd-orespawn-245586:9356289`. No sibling checkout, local jar or +is `curse.maven:mmd-orespawn-245586:9397156`. No sibling checkout, local jar or `local.properties` file is needed. An optional local-candidate override is still available for development, but it diff --git a/build.gradle b/build.gradle index 7bf31bb..1fb2c4b 100644 --- a/build.gradle +++ b/build.gradle @@ -450,11 +450,11 @@ tasks.register('verifyProjectIdentity') { } if (project.minecraft_version != '1.10.2' || project.forge_version != '12.18.3.2511' - || project.orespawn_version != '4.1.4.110021' - || project.orespawn_version_range != '[4.1.4.110021,5.0.0)' - || (!oreSpawnCandidateMode && oreSpawnCoordinate != 'curse.maven:mmd-orespawn-245586:9356289') + || project.orespawn_version != '4.1.5.110021' + || project.orespawn_version_range != '[4.1.5.110021,5.0.0)' + || (!oreSpawnCandidateMode && oreSpawnCoordinate != 'curse.maven:mmd-orespawn-245586:9397156') || project.orespawn_sha256 - != 'DB254E9E865685A74262DE9D469A006AB3C8053573CC6136FD4A8591AD806CA3') { + != '1E580EBB8D93B1256E2E81C7D0D815DEAAAC58A028CB5A77A52773B62048E010') { throw new GradleException('Unexpected target or OreSpawn dependency identity') } if (project.mod_version.toString().contains('-')) { @@ -648,3 +648,4 @@ tasks.named('check') { apply from: 'gradle/release.gradle' apply from: 'gradle/coal-manual.gradle' apply from: 'gradle/diamond-manual.gradle' +apply from: 'gradle/gold-manual.gradle' diff --git a/docs/GOLD.md b/docs/GOLD.md new file mode 100644 index 0000000..3677fe3 --- /dev/null +++ b/docs/GOLD.md @@ -0,0 +1,108 @@ +# Goldfields and river gold + +A goldfield is worth exploring, but its gold is not spread evenly. Narrow, +steep veins branch and pinch out, with rich shoots separated by lean stretches. +Following one promising vein can lead to another pocket rather than a solid +wall of gold. Where it is safe, abandoned workings provide a gradual entrance +and a short route into the principal vein. + +The idea comes from gold deposited by fluids moving through fractures, followed +by weathering and concentration in rivers. This is one family of gold deposits, +not a model of every way gold occurs in nature. The +[Geological Survey of South Australia](https://energymining.sa.gov.au/industry/minerals-and-mining/mineral-commodities/gold) +describes both lode and alluvial deposits. + +## With Mineralogy + +Goldfields favour mountain belts and selected older crustal or volcanic +districts. They use suitable declared natural rocks, including slate, phyllite, +schist, amphibolite and selected granitic or metasedimentary hosts. The veins +cut across the rocks already there; they do not create surrounding strata. + +Mineralogy does not yet have vein quartz. This update therefore represents the +gold-bearing parts of a fracture system, without a visible quartz filling. +Quartzite is not used as a substitute for vein quartz. A modern surface biome +also need not resemble the setting in which the deposit originally formed. + +## Without Mineralogy + +The same vein planner works in natural vanilla stone, granite, diorite and +andesite. Polished variants are excluded. OreSpawn still supplies geological +classification without replacing terrain. If a legacy geology profile lacks +that classification, a rarer, seed-based approximation is used instead. It +does not change the world's geology settings. + +## River placers + +Some rivers near a planned goldfield contain patches of gold-bearing gravel. +Only suitable natural submerged sediment is replaced. Rivers are not reshaped, +and the source link is a regional approximation, not a simulation of drainage +and erosion. Mining the source first does not change the placer. + +Mine rich gravel with a shovel. Each block leaves one ordinary gravel and has +a one-in-four chance of one vanilla gold nugget. Fortune multiplies a successful +drop; Silk Touch returns the rich block instead. Falling also preserves its +identity. The ordinary gravel left behind is spent: placing it again cannot +produce more gold. There are no panning or processing recipes for rich gravel. + +Both the gravel background and the small gold flecks use vanilla textures, so +resource packs can replace them. River placers do not receive mine entrances. + +Before Fortune, 36 rich gravel blocks give one ingot's worth of nuggets on +average. Individual drops vary, and a small pocket may give no gold at all. + +## How much gold to expect + +Gold is deliberately concentrated rather than evenly available. In the +six-seed standalone survey, the default settings gave an estimated 2.41 +ingots per chunk overall, against 10.91 in the vanilla controls: about 22% of +vanilla's generated supply. The two holdout seeds gave a similar result. The +defaults were not changed to meet a vanilla-equivalent target. + +The 179 productive fields in that survey averaged about 27,000 ore blocks +each, ranging from roughly 7,400 to 57,700. Those are terrain-based reserve +estimates before collisions with other ores, structures or workings. They +are not a promise that every field can be mined completely. + +From fixed prospecting points, the median horizontal distance to a productive +district was about 1.1–1.5 km across the six seeds. The 90th-percentile distance +was about 2.2–3.1 km. Walking routes and finding a safe entrance can take longer. + +Background generation supplied about 68% of the estimated total, lodes 32% +and placers less than 1%. Placers are a little extra while exploring rivers, +not the main source of gold. Fortune helps nugget drops; vanilla 1.10 gold +ore still smelts to one ingot regardless of Fortune. + +These figures come from a limited, fixed sample: four survey seeds and two +holdouts, with 1,024 sampled chunks per seed for decorated river sediments. +The bulk comparison leaves vanilla structures off; the manual mine and river +locations were checked separately with normal structures enabled. +The whole-seed bootstrap range was about 21–23% of vanilla, but it is not a +guarantee for other seeds. River sampling is particularly sparse. Mineralogy +and third-party ore processing can change the experience; they are not part +of these standalone reward estimates. Mob drops, chest loot and trading are +not included either. + +## Settings and existing worlds + +The first launch installs `config/realisticdeposits/deposits/gold.json` only if +it is absent. Defaults use 64-chunk regions with 50% candidate selection before +geological filtering, two to four veins, 256–512 block strike lengths, widths +of one to three blocks and dips of 55–85 degrees. Gold stays within Y 8–128 +and beneath the local surface. River pockets are one to three blocks thick +and must be within 1,024 blocks of a planned source district. + +The lode rule retains 15% ordinary background gold, including the managed Mesa +supply. The placer does not reduce it again. Explicit OreSpawn overrides and +custom placement channels retain their existing meaning. Turning off the +effective lode rule also turns off its linked placers; placers can be disabled +independently. + +Gold geometry and source links are captured with the world and require a +restart after structural changes. Installing this version does not silently +add gold rules to an existing world, reduce its gold supply or regenerate old +chunks. Existing catalogues and saved mine layouts are preserved. + +Use `/realisticdeposits preview realisticdeposits:overworld_gold_lodes` +for a read-only preview. The separate [gold walkthrough](../tools/qualification/GOLD_TESTING.md) +uses disposable saves for manual testing. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 45d309e..9143ee9 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -1,11 +1,11 @@ # Releasing Realistic Deposits -Coalfields use published OreSpawn 4.1.4.110021, pinned by file and checksum. +This candidate uses published OreSpawn 4.1.5.110021, pinned by file and checksum. 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.3.0.110021`; the final +branch. Enter the full numeric version, such as `0.4.0.110021`; the final component identifies the Minecraft version and loader. CurseForge's alpha, beta or release label is separate from the numeric version. diff --git a/gradle.properties b/gradle.properties index dd69f08..90c1bf1 100644 --- a/gradle.properties +++ b/gradle.properties @@ -22,16 +22,16 @@ gradle_java_version=17 mod_id=realisticdeposits mod_name=Realistic Deposits mod_license=LGPL-2.1 -mod_version=0.3.0.110021 +mod_version=0.4.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. -orespawn_version=4.1.4.110021 -orespawn_version_range=[4.1.4.110021,5.0.0) +orespawn_version=4.1.5.110021 +orespawn_version_range=[4.1.5.110021,5.0.0) orespawn_curse_project_id=245586 -orespawn_curse_file_id=9356289 -orespawn_sha256=DB254E9E865685A74262DE9D469A006AB3C8053573CC6136FD4A8591AD806CA3 +orespawn_curse_file_id=9397156 +orespawn_sha256=1E580EBB8D93B1256E2E81C7D0D815DEAAAC58A028CB5A77A52773B62048E010 curseforge_project_slug=realisticdeposits diff --git a/gradle/gold-manual.gradle b/gradle/gold-manual.gradle new file mode 100644 index 0000000..68d7ef8 --- /dev/null +++ b/gradle/gold-manual.gradle @@ -0,0 +1,42 @@ +// Gold testing has its own settings and saves, away from ordinary development. +tasks.named('isolateEclipseProductionRuns') { + doLast { + [['runClient.launch', 'runGoldDepositsClient.launch'], + ['runServer.launch', 'runGoldDepositsServer.launch']].each { pair -> + String contents = file(pair[0]).getText('UTF-8') + contents = contents.replaceAll( + /()/, + '$1' + file('run-gold-deposits').absolutePath.replace('\\', '\\\\') + '$2') + file(pair[1]).setText(contents.replace('\r\n', '\n'), 'UTF-8') + } + } +} + +tasks.named('verifyEclipseProductionClasspath') { + doLast { + ['runGoldDepositsClient.launch', 'runGoldDepositsServer.launch'].each { name -> + String contents = file(name).getText('UTF-8').replace('\\', '/') + if (!contents.contains('run-gold-deposits') || !contents.contains('ATTR_EXCLUDE_TEST_CODE')) { + throw new GradleException("Gold launch is not isolated: ${name}") + } + } + } +} + +tasks.register('prepareGoldDepositsManual') { + group = 'verification' + description = 'Prepares separate gold-testing settings and Eclipse launches.' + dependsOn tasks.named('verifyEclipseProductionClasspath') + doLast { + ['vanilla', 'coal', 'diamonds', 'gold'].each { name -> + File target = file("run-gold-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 runGoldDepositsClient.launch and tools/qualification/GOLD_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 63f4e76..33d0fc9 100644 --- a/gradle/release.gradle +++ b/gradle/release.gradle @@ -13,7 +13,7 @@ tasks.register('verifyPublicationAllowed') { group = 'verification' doLast { if (project.oreSpawnCandidateMode) { - throw new GradleException('Local OreSpawn candidates cannot be published. Pin a separately released exact-host API first.') + throw new GradleException('Local OreSpawn candidates cannot be published. Pin a public OreSpawn release first.') } } } @@ -81,7 +81,7 @@ tasks.register('verifyJavadocOrdering') { tasks.register('verifyReleaseConfiguration') { group = 'verification' doLast { - if (project.mod_version != '0.3.0.110021' + if (project.mod_version != '0.4.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 2c46c99..6b0a35c 100644 --- a/src/main/java/zone/moddev/mc/realisticdeposits/RealisticDeposits.java +++ b/src/main/java/zone/moddev/mc/realisticdeposits/RealisticDeposits.java @@ -6,6 +6,7 @@ import zone.moddev.mc.realisticdeposits.generation.CoalMeasuresPattern; 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.content.ModContent; import zone.moddev.mc.realisticdeposits.integration.OreSpawnIntegration; import zone.moddev.mc.realisticdeposits.proxy.CommonProxy; @@ -32,12 +33,12 @@ */ @Mod(modid = RealisticDeposits.MOD_ID, name = RealisticDeposits.NAME, version = RealisticDeposits.VERSION, acceptedMinecraftVersions = "[1.10.2]", - dependencies = "required-after:orespawn@[4.1.4.110021,5.0.0)", + dependencies = "required-after:orespawn@[4.1.5.110021,5.0.0)", guiFactory = "zone.moddev.mc.realisticdeposits.client.RealisticDepositsGuiFactory") public final class RealisticDeposits { public static final String MOD_ID = "realisticdeposits"; public static final String NAME = "Realistic Deposits"; - public static final String VERSION = "0.3.0.110021"; + public static final String VERSION = "0.4.0.110021"; private static final OrePatternType STRATIFORM_SEAM = StratiformSeamPattern.type() .setRegistryName(OreSpawnIntegration.PATTERN_ID); private static final OrePatternType COAL_MEASURES = CoalMeasuresPattern.type() @@ -46,6 +47,10 @@ public final class RealisticDeposits { .setRegistryName(OreSpawnIntegration.DIAMOND_PIPE_ID); private static final OrePatternType DIAMOND_PLACER = DiamondPattern.type(true) .setRegistryName(OreSpawnIntegration.DIAMOND_PLACER_ID); + private static final OrePatternType GOLD_LODE = GoldPattern.type(false) + .setRegistryName(OreSpawnIntegration.GOLD_LODE_ID); + private static final OrePatternType GOLD_PLACER = GoldPattern.type(true) + .setRegistryName(OreSpawnIntegration.GOLD_PLACER_ID); @SidedProxy(clientSide = "zone.moddev.mc.realisticdeposits.proxy.ClientProxy", serverSide = "zone.moddev.mc.realisticdeposits.proxy.CommonProxy") @@ -62,6 +67,8 @@ public void registerPatterns(RegistryEvent.Register event) { event.getRegistry().register(COAL_MEASURES); event.getRegistry().register(DIAMOND_PIPE); event.getRegistry().register(DIAMOND_PLACER); + event.getRegistry().register(GOLD_LODE); + event.getRegistry().register(GOLD_PLACER); } @Mod.EventHandler @@ -84,6 +91,7 @@ public void initialize(FMLInitializationEvent event) { public void serverAboutToStart(FMLServerAboutToStartEvent event) { CoalMeasuresPattern.newServerSession(); DiamondPattern.newServerSession(); + GoldPattern.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 9eed4e8..4f14ffb 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,14 @@ 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.isGold()) { + if (!(world instanceof net.minecraft.world.WorldServer)) { + throw new CommandException("Gold previews require a server world"); + } + sender.sendMessage(new TextComponentString(zone.moddev.mc.realisticdeposits.generation.GoldPattern + .preview(definition, (net.minecraft.world.WorldServer) world, regionX, regionZ))); + return; + } if (definition.isDiamond()) { if (!(world instanceof net.minecraft.world.WorldServer)) { throw new CommandException("Diamond 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 f5c0ba8..6f3592a 100644 --- a/src/main/java/zone/moddev/mc/realisticdeposits/config/DepositCatalogLoader.java +++ b/src/main/java/zone/moddev/mc/realisticdeposits/config/DepositCatalogLoader.java @@ -22,6 +22,8 @@ import zone.moddev.mc.realisticdeposits.model.CoalMeasuresSettings; import zone.moddev.mc.realisticdeposits.model.DiamondPipeSettings; 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.IntRange; import zone.moddev.mc.realisticdeposits.model.InteractionPolicy; import zone.moddev.mc.realisticdeposits.model.StratiformSeamSettings; @@ -111,6 +113,24 @@ private static DepositDefinition definition(JsonObject json, String path, List warnings) { String type = string(shape, "type", path + " shape"); + if ("gold_lode".equals(type)) { + unknownKeys(shape, keys("type", "veins", "strike_length", "thickness", "surface_cover", + "dip_degrees", "legacy_selection_scale"), path + " shape", warnings); + double[] dip = doubleRange(shape, "dip_degrees", path); + return new GoldLodeSettings(integerRange(shape, "veins", path), + integerRange(shape, "strike_length", path), integerRange(shape, "thickness", path), + integerRange(shape, "surface_cover", path), dip[0], dip[1], + number(shape, "legacy_selection_scale", path)); + } + + if ("gold_placer".equals(type)) { + unknownKeys(shape, keys("type", "source_deposit", "maximum_source_distance", "thickness", + "coverage"), path + " shape", warnings); + return new GoldPlacerSettings(string(shape, "source_deposit", path), + integer(shape, "maximum_source_distance", path), integerRange(shape, "thickness", path), + number(shape, "coverage", path)); + } + if ("diamond_pipe".equals(type)) { unknownKeys(shape, keys("type", "pipes", "upper_diameter", "root_y", "surface_cover", "district_radius", "ore_density", "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 3e0c43f..19167ec 100644 --- a/src/main/java/zone/moddev/mc/realisticdeposits/config/RealisticDepositsRuntime.java +++ b/src/main/java/zone/moddev/mc/realisticdeposits/config/RealisticDepositsRuntime.java @@ -79,6 +79,7 @@ private static void reloadCatalogs(boolean diagnostics) { createDefault(defaultCatalog); 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"); List files = jsonFiles(deposits); List catalogs = new ArrayList<>(); List definitions = new ArrayList<>(); @@ -189,6 +190,7 @@ private static synchronized void warnStructuralReload(String definitionId) { private static void validateResourcePolicies(List definitions) { zone.moddev.mc.realisticdeposits.integration.OreSpawnIntegration.validateDiamondLinks(definitions); + zone.moddev.mc.realisticdeposits.integration.OreSpawnIntegration.validateGoldLinks(definitions); Map contracts = new LinkedHashMap<>(); for (DepositDefinition definition : definitions) { if (!definition.enabled()) continue; @@ -252,7 +254,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) -> "diamonds.json".equals(file.getName()) ? 2 + result.sort(Comparator.comparingInt((File file) -> "gold.json".equals(file.getName()) ? 3 + : "diamonds.json".equals(file.getName()) ? 2 : "coal.json".equals(file.getName()) ? 1 : 0) .thenComparing(File::getPath)); return result; diff --git a/src/main/java/zone/moddev/mc/realisticdeposits/content/GoldGravelBlock.java b/src/main/java/zone/moddev/mc/realisticdeposits/content/GoldGravelBlock.java new file mode 100644 index 0000000..f013a87 --- /dev/null +++ b/src/main/java/zone/moddev/mc/realisticdeposits/content/GoldGravelBlock.java @@ -0,0 +1,38 @@ +package zone.moddev.mc.realisticdeposits.content; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +import net.minecraft.block.BlockGravel; +import net.minecraft.block.state.IBlockState; +import net.minecraft.creativetab.CreativeTabs; +import net.minecraft.init.Blocks; +import net.minecraft.init.Items; +import net.minecraft.item.ItemStack; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.IBlockAccess; + +/** Mining consumes the rich sediment; the ordinary gravel left behind is spent. */ +public final class GoldGravelBlock extends BlockGravel { + public GoldGravelBlock() { + setRegistryName("realisticdeposits", "gold_gravel"); + setUnlocalizedName("realisticdeposits.gold_gravel"); + setCreativeTab(CreativeTabs.BUILDING_BLOCKS); + } + + @Override + public List getDrops(IBlockAccess world, BlockPos pos, IBlockState state, int fortune) { + List drops = new ArrayList<>(); + drops.add(new ItemStack(Blocks.GRAVEL)); + int nuggets = nuggetYield(RANDOM, fortune); + if (nuggets > 0) drops.add(new ItemStack(Items.GOLD_NUGGET, nuggets)); + return drops; + } + + public static int nuggetYield(Random random, int fortune) { + if (random.nextInt(4) != 0) return 0; + int bonus = fortune > 0 ? Math.max(0, random.nextInt(Math.min(fortune, 255) + 2) - 1) : 0; + return 1 + bonus; + } +} diff --git a/src/main/java/zone/moddev/mc/realisticdeposits/content/ModContent.java b/src/main/java/zone/moddev/mc/realisticdeposits/content/ModContent.java index 28bcc3c..55bb5ad 100644 --- a/src/main/java/zone/moddev/mc/realisticdeposits/content/ModContent.java +++ b/src/main/java/zone/moddev/mc/realisticdeposits/content/ModContent.java @@ -3,15 +3,18 @@ import net.minecraft.item.ItemBlock; import net.minecraftforge.fml.common.registry.GameRegistry; -/** Registry identity stays the same even when every diamond rule is disabled. */ +/** Sediment blocks remain registered even when their generation rules are disabled. */ public final class ModContent { public static final DiamondGravelBlock DIAMOND_GRAVEL = new DiamondGravelBlock(); + public static final GoldGravelBlock GOLD_GRAVEL = new GoldGravelBlock(); private ModContent() { } public static void register() { GameRegistry.register(DIAMOND_GRAVEL); GameRegistry.register(new ItemBlock(DIAMOND_GRAVEL).setRegistryName(DIAMOND_GRAVEL.getRegistryName())); + GameRegistry.register(GOLD_GRAVEL); + GameRegistry.register(new ItemBlock(GOLD_GRAVEL).setRegistryName(GOLD_GRAVEL.getRegistryName())); } } diff --git a/src/main/java/zone/moddev/mc/realisticdeposits/generation/GoldGeology.java b/src/main/java/zone/moddev/mc/realisticdeposits/generation/GoldGeology.java new file mode 100644 index 0000000..6b11d11 --- /dev/null +++ b/src/main/java/zone/moddev/mc/realisticdeposits/generation/GoldGeology.java @@ -0,0 +1,56 @@ +package zone.moddev.mc.realisticdeposits.generation; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Map; +import java.util.Optional; +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.server.MinecraftServer; +import net.minecraft.util.ResourceLocation; + +import zone.moddev.mc.orespawn.api.GeologyProfileView; +import zone.moddev.mc.orespawn.api.OreSpawnApi; + +/** Exact natural hosts for the first lode model; quartz carrier rock comes later. */ +final class GoldGeology { + private static final Set ROCKS = Collections.unmodifiableSet(new HashSet<>(Arrays.asList( + "mineralogy:slate", "mineralogy:phyllite", "mineralogy:schist", "mineralogy:amphibolite", + "mineralogy:gneiss", "mineralogy:hornfels", "mineralogy:shale", "mineralogy:siltstone", + "mineralogy:chert", "minecraft:sandstone"))); + + private GoldGeology() { } + + static Set hosts(MinecraftServer server) { + Set hosts = new HashSet<>(); + for (int metadata : new int[] {0, 1, 3, 5}) hosts.add(Blocks.STONE.getStateFromMeta(metadata)); + Optional profile = OreSpawnApi.getActiveProfile(server); + if (!profile.isPresent()) return hosts; + + JsonObject json = profile.get().toJson(); + if (!json.has("rocks")) return hosts; + for (Map.Entry entry : json.getAsJsonObject("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)) continue; + + ResourceLocation blockId = new ResourceLocation(id); + if (Block.REGISTRY.containsKey(blockId)) hosts.add(Block.REGISTRY.getObject(blockId).getStateFromMeta(metadata)); + } + return hosts; + } + + static boolean permittedRock(String id, int metadata) { + if ("minecraft:stone".equals(id)) return metadata == 0 || metadata == 1 || metadata == 3 || metadata == 5; + return metadata == 0 && ROCKS.contains(id); + } +} diff --git a/src/main/java/zone/moddev/mc/realisticdeposits/generation/GoldPattern.java b/src/main/java/zone/moddev/mc/realisticdeposits/generation/GoldPattern.java new file mode 100644 index 0000000..6898d9a --- /dev/null +++ b/src/main/java/zone/moddev/mc/realisticdeposits/generation/GoldPattern.java @@ -0,0 +1,368 @@ +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.material.Material; +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.biome.Biome; +import net.minecraft.world.chunk.Chunk; +import net.minecraftforge.common.BiomeDictionary; +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.GoldDistrict; +import zone.moddev.mc.realisticdeposits.model.DepositDefinition; +import zone.moddev.mc.realisticdeposits.model.AlignmentMode; + +/** OreSpawn owns the pass; these patterns only place their current chunk's slice. */ +public final class GoldPattern { + private static int epoch; + private static boolean warnedTerrain; + private static boolean warnedGeology; + + private GoldPattern() { } + + public static OrePatternType type(boolean placer) { + return OrePatternType.create(Codec.of(input -> { + if (input == null || !input.isJsonObject()) { + throw new IllegalArgumentException("Gold settings need a captured definition"); + } + JsonObject json = input.getAsJsonObject(); + DepositDefinition definition = DepositCatalogLoader.loadDefinition( + json.getAsJsonObject("definition"), "OreSpawn gold profile"); + if (placer != definition.isGoldPlacer() || !definition.isGold()) { + throw new IllegalArgumentException("Gold pattern and shape do not match"); + } + DepositDefinition source = placer ? DepositCatalogLoader.loadDefinition( + json.getAsJsonObject("source_definition"), "OreSpawn placer source") : definition; + if (!source.isGoldLode() || (placer && !source.id().equals(definition.goldPlacer().sourceDeposit()))) { + throw new IllegalArgumentException("The placer needs its captured gold-lode source"); + } + return new Captured(enabledForCallback(definition), enabledForCallback(source)); + }), captured -> { + Renderer renderer = new Renderer(captured); + return base -> { + if (!(base instanceof OreGenerationContext)) { + throw new IllegalStateException("Gold requires OreSpawn's extended generation context"); + } + return renderer.place((OreGenerationContext) base); + }; + }); + } + + public static void newServerSession() { + epoch++; + warnedTerrain = false; + warnedGeology = false; + } + + static DepositDefinition enabledForCallback(DepositDefinition definition) { + // OreSpawn calls the pattern only for an enabled rule. Its saved switch + // takes precedence over the catalogue default captured inside the settings. + if (definition.enabled()) return definition; + JsonObject json = definition.toJson(); + json.addProperty("enabled", true); + return DepositCatalogLoader.loadDefinition(json, "enabled world gold rule"); + } + + private static final class Captured { + final DepositDefinition definition; + final DepositDefinition source; + + Captured(DepositDefinition definition, DepositDefinition source) { + this.definition = definition; + this.source = source; + } + } + + private static final class Renderer { + private final Captured captured; + private final Map> districts = + new LinkedHashMap>() { + @Override + protected boolean removeEldestEntry(Map.Entry> entry) { + return size() > 128; + } + }; + private int observedEpoch = -1; + private long observedSeed; + private DepositDefinition source; + private Set hosts; + + Renderer(Captured captured) { + this.captured = captured; + } + + boolean place(OreGenerationContext context) { + DepositDefinition definition = captured.definition; + if (!definition.enabled() || !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("Gold placement has no active Overworld"); + Chunk chunk = world.getChunkProvider().getLoadedChunk(context.chunkX(), context.chunkZ()); + if (chunk == null) throw new IllegalStateException("Gold placement may only inspect its loaded chunk"); + + if (observedEpoch != epoch || observedSeed != context.worldSeed()) { + districts.clear(); + observedEpoch = epoch; + observedSeed = context.worldSeed(); + source = definition.isGoldPlacer() ? activeSource(server, captured.source) : captured.source; + hosts = GoldGeology.hosts(server); + } + if (source == null || !source.enabled()) return false; + + List structures = CoalStructureProtection.inChunk(context.chunkX(), context.chunkZ()); + int count = definition.isGoldLode() ? lodes(context, world, chunk, structures) + : placer(context, world, chunk, structures); + if (count > 0) RealisticDepositsRuntime.reportFirstGeneration(definition.id(), + context.dimension().toString(), context.chunkX(), context.chunkZ(), count); + return count > 0; + } + + private Optional district(OreGenerationContext context, WorldServer world, int rx, int rz) { + long key = ((long) rx << 32) ^ (rz & 0xffffffffL); + Optional result = districts.get(key); + if (result != null) return result; + + MineTerrainSampler terrain; + try { + terrain = new MineTerrainSampler(world, world.getChunkProvider().chunkGenerator); + } catch (IllegalArgumentException unsupported) { + if (!warnedTerrain) { + warnedTerrain = true; + LogManager.getLogger("Realistic Deposits").warn("Gold deposits cannot safely sample this terrain generator. " + + "No neighbouring chunks will be generated to guess a site. " + + "Disable the lode rule in OreSpawn if you want to keep the ordinary gold supply."); + } + result = Optional.empty(); + districts.put(key, result); + return result; + } + + Optional sampler = context.geologySampler(); + result = GoldDistrict.plan(source, context.worldSeed(), context.dimension().toString(), rx, rz, + new GoldDistrict.Site() { + @Override public int surfaceY(int x, int z) { return terrain.naturalSurfaceY(x, z); } + + @Override + public String geome(int x, int z, int surface) { + String id = sampler.isPresent() ? sampler.get().sampleColumn(x, z, surface).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. " + + "Gold districts use the configured rarity-only approximation; geology settings are unchanged."); + } + return id; + } + }); + if (result.isPresent() && CoalGeology.active().physicalStrata) { + if (!sampler.isPresent() || !productiveHosts(result.get(), sampler.get())) result = Optional.empty(); + } + districts.put(key, result); + return result; + } + + private boolean productiveHosts(GoldDistrict district, GeologySampler sampler) { + for (int vein = 0; vein < district.veins().size(); vein++) { + for (int offset = -1; offset <= 1; offset++) { + zone.moddev.mc.realisticdeposits.geometry.MinePoint point = district.pointOnVein( + vein, offset * district.veins().get(vein).length() * 0.25, district.centerY()); + GeologyColumn column = sampler.sampleColumn(point.x(), point.z(), district.bounds().maximumY()); + if (hosts.contains(column.rockAt(point.y()))) return true; + } + } + return false; + } + + private int lodes(OreGenerationContext context, WorldServer world, Chunk chunk, List structures) { + int minX = context.chunkX() << 4; + int minZ = context.chunkZ() << 4; + int radius = source.lode().strikeLength().maximum() / 2 + 192; + int size = source.regionSpacingChunks() * 16; + int count = 0; + boolean physical = CoalGeology.active().physicalStrata; + 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++) { + Optional selected = district(context, world, rx, rz); + if (!selected.isPresent()) continue; + + GoldDistrict district = selected.get(); + BlockBounds bounds = district.bounds(); + if (source.abandonedMine().enabled()) MineWorkingsGenerator.offer(context, source, district); + 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) || !naturalRock(source, state, physical, + columns, index, context, x, y, z, surface)) continue; + if (context.tryPlace(x, y, z, district.identity().seed())) count++; + } + } + } + } + } + return count; + } + + private int placer(OreGenerationContext context, WorldServer world, Chunk chunk, List structures) { + DepositDefinition definition = captured.definition; + int minX = context.chunkX() << 4; + int minZ = context.chunkZ() << 4; + int reach = definition.goldPlacer().maximumSourceDistance(); + int size = source.regionSpacingChunks() * 16; + int search = reach + (source.lode().strikeLength().maximum() / 2 + 96); + java.util.ArrayList nearby = new java.util.ArrayList<>(); + + Biome[] biomes = world.provider.getBiomeProvider().getBiomes(null, minX, minZ, 16, 16); + boolean possibleRiver = false; + for (Biome biome : biomes) { + if (BiomeDictionary.isBiomeOfType(biome, BiomeDictionary.Type.RIVER)) { + possibleRiver = true; + break; + } + } + if (!possibleRiver) return 0; + + for (int rx = Math.floorDiv(minX - search, size); rx <= Math.floorDiv(minX + 15 + search, size); rx++) { + for (int rz = Math.floorDiv(minZ - search, size); rz <= Math.floorDiv(minZ + 15 + search, size); rz++) { + Optional candidate = district(context, world, rx, rz); + if (candidate.isPresent()) nearby.add(candidate.get()); + } + } + if (nearby.isEmpty()) return 0; + + int count = 0; + BlockPos.MutableBlockPos position = new BlockPos.MutableBlockPos(); + for (int x = minX; x < minX + 16; x++) { + for (int z = minZ; z < minZ + 16; z++) { + Biome biome = biomes[(x & 15) + (z & 15) * 16]; + if (!BiomeDictionary.isBiomeOfType(biome, BiomeDictionary.Type.RIVER)) continue; + GoldDistrict nearest = null; + double distance = (double) reach * reach; + for (GoldDistrict candidate : nearby) { + double current = candidate.distanceSquared(x, z); + if (current < distance) { nearest = candidate; distance = current; } + } + if (nearest == null) continue; + + int y = Math.min(255, chunk.getHeightValue(x & 15, z & 15) - 1); + IBlockState state = chunk.getBlockState(position.setPos(x, y, z)); + if (state.getMaterial() != Material.WATER) continue; + while (y > definition.minimumY() && state.getMaterial() == Material.WATER) { + state = chunk.getBlockState(position.setPos(x, --y, z)); + } + int thickness = definition.goldPlacer().thickness().minimum() + + (int) Math.floorMod(nearest.identity().seed() ^ x ^ ((long) z << 32), + definition.goldPlacer().thickness().maximum() - definition.goldPlacer().thickness().minimum() + 1); + for (int depth = 0; depth < thickness && y - depth >= definition.minimumY(); depth++) { + int py = y - depth; + if (py > definition.maximumY()) continue; + state = chunk.getBlockState(position.setPos(x, py, z)); + if (state.getBlock() != Blocks.GRAVEL && state.getBlock() != Blocks.SAND) break; + IBlockState below = chunk.getBlockState(position.setPos(x, py - 1, z)); + if (!below.getMaterial().isSolid() || CoalStructureProtection.contains(structures, x, py, z)) continue; + if (GoldDistrict.placerPocket(nearest.identity().seed() ^ definition.salt(), x, z, + depth, definition.goldPlacer().coverage()) && context.inside(x, py, z) + && context.tryPlace(x, py, z, nearest.identity().seed())) count++; + } + } + } + return count; + } + } + + private static boolean naturalRock(DepositDefinition definition, IBlockState state, boolean physical, GeologyColumn[] columns, + int index, OreGenerationContext context, int x, int y, int z, int surface) { + if (state.getMaterial() != Material.ROCK || state.getBlock().hasTileEntity(state)) return false; + if (physical && context.geologySampler().isPresent()) { + if (columns[index] == null) columns[index] = context.geologySampler().get().sampleColumn(x, z, surface); + if (!columns[index].familyAt(y).isPresent() || !state.equals(columns[index].rockAt(y))) return false; + return definition.alignment() != AlignmentMode.ROCK_ALIGNED + || definition.rockFamilies().contains(columns[index].familyAt(y).get()); + } + if (state.getBlock() != Blocks.STONE) return false; + int meta = Blocks.STONE.getMetaFromState(state); + return meta == 0 || meta == 1 || meta == 3 || meta == 5; + } + + private static DepositDefinition activeSource(MinecraftServer server, DepositDefinition fallback) { + if (fallback == null) return null; + Optional profile = OreSpawnApi.getActiveProfile(server); + if (!profile.isPresent()) return null; + JsonObject root = profile.get().toJson(); + if (!root.has("ores")) return null; + JsonObject ores = root.getAsJsonObject("ores"); + String key = "realisticdeposits:deposit/" + fallback.id().replace(':', '/'); + if (!ores.has(key)) return null; + JsonObject rule = ores.getAsJsonObject(key); + if (rule.has("enabled") && !rule.get("enabled").getAsBoolean()) return null; + JsonObject dimensions = rule.getAsJsonObject("dimensions"); + if (dimensions == null || !dimensions.has("minecraft:overworld")) return null; + JsonObject dimension = dimensions.getAsJsonObject("minecraft:overworld"); + if (dimension.has("enabled") && !dimension.get("enabled").getAsBoolean()) return null; + if (!dimension.has("pattern") || !dimension.get("pattern").isJsonObject()) return null; + JsonObject pattern = dimension.getAsJsonObject("pattern"); + if (!"realisticdeposits:gold_lode".equals(pattern.get("type").getAsString())) return null; + DepositDefinition source = DepositCatalogLoader.loadDefinition(pattern.getAsJsonObject("settings") + .getAsJsonObject("definition"), "world-captured gold source"); + return source.isGoldLode() ? enabledForCallback(source) : null; + } + + public static String preview(DepositDefinition definition, WorldServer world, int rx, int rz) { + DepositDefinition source = definition.isGoldLode() ? definition + : activeSource(world.getMinecraftServer(), RealisticDepositsRuntime.state() + .definition(definition.goldPlacer().sourceDeposit())); + if (source == null) return "No active captured lode source is available for this placer."; + MineTerrainSampler terrain; + try { + terrain = new MineTerrainSampler(world, world.getChunkProvider().chunkGenerator); + } catch (IllegalArgumentException unsupported) { + return "This terrain generator does not support safe gold-site previews."; + } + Optional sampler = OreSpawnApi.createSampler(world); + Optional district = GoldDistrict.plan(source, world.getSeed(), "minecraft:overworld", rx, rz, + new GoldDistrict.Site() { + @Override public int surfaceY(int x, int z) { return terrain.naturalSurfaceY(x, z); } + @Override public String geome(int x, int z, int surface) { + return sampler.isPresent() ? sampler.get().sampleColumn(x, z, surface).geome().toString() : null; + } + }); + return district.isPresent() ? "Gold district at " + district.get().centerX() + "," + + district.get().centerZ() + "; veins=" + district.get().veins().size() + + ". River placers also require suitable submerged sediment nearby. Preview changes no blocks." + : "No productive gold district is selected in this region."; + } +} diff --git a/src/main/java/zone/moddev/mc/realisticdeposits/geometry/GoldDistrict.java b/src/main/java/zone/moddev/mc/realisticdeposits/geometry/GoldDistrict.java new file mode 100644 index 0000000..5e66191 --- /dev/null +++ b/src/main/java/zone/moddev/mc/realisticdeposits/geometry/GoldDistrict.java @@ -0,0 +1,237 @@ +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.GoldLodeSettings; + +/** Steep fractures with plunging ore shoots, planned independently of mined blocks. */ +public final class GoldDistrict implements MineableDepositBody { + public interface Site { + int surfaceY(int x, int z); + String geome(int x, int z, int surfaceY); + } + + private final DepositIdentity identity; + private final int x; + private final int y; + private final int z; + private final int bottom; + private final int top; + private final int cover; + private final double cosine; + private final double sine; + private final List veins; + private final BlockBounds bounds; + + private GoldDistrict(DepositIdentity identity, int x, int z, int bottom, int top, + int cover, Random random, GoldLodeSettings settings) { + this.identity = identity; + this.x = x; + this.z = z; + this.bottom = bottom; + this.top = top; + this.y = (bottom + top) / 2; + this.cover = cover; + + double angle = random.nextDouble() * Math.PI * 2; + cosine = Math.cos(angle); + sine = Math.sin(angle); + List planned = new ArrayList<>(); + int count = settings.veinCount().sample(random); + for (int index = 0; index < count; index++) { + planned.add(new Vein(StableHash.combine(identity.seed(), index), + settings.strikeLength().sample(random), settings.thickness().sample(random), + settings.minimumDip() + random.nextDouble() * (settings.maximumDip() - settings.minimumDip()), + index == 0 ? 0 : (index % 2 == 0 ? 1 : -1) * (10 + random.nextDouble() * 22))); + } + veins = Collections.unmodifiableList(planned); + + double along = settings.strikeLength().maximum() * 0.5 + 2; + double across = (top - bottom) * 0.5 / Math.tan(Math.toRadians(settings.minimumDip())) + 64; + int reachX = (int) Math.ceil(Math.abs(cosine) * along + Math.abs(sine) * across); + int reachZ = (int) Math.ceil(Math.abs(sine) * along + Math.abs(cosine) * across); + bounds = new BlockBounds(x - reachX, bottom, z - reachZ, x + reachX, top, z + reachZ); + } + + public static Optional plan(DepositDefinition definition, long worldSeed, + String dimension, int regionX, int regionZ, Site site) { + if (!definition.isGoldLode() || !definition.enabled() || !definition.dimensions().contains(dimension)) { + return Optional.empty(); + } + + DepositIdentity identity = new DepositIdentity(worldSeed, dimension, definition.id(), + regionX, regionZ, definition.salt()); + if (StableHash.unit(identity.seed() ^ 0x41C784B2A39F518DL) >= definition.selectionChance()) { + return Optional.empty(); + } + + Random random = new Random(identity.seed()); + 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); + if (surface < 0) return Optional.empty(); + + GoldLodeSettings settings = definition.lode(); + String geome = site.geome(x, z, surface); + if (definition.alignment() != AlignmentMode.MASSIVE_RARE) { + boolean approximate = geome == null || "orespawn:cyano".equals(geome); + if (approximate) { + if (StableHash.unit(identity.seed() ^ 0x11A9F6B2E8138A5CL) >= settings.legacySelectionScale()) { + return Optional.empty(); + } + } else if (!definition.geomes().contains(geome)) { + return Optional.empty(); + } else if (!"orespawn:mountain_belt".equals(geome) + && StableHash.unit(identity.seed() ^ 0x2F54D932E77145A1L) >= 0.5) { + return Optional.empty(); + } + } + + int cover = settings.surfaceCover().sample(random); + int top = Math.min(definition.maximumY(), surface - cover); + int bottom = definition.minimumY(); + if (top - bottom < 24) return Optional.empty(); + + return Optional.of(new GoldDistrict(identity, x, z, bottom, top, cover, random, settings)); + } + + @Override public DepositIdentity identity() { return identity; } + @Override public int centerX() { return x; } + @Override public int centerY() { return y; } + @Override public int centerZ() { return z; } + @Override public int strikeLength() { return veins.get(0).length; } + @Override public BlockBounds bounds() { return bounds; } + public List veins() { return veins; } + public int surfaceCover() { return cover; } + + public MinePoint pointOnVein(int index, double along, int height) { + Vein vein = veins.get(index); + double across = vein.midline(along, 0) + (height - y) * vein.cotangent; + return new MinePoint((int) Math.floor(x + along * cosine - across * sine), height, + (int) Math.floor(z + along * sine + across * cosine)); + } + + public double alongAt(double blockX, double blockZ) { + return (blockX - x) * cosine + (blockZ - z) * sine; + } + + public double acrossAt(double blockX, double blockZ) { + return -(blockX - x) * sine + (blockZ - z) * cosine; + } + + @Override + public boolean isOreAt(int blockX, int blockY, int blockZ) { + if (blockY < bottom || blockY > top) return false; + double along = alongAt(blockX + 0.5, blockZ + 0.5); + double across = acrossAt(blockX + 0.5, blockZ + 0.5); + for (Vein vein : veins) { + if (vein.isOreAt(along, across, blockY - y, blockY - bottom, top - blockY)) return true; + } + return false; + } + + @Override public int minimumCandidateYAt(int blockX, int blockZ) { + return candidateY(blockX, blockZ, true); + } + + @Override public int maximumCandidateYAt(int blockX, int blockZ) { + return candidateY(blockX, blockZ, false); + } + + private int candidateY(int blockX, int blockZ, boolean minimum) { + double along = alongAt(blockX + 0.5, blockZ + 0.5); + double across = acrossAt(blockX + 0.5, blockZ + 0.5); + double result = minimum ? 256 : -1; + for (Vein vein : veins) { + if (Math.abs(along) > vein.length * 0.5) continue; + for (int branch = 0; branch < 2; branch++) { + double centre = y + (across - vein.midline(along, branch)) / vein.cotangent; + double margin = (vein.width * 0.5 + 0.75) / vein.cotangent; + result = minimum ? Math.min(result, centre - margin) : Math.max(result, centre + margin); + } + } + return minimum ? Math.max(bottom, (int) Math.floor(result)) : Math.min(top, (int) Math.ceil(result)); + } + + public double distanceSquared(int blockX, int blockZ) { + double along = Math.max(0, Math.abs(alongAt(blockX, blockZ)) - strikeLength() * 0.5); + double across = acrossAt(blockX, blockZ); + return along * along + across * across; + } + + @Override + public MinePoint miningRoutePoint(double along, double lateralOffset) { + Vein vein = veins.get(0); + // Aim the short gallery at a rich shoot, not an arbitrary lean stretch. + double phase = Math.atan2(Math.sin(vein.phase), Math.cos(vein.phase)); + double forward = Math.max(-vein.length * 0.45, Math.min(vein.length * 0.45, along - phase * 31)); + double side = vein.midline(forward, 0) + lateralOffset; + return new MinePoint((int) Math.floor(x + forward * cosine - side * sine), y, + (int) Math.floor(z + forward * sine + side * cosine)); + } + + public static boolean placerPocket(long seed, int x, int z, int depth, double coverage) { + long patch = StableHash.combine(StableHash.combine(seed, Math.floorDiv(x, 8)), Math.floorDiv(z, 8)); + long grain = StableHash.combine(StableHash.combine(seed ^ 0x11797A38E1D72B83L, x), z); + return StableHash.unit(patch) < coverage + && StableHash.unit(StableHash.combine(grain, depth)) < 0.75 - depth * 0.15; + } + + /** Ore shoots plunge within each fracture; lean runs remain mostly country rock. */ + public static final class Vein { + private final long seed; + private final int length; + private final int width; + private final double dip; + private final double offset; + private final double cotangent; + private final double phase; + + private Vein(long seed, int length, int width, double dip, double offset) { + this.seed = seed; + this.length = length; + this.width = width; + this.dip = dip; + this.offset = offset; + cotangent = 1 / Math.tan(Math.toRadians(dip)); + phase = StableHash.unit(seed) * Math.PI * 2; + } + + public int length() { return length; } + public int width() { return width; } + public double dipDegrees() { return dip; } + + private double midline(double along, int branch) { + double bend = 5 * Math.sin(along / 71 + phase) + 2 * Math.sin(along / 29 - phase); + double fault = 3 * Math.tanh((along - Math.sin(phase) * length * 0.15) / 6); + double splay = branch == 0 ? 0 : 14 * Math.pow(Math.max(0, + Math.sin((along / length + 0.5) * Math.PI)), 2); + return offset + bend + fault + splay; + } + + private boolean isOreAt(double along, double across, int height, int aboveBottom, int belowTop) { + if (Math.abs(along) >= length * 0.5) return false; + double edge = Math.min(1, (length * 0.5 - Math.abs(along)) / 20); + double pinch = 0.55 + 0.45 * Math.pow(Math.sin(along / 35 + phase + height / 45.0), 2); + double halfWidth = Math.max(0.4, width * 0.5 * pinch * edge); + boolean inside = false; + for (int branch = 0; branch < 2; branch++) { + if (Math.abs(across - midline(along, branch) - height * cotangent) <= halfWidth) inside = true; + } + if (!inside || aboveBottom < 1 || belowTop < 1) return false; + + double shoot = Math.pow(Math.max(0, Math.cos(along / 31 + height / 40.0 + phase)), 6); + if (shoot > 0.38) return true; + long cluster = StableHash.combine(StableHash.combine(seed, (long) Math.floor(along / 4)), + Math.floorDiv(height, 3)); + return shoot > 0.06 && StableHash.unit(cluster) < 0.12; + } + } +} 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 b3fdac1..83ffa08 100644 --- a/src/main/java/zone/moddev/mc/realisticdeposits/integration/OreSpawnIntegration.java +++ b/src/main/java/zone/moddev/mc/realisticdeposits/integration/OreSpawnIntegration.java @@ -19,6 +19,10 @@ public final class OreSpawnIntegration { new ResourceLocation("realisticdeposits", "diamond_pipe"); public static final ResourceLocation DIAMOND_PLACER_ID = new ResourceLocation("realisticdeposits", "diamond_placer"); + public static final ResourceLocation GOLD_LODE_ID = + new ResourceLocation("realisticdeposits", "gold_lode"); + public static final ResourceLocation GOLD_PLACER_ID = + new ResourceLocation("realisticdeposits", "gold_placer"); private OreSpawnIntegration() { } @@ -26,6 +30,7 @@ public static WorldgenProvider provider(Iterable definitions) java.util.Map indexed = new java.util.LinkedHashMap<>(); for (DepositDefinition definition : definitions) indexed.put(definition.id(), definition); validateDiamondLinks(indexed.values()); + validateGoldLinks(indexed.values()); WorldgenProvider.Builder provider = WorldgenProvider.builder("realisticdeposits", 1) .mergeNewEntriesIntoExistingWorlds(false); for (DepositDefinition definition : indexed.values()) add(provider, definition, indexed); @@ -52,6 +57,8 @@ private static ResourceLocation patternId(DepositDefinition definition) { case "coal_measures": return COAL_PATTERN_ID; case "diamond_pipe": return DIAMOND_PIPE_ID; case "diamond_placer": return DIAMOND_PLACER_ID; + case "gold_lode": return GOLD_LODE_ID; + case "gold_placer": return GOLD_PLACER_ID; default: throw new IllegalArgumentException("Unsupported shape: " + definition.shape().type()); } } @@ -60,6 +67,22 @@ public static boolean enqueue(Iterable definitions) { return OreSpawnApi.enqueue(provider(definitions)); } + public static void validateGoldLinks(Iterable definitions) { + java.util.Map indexed = new java.util.LinkedHashMap<>(); + for (DepositDefinition definition : definitions) indexed.put(definition.id(), definition); + + for (DepositDefinition definition : indexed.values()) { + if (!definition.isGoldPlacer()) continue; + DepositDefinition source = indexed.get(definition.goldPlacer().sourceDeposit()); + if (source == null || !source.isGoldLode() + || !source.dimensions().containsAll(definition.dimensions())) { + throw new IllegalArgumentException("Gold placer '" + definition.id() + + "' needs a gold-lode source in the same dimensions: " + + definition.goldPlacer().sourceDeposit()); + } + } + } + private static void add(WorldgenProvider.Builder provider, DepositDefinition definition, java.util.Map indexed) { ResourceLocation output = new ResourceLocation(definition.resource()); @@ -69,16 +92,19 @@ private static void add(WorldgenProvider.Builder provider, DepositDefinition def JsonObject patternSettings = new JsonObject(); patternSettings.add("definition", definition.toJson()); DepositDefinition source = definition.isDiamondPlacer() ? indexed.get(definition.placer().sourceDeposit()) : null; + if (definition.isGoldPlacer()) source = indexed.get(definition.goldPlacer().sourceDeposit()); if (source != null) patternSettings.add("source_definition", source.toJson()); + final DepositDefinition linkedSource = source; provider.ore(oreId, output, ore -> { ore.retrogen(false).suppressVanilla(false); - if (definition.isCoal() || definition.isDiamond()) { - ore.enabled(definition.enabled() && (source == null || source.enabled())); + if (definition.isCoal() || definition.isDiamond() || definition.isGold()) { + ore.enabled(definition.enabled() && (linkedSource == null || linkedSource.enabled())); } if (definition.isDiamondPlacer()) { // Sediment is not an interchangeable output for diamond-bearing rock. ore.material(new ResourceLocation("realisticdeposits", "diamond_gravel")); } + if (definition.isGoldPlacer()) ore.material(new ResourceLocation("realisticdeposits", "gold_gravel")); for (String dimensionId : definition.dimensions()) { ore.dimension(new ResourceLocation(dimensionId), dimension -> { // Keep the enclosing OreSpawn attempt broad: live geometry reload may @@ -87,11 +113,12 @@ private static void add(WorldgenProvider.Builder provider, DepositDefinition def .attempts(1.0D).quantity(1).spread(0, 0).nodeSize(1) .pattern(patternId(definition), patternSettings); for (String host : definition.hosts()) { - if (definition.isCoal() || definition.isDiamondPlacer()) { + if (definition.isCoal() || definition.isDiamondPlacer() || definition.isGoldPlacer()) { ResourceLocation id = new ResourceLocation(host); if (net.minecraft.block.Block.REGISTRY.containsKey(id)) dimension.hostState(id, 0); - if (definition.isDiamondPlacer() && "minecraft:sand".equals(host)) dimension.hostState(id, 1); - } else if (definition.isDiamondPipe() && "minecraft:stone".equals(host)) { + if ((definition.isDiamondPlacer() || definition.isGoldPlacer()) + && "minecraft:sand".equals(host)) dimension.hostState(id, 1); + } else if ((definition.isDiamondPipe() || definition.isGoldLode()) && "minecraft:stone".equals(host)) { for (int metadata : new int[] {0, 1, 3, 5}) { dimension.hostState(new ResourceLocation(host), metadata); } @@ -101,7 +128,7 @@ private static void add(WorldgenProvider.Builder provider, DepositDefinition def } // 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()) { + if (!definition.isCoal() && !definition.isDiamondPlacer() && !definition.isGoldPlacer()) { 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 dd8ac2d..5216cd4 100644 --- a/src/main/java/zone/moddev/mc/realisticdeposits/model/DepositDefinition.java +++ b/src/main/java/zone/moddev/mc/realisticdeposits/model/DepositDefinition.java @@ -113,12 +113,15 @@ public DepositDefinition(String id, boolean enabled, String resource, this.salt = salt; this.shape = Objects.requireNonNull(seam, "shape"); this.abandonedMine = Objects.requireNonNull(abandonedMine, "abandonedMine"); - if (isDiamondPlacer() && abandonedMine.enabled()) { + if ((isDiamondPlacer() || isGoldPlacer()) && abandonedMine.enabled()) { throw new IllegalArgumentException("River placers do not support underground workings: " + id); } if (isDiamondPlacer() && interaction != InteractionPolicy.ADDITIVE) { throw new IllegalArgumentException("The source pipe, not its placer, controls background diamonds: " + id); } + if (isGoldPlacer() && interaction != InteractionPolicy.ADDITIVE) { + throw new IllegalArgumentException("The source lode, not its placer, controls background gold: " + id); + } if (alignment == AlignmentMode.MASSIVE_RARE && (!this.geomes.isEmpty() || !this.rockFamilies.isEmpty())) { throw new IllegalArgumentException("Massive-and-rare deposit cannot declare geology alignment: " + id); @@ -176,6 +179,11 @@ public OptionalDouble effectiveBackgroundGenerationScale() { public boolean isDiamondPipe() { return shape instanceof DiamondPipeSettings; } public boolean isDiamondPlacer() { return shape instanceof DiamondPlacerSettings; } public boolean isDiamond() { return isDiamondPipe() || isDiamondPlacer(); } + public boolean isGoldLode() { return shape instanceof GoldLodeSettings; } + public boolean isGoldPlacer() { return shape instanceof GoldPlacerSettings; } + public boolean isGold() { return isGoldLode() || isGoldPlacer(); } + public GoldLodeSettings lode() { return (GoldLodeSettings) shape; } + public GoldPlacerSettings goldPlacer() { return (GoldPlacerSettings) shape; } public DiamondPipeSettings pipe() { return (DiamondPipeSettings) shape; } public DiamondPlacerSettings placer() { return (DiamondPlacerSettings) shape; } public StratiformSeamSettings seam() { return (StratiformSeamSettings) shape; } @@ -195,7 +203,8 @@ public String structuralFingerprint() { ? effectiveBackgroundGenerationScale().getAsDouble() : "additive") + (abandonedMine.enabled() ? "|abandoned_mine:" + abandonedMine.chance() : "") + (isCoal() ? "|shape:coal_measures" : "") - + (isDiamond() ? "|diamond:" + toJson() : ""); + + (isDiamond() ? "|diamond:" + toJson() : "") + + (isGold() ? "|gold:" + toJson() : ""); } /** Deterministic schema-1 representation embedded in OreSpawn pattern settings. */ diff --git a/src/main/java/zone/moddev/mc/realisticdeposits/model/GoldLodeSettings.java b/src/main/java/zone/moddev/mc/realisticdeposits/model/GoldLodeSettings.java new file mode 100644 index 0000000..23a9732 --- /dev/null +++ b/src/main/java/zone/moddev/mc/realisticdeposits/model/GoldLodeSettings.java @@ -0,0 +1,67 @@ +package zone.moddev.mc.realisticdeposits.model; + +import java.util.Objects; + +import com.google.gson.JsonObject; + +/** Shape and concentration of a district of steep gold-bearing fractures. */ +public final class GoldLodeSettings implements DepositShapeSettings { + private final IntRange veins; + private final IntRange strike; + private final IntRange thickness; + private final IntRange cover; + private final double minimumDip; + private final double maximumDip; + private final double legacyScale; + + public GoldLodeSettings(IntRange veins, IntRange strike, IntRange thickness, + IntRange cover, double minimumDip, double maximumDip, double legacyScale) { + this.veins = checked(veins, 1, 8, "vein count"); + this.strike = checked(strike, 64, 1024, "strike length"); + this.thickness = checked(thickness, 1, 8, "vein width"); + this.cover = checked(cover, 1, 64, "surface cover"); + + if (!Double.isFinite(minimumDip) || !Double.isFinite(maximumDip) + || minimumDip < 45 || maximumDip > 89 || minimumDip > maximumDip + || !Double.isFinite(legacyScale) || legacyScale <= 0 || legacyScale > 1) { + throw new IllegalArgumentException("Invalid gold dip or legacy selection scale"); + } + + this.minimumDip = minimumDip; + this.maximumDip = maximumDip; + this.legacyScale = legacyScale; + } + + @Override public String type() { return "gold_lode"; } + public IntRange veinCount() { return veins; } + public IntRange strikeLength() { return strike; } + public IntRange thickness() { return thickness; } + public IntRange surfaceCover() { return cover; } + public double minimumDip() { return minimumDip; } + public double maximumDip() { return maximumDip; } + public double legacySelectionScale() { return legacyScale; } + + @Override + public JsonObject toJson() { + JsonObject json = new JsonObject(); + json.addProperty("type", type()); + json.add("veins", DiamondPipeSettings.array(veins)); + json.add("strike_length", DiamondPipeSettings.array(strike)); + json.add("thickness", DiamondPipeSettings.array(thickness)); + json.add("surface_cover", DiamondPipeSettings.array(cover)); + com.google.gson.JsonArray dip = new com.google.gson.JsonArray(); + dip.add(new com.google.gson.JsonPrimitive(minimumDip)); + dip.add(new com.google.gson.JsonPrimitive(maximumDip)); + json.add("dip_degrees", dip); + json.addProperty("legacy_selection_scale", legacyScale); + return json; + } + + private static IntRange checked(IntRange value, int min, int max, String name) { + Objects.requireNonNull(value, name); + if (value.minimum() < min || value.maximum() > max) { + throw new IllegalArgumentException("Invalid gold " + name); + } + return value; + } +} diff --git a/src/main/java/zone/moddev/mc/realisticdeposits/model/GoldPlacerSettings.java b/src/main/java/zone/moddev/mc/realisticdeposits/model/GoldPlacerSettings.java new file mode 100644 index 0000000..bb83f9d --- /dev/null +++ b/src/main/java/zone/moddev/mc/realisticdeposits/model/GoldPlacerSettings.java @@ -0,0 +1,43 @@ +package zone.moddev.mc.realisticdeposits.model; + +import java.util.Objects; + +import com.google.gson.JsonObject; + +/** A river pocket linked to a captured goldfield, not to unmined ore blocks. */ +public final class GoldPlacerSettings implements DepositShapeSettings { + private final String source; + private final int distance; + private final IntRange thickness; + private final double coverage; + + public GoldPlacerSettings(String source, int distance, IntRange thickness, double coverage) { + this.source = DepositDefinition.identifier(source, "gold placer source"); + this.thickness = Objects.requireNonNull(thickness, "thickness"); + + if (distance < 16 || distance > 2048 || thickness.minimum() < 1 || thickness.maximum() > 3 + || !Double.isFinite(coverage) || coverage <= 0 || coverage > 1) { + throw new IllegalArgumentException("Invalid gold placer distance, thickness or coverage"); + } + + this.distance = distance; + this.coverage = coverage; + } + + @Override public String type() { return "gold_placer"; } + public String sourceDeposit() { return source; } + public int maximumSourceDistance() { return distance; } + public IntRange thickness() { return thickness; } + public double coverage() { return coverage; } + + @Override + public JsonObject toJson() { + JsonObject json = new JsonObject(); + json.addProperty("type", type()); + json.addProperty("source_deposit", source); + json.addProperty("maximum_source_distance", distance); + json.add("thickness", DiamondPipeSettings.array(thickness)); + json.addProperty("coverage", coverage); + return json; + } +} diff --git a/src/main/java/zone/moddev/mc/realisticdeposits/proxy/ClientProxy.java b/src/main/java/zone/moddev/mc/realisticdeposits/proxy/ClientProxy.java index 702339b..1cd15ef 100644 --- a/src/main/java/zone/moddev/mc/realisticdeposits/proxy/ClientProxy.java +++ b/src/main/java/zone/moddev/mc/realisticdeposits/proxy/ClientProxy.java @@ -17,9 +17,10 @@ public final class ClientProxy extends CommonProxy { @Override public void registerClientModels() { - Block block = ModContent.DIAMOND_GRAVEL; - ModelLoader.setCustomModelResourceLocation(Item.getItemFromBlock(block), 0, - new ModelResourceLocation(block.getRegistryName(), "inventory")); + for (Block block : new Block[] {ModContent.DIAMOND_GRAVEL, ModContent.GOLD_GRAVEL}) { + ModelLoader.setCustomModelResourceLocation(Item.getItemFromBlock(block), 0, + new ModelResourceLocation(block.getRegistryName(), "inventory")); + } } @Override diff --git a/src/main/resources/assets/realisticdeposits/blockstates/gold_gravel.json b/src/main/resources/assets/realisticdeposits/blockstates/gold_gravel.json new file mode 100644 index 0000000..cad1cf8 --- /dev/null +++ b/src/main/resources/assets/realisticdeposits/blockstates/gold_gravel.json @@ -0,0 +1,5 @@ +{ + "variants": { + "normal": {"model": "realisticdeposits:gold_gravel"} + } +} diff --git a/src/main/resources/assets/realisticdeposits/defaults/gold.json b/src/main/resources/assets/realisticdeposits/defaults/gold.json new file mode 100644 index 0000000..7425008 --- /dev/null +++ b/src/main/resources/assets/realisticdeposits/defaults/gold.json @@ -0,0 +1,53 @@ +{ + "schema_version": 1, + "catalog": "realisticdeposits:gold", + "deposits": [ + { + "id": "realisticdeposits:overworld_gold_lodes", + "enabled": true, + "resource": "minecraft:gold_ore", + "alignment": "geome_aligned", + "interaction": "hybrid", + "background_generation_scale": 0.15, + "dimensions": ["minecraft:overworld"], + "hosts": ["minecraft:stone"], + "geomes": ["orespawn:mountain_belt", "orespawn:stable_craton", "orespawn:volcanic_arc"], + "min_y": 8, + "max_y": 128, + "region_spacing_chunks": 64, + "selection_chance": 0.5, + "salt": 4831257061296041511, + "abandoned_mine": {"enabled": true, "chance": 1.0}, + "shape": { + "type": "gold_lode", + "veins": [2, 4], + "strike_length": [256, 512], + "thickness": [1, 3], + "surface_cover": [4, 12], + "dip_degrees": [55, 85], + "legacy_selection_scale": 0.25 + } + }, + { + "id": "realisticdeposits:overworld_gold_placers", + "enabled": true, + "resource": "realisticdeposits:gold_gravel", + "alignment": "massive_rare", + "interaction": "additive", + "dimensions": ["minecraft:overworld"], + "hosts": ["minecraft:gravel", "minecraft:sand"], + "min_y": 8, + "max_y": 128, + "region_spacing_chunks": 64, + "selection_chance": 1.0, + "salt": 4831257061296041512, + "shape": { + "type": "gold_placer", + "source_deposit": "realisticdeposits:overworld_gold_lodes", + "maximum_source_distance": 1024, + "thickness": [1, 3], + "coverage": 0.35 + } + } + ] +} diff --git a/src/main/resources/assets/realisticdeposits/lang/en_US.lang b/src/main/resources/assets/realisticdeposits/lang/en_US.lang index 1da67c4..31f0cfb 100644 --- a/src/main/resources/assets/realisticdeposits/lang/en_US.lang +++ b/src/main/resources/assets/realisticdeposits/lang/en_US.lang @@ -2,10 +2,11 @@ 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 diamond settings and other structural edits require a restart. +screen.realisticdeposits.reload_policy=Iron and coal shapes reload live. 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 status.realisticdeposits.reload_ok=Definitions reloaded; structural edits, if any, are deferred. status.realisticdeposits.reload_failed=Reload failed: %s tile.realisticdeposits.diamond_gravel.name=Diamond-bearing Gravel +tile.realisticdeposits.gold_gravel.name=Gold-bearing Gravel diff --git a/src/main/resources/assets/realisticdeposits/models/block/gold_gravel.json b/src/main/resources/assets/realisticdeposits/models/block/gold_gravel.json new file mode 100644 index 0000000..b6eb1e1 --- /dev/null +++ b/src/main/resources/assets/realisticdeposits/models/block/gold_gravel.json @@ -0,0 +1,480 @@ +{ + "parent": "minecraft:block/block", + "textures": { + "particle": "minecraft:blocks/gravel", + "all": "minecraft:blocks/gravel", + "gold": "minecraft:blocks/gold_block" + }, + "elements": [ + { + "from": [ + 0, + 0, + 0 + ], + "to": [ + 16, + 16, + 16 + ], + "faces": { + "down": { + "texture": "#all", + "cullface": "down" + }, + "up": { + "texture": "#all", + "cullface": "up" + }, + "north": { + "texture": "#all", + "cullface": "north" + }, + "south": { + "texture": "#all", + "cullface": "south" + }, + "west": { + "texture": "#all", + "cullface": "west" + }, + "east": { + "texture": "#all", + "cullface": "east" + } + } + }, + { + "from": [ + 3, + -0.05, + 5 + ], + "to": [ + 4, + -0.05, + 6 + ], + "faces": { + "down": { + "uv": [ + 2, + 2, + 3, + 3 + ], + "texture": "#gold", + "cullface": "down" + } + } + }, + { + "from": [ + 10, + -0.05, + 9 + ], + "to": [ + 11, + -0.05, + 11 + ], + "faces": { + "down": { + "uv": [ + 2, + 2, + 3, + 3 + ], + "texture": "#gold", + "cullface": "down" + } + } + }, + { + "from": [ + 6, + -0.05, + 13 + ], + "to": [ + 8, + -0.05, + 14 + ], + "faces": { + "down": { + "uv": [ + 2, + 2, + 3, + 3 + ], + "texture": "#gold", + "cullface": "down" + } + } + }, + { + "from": [ + 3, + 16.05, + 5 + ], + "to": [ + 4, + 16.05, + 6 + ], + "faces": { + "up": { + "uv": [ + 2, + 2, + 3, + 3 + ], + "texture": "#gold", + "cullface": "up" + } + } + }, + { + "from": [ + 10, + 16.05, + 9 + ], + "to": [ + 11, + 16.05, + 11 + ], + "faces": { + "up": { + "uv": [ + 2, + 2, + 3, + 3 + ], + "texture": "#gold", + "cullface": "up" + } + } + }, + { + "from": [ + 6, + 16.05, + 13 + ], + "to": [ + 8, + 16.05, + 14 + ], + "faces": { + "up": { + "uv": [ + 2, + 2, + 3, + 3 + ], + "texture": "#gold", + "cullface": "up" + } + } + }, + { + "from": [ + 3, + 5, + -0.05 + ], + "to": [ + 4, + 6, + -0.05 + ], + "faces": { + "north": { + "uv": [ + 2, + 2, + 3, + 3 + ], + "texture": "#gold", + "cullface": "north" + } + } + }, + { + "from": [ + 10, + 9, + -0.05 + ], + "to": [ + 11, + 11, + -0.05 + ], + "faces": { + "north": { + "uv": [ + 2, + 2, + 3, + 3 + ], + "texture": "#gold", + "cullface": "north" + } + } + }, + { + "from": [ + 6, + 13, + -0.05 + ], + "to": [ + 8, + 14, + -0.05 + ], + "faces": { + "north": { + "uv": [ + 2, + 2, + 3, + 3 + ], + "texture": "#gold", + "cullface": "north" + } + } + }, + { + "from": [ + 3, + 5, + 16.05 + ], + "to": [ + 4, + 6, + 16.05 + ], + "faces": { + "south": { + "uv": [ + 2, + 2, + 3, + 3 + ], + "texture": "#gold", + "cullface": "south" + } + } + }, + { + "from": [ + 10, + 9, + 16.05 + ], + "to": [ + 11, + 11, + 16.05 + ], + "faces": { + "south": { + "uv": [ + 2, + 2, + 3, + 3 + ], + "texture": "#gold", + "cullface": "south" + } + } + }, + { + "from": [ + 6, + 13, + 16.05 + ], + "to": [ + 8, + 14, + 16.05 + ], + "faces": { + "south": { + "uv": [ + 2, + 2, + 3, + 3 + ], + "texture": "#gold", + "cullface": "south" + } + } + }, + { + "from": [ + -0.05, + 5, + 3 + ], + "to": [ + -0.05, + 6, + 4 + ], + "faces": { + "west": { + "uv": [ + 2, + 2, + 3, + 3 + ], + "texture": "#gold", + "cullface": "west" + } + } + }, + { + "from": [ + -0.05, + 9, + 10 + ], + "to": [ + -0.05, + 11, + 11 + ], + "faces": { + "west": { + "uv": [ + 2, + 2, + 3, + 3 + ], + "texture": "#gold", + "cullface": "west" + } + } + }, + { + "from": [ + -0.05, + 13, + 6 + ], + "to": [ + -0.05, + 14, + 8 + ], + "faces": { + "west": { + "uv": [ + 2, + 2, + 3, + 3 + ], + "texture": "#gold", + "cullface": "west" + } + } + }, + { + "from": [ + 16.05, + 5, + 3 + ], + "to": [ + 16.05, + 6, + 4 + ], + "faces": { + "east": { + "uv": [ + 2, + 2, + 3, + 3 + ], + "texture": "#gold", + "cullface": "east" + } + } + }, + { + "from": [ + 16.05, + 9, + 10 + ], + "to": [ + 16.05, + 11, + 11 + ], + "faces": { + "east": { + "uv": [ + 2, + 2, + 3, + 3 + ], + "texture": "#gold", + "cullface": "east" + } + } + }, + { + "from": [ + 16.05, + 13, + 6 + ], + "to": [ + 16.05, + 14, + 8 + ], + "faces": { + "east": { + "uv": [ + 2, + 2, + 3, + 3 + ], + "texture": "#gold", + "cullface": "east" + } + } + } + ] +} diff --git a/src/main/resources/assets/realisticdeposits/models/item/gold_gravel.json b/src/main/resources/assets/realisticdeposits/models/item/gold_gravel.json new file mode 100644 index 0000000..edf573b --- /dev/null +++ b/src/main/resources/assets/realisticdeposits/models/item/gold_gravel.json @@ -0,0 +1,3 @@ +{ + "parent": "realisticdeposits:block/gold_gravel" +} diff --git a/src/test/java/zone/moddev/mc/realisticdeposits/ProjectIdentityTest.java b/src/test/java/zone/moddev/mc/realisticdeposits/ProjectIdentityTest.java index 81a9fa9..2749e61 100644 --- a/src/test/java/zone/moddev/mc/realisticdeposits/ProjectIdentityTest.java +++ b/src/test/java/zone/moddev/mc/realisticdeposits/ProjectIdentityTest.java @@ -16,11 +16,11 @@ class ProjectIdentityTest { void usesStablePublicIdentity() { assertEquals("realisticdeposits", RealisticDeposits.MOD_ID); assertEquals("Realistic Deposits", RealisticDeposits.NAME); - assertEquals("0.3.0.110021", RealisticDeposits.VERSION); + assertEquals("0.4.0.110021", RealisticDeposits.VERSION); net.minecraftforge.fml.common.Mod annotation = RealisticDeposits.class.getAnnotation( net.minecraftforge.fml.common.Mod.class); assertTrue(annotation.guiFactory().endsWith("RealisticDepositsGuiFactory")); - assertEquals("required-after:orespawn@[4.1.4.110021,5.0.0)", annotation.dependencies()); + assertEquals("required-after:orespawn@[4.1.5.110021,5.0.0)", annotation.dependencies()); } @Test @@ -31,11 +31,11 @@ void pinsThePublishedOreSpawnRelease() throws IOException { properties.load(input); } - assertEquals("4.1.4.110021", properties.getProperty("orespawn_version")); - assertEquals("[4.1.4.110021,5.0.0)", properties.getProperty("orespawn_version_range")); + assertEquals("4.1.5.110021", properties.getProperty("orespawn_version")); + assertEquals("[4.1.5.110021,5.0.0)", properties.getProperty("orespawn_version_range")); assertEquals("245586", properties.getProperty("orespawn_curse_project_id")); - assertEquals("9356289", properties.getProperty("orespawn_curse_file_id")); - assertEquals("DB254E9E865685A74262DE9D469A006AB3C8053573CC6136FD4A8591AD806CA3", + assertEquals("9397156", properties.getProperty("orespawn_curse_file_id")); + assertEquals("1E580EBB8D93B1256E2E81C7D0D815DEAAAC58A028CB5A77A52773B62048E010", properties.getProperty("orespawn_sha256")); } } diff --git a/src/test/java/zone/moddev/mc/realisticdeposits/client/GoldClientProbe.java b/src/test/java/zone/moddev/mc/realisticdeposits/client/GoldClientProbe.java new file mode 100644 index 0000000..84076aa --- /dev/null +++ b/src/test/java/zone/moddev/mc/realisticdeposits/client/GoldClientProbe.java @@ -0,0 +1,200 @@ +package zone.moddev.mc.realisticdeposits.client; + +import java.io.File; +import java.io.FileOutputStream; +import java.util.List; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Properties; +import java.util.stream.Collectors; + +import net.minecraft.client.Minecraft; +import net.minecraft.block.Block; +import net.minecraft.client.gui.GuiButton; +import net.minecraft.client.gui.GuiIngameMenu; +import net.minecraft.client.gui.GuiMainMenu; +import net.minecraft.client.gui.GuiScreen; +import net.minecraft.client.renderer.block.model.IBakedModel; +import net.minecraft.client.resources.ResourcePackRepository; +import net.minecraft.init.Blocks; +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; +import net.minecraft.util.EnumFacing; +import net.minecraft.util.ScreenShotHelper; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.GameType; +import net.minecraft.world.WorldSettings; +import net.minecraft.world.WorldType; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.client.event.RenderWorldLastEvent; +import net.minecraftforge.fml.common.Mod; +import net.minecraftforge.fml.common.event.FMLInitializationEvent; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; +import net.minecraftforge.fml.common.gameevent.TickEvent; + +import zone.moddev.mc.orespawn.api.client.WorldSettingsExtension; +import zone.moddev.mc.orespawn.api.client.WorldSettingsExtensionRegistry; +import zone.moddev.mc.realisticdeposits.content.ModContent; + +/** A small disposable client world for the gravel model, screen and save checks. */ +@Mod(modid = "rdgoldclientprobe", name = "Gold Client Checks", version = "1", + dependencies = "required-after:realisticdeposits", clientSideOnly = true) +public final class GoldClientProbe { + private static final BlockPos GRAVEL = new BlockPos(8, 100, 8); + private GuiScreen parent; + private RealisticDepositsConfigScreen screen; + private int phase; + private int ticks; + private int frames; + private volatile boolean placed; + private volatile Throwable failure; + + @Mod.EventHandler + public void initialize(FMLInitializationEvent event) { + MinecraftForge.EVENT_BUS.register(this); + } + + @SubscribeEvent + public void rendered(RenderWorldLastEvent event) { + frames++; + } + + @SubscribeEvent + public void tick(TickEvent.ClientTickEvent event) throws Exception { + if (event.phase != TickEvent.Phase.END) return; + Minecraft mc = Minecraft.getMinecraft(); + Block gold = ModContent.GOLD_GRAVEL; + Block diamond = ModContent.DIAMOND_GRAVEL; + if (++ticks > 3600 || failure != null) { + if (failure != null) failure.printStackTrace(); + System.err.println("RD_GOLD_CLIENT_FAILED phase=" + phase); + mc.shutdown(); + return; + } + + if (phase == 0 && mc.currentScreen instanceof GuiMainMenu) { + mc.gameSettings.pauseOnLostFocus = false; + mc.gameSettings.renderDistanceChunks = 3; + parent = mc.currentScreen; + List extensions = WorldSettingsExtensionRegistry.extensions().stream() + .filter(entry -> "realisticdeposits".equals(entry.id().getResourceDomain())) + .collect(Collectors.toList()); + if (extensions.size() != 1) throw new IllegalStateException("Expected one settings entry"); + screen = (RealisticDepositsConfigScreen) extensions.get(0).createScreen(parent); + if (new RealisticDepositsGuiFactory().mainConfigGuiClass() != screen.getClass()) { + throw new IllegalStateException("The two settings entry points differ"); + } + mc.displayGuiScreen(screen); + phase = 1; + ticks = 0; + } else if (phase == 1 && ticks > 20) { + java.lang.reflect.Method done = screen.getClass().getDeclaredMethod("func_146284_a", GuiButton.class); + done.setAccessible(true); + done.invoke(screen, new GuiButton(0, 0, 0, "Done")); + if (mc.currentScreen != parent) throw new IllegalStateException("Settings lost their parent screen"); + phase = 2; + ticks = 0; + } else if ((phase == 2 || (phase == 5 && ticks > 40)) && mc.currentScreen instanceof GuiMainMenu + && !mc.isIntegratedServerRunning()) { + if (phase == 5) { + ResourcePackRepository repository = mc.getResourcePackRepository(); + repository.updateRepositoryEntriesAll(); + ResourcePackRepository.Entry testPack = repository.getRepositoryEntriesAll().stream() + .filter(entry -> "gold-host-test.zip".equals(entry.getResourcePackName())) + .findFirst().orElseThrow(() -> new IllegalStateException("The host-texture test pack is missing")); + List selected = new ArrayList<>(repository.getRepositoryEntries()); + selected.add(testPack); + repository.setRepositories(selected); + mc.gameSettings.resourcePacks.add(testPack.getResourcePackName()); + mc.refreshResources(); + } + + mc.launchIntegratedServer("gold-client-world", "Gold Client Checks", + new WorldSettings(3346071746553592696L, GameType.CREATIVE, true, false, WorldType.FLAT)); + placed = false; + phase++; + ticks = 0; + frames = 0; + } else if ((phase == 3 || phase == 6) && mc.player != null) { + final boolean reload = phase == 6; + phase++; + mc.getIntegratedServer().addScheduledTask(() -> { + try { + net.minecraft.entity.player.EntityPlayerMP player = mc.getIntegratedServer().getPlayerList() + .getPlayerByUUID(mc.player.getUniqueID()); + if (reload) { + if (player.getServerWorld().getBlockState(GRAVEL).getBlock() != ModContent.GOLD_GRAVEL + || !"Saved prospecting gravel".equals(player.inventory.getStackInSlot(0).getDisplayName())) { + throw new IllegalStateException("Gold gravel or its saved name changed on reload"); + } + } else { + for (int x = 4; x <= 12; x++) { + for (int z = 4; z <= 14; z++) { + player.getServerWorld().setBlockState(new BlockPos(x, 99, z), Blocks.STONE.getDefaultState(), 2); + } + } + player.getServerWorld().setBlockState(GRAVEL, gold.getDefaultState(), 2); + player.getServerWorld().setBlockState(GRAVEL.east(2), diamond.getDefaultState(), 2); + ItemStack saved = new ItemStack(Item.getItemFromBlock(ModContent.GOLD_GRAVEL), 7); + saved.setStackDisplayName("Saved prospecting gravel"); + player.inventory.setInventorySlotContents(0, saved); + } + player.connection.setPlayerLocation(8.5, 101, 13.5, 180, 25); + placed = true; + } catch (Throwable error) { + failure = error; + } + }); + ticks = 0; + } else if ((phase == 4 || phase == 7) && placed && ticks > 100 && frames > 20 + && mc.world != null && mc.world.getBlockState(GRAVEL).getBlock() == ModContent.GOLD_GRAVEL) { + IBakedModel model = mc.getBlockRendererDispatcher().getBlockModelShapes() + .getModelForState(gold.getDefaultState()); + for (EnumFacing face : EnumFacing.values()) { + if (model.getQuads(gold.getDefaultState(), face, 0).size() != 4) { + throw new IllegalStateException("The gravel face or its three gold flecks did not bake: " + face); + } + } + if (!"minecraft:blocks/gravel".equals(model.getParticleTexture().getIconName())) { + throw new IllegalStateException("The model lost its vanilla gravel background"); + } + boolean replaced = Arrays.deepEquals(model.getParticleTexture().getFrameTextureData(0), + mc.getTextureMapBlocks().getAtlasSprite("minecraft:blocks/diamond_block").getFrameTextureData(0)); + if (replaced != (phase == 7)) { + throw new IllegalStateException("The resource pack did not replace only the reloaded gravel host"); + } + ScreenShotHelper.saveScreenshot(new File("."), "gold-client-" + phase + ".png", + mc.displayWidth, mc.displayHeight, mc.getFramebuffer()); + // Pause before leaving so queued world packets finish while the world is still loaded. + mc.displayGuiScreen(new GuiIngameMenu()); + phase = phase == 4 ? 9 : 10; + ticks = 0; + } else if ((phase == 9 || phase == 10) && ticks > 40) { + if (!(mc.currentScreen instanceof GuiIngameMenu)) { + throw new IllegalStateException("The pause menu did not open before saving"); + } + + java.lang.reflect.Method quit = GuiIngameMenu.class.getDeclaredMethod("func_146284_a", GuiButton.class); + quit.setAccessible(true); + quit.invoke(mc.currentScreen, new GuiButton(1, 0, 0, "Save and Quit to Title")); + phase = phase == 9 ? 5 : 8; + ticks = 0; + } else if ((phase == 5 || phase == 8) && mc.world == null && !mc.isIntegratedServerRunning() && ticks > 40) { + if (phase == 5) { + mc.displayGuiScreen(parent); + } else { + Properties result = new Properties(); + result.setProperty("config_entry_points", "PASS"); + result.setProperty("model_faces", "4 per side"); + result.setProperty("fresh_render", "PASS"); + result.setProperty("reload_render_and_named_stack", "PASS"); + result.setProperty("resource_pack_host", "PASS"); + try (FileOutputStream output = new FileOutputStream("gold-client-pass.properties")) { + result.store(output, "Gold packaged-client checks"); + } + MinecraftForge.EVENT_BUS.unregister(this); + mc.shutdown(); + } + } + } +} diff --git a/src/test/java/zone/moddev/mc/realisticdeposits/config/GoldCatalogTest.java b/src/test/java/zone/moddev/mc/realisticdeposits/config/GoldCatalogTest.java new file mode 100644 index 0000000..5c08c2b --- /dev/null +++ b/src/test/java/zone/moddev/mc/realisticdeposits/config/GoldCatalogTest.java @@ -0,0 +1,88 @@ +package zone.moddev.mc.realisticdeposits.config; + +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.List; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.junit.jupiter.api.Test; + +import zone.moddev.mc.realisticdeposits.integration.OreSpawnIntegration; +import zone.moddev.mc.realisticdeposits.model.DepositDefinition; + +import static org.junit.jupiter.api.Assertions.*; + +public class GoldCatalogTest { + + public static List defaults() { + InputStream source = GoldCatalogTest.class.getResourceAsStream( + "/assets/realisticdeposits/defaults/gold.json"); + assertNotNull(source, "The gold catalogue must be shipped, not installed by a test helper"); + + return DepositCatalogLoader.load(new InputStreamReader(source, StandardCharsets.UTF_8), + "gold.json").definitions(); + } + + @Test + void shipsTwoLinkedShapesWithOneBackgroundController() { + List definitions = defaults(); + assertEquals(2, definitions.size()); + assertEquals("gold_lode", definitions.get(0).shape().type()); + assertEquals("gold_placer", definitions.get(1).shape().type()); + assertEquals(0.15, definitions.get(0).effectiveBackgroundGenerationScale().getAsDouble()); + assertFalse(definitions.get(1).effectiveBackgroundGenerationScale().isPresent()); + assertTrue(definitions.get(0).abandonedMine().enabled()); + assertEquals(1.0, definitions.get(0).abandonedMine().chance()); + assertFalse(definitions.get(1).abandonedMine().enabled()); + assertDoesNotThrow(() -> OreSpawnIntegration.provider(definitions)); + } + + @Test + void goldGeometryAndSourceLinksAreWorldCaptured() { + for (DepositDefinition definition : defaults()) { + JsonObject changed = definition.toJson(); + changed.addProperty("salt", definition.salt() + 1); + DepositDefinition replacement = DepositCatalogLoader.loadDefinition(changed, "changed gold"); + assertNotEquals(definition.structuralFingerprint(), replacement.structuralFingerprint()); + } + } + + @Test + void placersCannotAcquireTheirOwnBackgroundPolicyOrLinkToAnotherShape() { + JsonObject placer = defaults().get(1).toJson(); + placer.addProperty("interaction", "replacement"); + assertThrows(IllegalArgumentException.class, () -> DepositCatalogLoader.loadDefinition(placer, "placer")); + + placer.addProperty("interaction", "additive"); + placer.getAsJsonObject("shape").addProperty("source_deposit", "example:missing"); + DepositDefinition broken = DepositCatalogLoader.loadDefinition(placer, "bad source"); + assertThrows(IllegalArgumentException.class, () -> OreSpawnIntegration.provider( + java.util.Arrays.asList(defaults().get(0), broken))); + } + + @Test + void goldSettingsRoundTripAndRejectUnboundedGeometry() { + for (DepositDefinition definition : defaults()) { + assertEquals(definition.toJson(), DepositCatalogLoader.loadDefinition(definition.toJson(), "round trip").toJson()); + } + JsonObject lode = defaults().get(0).toJson(); + lode.getAsJsonObject("shape").addProperty("legacy_selection_scale", 0); + assertThrows(IllegalArgumentException.class, () -> DepositCatalogLoader.loadDefinition(lode, "bad rarity")); + } + + @Test + void oldCatalogueStillParsesWithoutGoldFields() { + JsonObject old = new JsonParser().parse("{\"id\":\"example:old\",\"resource\":\"minecraft:iron_ore\"," + + "\"alignment\":\"massive_rare\",\"interaction\":\"additive\"," + + "\"dimensions\":[\"minecraft:overworld\"],\"hosts\":[\"minecraft:stone\"]," + + "\"min_y\":8,\"max_y\":64,\"region_spacing_chunks\":32,\"selection_chance\":1,\"salt\":1," + + "\"shape\":{\"type\":\"stratiform_seam\",\"strike_length\":[64,128]," + + "\"down_dip_width\":[16,24],\"thickness\":[1,3],\"dip_degrees\":[5,15]," + + "\"bend_amplitude\":2,\"bend_wavelength\":80,\"fold_amplitude\":2," + + "\"fold_wavelength\":80,\"density\":0.2,\"edge_falloff\":0.2}}") + .getAsJsonObject(); + assertEquals("stratiform_seam", DepositCatalogLoader.loadDefinition(old, "old").shape().type()); + } +} 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 8c649b2..6cd56b7 100644 --- a/src/test/java/zone/moddev/mc/realisticdeposits/config/MineDefaultsTest.java +++ b/src/test/java/zone/moddev/mc/realisticdeposits/config/MineDefaultsTest.java @@ -16,9 +16,9 @@ class MineDefaultsTest { @Test void everyNewUndergroundDepositAttemptsWorkings() { - for (String name : new String[] {"vanilla", "coal", "diamonds"}) { + for (String name : new String[] {"vanilla", "coal", "diamonds", "gold"}) { for (DepositDefinition definition : catalogue(name).definitions()) { - if (definition.isDiamondPlacer()) { + if (definition.isDiamondPlacer() || definition.isGoldPlacer()) { assertFalse(definition.abandonedMine().enabled()); } else { assertTrue(definition.abandonedMine().enabled(), definition.id()); @@ -46,7 +46,7 @@ void diamondPipesCanCaptureWorkingsWithoutMovingTheDeposit() { @Test void oldProfilesAndExplicitOptOutsStayOff() { - for (String name : new String[] {"vanilla", "coal", "diamonds"}) { + for (String name : new String[] {"vanilla", "coal", "diamonds", "gold"}) { 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 60084b0..93acaa5 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:gold_ore", "additive", 0.5D)); + write(catalog, document("minecraft:lapis_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/GoldGeologyTest.java b/src/test/java/zone/moddev/mc/realisticdeposits/generation/GoldGeologyTest.java new file mode 100644 index 0000000..18da8f0 --- /dev/null +++ b/src/test/java/zone/moddev/mc/realisticdeposits/generation/GoldGeologyTest.java @@ -0,0 +1,44 @@ +package zone.moddev.mc.realisticdeposits.generation; + +import java.util.Random; + +import org.junit.jupiter.api.Test; +import zone.moddev.mc.realisticdeposits.content.GoldGravelBlock; + +import static org.junit.jupiter.api.Assertions.*; + +class GoldGeologyTest { + @Test + void exactHostsRejectPolishedStoneAndUnrelatedMineralogyRocks() { + for (int metadata = 0; metadata <= 6; metadata++) { + assertEquals(metadata == 0 || metadata == 1 || metadata == 3 || metadata == 5, + GoldGeology.permittedRock("minecraft:stone", metadata)); + } + for (String rock : new String[] {"slate", "phyllite", "schist", "amphibolite", "gneiss", "shale"}) { + assertTrue(GoldGeology.permittedRock("mineralogy:" + rock, 0)); + assertFalse(GoldGeology.permittedRock("mineralogy:" + rock, 1)); + } + for (String rock : new String[] {"peridotite", "pumice", "marble", "rock_salt", "gypsum", "quartzite"}) { + assertFalse(GoldGeology.permittedRock("mineralogy:" + rock, 0)); + } + assertFalse(GoldGeology.permittedRock("anothermod:slate", 0)); + } + + @Test + void nuggetRollIsOneInFourAndFortuneOnlyMultipliesSuccesses() { + Random plain = new Random(71); + Random fortune = new Random(71); + int ordinary = 0; + int enchanted = 0; + for (int sample = 0; sample < 100000; sample++) { + int normal = GoldGravelBlock.nuggetYield(plain, 0); + int bonus = GoldGravelBlock.nuggetYield(fortune, 3); + assertTrue(normal == 0 || normal == 1); + assertTrue(bonus >= 0 && bonus <= 4); + ordinary += normal; + enchanted += bonus; + } + assertTrue(ordinary > 24000 && ordinary < 26000); + assertTrue(enchanted > ordinary * 2 && enchanted < ordinary * 2.4); + } +} diff --git a/src/test/java/zone/moddev/mc/realisticdeposits/generation/GoldGravelRuntimeChecks.java b/src/test/java/zone/moddev/mc/realisticdeposits/generation/GoldGravelRuntimeChecks.java new file mode 100644 index 0000000..b32df42 --- /dev/null +++ b/src/test/java/zone/moddev/mc/realisticdeposits/generation/GoldGravelRuntimeChecks.java @@ -0,0 +1,115 @@ +package zone.moddev.mc.realisticdeposits.generation; + +import java.util.List; + +import com.google.gson.JsonObject; + +import net.minecraft.block.Block; +import net.minecraft.entity.item.EntityFallingBlock; +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.nbt.NBTTagCompound; +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; + +/** Native harvest and gravity paths, used only in the disposable server fixture. */ +final class GoldGravelRuntimeChecks { + private static final BlockPos POS = new BlockPos(8, 200, 8); + private static final AxisAlignedBB AREA = new AxisAlignedBB(0, 170, 0, 16, 240, 16); + + private GoldGravelRuntimeChecks() { } + + static JsonObject run(WorldServer world, Block rich) { + FakePlayer player = FakePlayerFactory.getMinecraft(world); + ItemStack shovel = new ItemStack(Items.DIAMOND_SHOVEL); + Item richItem = Item.getItemFromBlock(rich); + clearItems(world); + + for (int i = 0; i < 128; i++) { + world.setBlockState(POS, rich.getDefaultState(), 2); + world.setBlockToAir(POS); + rich.harvestBlock(world, player, POS, rich.getDefaultState(), null, shovel); + List drops = items(world); + require(count(drops, Item.getItemFromBlock(Blocks.GRAVEL)) == 1, "Mining must return one spent gravel"); + require(count(drops, richItem) == 0, "Mining must consume the rich block"); + require(drops.stream().allMatch(item -> item.getEntityItem().getItem() == Items.GOLD_NUGGET + || item.getEntityItem().getItem() == Item.getItemFromBlock(Blocks.GRAVEL)), "Unexpected mining drop"); + clearItems(world); + } + + shovel.addEnchantment(Enchantments.SILK_TOUCH, 1); + rich.harvestBlock(world, player, POS, rich.getDefaultState(), null, shovel); + require(count(items(world), richItem) == 1 && items(world).size() == 1, "Silk Touch must return only rich gravel"); + clearItems(world); + + for (int i = 0; i < 512; i++) { + for (ItemStack drop : Blocks.GRAVEL.getDrops(world, POS, Blocks.GRAVEL.getDefaultState(), 3)) { + require(drop.getItem() != Items.GOLD_NUGGET && drop.getItem() != richItem, "Ordinary gravel became rich"); + } + } + + world.setBlockState(POS.down(), Blocks.STONE.getDefaultState(), 2); + world.setBlockState(POS.up(8), rich.getDefaultState(), 2); + EntityFallingBlock falling = new EntityFallingBlock(world, POS.getX() + 0.5, + POS.getY() + 8.5, POS.getZ() + 0.5, rich.getDefaultState()); + for (int tick = 0; tick < 100 && !falling.isDead; tick++) falling.onUpdate(); + require(falling.isDead && world.getBlockState(POS).getBlock() == rich, "Falling gravel lost its identity"); + require(items(world).isEmpty(), "Landing extracted gold nuggets"); + world.setBlockToAir(POS); + + world.setBlockState(POS.up(8), rich.getDefaultState(), 2); + falling = new EntityFallingBlock(world, POS.getX() + 0.5, + POS.getY() + 8.5, POS.getZ() + 0.5, rich.getDefaultState()); + falling.onUpdate(); + NBTTagCompound saved = new NBTTagCompound(); + falling.writeToNBT(saved); + EntityFallingBlock restored = new EntityFallingBlock(world); + restored.readFromNBT(saved); + restored.fallTime = 600; + restored.onUpdate(); + require(restored.isDead && count(items(world), richItem) == 1 && items(world).size() == 1, + "Indirect falling drop must preserve rich gravel without extracting gold nuggets"); + clearItems(world); + + for (int i = 0; i < 32; i++) { + world.setBlockState(POS, rich.getDefaultState(), 2); + world.newExplosion(null, POS.getX() + 0.5, POS.getY() + 0.5, POS.getZ() + 0.5, 2, false, true); + require(world.getBlockState(POS).getBlock() != rich, "Explosion did not consume the test block"); + require(count(items(world), richItem) == 0, "Explosion returned an intact rich block"); + clearItems(world); + } + + JsonObject report = new JsonObject(); + report.addProperty("normal_harvests", 128); + report.addProperty("spent_gravel_checks", 512); + report.addProperty("explosions", 32); + report.addProperty("silk_touch", true); + report.addProperty("falling_landing", true); + report.addProperty("falling_save_reload_indirect_drop", true); + return report; + } + + private static List items(WorldServer world) { + return world.getEntitiesWithinAABB(EntityItem.class, AREA, item -> !item.isDead); + } + + private static int count(List items, Item item) { + return items.stream().filter(entity -> entity.getEntityItem().getItem() == item) + .mapToInt(entity -> entity.getEntityItem().stackSize).sum(); + } + + private static void clearItems(WorldServer world) { + for (EntityItem item : items(world)) item.setDead(); + } + + private static void require(boolean passed, String message) { + if (!passed) throw new IllegalStateException(message); + } +} diff --git a/src/test/java/zone/moddev/mc/realisticdeposits/generation/GoldRuntimeProbe.java b/src/test/java/zone/moddev/mc/realisticdeposits/generation/GoldRuntimeProbe.java new file mode 100644 index 0000000..94df3b1 --- /dev/null +++ b/src/test/java/zone/moddev/mc/realisticdeposits/generation/GoldRuntimeProbe.java @@ -0,0 +1,247 @@ +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.Optional; + +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.ResourceLocation; +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 net.minecraftforge.fml.common.event.FMLServerStartedEvent; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; +import net.minecraftforge.event.terraingen.PopulateChunkEvent; + +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.GoldDistrict; +import zone.moddev.mc.realisticdeposits.model.DepositDefinition; + +/** Runs only in the separately packaged, disposable gold-test server. */ +@Mod(modid = "rdgoldprobe", name = "Gold Qualification", version = "1", + dependencies = "required-after:realisticdeposits") +public final class GoldRuntimeProbe { + private static final int SIDE = 8; + private static int riverMinX = Integer.MAX_VALUE; + private static int riverMinZ; + private static final java.util.Set installedRiverChunks = new java.util.HashSet<>(); + + public GoldRuntimeProbe() { + net.minecraftforge.common.MinecraftForge.EVENT_BUS.register(this); + } + + @SubscribeEvent + public void beforePopulation(PopulateChunkEvent.Pre event) { + if (!Boolean.getBoolean("rd.gold.enabled") || !Boolean.getBoolean("rd.gold.fixture_river") + || event.getChunkX() < riverMinX || event.getChunkX() >= riverMinX + 3 + || event.getChunkZ() < riverMinZ || event.getChunkZ() >= riverMinZ + SIDE) return; + if (!(event.getWorld() instanceof WorldServer) || !"gold-test-world".equals( + ((WorldServer) event.getWorld()).getSaveHandler().getWorldDirectory().getName())) { + throw new IllegalStateException("The river fixture cannot touch another world"); + } + + Chunk chunk = ((WorldServer) event.getWorld()).getChunkProvider() + .getLoadedChunk(event.getChunkX(), event.getChunkZ()); + long chunkKey = ((long) event.getChunkX() << 32) ^ (event.getChunkZ() & 0xffffffffL); + if (chunk == null || !installedRiverChunks.add(chunkKey)) { + throw new IllegalStateException("The river fixture requires one pre-population event per new chunk"); + } + for (int x = event.getChunkX() * 16; x < event.getChunkX() * 16 + 16; x++) { + for (int z = event.getChunkZ() * 16; z < event.getChunkZ() * 16 + 16; z++) { + for (int y = 58; y < 130; y++) { + IBlockState state = y <= 60 ? Blocks.GRAVEL.getDefaultState() + : y <= 63 ? Blocks.WATER.getDefaultState() : Blocks.AIR.getDefaultState(); + chunk.setBlockState(new BlockPos(x, y, z), state); + } + } + } + chunk.generateSkylightMap(); + } + + @Mod.EventHandler + public void started(FMLServerStartedEvent event) throws Exception { + if (!Boolean.getBoolean("rd.gold.enabled")) return; + MinecraftServer server = FMLCommonHandler.instance().getMinecraftServerInstance(); + try { + WorldServer world = server.worldServerForDimension(0); + if (!"gold-test-world".equals(world.getSaveHandler().getWorldDirectory().getName())) { + throw new IllegalStateException("Gold checks require their disposable world"); + } + world.getGameRules().setOrCreateGameRule("randomTickSpeed", "0"); + Block gravel = Block.REGISTRY.getObject(new ResourceLocation("realisticdeposits:gold_gravel")); + if (gravel == Blocks.AIR) throw new IllegalStateException("Gold-bearing gravel is not registered"); + DepositDefinition original = RealisticDepositsRuntime.state().definitions().stream() + .filter(DepositDefinition::isGoldLode).findFirst().get(); + JsonObject enabled = original.toJson(); + enabled.addProperty("enabled", true); + DepositDefinition definition = DepositCatalogLoader.loadDefinition(enabled, "gold fixture geometry"); + String phase = System.getProperty("rd.gold.phase", "first"); + File target = new File("gold-target.json"); + JsonObject coordinates; + if (target.isFile()) { + try (FileReader reader = new FileReader(target)) { + coordinates = new JsonParser().parse(reader).getAsJsonObject(); + } + } else { + coordinates = new JsonObject(); + find(world, definition, 32, "first", coordinates); + find(world, definition, 96, "later", coordinates); + write(target, coordinates); + } + + GoldDistrict first = resolve(world, definition, coordinates, "first"); + GoldDistrict 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("gravel_contract", GoldGravelRuntimeChecks.run(world, gravel)); + report.add("first", sample(world, first, gravel)); + if ("later".equals(phase) || "stable".equals(phase)) report.add("later", sample(world, later, gravel)); + report.add("profile", OreSpawnApi.getActiveProfile(server).get().toJson()); + write(new File("gold-" + phase + ".json"), report); + System.out.println("RD_GOLD_PASS " + phase); + } finally { + server.initiateShutdown(); + } + } + + private static GoldDistrict.Site site(WorldServer world) { + MineTerrainSampler terrain = new MineTerrainSampler(world, world.getChunkProvider().chunkGenerator); + Optional sampler = OreSpawnApi.createSampler(world); + return new GoldDistrict.Site() { + @Override public int surfaceY(int x, int z) { return terrain.naturalSurfaceY(x, z); } + @Override public String geome(int x, int z, int surface) { + return sampler.isPresent() ? sampler.get().sampleColumn(x, z, surface).geome().toString() : null; + } + }; + } + + private static void find(WorldServer world, DepositDefinition definition, int start, String prefix, JsonObject output) { + for (int x = start; x < start + 32; x++) { + for (int z = start; z < start + 32; z++) { + Optional planned = GoldDistrict.plan(definition, world.getSeed(), + "minecraft:overworld", x, z, site(world)); + if (!planned.isPresent()) continue; + output.addProperty(prefix + "_rx", x); + output.addProperty(prefix + "_rz", z); + return; + } + } + throw new IllegalStateException("No gold district in bounded fixture search"); + } + + private static GoldDistrict resolve(WorldServer world, DepositDefinition definition, JsonObject coordinates, String prefix) { + return GoldDistrict.plan(definition, world.getSeed(), "minecraft:overworld", + coordinates.get(prefix + "_rx").getAsInt(), coordinates.get(prefix + "_rz").getAsInt(), site(world)).get(); + } + + private static void populate(WorldServer world, GoldDistrict district) { + ChunkProviderServer provider = world.getChunkProvider(); + int baseX = (district.centerX() >> 4) - SIDE / 2; + int baseZ = (district.centerZ() >> 4) - SIDE / 2; + riverMinX = baseX + 4; + riverMinZ = baseZ; + java.util.List halo = new java.util.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.gold.reverse")) java.util.Collections.reverse(halo); + for (int[] cell : halo) provider.provideChunk(cell[0], cell[1]); + + java.util.List order = new java.util.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.gold.reverse")) java.util.Collections.reverse(order); + for (int[] cell : order) provider.provideChunk(cell[0], cell[1]).populateChunk(provider, provider.chunkGenerator); + } + + private static JsonObject sample(WorldServer world, GoldDistrict district, Block gravel) throws Exception { + MessageDigest terrain = MessageDigest.getInstance("SHA-256"); + MessageDigest gold = MessageDigest.getInstance("SHA-256"); + int baseX = (district.centerX() >> 4) - SIDE / 2; + int baseZ = (district.centerZ() >> 4) - SIDE / 2; + int lodeOre = 0; + int background = 0; + int sediment = 0; + int shallow = 0; + JsonArray section = new JsonArray(); + BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(); + 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 IllegalStateException("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)); + update(terrain, Block.getStateId(state)); + if (state.getBlock() == Blocks.GOLD_ORE) { + boolean planned = district.isOreAt(pos.getX(), pos.getY(), pos.getZ()); + if (planned) lodeOre++; else background++; + if (y > 40) shallow++; + update(gold, x); update(gold, y); update(gold, z); + } + if (state.getBlock() == gravel) { + sediment++; + update(gold, x); update(gold, y); update(gold, z); + } + if (z == district.centerZ() && y <= 140) { + 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())); + section.add(cell); + } + } + } + } + } + } + JsonObject report = new JsonObject(); + report.addProperty("centre_x", district.centerX()); + report.addProperty("centre_z", district.centerZ()); + report.addProperty("lode_ore", lodeOre); + report.addProperty("background_ore", background); + report.addProperty("rich_gravel", sediment); + report.addProperty("shallow_ore", shallow); + report.addProperty("terrain_sha256", hex(terrain.digest())); + report.addProperty("gold_sha256", hex(gold.digest())); + report.add("section", section); + return report; + } + + private static void update(MessageDigest digest, int value) { + digest.update((byte) (value >>> 24)); digest.update((byte) (value >>> 16)); + digest.update((byte) (value >>> 8)); digest.update((byte) value); + } + + private static String hex(byte[] bytes) { + StringBuilder text = new StringBuilder(); + for (byte value : bytes) text.append(String.format("%02x", value & 255)); + return text.toString(); + } + + private static void write(File file, JsonObject json) throws Exception { + try (FileWriter writer = new FileWriter(file)) { + new GsonBuilder().setPrettyPrinting().create().toJson(json, writer); + } + } +} diff --git a/src/test/java/zone/moddev/mc/realisticdeposits/generation/GoldSurveyProbe.java b/src/test/java/zone/moddev/mc/realisticdeposits/generation/GoldSurveyProbe.java new file mode 100644 index 0000000..297cff1 --- /dev/null +++ b/src/test/java/zone/moddev/mc/realisticdeposits/generation/GoldSurveyProbe.java @@ -0,0 +1,342 @@ +package zone.moddev.mc.realisticdeposits.generation; + +import java.io.File; +import java.io.FileWriter; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Random; + +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import net.minecraft.block.material.Material; +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.biome.Biome; +import net.minecraft.world.chunk.Chunk; +import net.minecraft.world.gen.ChunkProviderOverworld; +import net.minecraftforge.common.BiomeDictionary; +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.GeologySampler; +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.GoldDistrict; +import zone.moddev.mc.realisticdeposits.model.DepositDefinition; + +/** Area-weighted native-terrain survey; no survey chunk is installed in the world. */ +@Mod(modid = "rdgoldsurvey", name = "Gold Reward Survey", version = "1", + dependencies = "required-after:realisticdeposits") +public final class GoldSurveyProbe { + private final Map> districts = new HashMap<>(); + private final Map terrain = new java.util.LinkedHashMap() { + @Override protected boolean removeEldestEntry(Map.Entry entry) { return size() > 128; } + }; + private WorldServer world; + private ChunkProviderOverworld generator; + private MineTerrainSampler heights; + private Optional geology; + private DepositDefinition lode; + private DepositDefinition placer; + + @Mod.EventHandler + public void started(FMLServerStartedEvent event) throws Exception { + if (!Boolean.getBoolean("rd.gold.survey")) return; + MinecraftServer server = FMLCommonHandler.instance().getMinecraftServerInstance(); + try { + world = server.worldServerForDimension(0); + if (!"gold-test-world".equals(world.getSaveHandler().getWorldDirectory().getName())) { + throw new IllegalStateException("The survey requires its disposable world"); + } + if (Boolean.getBoolean("rd.gold.survey.control")) { + control(); + return; + } + lode = RealisticDepositsRuntime.state().definitions().stream().filter(DepositDefinition::isGoldLode).findFirst().get(); + placer = RealisticDepositsRuntime.state().definitions().stream().filter(DepositDefinition::isGoldPlacer).findFirst().get(); + geology = OreSpawnApi.createSampler(world); + generator = new ChunkProviderOverworld(world, world.getSeed(), false, world.getWorldInfo().getGeneratorOptions()); + heights = new MineTerrainSampler(world, generator); + int loadedBefore = world.getChunkProvider().getLoadedChunkCount(); + int side = Integer.getInteger("rd.gold.survey.regions", 16); + int regionStart = 256; + int size = lode.regionSpacingChunks() * 16; + boolean nativeSediment = Boolean.getBoolean("rd.gold.survey.native_sediment"); + long lodeOre = 0; + java.util.Set orePositions = new java.util.HashSet<>(); + java.util.List productiveBodies = new java.util.ArrayList<>(); + JsonArray reserves = new JsonArray(); + JsonArray gravelSamples = new JsonArray(); + JsonArray placerSection = new JsonArray(); + int productive = 0; + int bodies = 0; + for (int rx = regionStart; !nativeSediment && rx < regionStart + side; rx++) { + for (int rz = regionStart; rz < regionStart + side; rz++) { + Optional selected = district(rx, rz); + if (!selected.isPresent()) continue; + GoldDistrict body = selected.get(); + long before = lodeOre; + bodies += body.veins().size(); + BlockBounds bounds = body.bounds(); + BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(); + for (int x = bounds.minimumX(); x <= bounds.maximumX(); x++) { + for (int z = bounds.minimumZ(); z <= bounds.maximumZ(); z++) { + int bottom = body.minimumCandidateYAt(x, z); + int top = body.maximumCandidateYAt(x, z); + if (bottom > top) continue; + + Chunk chunk = terrain(x >> 4, z >> 4); + top = Math.min(top, chunk.getHeightValue(x & 15, z & 15) - body.surfaceCover()); + for (int y = bottom; y <= top; y++) { + if (body.isOreAt(x, y, z) && natural(chunk.getBlockState(pos.setPos(x, y, z))) + && orePositions.add(pos.toLong())) lodeOre++; + } + } + } + if (lodeOre > before) { + productive++; + productiveBodies.add(body); + JsonObject reserve = new JsonObject(); + reserve.addProperty("x", body.centerX()); + reserve.addProperty("z", body.centerZ()); + reserve.addProperty("ore", lodeOre - before); + reserves.add(reserve); + } + } + System.out.println("Gold reserve survey row " + (rx - regionStart + 1) + "/" + side); + } + + int samples = Integer.getInteger("rd.gold.survey.chunks", 4096); + Random random = new Random(world.getSeed() ^ 0x795AB7C3D012461FL); + long rich = 0; + long eligible = 0; + long riverColumns = 0; + long actualRich = 0; + for (int sample = 0; sample < samples; sample++) { + int cx = regionStart * lode.regionSpacingChunks() + random.nextInt(side * lode.regionSpacingChunks()); + int cz = regionStart * lode.regionSpacingChunks() + random.nextInt(side * lode.regionSpacingChunks()); + Biome[] biomes = world.getBiomeProvider().getBiomes(null, cx * 16, cz * 16, 16, 16); + boolean river = false; + for (Biome biome : biomes) river |= BiomeDictionary.isBiomeOfType(biome, BiomeDictionary.Type.RIVER); + if (!river) continue; + Chunk chunk = nativeSediment ? populated(cx, cz) : terrain(cx, cz); + BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(); + for (int lx = 0; lx < 16; lx++) { + for (int lz = 0; lz < 16; lz++) { + if (!BiomeDictionary.isBiomeOfType(biomes[lx + lz * 16], BiomeDictionary.Type.RIVER)) continue; + riverColumns++; + int x = cx * 16 + lx; + int z = cz * 16 + lz; + GoldDistrict source = nearest(x, z); + if (source == null) continue; + int y = chunk.getHeightValue(lx, lz) - 1; + IBlockState state = chunk.getBlockState(pos.setPos(x, y, z)); + if (state.getMaterial() != Material.WATER) continue; + while (y > placer.minimumY() && state.getMaterial() == Material.WATER) { + state = chunk.getBlockState(pos.setPos(x, --y, z)); + } + int thickness = placer.goldPlacer().thickness().minimum() + (int) Math.floorMod( + source.identity().seed() ^ x ^ ((long) z << 32), + placer.goldPlacer().thickness().maximum() - placer.goldPlacer().thickness().minimum() + 1); + for (int depth = 0; depth < thickness && y - depth >= placer.minimumY(); depth++) { + int py = y - depth; + state = chunk.getBlockState(pos.setPos(x, py, z)); + boolean placedRich = state.getBlock().getRegistryName().toString().equals("realisticdeposits:gold_gravel"); + if (state.getBlock() != Blocks.SAND && state.getBlock() != Blocks.GRAVEL && !placedRich) break; + if (py > placer.maximumY() || !chunk.getBlockState(pos.setPos(x, py - 1, z)).getMaterial().isSolid()) continue; + eligible++; + if (placedRich) { + actualRich++; + if (gravelSamples.size() < 24) { + JsonObject location = new JsonObject(); + location.addProperty("x", x); + location.addProperty("y", py); + location.addProperty("z", z); + location.addProperty("source_x", source.centerX()); + location.addProperty("source_z", source.centerZ()); + gravelSamples.add(location); + } + if (placerSection.size() == 0) capturePlacerSection(x, z, placerSection); + } + if (GoldDistrict.placerPocket(source.identity().seed() ^ placer.salt(), x, z, depth, + placer.goldPlacer().coverage())) rich++; + } + } + } + if (nativeSediment && world.getChunkProvider().getLoadedChunkCount() > 800) { + world.getChunkProvider().unloadAllChunks(); + for (int pass = 0; pass < 12; pass++) world.getChunkProvider().tick(); + } + } + if (!nativeSediment && world.getChunkProvider().getLoadedChunkCount() != loadedBefore) { + throw new IllegalStateException("The in-memory survey loaded real world chunks"); + } + JsonObject report = new JsonObject(); + report.addProperty("seed", world.getSeed()); + report.addProperty("survey_region_side", side); + report.addProperty("represented_chunks", (long) side * side * size * size / 256); + report.addProperty("productive_districts", productive); + report.addProperty("veins", bodies); + report.addProperty("lode_ore", lodeOre); + report.addProperty("placer_sample_chunks", samples); + report.addProperty("river_columns", riverColumns); + report.addProperty("eligible_sediment", eligible); + report.addProperty("rich_gravel", rich); + report.addProperty("actual_rich_gravel", actualRich); + report.addProperty("native_sediment", nativeSediment); + report.addProperty("loaded_chunks_unchanged", !nativeSediment); + report.add("district_reserves", reserves); + report.add("rich_gravel_samples", gravelSamples); + report.add("placer_section", placerSection); + JsonArray distances = new JsonArray(); + if (!productiveBodies.isEmpty() && side > 2) { + for (int gx = 0; gx < 32; gx++) { + for (int gz = 0; gz < 32; gz++) { + int x = (regionStart + 1) * size + (int) ((gx + 0.5) * (side - 2) * size / 32); + int z = (regionStart + 1) * size + (int) ((gz + 0.5) * (side - 2) * size / 32); + double distance = Double.POSITIVE_INFINITY; + for (GoldDistrict body : productiveBodies) distance = Math.min(distance, body.distanceSquared(x, z)); + distances.add(new com.google.gson.JsonPrimitive(Math.sqrt(distance))); + } + } + } + report.add("prospecting_distances", distances); + try (FileWriter output = new FileWriter(new File("gold-survey.json"))) { + new GsonBuilder().setPrettyPrinting().create().toJson(report, output); + } + System.out.println("RD_GOLD_SURVEY_PASS"); + } finally { + server.initiateShutdown(); + } + } + + private Optional district(int rx, int rz) { + long key = ((long) rx << 32) ^ (rz & 0xffffffffL); + return districts.computeIfAbsent(key, ignored -> GoldDistrict.plan(lode, world.getSeed(), "minecraft:overworld", rx, rz, + new GoldDistrict.Site() { + @Override public int surfaceY(int x, int z) { return heights.naturalSurfaceY(x, z); } + @Override public String geome(int x, int z, int y) { + return geology.isPresent() ? geology.get().sampleColumn(x, z, y).geome().toString() : null; + } + })); + } + + private GoldDistrict nearest(int x, int z) { + int reach = placer.goldPlacer().maximumSourceDistance(); + int search = reach + (lode.lode().strikeLength().maximum() / 2 + 96); + int size = lode.regionSpacingChunks() * 16; + GoldDistrict closest = null; + double distance = (double) reach * reach; + for (int rx = Math.floorDiv(x - search, size); rx <= Math.floorDiv(x + search, size); rx++) { + for (int rz = Math.floorDiv(z - search, size); rz <= Math.floorDiv(z + search, size); rz++) { + Optional candidate = district(rx, rz); + if (candidate.isPresent() && candidate.get().distanceSquared(x, z) < distance) { + closest = candidate.get(); + distance = closest.distanceSquared(x, z); + } + } + } + return closest; + } + + private Chunk terrain(int cx, int cz) { + long key = ((long) cx << 32) ^ (cz & 0xffffffffL); + return terrain.computeIfAbsent(key, ignored -> generator.provideChunk(cx, cz)); + } + + private Chunk populated(int cx, int cz) { + net.minecraft.world.gen.ChunkProviderServer provider = world.getChunkProvider(); + for (int dx = -2; dx <= 2; dx++) { + for (int dz = -2; dz <= 2; dz++) provider.provideChunk(cx + dx, cz + dz); + } + for (int dx = -1; dx <= 1; dx++) { + for (int dz = -1; dz <= 1; dz++) { + provider.provideChunk(cx + dx, cz + dz).populateChunk(provider, provider.chunkGenerator); + } + } + return provider.getLoadedChunk(cx, cz); + } + + private static boolean natural(IBlockState state) { + if (state.getBlock() != Blocks.STONE) return false; + int meta = Blocks.STONE.getMetaFromState(state); + return meta == 0 || meta == 1 || meta == 3 || meta == 5; + } + + private void capturePlacerSection(int x, int z, JsonArray section) { + for (int px = x - 24; px <= x + 24; px++) { + Chunk chunk = world.getChunkProvider().getLoadedChunk(px >> 4, z >> 4); + if (chunk == null) throw new IllegalStateException("The river section extends beyond its populated sample"); + + for (int y = 45; y <= 80; y++) { + JsonArray cell = new JsonArray(); + cell.add(new com.google.gson.JsonPrimitive(px)); + cell.add(new com.google.gson.JsonPrimitive(y)); + cell.add(new com.google.gson.JsonPrimitive(chunk.getBlockState(new BlockPos(px, y, z)) + .getBlock().getRegistryName().toString())); + section.add(cell); + } + } + } + + private void control() throws Exception { + if (RealisticDepositsRuntime.state().definitions().stream().anyMatch(def -> def.isGold() && def.enabled())) { + throw new IllegalStateException("Vanilla controls must disable both gold definitions before startup"); + } + int base = 256 * 64; + int extent = Integer.getInteger("rd.gold.survey.regions", 16) * 64; + int side = 4; + int golds = 0; + int mesaChunks = 0; + Random random = new Random(world.getSeed() ^ 0x16E1C830A5572BL); + net.minecraft.world.gen.ChunkProviderServer provider = world.getChunkProvider(); + BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(); + // Spread the controls across the survey area instead of counting one small biome patch. + for (int gx = 0; gx < 4; gx++) { + for (int gz = 0; gz < 4; gz++) { + int startX = base + gx * extent / 4 + random.nextInt(Math.max(1, extent / 4 - side)); + int startZ = base + 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++) provider.provideChunk(cx, cz); + } + for (int cx = startX - 1; cx <= startX + side; cx++) { + for (int cz = startZ - 1; cz <= startZ + side; cz++) { + provider.provideChunk(cx, cz).populateChunk(provider, provider.chunkGenerator); + } + } + for (int cx = startX; cx < startX + side; cx++) { + for (int cz = startZ; cz < startZ + side; cz++) { + Chunk chunk = provider.getLoadedChunk(cx, cz); + if (world.getBiome(new BlockPos(cx * 16, 64, cz * 16)) instanceof net.minecraft.world.biome.BiomeMesa) mesaChunks++; + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + for (int y = 0; y < 128; y++) { + if (chunk.getBlockState(pos.setPos(cx * 16 + x, y, cz * 16 + z)).getBlock() == Blocks.GOLD_ORE) golds++; + } + } + } + } + } + provider.unloadAllChunks(); + for (int pass = 0; pass < 12; pass++) provider.tick(); + } + } + JsonObject report = new JsonObject(); + report.addProperty("seed", world.getSeed()); + report.addProperty("chunks", 16 * side * side); + report.addProperty("mesa_chunks", mesaChunks); + report.addProperty("gold_ore", golds); + try (FileWriter output = new FileWriter(new File("gold-survey.json"))) { + new GsonBuilder().setPrettyPrinting().create().toJson(report, output); + } + System.out.println("RD_GOLD_SURVEY_PASS"); + } +} diff --git a/src/test/java/zone/moddev/mc/realisticdeposits/generation/GoldWorkingsRuntimeChecks.java b/src/test/java/zone/moddev/mc/realisticdeposits/generation/GoldWorkingsRuntimeChecks.java new file mode 100644 index 0000000..6c4c6e3 --- /dev/null +++ b/src/test/java/zone/moddev/mc/realisticdeposits/generation/GoldWorkingsRuntimeChecks.java @@ -0,0 +1,236 @@ +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.GoldDistrict; +import zone.moddev.mc.realisticdeposits.geometry.MinePoint; +import zone.moddev.mc.realisticdeposits.model.AbandonedMineSettings; +import zone.moddev.mc.realisticdeposits.model.DepositDefinition; + +/** Gold access checks share the iron suite's exact block and reload assertions. */ +final class GoldWorkingsRuntimeChecks { + private GoldWorkingsRuntimeChecks() { } + + static void run(WorldServer world, String phase) throws Exception { + DepositDefinition captured = RealisticDepositsRuntime.state().definitions().stream() + .filter(DepositDefinition::isGoldLode).findFirst().get(); + JsonObject enabled = captured.toJson(); + enabled.addProperty("enabled", true); + DepositDefinition geometry = DepositCatalogLoader.loadDefinition(enabled, "gold mine geometry"); + String scenario = System.getProperty("rd.mines.scenario", "flat"); + boolean obstructed = "blocked".equals(scenario); + boolean treeObstruction = "normal-trees".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(); + GoldDistrict body = null; + AbandonedMinePlan plan = null; + + if ("first".equals(phase)) { + int loaded = world.getChunkProvider().getLoadedChunkCount(); + 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; + // Keep the walking fixture on open ground. Obstructed sections + // have their own protection checks. + if ((scenario.startsWith("normal") || "mineralogy".equals(scenario)) && !treeObstruction) { + 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; + } + + { + GoldDistrict 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; + 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(), "Gold mine search loaded a neighbouring chunk"); + require(body != null, "No safe gold 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("gold-target.properties", target); + } else { + try (FileInputStream input = new FileInputStream("gold-target.properties")) { + target.load(input); + } + + GoldDistrict 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)), "Gold 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) || 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, "Gold mine rejected: " + data.rejectionReason(key)); + require(plan.blocks().equals(data.plan(key).blocks()), "Native gold mine differs from its planner"); + if (!obstructed && !treeObstruction) { + // 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("gold-target.properties", target); + if ("flat".equals(scenario)) require(old.size() < allChunks(plan).size(), "No gold 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 goldfield 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("gold-target.properties", target); + } else if ("stable".equals(phase)) { + require(target.getProperty("completed_terrain").equals(terrainHash(world, allChunks(plan))), + "Reload changed the later-generated goldfield sections"); + } + require(target.getProperty("terrain").equals(terrainHash(world, old)), "Later workings excavated old chunks"); + } + + if (rejected) { + require(data.plan(key) == null, "Disabled or unsafe goldfield 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 gold 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 (treeObstruction) { + BlockPos leaves = new BlockPos(101304, 80, 122762); + require(world.getBlockState(leaves).getBlock() == Blocks.LEAVES, "The protected oak leaves changed"); + for (AbandonedMinePlan.Cell cell : plan.cellsInChunk(leaves.getX() >> 4, leaves.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 tree-blocked section was partly excavated"); + } + report.setProperty("tree_obstruction_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) { + return GoldDistrict.plan(definition, world.getSeed(), "minecraft:overworld", rx, rz, new GoldDistrict.Site() { + @Override public int surfaceY(int x, int z) { + return Boolean.getBoolean("rd.mines.wet") ? 129 : terrain.naturalSurfaceY(x, z); + } + + @Override public String geome(int x, int z, int y) { + return sampler.isPresent() ? sampler.get().sampleColumn(x, z, y).geome().toString() : null; + } + }); + } + + private static void store(String name, Properties properties) throws Exception { + try (FileOutputStream output = new FileOutputStream(name)) { + properties.store(output, "Disposable goldfield 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 6ac4180..a17f151 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,11 @@ 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.gold")) { + GoldWorkingsRuntimeChecks.run(world, phase); + LOG.info("RD_MINES_PASS {}", phase); + return; + } if (Boolean.getBoolean("rd.mines.pipe")) { PipeWorkingsRuntimeChecks.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 23c2635..55d6175 100644 --- a/src/test/java/zone/moddev/mc/realisticdeposits/generation/PolicyQualificationProbe.java +++ b/src/test/java/zone/moddev/mc/realisticdeposits/generation/PolicyQualificationProbe.java @@ -39,6 +39,7 @@ import zone.moddev.mc.realisticdeposits.geometry.CoalMeasuresBody; 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.model.DepositDefinition; /** Packaged-server qualification fixture, excluded from the public mod jars. */ @@ -60,7 +61,8 @@ public void initialize(FMLInitializationEvent event) { @SubscribeEvent(priority = EventPriority.LOWEST, receiveCanceled = true) public void observeIron(OreGenEvent.GenerateMinable event) { - OreGenEvent.GenerateMinable.EventType observed = diamond() ? OreGenEvent.GenerateMinable.EventType.DIAMOND + OreGenEvent.GenerateMinable.EventType observed = gold() ? OreGenEvent.GenerateMinable.EventType.GOLD + : diamond() ? OreGenEvent.GenerateMinable.EventType.DIAMOND : coal() ? OreGenEvent.GenerateMinable.EventType.COAL : OreGenEvent.GenerateMinable.EventType.IRON; if (event.getType() != observed) { return; @@ -241,7 +243,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.isCoal() + int radius = 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 + definition.seam().downDipWidth().maximum() * 0.5 + definition.seam().bendAmplitude() + 13.0); @@ -249,7 +252,12 @@ 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.isCoal() + MineableDepositBody body = 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; } + }).orElse(null) + : definition.isCoal() ? CoalMeasuresBody.plan(definition, worldSeed, "minecraft:overworld", rx, rz).orElse(null) : RegionDepositPlanner.plan(definition, worldSeed, "minecraft:overworld", rx, rz).orElse(null); @@ -278,7 +286,9 @@ private static Set plannedPositions(long worldSeed, int base) { private static boolean diamond() { return "diamond".equals(System.getProperty("rd.qualification.resource")); } - private static Block resource() { return diamond() ? Blocks.DIAMOND_ORE : coal() ? Blocks.COAL_ORE : Blocks.IRON_ORE; } + 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 Set diamondPositions(DepositDefinition definition, long seed, int base) { Set positions = new HashSet<>(); diff --git a/src/test/java/zone/moddev/mc/realisticdeposits/geometry/GoldDistrictTest.java b/src/test/java/zone/moddev/mc/realisticdeposits/geometry/GoldDistrictTest.java new file mode 100644 index 0000000..019851d --- /dev/null +++ b/src/test/java/zone/moddev/mc/realisticdeposits/geometry/GoldDistrictTest.java @@ -0,0 +1,130 @@ +package zone.moddev.mc.realisticdeposits.geometry; + +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import zone.moddev.mc.realisticdeposits.config.GoldCatalogTest; +import zone.moddev.mc.realisticdeposits.model.DepositDefinition; + +import static org.junit.jupiter.api.Assertions.*; + +public class GoldDistrictTest { + public static final GoldDistrict.Site MOUNTAIN = site("orespawn:mountain_belt", 80); + + public static GoldDistrict.Site site(String geome, int height) { + return new GoldDistrict.Site() { + @Override public int surfaceY(int x, int z) { return height; } + @Override public String geome(int x, int z, int surface) { return geome; } + }; + } + + public static GoldDistrict body(long seed) { + DepositDefinition definition = GoldCatalogTest.defaults().get(0); + for (int region = -16; region <= 16; region++) { + Optional planned = GoldDistrict.plan(definition, seed, "minecraft:overworld", + region, -2, MOUNTAIN); + if (planned.isPresent()) return planned.get(); + } + throw new AssertionError("No gold district in fixture search"); + } + + @Test + void repeatsTheSameDistrictWithSteepThinVeins() { + for (long seed = 10; seed < 18; seed++) { + GoldDistrict body = body(seed); + GoldDistrict again = body(seed); + assertEquals(body.identity().seed(), again.identity().seed()); + assertEquals(body.centerX(), again.centerX()); + assertEquals(body.centerZ(), again.centerZ()); + assertTrue(body.veins().size() >= 2 && body.veins().size() <= 4); + assertTrue(body.bounds().minimumY() >= 8); + assertTrue(body.bounds().maximumY() <= 80 - body.surfaceCover()); + for (GoldDistrict.Vein vein : body.veins()) { + assertTrue(vein.width() >= 1 && vein.width() <= 3); + assertTrue(vein.dipDegrees() >= 55 && vein.dipDegrees() <= 85); + assertTrue(vein.length() >= 256 && vein.length() <= 512); + } + assertThrows(UnsupportedOperationException.class, () -> body.veins().clear()); + } + } + + @Test + void geologyAndHeightRejectUnsuitableSitesWithoutChangingSelection() { + DepositDefinition definition = GoldCatalogTest.defaults().get(0); + for (int region = 0; region < 32; region++) { + assertFalse(GoldDistrict.plan(definition, 17, "minecraft:overworld", region, 0, + site("orespawn:sedimentary_basin", 80)).isPresent()); + assertFalse(GoldDistrict.plan(definition, 17, "minecraft:overworld", region, 0, + site("orespawn:mountain_belt", 20)).isPresent()); + assertFalse(GoldDistrict.plan(definition, 17, "minecraft:the_nether", region, 0, MOUNTAIN).isPresent()); + } + } + + @Test + void legacyApproximationIsExplicitRarerAndDeterministic() { + DepositDefinition definition = GoldCatalogTest.defaults().get(0); + int mountain = 0; + int legacy = 0; + for (int region = 0; region < 256; region++) { + if (GoldDistrict.plan(definition, 57, "minecraft:overworld", region, 0, MOUNTAIN).isPresent()) mountain++; + Optional cyano = GoldDistrict.plan(definition, 57, "minecraft:overworld", + region, 0, site("orespawn:cyano", 80)); + Optional absent = GoldDistrict.plan(definition, 57, "minecraft:overworld", + region, 0, site(null, 80)); + assertEquals(cyano.isPresent(), absent.isPresent()); + if (cyano.isPresent()) legacy++; + } + assertTrue(legacy > 0 && legacy < mountain / 2); + } + + @Test + void narrowColumnBoundsMatchAnExhaustiveSliceAndContainRichAndLeanSections() { + GoldDistrict body = body(71); + Set exhaustive = new HashSet<>(); + Set bounded = new HashSet<>(); + int richSections = 0; + int leanSections = 0; + + for (int along = -120; along <= 120; along += 3) { + int section = 0; + for (int height = body.centerY() - 16; height <= body.centerY() + 16; height++) { + MinePoint point = body.pointOnVein(0, along, height); + for (int dx = -2; dx <= 2; dx++) { + for (int dz = -2; dz <= 2; dz++) { + int x = point.x() + dx; + int z = point.z() + dz; + if (body.isOreAt(x, height, z)) { + MinePoint ore = new MinePoint(x, height, z); + exhaustive.add(ore); + section++; + if (height >= body.minimumCandidateYAt(x, z) && height <= body.maximumCandidateYAt(x, z)) { + bounded.add(ore); + } + } + } + } + } + if (section > 40) richSections++; + if (section < 8) leanSections++; + } + assertEquals(exhaustive, bounded); + assertTrue(exhaustive.size() > 1000, "The lode needs productive mining sections"); + assertTrue(richSections > 0 && leanSections > 0, "Gold must form shoots, not a solid wall"); + } + + @Test + void placerPatchesAreStableAndDoNotCoverEveryRiverColumn() { + int accepted = 0; + for (int x = -32; x < 32; x++) { + for (int z = -32; z < 32; z++) { + boolean selected = GoldDistrict.placerPocket(812, x, z, 0, 0.35); + assertEquals(selected, GoldDistrict.placerPocket(812, x, z, 0, 0.35)); + if (selected) accepted++; + } + } + assertTrue(accepted > 100 && accepted < 2500); + } +} diff --git a/tools/qualification/GOLD_TESTING.md b/tools/qualification/GOLD_TESTING.md new file mode 100644 index 0000000..7041cdf --- /dev/null +++ b/tools/qualification/GOLD_TESTING.md @@ -0,0 +1,86 @@ +# A walk around the goldfields + +Use the separate `RealisticDeposits 110 Gold` workspace for this local candidate. +Import its nested Gradle project into Eclipse, then run `prepareGoldDepositsManual` +from Gradle Tasks. Launch `runGoldDepositsClient`. + +The launch keeps its configuration and saves in `run-gold-deposits`. It does not +use or change the ordinary 0.3 testing worlds. Preparation copies only missing +catalogues; it does not overwrite settings you have edited. + +Create a fresh Creative world with cheats and seed `3346071746553592696`. +Leave Generate Structures on; both locations below were checked that way. +Existing worlds deliberately keep their captured rules and are not a shortcut +for testing newly added deposits. + +## What to look for + +- Enter a gold mine through its gradual surface entrance. Follow the main + gallery and inspect the surrounding veins in Spectator mode. +- Look for steep, narrow bodies with branches, pinched sections and richer + pockets. The entire fracture is not a wall of solid gold. +- Mine gold-bearing gravel from a river pocket. It leaves ordinary gravel and + sometimes a nugget. Try Fortune and Silk Touch, then place the spent gravel + again: it must not provide more gold. +- Save, quit and reload. Mine plans, excavated blocks and stored rich gravel + must remain intact. +- Check the gravel in your inventory and in the world. Its small gold flecks + should not obscure the gravel background. A resource pack can replace both + vanilla textures. + +Gold uses existing country rock. There is no vein-quartz block in this update, +and quartzite is not standing in for one. With Mineralogy active, inspect how +the veins cut across suitable natural rocks rather than following coal-like +bedding. Installing Mineralogy is optional. + +## A tested mine + +Without Mineralogy, the seed above has a tested entrance at **172331, 69, +187683**, with its main gallery near **172317, 34, 187699**. In Creative mode, +use `/tp @p 172331 70 187683`, then enter on foot. The packaged checks followed +the complete route and verified it again after saving and reloading. + +With Mineralogy installed, other ore bodies may block parts of that route. +Water and protected blocks can also leave a section sealed. The workings do +not remove those obstacles just to finish a tunnel. + +A placer is not guaranteed beside every entrance. For a quick gravel drop +test, take Gold-bearing Gravel from Creative inventory and place it on a +solid block before mining it. Use a fresh rich block for each trial: the +ordinary gravel left behind has already been spent. + +## A natural river pocket + +The same seed has gold-bearing gravel near **278056, 50, 272928**, below the +water beside the riverbank. Use `/tp @p 278056 66 272928`, then swim down or +switch to Spectator mode to look at the sediment. Night Vision helps. This +pocket was checked with normal structures enabled and again after reloading. + +The source district is nearby, around **277321, 272526**. The river is not +reshaped to point towards it, and the link does not simulate a whole watershed. +This is a separate stop from the mine above; they are far apart, so teleport +between them for this test rather than walking. + +Try a normal shovel, Fortune and Silk Touch on separate rich blocks. Expect +roughly one nugget per four blocks before Fortune, not a nugget every time. +Place the spent ordinary gravel and mine it again: it must not produce gold. +Silk-touched rich gravel should keep its identity when placed or allowed to fall. + +## Things worth checking before signing it off + +1. Walk the mine entrance and follow the gallery. Check that the slope feels + comfortable and that water, buildings and other ores are left alone. +2. Look around the veins in Spectator mode. Compare rich shoots, lean sections + and branches rather than expecting a continuous wall of gold. +3. Visit the river pocket and try the different harvesting methods above. +4. Save, quit and reload. Revisit both places and check your stored rich gravel. +5. Try a texture pack that changes vanilla gravel; the rich gravel should use + that background too. The automated client also checks this replacement. + +The defaults are scarcer overall than vanilla. The survey found about 22% of +vanilla's generated gold, concentrated into large fields rather than spread evenly. +Please judge discovery, mine size and rewards as well as the appearance. + +For a Mineralogy comparison, use its compatible 1.10.2 release in a separate +test world. These exact coordinates are the standalone example, not a promise +of the same open tunnel or sediment pocket with different terrain settings. diff --git a/tools/qualification/README.md b/tools/qualification/README.md index 4221a0a..679dd52 100644 --- a/tools/qualification/README.md +++ b/tools/qualification/README.md @@ -31,7 +31,7 @@ independently. These are test settings, not new gameplay defaults. ## Coalfield checks -Coalfield checks use the published OreSpawn 4.1.4.110021 dependency and its +Coalfield checks use the published OreSpawn 4.1.5.110021 dependency and its official checksum. The exact-host API is included in that release; a local OreSpawn build is not needed. @@ -111,7 +111,7 @@ Then run `python tools/qualification/qualify_110.py` with these arguments: | `--java` | Java 8 executable | | `--runtime` | An official Forge 1.10.2-12.18.3.2511 server installation, including libraries and an already accepted EULA | | `--mod` | The built Realistic Deposits release jar | -| `--orespawn` | Public OreSpawn 4.1.4.110021; its official checksum is checked | +| `--orespawn` | Public OreSpawn 4.1.5.110021; its official checksum is checked | | `--probe` | `build/qualification/policy-probe.jar` | | `--output` | A new disposable evidence directory, preferably under an ignored `run-*` folder | | `--jobs` | Concurrent servers; defaults to two | @@ -174,3 +174,30 @@ workings with active strata and no cross-chunk vanilla decoration. Native terrain cases populate the complete mine footprint before checking reloads; vanilla decoration in adjacent new chunks can otherwise change unworked ground. CI runs the controlled cases. + +## Gold and the combined regression pass + +The packaged CI driver covers iron, coal, diamonds and gold, 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 +fail the driver even when a probe has written a pass marker. + +`ci_runtime.py --only ` selects a smaller local pass. Its optional +`--runtime`, `--mod`, `--orespawn` and `--probe-directory` arguments reuse sealed +inputs; the engine must still match the public pin. They do not enable a local +engine override or change the supplied runtime. Without those options, CI +installs the checksum-pinned official Forge server and downloads the public +dependency itself. + +`gold_survey_110.py` measures six fixed seeds, reserving the last two as +holdouts. Run lode, `--control` and `--native-sediment` passes, then combine +their results with `gold_balance_110.py`. The report counts expected nuggets +and ingots, includes Fortune and records uncertainty without enforcing a +vanilla-equivalent reward target. All three passes must use the same mod, +engine and survey probe. `--structures --reload` additionally checks a manual +site under ordinary structure generation and after a clean restart. + +`gold_maps.py` draws measured lode sections or a `--placer` river section. +Keep the generated images with the disposable evidence, not in public jars. +The player-facing steps and fixed test locations are in [the gold walkthrough](GOLD_TESTING.md). diff --git a/tools/qualification/ci_runtime.py b/tools/qualification/ci_runtime.py index 9dc990d..a21ec1a 100644 --- a/tools/qualification/ci_runtime.py +++ b/tools/qualification/ci_runtime.py @@ -3,6 +3,7 @@ import argparse import hashlib import json +import re import shutil import subprocess import sys @@ -25,10 +26,62 @@ def download(url, target, expected): raise AssertionError(f"Unexpected download checksum: {url}") +def suite_plan(): + suites = {} + for resource in ("iron", "coal", "diamond", "gold"): + suites["policy-" + resource] = { + "script": "qualify_110.py", "probe": "policy-probe.jar", + "arguments": ["--resource", resource], "ports": True, + } + suites["policy-gold-mesa"] = { + "script": "qualify_110.py", "probe": "policy-probe.jar", + "arguments": ["--resource", "gold", "--mesa"], "ports": True, + } + for name, script, probe, cases, ports in ( + ("iron-workings", "mines_110.py", "mine-probe.jar", + ["flat", "off", "chance-zero", "wet", "order-reverse", "order-shuffle", "structure", "blocked"], False), + ("coal", "coal_110.py", "coal-probe.jar", ["standalone", "normal", "structure", "workings-flat"], False), + ("diamonds", "diamonds_110.py", "diamond-probe.jar", + ["standalone", "reverse", "source-off", "placer-off", "source-enable"], True), + ("gold", "gold_110.py", "gold-probe.jar", + ["standalone", "normal", "reverse", "source-off", "source-enable", "source-world-off", + "source-dimension-off", "placer-off"], 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", + ["flat", "reverse", "off", "chance-zero", "source-off", "structure", "wet", "blocked"], True), + ): + suites[name] = {"script": script, "probe": probe, "arguments": ["--cases", *cases], "ports": ports} + return suites + + +def suite_command(spec, java, runtime, mod, orespawn, probe, output, port): + command = [sys.executable, str(ROOT / "tools/qualification" / spec["script"]), + "--java", str(java), "--runtime", str(runtime), "--mod", str(mod), + "--orespawn", str(orespawn), "--probe", str(probe), "--output", str(output), + *spec["arguments"]] + if spec["ports"]: + command.extend(["--base-port", str(port)]) + return command + + +def check_runtime_log(text): + errors = [line for line in text.splitlines() + if re.search(r"/(ERROR|FATAL)\]|Tried to assign a mutable BlockPos|\bGL ERROR\b", line) + and "FML appears to be missing any signature data" not in line] + assert not errors, "Unexpected packaged-runtime log errors: " + "\n".join(errors) + + def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--java", required=True, type=Path) parser.add_argument("--output", type=Path, default=ROOT / "build/qualification/packaged") + parser.add_argument("--runtime", type=Path, help="Reuse an existing official runtime without modifying it") + parser.add_argument("--mod", type=Path, help="Use a sealed local release jar") + parser.add_argument("--orespawn", type=Path, help="Use an already downloaded, checksum-verified public jar") + parser.add_argument("--probe-directory", type=Path, default=ROOT / "build/qualification") + parser.add_argument("--only", nargs="+", choices=tuple(suite_plan()), help="Run selected suites locally") + parser.add_argument("--base-port", type=int, default=32000) args = parser.parse_args() properties = dict(line.split("=", 1) for line in (ROOT / "gradle.properties").read_text().splitlines() if "=" in line and not line.startswith("#")) @@ -39,54 +92,55 @@ def main(): candidate = output / "candidate" candidate.mkdir() mod = candidate / f"RealisticDeposits-{properties['mod_version']}.jar" - probe = candidate / "policy-probe.jar" - shutil.copy2(ROOT / "build/libs" / mod.name, mod) - shutil.copy2(ROOT / "build/qualification/policy-probe.jar", probe) - installer = output / "forge-installer.jar" - download("https://maven.minecraftforge.net/net/minecraftforge/forge/1.10.2-12.18.3.2511/" - "forge-1.10.2-12.18.3.2511-installer.jar", installer, INSTALLER_HASH) - runtime = output / "runtime" - runtime.mkdir() - with (output / "install.log").open("w") as log: - subprocess.run([str(args.java), "-jar", str(installer), "--installServer", str(runtime)], - cwd=runtime, stdout=log, stderr=subprocess.STDOUT, check=True, timeout=600) + shutil.copy2(args.mod or ROOT / "build/libs" / mod.name, mod) + plan = suite_plan() + selected = args.only or list(plan) + assert len(set(selected)) == len(selected), "Do not replay a suite in the same evidence directory" + for name in {plan[suite]["probe"] for suite in selected}: + shutil.copy2(args.probe_directory / name, candidate / name) + + runtime = args.runtime.resolve() if args.runtime else output / "runtime" + if not args.runtime: + installer = output / "forge-installer.jar" + download("https://maven.minecraftforge.net/net/minecraftforge/forge/1.10.2-12.18.3.2511/" + "forge-1.10.2-12.18.3.2511-installer.jar", installer, INSTALLER_HASH) + runtime.mkdir() + with (output / "install.log").open("w") as log: + subprocess.run([str(args.java), "-jar", str(installer), "--installServer", str(runtime)], + cwd=runtime, stdout=log, stderr=subprocess.STDOUT, check=True, timeout=600, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) + (runtime / "eula.txt").write_text("eula=true\n") forge = runtime / "forge-1.10.2-12.18.3.2511-universal.jar" if hashlib.sha256(forge.read_bytes()).hexdigest().upper() != FORGE_HASH: raise AssertionError("The installed Forge server changed") - (runtime / "eula.txt").write_text("eula=true\n") + assert "eula=true" in (runtime / "eula.txt").read_text(), "Accept the supplied runtime's EULA first" orespawn = output / f"OreSpawn-{properties['orespawn_version']}.jar" file_id = properties["orespawn_curse_file_id"] - download(f"https://cursemaven.com/curse/maven/mmd-orespawn-245586/{file_id}/" - f"mmd-orespawn-245586-{file_id}.jar", orespawn, properties["orespawn_sha256"]) - command = [sys.executable, str(ROOT / "tools/qualification/qualify_110.py"), - "--java", str(args.java), "--runtime", str(runtime), - "--mod", str(mod), "--orespawn", str(orespawn), "--probe", str(probe), - "--output", str(output / "matrix"), "--jobs", "2"] - for resource in ("iron", "diamond"): - matrix_output = output / ("matrix" if resource == "iron" else "diamond-matrix") - matrix_command = list(command) - matrix_command[matrix_command.index("--output") + 1] = str(matrix_output) - matrix_command.extend(["--resource", resource]) - subprocess.run(matrix_command, check=True) - result = json.loads((matrix_output / "results.json").read_text()) - if not result["passed"] or len(result["cases"]) != 32: - raise AssertionError(f"The complete {resource} policy matrix did not pass") - - diamond_probe = candidate / "diamond-probe.jar" - shutil.copy2(ROOT / "build/qualification/diamond-probe.jar", diamond_probe) - subprocess.run([sys.executable, str(ROOT / "tools/qualification/diamonds_110.py"), - "--java", str(args.java), "--runtime", str(runtime), "--mod", str(mod), - "--orespawn", str(orespawn), "--probe", str(diamond_probe), - "--output", str(output / "diamond-runtime"), - "--cases", "standalone", "reverse", "source-off", "placer-off", "source-enable"], check=True) - - mine_probe = candidate / "mine-probe.jar" - shutil.copy2(ROOT / "build/qualification/mine-probe.jar", mine_probe) - subprocess.run([sys.executable, str(ROOT / "tools/qualification/pipe_mines_110.py"), - "--java", str(args.java), "--runtime", str(runtime), "--mod", str(mod), - "--orespawn", str(orespawn), "--probe", str(mine_probe), - "--output", str(output / "pipe-workings"), - "--cases", "flat", "reverse", "off", "chance-zero", "source-off", "structure", "wet", "blocked"], check=True) + if args.orespawn: + shutil.copy2(args.orespawn, orespawn) + else: + download(f"https://cursemaven.com/curse/maven/mmd-orespawn-245586/{file_id}/" + f"mmd-orespawn-245586-{file_id}.jar", orespawn, properties["orespawn_sha256"]) + assert hashlib.sha256(orespawn.read_bytes()).hexdigest().upper() == properties["orespawn_sha256"] + + completed = [] + for index, name in enumerate(selected): + suite = plan[name] + target = output / name + command = suite_command(suite, args.java, runtime, mod, orespawn, + candidate / suite["probe"], target, args.base_port + index * 100) + subprocess.run(command, check=True) + result = json.loads((target / "results.json").read_text()) + assert result["passed"], f"Packaged suite failed: {name}" + assert not list(target.rglob("crash-*.txt")), f"Packaged suite produced a crash: {name}" + for log in target.rglob("*.log"): + check_runtime_log(log.read_text(encoding="utf-8", errors="replace")) + if name.startswith("policy-"): + assert len(result["cases"]) == 32 and len(result["switches"]) == 2 + completed.append(name) + (output / "results.json").write_text(json.dumps({ + "passed": len(completed) == len(selected), "expected_suites": selected, "suites": completed, + }, indent=2) + "\n") if __name__ == "__main__": diff --git a/tools/qualification/coal_110.py b/tools/qualification/coal_110.py index 9b62618..a6613b0 100644 --- a/tools/qualification/coal_110.py +++ b/tools/qualification/coal_110.py @@ -17,7 +17,7 @@ def run(java, root, phase, case): command = [str(java), "-Xms256m", "-Xmx2G", "-XX:ActiveProcessorCount=2", "-Drd.coal.enabled=true", f"-Drd.coal.phase={phase}", - f"-Drd.coal.workings={str(case == 'workings').lower()}", + f"-Drd.coal.workings={str(case in ('workings', 'workings-flat')).lower()}", f"-Drd.coal.survey={str(case == 'survey').lower()}", f"-Drd.coal.structure={str(case == 'structure').lower()}", "-jar", FORGE_JAR, "nogui"] with (root / f"{phase}.log").open("w", encoding="utf-8") as output: @@ -77,7 +77,7 @@ def main(): parser.add_argument("--mineralogy", type=Path) parser.add_argument("--geology-profile", type=Path, help="Read-only reference for the inactive-strata fixture") parser.add_argument("--local-orespawn-sha256", help="Explicit checksum for an optional unpublished test candidate") - parser.add_argument("--cases", nargs="+", choices=("standalone", "normal", "mineralogy", "inactive", "legacy", "workings", "structure"), + parser.add_argument("--cases", nargs="+", choices=("standalone", "normal", "mineralogy", "inactive", "legacy", "workings", "workings-flat", "structure"), default=("standalone", "normal", "mineralogy")) args = parser.parse_args() assert not args.output.exists(), "Use a new evidence directory" @@ -99,9 +99,17 @@ def main(): results = [] for case in args.cases: root = prepare(args, args.output / case, case in ("mineralogy", "inactive", "legacy"), case in ("normal", "workings")) - if case in ("workings", "structure"): + if case == "workings-flat": + # Isolate the coal entrance from lakes, decoration and other deposit workings. + for name in ("vanilla", "diamonds", "gold"): + catalogue = json.loads((ROOT / f"src/main/resources/assets/realisticdeposits/defaults/{name}.json") + .read_text(encoding="utf-8")) + for definition in catalogue["deposits"]: + definition["enabled"] = False + write_json(root / f"config/realisticdeposits/deposits/{name}.json", catalogue) + if case in ("workings", "workings-flat", "structure"): coal = json.loads((ROOT / "src/main/resources/assets/realisticdeposits/defaults/coal.json").read_text(encoding="utf-8")) - if case == "workings": + if case in ("workings", "workings-flat"): coal["deposits"][0]["abandoned_mine"] = {"enabled": True, "chance": 1.0} else: coal["deposits"][0]["interaction"] = "replacement" diff --git a/tools/qualification/coal_upgrade_110.py b/tools/qualification/coal_upgrade_110.py index 7ade023..78d0086 100644 --- a/tools/qualification/coal_upgrade_110.py +++ b/tools/qualification/coal_upgrade_110.py @@ -52,7 +52,7 @@ def main(): parser.add_argument("--" + name, required=True, type=Path) parser.add_argument("--local-orespawn-sha256", help="Explicit checksum for an optional unpublished test candidate") parser.add_argument("--old-mod-sha256", help="Exact checksum of a different, explicitly selected old release") - parser.add_argument("--new-rules", choices=("coal", "diamond"), default="coal") + parser.add_argument("--new-rules", choices=("coal", "diamond", "gold"), default="coal") parser.add_argument("--mineralogy", type=Path) parser.add_argument("--legacy-profile", type=Path, help="Read-only geology reference for a separate legacy-coal-layer comparison") @@ -111,7 +111,8 @@ def main(): profile = json.loads((target / profile_relative).read_text(encoding="utf-8")) assert captured_settings(profile) == captured_settings(original_profile), "World settings were changed" assert digest(target / "config/realisticdeposits/deposits/vanilla.json") == original_config - new_rule = "overworld_diamond_" if args.new_rules == "diamond" else "overworld_coal_measures" + new_rule = {"diamond": "overworld_diamond_", "gold": "overworld_gold_", + "coal": "overworld_coal_measures"}[args.new_rules] assert not any(new_rule in key for key in profile["ores"]), "New rules were injected into an old world" completed[name] = report assert completed["control"] == completed["upgraded"], "The update changed later-generated terrain or coal supply" diff --git a/tools/qualification/frozen_mines_110.py b/tools/qualification/frozen_mines_110.py index b59b591..81e1465 100644 --- a/tools/qualification/frozen_mines_110.py +++ b/tools/qualification/frozen_mines_110.py @@ -1,9 +1,11 @@ """Complete an older saved shaft on disposable control and upgraded servers.""" import argparse +import copy import json import shutil import subprocess +import zipfile from pathlib import Path from coal_110 import prepare @@ -15,6 +17,17 @@ ROOT = Path(__file__).resolve().parents[2] +def check_saved_profile(before, after, version): + expected = copy.deepcopy(before) + for settings in expected.get("ore_source_policies", {}).values(): + for source in settings.get("candidates", []): + if (source.get("owner") == "realisticdeposits" + and str(source.get("source_id", "")).startswith("realisticdeposits:") + and "owner_version" in source): + source["owner_version"] = version + assert after == expected, "Saved settings changed beyond this mod's displayed source version" + + def frozen_boot(java, root, phase): command = [str(java), "-Xms256m", "-Xmx2G", "-XX:ActiveProcessorCount=2", "-Drd.frozen.enabled=true", f"-Drd.frozen.phase={phase}", "-jar", FORGE_JAR, "nogui"] @@ -57,6 +70,9 @@ def main(): setattr(args, name, destination) write_json(args.output / "artifacts.json", {p.name: digest(p) for p in inputs.iterdir()}) current = args.mod + with zipfile.ZipFile(current) as archive: + metadata = json.loads(archive.read("mcmod.info")) + current_version = next(mod["version"] for mod in metadata if mod["modid"] == "realisticdeposits") args.mod = args.old_mod args.probe = args.old_probe args.port = 48670 @@ -80,7 +96,11 @@ def main(): (target / "server.properties").write_text(settings.replace("48670", str(48671 + index)), encoding="utf-8", newline="\n") profile = target / "mine-test-world/serverconfig/orespawn-worldgen.json" profile_hash = digest(profile) + original_profile = json.loads(profile.read_text()) complete = frozen_boot(args.java, target, "complete") + if name == "upgraded": + check_saved_profile(original_profile, json.loads(profile.read_text()), current_version) + profile_hash = digest(profile) assert frozen_boot(args.java, target, "reload") == complete assert digest(profile) == profile_hash, "Saved profile changed" reports.append(complete) diff --git a/tools/qualification/gold_110.py b/tools/qualification/gold_110.py new file mode 100644 index 0000000..70f76c2 --- /dev/null +++ b/tools/qualification/gold_110.py @@ -0,0 +1,183 @@ +"""Check gold lodes and river placers in disposable packaged Forge worlds.""" + +import argparse +import copy +import json +import shutil +import subprocess +from pathlib import Path + +from coal_110 import prepare +from qualify_110 import FORGE_JAR, checked_orespawn_sha256, digest, write_json +from ci_runtime import FORGE_HASH + + +ROOT = Path(__file__).resolve().parents[2] + + +def run(args, root, phase, case): + command = [str(args.java), "-Xms256m", "-Xmx2G", "-XX:ActiveProcessorCount=2", + "-Drd.gold.enabled=true", f"-Drd.gold.phase={phase}", + f"-Drd.gold.fixture_river={str(case not in ('normal', 'mineralogy', 'inactive', 'legacy')).lower()}", + f"-Drd.gold.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}; evidence retained") + log = (root / f"{phase}.log").read_text(encoding="utf-8", errors="replace") + assert code == 0 and f"RD_GOLD_PASS {phase}" in log, f"Failed: {root}/{phase}" + unexpected = [line for line in log.splitlines() if ("/ERROR]" in line or "/FATAL]" in line) + and "FML appears to be missing any signature data" not in line] + unexpected.extend(line for line in log.splitlines() if "Tried to assign a mutable BlockPos" in line) + assert not unexpected and not list((root / "crash-reports").glob("*")), unexpected + if (root / "logs/fml-server-latest.log").is_file(): + shutil.copy2(root / "logs/fml-server-latest.log", root / f"{phase}.fml.log") + print(f"PASS {case}/{phase}", flush=True) + return json.loads((root / f"gold-{phase}.json").read_text()) + + +def same(left, right): + for key in ("terrain_sha256", "gold_sha256", "lode_ore", "rich_gravel", "background_ore"): + assert left[key] == right[key], f"Saved sample changed: {key}" + + +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("--local-orespawn-sha256", help="Exact checksum of the deliberately selected local test candidate") + parser.add_argument("--geology-profile", type=Path, help="Read-only active-strata fixture for inactive and legacy cases") + parser.add_argument("--base-port", type=int, default=48800) + parser.add_argument("--cases", nargs="+", default=("standalone", "normal", "source-off", "placer-off", "reverse"), + choices=("standalone", "normal", "source-off", "source-enable", "source-world-off", + "source-dimension-off", "placer-off", "reverse", "mineralogy", "inactive", "legacy")) + args = parser.parse_args() + assert not args.output.exists(), "Use a new evidence directory" + assert digest(args.runtime / FORGE_JAR) == FORGE_HASH + checked_orespawn_sha256(args.orespawn, args.local_orespawn_sha256) + args.output.mkdir(parents=True) + inputs = args.output / "inputs" + inputs.mkdir() + for name in ("mod", "orespawn", "probe", "mineralogy"): + 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", "gold-test-world") + if not natural: + # Fixed hosts make the chunk-order comparison independent of vanilla's + # cross-chunk lakes and decoration. Normal cases retain that decoration. + properties = properties.replace(";1;decoration", ";7;") + (root / "server.properties").write_text(properties, encoding="utf-8", newline="\n") + gold = json.loads((ROOT / "src/main/resources/assets/realisticdeposits/defaults/gold.json").read_text()) + if not natural: + gold["deposits"][0]["alignment"] = "massive_rare" + gold["deposits"][0].pop("geomes") + if case in ("source-off", "source-enable"): + gold["deposits"][0]["enabled"] = False + if case == "placer-off": + gold["deposits"][1]["enabled"] = False + write_json(root / "config/realisticdeposits/deposits/gold.json", gold) + 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:")} + profile.get("providers", {}).pop("realisticdeposits", None) + profile.get("ore_providers", {}).pop("realisticdeposits", None) + profile.pop("ore_sources", None) + if case == "inactive": + profile["terrain_dimensions"]["minecraft:overworld"]["enabled"] = False + else: + profile["geology_mode"] = "legacy" + profile["cyano"]["realistic_coal_layers"] = True + write_json(root / "config/orespawn-worldgen.json", profile) + first = run(args, root, "first", case) + sample = first["first"] + if case in ("source-off", "source-enable"): + assert sample["shallow_ore"] == 0 and sample["rich_gravel"] == 0 + else: + assert sample["lode_ore"] > 0, "The gold lode did not generate" + if case in ("standalone", "reverse"): + assert sample["rich_gravel"] > 0, "The river placer did not generate" + if case == "placer-off": + assert sample["rich_gravel"] == 0 + profile = root / "gold-test-world/serverconfig/orespawn-worldgen.json" + explicitly_enabled = None + disabled_profile = None + if case in ("source-world-off", "source-dimension-off"): + captured = json.loads(profile.read_text()) + source_rule = captured["ores"]["realisticdeposits:deposit/realisticdeposits/overworld_gold_lodes"] + if case == "source-world-off": + source_rule["enabled"] = False + else: + source_rule["dimensions"]["minecraft:overworld"]["enabled"] = False + write_json(profile, captured) + disabled_profile = copy.deepcopy(captured) + for candidate in disabled_profile["ore_source_policies"]["orespawn:gold|minecraft:overworld"]["candidates"]: + if candidate["source_id"] == "realisticdeposits:deposit/realisticdeposits/overworld_gold_lodes": + candidate["placement_active"] = False + if case == "source-enable": + captured = json.loads(profile.read_text()) + for rule_id, rule in captured["ores"].items(): + if rule_id.startswith("realisticdeposits:") and "overworld_gold_" in rule_id: + rule["enabled"] = True + write_json(profile, captured) + explicitly_enabled = captured + profile_hash = digest(profile) + for phase in ("reload", "later", "stable"): + current = run(args, root, phase, case) + same(first["first"], current["first"]) + if case == "source-enable" and phase == "reload": + refreshed = json.loads(profile.read_text()) + # Enabling a source refreshes OreSpawn's derived source directory. + for key in set(explicitly_enabled) | set(refreshed): + if key not in ("ore_sources", "ore_source_policies"): + assert explicitly_enabled.get(key) == refreshed.get(key), f"World setting changed: {key}" + before_policies = explicitly_enabled["ore_source_policies"] + after_policies = refreshed["ore_source_policies"] + for key, previous in before_policies.items(): + assert key in after_policies, "An existing source policy disappeared" + old_choices = {name: value for name, value in previous.items() if name != "candidates"} + new_choices = {name: value for name, value in after_policies[key].items() if name != "candidates"} + assert old_choices == new_choices, "Enabling gold changed an existing source choice" + added = set(after_policies) - set(before_policies) + assert added <= {"realisticdeposits:gold_gravel|minecraft:overworld"} + profile_hash = digest(profile) + elif disabled_profile is not None and phase == "reload": + assert json.loads(profile.read_text()) == disabled_profile, "Disabling the source changed an unrelated world setting" + profile_hash = digest(profile) + else: + assert digest(profile) == profile_hash, "Saved profile changed" + if phase == "later": + later = current["later"] + if case in ("source-world-off", "source-dimension-off"): + assert later["shallow_ore"] == 0 and later["rich_gravel"] == 0, "A disabled world source still fed placers" + if case == "source-enable": + assert later["shallow_ore"] > 0 and later["rich_gravel"] > 0, "The explicit world override did not enable gold" + elif phase == "stable": + same(later, current["later"]) + results.append({"case": case, "passed": True, "sample": sample, "profile_sha256": profile_hash}) + if case == "reverse": + forward = next((item for item in results if item["case"] == "standalone"), None) + if forward: + same(forward["sample"], sample) + write_json(args.output / "results.json", {"passed": True, "cases": results}) + + +if __name__ == "__main__": + main() diff --git a/tools/qualification/gold_balance_110.py b/tools/qualification/gold_balance_110.py new file mode 100644 index 0000000..23fa539 --- /dev/null +++ b/tools/qualification/gold_balance_110.py @@ -0,0 +1,104 @@ +"""Report gold reserves and rewards without imposing a vanilla-equivalent budget.""" + +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_candidate(artifacts): + candidates = set() + probes = set() + for record in artifacts: + mods = [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(mods) == 1 and engines == [ORESPAWN_SHA256], "Use the exact public engine and one mod jar" + candidates.add(mods[0]) + probes.add(record["gold-survey.jar"]) + assert len(candidates) == len(probes) == 1, "Every survey must use the same immutable mod and probe" + return candidates.pop() + + +def reward_row(lode, river, control): + assert river["native_sediment"], "Use fully decorated river samples, not just the terrain estimate" + primary = lode["lode_ore"] / lode["represented_chunks"] + placer = river["actual_rich_gravel"] / (36 * river["placer_sample_chunks"]) + vanilla = control["gold_ore"] / control["chunks"] + background = vanilla * 0.15 + productive = lode["productive_districts"] + distances = sorted(lode.get("prospecting_distances", [])) + return { + "seed": lode["seed"], "set": lode["set"], "lodes": primary, "placers": placer, + "background": background, "total_ingots": primary + placer + background, + "vanilla_ingots": vanilla, "productive_districts": productive, + "mean_reserve_per_district": lode["lode_ore"] / productive if productive else None, + "characteristic_district_spacing_blocks": (lode["represented_chunks"] * 256 / productive) ** 0.5 + if productive else None, + "horizontal_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 ratio_interval(rows): + random_source = random.Random(830157) + estimates = [] + for _ in range(10000): + sample = [random_source.choice(rows) for _ in rows] + estimates.append(sum(row["total_ingots"] for row in sample) / sum(row["vanilla_ingots"] for row in sample)) + estimates.sort() + return [estimates[249], estimates[9749]] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + for name in ("lodes", "controls", "sediment", "output"): + parser.add_argument("--" + name, required=True, type=Path) + args = parser.parse_args() + assert not args.output.exists(), "Keep earlier reports and choose another output" + inputs = [args.lodes, args.controls, args.sediment] + lodes, controls, sediment = [{row["seed"]: row for row in json.loads(path.read_text())} for path in inputs] + assert set(lodes) == set(controls) == set(sediment) == set(SEEDS) + + artifacts = [json.loads((path.parent / "artifacts.json").read_text()) for path in inputs] + candidate = checked_candidate(artifacts) + rows = [reward_row(lodes[seed], sediment[seed], controls[seed]) for seed in SEEDS] + + report = {"candidate_sha256": candidate, "seeds": rows, "groups": {}, + "inputs": {str(path): digest(path) for path in inputs}, "limitations": [ + "Lode reserves are a natural-terrain estimate before other ore and structure collisions.", + "Background rewards apply the verified 15% policy to matched decorated controls.", + "Characteristic spacing is the square root of surveyed area per district, not a guaranteed walking distance.", + "Prospecting distances are horizontal distances to an estimated productive district, not a walking route or guarantee of finding its entrance.", + "Whole-seed intervals show rare-deposit uncertainty; no vanilla-equivalent reward target is imposed.", + "Gold ore smelts to one ingot in vanilla 1.10; Fortune affects placer nuggets, not those ore blocks.", + "Third-party ore processing is outside these vanilla-smelting reward estimates.", + ]} + for name in ("calibration", "holdout", "all"): + selected = [row for row in rows if name == "all" or row["set"] == name] + total = statistics.mean(row["total_ingots"] for row in selected) + vanilla = statistics.mean(row["vanilla_ingots"] for row in selected) + report["groups"][name] = { + "seeds": len(selected), "expected_ingots_per_chunk": total, + "vanilla_ingots_per_chunk": vanilla, "ratio": total / vanilla, + "seed_bootstrap_95_percent_ratio": ratio_interval(selected), + "source_share": {key: statistics.mean(row[key] for row in selected) / total + for key in ("lodes", "placers", "background")}, + "fortune_expected_ingots_per_chunk": { + str(level): statistics.mean(row["lodes"] + row["background"] + row["placers"] * bonus for row in selected) + for level, bonus in enumerate((1, 4 / 3, 7 / 4, 11 / 5)) + }, + } + report["passed"] = True + write_json(args.output, report) + print(json.dumps(report["groups"], indent=2)) + + +if __name__ == "__main__": + main() diff --git a/tools/qualification/gold_client_110.ps1 b/tools/qualification/gold_client_110.ps1 new file mode 100644 index 0000000..5a9d62e --- /dev/null +++ b/tools/qualification/gold_client_110.ps1 @@ -0,0 +1,214 @@ +param( + [Parameter(Mandatory = $true)] [string] $RunDirectory, + [Parameter(Mandatory = $true)] [string] $JavaPath, + [Parameter(Mandatory = $true)] [string] $GameInstall, + [Parameter(Mandatory = $true)] [string] $ForgeRuntime, + [Parameter(Mandatory = $true)] [string] $ModJar, + [Parameter(Mandatory = $true)] [string] $OreSpawnJar, + [Parameter(Mandatory = $true)] [string] $ProbeJar, + [string] $LocalOreSpawnSha256 +) + +$ErrorActionPreference = 'Stop' + +function Assert-ClientLog([string] $LogPath) { + $errors = @(Get-Content -LiteralPath $LogPath | Where-Object { + $_ -match '/(ERROR|FATAL)\]|Tried to assign a mutable BlockPos|\bGL ERROR\b' -and + $_ -notlike '*FML appears to be missing any signature data*' + }) + + if ($errors.Count -gt 0) { + throw "The packaged client logged an unexpected error. See ${LogPath}: $($errors[0])" + } +} + +function New-HostTestPack([string] $ClientPath, [string] $TargetDirectory) { + $packs = Join-Path $TargetDirectory 'resourcepacks' + New-Item -ItemType Directory -Path $packs | Out-Null + $pack = [IO.Compression.ZipFile]::Open((Join-Path $packs 'gold-host-test.zip'), + [IO.Compression.ZipArchiveMode]::Create) + $client = [IO.Compression.ZipFile]::OpenRead($ClientPath) + + try { + $writer = [IO.StreamWriter]::new($pack.CreateEntry('pack.mcmeta').Open()) + try { + $writer.Write('{"pack":{"pack_format":2,"description":"Gold gravel host check"}}') + } + finally { + $writer.Dispose() + } + + # Reuse an unmodified vanilla texture so the comparison has exact expected pixels. + $source = $client.GetEntry('assets/minecraft/textures/blocks/diamond_block.png').Open() + $destination = $pack.CreateEntry('assets/minecraft/textures/blocks/gravel.png').Open() + try { + $source.CopyTo($destination) + } + finally { + $destination.Dispose() + $source.Dispose() + } + } + finally { + $client.Dispose() + $pack.Dispose() + } +} + +$runDir = $RunDirectory +$installDir = $GameInstall +$versionDir = Join-Path $installDir 'versions\1.10.2' +$versionJson = Join-Path $versionDir '1.10.2.json' +$clientJar = Join-Path $versionDir '1.10.2.jar' +$forgeJar = Join-Path $ForgeRuntime 'forge-1.10.2-12.18.3.2511-universal.jar' +$java = $JavaPath +$orespawnJar = $OreSpawnJar +$realisticDepositsJar = $ModJar +$clientProbe = $ProbeJar +$nativeDir = Join-Path $runDir 'natives' + +if (Test-Path -LiteralPath $runDir) { throw "Disposable run already exists: $runDir" } +foreach ($required in @($versionJson, $clientJar, $forgeJar, $java, $orespawnJar, $realisticDepositsJar, $clientProbe)) { + if (-not (Test-Path -LiteralPath $required -PathType Leaf)) { throw "Missing input: $required" } +} +New-Item -ItemType Directory -Path (Join-Path $runDir 'mods') -Force | Out-Null +New-Item -ItemType Directory -Path $nativeDir -Force | Out-Null +Copy-Item -LiteralPath $orespawnJar -Destination (Join-Path $runDir 'mods') +Copy-Item -LiteralPath $realisticDepositsJar -Destination (Join-Path $runDir 'mods') +Copy-Item -LiteralPath $clientProbe -Destination (Join-Path $runDir 'mods') +$expectedOreSpawn = '1E580EBB8D93B1256E2E81C7D0D815DEAAAC58A028CB5A77A52773B62048E010' +if ($LocalOreSpawnSha256) { + if ($LocalOreSpawnSha256 -cnotmatch '^[0-9A-Fa-f]{64}$') { throw 'Supply the complete candidate SHA-256' } + $expectedOreSpawn = $LocalOreSpawnSha256.ToUpperInvariant() +} +if ((Get-FileHash -LiteralPath $orespawnJar -Algorithm SHA256).Hash -ne $expectedOreSpawn) { + throw 'OreSpawn does not match the selected public or local dependency' +} +if ((Get-FileHash -LiteralPath $forgeJar -Algorithm SHA256).Hash -ne '809C2EA5B4D4F3E5A294D3A326744F20FEC412A1263E708297D00F512E377542') { + throw 'Forge does not match the qualified runtime' +} + +$libraryRoots = @( + (Join-Path $installDir 'libraries'), + (Join-Path $env:APPDATA '.minecraft\libraries'), + (Join-Path $ForgeRuntime 'libraries') +) +$version = Get-Content -LiteralPath $versionJson -Raw | ConvertFrom-Json +if ((Get-FileHash -LiteralPath $clientJar -Algorithm SHA1).Hash -ne $version.downloads.client.sha1.ToUpperInvariant()) { + throw 'The Minecraft client does not match its official version metadata' +} +# The server-side copy has a manifest pointing to an adjacent Minecraft server +# jar. Stage Forge alone so those classes cannot enter this client launch. +$stagedForge = Join-Path $runDir 'forge-client.jar' +Copy-Item -LiteralPath $forgeJar -Destination $stagedForge +$forgeJar = $stagedForge +$classpath = New-Object System.Collections.Generic.List[string] +$seen = @{} + +function Resolve-Library([string] $relativePath) { + foreach ($root in $libraryRoots) { + $path = Join-Path $root ($relativePath -replace '/', '\') + if (Test-Path -LiteralPath $path -PathType Leaf) { return $path } + } + throw "Missing Minecraft library: $relativePath" +} + +function Add-Classpath([string] $path) { + $resolved = (Resolve-Path -LiteralPath $path).Path + $key = $resolved.ToLowerInvariant() + if (-not $seen.ContainsKey($key)) { + $seen[$key] = $true + $classpath.Add($resolved) + } +} + +function Test-WindowsRule($library) { + if (-not $library.rules) { return $true } + $allowed = $false + foreach ($rule in $library.rules) { + if (-not $rule.os -or $rule.os.name -eq 'windows') { $allowed = $rule.action -eq 'allow' } + } + return $allowed +} + +Add-Type -AssemblyName System.IO.Compression.FileSystem +New-HostTestPack $clientJar $runDir +foreach ($library in $version.libraries) { + if (-not (Test-WindowsRule $library)) { continue } + if ($library.downloads.artifact.path) { + Add-Classpath (Resolve-Library $library.downloads.artifact.path) + } + if ($library.natives.windows) { + $classifier = $library.downloads.classifiers.([string] $library.natives.windows) + if ($classifier.path) { + $archive = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Library $classifier.path)) + try { + foreach ($entry in $archive.Entries) { + if (-not $entry.Name -or $entry.FullName.StartsWith('META-INF/')) { continue } + $target = Join-Path $nativeDir $entry.Name + [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $target, $true) + } + } + finally { $archive.Dispose() } + } + } +} + +Get-ChildItem (Join-Path $ForgeRuntime 'libraries') -Recurse -Filter '*.jar' | ForEach-Object { + Add-Classpath $_.FullName +} +Add-Classpath $clientJar +Add-Classpath $forgeJar + +$arguments = @( + '-Xms512m', '-Xmx3g', + '-Dclientprobe.enabled=true', + "-Dminecraft.client.jar=$clientJar", + "-Djava.library.path=$nativeDir", + "-Dorg.lwjgl.librarypath=$nativeDir", + "-Dnet.java.games.input.librarypath=$nativeDir", + '-cp', ($classpath -join [System.IO.Path]::PathSeparator), + 'net.minecraft.launchwrapper.Launch', + '--username', 'OreSpawnTester', + '--version', '1.10.2-forge-12.18.3.2511', + '--gameDir', $runDir, + '--assetsDir', (Join-Path $installDir 'assets'), + '--assetIndex', '1.10', + '--uuid', '00000000000000000000000000000002', + '--accessToken', '0', + '--userType', 'mojang', + '--versionType', 'Forge', + '--tweakClass', 'net.minecraftforge.fml.common.launcher.FMLTweaker' +) + +$start = [System.Diagnostics.ProcessStartInfo]::new() +$start.FileName = $java +$start.WorkingDirectory = $runDir +$start.UseShellExecute = $false +$start.CreateNoWindow = $true +$start.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden +$start.RedirectStandardOutput = $true +$start.RedirectStandardError = $true +foreach ($argument in $arguments) { $start.ArgumentList.Add($argument) } + +$process = [System.Diagnostics.Process]::Start($start) +$stdout = $process.StandardOutput.ReadToEndAsync() +$stderr = $process.StandardError.ReadToEndAsync() +if (-not $process.WaitForExit(360000)) { + $process.Kill($true) + $process.WaitForExit() + [System.IO.File]::WriteAllText((Join-Path $runDir 'client.log'), $stdout.GetAwaiter().GetResult()) + [System.IO.File]::WriteAllText((Join-Path $runDir 'client-stderr.log'), $stderr.GetAwaiter().GetResult()) + throw 'Gold client timed out; its evidence is retained' +} +[System.IO.File]::WriteAllText((Join-Path $runDir 'client.log'), $stdout.GetAwaiter().GetResult()) +[System.IO.File]::WriteAllText((Join-Path $runDir 'client-stderr.log'), $stderr.GetAwaiter().GetResult()) +if ($process.ExitCode -ne 0) { throw "Packaged client exit code: $($process.ExitCode)" } +if (-not (Test-Path -LiteralPath (Join-Path $runDir 'gold-client-pass.properties'))) { + throw 'The gold client checks did not complete' +} +if (Get-ChildItem -LiteralPath (Join-Path $runDir 'crash-reports') -File -ErrorAction SilentlyContinue) { + throw 'The gold client produced a crash report' +} +Assert-ClientLog (Join-Path $runDir 'client.log') +Get-Content -LiteralPath (Join-Path $runDir 'gold-client-pass.properties') diff --git a/tools/qualification/gold_engine_upgrade_110.py b/tools/qualification/gold_engine_upgrade_110.py new file mode 100644 index 0000000..dceb433 --- /dev/null +++ b/tools/qualification/gold_engine_upgrade_110.py @@ -0,0 +1,222 @@ +"""Check the Mesa correction on copies of public OreSpawn gold-policy worlds.""" + +import argparse +import copy +import json +import shutil +from concurrent.futures import ThreadPoolExecutor, as_completed +from pathlib import Path + +import qualify_110 as policy + +PRE_MESA_RELEASE_SHA256 = "DB254E9E865685A74262DE9D469A006AB3C8053573CC6136FD4A8591AD806CA3" + + +def load(path): + return json.loads(path.read_text(encoding="utf-8")) + + +def check_global_metadata(before, after): + expected = copy.deepcopy(before) + for name, rule in after.get("ores", {}).items(): + previous = expected.get("ores", {}).get(name) + if (previous is not None and name.startswith("realisticdeposits:deposit/") + and previous.get("source_provider") == "realisticdeposits" + and rule.get("orphaned_provider") is True): + previous["orphaned_provider"] = True + assert after == expected, "Reload changed settings beyond the public provider-status metadata" + + +def set_port(root, port): + settings = root / "server.properties" + lines = settings.read_text().splitlines() + settings.write_text("\n".join(f"server-port={port}" if line.startswith("server-port=") else line + for line in lines) + "\n", encoding="utf-8", newline="\n") + + +def check_custom_profile_refresh(before, after): + expected = copy.deepcopy(before) + group = "orespawn:gold|minecraft:overworld" + expected["ore_source_policies"][group]["candidates"] = after["ore_source_policies"][group]["candidates"] + assert expected == after, "The public reload changed a setting instead of refreshing its candidate list" + + +def check_engine_version_metadata(before, after, version): + expected = copy.deepcopy(before) + for group, settings in expected.get("ore_source_policies", {}).items(): + previous = settings.get("candidates", []) + current = after["ore_source_policies"][group].get("candidates", []) + assert len(previous) == len(current), "Engine upgrade changed the source candidates" + for old, new in zip(previous, current): + if (old.get("owner") == "orespawn" and "owner_version" in old + and new.get("owner_version") == version): + old["owner_version"] = version + assert expected == after, "Engine upgrade changed settings beyond its displayed version" + + +def scenario(args, origin, mode, managed, index, custom=False): + name = f"{origin}-{mode}-{'on' if managed else 'off'}" + source = args.source_matrix / name + fingerprint = policy.world_fingerprint(source / "world") + root = args.output / (name + ("-custom-channel" if custom else "")) + shutil.copytree(source, root) + + control = args.output / (root.name + "-public-control") + shutil.copytree(source, control) + set_port(control, args.base_port + 64 + index) + + old = list((root / "mods").glob("OreSpawn-*.jar")) + assert len(old) == 1 + policy.checked_orespawn_sha256(old[0], PRE_MESA_RELEASE_SHA256) + old[0].rename(root / "retained-public-orespawn.jar") + shutil.copy2(args.orespawn, root / "mods" / args.orespawn.name) + + profile_path = root / "world/serverconfig/orespawn-worldgen.json" + if custom: + profile = load(profile_path) + profile["ores"]["orespawn:legacy/gold_ore"]["dimensions"]["minecraft:overworld"][ + "placement_channel"] = "qualification:custom_gold" + policy.write_json(profile_path, profile) + policy.write_json(control / "world/serverconfig/orespawn-worldgen.json", profile) + + profile_hash = policy.digest(profile_path) + set_port(root, args.base_port + index) + + before = load(source / "qualification-first.json")["first"] + public = policy.run(args, control, "reload", label="public-engine-reload") + policy.same_sample(before, public["first"]) + control_profile = control / "world/serverconfig/orespawn-worldgen.json" + if custom: + check_custom_profile_refresh(load(profile_path), load(control_profile)) + # Save the edited channel with the public engine first, then upgrade + # that settled save. Keep the pre-edit copies as evidence too. + assert root.resolve().parent == args.output.resolve() + for name in ("world", "config"): + original = root / name + retained = root / ("before-custom-" + name) + assert original.resolve().parent == root.resolve() and not retained.exists() + original.rename(retained) + shutil.copytree(control / name, original) + profile_hash = policy.digest(profile_path) + else: + assert policy.digest(control_profile) == profile_hash + expected_profile = load(profile_path) + if args.mod: + previous_mods = list((root / "mods").glob("RealisticDeposits-*.jar")) + assert len(previous_mods) == 1 + previous_mods[0].rename(root / "retained-previous-realisticdeposits.jar") + shutil.copy2(args.mod, root / "mods" / args.mod.name) + for path in (source / "config").rglob("*"): + if not path.is_file(): + continue + relative = path.relative_to(source / "config") + after = control / "config" / relative + if relative.as_posix() == "orespawn-worldgen.json": + check_global_metadata(load(path), load(after)) + else: + assert policy.digest(path) == policy.digest(after), "The public control changed a custom/imported file" + config_hashes = {path.relative_to(control / "config"): policy.digest(path) + for path in (control / "config").rglob("*") if path.is_file()} + old_later_path = source / "qualification-later.json" + old_later = load(old_later_path)["later"] if old_later_path.exists() else None + properties = {"rd.qualification.laterBase": 320} + if old_later is not None: + properties["rd.qualification.protectedBase"] = 128 + + assert not (root / "world/region/r.10.10.mca").exists(), "Later sample was already generated" + + for phase in ("reload", "later", "stable"): + report = policy.run(args, root, phase, properties, label="engine-upgrade-" + phase) + policy.same_sample(before, report["first"]) + assert report["first"]["native_iron_events"] == 0, "Old chunks were regenerated" + if old_later is not None: + policy.same_sample(old_later, report["protected"]) + if phase == "later": + if custom: + assert report["later"]["ordinary_iron"] > 0, "Custom channel was scaled or suppressed" + assert report["later"]["ordinary_outside_import_height"] == 0, "Mesa duplicated the custom rule" + assert report["later"]["massive_iron"] > 0 + assert report["later"]["denied_native_iron_events"] == 81 + else: + policy.check_profile(report, origin, mode, managed) + policy.check_generation(report["later"], origin, mode, managed) + policy.same_sample(before, report["first_after_later"]) + later = report["later"] + elif phase == "stable": + policy.same_sample(later, report["later"]) + assert report["later"]["native_iron_events"] == 0 + + if phase == "reload" and args.orespawn_version: + check_engine_version_metadata(expected_profile, load(profile_path), args.orespawn_version) + profile_hash = policy.digest(profile_path) + else: + assert policy.digest(profile_path) == profile_hash, "The engine rewrote saved world settings" + actual = {path.relative_to(root / "config"): policy.digest(path) + for path in (root / "config").rglob("*") if path.is_file()} + if phase == "reload" and args.orespawn_version: + assert set(actual) == set(config_hashes), "The engine changed the configuration file set" + for relative, expected in config_hashes.items(): + if relative.as_posix() == "orespawn-worldgen.json": + check_engine_version_metadata(load(control / "config" / relative), + load(root / "config" / relative), args.orespawn_version) + else: + assert actual[relative] == expected, "A custom/imported configuration file changed" + config_hashes = actual + assert actual == config_hashes, "Custom/imported settings differ from the public reload control" + + assert policy.world_fingerprint(source / "world") == fingerprint, "A public-release source world changed" + result = {"case": root.name, "passed": True, "boots": 4, "old_chunks_unchanged": True, + "settings_unchanged": True, "source_unchanged": True, "custom_channel": custom, + "global_metadata_matches_public_control": True, + "engine_version_metadata": args.orespawn_version, + "later": later} + policy.write_json(root / "engine-upgrade-result.json", result) + print("PASS engine upgrade " + root.name, flush=True) + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + for name in ("java", "orespawn", "source-matrix", "output"): + parser.add_argument("--" + name, required=True, type=Path) + parser.add_argument("--local-orespawn-sha256", help="Explicit checksum for an unpublished test candidate") + parser.add_argument("--orespawn-version", help="Permit only this displayed engine-version metadata refresh") + parser.add_argument("--mod", type=Path, help="Use the repinned Realistic Deposits jar after the public control") + parser.add_argument("--jobs", type=int, default=2) + parser.add_argument("--base-port", type=int, default=29100) + parser.add_argument("--only", nargs="+", help="Run these exact case names in a fresh evidence directory") + args = parser.parse_args() + candidate = policy.checked_orespawn_sha256(args.orespawn, args.local_orespawn_sha256) + assert not args.output.exists(), "Keep earlier evidence; choose a new directory" + args.output.mkdir(parents=True) + policy.RESOURCE_KEY = "gold_ore" + policy.DEPOSIT_ID = "realisticdeposits:deposit/realisticdeposits/qualification_gold" + cases = [(origin, mode, managed, False) for origin in policy.ORIGINS + for mode in policy.POLICIES for managed in (False, True)] + cases.extend((origin, "replacement", True, True) for origin in ("os3", "existing-override")) + if args.only: + cases = [case for case in cases if + f"{case[0]}-{case[1]}-{'on' if case[2] else 'off'}" + + ("-custom-channel" if case[3] else "") in args.only] + assert len(cases) == len(set(args.only)), "Unknown or duplicate upgrade case names" + results, failures = [], [] + + with ThreadPoolExecutor(max_workers=args.jobs) as pool: + pending = {pool.submit(scenario, args, origin, mode, managed, index, custom): + (origin, mode, managed, custom) + for index, (origin, mode, managed, custom) in enumerate(cases)} + for future in as_completed(pending): + try: + results.append(future.result()) + except Exception as error: + failures.append({"case": pending[future], "error": repr(error)}) + print("FAIL engine upgrade", pending[future], repr(error), flush=True) + + policy.write_json(args.output / "results.json", {"passed": not failures, "orespawn_sha256": candidate, + "cases": results, "failures": failures, + "boots": len(results) * 4}) + assert not failures, "Engine upgrade checks failed; evidence is preserved" + + +if __name__ == "__main__": + main() diff --git a/tools/qualification/gold_maps.py b/tools/qualification/gold_maps.py new file mode 100644 index 0000000..09c0849 --- /dev/null +++ b/tools/qualification/gold_maps.py @@ -0,0 +1,74 @@ +"""Draw labelled gold sections from the exact packaged-server measurements.""" + +import argparse +import json +from pathlib import Path + +from PIL import Image, ImageDraw + + +COLOURS = { + "minecraft:air": "#e5eff5", "minecraft:stone": "#8d9091", "minecraft:bedrock": "#41464b", + "minecraft:gold_ore": "#e3b431", "realisticdeposits:gold_gravel": "#f0c849", + "minecraft:water": "#5592c8", "minecraft:flowing_water": "#5592c8", + "minecraft:grass": "#6e974f", "minecraft:dirt": "#9c795a", "minecraft:sand": "#dac995", + "minecraft:gravel": "#b1a69c", "minecraft:sandstone": "#cdbd87", + "minecraft:coal_ore": "#4a4b49", "minecraft:iron_ore": "#af8e76", + "minecraft:redstone_ore": "#a44c4c", "minecraft:diamond_ore": "#54bfc0", + "minecraft:leaves": "#6c8e54", "minecraft:log": "#81634b", + "minecraft:cobblestone": "#70767c", "minecraft:mossy_cobblestone": "#6f846c", + "minecraft:chest": "#ab8c51", + "minecraft:lava": "#df7839", "minecraft:flowing_lava": "#df7839", + "mineralogy:slate": "#555d70", "mineralogy:phyllite": "#6c7372", + "mineralogy:schist": "#7b8377", "mineralogy:amphibolite": "#414f48", + "mineralogy:gneiss": "#a3a398", "mineralogy:shale": "#846f5a", + "mineralogy:siltstone": "#b09b7c", "mineralogy:limestone": "#b8c8ac", + "mineralogy:basalt": "#505252", "mineralogy:dolomite": "#b8aa96", +} + + +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("--placer", action="store_true") + parser.add_argument("--title", default="Gold veins and their surrounding rock") + args = parser.parse_args() + assert not args.output.exists(), "Keep the earlier map and choose another filename" + + report = json.loads(args.report.read_text(encoding="utf-8")) + sample = report if args.placer else report[args.sample] + cells = sample["placer_section" if args.placer else "section"] + assert cells, "The measured section is empty" + z = sample["rich_gravel_samples"][0]["z"] if args.placer else sample["centre_z"] + left_x, right_x = 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) + used = sorted({cell[2] for cell in cells}) + scale = 5 if not args.placer else 10 + width = max(780, (right_x - left_x + 1) * scale + 100) + height = (top - bottom + 1) * scale + 150 + 20 * ((len(used) + 2) // 3) + image = Image.new("RGB", (width, height), "#f9faf8") + draw = ImageDraw.Draw(image) + draw.text((60, 15), args.title, fill="#222b31") + draw.text((60, 33), f"Vertical slice at Z {z}; X and Y are world coordinates", fill="#3f4a50") + + for x, y, block in cells: + px, py = 60 + (x - left_x) * scale, 60 + (top - y) * scale + draw.rectangle((px, py, px + scale - 1, py + scale - 1), fill=COLOURS.get(block, "#a09a93")) + for y in range((bottom // 10 + 1) * 10, top + 1, 10 if args.placer else 20): + draw.text((10, 58 + (top - y) * scale), f"Y {y}", fill="#222b31") + axis_y = 65 + (top - bottom + 1) * scale + for x in (left_x, (left_x + right_x) // 2, right_x): + draw.text((50 + (x - left_x) * scale, axis_y), str(x), fill="#222b31") + for index, block in enumerate(used): + px, py = 15 + index % 3 * 250, axis_y + 30 + index // 3 * 20 + draw.rectangle((px, py, px + 12, py + 12), fill=COLOURS.get(block, "#a09a93")) + draw.text((px + 18, py), block.split(":")[1].replace("_", " "), fill="#222b31") + draw.text((15, height - 25), "Measured blocks, not a schematic. Yellow marks gold ore or gold-bearing gravel.", fill="#3f4a50") + args.output.parent.mkdir(parents=True, exist_ok=True) + image.save(args.output) + + +if __name__ == "__main__": + main() diff --git a/tools/qualification/gold_mines_110.py b/tools/qualification/gold_mines_110.py new file mode 100644 index 0000000..5ce0624 --- /dev/null +++ b/tools/qualification/gold_mines_110.py @@ -0,0 +1,114 @@ +"""Check goldfield workings, saved plans and safe opt-outs on disposable servers.""" + +import argparse +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, 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("--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=48920) + 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-trees", "normal-structures", "blocked", "mineralogy", "mineralogy-flat", "regular")) + args = parser.parse_args() + 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-trees", "normal-structures", "mineralogy", "regular") + root = prepare(args, args.output / case, mineralogy=case.startswith("mineralogy"), normal=natural) + if args.strata_only_profile: + assert case == "mineralogy", "The strata-only control is a separate Mineralogy walking check" + profile = json.loads(args.strata_only_profile.read_text()) + assert profile["schema_version"] == 8 + for name, rule in profile["ores"].items(): + if name.startswith("mineralogy:"): + rule["enabled"] = False + 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"): + 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"] == "gold_lode" and case != "source-off" + if definition["shape"]["type"] != "gold_lode": + 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.gold": "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", "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 / "gold-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 pipe 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/gold_survey_110.py b/tools/qualification/gold_survey_110.py new file mode 100644 index 0000000..be846e8 --- /dev/null +++ b/tools/qualification/gold_survey_110.py @@ -0,0 +1,128 @@ +"""Measure gold geometry and native river sediments without loading survey chunks.""" + +import argparse +import json +import shutil +import subprocess +from pathlib import Path + +from coal_110 import prepare +from qualify_110 import FORGE_JAR, checked_orespawn_sha256, digest, write_json +from ci_runtime import FORGE_HASH + + +SEEDS = (3346071746553592696, 741209317, -893146051, 608145372901, -2719046037, 49277810623) +ROOT = Path(__file__).resolve().parents[2] + + +def load_catalogue(name): + return json.loads((ROOT / f"src/main/resources/assets/realisticdeposits/defaults/{name}.json") + .read_text(encoding="utf-8")) + + +def control_catalogues(): + catalogues = {name: load_catalogue(name) for name in ("vanilla", "coal", "diamonds", "gold")} + for catalogue in catalogues.values(): + for definition in catalogue["deposits"]: + definition["enabled"] = False + return catalogues + + +def checked_indices(indices): + if not indices or len(set(indices)) != len(indices) or any(index < 0 or index >= len(SEEDS) for index in indices): + raise ValueError("Select distinct indices from the six fixed survey seeds") + return indices + + +def run_survey(command, root, stage): + with (root / f"{stage}.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}") + log = (root / f"{stage}.log").read_text(encoding="utf-8", errors="replace") + assert code == 0 and "RD_GOLD_SURVEY_PASS" in log, f"Survey failed: {root}/{stage}" + errors = [line for line in log.splitlines() if ("/ERROR]" in line or "/FATAL]" in line) + and "FML appears to be missing any signature data" not in line] + assert not errors, f"Survey logged an error: {errors}" + assert not list((root / "crash-reports").glob("*")), f"Survey crash: {root}" + + +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("--local-orespawn-sha256", help="Exact checksum of the deliberately selected local test candidate") + parser.add_argument("--chunks", type=int, default=4096) + parser.add_argument("--seed-index", type=int, nargs="+", default=list(range(len(SEEDS)))) + parser.add_argument("--control", action="store_true") + parser.add_argument("--native-sediment", action="store_true") + parser.add_argument("--structures", action="store_true", help="Use ordinary structure generation for manual-site checks") + parser.add_argument("--reload", action="store_true", help="Repeat the same measurement after a clean save and restart") + args = parser.parse_args() + checked_indices(args.seed_index) + assert not (args.control and args.native_sediment), "Choose controls or river measurements, not both" + assert args.regions > 0 and args.chunks > 0 + assert not args.output.exists(), "Keep earlier evidence; choose a new output directory" + assert digest(args.runtime / FORGE_JAR) == FORGE_HASH + checked_orespawn_sha256(args.orespawn, args.local_orespawn_sha256) + args.output.mkdir(parents=True) + inputs = args.output / "inputs" + inputs.mkdir() + for name in ("mod", "orespawn", "probe"): + path = getattr(args, name) + target = inputs / path.name + shutil.copy2(path, target) + setattr(args, name, target) + write_json(args.output / "artifacts.json", {p.name: digest(p) for p in inputs.iterdir()}) + results = [] + for index in args.seed_index: + seed = SEEDS[index] + args.port = 48900 + index + (32 if args.control else 64 if args.native_sediment else 0) + root = prepare(args, args.output / str(index), normal=True) + properties = (root / "server.properties").read_text().replace("coal-test-world", "gold-test-world") + properties = properties.replace("3346071746553592696", str(seed)) + if args.structures: + properties = properties.replace("generate-structures=false", "generate-structures=true") + (root / "server.properties").write_text(properties, encoding="utf-8", newline="\n") + if args.control: + for name, catalogue in control_catalogues().items(): + write_json(root / f"config/realisticdeposits/deposits/{name}.json", catalogue) + command = [str(args.java), "-Xms256m", "-Xmx3G", "-XX:ActiveProcessorCount=2", + "-Drd.gold.survey=true", f"-Drd.gold.survey.regions={args.regions}", + f"-Drd.gold.survey.control={str(args.control).lower()}", + f"-Drd.gold.survey.native_sediment={str(args.native_sediment).lower()}", + f"-Drd.gold.survey.chunks={args.chunks}", "-jar", FORGE_JAR, "nogui"] + run_survey(command, root, "survey") + report = json.loads((root / "gold-survey.json").read_text()) + if args.reload: + shutil.copy2(root / "gold-survey.json", root / "gold-survey-first.json") + profile = root / "gold-test-world/serverconfig/orespawn-worldgen.json" + captured = digest(profile) + run_survey(command, root, "survey-reload") + assert json.loads((root / "gold-survey.json").read_text()) == report, "Reload changed the measured deposits" + assert digest(profile) == captured, "Reload changed the saved world profile" + report["reload_unchanged"] = True + report["structures"] = args.structures + report["set"] = "calibration" if index < 4 else "holdout" + if args.control: + report["ingots_per_chunk"] = report["gold_ore"] / report["chunks"] + else: + report["lode_ingots_per_chunk"] = report["lode_ore"] / report["represented_chunks"] + report["placer_ingots_per_chunk"] = report["rich_gravel"] / (36 * report["placer_sample_chunks"]) + report["maximum_placer_ingots_per_chunk"] = report["eligible_sediment"] / (36 * report["placer_sample_chunks"]) + report["actual_placer_ingots_per_chunk"] = report["actual_rich_gravel"] / (36 * report["placer_sample_chunks"]) + results.append(report) + write_json(args.output / "results.json", results) + print(f"PASS seed {index} ({report['set']}): " + f"{report.get('lode_ore', report.get('gold_ore', 0))} ore, " + f"{report.get('actual_rich_gravel', 0)} decorated river gravel", flush=True) + + +if __name__ == "__main__": + main() diff --git a/tools/qualification/mines_110.py b/tools/qualification/mines_110.py index 523fdc0..7a0d067 100644 --- a/tools/qualification/mines_110.py +++ b/tools/qualification/mines_110.py @@ -102,6 +102,10 @@ def main(): if "abandoned_mine" in definition: definition["abandoned_mine"]["enabled"] = False write_json(root / "config/realisticdeposits/deposits/diamonds.json", diamonds) + gold = json.loads((ROOT / "src/main/resources/assets/realisticdeposits/defaults/gold.json").read_text()) + for definition in gold["deposits"]: + definition["enabled"] = False + write_json(root / "config/realisticdeposits/deposits/gold.json", gold) if case == "flat": phases = ["first", "complete", "reload"] elif normal: diff --git a/tools/qualification/probes.init.gradle b/tools/qualification/probes.init.gradle index b071338..87ffcef 100644 --- a/tools/qualification/probes.init.gradle +++ b/tools/qualification/probes.init.gradle @@ -1,5 +1,55 @@ gradle.afterProject { project, state -> if (project == project.rootProject && state.failure == null) { + def goldInput = project.tasks.register('goldQualificationProbeInput', Jar) { + dependsOn project.tasks.named('testClasses') + from project.sourceSets.test.output.classesDirs + include 'zone/moddev/mc/realisticdeposits/generation/GoldRuntimeProbe*.class' + include 'zone/moddev/mc/realisticdeposits/generation/GoldGravelRuntimeChecks*.class' + archiveFileName = 'gold-probe-deobf.jar' + destinationDirectory = project.layout.buildDirectory.dir('qualification') + } + def goldPackaged = project.renamer.classes(goldInput) { + map.from project.minecraft.dependency.toSrgFile + output = project.layout.buildDirectory.file('qualification/gold-probe.jar') + } + project.tasks.register('prepareGoldQualification') { + group = 'verification' + description = 'Builds the isolated gold deposit server probe.' + dependsOn goldPackaged + } + def goldSurveyInput = project.tasks.register('goldSurveyProbeInput', Jar) { + dependsOn project.tasks.named('testClasses') + from project.sourceSets.test.output.classesDirs + include 'zone/moddev/mc/realisticdeposits/generation/GoldSurveyProbe*.class' + archiveFileName = 'gold-survey-deobf.jar' + destinationDirectory = project.layout.buildDirectory.dir('qualification') + } + def goldSurveyPackaged = project.renamer.classes(goldSurveyInput) { + map.from project.minecraft.dependency.toSrgFile + output = project.layout.buildDirectory.file('qualification/gold-survey.jar') + } + project.tasks.register('prepareGoldSurvey') { + group = 'verification' + description = 'Builds the isolated gold reserve and river survey.' + dependsOn goldSurveyPackaged + } + def goldClientInput = project.tasks.register('goldClientProbeInput', Jar) { + dependsOn project.tasks.named('testClasses') + from project.sourceSets.test.output.classesDirs + include 'zone/moddev/mc/realisticdeposits/client/GoldClientProbe*.class' + archiveFileName = 'gold-client-deobf.jar' + destinationDirectory = project.layout.buildDirectory.dir('qualification') + } + def goldClientPackaged = project.renamer.classes(goldClientInput) { + map.from project.minecraft.dependency.toSrgFile + output = project.layout.buildDirectory.file('qualification/gold-client.jar') + } + project.tasks.register('prepareGoldClient') { + group = 'verification' + description = 'Builds the isolated gold gravel client checks.' + dependsOn goldClientPackaged + } + def surveyInput = project.tasks.register('diamondSurveyProbeInput', Jar) { dependsOn project.tasks.named('testClasses') from project.sourceSets.test.output.classesDirs @@ -93,6 +143,7 @@ gradle.afterProject { project, state -> from project.sourceSets.test.output.classesDirs include 'zone/moddev/mc/realisticdeposits/generation/MineWorkingsRuntimeProbe*.class' include 'zone/moddev/mc/realisticdeposits/generation/PipeWorkingsRuntimeChecks*.class' + include 'zone/moddev/mc/realisticdeposits/generation/GoldWorkingsRuntimeChecks*.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 5bc75e6..ddb6dfa 100644 --- a/tools/qualification/qualify_110.py +++ b/tools/qualification/qualify_110.py @@ -11,7 +11,7 @@ from pathlib import Path -ORESPAWN_SHA256 = "DB254E9E865685A74262DE9D469A006AB3C8053573CC6136FD4A8591AD806CA3" +ORESPAWN_SHA256 = "1E580EBB8D93B1256E2E81C7D0D815DEAAAC58A028CB5A77A52773B62048E010" FORGE_JAR = "forge-1.10.2-12.18.3.2511-universal.jar" POLICIES = ("additive", "hybrid-default", "hybrid-15", "replacement") ORIGINS = ("native", "os1", "os3", "existing-override") @@ -19,6 +19,10 @@ RESOURCE_KEY = "iron_ore" +def disabled_control_key(): + return "lapis_ore" if RESOURCE_KEY == "gold_ore" else "gold_ore" + + def digest(path): return hashlib.sha256(path.read_bytes()).hexdigest().upper() @@ -77,7 +81,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') else 127}*minecraft:stone,minecraft:grass;1;decoration\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"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" @@ -103,6 +107,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 == "gold_ore": + definition.update(min_y=180, max_y=215) + definition["shape"] = { + "type": "gold_lode", "veins": [2, 2], "strike_length": [64, 96], + "thickness": [2, 3], "surface_cover": [4, 12], "dip_degrees": [55, 85], + "legacy_selection_scale": 0.25, + } elif RESOURCE_KEY == "diamond_ore": # Separate pipe diamonds from the native/imported low-level supply. definition.update(min_y=180, max_y=210) @@ -126,6 +137,11 @@ def prepare(args, name, origin, policy, managed, port, bootstrap=False): for default in diamonds["deposits"]: default["enabled"] = False write_json(root / "config/realisticdeposits/deposits/diamonds.json", diamonds) + gold = json.loads((Path(__file__).parents[2] / "src/main/resources/assets/realisticdeposits/defaults/gold.json") + .read_text(encoding="utf-8")) + for default in gold["deposits"]: + default["enabled"] = False + write_json(root / "config/realisticdeposits/deposits/gold.json", gold) sources = {} if origin != "native": @@ -153,19 +169,19 @@ def prepare(args, name, origin, policy, managed, port, bootstrap=False): } gold = copy.deepcopy(iron) gold["enabled"] = False - gold["blocks"] = [{"name": "minecraft:gold_ore", "chance": 100}] - write_json(source, {"version": "2.0", "spawns": {RESOURCE_KEY: iron, "gold_ore": gold}}) + gold["blocks"] = [{"name": "minecraft:" + disabled_control_key(), "chance": 100}] + write_json(source, {"version": "2.0", "spawns": {RESOURCE_KEY: iron, disabled_control_key(): gold}}) sources[source] = digest(source) if origin == "existing-override": override = root / "config/orespawn-orespawn.json" disabled = imported_rule() - disabled.update(enabled=False, block="minecraft:gold_ore") + disabled.update(enabled=False, block="minecraft:" + disabled_control_key()) write_json(override, { "schema_version": 4, "provider_modid": "orespawn", "provider_revision": 1, "ores": {"orespawn:legacy/" + RESOURCE_KEY: imported_rule(), - "orespawn:legacy/gold_ore": disabled}, + "orespawn:legacy/" + disabled_control_key(): disabled}, }) sources[override] = digest(override) @@ -243,10 +259,10 @@ 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"): + for name in ("overworld_diamond_pipes", "overworld_diamond_placers", "overworld_gold_lodes", "overworld_gold_placers"): default = owned.pop("realisticdeposits:deposit/realisticdeposits/" + name, None) if default is not None: - assert default["enabled"] is False, "An unrelated default diamond rule became active" + assert default["enabled"] is False, "An unrelated default deposit rule became active" assert set(owned) == {DEPOSIT_ID}, "Duplicate or missing Realistic Deposits rule" rule = owned[DEPOSIT_ID] assert rule["retrogen"] is False @@ -263,7 +279,7 @@ def check_profile(report, origin, policy, managed): assert placement["frequency"] == (6.5 if origin == "existing-override" else 3.25) if origin in ("os3", "existing-override"): - assert profile["ores"]["orespawn:legacy/gold_ore"]["enabled"] is False + assert profile["ores"]["orespawn:legacy/" + disabled_control_key()]["enabled"] is False def check_generation(sample, origin, policy, managed): @@ -422,7 +438,8 @@ 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"), default="iron") + parser.add_argument("--resource", choices=("iron", "coal", "diamond", "gold"), 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") diff --git a/tools/qualification/test_ci_runtime.py b/tools/qualification/test_ci_runtime.py new file mode 100644 index 0000000..ddf61d9 --- /dev/null +++ b/tools/qualification/test_ci_runtime.py @@ -0,0 +1,46 @@ +"""Check which packaged suites the CI driver actually runs.""" + +import unittest +from pathlib import Path + +import ci_runtime + + +class PackagedCiPlanTest(unittest.TestCase): + def test_a_pass_marker_cannot_hide_a_runtime_error(self): + ci_runtime.check_runtime_log("[main/ERROR]: FML appears to be missing any signature data\nPASS") + for line in ("[Server thread/FATAL]: Error executing task", "[main/ERROR]: Missing a model", + "Tried to assign a mutable BlockPos", "GL ERROR 1281"): + with self.assertRaises(AssertionError): + ci_runtime.check_runtime_log(line + "\nRD_GOLD_PASS first") + + def test_the_workflow_prepares_every_probe_used_by_the_driver(self): + workflow = (ci_runtime.ROOT / ".github/workflows/ci.yml").read_text() + for task in ("preparePolicyQualification", "prepareCoalQualification", + "prepareDiamondQualification", "prepareGoldQualification", "prepareMineQualification"): + self.assertIn(task, workflow) + + def test_every_deposit_has_policy_and_gameplay_coverage(self): + plan = ci_runtime.suite_plan() + for resource in ("iron", "coal", "diamond", "gold"): + self.assertIn("policy-" + resource, plan) + self.assertIn("--mesa", plan["policy-gold-mesa"]["arguments"]) + self.assertIn("gold", plan) + self.assertIn("gold-workings", plan) + self.assertIn("workings", plan) + self.assertIn("iron-workings", plan) + + def test_gold_uses_its_own_probe_and_does_not_opt_into_local_dependencies(self): + plan = ci_runtime.suite_plan() + command = ci_runtime.suite_command(plan["gold"], Path("java"), Path("runtime"), + Path("mod.jar"), Path("orespawn.jar"), Path("gold-probe.jar"), + Path("evidence"), 32000) + self.assertIn("gold_110.py", command[1]) + self.assertIn("source-enable", command) + self.assertIn("source-world-off", command) + self.assertNotIn("--local-orespawn-sha256", command) + self.assertEqual("gold-probe.jar", plan["gold"]["probe"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/qualification/test_frozen_profile.py b/tools/qualification/test_frozen_profile.py new file mode 100644 index 0000000..605cf8d --- /dev/null +++ b/tools/qualification/test_frozen_profile.py @@ -0,0 +1,36 @@ +"""Keep saved mine upgrades strict while permitting the mod's displayed version.""" + +import copy +import unittest + +from frozen_mines_110 import check_saved_profile + + +class FrozenProfileTest(unittest.TestCase): + def test_only_the_owned_source_display_version_can_change(self): + before = {"ores": {"iron": {"enabled": True, "frequency": 0.15}}, "ore_source_policies": { + "iron": {"candidates": [{"owner": "realisticdeposits", "source_id": "realisticdeposits:deposit/iron", + "owner_version": "0.3.0.110021"}]}}} + after = copy.deepcopy(before) + after["ore_source_policies"]["iron"]["candidates"][0]["owner_version"] = "0.4.0.110021" + check_saved_profile(before, after, "0.4.0.110021") + for key, value in (("enabled", False), ("frequency", 0.2)): + changed = copy.deepcopy(after) + changed["ores"]["iron"][key] = value + with self.assertRaises(AssertionError): + check_saved_profile(before, changed, "0.4.0.110021") + + def test_new_rules_and_other_owners_remain_protected(self): + before = {"ore_source_policies": {"iron": {"candidates": [ + {"owner": "someone_else", "owner_version": "1"}]}}} + after = copy.deepcopy(before) + after["ore_source_policies"]["iron"]["candidates"][0]["owner_version"] = "2" + with self.assertRaises(AssertionError): + check_saved_profile(before, after, "0.4.0.110021") + after = dict(before, ores={"new_gold": {"enabled": True}}) + with self.assertRaises(AssertionError): + check_saved_profile(before, after, "0.4.0.110021") + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/qualification/test_gold_engine_upgrade.py b/tools/qualification/test_gold_engine_upgrade.py new file mode 100644 index 0000000..368414c --- /dev/null +++ b/tools/qualification/test_gold_engine_upgrade.py @@ -0,0 +1,66 @@ +"""Do not mistake public status bookkeeping for permission to change settings.""" + +import copy +import unittest +from gold_engine_upgrade_110 import check_global_metadata, check_custom_profile_refresh, check_engine_version_metadata + + +class GoldEngineUpgradeChecksTest(unittest.TestCase): + def test_version_refresh_cannot_change_generation_or_source_preferences(self): + group = "orespawn:gold|minecraft:overworld" + before = {"manage_vanilla_ores": False, "ores": {"a": {"frequency": 0.375}}, + "ore_source_policies": {group: {"placement_sources": {"orespawn:standard": "a"}, + "candidates": [{"owner": "orespawn", "owner_version": "4.1.4.110021"}]}}} + after = copy.deepcopy(before) + after["ore_source_policies"][group]["candidates"][0]["owner_version"] = "4.1.5.110021" + check_engine_version_metadata(before, after, "4.1.5.110021") + for key, value in (("frequency", 2), ("enabled", False)): + invalid = copy.deepcopy(after) + invalid["ores"]["a"][key] = value + with self.assertRaisesRegex(AssertionError, "settings"): + check_engine_version_metadata(before, invalid, "4.1.5.110021") + invalid = copy.deepcopy(after) + invalid["ore_source_policies"][group]["placement_sources"] = {} + with self.assertRaisesRegex(AssertionError, "settings"): + check_engine_version_metadata(before, invalid, "4.1.5.110021") + with self.assertRaisesRegex(AssertionError, "settings"): + check_engine_version_metadata(before, after, "4.1.6.110021") + + def test_a_channel_edit_cannot_change_saved_source_preferences(self): + group = "orespawn:gold|minecraft:overworld" + before = {"ores": {"a": {"frequency": 0.375}}, "ore_source_policies": {group: { + "placement_sources": {"orespawn:standard": "a"}, "candidates": []}}} + after = copy.deepcopy(before) + after["ore_source_policies"][group]["candidates"].append({"placement_channel": "test:custom"}) + check_custom_profile_refresh(before, after) + after["ore_source_policies"][group]["placement_sources"] = {} + with self.assertRaisesRegex(AssertionError, "setting"): + check_custom_profile_refresh(before, after) + + def test_only_the_public_provider_status_may_change(self): + name = "realisticdeposits:deposit/realisticdeposits/overworld_gold_lodes" + before = {"manage_vanilla_ores": False, "ores": {name: { + "source_provider": "realisticdeposits", "enabled": False, "frequency": 0.375}}} + after = copy.deepcopy(before) + after["ores"][name]["orphaned_provider"] = True + check_global_metadata(before, after) + + for altered in ("frequency", "enabled"): + invalid = copy.deepcopy(after) + invalid["ores"][name][altered] = 1 + with self.assertRaisesRegex(AssertionError, "settings"): + check_global_metadata(before, invalid) + + invalid = copy.deepcopy(after) + invalid["manage_vanilla_ores"] = True + with self.assertRaisesRegex(AssertionError, "settings"): + check_global_metadata(before, invalid) + + invalid = copy.deepcopy(after) + invalid["ores"][name]["placement_channel"] = "another:channel" + with self.assertRaisesRegex(AssertionError, "settings"): + check_global_metadata(before, invalid) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/qualification/test_gold_survey.py b/tools/qualification/test_gold_survey.py new file mode 100644 index 0000000..ccf1b5a --- /dev/null +++ b/tools/qualification/test_gold_survey.py @@ -0,0 +1,56 @@ +"""Keep the gold survey controls and reward arithmetic honest.""" + +import copy +import unittest + +import gold_balance_110 as balance +import gold_survey_110 as survey +from qualify_110 import ORESPAWN_SHA256 + + +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.assertTrue(all(not definition["enabled"] for catalogue in catalogues.values() + for definition in catalogue["deposits"])) + self.assertTrue(any(definition["enabled"] for definition in + survey.load_catalogue("gold")["deposits"])) + + def test_seed_indices_must_be_unique_and_inside_the_fixed_survey(self): + self.assertEqual([0, 4, 5], survey.checked_indices([0, 4, 5])) + for indices in ([-1], [6], [0, 0], []): + with self.assertRaises(ValueError): + survey.checked_indices(indices) + + def test_all_surveys_must_use_the_same_public_engine_mod_and_probe(self): + inputs = {"RealisticDeposits-0.4.0.110021.jar": "A" * 64, + "OreSpawn-4.1.5.110021.jar": ORESPAWN_SHA256, + "gold-survey.jar": "B" * 64} + self.assertEqual("A" * 64, balance.checked_candidate([inputs, inputs, inputs])) + for key in inputs: + changed = copy.deepcopy(inputs) + changed[key] = "C" * 64 + with self.assertRaises(AssertionError): + balance.checked_candidate([inputs, changed, inputs]) + + def test_gravel_rewards_are_nuggets_not_ingots(self): + lode = {"seed": 1, "set": "holdout", "lode_ore": 1000, + "represented_chunks": 100, "productive_districts": 2} + river = {"native_sediment": True, "actual_rich_gravel": 360, + "placer_sample_chunks": 100} + control = {"gold_ore": 2000, "chunks": 100} + result = balance.reward_row(lode, river, control) + self.assertAlmostEqual(10, result["lodes"]) + self.assertAlmostEqual(0.1, result["placers"]) + self.assertAlmostEqual(3, result["background"]) + self.assertAlmostEqual(13.1, result["total_ingots"]) + self.assertEqual(500, result["mean_reserve_per_district"]) + + def test_placer_estimates_cannot_replace_decorated_river_measurements(self): + with self.assertRaisesRegex(AssertionError, "decorated"): + balance.reward_row({}, {"native_sediment": False}, {}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/qualification/test_qualify_110.py b/tools/qualification/test_qualify_110.py index 6f4de4d..3cecffe 100644 --- a/tools/qualification/test_qualify_110.py +++ b/tools/qualification/test_qualify_110.py @@ -5,6 +5,9 @@ import unittest from pathlib import Path from unittest.mock import Mock +from unittest.mock import patch + +import qualify_110 from qualify_110 import (ORESPAWN_SHA256, checked_orespawn_sha256, check_generation, check_legacy_flags, check_matrix, digest, same_sample) @@ -27,6 +30,11 @@ def sample(ordinary, denied): class QualificationChecksTest(unittest.TestCase): + 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): + self.assertNotEqual(resource, qualify_110.disabled_control_key()) + def test_default_checksum_matches_the_build_dependency(self): properties = dict(line.split("=", 1) for line in (Path(__file__).resolve().parents[2] / "gradle.properties").read_text().splitlines() diff --git a/tools/release/test_release.py b/tools/release/test_release.py index 20b10cf..d284f16 100644 --- a/tools/release/test_release.py +++ b/tools/release/test_release.py @@ -13,10 +13,10 @@ def test_public_dependency_and_beta_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.3.0.110021", properties["mod_version"]) - self.assertEqual("4.1.4.110021", properties["orespawn_version"]) - self.assertEqual("9356289", properties["orespawn_curse_file_id"]) - self.assertEqual("DB254E9E865685A74262DE9D469A006AB3C8053573CC6136FD4A8591AD806CA3", + self.assertEqual("0.4.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", properties["orespawn_sha256"]) self.assertEqual("1689703", properties["curseforge_project_id"])