diff --git a/.gitattributes b/.gitattributes index f2812e3..2cbd113 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,6 +5,7 @@ /LICENSE text eol=lf /gradlew text eol=lf *.bat text eol=crlf +*.cfg text eol=lf *.gradle text eol=lf *.info text eol=lf *.java text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8423cb2..4136ddf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -156,9 +156,9 @@ jobs: if-no-files-found: error retention-days: 30 path: | - build/libs/RealisticDeposits-0.1.1.110021.jar - build/libs/RealisticDeposits-0.1.1.110021-sources.jar - build/libs/RealisticDeposits-0.1.1.110021-javadoc.jar + build/libs/RealisticDeposits-0.2.0.110021.jar + build/libs/RealisticDeposits-0.2.0.110021-sources.jar + build/libs/RealisticDeposits-0.2.0.110021-javadoc.jar build/release/SHA256SUMS CHANGELOG.md diff --git a/.github/workflows/deploy-release.yml b/.github/workflows/deploy-release.yml index 2117776..edd4e4e 100644 --- a/.github/workflows/deploy-release.yml +++ b/.github/workflows/deploy-release.yml @@ -20,7 +20,7 @@ on: curseforge_release_level: description: CurseForge release level only; this does not change the GitHub Release type. required: true - default: alpha + default: beta type: choice options: - release @@ -416,7 +416,7 @@ jobs: echo "- Target branch: \`$RELEASE_BRANCH\`" echo "- Commit: \`$RELEASE_SHA\`" echo "- CurseForge release level: \`$RELEASE_LEVEL\`" - echo "- GitHub Release type: ordinary release" + echo "- GitHub Release type: prerelease" echo echo "### Public artifact checksums" echo '```text' diff --git a/CHANGELOG.md b/CHANGELOG.md index eec008e..f27c239 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## 0.2.0.110021 + +Beta for Minecraft 1.10.2. + +- Add large coalfields with two to four thin beds, shared folds and faults, + gradual thinning and local rock partings. Keep 15% background coal for early + play and retain vanilla coal drops and Fortune behaviour. +- Fit coal beds to suitable existing sedimentary rocks when Mineralogy geology + is active. Use plain stone otherwise, without adding rocks or enabling + terrain replacement. Keep granite, diorite, andesite and unsuitable hosts out. +- Keep the coal catalogue separate from iron. Preserve existing settings, + world profiles, legacy coal layers and previously generated terrain. +- Require OreSpawn 4.1.4.110021 so coal replaces only the intended rock variants. + Download and check the release automatically. + +- Add optional abandoned workings to deposits: a surface shaft, ladder access, + short seam-following galleries, timber supports, broken rails, cobwebs and + small rubble piles. Mines are off by default, with a separate chance for each + deposit definition. +- Keep mine layouts stable through reloads and later exploration. Protect old + chunks, and finish only the unbuilt sections of an already accepted plan. +- Skip unsafe sites and leave obstructions sealed rather than cutting into + liquids or another structure. Mines do not reroll deposit selection or + produce item drops while carving. +- Add a separate Eclipse mine-testing profile without changing ordinary saves + or configuration. + ## 0.1.1.110021 - Let pack authors define deposits in JSON, starting with a supplied iron diff --git a/README.md b/README.md index 233421f..c0524aa 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,10 @@ projects. The project starts with Minecraft 1.10.2 and is intended to follow the maintained OreSpawn branches across Forge and NeoForge. -This is an alpha. The supplied catalog currently adds iron only. Back up your -world before updating, and expect the defaults to change as testing continues. +This beta adds coalfields and optional abandoned workings +alongside the existing iron districts. Back up your world before updating. + +Coalfields use the exact-host API in the published OreSpawn 4.1.4.110021 release. ## Current features @@ -30,6 +32,9 @@ world before updating, and expect the defaults to change as testing continues. around the fault gaps instead of making the displaced ore impossible to rediscover. Each chunk writes only its own deterministic part of a deposit. - Massive-and-rare, geome-aware, and host-rock-aware generation modes. +- Coalfields with several thin beds, folds, fault offsets and local rock + partings. Existing Mineralogy sedimentary rocks are used when available; + Mineralogy is not required. See [coalfields](docs/COALFIELDS.md). - Additive, hybrid, and replacement policies for controlling interaction with ordinary ore generation. - Configurable support for vanilla and modded resources. @@ -38,10 +43,12 @@ world before updating, and expect the defaults to change as testing continues. - Live reload for geometry and alignment tuning, with structural changes clearly deferred until restart. - An entry in both Forge's Mods configuration and OreSpawn's World Settings. +- Optional abandoned workings, with surface access and short seam-following + galleries. They are off by default; see the [mine settings](docs/ABANDONED_MINES.md). -Compatibility catalogs for Mineralogy, Base Metals, Modern Metals, Base -Minerals, and Base Gems will be added as those product lines become available -on each target. +Coalfields can already use Mineralogy's existing shale, siltstone and +conglomerate. Wider catalogues for Mineralogy, Base Metals, Modern Metals, +Base Minerals and Base Gems remain future work. ## Requirements @@ -49,7 +56,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.3.110021](https://www.curseforge.com/minecraft/mc-mods/mmd-orespawn/files/9335434) +- [MMD OreSpawn 4.1.4.110021](https://www.curseforge.com/minecraft/mc-mods/mmd-orespawn/files/9356289) or a newer compatible 1.10.2 release, but earlier than OreSpawn 5; - Java 8 to run Minecraft. @@ -63,6 +70,7 @@ The first launch creates: ```text config/realisticdeposits/deposits/vanilla.json +config/realisticdeposits/deposits/coal.json ``` The supplied definition describes one rare iron district using a 640–960 block @@ -74,6 +82,13 @@ The shipped iron definition uses `hybrid` interaction with 15% background iron so early play remains viable while a discovered district can support a long-lived mine and rail network. +The separate coal catalogue adds two to four beds across a large coalfield, +keeping 15% ordinary background coal. It favours sedimentary and wetland basins +while allowing sedimentary cover in some other geological regions. Coalfields +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. + Deposits only affect newly generated chunks. Changing these settings does not retrogen, replace, or rewrite an existing chunk. @@ -120,7 +135,8 @@ formations: `reload` requires operator permission. It immediately applies `enabled`, selection, shape, height, geome, family, and alignment changes. Resource, dimension, host, biome-filter, interaction-policy, scale, added, and removed -definition changes are structural and take effect after restart. New provider +definition changes are structural and take effect after restart. Abandoned-mine +settings are also captured with the world's deposit definition. New provider entries are deliberately not injected into a world profile that already exists. @@ -132,10 +148,14 @@ 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.3.110021 is downloaded from Curse Maven, checked against the -official release checksum and mapped for development automatically. No sibling -checkout, local OreSpawn build or `local.properties` file is needed. -The exact dependency is `curse.maven:mmd-orespawn-245586:9335434`. +OreSpawn 4.1.4.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 +`local.properties` file is needed. + +An optional local-candidate override is still available for development, but it +must name an isolated repository and a complete checksum. Publication is blocked +whenever that override is selected. Normal builds use the public release. On Windows, run: @@ -152,6 +172,10 @@ and configuration in `run-major-iron`, apart from the ordinary client launch. The [generation qualification guide](tools/qualification/README.md) describes the optional packaged-server checks for policies, imports and saved worlds. +The [mine-testing walkthrough](tools/qualification/MINES_TESTING.md) uses a +separate Eclipse profile and disposable saves for the new workings. +For coalfields, use `prepareCoalMeasuresManual` and the +[coal-testing walkthrough](tools/qualification/COAL_TESTING.md). CI also checks reproducible jars, dependency hashes and ordinary Eclipse launches. The [release guide](docs/RELEASING.md) explains the manual release diff --git a/build.gradle b/build.gradle index 72205f4..fffd572 100644 --- a/build.gradle +++ b/build.gradle @@ -33,6 +33,21 @@ if (versionParts[3] != expectedTarget) { def oreSpawnCoordinate = "curse.maven:mmd-orespawn-${project.orespawn_curse_project_id}:" \ + project.orespawn_curse_file_id +def localCandidate = new Properties() +if (file('local.properties').isFile()) { + file('local.properties').withInputStream { localCandidate.load(it) } +} +def candidateProperty = { String name -> providers.gradleProperty(name) + .getOrElse(localCandidate.getProperty(name, '')) } +def candidateRepository = candidateProperty('orespawnCandidateRepository') +def candidateHash = candidateProperty('orespawnCandidateSha256').toUpperCase(Locale.ROOT) +ext.oreSpawnCandidateMode = !candidateRepository.isEmpty() +if (oreSpawnCandidateMode) { + if (!(candidateHash ==~ /[0-9A-F]{64}/)) { + throw new GradleException('The local OreSpawn candidate needs its complete SHA-256') + } + oreSpawnCoordinate = "zone.moddev.mc.orespawn.candidate:OreSpawn:${project.orespawn_version}" +} def mappedOreSpawn = renamer.dependency(oreSpawnCoordinate) configurations { @@ -136,6 +151,12 @@ tasks.configureEach { } repositories { + if (oreSpawnCandidateMode) { + exclusiveContent { + forRepository { maven { name = 'LocalCoalCandidate'; url = uri(candidateRepository) } } + filter { includeGroup 'zone.moddev.mc.orespawn.candidate' } + } + } minecraft.mavenizer(it) maven fg.forgeMaven maven fg.minecraftLibsMaven @@ -229,8 +250,10 @@ tasks.register('isolateEclipseProductionRuns') { dependsOn tasks.named('configureEclipseBuildship') dependsOn prepareEclipseResources doLast { - [['runClient.launch', 'runMajorIronClient.launch'], - ['runServer.launch', 'runMajorIronServer.launch']].each { pair -> + [['runClient.launch', 'runMajorIronClient.launch', 'run-major-iron'], + ['runServer.launch', 'runMajorIronServer.launch', 'run-major-iron'], + ['runClient.launch', 'runAbandonedMinesClient.launch', 'run-abandoned-mines'], + ['runServer.launch', 'runAbandonedMinesServer.launch', 'run-abandoned-mines']].each { pair -> File source = file(pair[0]) File target = file(pair[1]) if (!source.isFile()) { @@ -239,11 +262,12 @@ tasks.register('isolateEclipseProductionRuns') { String contents = source.getText('UTF-8') contents = contents.replaceAll( /()/, - '$1' + file('run-major-iron').absolutePath.replace('\\', '\\\\') + '$2') + '$1' + file(pair[2]).absolutePath.replace('\\', '\\\\') + '$2') target.setText(contents.replace('\r\n', '\n'), 'UTF-8') } ['runClient.launch', 'runServer.launch', 'runMajorIronClient.launch', 'runMajorIronServer.launch', + 'runAbandonedMinesClient.launch', 'runAbandonedMinesServer.launch', 'runTestClient.launch', 'runTestServer.launch'].each { name -> File launch = file(name) if (!launch.isFile()) { @@ -279,7 +303,8 @@ tasks.register('isolateEclipseProductionRuns') { programArguments[0][1] + arguments + programArguments[0][3]) if ((name == 'runClient.launch' || name == 'runServer.launch' - || name == 'runMajorIronClient.launch' || name == 'runMajorIronServer.launch') + || name == 'runMajorIronClient.launch' || name == 'runMajorIronServer.launch' + || name == 'runAbandonedMinesClient.launch' || name == 'runAbandonedMinesServer.launch') && !contents.contains('org.eclipse.jdt.launching.ATTR_EXCLUDE_TEST_CODE')) { contents = contents.replace('', ' \n' @@ -306,7 +331,8 @@ tasks.register('verifyEclipseProductionClasspath') { throw new GradleException('Eclipse metadata is not version-expanded') } ['runClient.launch', 'runServer.launch', - 'runMajorIronClient.launch', 'runMajorIronServer.launch'].each { name -> + 'runMajorIronClient.launch', 'runMajorIronServer.launch', + 'runAbandonedMinesClient.launch', 'runAbandonedMinesServer.launch'].each { name -> String contents = file(name).getText('UTF-8').replace('\\', '/') ['src/test', 'bin/test', 'build/classes/java/test', 'junit-', 'opentest4j-', 'junit-platform-'].each { forbidden -> @@ -318,7 +344,8 @@ tasks.register('verifyEclipseProductionClasspath') { || !contents.contains('PROJECT_ATTR" value="RealisticDeposits"')) { throw new GradleException("${name} is not a production-only RealisticDeposits launch") } - if ((name == 'runServer.launch' || name == 'runMajorIronServer.launch') + if ((name == 'runServer.launch' || name == 'runMajorIronServer.launch' + || name == 'runAbandonedMinesServer.launch') && (!contents.contains(' -- nogui"') || contents.contains('--nogui'))) { throw new GradleException('Forge 1.10 server launch must use bare nogui') } @@ -326,9 +353,13 @@ tasks.register('verifyEclipseProductionClasspath') { && !contents.contains('run-major-iron')) { throw new GradleException("${name} does not use the isolated major-iron directory") } + if (name.startsWith('runAbandonedMines') && !contents.contains('run-abandoned-mines')) { + throw new GradleException("${name} does not use the isolated abandoned-mine directory") + } } ['runClient.launch', 'runServer.launch', 'runMajorIronClient.launch', 'runMajorIronServer.launch', + 'runAbandonedMinesClient.launch', 'runAbandonedMinesServer.launch', 'runTestClient.launch', 'runTestServer.launch'].each { name -> String contents = file(name).getText('UTF-8') ['cache', 'metadata', 'to-srg', 'to-obf'].each { flag -> @@ -340,6 +371,24 @@ tasks.register('verifyEclipseProductionClasspath') { } } +tasks.register('prepareAbandonedMinesManual') { + group = 'verification' + description = 'Prepares a separate mine-testing configuration and Eclipse launches.' + dependsOn tasks.named('verifyEclipseProductionClasspath') + doLast { + File catalog = file('run-abandoned-mines/config/realisticdeposits/deposits/vanilla.json') + if (!catalog.exists()) { + def settings = new groovy.json.JsonSlurper().parse( + file('src/main/resources/assets/realisticdeposits/defaults/vanilla.json'), 'UTF-8') + settings.deposits.each { it.abandoned_mine = [enabled: true, chance: 1.0] } + catalog.parentFile.mkdirs() + catalog.setText(groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson(settings)) + '\n', 'UTF-8') + } + logger.lifecycle('Use runAbandonedMinesClient.launch. Existing manual settings and saves were not changed.') + logger.lifecycle('The walkthrough is tools/qualification/MINES_TESTING.md.') + } +} + tasks.named('jar', Jar) { archiveClassifier = 'deobf' destinationDirectory = layout.buildDirectory.dir('libs-dev') @@ -401,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.3.110021' - || project.orespawn_version_range != '[4.1.3.110021,5.0.0)' - || oreSpawnCoordinate != 'curse.maven:mmd-orespawn-245586:9335434' + || 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_sha256 - != 'FA1F4CAE66374E32162EC9543FF2BFBA236934C9E44D3F6AD882BC00761C15EF') { + != 'DB254E9E865685A74262DE9D469A006AB3C8053573CC6136FD4A8591AD806CA3') { throw new GradleException('Unexpected target or OreSpawn dependency identity') } if (project.mod_version.toString().contains('-')) { @@ -448,9 +497,10 @@ tasks.register('verifyOreSpawnDependency') { File oreSpawnFile = configurations.oreSpawnRelease.singleFile String actualHash = java.security.MessageDigest.getInstance('SHA-256') .digest(oreSpawnFile.bytes).encodeHex().toString().toUpperCase() - if (actualHash != project.orespawn_sha256) { + String expectedHash = oreSpawnCandidateMode ? candidateHash : project.orespawn_sha256 + if (actualHash != expectedHash) { throw new GradleException("OreSpawn SHA-256 is ${actualHash}, expected " - + project.orespawn_sha256) + + expectedHash) } java.util.zip.ZipFile archive = new java.util.zip.ZipFile(oreSpawnFile) try { @@ -459,6 +509,11 @@ tasks.register('verifyOreSpawnDependency') { if (apiEntry == null || providerEntry == null) { throw new GradleException('OreSpawn is missing the 4.1 extension API') } + def dimensionBuilder = archive.getEntry('zone/moddev/mc/orespawn/api/WorldgenProvider$OreDimensionDefinition$Builder.class') + if (dimensionBuilder == null || !new String(archive.getInputStream(dimensionBuilder).bytes, + java.nio.charset.StandardCharsets.ISO_8859_1).contains('hostState')) { + throw new GradleException('Coalfields need the exact-host API from OreSpawn 4.1.4.110021 or later.') + } def metadata = new groovy.json.JsonSlurper().parse( archive.getInputStream(archive.getEntry('mcmod.info'))) def manifest = new java.util.jar.Manifest( @@ -591,3 +646,4 @@ tasks.named('check') { } apply from: 'gradle/release.gradle' +apply from: 'gradle/coal-manual.gradle' diff --git a/docs/ABANDONED_MINES.md b/docs/ABANDONED_MINES.md new file mode 100644 index 0000000..d60310a --- /dev/null +++ b/docs/ABANDONED_MINES.md @@ -0,0 +1,56 @@ +# Abandoned mines + +Some deposits can have old workings: a timber-framed surface shaft, a ladder +down to the seam, a short main gallery and a couple of side passages. Broken +rails, cobwebs and small piles of fallen rock give them an abandoned look. +Most of the ore is left for you to mine. These workings contain no loot or +spawners, and they do not replace Minecraft's own mineshafts. + +The feature is experimental and **off by default**. Older configuration files +remain off without needing any changes. + +## Enable it + +Before creating a world, add this section to a deposit in +`config/realisticdeposits/deposits/vanilla.json`: + +```json +"abandoned_mine": { + "enabled": true, + "chance": 0.15 +} +``` + +`chance` is the chance of attempting workings for each selected deposit, not +for each chunk. `0.15` means 15%; `0` disables attempts and `1` attempts every +deposit. Different deposit definitions can use different values. + +Not every attempt finds a suitable site. This first version looks for dry, +reasonably level, open ground in the Overworld. It skips wooded entrances, +oceans, rivers, exposed galleries, excessive shaft depths and known structure +collisions. It currently supports the normal and superflat terrain generators; +unrecognised terrain generators are left alone. + +The seed and deposit settings choose the layout. Site safety still takes +priority: if custom deposits overlap, an already accepted mine keeps its space. +This first version does not join neighbouring mines into a larger complex. + +## Saved worlds + +OreSpawn captures deposit settings for each world. Changing a catalog does not +silently enable mines in an older world or replace its saved settings. Use a +new test world when trying the feature for the first time. + +Accepted mines keep their layout in the world save. Their remaining sections +can finish as new terrain is populated, including after a restart. Completed +chunks are never excavated again. Chunks saved before the feature was enabled +are protected, even if they had not yet been populated. A raw chunk first +created for a new mine can finish its initial population later, but only while +the saved plan still records that section as unbuilt. + +If later generation places liquid or another structure in a planned section, +the mine leaves that section sealed rather than cutting into it. The server +log reports the obstruction. Clearing tunnels does not create item drops. + +Back up important worlds before installing an experimental build. For an +isolated visual test, follow the [testing walkthrough](../tools/qualification/MINES_TESTING.md). diff --git a/docs/COALFIELDS.md b/docs/COALFIELDS.md new file mode 100644 index 0000000..a83d4d3 --- /dev/null +++ b/docs/COALFIELDS.md @@ -0,0 +1,76 @@ +# Coalfields + +Coalfields contain several thin beds separated by rock, rather than a cloud of +coal blocks. The beds share their folds and fault offsets. They thin towards +the edges, and a local rock parting can split a thicker bed. + +Coalfields require OreSpawn 4.1.4.110021 or a later compatible 1.10.2 release. +Its exact-host API keeps plain stone separate from granite, diorite and andesite. + +## Where to look + +Coalfields favour sedimentary and wetland basins. They can also occur less +often beneath stable continental regions, coastal shelves and mountain belts. +Today's surface biome is not treated as proof of an ancient swamp. + +With physical Mineralogy strata active, beds fit suitable shale, siltstone, +sandstone and conglomerate. They stop where their rock setting is unsuitable. +Limestone, dolomite, evaporites, igneous rocks and metamorphic rocks are not +default hosts. A broad rock-family selector cannot bypass that restriction. + +Without those physical strata, plain vanilla stone is an approximation of the +coal-bearing sequence. Granite, diorite and andesite are still excluded. The +mod does not add surrounding rocks or turn on terrain replacement. + +Some ordinary coal remains outside the fields. It follows the existing +background generator, not the new coal-bed model. + +## Defaults + +The separate file is `config/realisticdeposits/deposits/coal.json`. It is created +only if absent. The [packaged catalogue](../src/main/resources/assets/realisticdeposits/defaults/coal.json) +shows every setting. + +- Overworld, Y 8-128. +- 32-chunk regions, with a 50% candidate chance before geological selection. +- 384-768 blocks along strike and 128-256 blocks across. +- Two to four beds, 1-3 blocks thick, separated by 4-12 blocks of rock. +- Hybrid generation with 15% ordinary background coal. +- Abandoned workings off by default, with a 15% attempt chance when enabled. + +Candidate selection is not a promise that every region has a coalfield. +Geology, available height, caves and safe hosts can reduce or reject a deposit. +Basins receive the strongest preference; the other listed geomes accept one +quarter of their otherwise selected candidates. + +Coalfields place vanilla coal ore. Mining, Silk Touch and Fortune keep their +normal behaviour. There are no new coal grades, fuel recipes or hazards. + +## Existing worlds and other coal settings + +Existing worlds retain their saved OreSpawn definitions. Adding the new +catalogue does not silently add coalfields or reduce their background coal. +Use a new test world to try the defaults. + +Some older Mineralogy worlds have Cyano coal layers, where coal is part of +the rock sequence itself. Those layers remain independent. Realistic Deposits +reports their presence but does not disable them or rewrite the saved choice. +Background ore controls do not remove coal supplied as a rock layer. If you +deliberately enable both, consider their combined supply. + +The old Cyano geology sampler exposes a single `orespawn:cyano` geome rather +than the newer basin map. It is not in the default coalfield list. If you want +coalfields in a new legacy-geology world, add that geome to your coal definition +explicitly; it receives the lower, non-basin selection rate. This does not turn +off the older coal rock layers. Existing saved profiles still take precedence. + +Changing shape parameters can affect later new chunks. Changes to resources, +hosts, interaction policy or shape type need a restart and must respect the +world's captured settings. There is no retrogen. + +## Old workings + +The existing `abandoned_mine` section can enable short workings along one +principal coal bed. It does not connect every bed or excavate the whole field. +Entrances still need dry, covered, suitable terrain; an enabled attempt can be +rejected. See [abandoned mines](ABANDONED_MINES.md). diff --git a/docs/RELEASING.md b/docs/RELEASING.md index a1ee419..07387e0 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -1,7 +1,11 @@ # Releasing Realistic Deposits +Coalfields use published OreSpawn 4.1.4.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.1.1.110021`; the final +branch. Enter the full numeric version, such as `0.2.0.110021`; the final component identifies the Minecraft version and loader. CurseForge's alpha, beta or release label is separate from the numeric version. @@ -9,7 +13,7 @@ First run with **mode: validate**. This checks the branch, version, Java settings and successful CI, then builds and checksums a release bundle. It does not create a tag or upload anything. -To publish, select **mode: publish**, choose **alpha**, and tick the explicit +To publish, select **mode: publish**, choose **beta**, and tick the explicit publication confirmation. The workflow requires all four secrets: - `CURSEFORGE_TOKEN` @@ -21,6 +25,10 @@ The upload order is Maven, CurseForge, then GitHub. Each job downloads the same immutable bundle and checks its hashes. OreSpawn is a required CurseForge dependency. The current project ID is `1689703`. +Beta is the default CurseForge channel for this update. The numeric version +stays unchanged, and the GitHub release remains a prerelease. Validation mode +is still the safe default; changing the channel does not authorize an upload. + Do not upload a second file because the first is awaiting moderation. If a job fails after an upload, check its receipt and public destination before retrying. Published versions and tags must never be replaced with new bytes. diff --git a/gradle.properties b/gradle.properties index 3684c9c..bb6af0c 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.1.1.110021 +mod_version=0.2.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.3.110021 -orespawn_version_range=[4.1.3.110021,5.0.0) +orespawn_version=4.1.4.110021 +orespawn_version_range=[4.1.4.110021,5.0.0) orespawn_curse_project_id=245586 -orespawn_curse_file_id=9335434 -orespawn_sha256=FA1F4CAE66374E32162EC9543FF2BFBA236934C9E44D3F6AD882BC00761C15EF +orespawn_curse_file_id=9356289 +orespawn_sha256=DB254E9E865685A74262DE9D469A006AB3C8053573CC6136FD4A8591AD806CA3 curseforge_project_slug=realisticdeposits diff --git a/gradle/coal-manual.gradle b/gradle/coal-manual.gradle new file mode 100644 index 0000000..e50fbe3 --- /dev/null +++ b/gradle/coal-manual.gradle @@ -0,0 +1,45 @@ +// Coal tests use their own saves and settings, never the ordinary run directory. +tasks.named('isolateEclipseProductionRuns') { + doLast { + [['runClient.launch', 'runCoalMeasuresClient.launch'], + ['runServer.launch', 'runCoalMeasuresServer.launch']].each { pair -> + String contents = file(pair[0]).getText('UTF-8') + contents = contents.replaceAll( + /()/, + '$1' + file('run-coal-measures').absolutePath.replace('\\', '\\\\') + '$2') + file(pair[1]).setText(contents.replace('\r\n', '\n'), 'UTF-8') + } + } +} + +tasks.named('verifyEclipseProductionClasspath') { + doLast { + ['runCoalMeasuresClient.launch', 'runCoalMeasuresServer.launch'].each { name -> + String contents = file(name).getText('UTF-8').replace('\\', '/') + if (!contents.contains('run-coal-measures') || !contents.contains('ATTR_EXCLUDE_TEST_CODE')) { + throw new GradleException("${name} must use isolated, production-only output") + } + ['src/test', 'bin/test', 'junit-', 'coal-probe', 'mine-probe'].each { forbidden -> + if (contents.contains(forbidden)) throw new GradleException("${name} exposes ${forbidden}") + } + } + } +} + +tasks.register('prepareCoalMeasuresManual') { + group = 'verification' + description = 'Prepares a separate coalfield test and its Eclipse launches.' + dependsOn tasks.named('verifyEclipseProductionClasspath') + doLast { + File target = file('run-coal-measures/config/realisticdeposits/deposits/coal.json') + if (!target.exists()) { + def coal = new groovy.json.JsonSlurper().parse( + file('src/main/resources/assets/realisticdeposits/defaults/coal.json'), 'UTF-8') + coal.deposits.each { it.abandoned_mine = [enabled: true, chance: 1.0] } + target.parentFile.mkdirs() + target.setText(groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson(coal)) + '\n', 'UTF-8') + } + logger.lifecycle('Use runCoalMeasuresClient.launch and tools/qualification/COAL_TESTING.md.') + logger.lifecycle('Existing settings and saves were left alone. The test profile attempts old workings at every suitable coalfield.') + } +} diff --git a/gradle/release.gradle b/gradle/release.gradle index 706e526..e28882e 100644 --- a/gradle/release.gradle +++ b/gradle/release.gradle @@ -8,6 +8,16 @@ def releaseNames = [ "RealisticDeposits-${version}-javadoc.jar" ] def preparedDirectory = providers.gradleProperty('preparedReleaseDir') + +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.') + } + } +} + def hash = { File input -> MessageDigest.getInstance('SHA-256').digest(input.bytes).encodeHex().toString().toUpperCase() } @@ -71,7 +81,7 @@ tasks.register('verifyJavadocOrdering') { tasks.register('verifyReleaseConfiguration') { group = 'verification' doLast { - if (project.mod_version != '0.1.1.110021' + if (project.mod_version != '0.2.0.110021' || project.curseforge_project_id != '1689703' || project.mod_artifact != 'RealisticDeposits' || project.java_version != '8' || project.gradle_java_version != '17' @@ -144,6 +154,7 @@ tasks.register('writeReleaseChecksums') { // It must not quietly rebuild a different jar on the upload runner. tasks.register('verifyPreparedReleaseArtifacts') { group = 'verification' + dependsOn tasks.named('verifyPublicationAllowed') doLast { if (!preparedDirectory.isPresent()) { throw new GradleException('preparedReleaseDir is required for publication') @@ -232,6 +243,7 @@ tasks.register('verifyMavenCoordinates') { } tasks.withType(PublishToMavenRepository).configureEach { + dependsOn tasks.named('verifyPublicationAllowed') dependsOn tasks.named('verifyPreparedReleaseArtifacts') dependsOn tasks.named('verifyMavenCoordinates') doFirst { diff --git a/src/main/java/zone/moddev/mc/realisticdeposits/RealisticDeposits.java b/src/main/java/zone/moddev/mc/realisticdeposits/RealisticDeposits.java index 7bbdba7..8d4041a 100644 --- a/src/main/java/zone/moddev/mc/realisticdeposits/RealisticDeposits.java +++ b/src/main/java/zone/moddev/mc/realisticdeposits/RealisticDeposits.java @@ -3,6 +3,8 @@ import zone.moddev.mc.realisticdeposits.command.RealisticDepositsCommand; import zone.moddev.mc.realisticdeposits.config.RealisticDepositsRuntime; import zone.moddev.mc.realisticdeposits.generation.StratiformSeamPattern; +import zone.moddev.mc.realisticdeposits.generation.CoalMeasuresPattern; +import zone.moddev.mc.realisticdeposits.generation.MineWorkingsGenerator; import zone.moddev.mc.realisticdeposits.integration.OreSpawnIntegration; import zone.moddev.mc.realisticdeposits.proxy.CommonProxy; @@ -14,8 +16,10 @@ import net.minecraftforge.fml.common.event.FMLPreInitializationEvent; import net.minecraftforge.fml.common.event.FMLInitializationEvent; import net.minecraftforge.fml.common.event.FMLServerStartingEvent; +import net.minecraftforge.fml.common.event.FMLServerAboutToStartEvent; import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; import net.minecraftforge.event.RegistryEvent; +import net.minecraftforge.fml.common.registry.GameRegistry; /** * Forge entry point for Realistic Deposits. @@ -26,14 +30,16 @@ */ @Mod(modid = RealisticDeposits.MOD_ID, name = RealisticDeposits.NAME, version = RealisticDeposits.VERSION, acceptedMinecraftVersions = "[1.10.2]", - dependencies = "required-after:orespawn@[4.1.3.110021,5.0.0)", + dependencies = "required-after:orespawn@[4.1.4.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.1.1.110021"; + public static final String VERSION = "0.2.0.110021"; private static final OrePatternType STRATIFORM_SEAM = StratiformSeamPattern.type() .setRegistryName(OreSpawnIntegration.PATTERN_ID); + private static final OrePatternType COAL_MEASURES = CoalMeasuresPattern.type() + .setRegistryName(OreSpawnIntegration.COAL_PATTERN_ID); @SidedProxy(clientSide = "zone.moddev.mc.realisticdeposits.proxy.ClientProxy", serverSide = "zone.moddev.mc.realisticdeposits.proxy.CommonProxy") @@ -41,16 +47,19 @@ public final class RealisticDeposits { public RealisticDeposits() { MinecraftForge.EVENT_BUS.register(this); + MinecraftForge.EVENT_BUS.register(MineWorkingsGenerator.INSTANCE); } @SubscribeEvent public void registerPatterns(RegistryEvent.Register event) { event.getRegistry().register(STRATIFORM_SEAM); + event.getRegistry().register(COAL_MEASURES); } @Mod.EventHandler public void preInitialize(FMLPreInitializationEvent event) { RealisticDepositsRuntime.initialize(event.getModConfigurationDirectory()); + GameRegistry.registerWorldGenerator(MineWorkingsGenerator.INSTANCE, Integer.MAX_VALUE); } @Mod.EventHandler @@ -61,6 +70,11 @@ public void initialize(FMLInitializationEvent event) { proxy.initializeClient(); } + @Mod.EventHandler + public void serverAboutToStart(FMLServerAboutToStartEvent event) { + CoalMeasuresPattern.newServerSession(); + } + @Mod.EventHandler public void serverStarting(FMLServerStartingEvent event) { event.registerServerCommand(new RealisticDepositsCommand()); 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 15ec19f..3f3c7d5 100644 --- a/src/main/java/zone/moddev/mc/realisticdeposits/command/RealisticDepositsCommand.java +++ b/src/main/java/zone/moddev/mc/realisticdeposits/command/RealisticDepositsCommand.java @@ -8,6 +8,8 @@ import zone.moddev.mc.realisticdeposits.geometry.BlockBounds; import zone.moddev.mc.realisticdeposits.geometry.RegionDepositPlanner; import zone.moddev.mc.realisticdeposits.geometry.StratiformSeamBody; +import zone.moddev.mc.realisticdeposits.geometry.MineableDepositBody; +import zone.moddev.mc.realisticdeposits.geometry.CoalMeasuresBody; import zone.moddev.mc.realisticdeposits.model.DepositDefinition; import net.minecraft.command.CommandBase; @@ -67,18 +69,19 @@ 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()); - Optional planned = RegionDepositPlanner.plan(definition, - world.getSeed(), dimension, regionX, regionZ); + Optional planned = definition.isCoal() + ? CoalMeasuresBody.plan(definition, world.getSeed(), dimension, regionX, regionZ) + : RegionDepositPlanner.plan(definition, world.getSeed(), dimension, regionX, regionZ); if (!planned.isPresent()) { sender.sendMessage(new TextComponentString("No " + definition.id() + " candidate is selected in region " + regionX + "," + regionZ + ".")); return; } - StratiformSeamBody body = planned.get(); + MineableDepositBody body = planned.get(); BlockBounds bounds = body.bounds(); sender.sendMessage(new TextComponentString("Planned " + definition.id() + " at " + body.centerX() + "," + body.centerY() + "," + body.centerZ() - + "; strike=" + body.strikeLength() + ", down-dip=" + body.downDipWidth() + + "; shape=" + definition.shape().type() + ", strike=" + body.strikeLength() + "; bounds=" + bounds.minimumX() + "," + bounds.minimumY() + "," + bounds.minimumZ() + " to " + bounds.maximumX() + "," + bounds.maximumY() + "," + bounds.maximumZ() + ". Preview does not change blocks.")); 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 03c6271..b420e98 100644 --- a/src/main/java/zone/moddev/mc/realisticdeposits/config/DepositCatalogLoader.java +++ b/src/main/java/zone/moddev/mc/realisticdeposits/config/DepositCatalogLoader.java @@ -16,7 +16,10 @@ import com.google.gson.JsonParser; import zone.moddev.mc.realisticdeposits.model.AlignmentMode; +import zone.moddev.mc.realisticdeposits.model.AbandonedMineSettings; import zone.moddev.mc.realisticdeposits.model.DepositDefinition; +import zone.moddev.mc.realisticdeposits.model.DepositShapeSettings; +import zone.moddev.mc.realisticdeposits.model.CoalMeasuresSettings; import zone.moddev.mc.realisticdeposits.model.IntRange; import zone.moddev.mc.realisticdeposits.model.InteractionPolicy; import zone.moddev.mc.realisticdeposits.model.StratiformSeamSettings; @@ -29,7 +32,8 @@ public final class DepositCatalogLoader { "interaction", "dimensions", "hosts", "min_y", "max_y", "region_spacing_chunks", "selection_chance", "salt", "shape", "geomes", "rock_families", "biome_ids", "excluded_biome_ids", "biome_dictionary", "excluded_biome_dictionary", - "background_generation_scale"); + "background_generation_scale", "abandoned_mine"); + private static final Set MINE_KEYS = keys("enabled", "chance"); private static final Set SHAPE_KEYS = keys("type", "strike_length", "down_dip_width", "thickness", "dip_degrees", "bend_amplitude", "bend_wavelength", "fold_amplitude", "fold_wavelength", "density", "edge_falloff"); @@ -76,23 +80,16 @@ public static DepositCatalog load(Reader reader, String source) { private static DepositDefinition definition(JsonObject json, String path, List warnings) { unknownKeys(json, DEPOSIT_KEYS, path, warnings); JsonObject shape = object(json, "shape", path); - unknownKeys(shape, SHAPE_KEYS, path + " shape", warnings); - String shapeType = string(shape, "type", path + " shape"); - if (!"stratiform_seam".equals(shapeType)) { - throw invalid(path, "unsupported shape type " + shapeType); + DepositShapeSettings settings = shape(shape, path, warnings); + AbandonedMineSettings mine = AbandonedMineSettings.DISABLED; + if (json.has("abandoned_mine")) { + JsonObject mineJson = object(json, "abandoned_mine", path); + unknownKeys(mineJson, MINE_KEYS, path + " abandoned_mine", warnings); + mine = new AbandonedMineSettings(bool(mineJson, "enabled", false, path), + mineJson.has("chance") ? number(mineJson, "chance", path) + : AbandonedMineSettings.DEFAULT_CHANCE); } - double[] thickness = doubleRange(shape, "thickness", path); - double[] dipDegrees = doubleRange(shape, "dip_degrees", path); - StratiformSeamSettings settings = new StratiformSeamSettings( - integerRange(shape, "strike_length", path), - integerRange(shape, "down_dip_width", path), - thickness[0], thickness[1], dipDegrees[0], dipDegrees[1], - number(shape, "bend_amplitude", path), - number(shape, "bend_wavelength", path), - number(shape, "fold_amplitude", path), - number(shape, "fold_wavelength", path), - number(shape, "density", path), - number(shape, "edge_falloff", path)); + return new DepositDefinition( string(json, "id", path), bool(json, "enabled", true, path), string(json, "resource", path), @@ -107,7 +104,31 @@ private static DepositDefinition definition(JsonObject json, String path, List warnings) { + String type = string(shape, "type", path + " shape"); + if ("coal_measures".equals(type)) { + unknownKeys(shape, keys("type", "strike_length", "down_dip_width", "beds", "thickness", + "rock_interval", "maximum_dip_degrees", "fold_amplitude", "maximum_fault_throw"), + path + " shape", warnings); + return new CoalMeasuresSettings(integerRange(shape, "strike_length", path), + integerRange(shape, "down_dip_width", path), integerRange(shape, "beds", path), + integerRange(shape, "thickness", path), integerRange(shape, "rock_interval", path), + number(shape, "maximum_dip_degrees", path), number(shape, "fold_amplitude", path), + integer(shape, "maximum_fault_throw", path)); + } + + if (!"stratiform_seam".equals(type)) throw invalid(path, "unsupported shape type " + type); + unknownKeys(shape, SHAPE_KEYS, path + " shape", warnings); + double[] thickness = doubleRange(shape, "thickness", path); + double[] dip = doubleRange(shape, "dip_degrees", path); + return new StratiformSeamSettings(integerRange(shape, "strike_length", path), + integerRange(shape, "down_dip_width", path), thickness[0], thickness[1], dip[0], dip[1], + number(shape, "bend_amplitude", path), number(shape, "bend_wavelength", path), + number(shape, "fold_amplitude", path), number(shape, "fold_wavelength", path), + number(shape, "density", path), number(shape, "edge_falloff", path)); } private static void unknownKeys(JsonObject json, Set allowed, String path, 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 0923a85..8d3e1e2 100644 --- a/src/main/java/zone/moddev/mc/realisticdeposits/config/RealisticDepositsRuntime.java +++ b/src/main/java/zone/moddev/mc/realisticdeposits/config/RealisticDepositsRuntime.java @@ -77,6 +77,7 @@ private static void reloadCatalogs(boolean diagnostics) { File deposits = new File(root, "deposits"); File defaultCatalog = new File(deposits, "vanilla.json"); createDefault(defaultCatalog); + createDefault(new File(deposits, "coal.json"), "/assets/realisticdeposits/defaults/coal.json"); List files = jsonFiles(deposits); List catalogs = new ArrayList<>(); List definitions = new ArrayList<>(); @@ -188,6 +189,7 @@ private static synchronized void warnStructuralReload(String definitionId) { private static void validateResourcePolicies(List definitions) { Map contracts = new LinkedHashMap<>(); for (DepositDefinition definition : definitions) { + if (!definition.enabled()) continue; for (String dimension : definition.dimensions()) { String key = definition.resource() + "@" + dimension; String contract = definition.interaction().configName() + ":" @@ -223,13 +225,17 @@ private static List structuralChanges(Map initial, } private static void createDefault(File target) { + createDefault(target, DEFAULT_RESOURCE); + } + + private static void createDefault(File target, String resource) { if (target.isFile()) return; File parent = target.getParentFile(); if (!parent.isDirectory() && !parent.mkdirs()) { throw new IllegalStateException("Could not create configuration directory " + parent); } - try (InputStream input = RealisticDepositsRuntime.class.getResourceAsStream(DEFAULT_RESOURCE)) { - if (input == null) throw new IllegalStateException("Missing packaged default " + DEFAULT_RESOURCE); + try (InputStream input = RealisticDepositsRuntime.class.getResourceAsStream(resource)) { + if (input == null) throw new IllegalStateException("Missing packaged default " + resource); try (OutputStream output = new FileOutputStream(target)) { byte[] buffer = new byte[8192]; int count; @@ -243,7 +249,9 @@ private static void createDefault(File target) { private static List jsonFiles(File directory) { List result = new ArrayList<>(); collectJson(directory, result); - result.sort(Comparator.comparing(File::getPath)); + // Keep existing catalogue order; the new default must not become the first deposit. + result.sort(Comparator.comparingInt((File file) -> "coal.json".equals(file.getName()) ? 1 : 0) + .thenComparing(File::getPath)); return result; } diff --git a/src/main/java/zone/moddev/mc/realisticdeposits/generation/CoalGeology.java b/src/main/java/zone/moddev/mc/realisticdeposits/generation/CoalGeology.java new file mode 100644 index 0000000..bde2d02 --- /dev/null +++ b/src/main/java/zone/moddev/mc/realisticdeposits/generation/CoalGeology.java @@ -0,0 +1,171 @@ +package zone.moddev.mc.realisticdeposits.generation; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; + +import com.google.gson.JsonObject; + +import org.apache.logging.log4j.LogManager; + +import net.minecraft.block.Block; +import net.minecraft.block.state.IBlockState; +import net.minecraft.server.MinecraftServer; +import net.minecraft.util.ResourceLocation; +import net.minecraftforge.fml.common.FMLCommonHandler; + +import zone.moddev.mc.orespawn.api.GeologyColumn; +import zone.moddev.mc.orespawn.api.GeologyFamily; +import zone.moddev.mc.orespawn.api.GeologySampler; +import zone.moddev.mc.orespawn.api.OreSpawnApi; +import zone.moddev.mc.realisticdeposits.geometry.CoalMeasuresBody; +import zone.moddev.mc.realisticdeposits.model.AlignmentMode; +import zone.moddev.mc.realisticdeposits.model.DepositDefinition; + +/** Reads the active geology without changing rocks or anybody else's coal settings. */ +final class CoalGeology { + private static Snapshot active; + private static int epoch; + + private CoalGeology() { } + + static synchronized void reset() { + active = null; + epoch++; + } + + static int epoch() { return epoch; } + + static synchronized Snapshot active() { + if (active != null) return active; + MinecraftServer server = FMLCommonHandler.instance().getMinecraftServerInstance(); + Optional profile = OreSpawnApi.getActiveProfile(server); + if (!profile.isPresent()) return new Snapshot(false, false); + + active = Snapshot.fromJson(profile.get().toJson()); + if (active.legacyCoal) { + LogManager.getLogger("Realistic Deposits").warn("This world also has legacy coal rock layers. " + + "They remain independent of the new coalfields; choose which supply you want in the world's settings."); + } + return active; + } + + static Set hosts(DepositDefinition definition) { + Set hosts = new HashSet<>(); + for (String name : definition.hosts()) { + ResourceLocation id = new ResourceLocation(name); + if (Block.REGISTRY.containsKey(id)) hosts.add(Block.REGISTRY.getObject(id).getStateFromMeta(0)); + } + return hosts; + } + + static boolean acceptsGeome(DepositDefinition definition, CoalMeasuresBody body, GeologySampler sampler) { + if (definition.alignment() == AlignmentMode.MASSIVE_RARE) return true; + String geome = sampler.sampleColumn(body.centerX(), body.centerZ(), body.centerY()).geome().toString(); + if (!definition.geomes().contains(geome)) return false; + boolean basin = "orespawn:sedimentary_basin".equals(geome) || "orespawn:wetland_basin".equals(geome); + + // Ancient coal measures can survive beneath a craton or a later mountain belt. + return basin || body.selectedOutsideBasin(); + } + + static Optional align(CoalMeasuresBody body, DepositDefinition definition, + GeologySampler sampler, Set hosts, Snapshot profile) { + if (!profile.physicalStrata) return Optional.of(body); + + GeologyColumn centre = sampler.sampleColumn(body.centerX(), body.centerZ(), body.centerY()); + if (!Double.isFinite(findBed(centre, body.centerY() + body.bedOffset(0), + definition.minimumY(), definition.maximumY(), body.bedThickness(0), hosts))) { + return Optional.empty(); + } + + int step = 16; + int x = body.bounds().minimumX() - step; + int z = body.bounds().minimumZ() - step; + int side = (body.bounds().maximumX() - x + step - 1) / step + 2; + float[][] heights = new float[body.bedCount()][side * side]; + for (float[] bed : heights) Arrays.fill(bed, Float.NaN); + + for (int gz = 0; gz < side; gz++) { + for (int gx = 0; gx < side; gx++) { + int blockX = x + gx * step; + int blockZ = z + gz * step; + GeologyColumn column = sampler.sampleColumn(blockX, blockZ, body.centerY()); + int minimum = definition.minimumY(); + + for (int bed = 0; bed < body.bedCount(); bed++) { + double target = body.bedY(bed, blockX, blockZ); + double fitted = findBed(column, target, minimum, definition.maximumY(), + body.bedThickness(bed), hosts); + if (!Double.isFinite(fitted)) break; + + // Keep the shared folds and fault throws after fitting the parent bedding. + double deformation = target - body.centerY() - body.bedOffset(bed); + heights[bed][gx + gz * side] = (float) (fitted - deformation); + minimum = (int) Math.ceil(fitted + body.bedThickness(bed) * 0.5D + 4); + } + } + } + + CoalMeasuresBody.BeddingGrid[] grids = new CoalMeasuresBody.BeddingGrid[body.bedCount()]; + for (int bed = 0; bed < grids.length; bed++) { + grids[bed] = new CoalMeasuresBody.BeddingGrid(x, z, step, side, heights[bed]); + } + CoalMeasuresBody fitted = body.withBedding(grids); + return Double.isFinite(fitted.bedY(0, body.centerX(), body.centerZ())) + ? Optional.of(fitted) : Optional.empty(); + } + + private static double findBed(GeologyColumn column, double target, int min, int max, + int thickness, Set hosts) { + double best = Double.NaN; + double distance = Double.POSITIVE_INFINITY; + int start = -1; + + for (int y = min; y <= max + 1; y++) { + boolean allowed = y <= max && column.familyAt(y).orElse(null) == GeologyFamily.SEDIMENTARY + && hosts.contains(column.rockAt(y)); + if (allowed && start < 0) start = y; + if (allowed || start < 0) continue; + + int end = y - 1; + if (end - start + 1 >= thickness) { + double centre = Math.max(start + thickness * 0.5D, + Math.min(end + 1 - thickness * 0.5D, target)); + double candidateDistance = Math.abs(centre - target); + if (candidateDistance <= 16 && candidateDistance < distance) { + best = centre; + distance = candidateDistance; + } + } + start = -1; + } + return best; + } + + static final class Snapshot { + final boolean physicalStrata; + final boolean legacyCoal; + + Snapshot(boolean physicalStrata, boolean legacyCoal) { + this.physicalStrata = physicalStrata; + this.legacyCoal = legacyCoal; + } + + static Snapshot fromJson(JsonObject root) { + JsonObject terrain = root.has("terrain_dimensions") ? root.getAsJsonObject("terrain_dimensions") : new JsonObject(); + JsonObject overworld = terrain.has("minecraft:overworld") + ? terrain.getAsJsonObject("minecraft:overworld") : new JsonObject(); + JsonObject cyano = root.has("cyano") ? root.getAsJsonObject("cyano") : new JsonObject(); + boolean physical = overworld.has("enabled") && overworld.get("enabled").getAsBoolean(); + boolean legacy = cyano.has("realistic_coal_layers") && cyano.get("realistic_coal_layers").getAsBoolean(); + if (cyano.has("sedimentary_rocks")) { + for (com.google.gson.JsonElement rock : cyano.getAsJsonArray("sedimentary_rocks")) { + legacy |= "minecraft:coal_ore".equals(rock.getAsString()); + } + } + return new Snapshot(physical, legacy); + } + } +} diff --git a/src/main/java/zone/moddev/mc/realisticdeposits/generation/CoalMeasuresPattern.java b/src/main/java/zone/moddev/mc/realisticdeposits/generation/CoalMeasuresPattern.java new file mode 100644 index 0000000..69da1da --- /dev/null +++ b/src/main/java/zone/moddev/mc/realisticdeposits/generation/CoalMeasuresPattern.java @@ -0,0 +1,141 @@ +package zone.moddev.mc.realisticdeposits.generation; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import com.google.gson.JsonObject; +import com.mojang.serialization.Codec; + +import net.minecraft.block.state.IBlockState; + +import zone.moddev.mc.orespawn.api.CompiledOrePattern; +import zone.moddev.mc.orespawn.api.GeologyColumn; +import zone.moddev.mc.orespawn.api.GeologyFamily; +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.OrePlacementContext; +import zone.moddev.mc.realisticdeposits.config.DepositCatalogLoader; +import zone.moddev.mc.realisticdeposits.config.RealisticDepositsRuntime; +import zone.moddev.mc.realisticdeposits.geometry.CoalMeasuresBody; +import zone.moddev.mc.realisticdeposits.model.AlignmentMode; +import zone.moddev.mc.realisticdeposits.model.DepositDefinition; + +/** Places the current chunk's beds through OreSpawn's exact-host safety checks. */ +public final class CoalMeasuresPattern { + private CoalMeasuresPattern() { } + + public static OrePatternType type() { + return OrePatternType.create(Codec.of(input -> { + if (input == null || !input.isJsonObject()) { + throw new IllegalArgumentException("Coal measures need a captured deposit definition"); + } + JsonObject settings = input.getAsJsonObject(); + if (!settings.has("definition") || !settings.get("definition").isJsonObject()) { + throw new IllegalArgumentException("Coal measures need a definition object"); + } + DepositDefinition definition = DepositCatalogLoader.loadDefinition( + settings.getAsJsonObject("definition"), "OreSpawn coal profile"); + if (!definition.isCoal()) throw new IllegalArgumentException("Expected a coal_measures shape"); + return definition; + }), CoalMeasuresPattern::compile); + } + + private static CompiledOrePattern compile(DepositDefinition captured) { + RealisticDepositsRuntime.GenerationDefinitionHandle handle = RealisticDepositsRuntime.generationHandle(captured); + Renderer renderer = new Renderer(); + return context -> renderer.place(handle.current(), context, CoalGeology.active()); + } + + static boolean place(DepositDefinition definition, OreGenerationContext context, CoalGeology.Snapshot profile) { + return new Renderer().place(definition, context, profile); + } + + public static void newServerSession() { CoalGeology.reset(); } + + private static final class Renderer { + private final Map> bodies = new LinkedHashMap>() { + @Override + protected boolean removeEldestEntry(Map.Entry> eldest) { + return size() > 64; + } + }; + private DepositDefinition observed; + private Set hosts; + private long seed; + private int epoch = -1; + + boolean place(DepositDefinition definition, OrePlacementContext base, CoalGeology.Snapshot profile) { + if (!(base instanceof OreGenerationContext)) { + throw new IllegalStateException("Coalfields require OreSpawn's extended generation context"); + } + OreGenerationContext context = (OreGenerationContext) base; + if (!definition.enabled() || !definition.dimensions().contains(context.dimension().toString())) return false; + + Optional sampler = context.geologySampler(); + if (!sampler.isPresent() && definition.alignment() != AlignmentMode.MASSIVE_RARE) { + RealisticDepositsRuntime.warnMissingSampler(definition.id()); + return false; + } + if (definition != observed || seed != context.worldSeed() || epoch != CoalGeology.epoch()) { + bodies.clear(); + hosts = CoalGeology.hosts(definition); + observed = definition; + seed = context.worldSeed(); + epoch = CoalGeology.epoch(); + } + + int chunkX = context.chunkX() << 4; + int chunkZ = context.chunkZ() << 4; + int regionSize = definition.regionSpacingChunks() * 16; + int radius = (definition.coal().strikeLength().maximum() + definition.coal().downDipWidth().maximum()) / 2 + 3; + int count = 0; + java.util.List structures = CoalStructureProtection.inChunk(context.chunkX(), context.chunkZ()); + GeologyColumn[] columns = new GeologyColumn[256]; + + for (int rx = Math.floorDiv(chunkX - radius, regionSize); rx <= Math.floorDiv(chunkX + 15 + radius, regionSize); rx++) { + for (int rz = Math.floorDiv(chunkZ - radius, regionSize); rz <= Math.floorDiv(chunkZ + 15 + radius, regionSize); rz++) { + long key = ((long) rx << 32) ^ (rz & 0xffffffffL); + Optional selected = bodies.get(key); + if (selected == null) { + selected = CoalMeasuresBody.plan(definition, seed, context.dimension().toString(), rx, rz); + if (selected.isPresent() && sampler.isPresent()) { + CoalMeasuresBody body = selected.get(); + if (!CoalGeology.acceptsGeome(definition, body, sampler.get())) { + selected = Optional.empty(); + } else { + selected = CoalGeology.align(body, definition, sampler.get(), hosts, profile); + } + } + bodies.put(key, selected); + } + if (!selected.isPresent() || !selected.get().bounds().intersectsChunk(context.chunkX(), context.chunkZ())) continue; + CoalMeasuresBody body = selected.get(); + MineWorkingsGenerator.offer(context, definition, body); + + for (int x = chunkX; x < chunkX + 16; x++) { + for (int z = chunkZ; z < chunkZ + 16; z++) { + int index = (x - chunkX) + (z - chunkZ) * 16; + for (int y = body.minimumCandidateYAt(x, z); y <= body.maximumCandidateYAt(x, z); y++) { + if (!body.isOreAt(x, y, z) || !context.inside(x, y, z)) continue; + if (CoalStructureProtection.contains(structures, x, y, z)) continue; + if (profile.physicalStrata && sampler.isPresent()) { + if (columns[index] == null) columns[index] = sampler.get().sampleColumn(x, z, body.centerY()); + GeologyColumn column = columns[index]; + if (column.familyAt(y).orElse(null) != GeologyFamily.SEDIMENTARY + || !hosts.contains(column.rockAt(y))) continue; + } + if (context.tryPlace(x, y, z)) count++; + } + } + } + } + } + if (count > 0) RealisticDepositsRuntime.reportFirstGeneration(definition.id(), + context.dimension().toString(), context.chunkX(), context.chunkZ(), count); + return count > 0; + } + } +} diff --git a/src/main/java/zone/moddev/mc/realisticdeposits/generation/CoalStructureProtection.java b/src/main/java/zone/moddev/mc/realisticdeposits/generation/CoalStructureProtection.java new file mode 100644 index 0000000..5ecce84 --- /dev/null +++ b/src/main/java/zone/moddev/mc/realisticdeposits/generation/CoalStructureProtection.java @@ -0,0 +1,44 @@ +package zone.moddev.mc.realisticdeposits.generation; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.server.MinecraftServer; +import net.minecraft.world.WorldServer; +import net.minecraft.world.gen.structure.MapGenStructureData; +import net.minecraftforge.fml.common.FMLCommonHandler; + +/** Leaves known vanilla structures alone, including their natural-looking stone. */ +final class CoalStructureProtection { + private static final String[] NAMES = {"Mineshaft", "Village", "Stronghold", "Temple", "Monument"}; + + private CoalStructureProtection() { } + + static List inChunk(int chunkX, int chunkZ) { + MinecraftServer server = FMLCommonHandler.instance().getMinecraftServerInstance(); + WorldServer world = server == null ? null : server.worldServerForDimension(0); + if (world == null) return Collections.emptyList(); + List boxes = new ArrayList<>(); + + for (String name : NAMES) { + MapGenStructureData data = (MapGenStructureData) world.getMapStorage().getOrLoadData(MapGenStructureData.class, name); + if (data == null) continue; + NBTTagCompound root = data.getTagCompound(); + for (String key : root.getKeySet()) { + int[] box = root.getCompoundTag(key).getIntArray("BB"); + if (box.length == 6 && box[0] <= (chunkX << 4) + 15 && box[3] >= (chunkX << 4) + && box[2] <= (chunkZ << 4) + 15 && box[5] >= (chunkZ << 4)) boxes.add(box); + } + } + return boxes; + } + + static boolean contains(List boxes, int x, int y, int z) { + for (int[] box : boxes) { + if (x >= box[0] && x <= box[3] && y >= box[1] && y <= box[4] && z >= box[2] && z <= box[5]) return true; + } + return false; + } +} diff --git a/src/main/java/zone/moddev/mc/realisticdeposits/generation/ExistingMineTerrain.java b/src/main/java/zone/moddev/mc/realisticdeposits/generation/ExistingMineTerrain.java new file mode 100644 index 0000000..9be1b9a --- /dev/null +++ b/src/main/java/zone/moddev/mc/realisticdeposits/generation/ExistingMineTerrain.java @@ -0,0 +1,64 @@ +package zone.moddev.mc.realisticdeposits.generation; + +import java.io.File; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.util.HashSet; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import zone.moddev.mc.realisticdeposits.geometry.BlockBounds; +import zone.moddev.mc.realisticdeposits.geometry.MinePoint; + +/** Reads Anvil headers without loading, generating or rewriting any chunks. */ +public final class ExistingMineTerrain { + private static final Pattern REGION_NAME = Pattern.compile("r\\.(-?[0-9]+)\\.(-?[0-9]+)\\.mca"); + private final Set chunks = new HashSet<>(); + + private ExistingMineTerrain() { } + + public static ExistingMineTerrain read(File regionDirectory) throws IOException { + ExistingMineTerrain result = new ExistingMineTerrain(); + if (!regionDirectory.exists()) return result; + + File[] files = regionDirectory.listFiles(); + if (files == null) throw new IOException("Cannot read region directory " + regionDirectory); + + for (File file : files) { + Matcher name = REGION_NAME.matcher(file.getName()); + if (!name.matches()) continue; + + try (RandomAccessFile region = new RandomAccessFile(file, "r")) { + if (region.length() < 4096L) throw new IOException("Incomplete Anvil header: " + file); + int regionX = Integer.parseInt(name.group(1)); + int regionZ = Integer.parseInt(name.group(2)); + + for (int index = 0; index < 1024; index++) { + if (region.readInt() != 0) { + result.chunks.add(MinePoint.chunkKey(Math.addExact(Math.multiplyExact(regionX, 32), index & 31), + Math.addExact(Math.multiplyExact(regionZ, 32), index >> 5))); + } + } + } catch (ArithmeticException | NumberFormatException invalid) { + throw new IOException("Invalid Anvil region coordinates: " + file, invalid); + } + } + + return result; + } + + public boolean contains(int chunkX, int chunkZ) { + return chunks.contains(MinePoint.chunkKey(chunkX, chunkZ)); + } + + public boolean intersects(BlockBounds bounds) { + for (int x = Math.floorDiv(bounds.minimumX(), 16); x <= Math.floorDiv(bounds.maximumX(), 16); x++) { + for (int z = Math.floorDiv(bounds.minimumZ(), 16); z <= Math.floorDiv(bounds.maximumZ(), 16); z++) { + if (contains(x, z)) return true; + } + } + + return false; + } +} diff --git a/src/main/java/zone/moddev/mc/realisticdeposits/generation/MineTerrainSampler.java b/src/main/java/zone/moddev/mc/realisticdeposits/generation/MineTerrainSampler.java new file mode 100644 index 0000000..fa3b7c7 --- /dev/null +++ b/src/main/java/zone/moddev/mc/realisticdeposits/generation/MineTerrainSampler.java @@ -0,0 +1,125 @@ +package zone.moddev.mc.realisticdeposits.generation; + +import java.util.HashMap; +import java.util.Map; + +import net.minecraft.block.material.Material; +import net.minecraft.block.state.IBlockState; +import net.minecraft.init.Blocks; +import net.minecraft.world.WorldServer; +import net.minecraft.world.chunk.ChunkPrimer; +import net.minecraft.world.chunk.IChunkGenerator; +import net.minecraft.world.gen.ChunkProviderFlat; +import net.minecraft.world.gen.ChunkProviderOverworld; +import net.minecraft.world.gen.FlatGeneratorInfo; +import net.minecraft.world.gen.FlatLayerInfo; + +import zone.moddev.mc.realisticdeposits.geometry.AbandonedMinePlan; +import zone.moddev.mc.realisticdeposits.geometry.MineBlock; +import zone.moddev.mc.realisticdeposits.geometry.MinePoint; + +/** Samples base terrain in memory; never asks the world to load another chunk. */ +final class MineTerrainSampler { + private final IChunkGenerator generator; + private final FlatGeneratorInfo flat; + private final Map samples = new HashMap<>(); + private final Map heights = new HashMap<>(); + + MineTerrainSampler(WorldServer world, IChunkGenerator generator) { + if (generator.getClass() != ChunkProviderOverworld.class && generator.getClass() != ChunkProviderFlat.class) { + throw new IllegalArgumentException("Abandoned mines do not yet support this world's terrain generator"); + } + + this.generator = generator; + flat = generator instanceof ChunkProviderFlat + ? FlatGeneratorInfo.createFlatGeneratorFromString(world.getWorldInfo().getGeneratorOptions()) : null; + } + + int surfaceY(MinePoint landing) { + int surface = heightAt(landing.x(), landing.z()); + if (surface < 0) return -1; + + for (int dx = -2; dx <= 2; dx++) { + for (int dz = -2; dz <= 2; dz++) { + int nearby = heightAt(landing.x() + dx, landing.z() + dz); + if (nearby < 0 || Math.abs(nearby - surface) > 1) return -1; + } + } + + return surface; + } + + boolean dry(AbandonedMinePlan plan) { + int[][] neighbours = {{0,0,0}, {1,0,0}, {-1,0,0}, {0,1,0}, {0,-1,0}, {0,0,1}, {0,0,-1}}; + for (Map.Entry cell : plan.blocks().entrySet()) { + if (cell.getValue() != MineBlock.AIR) continue; + MinePoint point = MinePoint.unpack(cell.getKey()); + + for (int[] offset : neighbours) { + if (state(point.x() + offset[0], point.y() + offset[1], point.z() + offset[2]) + .getMaterial().isLiquid()) return false; + } + } + + return true; + } + + boolean covered(AbandonedMinePlan plan) { + for (Map.Entry cell : plan.blocks().entrySet()) { + MinePoint point = MinePoint.unpack(cell.getKey()); + if (Math.abs(point.x() - plan.landing().x()) <= 2 + && Math.abs(point.z() - plan.landing().z()) <= 2) continue; + if (heightAt(point.x(), point.z()) < point.y() + 2) return false; + } + + return true; + } + + IBlockState state(int x, int y, int z) { + if (y < 0 || y > 255) return Blocks.BEDROCK.getDefaultState(); + int chunkX = Math.floorDiv(x, 16); + int chunkZ = Math.floorDiv(z, 16); + long key = MinePoint.chunkKey(chunkX, chunkZ); + ChunkPrimer primer = samples.get(key); + if (primer == null) { + primer = new ChunkPrimer(); + if (flat == null) { + // This public vanilla method fills only the supplied primer. Calling + // provideChunk here would also generate structures and is not safe. + ((ChunkProviderOverworld) generator).setBlocksInChunk(chunkX, chunkZ, primer); + } else { + for (FlatLayerInfo layer : flat.getFlatLayers()) { + for (int localX = 0; localX < 16; localX++) { + for (int localZ = 0; localZ < 16; localZ++) { + for (int layerY = layer.getMinY(); layerY < layer.getMinY() + layer.getLayerCount(); layerY++) { + primer.setBlockState(localX, layerY, localZ, layer.getLayerMaterial()); + } + } + } + } + } + + samples.put(key, primer); + } + + return primer.getBlockState(x & 15, y, z & 15); + } + + private int heightAt(int x, int z) { + long column = MinePoint.chunkKey(x, z); + Integer cached = heights.get(column); + if (cached != null) return cached; + + for (int y = 255; y >= 0; y--) { + IBlockState block = state(x, y, z); + if (block.getMaterial() != Material.AIR) { + int height = block.getMaterial().isLiquid() ? -1 : y + 1; + heights.put(column, height); + return height; + } + } + + heights.put(column, -1); + return -1; + } +} diff --git a/src/main/java/zone/moddev/mc/realisticdeposits/generation/MineWorkingsGenerator.java b/src/main/java/zone/moddev/mc/realisticdeposits/generation/MineWorkingsGenerator.java new file mode 100644 index 0000000..c8295b5 --- /dev/null +++ b/src/main/java/zone/moddev/mc/realisticdeposits/generation/MineWorkingsGenerator.java @@ -0,0 +1,402 @@ +package zone.moddev.mc.realisticdeposits.generation; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Random; +import java.util.Set; +import java.util.WeakHashMap; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import net.minecraft.block.Block; +import net.minecraft.block.BlockLadder; +import net.minecraft.block.BlockRail; +import net.minecraft.block.BlockRailBase; +import net.minecraft.block.state.IBlockState; +import net.minecraft.init.Blocks; +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.util.EnumFacing; +import net.minecraft.util.ResourceLocation; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.World; +import net.minecraft.world.WorldServer; +import net.minecraft.world.biome.Biome; +import net.minecraft.world.chunk.Chunk; +import net.minecraft.world.chunk.IChunkGenerator; +import net.minecraft.world.chunk.IChunkProvider; +import net.minecraft.world.chunk.storage.ExtendedBlockStorage; +import net.minecraft.world.gen.structure.MapGenStructureData; +import net.minecraftforge.common.BiomeDictionary; +import net.minecraftforge.event.world.WorldEvent; +import net.minecraftforge.event.world.ChunkDataEvent; +import net.minecraftforge.fml.common.IWorldGenerator; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; + +import zone.moddev.mc.orespawn.api.OreGenerationContext; +import zone.moddev.mc.orespawn.api.OreSpawnApi; +import zone.moddev.mc.realisticdeposits.geometry.AbandonedMinePlan; +import zone.moddev.mc.realisticdeposits.geometry.AbandonedMinePlanner; +import zone.moddev.mc.realisticdeposits.geometry.BlockBounds; +import zone.moddev.mc.realisticdeposits.geometry.MineBlock; +import zone.moddev.mc.realisticdeposits.geometry.MinePoint; +import zone.moddev.mc.realisticdeposits.geometry.MineableDepositBody; +import zone.moddev.mc.realisticdeposits.model.DepositDefinition; + +/** Builds abandoned workings after OreSpawn, without owning or replacing its ore pass. */ +public final class MineWorkingsGenerator implements IWorldGenerator { + public static final MineWorkingsGenerator INSTANCE = new MineWorkingsGenerator(); + private static final Logger LOG = LogManager.getLogger("Realistic Deposits"); + private static final Map SESSIONS = new WeakHashMap<>(); + private static final Map> PENDING = new LinkedHashMap<>(); + private static final String[] STRUCTURES = {"Mineshaft", "Village", "Stronghold", "Temple", "Monument"}; + + private MineWorkingsGenerator() { } + + /** Called only for definitions accepted by the current world's OreSpawn profile. */ + static void offer(OreGenerationContext context, DepositDefinition definition, MineableDepositBody body) { + if (!"minecraft:overworld".equals(context.dimension().toString())) return; + if (!AbandonedMinePlanner.selected(body, definition.abandonedMine())) return; + + MinePoint landing = AbandonedMinePlanner.landing(body); + if (Math.abs((long) (context.chunkX() << 4) - landing.x()) > 96L + || Math.abs((long) (context.chunkZ() << 4) - landing.z()) > 96L) return; + + String chunk = queueKey(context.worldSeed(), context.dimension().toString(), context.chunkX(), context.chunkZ()); + List candidates = PENDING.computeIfAbsent(chunk, ignored -> new ArrayList<>()); + String key = body.identity().toString(); + if (candidates.stream().noneMatch(candidate -> candidate.key.equals(key))) { + candidates.add(new Candidate(definition, body)); + } + + // A third-party generator may omit Forge's final population pass. Do not + // retain those unused proposals forever or let them leak into another save. + if (PENDING.size() > 256) PENDING.remove(PENDING.keySet().iterator().next()); + } + + @SubscribeEvent + public void worldLoaded(WorldEvent.Load event) { + if (event.getWorld() instanceof WorldServer && event.getWorld().provider.getDimension() == 0) { + SESSIONS.computeIfAbsent(event.getWorld(), ignored -> new Session((WorldServer) event.getWorld())); + } + } + + @SubscribeEvent + public void worldUnloaded(WorldEvent.Unload event) { + SESSIONS.remove(event.getWorld()); + String prefix = event.getWorld().getSeed() + "/"; + PENDING.keySet().removeIf(key -> key.startsWith(prefix)); + } + + @SubscribeEvent + public void chunkLoaded(ChunkDataEvent.Load event) { + if (!(event.getWorld() instanceof WorldServer) || event.getWorld().provider.getDimension() != 0) return; + + Session session = SESSIONS.computeIfAbsent(event.getWorld(), ignored -> new Session((WorldServer) event.getWorld())); + NBTTagCompound level = event.getData().getCompoundTag("Level"); + if (level.getBoolean("TerrainPopulated")) { + session.loadedPopulated.add(MinePoint.chunkKey(event.getChunk().xPosition, event.getChunk().zPosition)); + } + } + + @Override + public void generate(Random ignored, int chunkX, int chunkZ, World baseWorld, + IChunkGenerator generator, IChunkProvider provider) { + String dimension = baseWorld.provider.getDimension() == 0 ? "minecraft:overworld" + : Integer.toString(baseWorld.provider.getDimension()); + List candidates = PENDING.remove(queueKey(baseWorld.getSeed(), dimension, chunkX, chunkZ)); + if (!(baseWorld instanceof WorldServer) || baseWorld.provider.getDimension() != 0) return; + + WorldServer world = (WorldServer) baseWorld; + Session session = SESSIONS.get(world); + if (session == null) { + // Spawn chunks can populate before Forge posts WorldEvent.Load. + // Take the disk snapshot on that first pass and keep it for the session. + session = new Session(world); + SESSIONS.put(world, session); + } + + long chunkKey = MinePoint.chunkKey(chunkX, chunkZ); + boolean alreadyVisited = !session.visited.add(chunkKey); + if (session.existing == null || alreadyVisited) return; + boolean pendingSection = session.data != null && !session.data.unbuiltMinesInChunk(chunkX, chunkZ).isEmpty(); + if (session.existing.contains(chunkX, chunkZ) + && (!pendingSection || session.loadedPopulated.contains(chunkKey))) return; + if (candidates == null && session.data == null) return; + + Chunk chunk = world.getChunkProvider().getLoadedChunk(chunkX, chunkZ); + if (chunk == null) throw new IllegalStateException("The mine pass must only use its current loaded chunk"); + try { + if (session.data == null) session.data = MineWorldData.get(world); + MineWorldData data = session.data; + if (candidates != null) { + for (Candidate candidate : candidates) { + if (!data.decided(candidate.key)) decide(candidate, data, session, world, generator, chunkKey); + } + } + + // Saved pieces finish even if live seam geometry has since changed. + for (String key : data.unbuiltMinesInChunk(chunkX, chunkZ)) { + place(data.plan(key), data.definition(key), chunk, world, session); + data.sectionHandled(key, chunkX, chunkZ); + } + } catch (RuntimeException failure) { + if (!session.warned) { + LOG.warn("Abandoned workings were skipped: {}. No old chunks were excavated.", failure.getMessage()); + session.warned = true; + } + } + } + + private static void decide(Candidate candidate, MineWorldData data, Session session, + WorldServer world, IChunkGenerator generator, long currentChunk) { + MineTerrainSampler terrain = new MineTerrainSampler(world, generator); + MinePoint landing = AbandonedMinePlanner.landing(candidate.body); + Optional proposed = AbandonedMinePlanner.plan(candidate.body, + candidate.definition.abandonedMine(), terrain.surfaceY(landing)); + if (!proposed.isPresent()) { + data.reject(candidate.key, "No safe surface access"); + return; + } + + AbandonedMinePlan plan = proposed.get(); + if (session.existing.intersects(plan.bounds()) || crossesVisited(plan, session, currentChunk)) { + data.reject(candidate.key, "Existing terrain"); + } else if (!world.getWorldBorder().contains(new BlockPos(plan.bounds().minimumX(), 64, plan.bounds().minimumZ())) + || !world.getWorldBorder().contains(new BlockPos(plan.bounds().maximumX(), 64, plan.bounds().maximumZ()))) { + data.reject(candidate.key, "World border"); + } else if (!terrain.dry(plan) || !terrain.covered(plan) || !biomesAllowed(plan, candidate.definition, world)) { + data.reject(candidate.key, "Water, insufficient cover or an excluded biome"); + } else if (intersectsStructure(plan.bounds(), world)) { + data.reject(candidate.key, "Another structure"); + } else if (data.plans().values().stream().anyMatch(other -> overlaps(plan.bounds(), other.bounds()))) { + data.reject(candidate.key, "Another abandoned mine"); + } else { + data.accept(candidate.key, plan, candidate.definition); + LOG.info("Planned abandoned workings for '{}': surface={}, seam={}, blocks={}", + candidate.key, plan.entrance(), plan.landing(), plan.blocks().size()); + } + } + + private static boolean crossesVisited(AbandonedMinePlan plan, Session session, long current) { + BlockBounds bounds = plan.bounds(); + for (int x = Math.floorDiv(bounds.minimumX(), 16); x <= Math.floorDiv(bounds.maximumX(), 16); x++) { + for (int z = Math.floorDiv(bounds.minimumZ(), 16); z <= Math.floorDiv(bounds.maximumZ(), 16); z++) { + long key = MinePoint.chunkKey(x, z); + if (key != current && session.visited.contains(key)) return true; + } + } + + return false; + } + + static boolean biomesAllowed(AbandonedMinePlan plan, DepositDefinition definition, WorldServer world) { + Biome entrance = world.getBiomeProvider().getBiome( + new BlockPos(plan.entrance().x(), 0, plan.entrance().z())); + // The first version needs open ground for its surface frame. Do not + // clear tree trunks or guess which wooden blocks belong to a structure. + if (BiomeDictionary.isBiomeOfType(entrance, BiomeDictionary.Type.FOREST) + || BiomeDictionary.isBiomeOfType(entrance, BiomeDictionary.Type.CONIFEROUS) + || BiomeDictionary.isBiomeOfType(entrance, BiomeDictionary.Type.JUNGLE) + || BiomeDictionary.isBiomeOfType(entrance, BiomeDictionary.Type.SWAMP)) return false; + + Set columns = new HashSet<>(); + Map checked = new HashMap<>(); + for (long position : plan.blocks().keySet()) { + MinePoint point = MinePoint.unpack(position); + if (!columns.add(MinePoint.chunkKey(point.x(), point.z()))) continue; + Biome biome = world.getBiomeProvider().getBiome(new BlockPos(point.x(), 0, point.z())); + if (!checked.computeIfAbsent(biome, value -> biomeAllowed(value, definition))) return false; + } + + return true; + } + + private static boolean biomeAllowed(Biome biome, DepositDefinition definition) { + if (BiomeDictionary.isBiomeOfType(biome, BiomeDictionary.Type.OCEAN) + || BiomeDictionary.isBiomeOfType(biome, BiomeDictionary.Type.RIVER)) return false; + String id = Biome.REGISTRY.getNameForObject(biome).toString(); + if ((!definition.biomeIds().isEmpty() && !definition.biomeIds().contains(id)) + || definition.excludedBiomeIds().contains(id)) return false; + + Set types = new HashSet<>(); + for (BiomeDictionary.Type type : BiomeDictionary.getTypesForBiome(biome)) types.add(type.name()); + return (definition.biomeDictionary().isEmpty() || !Collections.disjoint(types, definition.biomeDictionary())) + && Collections.disjoint(types, definition.excludedBiomeDictionary()); + } + + private static boolean overlaps(BlockBounds left, BlockBounds right) { + return left.minimumX() <= right.maximumX() && left.maximumX() >= right.minimumX() + && left.minimumY() <= right.maximumY() && left.maximumY() >= right.minimumY() + && left.minimumZ() <= right.maximumZ() && left.maximumZ() >= right.minimumZ(); + } + + private static boolean intersectsStructure(BlockBounds bounds, WorldServer world) { + for (String name : STRUCTURES) { + MapGenStructureData structures = (MapGenStructureData) world.getMapStorage() + .getOrLoadData(MapGenStructureData.class, name); + if (structures == null) continue; + + NBTTagCompound entries = structures.getTagCompound(); + for (String key : entries.getKeySet()) { + int[] box = entries.getCompoundTag(key).getIntArray("BB"); + if (box.length == 6 && box[0] <= bounds.maximumX() + 2 && box[3] >= bounds.minimumX() - 2 + && box[1] <= bounds.maximumY() + 2 && box[4] >= bounds.minimumY() - 2 + && box[2] <= bounds.maximumZ() + 2 && box[5] >= bounds.minimumZ() - 2) return true; + } + } + + return false; + } + + private static void place(AbandonedMinePlan plan, DepositDefinition definition, + Chunk chunk, WorldServer world, Session session) { + List cells = plan.cellsInChunk(chunk.xPosition, chunk.zPosition); + if (cells.isEmpty()) return; + String hostKey = definition == null ? "vanilla" : definition.id(); + Set hosts = session.hosts.computeIfAbsent(hostKey, ignored -> naturalHosts(definition, world)); + BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos(); + + for (AbandonedMinePlan.Cell cell : cells) { + MinePoint point = cell.point(); + cursor.setPos(point.x(), point.y(), point.z()); + IBlockState current = chunk.getBlockState(cursor); + if (current.getMaterial().isLiquid() || current.getBlock().hasTileEntity(current) + || current.getBlock() != Blocks.AIR && !hosts.contains(current.getBlock())) { + LOG.info("Left abandoned workings sealed in chunk {},{}: {} at {} is not safe to replace.", + chunk.xPosition, chunk.zPosition, current, point); + return; + } + + // A newly generated lake or another mod's structure is allowed to block + // this section. Never open a hole into it or remove somebody else's work. + if (cell.block() == MineBlock.AIR) { + for (int dx = -1; dx <= 1; dx++) { + for (int dz = -1; dz <= 1; dz++) { + int x = point.x() + dx; + int z = point.z() + dz; + if ((x >> 4) != chunk.xPosition || (z >> 4) != chunk.zPosition) continue; + for (int dy = -1; dy <= 1; dy++) { + if (chunk.getBlockState(x & 15, point.y() + dy, z & 15).getMaterial().isLiquid()) { + LOG.info("Left abandoned workings sealed in chunk {},{} to avoid opening a liquid at {},{},{}.", + chunk.xPosition, chunk.zPosition, x, point.y() + dy, z); + return; + } + } + } + } + } + } + + ExtendedBlockStorage[] sections = chunk.getBlockStorageArray(); + for (AbandonedMinePlan.Cell cell : cells) { + MinePoint point = cell.point(); + int section = point.y() >> 4; + if (sections[section] == null) { + if (cell.block() == MineBlock.AIR) continue; + sections[section] = new ExtendedBlockStorage(section << 4, !world.provider.hasNoSky()); + } + + // Direct generation writes avoid drops, rail physics and neighbour loads. + // Lighting and the chunk's save flag are updated once after the slice. + sections[section].set(point.x() & 15, point.y() & 15, point.z() & 15, state(cell.block())); + } + + chunk.generateSkylightMap(); + chunk.setChunkModified(); + chunk.resetRelightChecks(); + } + + private static Set naturalHosts(DepositDefinition definition, WorldServer world) { + Set blocks = new HashSet<>(); + Collections.addAll(blocks, Blocks.STONE, Blocks.DIRT, Blocks.GRASS, Blocks.GRAVEL, Blocks.SAND, + Blocks.SANDSTONE, Blocks.RED_SANDSTONE, Blocks.HARDENED_CLAY, Blocks.STAINED_HARDENED_CLAY, + Blocks.MYCELIUM, Blocks.SNOW, Blocks.SNOW_LAYER, Blocks.COAL_ORE, Blocks.IRON_ORE, + Blocks.GOLD_ORE, Blocks.DIAMOND_ORE, Blocks.LAPIS_ORE, Blocks.REDSTONE_ORE, + Blocks.LIT_REDSTONE_ORE, Blocks.EMERALD_ORE, Blocks.TALLGRASS, Blocks.DOUBLE_PLANT, + Blocks.DEADBUSH, Blocks.RED_FLOWER, Blocks.YELLOW_FLOWER, Blocks.BROWN_MUSHROOM, Blocks.RED_MUSHROOM); + if (definition != null) { + for (String id : definition.hosts()) addHost(blocks, new ResourceLocation(id)); + addHost(blocks, new ResourceLocation(definition.resource())); + } + OreSpawnApi.getActiveProfile(world.getMinecraftServer()).ifPresent(profile -> { + for (ResourceLocation rock : profile.rockIds()) addHost(blocks, rock); + }); + + // These may be natural-looking, but they also make up villages and ruins. + blocks.remove(Blocks.COBBLESTONE); + blocks.remove(Blocks.PLANKS); + blocks.remove(Blocks.LOG); + blocks.remove(Blocks.LOG2); + blocks.remove(Blocks.BEDROCK); + return blocks; + } + + private static void addHost(Set hosts, ResourceLocation id) { + if (Block.REGISTRY.containsKey(id)) hosts.add(Block.REGISTRY.getObject(id)); + } + + private static IBlockState state(MineBlock block) { + switch (block) { + case AIR: return Blocks.AIR.getDefaultState(); + case PLANKS: return Blocks.PLANKS.getDefaultState(); + case FENCE: return Blocks.OAK_FENCE.getDefaultState(); + case COBWEB: return Blocks.WEB.getDefaultState(); + case COBBLESTONE: return Blocks.COBBLESTONE.getDefaultState(); + case LADDER_WEST: return Blocks.LADDER.getDefaultState().withProperty(BlockLadder.FACING, EnumFacing.WEST); + case RAIL_EAST_WEST: return Blocks.RAIL.getDefaultState().withProperty( + BlockRail.SHAPE, BlockRailBase.EnumRailDirection.EAST_WEST); + case RAIL_NORTH_SOUTH: return Blocks.RAIL.getDefaultState().withProperty( + BlockRail.SHAPE, BlockRailBase.EnumRailDirection.NORTH_SOUTH); + default: throw new IllegalArgumentException("Unknown mine palette entry " + block); + } + } + + private static String queueKey(long seed, String dimension, int chunkX, int chunkZ) { + return seed + "/" + dimension + "/" + chunkX + "," + chunkZ; + } + + private static final class Candidate { + private final String key; + private final DepositDefinition definition; + private final MineableDepositBody body; + + private Candidate(DepositDefinition definition, MineableDepositBody body) { + key = body.identity().toString(); + this.definition = definition; + this.body = body; + } + } + + private static final class Session { + private ExistingMineTerrain existing; + private final Set visited = new HashSet<>(); + private final Set loadedPopulated = new HashSet<>(); + private final Map> hosts = new HashMap<>(); + private boolean warned; + private MineWorldData data; + + private Session(WorldServer world) { + try { + existing = ExistingMineTerrain.read(new File(world.getSaveHandler().getWorldDirectory(), "region")); + if (world.getSaveHandler().getMapFileFromName(MineWorldData.NAME).isFile()) { + data = MineWorldData.get(world); + } + } catch (IOException failure) { + LOG.warn("Abandoned mines are disabled for this session because old chunks could not be inspected: {}", + failure.getMessage()); + } catch (RuntimeException failure) { + existing = null; + LOG.warn("Abandoned mines are disabled because their saved plans could not be read: {}", failure.getMessage()); + } + } + } +} diff --git a/src/main/java/zone/moddev/mc/realisticdeposits/generation/MineWorldData.java b/src/main/java/zone/moddev/mc/realisticdeposits/generation/MineWorldData.java new file mode 100644 index 0000000..f16a377 --- /dev/null +++ b/src/main/java/zone/moddev/mc/realisticdeposits/generation/MineWorldData.java @@ -0,0 +1,233 @@ +package zone.moddev.mc.realisticdeposits.generation; + +import java.util.Collections; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.LinkedHashSet; +import java.util.LinkedHashMap; +import java.util.Map; + +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.nbt.NBTTagList; +import net.minecraft.world.WorldSavedData; +import net.minecraft.world.WorldServer; + +import zone.moddev.mc.realisticdeposits.geometry.AbandonedMinePlan; +import zone.moddev.mc.realisticdeposits.geometry.MineBlock; +import zone.moddev.mc.realisticdeposits.geometry.MinePoint; +import zone.moddev.mc.realisticdeposits.config.DepositCatalogLoader; +import zone.moddev.mc.realisticdeposits.model.DepositDefinition; +import com.google.gson.JsonParser; + +/** Keeps an accepted mine unchanged while its remaining chunks are explored. */ +public final class MineWorldData extends WorldSavedData { + public static final String NAME = "realisticdeposits_mines"; + private final Map plans = new LinkedHashMap<>(); + private final Map rejected = new LinkedHashMap<>(); + private final Map definitions = new LinkedHashMap<>(); + private final Map> byChunk = new LinkedHashMap<>(); + private final Map> remaining = new LinkedHashMap<>(); + + public MineWorldData(String name) { + super(name); + } + + public static MineWorldData get(WorldServer world) { + MineWorldData data = (MineWorldData) world.getPerWorldStorage().getOrLoadData(MineWorldData.class, NAME); + if (data == null) { + if (world.getSaveHandler().getMapFileFromName(NAME).isFile()) { + throw new IllegalStateException("The saved abandoned-mine plans could not be read; restore their backup before generating more workings"); + } + data = new MineWorldData(NAME); + world.getPerWorldStorage().setData(NAME, data); + } + + return data; + } + + public Map plans() { + return Collections.unmodifiableMap(plans); + } + + public boolean decided(String key) { + return plans.containsKey(key) || rejected.containsKey(key); + } + + public String rejectionReason(String key) { + return rejected.get(key); + } + + public AbandonedMinePlan plan(String key) { + return plans.get(key); + } + + public DepositDefinition definition(String key) { + return definitions.get(key); + } + + public List minesInChunk(int x, int z) { + return Collections.unmodifiableList(byChunk.getOrDefault(MinePoint.chunkKey(x, z), Collections.emptyList())); + } + + public List unbuiltMinesInChunk(int x, int z) { + long chunk = MinePoint.chunkKey(x, z); + List result = new ArrayList<>(); + for (String key : minesInChunk(x, z)) { + if (remaining.get(key).contains(chunk)) result.add(key); + } + return result; + } + + public void sectionHandled(String key, int x, int z) { + if (remaining.get(key).remove(MinePoint.chunkKey(x, z))) markDirty(); + } + + public void accept(String key, AbandonedMinePlan plan) { + accept(key, plan, null); + } + + public void accept(String key, AbandonedMinePlan plan, DepositDefinition definition) { + if (decided(key)) throw new IllegalStateException("A mine plan cannot be replaced: " + key); + plans.put(key, plan); + if (definition != null) definitions.put(key, definition); + index(key, plan); + Set pending = new LinkedHashSet<>(); + for (Map.Entry> slice : byChunk.entrySet()) { + if (slice.getValue().contains(key)) pending.add(slice.getKey()); + } + remaining.put(key, pending); + markDirty(); + } + + public void reject(String key, String reason) { + if (decided(key)) throw new IllegalStateException("A mine decision cannot be replaced: " + key); + rejected.put(key, reason); + markDirty(); + } + + @Override + public void readFromNBT(NBTTagCompound root) { + if (root.getInteger("schema_version") != 1) { + throw new IllegalArgumentException("Unsupported Realistic Deposits mine-save version"); + } + + plans.clear(); + rejected.clear(); + definitions.clear(); + byChunk.clear(); + remaining.clear(); + NBTTagList entries = root.getTagList("mines", 10); + for (int i = 0; i < entries.tagCount(); i++) { + NBTTagCompound entry = entries.getCompoundTagAt(i); + String key = entry.getString("key"); + if (key.isEmpty() || decided(key)) throw new IllegalArgumentException("Invalid or duplicate saved mine key"); + + if (entry.hasKey("rejected", 8)) { + rejected.put(key, entry.getString("rejected")); + continue; + } + + if (entry.getInteger("layout_version") != AbandonedMinePlan.LAYOUT_VERSION) { + throw new IllegalArgumentException("Unsupported abandoned mine layout version"); + } + + int[] positions = entry.getIntArray("positions"); + byte[] palette = entry.getByteArray("palette"); + if (positions.length != palette.length * 2 || palette.length == 0 + || palette.length > AbandonedMinePlan.MAXIMUM_BLOCKS) { + throw new IllegalArgumentException("Invalid saved mine block arrays"); + } + + Map blocks = new LinkedHashMap<>(); + for (int cell = 0; cell < palette.length; cell++) { + long position = (long) positions[cell * 2] << 32 | positions[cell * 2 + 1] & 0xFFFFFFFFL; + if (blocks.put(position, MineBlock.fromSavedId(palette[cell] & 255)) != null) { + throw new IllegalArgumentException("Duplicate saved mine position"); + } + } + + AbandonedMinePlan plan = new AbandonedMinePlan(entry.getLong("identity"), + MinePoint.unpack(entry.getLong("entrance")), MinePoint.unpack(entry.getLong("landing")), blocks); + plans.put(key, plan); + if (entry.hasKey("definition", 8)) { + definitions.put(key, DepositCatalogLoader.loadDefinition(new JsonParser() + .parse(entry.getString("definition")).getAsJsonObject(), "saved abandoned mine")); + } + index(key, plan); + if (!entry.hasKey("remaining_chunks", 11)) { + throw new IllegalArgumentException("Saved mine has no record of its unbuilt sections"); + } + int[] chunks = entry.getIntArray("remaining_chunks"); + if ((chunks.length & 1) != 0) throw new IllegalArgumentException("Invalid unbuilt mine sections"); + Set pending = new LinkedHashSet<>(); + for (int chunk = 0; chunk < chunks.length; chunk += 2) { + long position = MinePoint.chunkKey(chunks[chunk], chunks[chunk + 1]); + if (plan.cellsInChunk(chunks[chunk], chunks[chunk + 1]).isEmpty() || !pending.add(position)) { + throw new IllegalArgumentException("Invalid unbuilt mine section claim"); + } + } + remaining.put(key, pending); + } + } + + @Override + public NBTTagCompound writeToNBT(NBTTagCompound root) { + root.setInteger("schema_version", 1); + NBTTagList entries = new NBTTagList(); + + for (Map.Entry saved : plans.entrySet()) { + AbandonedMinePlan plan = saved.getValue(); + NBTTagCompound entry = new NBTTagCompound(); + entry.setString("key", saved.getKey()); + entry.setInteger("layout_version", AbandonedMinePlan.LAYOUT_VERSION); + entry.setLong("identity", plan.identity()); + entry.setLong("entrance", plan.entrance().packed()); + entry.setLong("landing", plan.landing().packed()); + if (definitions.containsKey(saved.getKey())) { + entry.setString("definition", definitions.get(saved.getKey()).toJson().toString()); + } + int[] positions = new int[plan.blocks().size() * 2]; + byte[] palette = new byte[plan.blocks().size()]; + int index = 0; + + for (Map.Entry block : plan.blocks().entrySet()) { + positions[index * 2] = (int) (block.getKey() >> 32); + positions[index * 2 + 1] = (int) (long) block.getKey(); + palette[index] = (byte) block.getValue().savedId(); + index++; + } + + entry.setIntArray("positions", positions); + entry.setByteArray("palette", palette); + int[] chunks = new int[remaining.get(saved.getKey()).size() * 2]; + int chunkIndex = 0; + for (long chunk : remaining.get(saved.getKey())) { + chunks[chunkIndex++] = (int) (chunk >> 32); + chunks[chunkIndex++] = (int) chunk; + } + entry.setIntArray("remaining_chunks", chunks); + entries.appendTag(entry); + } + + for (Map.Entry skipped : rejected.entrySet()) { + NBTTagCompound entry = new NBTTagCompound(); + entry.setString("key", skipped.getKey()); + entry.setString("rejected", skipped.getValue()); + entries.appendTag(entry); + } + + root.setTag("mines", entries); + return root; + } + + private void index(String key, AbandonedMinePlan plan) { + for (int x = Math.floorDiv(plan.bounds().minimumX(), 16); x <= Math.floorDiv(plan.bounds().maximumX(), 16); x++) { + for (int z = Math.floorDiv(plan.bounds().minimumZ(), 16); z <= Math.floorDiv(plan.bounds().maximumZ(), 16); z++) { + if (!plan.cellsInChunk(x, z).isEmpty()) { + byChunk.computeIfAbsent(MinePoint.chunkKey(x, z), ignored -> new ArrayList<>()).add(key); + } + } + } + } +} diff --git a/src/main/java/zone/moddev/mc/realisticdeposits/generation/StratiformSeamPattern.java b/src/main/java/zone/moddev/mc/realisticdeposits/generation/StratiformSeamPattern.java index cea43cd..18a41a0 100644 --- a/src/main/java/zone/moddev/mc/realisticdeposits/generation/StratiformSeamPattern.java +++ b/src/main/java/zone/moddev/mc/realisticdeposits/generation/StratiformSeamPattern.java @@ -87,6 +87,7 @@ private static boolean placeResolved(DepositDefinition definition, StratiformSeamBody body = planned.get(); if (!body.bounds().intersectsChunk(context.chunkX(), context.chunkZ()) || !acceptsCenter(definition, body, sampler)) continue; + MineWorkingsGenerator.offer(context, definition, body); BlockBounds bounds = body.bounds(); for (int x = Math.max(chunkMinX, bounds.minimumX()); x <= Math.min(chunkMaxX, bounds.maximumX()); x++) { diff --git a/src/main/java/zone/moddev/mc/realisticdeposits/geometry/AbandonedMinePlan.java b/src/main/java/zone/moddev/mc/realisticdeposits/geometry/AbandonedMinePlan.java new file mode 100644 index 0000000..629db54 --- /dev/null +++ b/src/main/java/zone/moddev/mc/realisticdeposits/geometry/AbandonedMinePlan.java @@ -0,0 +1,88 @@ +package zone.moddev.mc.realisticdeposits.geometry; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** A frozen, bounded set of workings, indexed by the chunks that build them. */ +public final class AbandonedMinePlan { + public static final int LAYOUT_VERSION = 1; + public static final int MAXIMUM_BLOCKS = 16384; + + private final long identity; + private final MinePoint entrance; + private final MinePoint landing; + private final Map blocks; + private final Map> chunks; + private final BlockBounds bounds; + + public AbandonedMinePlan(long identity, MinePoint entrance, MinePoint landing, + Map blocks) { + if (blocks.isEmpty() || blocks.size() > MAXIMUM_BLOCKS) { + throw new IllegalArgumentException("Mine plan has an invalid block count"); + } + + this.identity = identity; + this.entrance = entrance; + this.landing = landing; + this.blocks = Collections.unmodifiableMap(new LinkedHashMap<>(blocks)); + Map> slices = new LinkedHashMap<>(); + int minX = Integer.MAX_VALUE; + int minY = Integer.MAX_VALUE; + int minZ = Integer.MAX_VALUE; + int maxX = Integer.MIN_VALUE; + int maxY = Integer.MIN_VALUE; + int maxZ = Integer.MIN_VALUE; + + for (Map.Entry entry : blocks.entrySet()) { + MinePoint point = MinePoint.unpack(entry.getKey()); + if (point.packed() != entry.getKey() || point.y() < 5 || point.y() > 250 + || Math.abs((long) point.x() - landing.x()) > 80L + || Math.abs((long) point.z() - landing.z()) > 80L) { + throw new IllegalArgumentException("Mine plan exceeds its safe bounds"); + } + + minX = Math.min(minX, point.x()); + minY = Math.min(minY, point.y()); + minZ = Math.min(minZ, point.z()); + maxX = Math.max(maxX, point.x()); + maxY = Math.max(maxY, point.y()); + maxZ = Math.max(maxZ, point.z()); + long key = MinePoint.chunkKey(Math.floorDiv(point.x(), 16), Math.floorDiv(point.z(), 16)); + slices.computeIfAbsent(key, ignored -> new ArrayList<>()).add(new Cell(point, entry.getValue())); + } + + Map> immutable = new LinkedHashMap<>(); + for (Map.Entry> slice : slices.entrySet()) { + immutable.put(slice.getKey(), Collections.unmodifiableList(slice.getValue())); + } + + chunks = Collections.unmodifiableMap(immutable); + bounds = new BlockBounds(minX, minY, minZ, maxX, maxY, maxZ); + } + + public long identity() { return identity; } + public MinePoint entrance() { return entrance; } + public MinePoint landing() { return landing; } + public Map blocks() { return blocks; } + public BlockBounds bounds() { return bounds; } + + public List cellsInChunk(int x, int z) { + return chunks.getOrDefault(MinePoint.chunkKey(x, z), Collections.emptyList()); + } + + public static final class Cell { + private final MinePoint point; + private final MineBlock block; + + private Cell(MinePoint point, MineBlock block) { + this.point = point; + this.block = block; + } + + public MinePoint point() { return point; } + public MineBlock block() { return block; } + } +} diff --git a/src/main/java/zone/moddev/mc/realisticdeposits/geometry/AbandonedMinePlanner.java b/src/main/java/zone/moddev/mc/realisticdeposits/geometry/AbandonedMinePlanner.java new file mode 100644 index 0000000..b45cd93 --- /dev/null +++ b/src/main/java/zone/moddev/mc/realisticdeposits/geometry/AbandonedMinePlanner.java @@ -0,0 +1,198 @@ +package zone.moddev.mc.realisticdeposits.geometry; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Random; +import java.util.Set; + +import zone.moddev.mc.realisticdeposits.model.AbandonedMineSettings; + +/** Plans a small worked section while leaving the deposit's own random stream alone. */ +public final class AbandonedMinePlanner { + private static final long SELECTION_SALT = 0x13198A2E03707344L; + private static final long LAYOUT_SALT = 0xA4093822299F31D0L; + public static final int MAXIMUM_SHAFT_DEPTH = 96; + + private AbandonedMinePlanner() { } + + public static boolean selected(MineableDepositBody body, AbandonedMineSettings settings) { + return settings.enabled() + && StableHash.unit(body.identity().seed() ^ SELECTION_SALT) < settings.chance(); + } + + public static MinePoint landing(MineableDepositBody body) { + return body.miningRoutePoint(anchorAlong(body), 0.0D); + } + + public static Optional plan(MineableDepositBody body, + AbandonedMineSettings settings, int surfaceY) { + if (!selected(body, settings)) return Optional.empty(); + + MinePoint landing = landing(body); + if (surfaceY < landing.y() + 6 || surfaceY - landing.y() > MAXIMUM_SHAFT_DEPTH + || landing.y() < 9 || surfaceY > 246) { + return Optional.empty(); + } + + Random random = new Random(StableHash.mix(body.identity().seed() ^ LAYOUT_SALT)); + double anchor = anchorAlong(body); + int halfLength = 24 + random.nextInt(9); + List> routes = new ArrayList<>(); + List main = new ArrayList<>(); + + for (int along = -halfLength; along <= halfLength; along += 2) { + append(main, body.miningRoutePoint(anchor + along, 0.0D)); + } + + routes.add(main); + for (int branch = 0; branch < 2; branch++) { + int junction = branch == 0 ? main.size() / 3 : main.size() * 2 / 3; + MinePoint start = main.get(junction); + double along = anchor + (branch == 0 ? -halfLength / 3.0D : halfLength / 3.0D); + double offset = (branch == 0 ? -1.0D : 1.0D) * (12 + random.nextInt(9)); + List side = new ArrayList<>(); + side.add(start); + append(side, body.miningRoutePoint(along, offset)); + routes.add(side); + } + + List connection = new ArrayList<>(); + connection.add(landing); + append(connection, main.get(main.size() / 2)); + routes.add(connection); + + Map blocks = new LinkedHashMap<>(); + Set air = new LinkedHashSet<>(); + for (List route : routes) { + for (MinePoint point : route) { + if (point.y() < 8 || point.y() > 245) return Optional.empty(); + + for (int dx = -1; dx <= 1; dx++) { + for (int dz = -1; dz <= 1; dz++) { + put(blocks, point.x() + dx, point.y() - 1, point.z() + dz, MineBlock.PLANKS); + for (int dy = 0; dy < 3; dy++) { + air.add(new MinePoint(point.x() + dx, point.y() + dy, point.z() + dz).packed()); + } + } + } + } + } + + for (long position : air) blocks.put(position, MineBlock.AIR); + for (List route : routes) decorate(blocks, air, route, body.identity().seed()); + for (int branch = 1; branch <= 2; branch++) { + List route = routes.get(branch); + MinePoint end = route.get(route.size() - 1); + for (int dy = 0; dy <= 1; dy++) { + long rubble = new MinePoint(end.x() + 1, end.y() + dy, end.z() + 1).packed(); + if (air.contains(rubble)) blocks.put(rubble, MineBlock.COBBLESTONE); + } + } + shaft(blocks, landing, surfaceY); + + return Optional.of(new AbandonedMinePlan(body.identity().seed(), + new MinePoint(landing.x(), surfaceY, landing.z()), landing, blocks)); + } + + private static double anchorAlong(MineableDepositBody body) { + return (StableHash.unit(body.identity().seed() ^ LAYOUT_SALT) - 0.5D) + * Math.min(64.0D, body.strikeLength() * 0.16D); + } + + private static void append(List route, MinePoint end) { + if (route.isEmpty()) { + route.add(end); + return; + } + + MinePoint current = route.get(route.size() - 1); + int distance = Math.abs(end.x() - current.x()) + Math.abs(end.z() - current.z()); + for (int step = 0; step < distance; step++) { + int dx = end.x() - current.x(); + int dz = end.z() - current.z(); + boolean moveX = Math.abs(dx) >= Math.abs(dz); + current = new MinePoint(current.x() + (moveX ? Integer.signum(dx) : 0), + current.y() + Integer.signum(end.y() - current.y()), + current.z() + (moveX ? 0 : Integer.signum(dz))); + route.add(current); + } + } + + private static void decorate(Map blocks, Set air, + List route, long identity) { + for (int i = 2; i < route.size() - 2; i++) { + MinePoint point = route.get(i); + MinePoint previous = route.get(i - 1); + MinePoint next = route.get(i + 1); + boolean eastWest = previous.z() == next.z(); + boolean straight = eastWest || previous.x() == next.x(); + boolean flat = previous.y() == point.y() && next.y() == point.y(); + long detail = StableHash.combine(identity ^ LAYOUT_SALT, point.packed()); + + if (straight && flat && StableHash.unit(detail) < 0.55D + && blocks.get(new MinePoint(point.x(), point.y() - 1, point.z()).packed()) == MineBlock.PLANKS) { + put(blocks, point.x(), point.y(), point.z(), + eastWest ? MineBlock.RAIL_EAST_WEST : MineBlock.RAIL_NORTH_SOUTH); + } + + if (straight && i % 8 == 0) { + for (int offset = -2; offset <= 2; offset++) { + int x = point.x() + (eastWest ? 0 : offset); + int z = point.z() + (eastWest ? offset : 0); + long beam = new MinePoint(x, point.y() + 3, z).packed(); + if (!air.contains(beam)) blocks.put(beam, MineBlock.PLANKS); + + if (Math.abs(offset) == 2) { + for (int dy = 0; dy < 3; dy++) { + long post = new MinePoint(x, point.y() + dy, z).packed(); + if (!air.contains(post)) blocks.put(post, MineBlock.FENCE); + } + } + } + } + + if (StableHash.unit(detail ^ SELECTION_SALT) < 0.12D) { + put(blocks, point.x() + (eastWest ? 0 : 1), point.y() + 2, + point.z() + (eastWest ? 1 : 0), MineBlock.COBWEB); + } + } + } + + private static void shaft(Map blocks, MinePoint landing, int surfaceY) { + for (int y = landing.y(); y <= surfaceY + 2; y++) { + for (int dx = -1; dx <= 1; dx++) { + for (int dz = -1; dz <= 1; dz++) { + put(blocks, landing.x() + dx, y, landing.z() + dz, MineBlock.AIR); + } + } + + put(blocks, landing.x() + 2, y, landing.z(), MineBlock.PLANKS); + if (y <= surfaceY) put(blocks, landing.x() + 1, y, landing.z(), MineBlock.LADDER_WEST); + + if ((y - landing.y()) % 7 == 0 || y == surfaceY + 2) { + for (int offset = -2; offset <= 2; offset++) { + put(blocks, landing.x() - 2, y, landing.z() + offset, MineBlock.PLANKS); + put(blocks, landing.x() + 2, y, landing.z() + offset, MineBlock.PLANKS); + put(blocks, landing.x() + offset, y, landing.z() - 2, MineBlock.PLANKS); + put(blocks, landing.x() + offset, y, landing.z() + 2, MineBlock.PLANKS); + } + } + } + + for (int dx = -2; dx <= 2; dx++) { + for (int dz = -2; dz <= 2; dz++) { + if (Math.abs(dx) == 2 || Math.abs(dz) == 2) { + put(blocks, landing.x() + dx, surfaceY - 1, landing.z() + dz, MineBlock.PLANKS); + } + } + } + } + + private static void put(Map blocks, int x, int y, int z, MineBlock block) { + blocks.put(new MinePoint(x, y, z).packed(), block); + } +} diff --git a/src/main/java/zone/moddev/mc/realisticdeposits/geometry/CoalMeasuresBody.java b/src/main/java/zone/moddev/mc/realisticdeposits/geometry/CoalMeasuresBody.java new file mode 100644 index 0000000..4125927 --- /dev/null +++ b/src/main/java/zone/moddev/mc/realisticdeposits/geometry/CoalMeasuresBody.java @@ -0,0 +1,233 @@ +package zone.moddev.mc.realisticdeposits.geometry; + +import java.util.Optional; +import java.util.Random; + +import zone.moddev.mc.realisticdeposits.model.CoalMeasuresSettings; +import zone.moddev.mc.realisticdeposits.model.DepositDefinition; + +/** Several bedded coal seams, with the same folding and faulting in every bed. */ +public final class CoalMeasuresBody implements MineableDepositBody { + private final DepositIdentity identity; + private final int x; + private final int y; + private final int z; + private final int length; + private final int width; + private final int minY; + private final int maxY; + private final double cosine; + private final double sine; + private final double dip; + private final double fold; + private final double phase; + private final double[] offsets; + private final int[] thickness; + private final double[] faults; + private final int[] throwsBySegment; + private final BeddingGrid[] bedding; + private final BlockBounds bounds; + + private CoalMeasuresBody(DepositIdentity identity, int x, int y, int z, int length, int width, + int minY, int maxY, double cosine, double sine, double dip, double fold, double phase, + double[] offsets, int[] thickness, double[] faults, int[] throwsBySegment, BeddingGrid[] bedding) { + this.identity = identity; + this.x = x; + this.y = y; + this.z = z; + this.length = length; + this.width = width; + this.minY = minY; + this.maxY = maxY; + this.cosine = cosine; + this.sine = sine; + this.dip = dip; + this.fold = fold; + this.phase = phase; + this.offsets = offsets; + this.thickness = thickness; + this.faults = faults; + this.throwsBySegment = throwsBySegment; + this.bedding = bedding == null ? null : bedding.clone(); + int radius = (int) Math.ceil(length * 0.5D + width * 0.5D + 2); + bounds = new BlockBounds(x - radius, minY, z - radius, x + radius, maxY, z + radius); + } + + public static Optional plan(DepositDefinition definition, + long worldSeed, String dimension, int regionX, int regionZ) { + if (!definition.enabled() || !definition.isCoal() || !definition.dimensions().contains(dimension)) { + return Optional.empty(); + } + + DepositIdentity identity = new DepositIdentity(worldSeed, dimension, definition.id(), + regionX, regionZ, definition.salt()); + if (StableHash.unit(identity.seed() ^ 0x43A5C85C97CB3127L) >= definition.selectionChance()) { + return Optional.empty(); + } + + Random random = new Random(StableHash.mix(identity.seed() ^ 0xC6BC279692B5CC83L)); + CoalMeasuresSettings settings = definition.coal(); + int region = definition.regionSpacingChunks() * 16; + int x = Math.toIntExact((long) regionX * region + random.nextInt(region)); + int z = Math.toIntExact((long) regionZ * region + random.nextInt(region)); + int length = settings.strikeLength().sample(random); + int width = settings.downDipWidth().sample(random); + double strike = random.nextDouble() * Math.PI * 2; + double dip = Math.tan(Math.toRadians((random.nextDouble() * 2 - 1) * settings.maximumDip())); + int[] thickness = new int[settings.beds().sample(random)]; + double[] offsets = new double[thickness.length]; + int span = 0; + + for (int bed = 0; bed < thickness.length; bed++) { + thickness[bed] = settings.thickness().sample(random); + offsets[bed] = span + thickness[bed] * 0.5D; + span += thickness[bed]; + if (bed + 1 < thickness.length) span += settings.separation().sample(random); + } + for (int bed = 0; bed < offsets.length; bed++) offsets[bed] -= span * 0.5D; + + double[] faults = {-length * (0.12D + random.nextDouble() * 0.18D), + length * (0.12D + random.nextDouble() * 0.18D)}; + int[] throwsBySegment = {0, 0, 0}; + for (int segment = 1; segment < 3; segment++) { + throwsBySegment[segment] = throwsBySegment[segment - 1] + + random.nextInt(settings.maximumFaultThrow() * 2 + 1) - settings.maximumFaultThrow(); + } + + int radiusY = (int) Math.ceil(span * 0.5D + Math.abs(dip) * width * 0.5D + + settings.foldAmplitude() + settings.maximumFaultThrow() * 2 + 2); + int bottom = definition.minimumY() + radiusY; + int top = definition.maximumY() - radiusY; + if (bottom > top) return Optional.empty(); + int y = bottom + random.nextInt(top - bottom + 1); + + return Optional.of(new CoalMeasuresBody(identity, x, y, z, length, width, + definition.minimumY(), definition.maximumY(), Math.cos(strike), Math.sin(strike), + dip, settings.foldAmplitude(), random.nextDouble() * Math.PI * 2, + offsets, thickness, faults, throwsBySegment, null)); + } + + /** Takes only immutable sampled heights; no world or geology sampler is retained. */ + public CoalMeasuresBody withBedding(BeddingGrid[] grid) { + if (grid.length != bedCount()) throw new IllegalArgumentException("Each coal bed needs a bedding surface"); + return new CoalMeasuresBody(identity, x, y, z, length, width, minY, maxY, cosine, sine, + dip, fold, phase, offsets, thickness, faults, throwsBySegment, grid); + } + + @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 length; } + public int downDipWidth() { return width; } + public int bedCount() { return offsets.length; } + public boolean selectedOutsideBasin() { + return StableHash.unit(identity.seed() ^ 0xDB4F0B9175AE2165L) < 0.25D; + } + public double bedOffset(int bed) { return offsets[bed]; } + public int bedThickness(int bed) { return thickness[bed]; } + public double halfStackHeight() { return (offsets[offsets.length - 1] - offsets[0]) * 0.5D + 2; } + @Override public BlockBounds bounds() { return bounds; } + + public boolean containsColumn(int blockX, int blockZ) { + double along = along(blockX, blockZ) / (length * 0.5D); + double across = across(blockX, blockZ) / (width * 0.5D); + return along * along + across * across < 1.0D; + } + + public double bedY(int bed, int blockX, int blockZ) { + double along = along(blockX, blockZ); + double across = across(blockX, blockZ); + double base = bedding == null ? y + across * dip + offsets[bed] + : bedding[bed].heightAt(blockX, blockZ); + int segment = along < faults[0] ? 0 : along < faults[1] ? 1 : 2; + return base + throwsBySegment[segment] + + fold * Math.sin(along / 140.0D + phase) * Math.cos(across / 110.0D); + } + + public int minimumCandidateYAt(int blockX, int blockZ) { + if (!containsColumn(blockX, blockZ)) return 1; + int minimum = Integer.MAX_VALUE; + for (int bed = 0; bed < offsets.length; bed++) { + double base = bedY(bed, blockX, blockZ); + if (Double.isFinite(base)) minimum = Math.min(minimum, (int) Math.floor(base - thickness[bed])); + } + return minimum == Integer.MAX_VALUE ? 1 : Math.max(minY, minimum); + } + + public int maximumCandidateYAt(int blockX, int blockZ) { + if (!containsColumn(blockX, blockZ)) return 0; + int maximum = Integer.MIN_VALUE; + for (int bed = 0; bed < offsets.length; bed++) { + double base = bedY(bed, blockX, blockZ); + if (Double.isFinite(base)) maximum = Math.max(maximum, (int) Math.ceil(base + thickness[bed])); + } + return maximum == Integer.MIN_VALUE ? 0 : Math.min(maxY, maximum); + } + + public boolean isOreAt(int blockX, int blockY, int blockZ) { + if (blockY < minY || blockY > maxY || !containsColumn(blockX, blockZ)) return false; + double along = along(blockX, blockZ); + double across = across(blockX, blockZ); + double edge = 1 - Math.sqrt(Math.pow(along / (length * 0.5D), 2) + + Math.pow(across / (width * 0.5D), 2)); + double taper = Math.min(1, edge / 0.12D); + + for (int bed = 0; bed < offsets.length; bed++) { + double centre = bedY(bed, blockX, blockZ); + if (!Double.isFinite(centre)) continue; + double half = thickness[bed] * 0.5D * taper; + if (half < 0.3D || blockY + 0.5D < centre - half || blockY + 0.5D >= centre + half) continue; + + // A local rock parting splits thicker beds; it never fills the gap with more coal. + boolean split = thickness[bed] >= 3 && along > -length * 0.22D && along < length * 0.18D + && across > width * 0.08D && across < width * 0.30D; + if (!split || Math.abs(blockY + 0.5D - centre) >= 0.5D) return true; + } + return false; + } + + @Override + public MinePoint miningRoutePoint(double along, double lateralOffset) { + int blockX = (int) Math.floor(x + along * cosine - lateralOffset * sine); + int blockZ = (int) Math.floor(z + along * sine + lateralOffset * cosine); + return new MinePoint(blockX, (int) Math.floor(bedY(offsets.length - 1, blockX, blockZ)), blockZ); + } + + private double along(int blockX, int blockZ) { return (blockX - x) * cosine + (blockZ - z) * sine; } + private double across(int blockX, int blockZ) { return -(blockX - x) * sine + (blockZ - z) * cosine; } + + /** Coarse, read-only bedding surface shared by all beds of one coalfield. */ + public static final class BeddingGrid { + private final int x; + private final int z; + private final int step; + private final int side; + private final float[] heights; + + public BeddingGrid(int x, int z, int step, int side, float[] heights) { + if (step < 1 || side < 2 || heights.length != side * side) { + throw new IllegalArgumentException("Invalid coal bedding grid"); + } + this.x = x; + this.z = z; + this.step = step; + this.side = side; + this.heights = heights.clone(); + } + + public double heightAt(int blockX, int blockZ) { + double fx = (blockX - x) / (double) step; + double fz = (blockZ - z) / (double) step; + int ix = (int) Math.floor(fx); + int iz = (int) Math.floor(fz); + if (ix < 0 || iz < 0 || ix + 1 >= side || iz + 1 >= side) return Double.NaN; + double a = heights[ix + iz * side]; + double b = heights[ix + 1 + iz * side]; + double c = heights[ix + (iz + 1) * side]; + double d = heights[ix + 1 + (iz + 1) * side]; + return (a * (1 - (fx - ix)) + b * (fx - ix)) * (1 - (fz - iz)) + + (c * (1 - (fx - ix)) + d * (fx - ix)) * (fz - iz); + } + } +} diff --git a/src/main/java/zone/moddev/mc/realisticdeposits/geometry/MineBlock.java b/src/main/java/zone/moddev/mc/realisticdeposits/geometry/MineBlock.java new file mode 100644 index 0000000..e5d9c50 --- /dev/null +++ b/src/main/java/zone/moddev/mc/realisticdeposits/geometry/MineBlock.java @@ -0,0 +1,30 @@ +package zone.moddev.mc.realisticdeposits.geometry; + +/** Stable palette used by the world save; numbers must not be reassigned. */ +public enum MineBlock { + AIR(0), PLANKS(1), FENCE(2), RAIL_NORTH_SOUTH(3), RAIL_EAST_WEST(4), + COBWEB(5), LADDER_WEST(6), COBBLESTONE(7); + + private final int savedId; + + MineBlock(int savedId) { + this.savedId = savedId; + } + + public int savedId() { + return savedId; + } + + public boolean passable() { + return this == AIR || this == RAIL_NORTH_SOUTH || this == RAIL_EAST_WEST + || this == LADDER_WEST || this == COBWEB; + } + + public static MineBlock fromSavedId(int id) { + for (MineBlock block : values()) { + if (block.savedId == id) return block; + } + + throw new IllegalArgumentException("Unknown saved mine block " + id); + } +} diff --git a/src/main/java/zone/moddev/mc/realisticdeposits/geometry/MinePoint.java b/src/main/java/zone/moddev/mc/realisticdeposits/geometry/MinePoint.java new file mode 100644 index 0000000..db43a55 --- /dev/null +++ b/src/main/java/zone/moddev/mc/realisticdeposits/geometry/MinePoint.java @@ -0,0 +1,48 @@ +package zone.moddev.mc.realisticdeposits.geometry; + +/** A block coordinate in a saved mine plan. */ +public final class MinePoint { + private final int x; + private final int y; + private final int z; + + public MinePoint(int x, int y, int z) { + this.x = x; + this.y = y; + this.z = z; + } + + public int x() { return x; } + public int y() { return y; } + public int z() { return z; } + + public long packed() { + return ((long) x & 0x3FFFFFFL) << 34 | ((long) z & 0x3FFFFFFL) << 8 | y & 255L; + } + + public static MinePoint unpack(long value) { + return new MinePoint((int) (value << 4 >> 38), (int) (value & 255L), + (int) (value << 30 >> 38)); + } + + public static long chunkKey(int x, int z) { + return (long) x << 32 | z & 0xFFFFFFFFL; + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof MinePoint)) return false; + MinePoint point = (MinePoint) other; + return x == point.x && y == point.y && z == point.z; + } + + @Override + public int hashCode() { + return Long.hashCode(packed()); + } + + @Override + public String toString() { + return x + "," + y + "," + z; + } +} diff --git a/src/main/java/zone/moddev/mc/realisticdeposits/geometry/MineableDepositBody.java b/src/main/java/zone/moddev/mc/realisticdeposits/geometry/MineableDepositBody.java new file mode 100644 index 0000000..b117b0d --- /dev/null +++ b/src/main/java/zone/moddev/mc/realisticdeposits/geometry/MineableDepositBody.java @@ -0,0 +1,15 @@ +package zone.moddev.mc.realisticdeposits.geometry; + +/** The small part of a deposit needed to plan its abandoned workings. */ +public interface MineableDepositBody { + DepositIdentity identity(); + int centerX(); + int centerY(); + int centerZ(); + int strikeLength(); + BlockBounds bounds(); + int minimumCandidateYAt(int x, int z); + int maximumCandidateYAt(int x, int z); + boolean isOreAt(int x, int y, int z); + MinePoint miningRoutePoint(double along, double lateralOffset); +} diff --git a/src/main/java/zone/moddev/mc/realisticdeposits/geometry/StratiformSeamBody.java b/src/main/java/zone/moddev/mc/realisticdeposits/geometry/StratiformSeamBody.java index e64c537..65f351e 100644 --- a/src/main/java/zone/moddev/mc/realisticdeposits/geometry/StratiformSeamBody.java +++ b/src/main/java/zone/moddev/mc/realisticdeposits/geometry/StratiformSeamBody.java @@ -10,7 +10,7 @@ * fault displacement and a discontinuous lower-grade halo without Minecraft * world access. */ -public final class StratiformSeamBody { +public final class StratiformSeamBody implements MineableDepositBody { private static final long STRUCTURE_SALT = 0x3C6EF372FE94F82BL; private static final int MAXIMUM_FAULT_VERTICAL_OFFSET = 12; private static final int EMPTY_MINIMUM_Y = 1; @@ -131,6 +131,17 @@ public final class StratiformSeamBody { public int downDipWidth() { return downDipWidth; } public BlockBounds bounds() { return bounds; } + /** Finds the seam's main mining route without sampling the individual ore blocks. */ + public MinePoint miningRoutePoint(double along, double lateralOffset) { + double bentAcross = meanderAt(along) + lateralOffset; + double across = bentAcross + segmentLateralOffset[segmentAt(along)] + bendAmplitude + * Math.sin((along / bendWavelength) * Math.PI * 2.0D + bendPhase); + + return new MinePoint((int) Math.floor(centerX + along * strikeCosine - across * strikeSine), + (int) Math.floor(planeYAt(along, bentAcross)), + (int) Math.floor(centerZ + along * strikeSine + across * strikeCosine)); + } + /** Lowest Y that can contain this body's folded horizon in this column. */ public int minimumCandidateYAt(int x, int z) { double along = alongAt(x, z); 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 b7d9677..7b38d33 100644 --- a/src/main/java/zone/moddev/mc/realisticdeposits/integration/OreSpawnIntegration.java +++ b/src/main/java/zone/moddev/mc/realisticdeposits/integration/OreSpawnIntegration.java @@ -13,6 +13,8 @@ public final class OreSpawnIntegration { public static final ResourceLocation PATTERN_ID = new ResourceLocation("realisticdeposits", "stratiform_seam"); + public static final ResourceLocation COAL_PATTERN_ID = + new ResourceLocation("realisticdeposits", "coal_measures"); private OreSpawnIntegration() { } @@ -36,17 +38,27 @@ private static void add(WorldgenProvider.Builder provider, DepositDefinition def patternSettings.add("definition", definition.toJson()); provider.ore(oreId, output, ore -> { ore.retrogen(false).suppressVanilla(false); + if (definition.isCoal()) ore.enabled(definition.enabled()); for (String dimensionId : definition.dimensions()) { ore.dimension(new ResourceLocation(dimensionId), dimension -> { // Keep the enclosing OreSpawn attempt broad: live geometry reload may // change the deposit's own height envelope without mutating a saved profile. dimension.yRange(0, 255) .attempts(1.0D).quantity(1).spread(0, 0).nodeSize(1) - .pattern(PATTERN_ID, patternSettings); - for (String host : definition.hosts()) dimension.hostBlock(new ResourceLocation(host)); + .pattern(definition.isCoal() ? COAL_PATTERN_ID : PATTERN_ID, patternSettings); + for (String host : definition.hosts()) { + if (definition.isCoal()) { + ResourceLocation id = new ResourceLocation(host); + if (net.minecraft.block.Block.REGISTRY.containsKey(id)) dimension.hostState(id, 0); + } else { + dimension.hostBlock(new ResourceLocation(host)); + } + } // Every geology family is a safe outer host. ROCK_ALIGNED applies the // current live family selection per output block inside the pattern. - for (GeologyFamily family : GeologyFamily.values()) dimension.hostFamily(family); + if (!definition.isCoal()) { + for (GeologyFamily family : GeologyFamily.values()) dimension.hostFamily(family); + } for (String biome : definition.biomeIds()) dimension.biome(new ResourceLocation(biome)); for (String biome : definition.excludedBiomeIds()) { dimension.excludeBiome(new ResourceLocation(biome)); diff --git a/src/main/java/zone/moddev/mc/realisticdeposits/model/AbandonedMineSettings.java b/src/main/java/zone/moddev/mc/realisticdeposits/model/AbandonedMineSettings.java new file mode 100644 index 0000000..ab35c70 --- /dev/null +++ b/src/main/java/zone/moddev/mc/realisticdeposits/model/AbandonedMineSettings.java @@ -0,0 +1,36 @@ +package zone.moddev.mc.realisticdeposits.model; + +import com.google.gson.JsonObject; + +/** Optional abandoned workings attached to a deposit, not another ore source. */ +public final class AbandonedMineSettings { + public static final double DEFAULT_CHANCE = 0.15D; + public static final AbandonedMineSettings DISABLED = new AbandonedMineSettings(false, DEFAULT_CHANCE); + + private final boolean enabled; + private final double chance; + + public AbandonedMineSettings(boolean enabled, double chance) { + if (!Double.isFinite(chance) || chance < 0.0D || chance > 1.0D) { + throw new IllegalArgumentException("Abandoned mine chance must be within [0,1]"); + } + + this.enabled = enabled; + this.chance = chance; + } + + public boolean enabled() { + return enabled; + } + + public double chance() { + return chance; + } + + public JsonObject toJson() { + JsonObject json = new JsonObject(); + json.addProperty("enabled", enabled); + json.addProperty("chance", chance); + return json; + } +} diff --git a/src/main/java/zone/moddev/mc/realisticdeposits/model/CoalMeasuresSettings.java b/src/main/java/zone/moddev/mc/realisticdeposits/model/CoalMeasuresSettings.java new file mode 100644 index 0000000..523b745 --- /dev/null +++ b/src/main/java/zone/moddev/mc/realisticdeposits/model/CoalMeasuresSettings.java @@ -0,0 +1,78 @@ +package zone.moddev.mc.realisticdeposits.model; + +import java.util.Objects; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; + +/** A small stack of coal beds with shared bedding and faults. */ +public final class CoalMeasuresSettings implements DepositShapeSettings { + private final IntRange length; + private final IntRange width; + private final IntRange beds; + private final IntRange thickness; + private final IntRange separation; + private final double dip; + private final double fold; + private final int faultThrow; + + public CoalMeasuresSettings(IntRange length, IntRange width, IntRange beds, + IntRange thickness, IntRange separation, double dip, double fold, int faultThrow) { + this.length = range(length, 32, 4096, "strike length"); + this.width = range(width, 16, 1024, "down-dip width"); + this.beds = range(beds, 1, 8, "bed count"); + this.thickness = range(thickness, 1, 8, "bed thickness"); + this.separation = range(separation, 2, 32, "rock interval"); + + if (!Double.isFinite(dip) || dip < 0 || dip > 15 + || !Double.isFinite(fold) || fold < 0 || fold > 12 + || faultThrow < 0 || faultThrow > 12) { + throw new IllegalArgumentException("Coal bedding or fault values are outside their supported range"); + } + + this.dip = dip; + this.fold = fold; + this.faultThrow = faultThrow; + } + + @Override public String type() { return "coal_measures"; } + public IntRange strikeLength() { return length; } + public IntRange downDipWidth() { return width; } + public IntRange beds() { return beds; } + public IntRange thickness() { return thickness; } + public IntRange separation() { return separation; } + public double maximumDip() { return dip; } + public double foldAmplitude() { return fold; } + public int maximumFaultThrow() { return faultThrow; } + + @Override + public JsonObject toJson() { + JsonObject json = new JsonObject(); + json.addProperty("type", type()); + json.add("strike_length", array(length)); + json.add("down_dip_width", array(width)); + json.add("beds", array(beds)); + json.add("thickness", array(thickness)); + json.add("rock_interval", array(separation)); + json.addProperty("maximum_dip_degrees", dip); + json.addProperty("fold_amplitude", fold); + json.addProperty("maximum_fault_throw", faultThrow); + return json; + } + + private static IntRange range(IntRange value, int min, int max, String name) { + Objects.requireNonNull(value, name); + if (value.minimum() < min || value.maximum() > max) { + throw new IllegalArgumentException("Invalid coal " + name); + } + return value; + } + + private static JsonArray array(IntRange value) { + JsonArray array = new JsonArray(); + array.add(new JsonPrimitive(value.minimum())); + array.add(new JsonPrimitive(value.maximum())); + return array; + } +} 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 af2b08b..a9ffc1e 100644 --- a/src/main/java/zone/moddev/mc/realisticdeposits/model/DepositDefinition.java +++ b/src/main/java/zone/moddev/mc/realisticdeposits/model/DepositDefinition.java @@ -41,7 +41,8 @@ public final class DepositDefinition { private final int regionSpacingChunks; private final double selectionChance; private final long salt; - private final StratiformSeamSettings seam; + private final DepositShapeSettings shape; + private final AbandonedMineSettings abandonedMine; public DepositDefinition(String id, boolean enabled, String resource, AlignmentMode alignment, InteractionPolicy interaction, @@ -63,6 +64,22 @@ public DepositDefinition(String id, boolean enabled, String resource, Double backgroundGenerationScale, int minimumY, int maximumY, int regionSpacingChunks, double selectionChance, long salt, StratiformSeamSettings seam) { + this(id, enabled, resource, alignment, interaction, dimensions, hosts, geomes, + rockFamilies, biomeIds, excludedBiomeIds, biomeDictionary, excludedBiomeDictionary, + backgroundGenerationScale, minimumY, maximumY, regionSpacingChunks, + selectionChance, salt, seam, AbandonedMineSettings.DISABLED); + } + + public DepositDefinition(String id, boolean enabled, String resource, + AlignmentMode alignment, InteractionPolicy interaction, + Collection dimensions, Collection hosts, + Collection geomes, Collection rockFamilies, + Collection biomeIds, Collection excludedBiomeIds, + Collection biomeDictionary, Collection excludedBiomeDictionary, + Double backgroundGenerationScale, + int minimumY, int maximumY, int regionSpacingChunks, + double selectionChance, long salt, DepositShapeSettings seam, + AbandonedMineSettings abandonedMine) { this.id = identifier(id, "deposit id"); this.enabled = enabled; this.resource = identifier(resource, "resource"); @@ -94,7 +111,8 @@ public DepositDefinition(String id, boolean enabled, String resource, this.regionSpacingChunks = regionSpacingChunks; this.selectionChance = selectionChance; this.salt = salt; - this.seam = Objects.requireNonNull(seam, "seam"); + this.shape = Objects.requireNonNull(seam, "shape"); + this.abandonedMine = Objects.requireNonNull(abandonedMine, "abandonedMine"); if (alignment == AlignmentMode.MASSIVE_RARE && (!this.geomes.isEmpty() || !this.rockFamilies.isEmpty())) { throw new IllegalArgumentException("Massive-and-rare deposit cannot declare geology alignment: " + id); @@ -147,7 +165,14 @@ public OptionalDouble effectiveBackgroundGenerationScale() { public int regionSpacingChunks() { return regionSpacingChunks; } public double selectionChance() { return selectionChance; } public long salt() { return salt; } - public StratiformSeamSettings seam() { return seam; } + public DepositShapeSettings shape() { return shape; } + public boolean isCoal() { return shape instanceof CoalMeasuresSettings; } + public StratiformSeamSettings seam() { return (StratiformSeamSettings) shape; } + public CoalMeasuresSettings coal() { return (CoalMeasuresSettings) shape; } + + public AbandonedMineSettings abandonedMine() { + return abandonedMine; + } /** Fields captured in an existing world's OreSpawn profile. */ public String structuralFingerprint() { @@ -156,7 +181,9 @@ public String structuralFingerprint() { + sorted(biomeDictionary) + "|" + sorted(excludedBiomeDictionary) + "|" + interaction.configName() + "|" + (effectiveBackgroundGenerationScale().isPresent() - ? effectiveBackgroundGenerationScale().getAsDouble() : "additive"); + ? effectiveBackgroundGenerationScale().getAsDouble() : "additive") + + (abandonedMine.enabled() ? "|abandoned_mine:" + abandonedMine.chance() : "") + + (isCoal() ? "|shape:coal_measures" : ""); } /** Deterministic schema-1 representation embedded in OreSpawn pattern settings. */ @@ -189,7 +216,12 @@ public JsonObject toJson() { json.addProperty("region_spacing_chunks", regionSpacingChunks); json.addProperty("selection_chance", selectionChance); json.addProperty("salt", salt); - json.add("shape", seam.toJson()); + json.add("shape", shape.toJson()); + + // Leave old, opted-out world profiles byte-for-byte compatible. + if (abandonedMine.enabled() || abandonedMine.chance() != AbandonedMineSettings.DEFAULT_CHANCE) { + json.add("abandoned_mine", abandonedMine.toJson()); + } return json; } diff --git a/src/main/java/zone/moddev/mc/realisticdeposits/model/DepositShapeSettings.java b/src/main/java/zone/moddev/mc/realisticdeposits/model/DepositShapeSettings.java new file mode 100644 index 0000000..f367aa0 --- /dev/null +++ b/src/main/java/zone/moddev/mc/realisticdeposits/model/DepositShapeSettings.java @@ -0,0 +1,9 @@ +package zone.moddev.mc.realisticdeposits.model; + +import com.google.gson.JsonObject; + +/** Settings shared by deposit catalogues, without tying every shape to iron. */ +public interface DepositShapeSettings { + String type(); + JsonObject toJson(); +} diff --git a/src/main/java/zone/moddev/mc/realisticdeposits/model/StratiformSeamSettings.java b/src/main/java/zone/moddev/mc/realisticdeposits/model/StratiformSeamSettings.java index b2e84ce..040f164 100644 --- a/src/main/java/zone/moddev/mc/realisticdeposits/model/StratiformSeamSettings.java +++ b/src/main/java/zone/moddev/mc/realisticdeposits/model/StratiformSeamSettings.java @@ -7,7 +7,7 @@ import com.google.gson.JsonPrimitive; /** Bounded shape and grade settings for a folded, laterally extensive seam. */ -public final class StratiformSeamSettings { +public final class StratiformSeamSettings implements DepositShapeSettings { private final IntRange strikeLength; private final IntRange downDipWidth; private final double minimumThickness; @@ -54,6 +54,7 @@ public StratiformSeamSettings(IntRange strikeLength, IntRange downDipWidth, } public IntRange strikeLength() { return strikeLength; } + @Override public String type() { return "stratiform_seam"; } public IntRange downDipWidth() { return downDipWidth; } public double minimumThickness() { return minimumThickness; } public double maximumThickness() { return maximumThickness; } diff --git a/src/main/resources/assets/realisticdeposits/defaults/coal.json b/src/main/resources/assets/realisticdeposits/defaults/coal.json new file mode 100644 index 0000000..01dd950 --- /dev/null +++ b/src/main/resources/assets/realisticdeposits/defaults/coal.json @@ -0,0 +1,34 @@ +{ + "schema_version": 1, + "catalog": "realisticdeposits:coal", + "deposits": [ + { + "id": "realisticdeposits:overworld_coal_measures", + "enabled": true, + "resource": "minecraft:coal_ore", + "alignment": "geome_aligned", + "interaction": "hybrid", + "background_generation_scale": 0.15, + "dimensions": ["minecraft:overworld"], + "hosts": ["minecraft:stone", "minecraft:sandstone", "mineralogy:shale", "mineralogy:siltstone", "mineralogy:conglomerate"], + "geomes": ["orespawn:sedimentary_basin", "orespawn:wetland_basin", "orespawn:stable_craton", "orespawn:coastal_shelf", "orespawn:mountain_belt"], + "min_y": 8, + "max_y": 128, + "region_spacing_chunks": 32, + "selection_chance": 0.5, + "salt": 2685821657736338717, + "abandoned_mine": {"enabled": false, "chance": 0.15}, + "shape": { + "type": "coal_measures", + "strike_length": [384, 768], + "down_dip_width": [128, 256], + "beds": [2, 4], + "thickness": [1, 3], + "rock_interval": [4, 12], + "maximum_dip_degrees": 4.0, + "fold_amplitude": 3.0, + "maximum_fault_throw": 4 + } + } + ] +} diff --git a/src/main/resources/assets/realisticdeposits/defaults/vanilla.json b/src/main/resources/assets/realisticdeposits/defaults/vanilla.json index 52ff135..74dfd9f 100644 --- a/src/main/resources/assets/realisticdeposits/defaults/vanilla.json +++ b/src/main/resources/assets/realisticdeposits/defaults/vanilla.json @@ -20,6 +20,10 @@ "region_spacing_chunks": 64, "selection_chance": 0.25, "salt": 7146057694168187204, + "abandoned_mine": { + "enabled": false, + "chance": 0.15 + }, "shape": { "type": "stratiform_seam", "strike_length": [640, 960], diff --git a/src/test/java/zone/moddev/mc/realisticdeposits/ProjectIdentityTest.java b/src/test/java/zone/moddev/mc/realisticdeposits/ProjectIdentityTest.java index eb855a6..64072b9 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.1.1.110021", RealisticDeposits.VERSION); + assertEquals("0.2.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.3.110021,5.0.0)", annotation.dependencies()); + assertEquals("required-after:orespawn@[4.1.4.110021,5.0.0)", annotation.dependencies()); } @Test @@ -31,11 +31,11 @@ void pinsThePublishedOreSpawnRelease() throws IOException { properties.load(input); } - assertEquals("4.1.3.110021", properties.getProperty("orespawn_version")); - assertEquals("[4.1.3.110021,5.0.0)", properties.getProperty("orespawn_version_range")); + assertEquals("4.1.4.110021", properties.getProperty("orespawn_version")); + assertEquals("[4.1.4.110021,5.0.0)", properties.getProperty("orespawn_version_range")); assertEquals("245586", properties.getProperty("orespawn_curse_project_id")); - assertEquals("9335434", properties.getProperty("orespawn_curse_file_id")); - assertEquals("FA1F4CAE66374E32162EC9543FF2BFBA236934C9E44D3F6AD882BC00761C15EF", + assertEquals("9356289", properties.getProperty("orespawn_curse_file_id")); + assertEquals("DB254E9E865685A74262DE9D469A006AB3C8053573CC6136FD4A8591AD806CA3", properties.getProperty("orespawn_sha256")); } } diff --git a/src/test/java/zone/moddev/mc/realisticdeposits/config/AbandonedMineCatalogTest.java b/src/test/java/zone/moddev/mc/realisticdeposits/config/AbandonedMineCatalogTest.java new file mode 100644 index 0000000..fc80ea8 --- /dev/null +++ b/src/test/java/zone/moddev/mc/realisticdeposits/config/AbandonedMineCatalogTest.java @@ -0,0 +1,87 @@ +package zone.moddev.mc.realisticdeposits.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.InputStreamReader; +import java.io.StringReader; +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Test; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import zone.moddev.mc.realisticdeposits.geometry.RegionDepositPlanner; +import zone.moddev.mc.realisticdeposits.model.DepositDefinition; + +class AbandonedMineCatalogTest { + + @Test + void anOptedInMineSurvivesTheOreSpawnProfileRoundTrip() { + JsonObject catalog = catalog(); + JsonObject mine = new JsonObject(); + mine.addProperty("enabled", true); + mine.addProperty("chance", 0.25D); + catalog.getAsJsonArray("deposits").get(0).getAsJsonObject().add("abandoned_mine", mine); + + DepositDefinition definition = load(catalog); + assertTrue(definition.toJson().has("abandoned_mine"), "The world profile must retain mine settings"); + assertEquals(mine, definition.toJson().getAsJsonObject("abandoned_mine")); + assertEquals(definition.toJson(), DepositCatalogLoader.loadDefinition( + definition.toJson(), "round trip").toJson()); + } + + @Test + void anOlderCatalogRemainsOptedOutAndKeepsItsProfileIdentity() { + JsonObject catalog = catalog(); + catalog.getAsJsonArray("deposits").get(0).getAsJsonObject().remove("abandoned_mine"); + + DepositDefinition definition = load(catalog); + assertFalse(definition.toJson().has("abandoned_mine")); + assertFalse(definition.structuralFingerprint().contains("abandoned_mine")); + } + + @Test + void enablingMinesDoesNotMoveOrReseedTheOreBody() { + JsonObject catalog = catalog(); + DepositDefinition original = load(catalog); + JsonObject mine = new JsonObject(); + mine.addProperty("enabled", true); + mine.addProperty("chance", 1.0D); + catalog.getAsJsonArray("deposits").get(0).getAsJsonObject().add("abandoned_mine", mine); + DepositDefinition changed = load(catalog); + + assertEquals(RegionDepositPlanner.plan(original, 3346071746553592696L, + "minecraft:overworld", -1, -1).get().identity().seed(), + RegionDepositPlanner.plan(changed, 3346071746553592696L, + "minecraft:overworld", -1, -1).get().identity().seed()); + } + + @Test + void invalidChancesAreRejectedRatherThanQuietlyEnablingEveryMine() { + for (double chance : new double[] {-0.01D, 1.01D}) { + JsonObject catalog = catalog(); + JsonObject mine = new JsonObject(); + mine.addProperty("enabled", true); + mine.addProperty("chance", chance); + catalog.getAsJsonArray("deposits").get(0).getAsJsonObject().add("abandoned_mine", mine); + + assertThrows(IllegalArgumentException.class, () -> load(catalog)); + } + } + + private static DepositDefinition load(JsonObject catalog) { + return DepositCatalogLoader.load(new StringReader(catalog.toString()), "test catalog") + .definitions().get(0); + } + + private static JsonObject catalog() { + return new JsonParser().parse(new InputStreamReader( + AbandonedMineCatalogTest.class.getResourceAsStream( + "/assets/realisticdeposits/defaults/vanilla.json"), StandardCharsets.UTF_8)) + .getAsJsonObject(); + } +} diff --git a/src/test/java/zone/moddev/mc/realisticdeposits/config/CoalCatalogTest.java b/src/test/java/zone/moddev/mc/realisticdeposits/config/CoalCatalogTest.java new file mode 100644 index 0000000..f61f6db --- /dev/null +++ b/src/test/java/zone/moddev/mc/realisticdeposits/config/CoalCatalogTest.java @@ -0,0 +1,58 @@ +package zone.moddev.mc.realisticdeposits.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Test; +import net.minecraft.init.Bootstrap; +import zone.moddev.mc.realisticdeposits.integration.OreSpawnIntegration; + +class CoalCatalogTest { + + @Test + void shipsCoalSeparatelyWithoutChangingTheIronCatalogue() { + InputStream source = getClass().getResourceAsStream("/assets/realisticdeposits/defaults/coal.json"); + assertNotNull(source, "A separate coal catalogue is required"); + + DepositCatalog catalogue = DepositCatalogLoader.load( + new InputStreamReader(source, StandardCharsets.UTF_8), "coal.json"); + assertEquals(1, catalogue.definitions().size()); + assertEquals("minecraft:coal_ore", catalogue.definitions().get(0).resource()); + assertEquals(0.15D, catalogue.definitions().get(0).effectiveBackgroundGenerationScale().getAsDouble()); + assertTrue(catalogue.definitions().get(0).isCoal()); + assertEquals(32, catalogue.definitions().get(0).regionSpacingChunks()); + assertFalse(catalogue.definitions().get(0).abandonedMine().enabled()); + } + + @Test + void coalDoesNotOfferBroadFamiliesOrWholeStoneBlocksAsAlternativeHosts() { + Bootstrap.register(); + com.google.gson.JsonObject provider = OreSpawnIntegration.provider(java.util.Collections.singleton( + zone.moddev.mc.realisticdeposits.geometry.CoalMeasuresBodyTest.definition())).toJson(); + com.google.gson.JsonObject rule = provider.getAsJsonObject("ores").entrySet().iterator().next().getValue() + .getAsJsonObject().getAsJsonObject("dimensions").getAsJsonObject("minecraft:overworld"); + assertEquals(0, rule.getAsJsonArray("host_families").size()); + rule.getAsJsonArray("host_blocks").forEach(host -> assertTrue(host.getAsJsonObject().has("metadata"))); + } + + @Test + void rejectedHostWeightsCannotLeaveAValidEmptyRule() { + for (double weight : new double[] {-0.1D, 1.1D, Double.NaN, Double.POSITIVE_INFINITY}) { + zone.moddev.mc.orespawn.api.WorldgenProvider.OreDimensionDefinition.Builder builder = + zone.moddev.mc.orespawn.api.WorldgenProvider.OreDimensionDefinition.builder( + new net.minecraft.util.ResourceLocation("minecraft:overworld")); + + assertThrows(IllegalArgumentException.class, () -> builder.hostState( + new net.minecraft.util.ResourceLocation("minecraft:stone"), 0, weight)); + assertThrows(IllegalStateException.class, builder::build, + "A rejected host must not leave a usable empty rule"); + } + } +} 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 6e4ddd0..60084b0 100644 --- a/src/test/java/zone/moddev/mc/realisticdeposits/config/RealisticDepositsRuntimeTest.java +++ b/src/test/java/zone/moddev/mc/realisticdeposits/config/RealisticDepositsRuntimeTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import java.io.IOException; import java.nio.charset.StandardCharsets; @@ -56,6 +57,42 @@ void rejectsConflictingPoliciesForOneResourceAndDimension() throws IOException { () -> RealisticDepositsRuntime.initializeForTests(config.toFile())); } + @Test + void anUnusedDisabledRuleDoesNotConflictWithTheActivePolicy() throws IOException { + Path config = temporary.resolve("disabled-policy"); + Path catalog = config.resolve("realisticdeposits/deposits/vanilla.json"); + Files.createDirectories(catalog.getParent()); + String active = definition("realisticdeposits:active", "minecraft:iron_ore", "replacement", 1); + String disabled = definition("realisticdeposits:disabled", "minecraft:iron_ore", "hybrid", 1) + .replace("\"enabled\":true", "\"enabled\":false"); + write(catalog, "{\"schema_version\":1,\"catalog\":\"realisticdeposits:test\",\"deposits\":[" + + active + "," + disabled + "]}"); + RealisticDepositsRuntime.initializeForTests(config.toFile()); + assertEquals(RuntimeState.IntegrationStatus.ACTIVE, RealisticDepositsRuntime.state().integrationStatus()); + } + + @Test + void installingCoalLeavesBothEditedCataloguesByteForByteAlone() throws IOException { + Path config = temporary.resolve("edited-catalogues"); + Path deposits = config.resolve("realisticdeposits/deposits"); + Files.createDirectories(deposits); + byte[] iron = document("minecraft:iron_ore", "additive", 0.37D).getBytes(StandardCharsets.UTF_8); + byte[] coal; + try (java.io.InputStream source = getClass().getResourceAsStream( + "/assets/realisticdeposits/defaults/coal.json")) { + coal = new String(com.google.common.io.ByteStreams.toByteArray(source), StandardCharsets.UTF_8) + .replace("0.15", "0.37").getBytes(StandardCharsets.UTF_8); + } + Files.write(deposits.resolve("vanilla.json"), iron); + Files.write(deposits.resolve("coal.json"), coal); + + RealisticDepositsRuntime.initializeForTests(config.toFile()); + RealisticDepositsRuntime.reloadForTests(); + + assertArrayEquals(iron, Files.readAllBytes(deposits.resolve("vanilla.json"))); + assertArrayEquals(coal, Files.readAllBytes(deposits.resolve("coal.json"))); + } + private static void write(Path path, String value) throws IOException { Files.write(path, value.getBytes(StandardCharsets.UTF_8)); } diff --git a/src/test/java/zone/moddev/mc/realisticdeposits/generation/CoalGeologyTest.java b/src/test/java/zone/moddev/mc/realisticdeposits/generation/CoalGeologyTest.java new file mode 100644 index 0000000..dfbd408 --- /dev/null +++ b/src/test/java/zone/moddev/mc/realisticdeposits/generation/CoalGeologyTest.java @@ -0,0 +1,109 @@ +package zone.moddev.mc.realisticdeposits.generation; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.Optional; +import java.util.Set; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import net.minecraft.block.state.IBlockState; +import net.minecraft.init.Blocks; +import net.minecraft.init.Bootstrap; +import net.minecraft.util.ResourceLocation; + +import zone.moddev.mc.orespawn.api.GeologyColumn; +import zone.moddev.mc.orespawn.api.GeologyFamily; +import zone.moddev.mc.orespawn.api.GeologySampler; +import zone.moddev.mc.realisticdeposits.geometry.CoalMeasuresBody; +import zone.moddev.mc.realisticdeposits.geometry.CoalMeasuresBodyTest; + +class CoalGeologyTest { + @BeforeAll + static void vanilla() { Bootstrap.register(); } + + @Test + void plainStoneDoesNotIncludeAnyIntrusiveVariants() { + Set hosts = CoalGeology.hosts(CoalMeasuresBodyTest.definition()); + assertTrue(hosts.contains(Blocks.STONE.getStateFromMeta(0))); + for (int metadata = 1; metadata <= 6; metadata++) { + assertFalse(hosts.contains(Blocks.STONE.getStateFromMeta(metadata))); + } + assertFalse(hosts.contains(Blocks.OBSIDIAN.getDefaultState())); + assertFalse(hosts.contains(Blocks.AIR.getDefaultState())); + } + + @Test + void legacyCoalIsDiagnosedWithoutChangingItsSettings() { + JsonObject profile = new JsonParser().parse("{\"cyano\":{\"realistic_coal_layers\":true," + + "\"sedimentary_rocks\":[\"minecraft:coal_ore\"]}}").getAsJsonObject(); + String before = profile.toString(); + assertTrue(CoalGeology.Snapshot.fromJson(profile).legacyCoal); + assertFalse(CoalGeology.Snapshot.fromJson(profile).physicalStrata); + assertEquals(before, profile.toString()); + } + + @Test + void disabledPhysicalGeologyUsesTheOriginalStoneApproximation() { + CoalMeasuresBody body = CoalMeasuresBodyTest.body(); + assertSame(body, CoalGeology.align(body, CoalMeasuresBodyTest.definition(), sampler(false), + CoalGeology.hosts(CoalMeasuresBodyTest.definition()), new CoalGeology.Snapshot(false, false)).get()); + } + + @Test + void actualBeddingFollowsSedimentaryRocksAndRejectsAnIntrusion() { + CoalMeasuresBody body = CoalMeasuresBodyTest.body(); + Set hosts = CoalGeology.hosts(CoalMeasuresBodyTest.definition()); + assertFalse(CoalGeology.align(body, CoalMeasuresBodyTest.definition(), sampler(false), + hosts, new CoalGeology.Snapshot(true, false)).isPresent()); + Optional fitted = CoalGeology.align(body, CoalMeasuresBodyTest.definition(), + sampler(true), hosts, new CoalGeology.Snapshot(true, false)); + assertTrue(fitted.isPresent()); + assertTrue(Double.isFinite(fitted.get().bedY(0, body.centerX(), body.centerZ()))); + } + + private static GeologySampler sampler(boolean sedimentary) { + return (x, z, surface) -> new GeologyColumn() { + @Override public ResourceLocation dimension() { return new ResourceLocation("minecraft:overworld"); } + @Override public ResourceLocation biome() { return new ResourceLocation("minecraft:plains"); } + @Override public ResourceLocation geome() { return new ResourceLocation("orespawn:sedimentary_basin"); } + @Override public int blockX() { return x; } + @Override public int blockZ() { return z; } + @Override public int surfaceY() { return surface; } + @Override public IBlockState rockAt(int y) { return Blocks.SANDSTONE.getDefaultState(); } + @Override public Optional familyAt(int y) { + return Optional.of(sedimentary ? GeologyFamily.SEDIMENTARY : GeologyFamily.IGNEOUS_INTRUSIVE); + } + }; + } + + @Test + void coalKeepsVanillaDropsAndFortuneWithoutNewProcessingRules() { + assertEquals(net.minecraft.init.Items.COAL, Blocks.COAL_ORE.getItemDropped( + Blocks.COAL_ORE.getDefaultState(), new java.util.Random(7), 0)); + int ordinary = 0; + int fortune = 0; + java.util.Random random = new java.util.Random(17); + for (int sample = 0; sample < 1000; sample++) { + ordinary += Blocks.COAL_ORE.quantityDroppedWithBonus(0, random); + int bonus = Blocks.COAL_ORE.quantityDroppedWithBonus(3, random); + assertTrue(bonus >= 1 && bonus <= 4); + fortune += bonus; + } + assertEquals(1000, ordinary); + assertTrue(fortune > ordinary); + } + + @Test + void knownStructureBoundsIncludeTheirEdgesButNotNearbyNaturalRock() { + java.util.List boxes = java.util.Collections.singletonList(new int[] {-16, 20, -16, 0, 40, 0}); + assertTrue(CoalStructureProtection.contains(boxes, -16, 20, -16)); + assertTrue(CoalStructureProtection.contains(boxes, 0, 40, 0)); + assertFalse(CoalStructureProtection.contains(boxes, 1, 30, 0)); + assertFalse(CoalStructureProtection.contains(boxes, -5, 41, -5)); + } +} diff --git a/src/test/java/zone/moddev/mc/realisticdeposits/generation/CoalRuntimeProbe.java b/src/test/java/zone/moddev/mc/realisticdeposits/generation/CoalRuntimeProbe.java new file mode 100644 index 0000000..73087b1 --- /dev/null +++ b/src/test/java/zone/moddev/mc/realisticdeposits/generation/CoalRuntimeProbe.java @@ -0,0 +1,333 @@ +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.init.Blocks; +import net.minecraft.server.MinecraftServer; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.WorldServer; +import net.minecraft.world.chunk.Chunk; +import net.minecraft.world.gen.ChunkProviderServer; +import net.minecraftforge.fml.common.FMLCommonHandler; +import net.minecraftforge.fml.common.Mod; +import 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.CoalMeasuresBody; +import zone.moddev.mc.realisticdeposits.geometry.AbandonedMinePlan; +import zone.moddev.mc.realisticdeposits.geometry.AbandonedMinePlanner; +import zone.moddev.mc.realisticdeposits.model.DepositDefinition; + +/** Packaged coal fixtures only; the ordinary mod never includes this class. */ +@Mod(modid = "rdcoalprobe", name = "Coalfield Qualification", version = "1", + dependencies = "required-after:realisticdeposits") +public final class CoalRuntimeProbe { + private static final int SIDE = 8; + + @Mod.EventHandler + public void started(FMLServerStartedEvent event) throws Exception { + if (!Boolean.getBoolean("rd.coal.enabled")) return; + MinecraftServer server = FMLCommonHandler.instance().getMinecraftServerInstance(); + + try { + WorldServer world = server.worldServerForDimension(0); + if (!"coal-test-world".equals(world.getSaveHandler().getWorldDirectory().getName())) { + throw new IllegalStateException("The coal probe only runs in its disposable world"); + } + world.getGameRules().setOrCreateGameRule("randomTickSpeed", "0"); + DepositDefinition definition = RealisticDepositsRuntime.state().definitions().stream() + .filter(DepositDefinition::isCoal).findFirst().get(); + String phase = System.getProperty("rd.coal.phase", "first"); + GeologySampler sampler = OreSpawnApi.createSampler(world).get(); + CoalGeology.Snapshot geology = CoalGeology.active(); + if (Boolean.getBoolean("rd.coal.survey")) { + survey(world, definition, sampler, geology); + System.out.println("RD_COAL_PASS survey"); + return; + } + File target = new File("coal-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, sampler, geology, 32, "first", coordinates); + find(world, definition, sampler, geology, 64, "later", coordinates); + write(target, coordinates); + } + + CoalMeasuresBody first = resolved(world, definition, sampler, geology, coordinates, "first"); + CoalMeasuresBody later = resolved(world, definition, sampler, geology, coordinates, "later"); + if (("first".equals(phase) || "later".equals(phase)) && Boolean.getBoolean("rd.coal.structure")) { + CoalMeasuresBody protectedBody = "first".equals(phase) ? first : later; + net.minecraft.world.gen.structure.MapGenStructureData structure = + (net.minecraft.world.gen.structure.MapGenStructureData) world.getMapStorage().getOrLoadData( + net.minecraft.world.gen.structure.MapGenStructureData.class, "Mineshaft"); + if (structure == null) structure = new net.minecraft.world.gen.structure.MapGenStructureData("Mineshaft"); + net.minecraft.nbt.NBTTagCompound existing = new net.minecraft.nbt.NBTTagCompound(); + int x = (protectedBody.centerX() >> 4) << 4; + int z = (protectedBody.centerZ() >> 4) << 4; + existing.setIntArray("BB", new int[] {x, 0, z, x + 15, 255, z + 15}); + structure.writeInstance(existing, protectedBody.centerX() >> 4, protectedBody.centerZ() >> 4); + world.getMapStorage().setData("Mineshaft", structure); + } + if ("first".equals(phase)) populate(world, first); + if ("later".equals(phase)) populate(world, later); + + JsonObject report = new JsonObject(); + report.addProperty("phase", phase); + report.addProperty("physical_strata", geology.physicalStrata); + report.addProperty("legacy_coal_layers", geology.legacyCoal); + report.add("profile", OreSpawnApi.getActiveProfile(server).get().toJson()); + report.add("first", sample(world, first)); + if (Boolean.getBoolean("rd.coal.workings")) { + AbandonedMinePlan mine = MineWorldData.get(world).plan(first.identity().toString()); + if (mine == null) throw new IllegalStateException("The selected coal workings were not built"); + report.addProperty("mine_entrance", mine.entrance().toString()); + report.addProperty("mine_landing", mine.landing().toString()); + } + if ("later".equals(phase) || "stable".equals(phase)) report.add("later", sample(world, later)); + write(new File("coal-" + phase + ".json"), report); + System.out.println("RD_COAL_PASS " + phase); + } finally { + server.initiateShutdown(); + } + } + + private static void find(WorldServer world, DepositDefinition definition, GeologySampler sampler, + CoalGeology.Snapshot geology, int start, String name, JsonObject coordinates) { + boolean workings = Boolean.getBoolean("rd.coal.workings"); + int range = workings ? 96 : 24; + int[] rejected = new int[6]; + for (int rx = start; rx < start + range; rx++) { + for (int rz = start; rz < start + range; rz++) { + Optional planned = CoalMeasuresBody.plan(definition, + world.getSeed(), "minecraft:overworld", rx, rz); + if (!planned.isPresent()) continue; + CoalMeasuresBody body = planned.get(); + if (!CoalGeology.acceptsGeome(definition, body, sampler)) continue; + Optional fitted = CoalGeology.align(body, definition, sampler, + CoalGeology.hosts(definition), geology); + if (!fitted.isPresent()) continue; + rejected[0]++; + if (workings) { + MineTerrainSampler terrain = new MineTerrainSampler(world, world.getChunkProvider().chunkGenerator); + Optional mine = AbandonedMinePlanner.plan(fitted.get(), definition.abandonedMine(), + terrain.surfaceY(AbandonedMinePlanner.landing(fitted.get()))); + if (!mine.isPresent()) { rejected[1]++; continue; } + if (!terrain.dry(mine.get())) { rejected[2]++; continue; } + if (!terrain.covered(mine.get())) { rejected[3]++; continue; } + if (!MineWorkingsGenerator.biomesAllowed(mine.get(), definition, world)) { rejected[4]++; continue; } + } + + coordinates.addProperty(name + "_rx", rx); + coordinates.addProperty(name + "_rz", rz); + return; + } + } + throw new IllegalStateException("No suitable coalfield in the bounded fixture search: " + + java.util.Arrays.toString(rejected)); + } + + private static CoalMeasuresBody resolved(WorldServer world, DepositDefinition definition, + GeologySampler sampler, CoalGeology.Snapshot geology, JsonObject target, String name) { + CoalMeasuresBody body = CoalMeasuresBody.plan(definition, world.getSeed(), "minecraft:overworld", + target.get(name + "_rx").getAsInt(), target.get(name + "_rz").getAsInt()).get(); + return CoalGeology.align(body, definition, sampler, CoalGeology.hosts(definition), geology).get(); + } + + private static void populate(WorldServer world, CoalMeasuresBody body) { + ChunkProviderServer provider = world.getChunkProvider(); + int baseX = (body.centerX() >> 4) - SIDE / 2; + int baseZ = (body.centerZ() >> 4) - SIDE / 2; + for (int x = baseX - 2; x <= baseX + SIDE + 1; x++) { + for (int z = baseZ - 2; z <= baseZ + SIDE + 1; z++) provider.provideChunk(x, z); + } + for (int x = baseX - 1; x <= baseX + SIDE; x++) { + for (int z = baseZ - 1; z <= baseZ + SIDE; z++) { + Chunk chunk = provider.provideChunk(x, z); + chunk.populateChunk(provider, provider.chunkGenerator); + } + } + } + + private static JsonObject sample(WorldServer world, CoalMeasuresBody body) throws Exception { + MessageDigest terrain = MessageDigest.getInstance("SHA-256"); + MessageDigest coal = MessageDigest.getInstance("SHA-256"); + int baseX = (body.centerX() >> 4) - SIDE / 2; + int baseZ = (body.centerZ() >> 4) - SIDE / 2; + int inBody = 0; + int outside = 0; + int planned = 0; + int protectedCoal = 0; + JsonArray section = new JsonArray(); + BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos(); + + for (int cx = baseX; cx < baseX + SIDE; cx++) { + for (int cz = baseZ; cz < baseZ + SIDE; cz++) { + Chunk chunk = world.getChunkProvider().provideChunk(cx, cz); + if (!chunk.isTerrainPopulated()) throw new IllegalStateException("Fixture sampled unopened terrain"); + 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 < 256; y++) { + net.minecraft.block.state.IBlockState state = chunk.getBlockState(cursor.setPos(x, y, z)); + update(terrain, Block.getStateId(state)); + boolean expected = body.isOreAt(x, y, z); + if (expected) planned++; + if (state.getBlock() == Blocks.COAL_ORE) { + if ((x >> 4) == (body.centerX() >> 4) && (z >> 4) == (body.centerZ() >> 4)) protectedCoal++; + if (expected) inBody++; else outside++; + update(coal, x); + update(coal, y); + update(coal, z); + } + if (z == body.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); + } + } + } + } + } + } + if (inBody < 50) throw new IllegalStateException("The selected fixture has no substantial coal bed: " + inBody); + if (Boolean.getBoolean("rd.coal.structure") && protectedCoal != 0) { + throw new IllegalStateException("Coal entered the protected native structure: " + protectedCoal); + } + JsonObject result = new JsonObject(); + result.addProperty("centre_x", body.centerX()); + result.addProperty("centre_y", body.centerY()); + result.addProperty("centre_z", body.centerZ()); + result.addProperty("beds", body.bedCount()); + result.addProperty("coal_in_body", inBody); + result.addProperty("coal_outside_body", outside); + result.addProperty("planned_positions", planned); + result.addProperty("chunks", SIDE * SIDE); + result.addProperty("terrain_sha256", hex(terrain.digest())); + result.addProperty("coal_sha256", hex(coal.digest())); + result.add("section", section); + return result; + } + + private static void update(MessageDigest digest, int value) { + digest.update((byte) (value >>> 24)); + digest.update((byte) (value >>> 16)); + digest.update((byte) (value >>> 8)); + digest.update((byte) value); + } + + private static void survey(WorldServer world, DepositDefinition definition, + GeologySampler sampler, CoalGeology.Snapshot geology) throws Exception { + JsonObject enabled = definition.toJson(); + enabled.addProperty("enabled", true); + DepositDefinition geometry = zone.moddev.mc.realisticdeposits.config.DepositCatalogLoader + .loadDefinition(enabled, "coal reserve survey"); + int candidates = 0; + int accepted = 0; + long reserves = 0; + JsonArray fields = new JsonArray(); + + // An evenly spaced survey, not a search centred on a known rich deposit. + for (int rx = 32; rx < 48; rx++) { + for (int rz = 32; rz < 48; rz++) { + Optional body = CoalMeasuresBody.plan(geometry, world.getSeed(), + "minecraft:overworld", rx, rz); + if (!body.isPresent()) continue; + candidates++; + if (!CoalGeology.acceptsGeome(geometry, body.get(), sampler)) continue; + body = CoalGeology.align(body.get(), geometry, sampler, CoalGeology.hosts(geometry), geology); + if (!body.isPresent()) continue; + accepted++; + + CoalMeasuresBody field = body.get(); + long sampled = 0; + for (int x = field.bounds().minimumX(); x <= field.bounds().maximumX(); x += 8) { + for (int z = field.bounds().minimumZ(); z <= field.bounds().maximumZ(); z += 8) { + for (int y = field.minimumCandidateYAt(x, z); y <= field.maximumCandidateYAt(x, z); y++) { + if (field.isOreAt(x, y, z)) sampled++; + } + } + } + long estimate = sampled * 64; + reserves += estimate; + fields.add(new com.google.gson.JsonPrimitive(estimate)); + } + } + + long coal = 0; + int discoveries = 0; + int chunks = 0; + BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos(); + for (int rx = 32; rx < 41; rx++) { + for (int rz = 32; rz < 41; rz++) { + java.util.Random location = new java.util.Random(world.getSeed() ^ ((long) rx << 32) ^ rz); + int cx = rx * 32 + location.nextInt(32); + int cz = rz * 32 + location.nextInt(32); + ChunkProviderServer provider = world.getChunkProvider(); + for (int dx = -1; dx <= 2; dx++) { + for (int dz = -1; dz <= 2; dz++) provider.provideChunk(cx + dx, cz + dz); + } + provider.provideChunk(cx, cz).populateChunk(provider, provider.chunkGenerator); + Chunk chunk = provider.provideChunk(cx, cz); + int count = 0; + 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 < 256; y++) { + if (chunk.getBlockState(cursor.setPos(x, y, z)).getBlock() == Blocks.COAL_ORE) count++; + } + } + } + coal += count; + if (count >= 400) discoveries++; + chunks++; + } + } + + int fortune = 0; + java.util.Random random = new java.util.Random(17); + for (int trial = 0; trial < 10000; trial++) fortune += Blocks.COAL_ORE.quantityDroppedWithBonus(3, random); + JsonObject report = new JsonObject(); + report.addProperty("coalfields_enabled", definition.enabled()); + report.addProperty("regions", 256); + report.addProperty("selected_before_geology", candidates); + report.addProperty("accepted_fields", accepted); + report.add("estimated_field_reserves", fields); + report.addProperty("estimated_field_coal_per_chunk_before_host_and_terrain_rejection", reserves / (256.0 * 1024)); + report.addProperty("sample_chunks", chunks); + report.addProperty("actual_sample_coal", coal); + report.addProperty("actual_coal_per_sample_chunk", coal / (double) chunks); + report.addProperty("sample_chunks_with_at_least_400_coal", discoveries); + report.addProperty("fortune_iii_mean_per_ore", fortune / 10000.0); + write(new File("coal-survey.json"), report); + } + + 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 path, JsonObject json) throws Exception { + try (FileWriter writer = new FileWriter(path)) { + new GsonBuilder().setPrettyPrinting().create().toJson(json, writer); + } + } +} diff --git a/src/test/java/zone/moddev/mc/realisticdeposits/generation/CoalUpgradeProbe.java b/src/test/java/zone/moddev/mc/realisticdeposits/generation/CoalUpgradeProbe.java new file mode 100644 index 0000000..6d7c28f --- /dev/null +++ b/src/test/java/zone/moddev/mc/realisticdeposits/generation/CoalUpgradeProbe.java @@ -0,0 +1,110 @@ +package zone.moddev.mc.realisticdeposits.generation; + +import java.io.File; +import java.io.FileWriter; +import java.security.MessageDigest; + +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; + +import net.minecraft.block.Block; +import net.minecraft.init.Blocks; +import net.minecraft.init.Items; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.server.MinecraftServer; +import net.minecraft.tileentity.TileEntityChest; +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; + +/** Uses only old public APIs so the same fixture runs before and after the update. */ +@Mod(modid = "rdcoalupgradeprobe", name = "Coal Upgrade Fixture", version = "1", + dependencies = "required-after:realisticdeposits") +public final class CoalUpgradeProbe { + private static final BlockPos CHEST = new BlockPos(1032, 132, 1032); + private static final BlockPos RAW_MARKER = new BlockPos(2568, 48, 2568); + + @Mod.EventHandler + public void started(FMLServerStartedEvent event) throws Exception { + if (!Boolean.getBoolean("rd.coal.upgrade")) return; + MinecraftServer server = FMLCommonHandler.instance().getMinecraftServerInstance(); + try { + WorldServer world = server.worldServerForDimension(0); + if (!"coal-upgrade-world".equals(world.getSaveHandler().getWorldDirectory().getName())) { + throw new IllegalStateException("Wrong disposable upgrade world"); + } + world.getGameRules().setOrCreateGameRule("randomTickSpeed", "0"); + String phase = System.getProperty("rd.coal.phase"); + if ("first".equals(phase)) { + populate(world, 64); + world.setBlockState(CHEST, Blocks.CHEST.getDefaultState(), 2); + TileEntityChest chest = (TileEntityChest) world.getTileEntity(CHEST); + ItemStack stack = new ItemStack(Items.DIAMOND, 7); + stack.setStackDisplayName("Old coal fixture diamonds"); + chest.setInventorySlotContents(0, stack); + chest.markDirty(); + Chunk raw = world.getChunkProvider().provideChunk(160, 160); + if (raw.isTerrainPopulated()) throw new IllegalStateException("The later-chunk fixture opened early"); + raw.setBlockState(RAW_MARKER, Blocks.LAPIS_ORE.getDefaultState()); + raw.setChunkModified(); + } else if ("complete".equals(phase)) { + populate(world, 160); + } + + JsonObject report = new JsonObject(); + report.addProperty("old_terrain", terrain(world, 64)); + TileEntityChest chest = (TileEntityChest) world.getTileEntity(CHEST); + report.addProperty("chest", chest.writeToNBT(new NBTTagCompound()).toString()); + report.addProperty("raw_marker", world.getBlockState(RAW_MARKER).getBlock() == Blocks.LAPIS_ORE); + if (!"first".equals(phase)) report.addProperty("later_terrain", terrain(world, 160)); + try (FileWriter writer = new FileWriter(new File("coal-upgrade-" + phase + ".json"))) { + new GsonBuilder().setPrettyPrinting().create().toJson(report, writer); + } + System.out.println("RD_COAL_UPGRADE_PASS " + phase); + } finally { + server.initiateShutdown(); + } + } + + private static void populate(WorldServer world, int base) { + ChunkProviderServer provider = world.getChunkProvider(); + for (int x = base - 2; x <= base + 5; x++) { + for (int z = base - 2; z <= base + 5; z++) provider.provideChunk(x, z); + } + for (int x = base - 1; x <= base + 3; x++) { + for (int z = base - 1; z <= base + 3; z++) { + Chunk chunk = provider.provideChunk(x, z); + chunk.populateChunk(provider, provider.chunkGenerator); + } + } + } + + private static String terrain(WorldServer world, int base) throws Exception { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + for (int cx = base; cx < base + 3; cx++) { + for (int cz = base; cz < base + 3; cz++) { + Chunk chunk = world.getChunkProvider().provideChunk(cx, cz); + if (!chunk.isTerrainPopulated()) throw new IllegalStateException("Unexpected unopened sample"); + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + for (int y = 0; y < 256; y++) { + int state = Block.getStateId(chunk.getBlockState(x, y, z)); + digest.update((byte) (state >>> 24)); + digest.update((byte) (state >>> 16)); + digest.update((byte) (state >>> 8)); + digest.update((byte) state); + } + } + } + } + } + StringBuilder text = new StringBuilder(); + for (byte value : digest.digest()) text.append(String.format("%02x", value & 255)); + return text.toString(); + } +} diff --git a/src/test/java/zone/moddev/mc/realisticdeposits/generation/ExistingMineTerrainTest.java b/src/test/java/zone/moddev/mc/realisticdeposits/generation/ExistingMineTerrainTest.java new file mode 100644 index 0000000..7646dcd --- /dev/null +++ b/src/test/java/zone/moddev/mc/realisticdeposits/generation/ExistingMineTerrainTest.java @@ -0,0 +1,52 @@ +package zone.moddev.mc.realisticdeposits.generation; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import zone.moddev.mc.realisticdeposits.geometry.BlockBounds; + +class ExistingMineTerrainTest { + @TempDir + Path directory; + + @Test + void everySavedChunkIsProtectedIncludingUnpopulatedChunksAndNegativeRegions() throws Exception { + File file = directory.resolve("r.-1.2.mca").toFile(); + try (RandomAccessFile region = new RandomAccessFile(file, "rw")) { + region.setLength(8192); + region.seek((3 + 31 * 32) * 4); + region.writeInt(0x00000201); + } + + ExistingMineTerrain existing = ExistingMineTerrain.read(directory.toFile()); + assertTrue(existing.contains(-29, 95)); + assertFalse(existing.contains(-28, 95)); + assertTrue(existing.intersects(new BlockBounds(-464, 5, 1520, -449, 100, 1535))); + assertFalse(existing.intersects(new BlockBounds(0, 5, 0, 15, 100, 15))); + assertTrue(file.length() == 8192L, "Inspection must not change the region"); + } + + @Test + void incompleteHeadersFailClosed() throws Exception { + try (RandomAccessFile region = new RandomAccessFile(directory.resolve("r.0.0.mca").toFile(), "rw")) { + region.writeInt(1); + } + + assertThrows(IOException.class, () -> ExistingMineTerrain.read(directory.toFile())); + } + + @Test + void anUnusedWorldHasNoOldTerrain() throws Exception { + assertFalse(ExistingMineTerrain.read(directory.resolve("region").toFile()) + .intersects(new BlockBounds(-20, 5, -20, 20, 100, 20))); + } +} diff --git a/src/test/java/zone/moddev/mc/realisticdeposits/generation/MineLegacyTerrainProbe.java b/src/test/java/zone/moddev/mc/realisticdeposits/generation/MineLegacyTerrainProbe.java new file mode 100644 index 0000000..476f0dc --- /dev/null +++ b/src/test/java/zone/moddev/mc/realisticdeposits/generation/MineLegacyTerrainProbe.java @@ -0,0 +1,105 @@ +package zone.moddev.mc.realisticdeposits.generation; + +import java.io.FileOutputStream; +import java.nio.ByteBuffer; +import java.security.MessageDigest; +import java.util.Properties; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import net.minecraft.block.Block; +import net.minecraft.init.Blocks; +import net.minecraft.init.Items; +import net.minecraft.item.ItemStack; +import net.minecraft.server.MinecraftServer; +import net.minecraft.tileentity.TileEntityChest; +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; + +/** Creates the old-release world fixture without referring to the new mine classes. */ +@Mod(modid = "rdminelegacyprobe", name = "Old Terrain Mine Fixture", version = "1", + dependencies = "required-after:realisticdeposits") +public final class MineLegacyTerrainProbe { + private static final Logger LOG = LogManager.getLogger("rdminelegacyprobe"); + + @Mod.EventHandler + public void started(FMLServerStartedEvent event) { + if (!Boolean.getBoolean("rd.mines.legacy")) return; + + MinecraftServer server = FMLCommonHandler.instance().getMinecraftServerInstance(); + try { + WorldServer world = server.worldServerForDimension(0); + if (world.getSeed() != 3346071746553592696L + || !world.getSaveHandler().getWorldDirectory().getName().equals("mine-test-world")) { + throw new IllegalStateException("This fixture is restricted to its disposable world"); + } + + BlockPos marker = new BlockPos(value("markerX"), value("markerY"), value("markerZ")); + BlockPos rawMarker = new BlockPos(value("rawX"), value("rawY"), value("rawZ")); + int chunkX = marker.getX() >> 4; + int chunkZ = marker.getZ() >> 4; + ChunkProviderServer provider = world.getChunkProvider(); + for (int dx = 0; dx <= 1; dx++) { + for (int dz = 0; dz <= 1; dz++) provider.provideChunk(chunkX + dx, chunkZ + dz); + } + Chunk old = provider.provideChunk(chunkX, chunkZ); + old.populateChunk(provider, provider.chunkGenerator); + world.setBlockState(marker, Blocks.DIAMOND_BLOCK.getDefaultState(), 2); + BlockPos chestPosition = marker.add(0, 1, 0); + world.setBlockState(chestPosition, Blocks.CHEST.getDefaultState(), 2); + TileEntityChest chest = (TileEntityChest) world.getTileEntity(chestPosition); + chest.setCustomName("Old mine fixture"); + ItemStack diamonds = new ItemStack(Items.DIAMOND, 7); + diamonds.setStackDisplayName("Old fixture diamonds"); + chest.setInventorySlotContents(0, diamonds); + chest.markDirty(); + + Chunk raw = provider.provideChunk(rawMarker.getX() >> 4, rawMarker.getZ() >> 4); + if (raw.isTerrainPopulated()) throw new IllegalStateException("The fixture needs a later-opened unpopulated chunk"); + raw.getBlockStorageArray()[rawMarker.getY() >> 4].set(rawMarker.getX() & 15, + rawMarker.getY() & 15, rawMarker.getZ() & 15, Blocks.EMERALD_BLOCK.getDefaultState()); + raw.setChunkModified(); + + Properties report = new Properties(); + report.setProperty("marker", Long.toString(marker.toLong())); + report.setProperty("raw_marker", Long.toString(rawMarker.toLong())); + report.setProperty("old_terrain", terrainHash(old)); + report.setProperty("raw_terrain", terrainHash(raw)); + try (FileOutputStream output = new FileOutputStream("mine-legacy.properties")) { + report.store(output, "World made with the released Realistic Deposits alpha"); + } + LOG.info("RD_MINES_LEGACY_PASS"); + } catch (Throwable failure) { + LOG.error("Old-terrain fixture failed", failure); + } finally { + server.initiateShutdown(); + } + } + + private static int value(String key) { + return Integer.parseInt(System.getProperty("rd.mines.legacy." + key)); + } + + static String terrainHash(Chunk chunk) throws Exception { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + ByteBuffer value = ByteBuffer.allocate(4); + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + for (int y = 0; y < 256; y++) { + value.clear(); + value.putInt(Block.getStateId(chunk.getBlockState(x, y, z))); + digest.update(value.array()); + } + } + } + StringBuilder result = new StringBuilder(); + for (byte part : digest.digest()) result.append(String.format("%02x", part & 255)); + return result.toString(); + } +} diff --git a/src/test/java/zone/moddev/mc/realisticdeposits/generation/MineWorkingsRuntimeProbe.java b/src/test/java/zone/moddev/mc/realisticdeposits/generation/MineWorkingsRuntimeProbe.java new file mode 100644 index 0000000..04bc414 --- /dev/null +++ b/src/test/java/zone/moddev/mc/realisticdeposits/generation/MineWorkingsRuntimeProbe.java @@ -0,0 +1,510 @@ +package zone.moddev.mc.realisticdeposits.generation; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.nio.ByteBuffer; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.Random; +import java.util.Set; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import net.minecraft.block.BlockLadder; +import net.minecraft.block.BlockRail; +import net.minecraft.block.BlockRailBase; +import net.minecraft.block.state.IBlockState; +import net.minecraft.init.Blocks; +import net.minecraft.server.MinecraftServer; +import net.minecraft.tileentity.TileEntityChest; +import net.minecraft.init.Items; +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.util.EnumFacing; +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.minecraft.world.gen.structure.MapGenStructureData; +import net.minecraftforge.fml.common.FMLCommonHandler; +import net.minecraftforge.fml.common.Mod; +import net.minecraftforge.fml.common.event.FMLServerStartedEvent; + +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.MineBlock; +import zone.moddev.mc.realisticdeposits.geometry.MinePoint; +import zone.moddev.mc.realisticdeposits.geometry.RegionDepositPlanner; +import zone.moddev.mc.realisticdeposits.geometry.StratiformSeamBody; +import zone.moddev.mc.realisticdeposits.model.AbandonedMineSettings; +import zone.moddev.mc.realisticdeposits.model.DepositDefinition; +import zone.moddev.mc.orespawn.api.OreGenerationContext; +import zone.moddev.mc.orespawn.api.GeologySampler; +import net.minecraft.util.ResourceLocation; +import net.minecraftforge.fluids.Fluid; + +/** Runs only in disposable packaged-server fixtures, never in normal launches. */ +@Mod(modid = "rdmineprobe", name = "Abandoned Mine Qualification", version = "1", + dependencies = "required-after:realisticdeposits") +public final class MineWorkingsRuntimeProbe { + private static final Logger LOG = LogManager.getLogger("rdmineprobe"); + private static final long SEED = 3346071746553592696L; + + @Mod.EventHandler + public void started(FMLServerStartedEvent event) { + if (!Boolean.getBoolean("rd.mines.enabled")) return; + + MinecraftServer server = FMLCommonHandler.instance().getMinecraftServerInstance(); + String phase = System.getProperty("rd.mines.phase"); + try { + 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"); + DepositDefinition definition = RealisticDepositsRuntime.state().definitions().get(0); + if (phase.startsWith("default-")) { + normalWorld(world, definition, phase); + LOG.info("RD_MINES_PASS {}", phase); + return; + } + StratiformSeamBody body = distantBody(definition); + AbandonedMinePlan expected = AbandonedMinePlanner.plan(body, + new AbandonedMineSettings(true, 1.0D), 129).get(); + String key = body.identity().toString(); + Properties report = new Properties(); + report.setProperty("phase", phase); + report.setProperty("entrance", expected.entrance().toString()); + report.setProperty("landing", expected.landing().toString()); + report.setProperty("mine_chunks", join(allChunks(expected))); + List fixtureChunks = allChunks(expected); + report.setProperty("legacy_marker", firstAir(expected, fixtureChunks.get(0)).toString()); + report.setProperty("legacy_raw_marker", firstAir(expected, fixtureChunks.get(fixtureChunks.size() - 1)).toString()); + + if (phase.startsWith("upgrade-")) { + upgrade(world, definition, body, expected, phase, report); + } else if (phase.startsWith("order-")) { + List chunks = allChunks(expected); + if ("order-reverse".equals(phase)) { + Collections.reverse(chunks); + } else { + Collections.shuffle(chunks, new Random(34919L)); + } + + for (long chunk : chunks) populate(world, (int) (chunk >> 32), (int) chunk); + MineWorldData data = MineWorldData.get(world); + require(data.plan(key) != null, "Generation order rejected the mine: " + data.rejectionReason(key)); + require(expected.blocks().equals(data.plan(key).blocks()), "Generation order changed the saved layout"); + report.setProperty("checked_cells", Integer.toString(verify(world, expected, allChunks(expected), false))); + report.setProperty("plan", planHash(data.plan(key))); + } else if ("structure".equals(phase)) { + MapGenStructureData structures = new MapGenStructureData("Mineshaft"); + NBTTagCompound existing = new NBTTagCompound(); + existing.setIntArray("BB", new int[] {expected.bounds().minimumX(), expected.bounds().minimumY(), + expected.bounds().minimumZ(), expected.bounds().maximumX(), expected.bounds().maximumY(), + expected.bounds().maximumZ()}); + structures.writeInstance(existing, expected.landing().x() >> 4, expected.landing().z() >> 4); + world.getMapStorage().setData("Mineshaft", structures); + + for (long chunk : allChunks(expected)) populate(world, (int) (chunk >> 32), (int) chunk); + MineWorldData data = MineWorldData.get(world); + require(data.plan(key) == null && "Another structure".equals(data.rejectionReason(key)), + "A known native structure did not protect its terrain"); + } else if ("blocked".equals(phase)) { + long target = MinePoint.chunkKey(expected.landing().x() >> 4, expected.landing().z() >> 4); + MinePoint obstruction = firstAir(expected, target); + Chunk chunk = world.getChunkProvider().provideChunk((int) (target >> 32), (int) target); + require(!chunk.isTerrainPopulated(), "The obstruction fixture must start in a raw chunk"); + BlockPos position = new BlockPos(obstruction.x(), obstruction.y(), obstruction.z()); + chunk.setBlockState(position, Blocks.CHEST.getDefaultState()); + TileEntityChest chest = (TileEntityChest) world.getTileEntity(position); + chest.setCustomName("Protected fixture chest"); + chest.setInventorySlotContents(0, new net.minecraft.item.ItemStack(Items.DIAMOND, 3)); + for (long section : allChunks(expected)) populate(world, (int) (section >> 32), (int) section); + require(world.getBlockState(position).getBlock() == Blocks.CHEST, "The mine overwrote a foreign block"); + chest = (TileEntityChest) world.getTileEntity(position); + require(chest != null && "Protected fixture chest".equals(chest.getName()) + && chest.getStackInSlot(0).stackSize == 3, "The mine changed an obstructing chest"); + require(MineWorldData.get(world).unbuiltMinesInChunk(chunk.xPosition, chunk.zPosition).isEmpty(), + "A sealed section was left eligible for another excavation attempt"); + } else if ("first".equals(phase)) { + int x = Math.floorDiv(expected.landing().x(), 16); + int z = Math.floorDiv(expected.landing().z(), 16); + populate(world, x, z); + MineWorldData data = MineWorldData.get(world); + require(data.plan(key) != null, "No accepted mine: " + data.rejectionReason(key)); + require(expected.blocks().equals(data.plan(key).blocks()), "Native plan differs from the pure planner"); + List generated = populatedChunks(world, expected); + require(!generated.isEmpty(), "No first slice was generated"); + require(verify(world, expected, generated, false) > 0, "No planned cells were checked"); + require(generated.size() < allChunks(expected).size(), "The first phase must leave later chunks unexplored"); + + world.setBlockState(marker(expected), Blocks.DIAMOND_BLOCK.getDefaultState(), 2); + Properties first = new Properties(); + first.setProperty("chunks", join(generated)); + first.setProperty("terrain", terrainHash(world, generated)); + first.setProperty("plan", planHash(expected)); + try (FileOutputStream output = new FileOutputStream("mine-first.properties")) { + first.store(output, "First generated mine slices"); + } + report.setProperty("first_chunks", Integer.toString(generated.size())); + } else if ("complete".equals(phase) || "reload".equals(phase)) { + Properties first = new Properties(); + try (FileInputStream input = new FileInputStream("mine-first.properties")) { + first.load(input); + } + + List old = split(first.getProperty("chunks")); + require(first.getProperty("terrain").equals(terrainHash(world, old)), "Old terrain or the player's marker changed"); + MineWorldData data = MineWorldData.get(world); + require(first.getProperty("plan").equals(planHash(data.plan(key))), "The saved mine layout changed on reload"); + if ("complete".equals(phase)) { + for (long chunk : allChunks(expected)) populate(world, (int) (chunk >> 32), (int) chunk); + } + + require(first.getProperty("terrain").equals(terrainHash(world, old)), "Completing the mine changed old chunks"); + int checked = verify(world, expected, allChunks(expected), true); + report.setProperty("checked_cells", Integer.toString(checked)); + require(world.getBlockState(marker(expected)).getBlock() == Blocks.DIAMOND_BLOCK, "Player marker was replaced"); + } else if ("off".equals(phase) || "wet".equals(phase)) { + for (long chunk : allChunks(expected)) populate(world, (int) (chunk >> 32), (int) chunk); + if ("off".equals(phase)) { + require(!world.getSaveHandler().getMapFileFromName(MineWorldData.NAME).isFile(), "Opt-out created mine save data"); + } else { + MineWorldData data = MineWorldData.get(world); + require(data.plan(key) == null && data.decided(key), "The underwater site was not rejected"); + } + + for (long chunk : allChunks(expected)) { + Chunk terrain = world.getChunkFromChunkCoords((int) (chunk >> 32), (int) chunk); + for (AbandonedMinePlan.Cell cell : expected.cellsInChunk(terrain.xPosition, terrain.zPosition)) { + IBlockState actual = terrain.getBlockState(new BlockPos(cell.point().x(), cell.point().y(), cell.point().z())); + require(actual.getBlock() != Blocks.PLANKS && actual.getBlock() != Blocks.RAIL + && actual.getBlock() != Blocks.LADDER && actual.getBlock() != Blocks.WEB, + "A disabled or underwater site contains mine blocks"); + } + } + } else { + throw new IllegalArgumentException("Unknown mine probe phase " + phase); + } + + try (FileOutputStream output = new FileOutputStream("mine-" + phase + "-pass.properties")) { + report.store(output, "Packaged abandoned-mine qualification"); + } + LOG.info("RD_MINES_PASS {}", phase); + } catch (Throwable failure) { + LOG.error("Abandoned-mine qualification failed", failure); + } finally { + server.initiateShutdown(); + } + } + + private static int verify(WorldServer world, AbandonedMinePlan plan, List chunks, boolean hasMarker) { + int checked = 0; + for (long key : chunks) { + Chunk chunk = world.getChunkProvider().loadChunk((int) (key >> 32), (int) key); + require(chunk != null, "A claimed completed chunk was not saved"); + for (AbandonedMinePlan.Cell cell : plan.cellsInChunk(chunk.xPosition, chunk.zPosition)) { + BlockPos position = new BlockPos(cell.point().x(), cell.point().y(), cell.point().z()); + if (hasMarker && position.equals(marker(plan))) continue; + IBlockState actual = chunk.getBlockState(position); + require(actual.equals(expected(cell.block())), "Mine cell mismatch at " + position + + ": expected " + cell.block() + ", got " + actual); + checked++; + } + } + return checked; + } + + private static MinePoint firstAir(AbandonedMinePlan plan, long chunk) { + for (AbandonedMinePlan.Cell cell : plan.cellsInChunk((int) (chunk >> 32), (int) chunk)) { + if (cell.block() == MineBlock.AIR) return cell.point(); + } + throw new IllegalStateException("The fixture chunk has no tunnel air"); + } + + private static void upgrade(WorldServer world, DepositDefinition definition, StratiformSeamBody body, + AbandonedMinePlan expected, String phase, Properties report) throws Exception { + Properties old = new Properties(); + try (FileInputStream input = new FileInputStream("mine-legacy.properties")) { + old.load(input); + } + + BlockPos marker = BlockPos.fromLong(Long.parseLong(old.getProperty("marker"))); + BlockPos rawMarker = BlockPos.fromLong(Long.parseLong(old.getProperty("raw_marker"))); + long populated = MinePoint.chunkKey(marker.getX() >> 4, marker.getZ() >> 4); + long rawKey = MinePoint.chunkKey(rawMarker.getX() >> 4, rawMarker.getZ() >> 4); + require(old.getProperty("old_terrain").equals(terrainHash(world, java.util.Collections.singletonList(populated))), + "Upgrading changed the old populated chunk"); + TileEntityChest chest = (TileEntityChest) world.getTileEntity(marker.add(0, 1, 0)); + require(chest != null && "Old mine fixture".equals(chest.getName()), "Old chest name was lost"); + require(chest.getStackInSlot(0) != null && chest.getStackInSlot(0).getItem() == Items.DIAMOND + && chest.getStackInSlot(0).stackSize == 7 + && "Old fixture diamonds".equals(chest.getStackInSlot(0).getDisplayName()), "Old chest contents changed"); + + if ("upgrade-later".equals(phase)) { + require(world.getChunkProvider().getLoadedChunk(rawMarker.getX() >> 4, rawMarker.getZ() >> 4) == null, + "The later-opened chunk was already loaded"); + Chunk raw = world.getChunkProvider().loadChunk(rawMarker.getX() >> 4, rawMarker.getZ() >> 4); + require(raw != null && !raw.isTerrainPopulated(), "Expected the old saved raw chunk"); + require(old.getProperty("raw_terrain").equals(terrainHash(world, java.util.Collections.singletonList(rawKey))), + "The unopened old chunk changed before population"); + + // Exercise the excavation guard directly, before ordinary Minecraft + // population is allowed to decorate this saved-but-unpopulated chunk. + MineWorkingsGenerator.offer(new OldChunkContext(raw.xPosition, raw.zPosition), definition, body); + MineWorkingsGenerator.INSTANCE.generate(new Random(1), raw.xPosition, raw.zPosition, world, + world.getChunkProvider().chunkGenerator, world.getChunkProvider()); + require(old.getProperty("raw_terrain").equals(terrainHash(world, java.util.Collections.singletonList(rawKey))), + "Mine generation excavated an old raw chunk"); + for (long chunk : allChunks(expected)) populate(world, (int) (chunk >> 32), (int) chunk); + require(world.getBlockState(rawMarker).getBlock() == Blocks.EMERALD_BLOCK, "Later population lost the old marker"); + } else { + require(world.getChunkProvider().getLoadedChunk(rawMarker.getX() >> 4, rawMarker.getZ() >> 4) == null, + "The raw fixture must remain unopened until the later phase"); + populate(world, (marker.getX() >> 4) - 1, (marker.getZ() >> 4) - 1); + } + + require(old.getProperty("old_terrain").equals(terrainHash(world, java.util.Collections.singletonList(populated))), + "New chunks changed old populated terrain"); + String key = body.identity().toString(); + if (Boolean.getBoolean("rd.mines.upgrade.optIn")) { + MineWorldData data = MineWorldData.get(world); + require(data.plan(key) == null && "Existing terrain".equals(data.rejectionReason(key)), + "Explicit opt-in did not reject a mine crossing old terrain: " + data.rejectionReason(key)); + } else { + require(!world.getSaveHandler().getMapFileFromName(MineWorldData.NAME).isFile(), + "An unchanged old world profile silently enabled mines"); + } + report.setProperty("old_terrain_preserved", "true"); + } + + private static final class OldChunkContext implements OreGenerationContext { + private final int x; + private final int z; + + private OldChunkContext(int x, int z) { + this.x = x; + this.z = z; + } + + @Override public long worldSeed() { return SEED; } + @Override public ResourceLocation dimension() { return new ResourceLocation("minecraft:overworld"); } + @Override public int chunkX() { return x; } + @Override public int chunkZ() { return z; } + @Override public java.util.Optional geologySampler() { return java.util.Optional.empty(); } + @Override public Random random() { return new Random(1); } + @Override public int originX() { return x << 4; } + @Override public int originY() { return 0; } + @Override public int originZ() { return z << 4; } + @Override public int minY() { return 0; } + @Override public int maxY() { return 255; } + @Override public int quantity() { return 1; } + @Override public int spread() { return 0; } + @Override public int verticalSpread() { return 0; } + @Override public int nodeSize() { return 1; } + @Override public boolean inside(int px, int py, int pz) { return false; } + @Override public boolean isFluid(int px, int py, int pz, Fluid fluid) { return false; } + @Override public boolean tryPlace(int px, int py, int pz) { return false; } + } + + private static void normalWorld(WorldServer world, DepositDefinition definition, String phase) throws Exception { + Properties report = new Properties(); + MineWorldData data = MineWorldData.get(world); + AbandonedMinePlan plan; + String key; + + if ("default-first".equals(phase)) { + int loadedBefore = world.getChunkProvider().getLoadedChunkCount(); + MineTerrainSampler terrain = new MineTerrainSampler(world, world.getChunkProvider().chunkGenerator); + StratiformSeamBody chosen = null; + plan = null; + + search: for (int x = 8; x < 24; x++) { + for (int z = 8; z < 24; z++) { + java.util.Optional body = RegionDepositPlanner.plan( + definition, SEED, "minecraft:overworld", x, z); + if (!body.isPresent()) continue; + java.util.Optional proposed = AbandonedMinePlanner.plan(body.get(), + new AbandonedMineSettings(true, 1.0D), terrain.surfaceY(AbandonedMinePlanner.landing(body.get()))); + if (!proposed.isPresent()) continue; + AbandonedMinePlan candidate = proposed.get(); + if (terrain.covered(candidate) && terrain.dry(candidate) + && MineWorkingsGenerator.biomesAllowed(candidate, definition, world)) { + chosen = body.get(); + plan = candidate; + break search; + } + } + } + + require(loadedBefore == world.getChunkProvider().getLoadedChunkCount(), "Terrain sampling loaded neighbouring chunks"); + require(chosen != null && plan != null, "No suitable normal-Overworld fixture was found"); + key = chosen.identity().toString(); + for (long chunk : allChunks(plan)) populate(world, (int) (chunk >> 32), (int) chunk); + require(data.plan(key) != null, "Normal terrain rejected the mine: " + data.rejectionReason(key)); + require(plan.blocks().equals(data.plan(key).blocks()), "Native normal-terrain plan mismatch"); + verify(world, plan, allChunks(plan), false); + verifyAccess(world, plan, false); + world.setBlockState(marker(plan), Blocks.DIAMOND_BLOCK.getDefaultState(), 2); + report.setProperty("key", key); + report.setProperty("terrain", terrainHash(world, allChunks(plan))); + report.setProperty("plan", planHash(plan)); + try (FileOutputStream output = new FileOutputStream("mine-default.properties")) { + report.store(output, "Normal Overworld mine fixture"); + } + } else { + try (FileInputStream input = new FileInputStream("mine-default.properties")) { + report.load(input); + } + key = report.getProperty("key"); + plan = data.plan(key); + require(plan != null && report.getProperty("plan").equals(planHash(plan)), "Normal-terrain plan moved after reload"); + require(report.getProperty("terrain").equals(terrainHash(world, allChunks(plan))), "Normal-terrain mine or old terrain changed"); + verify(world, plan, allChunks(plan), true); + } + + verifyAccess(world, plan, true); + + report.setProperty("entrance", plan.entrance().toString()); + report.setProperty("landing", plan.landing().toString()); + report.setProperty("checked_cells", Integer.toString(plan.blocks().size())); + try (FileOutputStream output = new FileOutputStream("mine-" + phase + "-pass.properties")) { + report.store(output, "Packaged normal-Overworld mine qualification"); + } + } + + private static void verifyAccess(WorldServer world, AbandonedMinePlan plan, boolean hasMarker) { + MinePoint landing = plan.landing(); + for (int y = landing.y(); y <= plan.entrance().y(); y++) { + BlockPos ladder = new BlockPos(landing.x() + 1, y, landing.z()); + require(world.getBlockState(ladder).getBlock() == Blocks.LADDER, "The shaft ladder has a gap"); + require(world.isSideSolid(ladder.east(), EnumFacing.WEST), "Unsupported shaft ladder"); + BlockPos access = ladder.west(); + if (!hasMarker || !access.equals(marker(plan))) { + require(world.getBlockState(access).getBlock() == Blocks.AIR, "The shaft's access space is blocked"); + } + } + + BlockPos top = new BlockPos(landing.x(), plan.entrance().y(), landing.z()); + require(world.canSeeSky(top), "The surface entrance is not open to the sky"); + } + + private static StratiformSeamBody distantBody(DepositDefinition definition) { + for (int x = 8; x < 16; x++) { + for (int z = 8; z < 16; z++) { + java.util.Optional body = RegionDepositPlanner.plan( + definition, SEED, "minecraft:overworld", x, z); + if (body.isPresent() && AbandonedMinePlanner.plan(body.get(), + new AbandonedMineSettings(true, 1.0D), 129).isPresent()) return body.get(); + } + } + throw new IllegalStateException("The fixture has no suitable distant deposit"); + } + + private static void populate(WorldServer world, int x, int z) { + ChunkProviderServer provider = world.getChunkProvider(); + for (int dx = 0; dx <= 1; dx++) { + for (int dz = 0; dz <= 1; dz++) provider.provideChunk(x + dx, z + dz); + } + provider.provideChunk(x, z).populateChunk(provider, provider.chunkGenerator); + } + + private static List allChunks(AbandonedMinePlan plan) { + List chunks = new ArrayList<>(); + for (int x = Math.floorDiv(plan.bounds().minimumX(), 16); x <= Math.floorDiv(plan.bounds().maximumX(), 16); x++) { + for (int z = Math.floorDiv(plan.bounds().minimumZ(), 16); z <= Math.floorDiv(plan.bounds().maximumZ(), 16); z++) { + if (!plan.cellsInChunk(x, z).isEmpty()) chunks.add(MinePoint.chunkKey(x, z)); + } + } + return chunks; + } + + private static List populatedChunks(WorldServer world, AbandonedMinePlan plan) { + List chunks = new ArrayList<>(); + for (long key : allChunks(plan)) { + Chunk chunk = world.getChunkProvider().getLoadedChunk((int) (key >> 32), (int) key); + if (chunk != null && chunk.isTerrainPopulated()) chunks.add(key); + } + return chunks; + } + + private static BlockPos marker(AbandonedMinePlan plan) { + return new BlockPos(plan.landing().x(), plan.landing().y() + 1, plan.landing().z()); + } + + private static String terrainHash(WorldServer world, List chunks) throws Exception { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + ByteBuffer value = ByteBuffer.allocate(4); + for (long key : chunks) { + Chunk chunk = world.getChunkProvider().loadChunk((int) (key >> 32), (int) key); + require(chunk != null, "Expected saved old chunk"); + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + for (int y = 0; y < 256; y++) { + value.clear(); + value.putInt(net.minecraft.block.Block.getStateId(chunk.getBlockState(x, y, z))); + digest.update(value.array()); + } + } + } + } + return hex(digest.digest()); + } + + private static String planHash(AbandonedMinePlan plan) throws Exception { + require(plan != null, "Expected saved mine"); + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + ByteBuffer value = ByteBuffer.allocate(12); + for (Map.Entry cell : plan.blocks().entrySet()) { + value.clear(); + value.putLong(cell.getKey()).putInt(cell.getValue().savedId()); + digest.update(value.array()); + } + return hex(digest.digest()); + } + + private static String join(List values) { + StringBuilder text = new StringBuilder(); + for (long value : values) { + if (text.length() > 0) text.append(','); + text.append(value); + } + return text.toString(); + } + + private static List split(String text) { + List values = new ArrayList<>(); + for (String value : text.split(",")) values.add(Long.parseLong(value)); + return values; + } + + private static String hex(byte[] bytes) { + StringBuilder value = new StringBuilder(); + for (byte part : bytes) value.append(String.format("%02x", part & 255)); + return value.toString(); + } + + private static IBlockState expected(MineBlock block) { + switch (block) { + case AIR: return Blocks.AIR.getDefaultState(); + case PLANKS: return Blocks.PLANKS.getDefaultState(); + case FENCE: return Blocks.OAK_FENCE.getDefaultState(); + case COBWEB: return Blocks.WEB.getDefaultState(); + case COBBLESTONE: return Blocks.COBBLESTONE.getDefaultState(); + case LADDER_WEST: return Blocks.LADDER.getDefaultState().withProperty(BlockLadder.FACING, EnumFacing.WEST); + case RAIL_EAST_WEST: return Blocks.RAIL.getDefaultState().withProperty(BlockRail.SHAPE, BlockRailBase.EnumRailDirection.EAST_WEST); + case RAIL_NORTH_SOUTH: return Blocks.RAIL.getDefaultState().withProperty(BlockRail.SHAPE, BlockRailBase.EnumRailDirection.NORTH_SOUTH); + default: throw new IllegalArgumentException("Unknown test palette"); + } + } + + private static void require(boolean condition, String message) { + if (!condition) throw new IllegalStateException(message); + } +} diff --git a/src/test/java/zone/moddev/mc/realisticdeposits/generation/MineWorldDataTest.java b/src/test/java/zone/moddev/mc/realisticdeposits/generation/MineWorldDataTest.java new file mode 100644 index 0000000..454035d --- /dev/null +++ b/src/test/java/zone/moddev/mc/realisticdeposits/generation/MineWorldDataTest.java @@ -0,0 +1,68 @@ +package zone.moddev.mc.realisticdeposits.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.nbt.NBTTagList; + +import zone.moddev.mc.realisticdeposits.geometry.AbandonedMinePlan; +import zone.moddev.mc.realisticdeposits.geometry.MineBlock; +import zone.moddev.mc.realisticdeposits.geometry.MinePoint; + +class MineWorldDataTest { + + @Test + void laterChunksUseTheExactSavedCellsAndPalette() { + MineWorldData original = new MineWorldData("test"); + Map blocks = new LinkedHashMap<>(); + blocks.put(new MinePoint(-12, 23, -77).packed(), MineBlock.AIR); + blocks.put(new MinePoint(-12, 22, -77).packed(), MineBlock.PLANKS); + AbandonedMinePlan plan = new AbandonedMinePlan(123L, + new MinePoint(-12, 70, -77), new MinePoint(-12, 23, -77), blocks); + original.accept("deposit@region", plan); + original.reject("other@region", "existing terrain"); + NBTTagCompound saved = original.writeToNBT(new NBTTagCompound()); + + MineWorldData reloaded = new MineWorldData("test"); + reloaded.readFromNBT(saved); + + assertEquals(plan.blocks(), reloaded.plan("deposit@region").blocks()); + assertEquals(plan.entrance(), reloaded.plan("deposit@region").entrance()); + assertEquals(saved, reloaded.writeToNBT(new NBTTagCompound())); + assertTrue(reloaded.decided("other@region")); + assertThrows(IllegalStateException.class, () -> reloaded.accept("deposit@region", plan)); + assertEquals(1, reloaded.unbuiltMinesInChunk(-1, -5).size()); + reloaded.sectionHandled("deposit@region", -1, -5); + assertTrue(reloaded.unbuiltMinesInChunk(-1, -5).isEmpty()); + MineWorldData finished = new MineWorldData("test"); + finished.readFromNBT(reloaded.writeToNBT(new NBTTagCompound())); + assertTrue(finished.unbuiltMinesInChunk(-1, -5).isEmpty()); + assertEquals(plan.blocks(), finished.plan("deposit@region").blocks()); + } + + @Test + void unknownOrCorruptSaveDataCannotSilentlyMoveAMine() { + MineWorldData data = new MineWorldData("test"); + assertThrows(IllegalArgumentException.class, () -> data.readFromNBT(new NBTTagCompound())); + + NBTTagCompound root = new NBTTagCompound(); + root.setInteger("schema_version", 1); + NBTTagCompound entry = new NBTTagCompound(); + entry.setString("key", "broken"); + entry.setInteger("layout_version", 1); + entry.setIntArray("positions", new int[] {1, 2}); + entry.setByteArray("palette", new byte[] {(byte) 127}); + NBTTagList entries = new NBTTagList(); + entries.appendTag(entry); + root.setTag("mines", entries); + + assertThrows(IllegalArgumentException.class, () -> data.readFromNBT(root)); + } +} 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 38aa87f..20ecc2b 100644 --- a/src/test/java/zone/moddev/mc/realisticdeposits/generation/PolicyQualificationProbe.java +++ b/src/test/java/zone/moddev/mc/realisticdeposits/generation/PolicyQualificationProbe.java @@ -36,6 +36,8 @@ import zone.moddev.mc.realisticdeposits.geometry.BlockBounds; import zone.moddev.mc.realisticdeposits.geometry.RegionDepositPlanner; import zone.moddev.mc.realisticdeposits.geometry.StratiformSeamBody; +import zone.moddev.mc.realisticdeposits.geometry.CoalMeasuresBody; +import zone.moddev.mc.realisticdeposits.geometry.MineableDepositBody; import zone.moddev.mc.realisticdeposits.model.DepositDefinition; /** Packaged-server qualification fixture, excluded from the public mod jars. */ @@ -57,7 +59,7 @@ public void initialize(FMLInitializationEvent event) { @SubscribeEvent(priority = EventPriority.LOWEST, receiveCanceled = true) public void observeIron(OreGenEvent.GenerateMinable event) { - if (event.getType() != OreGenEvent.GenerateMinable.EventType.IRON) { + if (event.getType() != (coal() ? OreGenEvent.GenerateMinable.EventType.COAL : OreGenEvent.GenerateMinable.EventType.IRON)) { return; } @@ -177,19 +179,19 @@ private JsonObject sample(WorldServer world, int base) throws Exception { update(terrain, Block.getStateId(state)); if (y >= definition.minimumY() && y <= definition.maximumY() - && planned.contains(positionKey(x, y, z)) && state.getBlock() != Blocks.IRON_ORE) { - if (state.getBlock() == Blocks.STONE) { + && planned.contains(positionKey(x, y, z)) && state.getBlock() != resource()) { + if (state.getBlock() == Blocks.STONE && (!coal() || state.equals(Blocks.STONE.getDefaultState()))) { missedStone++; } else { blocked.merge(state.getBlock().getRegistryName().toString(), 1, Integer::sum); } } - if (state.getBlock() != Blocks.IRON_ORE) { + if (state.getBlock() != resource()) { continue; } - if (y >= 96) { + if (y >= definition.minimumY()) { if (!planned.contains(positionKey(x, y, z))) { unexpectedIron++; } @@ -235,15 +237,17 @@ 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 = (int) Math.ceil(definition.seam().strikeLength().maximum() * 0.5 - + definition.seam().downDipWidth().maximum() * 0.5 - + definition.seam().bendAmplitude() + 13.0); + int radius = 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); Set positions = new HashSet<>(); 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++) { - StratiformSeamBody body = RegionDepositPlanner.plan(definition, worldSeed, - "minecraft:overworld", rx, rz).orElse(null); + MineableDepositBody body = definition.isCoal() + ? CoalMeasuresBody.plan(definition, worldSeed, "minecraft:overworld", rx, rz).orElse(null) + : RegionDepositPlanner.plan(definition, worldSeed, "minecraft:overworld", rx, rz).orElse(null); if (body == null) { continue; @@ -266,6 +270,10 @@ private static Set plannedPositions(long worldSeed, int base) { return positions; } + private static boolean coal() { return "coal".equals(System.getProperty("rd.qualification.resource")); } + + private static Block resource() { return coal() ? Blocks.COAL_ORE : Blocks.IRON_ORE; } + private static long positionKey(int x, int y, int z) { return ((long) (x & 0x3ffffff) << 38) | ((long) (z & 0x3ffffff) << 12) | (y & 0xfff); } diff --git a/src/test/java/zone/moddev/mc/realisticdeposits/geometry/AbandonedMinePlannerTest.java b/src/test/java/zone/moddev/mc/realisticdeposits/geometry/AbandonedMinePlannerTest.java new file mode 100644 index 0000000..2083943 --- /dev/null +++ b/src/test/java/zone/moddev/mc/realisticdeposits/geometry/AbandonedMinePlannerTest.java @@ -0,0 +1,168 @@ +package zone.moddev.mc.realisticdeposits.geometry; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.InputStreamReader; +import java.io.StringReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import zone.moddev.mc.realisticdeposits.config.DepositCatalogLoader; +import zone.moddev.mc.realisticdeposits.model.AbandonedMineSettings; +import zone.moddev.mc.realisticdeposits.model.DepositDefinition; + +class AbandonedMinePlannerTest { + private static final long SEED = 3346071746553592696L; + private static final AbandonedMineSettings ALWAYS = new AbandonedMineSettings(true, 1.0D); + + @Test + void oldDefinitionsAndZeroChanceNeverProduceWorkings() { + StratiformSeamBody body = body(-1, -1); + + assertFalse(AbandonedMinePlanner.plan(body, AbandonedMineSettings.DISABLED, 100).isPresent()); + assertFalse(AbandonedMinePlanner.plan(body, new AbandonedMineSettings(true, 0.0D), 100).isPresent()); + } + + @Test + void chanceIsStablePerDepositRatherThanRolledOnceForEveryChunk() { + AbandonedMineSettings quarter = new AbandonedMineSettings(true, 0.25D); + int selected = 0; + + for (int x = -16; x < 16; x++) { + for (int z = -8; z < 8; z++) { + StratiformSeamBody body = body(x, z); + boolean first = AbandonedMinePlanner.selected(body, quarter); + assertEquals(first, AbandonedMinePlanner.selected(body(x, z), quarter)); + assertTrue(AbandonedMinePlanner.selected(body, ALWAYS)); + if (first) selected++; + } + } + + assertTrue(selected > 90 && selected < 170, "Expected roughly a quarter of 512 deposits, got " + selected); + } + + @Test + void networkIsBoundedConnectedAndHasAUsableSurfaceLadder() { + StratiformSeamBody body = body(-1, -1); + AbandonedMinePlan plan = AbandonedMinePlanner.plan(body, ALWAYS, 100).get(); + Set reachable = reachable(plan, plan.entrance()); + + assertTrue(reachable.contains(plan.landing().packed())); + assertTrue(plan.blocks().size() < AbandonedMinePlan.MAXIMUM_BLOCKS); + assertTrue(plan.bounds().maximumX() - plan.bounds().minimumX() < 100); + assertTrue(plan.bounds().maximumZ() - plan.bounds().minimumZ() < 100); + assertTrue(plan.blocks().containsValue(MineBlock.COBWEB)); + assertTrue(plan.blocks().containsValue(MineBlock.FENCE)); + assertTrue(plan.blocks().containsValue(MineBlock.RAIL_EAST_WEST) + || plan.blocks().containsValue(MineBlock.RAIL_NORTH_SOUTH)); + + for (Map.Entry cell : plan.blocks().entrySet()) { + if (cell.getValue().passable()) { + assertTrue(reachable.contains(cell.getKey()), "Disconnected tunnel cell " + MinePoint.unpack(cell.getKey())); + } + } + + for (int y = plan.landing().y(); y <= plan.entrance().y(); y++) { + assertEquals(MineBlock.LADDER_WEST, + plan.blocks().get(new MinePoint(plan.landing().x() + 1, y, plan.landing().z()).packed())); + assertEquals(MineBlock.PLANKS, + plan.blocks().get(new MinePoint(plan.landing().x() + 2, y, plan.landing().z()).packed())); + } + } + + @Test + void chunkSlicesAreDisjointAndReassembleInAnyOrder() { + AbandonedMinePlan plan = AbandonedMinePlanner.plan(body(-1, -1), ALWAYS, 100).get(); + List chunks = new ArrayList<>(); + + for (int x = Math.floorDiv(plan.bounds().minimumX(), 16); + x <= Math.floorDiv(plan.bounds().maximumX(), 16); x++) { + for (int z = Math.floorDiv(plan.bounds().minimumZ(), 16); + z <= Math.floorDiv(plan.bounds().maximumZ(), 16); z++) { + chunks.add(MinePoint.chunkKey(x, z)); + } + } + + for (int order = 0; order < 3; order++) { + Collections.shuffle(chunks, new Random(order)); + Map combined = new LinkedHashMap<>(); + + for (long key : chunks) { + int x = (int) (key >> 32); + int z = (int) key; + for (AbandonedMinePlan.Cell cell : plan.cellsInChunk(x, z)) { + assertEquals(x, Math.floorDiv(cell.point().x(), 16)); + assertEquals(z, Math.floorDiv(cell.point().z(), 16)); + assertFalse(combined.containsKey(cell.point().packed())); + combined.put(cell.point().packed(), cell.block()); + } + } + + assertEquals(plan.blocks(), combined); + } + } + + @Test + void unsafeShaftDepthsAreRejected() { + StratiformSeamBody body = body(-1, -1); + MinePoint landing = AbandonedMinePlanner.landing(body); + + assertFalse(AbandonedMinePlanner.plan(body, ALWAYS, landing.y() + 3).isPresent()); + assertFalse(AbandonedMinePlanner.plan(body, ALWAYS, landing.y() + 97).isPresent()); + assertFalse(AbandonedMinePlanner.plan(body, ALWAYS, 253).isPresent()); + } + + @Test + void coordinatePackingPreservesNegativeCoordinates() { + for (int x : new int[] {-30000000, -1, 0, 30000000}) { + for (int z : new int[] {-30000000, -1, 0, 30000000}) { + MinePoint point = new MinePoint(x, 67, z); + assertEquals(point, MinePoint.unpack(point.packed())); + } + } + } + + private static Set reachable(AbandonedMinePlan plan, MinePoint start) { + Set visited = new HashSet<>(); + ArrayDeque queue = new ArrayDeque<>(); + queue.add(start); + int[][] offsets = {{1,0,0}, {-1,0,0}, {0,1,0}, {0,-1,0}, {0,0,1}, {0,0,-1}}; + + while (!queue.isEmpty()) { + MinePoint point = queue.remove(); + MineBlock block = plan.blocks().get(point.packed()); + if (block == null || !block.passable() || !visited.add(point.packed())) continue; + + for (int[] offset : offsets) { + queue.add(new MinePoint(point.x() + offset[0], point.y() + offset[1], point.z() + offset[2])); + } + } + + return visited; + } + + private static StratiformSeamBody body(int x, int z) { + JsonObject catalog = new JsonParser().parse(new InputStreamReader( + AbandonedMinePlannerTest.class.getResourceAsStream( + "/assets/realisticdeposits/defaults/vanilla.json"), StandardCharsets.UTF_8)).getAsJsonObject(); + catalog.getAsJsonArray("deposits").get(0).getAsJsonObject().addProperty("selection_chance", 1.0D); + DepositDefinition definition = DepositCatalogLoader.load(new StringReader(catalog.toString()), "test") + .definitions().get(0); + return RegionDepositPlanner.plan(definition, SEED, "minecraft:overworld", x, z).get(); + } +} diff --git a/src/test/java/zone/moddev/mc/realisticdeposits/geometry/CoalMeasuresBodyTest.java b/src/test/java/zone/moddev/mc/realisticdeposits/geometry/CoalMeasuresBodyTest.java new file mode 100644 index 0000000..a798cee --- /dev/null +++ b/src/test/java/zone/moddev/mc/realisticdeposits/geometry/CoalMeasuresBodyTest.java @@ -0,0 +1,162 @@ +package zone.moddev.mc.realisticdeposits.geometry; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import zone.moddev.mc.realisticdeposits.config.DepositCatalogLoader; +import zone.moddev.mc.realisticdeposits.model.AbandonedMineSettings; +import zone.moddev.mc.realisticdeposits.model.DepositDefinition; + +public class CoalMeasuresBodyTest { + public static DepositDefinition definition() { + return DepositCatalogLoader.load(new InputStreamReader(CoalMeasuresBodyTest.class + .getResourceAsStream("/assets/realisticdeposits/defaults/coal.json"), StandardCharsets.UTF_8), + "coal.json").definitions().get(0); + } + + public static CoalMeasuresBody body() { + DepositDefinition definition = definition(); + for (int rx = -10; rx <= 10; rx++) { + java.util.Optional body = CoalMeasuresBody.plan(definition, + 3346071746553592696L, "minecraft:overworld", rx, -1); + if (body.isPresent()) return body.get(); + } + throw new AssertionError("No coalfield selected"); + } + + @Test + void coalHasSeveralThinBedsSeparatedByRock() { + CoalMeasuresBody body = body(); + assertTrue(body.bedCount() >= 2 && body.bedCount() <= 4); + for (int bed = 0; bed < body.bedCount(); bed++) { + assertTrue(body.bedThickness(bed) >= 1 && body.bedThickness(bed) <= 3); + if (bed > 0) { + double gap = body.bedOffset(bed) - body.bedThickness(bed) * 0.5D + - body.bedOffset(bed - 1) - body.bedThickness(bed - 1) * 0.5D; + assertTrue(gap >= 4 && gap <= 12, "Rock interval: " + gap); + } + } + } + + @Test + void foldingAndFaultThrowAreSharedByAllBeds() { + CoalMeasuresBody body = body(); + for (int dx = -200; dx <= 200; dx += 4) { + for (int bed = 1; bed < body.bedCount(); bed++) { + assertEquals(body.bedOffset(bed) - body.bedOffset(0), + body.bedY(bed, body.centerX() + dx, body.centerZ()) + - body.bedY(0, body.centerX() + dx, body.centerZ()), 0.000001D); + } + } + } + + @Test + void completeColumnsAndChunkSlicesAgreeInEitherOrder() { + CoalMeasuresBody body = body(); + Set forward = sample(body, false); + Set reverse = sample(body, true); + assertFalse(forward.isEmpty()); + assertEquals(forward, reverse); + assertTrue(forward.size() > 100000, "A coalfield should support a substantial mine"); + assertFalse(body.isOreAt(body.centerX(), 7, body.centerZ())); + assertFalse(body.isOreAt(body.centerX(), 129, body.centerZ())); + } + + @Test + void principalBedCanUseTheExistingMinePlanner() { + CoalMeasuresBody body = body(); + MinePoint landing = AbandonedMinePlanner.landing(body); + assertTrue(body.isOreAt(landing.x(), landing.y(), landing.z()) + || body.isOreAt(landing.x(), landing.y() - 1, landing.z())); + assertTrue(AbandonedMinePlanner.plan(body, new AbandonedMineSettings(true, 1), landing.y() + 20).isPresent()); + assertFalse(AbandonedMinePlanner.selected(body, AbandonedMineSettings.DISABLED)); + } + + @Test + void beddingGridIsImmutableAndMissingPackagesStayEmpty() { + float[] heights = {40, 42, 40, 42}; + CoalMeasuresBody.BeddingGrid grid = new CoalMeasuresBody.BeddingGrid(0, 0, 16, 2, heights); + heights[0] = 100; + assertEquals(41, grid.heightAt(8, 8)); + assertTrue(Double.isNaN(grid.heightAt(-1, 0))); + assertThrows(IllegalArgumentException.class, + () -> new CoalMeasuresBody.BeddingGrid(0, 0, 0, 2, heights)); + } + + private static Set sample(CoalMeasuresBody body, boolean reverse) { + Set result = new HashSet<>(); + BlockBounds bounds = body.bounds(); + int size = bounds.maximumX() - bounds.minimumX() + 1; + for (int ix = 0; ix < size; ix++) { + int x = reverse ? bounds.maximumX() - ix : bounds.minimumX() + ix; + for (int z = bounds.minimumZ(); z <= bounds.maximumZ(); z++) { + int min = body.minimumCandidateYAt(x, z); + int max = body.maximumCandidateYAt(x, z); + for (int y = min; y <= max; y++) { + if (body.isOreAt(x, y, z)) result.add(new MinePoint(x, y, z).packed()); + } + } + } + return result; + } + + @Test + void missingBeddingCannotFillTheRockBetweenOtherBeds() { + CoalMeasuresBody body = body(); + CoalMeasuresBody.BeddingGrid[] grids = new CoalMeasuresBody.BeddingGrid[body.bedCount()]; + int allowed = 0; + for (int bed = 0; bed < grids.length; bed++) { + float y = bed == 0 ? Float.NaN : (float) (body.centerY() + body.bedOffset(bed)); + grids[bed] = new CoalMeasuresBody.BeddingGrid(body.centerX() - 16, body.centerZ() - 16, + 32, 2, new float[] {y, y, y, y}); + if (bed > 0) allowed += body.bedThickness(bed); + } + CoalMeasuresBody fitted = body.withBedding(grids); + int actual = 0; + for (int y = 8; y <= 128; y++) { + if (fitted.isOreAt(body.centerX(), y, body.centerZ())) actual++; + } + assertTrue(actual <= allowed, "A missing package must stay empty, not become a solid coal column"); + } + + @Test + void thickerBedsHaveLocalRockPartingsAndEdgesThinGradually() { + DepositDefinition definition = definition(); + for (int rx = -10; rx <= 10; rx++) { + Optional planned = CoalMeasuresBody.plan(definition, + 3346071746553592696L, "minecraft:overworld", rx, -1); + if (!planned.isPresent()) continue; + CoalMeasuresBody body = planned.get(); + for (int bed = 0; bed < body.bedCount(); bed++) { + if (body.bedThickness(bed) != 3) continue; + MinePoint split = body.miningRoutePoint(0, body.downDipWidth() * 0.18D); + double centre = body.bedY(bed, split.x(), split.z()); + int rock = (int) Math.floor(centre); + assertFalse(body.isOreAt(split.x(), rock, split.z()), "The parting must be rock"); + assertTrue(body.isOreAt(split.x(), rock - 1, split.z())); + assertTrue(body.isOreAt(split.x(), rock + 1, split.z())); + + MinePoint middle = body.miningRoutePoint(0, 0); + MinePoint edge = body.miningRoutePoint(0, body.downDipWidth() * 0.48D); + assertTrue(columnCoal(body, middle) > columnCoal(body, edge)); + return; + } + } + fail("The deterministic fixture search needs a three-block bed"); + } + + private static int columnCoal(CoalMeasuresBody body, MinePoint point) { + int coal = 0; + for (int y = 8; y <= 128; y++) { + if (body.isOreAt(point.x(), y, point.z())) coal++; + } + return coal; + } +} diff --git a/tools/qualification/COAL_TESTING.md b/tools/qualification/COAL_TESTING.md new file mode 100644 index 0000000..6dd4628 --- /dev/null +++ b/tools/qualification/COAL_TESTING.md @@ -0,0 +1,54 @@ +# Try the coalfields + +Use the published OreSpawn dependency selected by this checkout. Keep these +checks in disposable worlds, separate from any important instance or save. + +1. Run the Gradle task `prepareCoalMeasuresManual`, then refresh the Gradle + project in Eclipse. +2. Launch `runCoalMeasuresClient.launch`. It uses its own `run-coal-measures` + settings and saves, separate from your iron and abandoned-mine tests. +3. Create a normal Creative world with cheats and seed `3346071746553592696`. +4. Enter `/tp 16702 80 18546`. Switch to Spectator with `/gamemode 3` and + descend through roughly Y 60-35 to inspect the beds. +5. Follow a bed sideways. Look for several separate layers, rock between + them, gentle changes in level, fault offsets and thinning near the ends. + Caves and the ground surface can cut through a bed naturally. +6. Return to Creative, mine some coal, and compare an ordinary pickaxe with + Fortune. The blocks should behave like vanilla coal ore. +7. Save and reload. Revisit the beds and explore farther along the field; + existing terrain must not change. + +The isolated profile attempts abandoned workings at every suitable coalfield. +The coal location above is selected for inspecting the beds, not as a promise +of a safe mine entrance. Public defaults keep workings off. Running the prepare +task again does not overwrite settings you have edited or any saves. + +For a coal working in the same normal-world seed, try `/tp 20418 72 53437`. +Its entrance is at Y 68 and the principal gallery is near Y 53. Check the ladder, +walk the short galleries beside the coal, then save and reload before following +the bed into new chunks. These coordinates assume the isolated profile's +workings setting was enabled before you created the world. + +The packaged `coal_110.py` helper separately checks standalone terrain, +Mineralogy strata and reloads. Its reports can be drawn with `coal_maps.py` +(Pillow required) to compare a labelled cross-section with the in-game view. +The helper always requires a new evidence directory and checks the official +OreSpawn release checksum. Test probes never belong in ordinary launches or public jars. + +## What the fixed-seed survey found + +Across 256 regions, 116 passed the initial selection and 24 also passed the +geological checks. Approximate whole-field reserves ranged from 142,080 to +1,027,520 coal blocks before caves, surface height and host rocks reduced them. + +An evenly spaced 81-chunk sample averaged 43.2 coal blocks per chunk with the +new defaults, compared with 187.7 in the matched vanilla-coal control. Two sample +chunks contained at least 400 coal blocks; none did in the control. Coal is much +less evenly spread, so finding a field matters more than those averages suggest. +This is one seed and a limited sample, not a promise for every world. + +Native Fortune III averaged 2.1988 coal items per ore over 10,000 trials. That +would turn those sample averages into roughly 95 and 413 coal items per chunk, +assuming every ore block could be found and mined. Unenchanted mining yields +one item per block. Neither figure includes Silk Touch recycling or a new +processing bonus; the mod adds neither. diff --git a/tools/qualification/MINES_TESTING.md b/tools/qualification/MINES_TESTING.md new file mode 100644 index 0000000..23f7def --- /dev/null +++ b/tools/qualification/MINES_TESTING.md @@ -0,0 +1,47 @@ +# Trying the abandoned mines + +Use this separate profile so your ordinary saves and settings stay untouched. + +1. Refresh the Gradle project in Eclipse, then run **prepareAbandonedMinesManual** + from Gradle Tasks. It prepares a separate configuration with mines enabled + for every suitable deposit. Running it again keeps your existing edits. +2. Launch **runAbandonedMinesClient.launch**. +3. Create a Creative world using seed **3346071746553592696**, normal world type, + structures enabled and cheats allowed. Leave OreSpawn's terrain defaults + unchanged for this comparison. +4. Run `/tp 15222 73 23842`. The tested entrance is around **15222, 68, 23842**, + with the landing around **15222, 49, 23842**. + +Look for the timber-framed opening and ladder. Walk down rather than using +Spectator mode first: check headroom, footing, ladder access and the junctions. +The workings should follow a short part of the seam, with ore still visible in +the surrounding rock. Broken rails, timber supports, cobwebs and small rubble +piles are intentional. An obstruction reported in the log should remain sealed. + +Save, quit and reopen the world. Check that the same entrance and passages are +still there. Place a distinctive block in a finished passage before quitting; +it must remain in place. The automated server checks separately cover mines +whose remaining chunks are not generated until after a restart. + +The profile uses `run-abandoned-mines`. It does not change `run`, `run-major-iron` +or their saved worlds. The prepared catalog uses a chance of `1.0` only to make +testing easier; normal definitions default to disabled, with a suggested chance +of `0.15` when enabled. To check opt-out, change `enabled` to `false`, restart +and create another new world. Existing workings are not removed. + +## Automated checks + +The separate `prepareMineQualification` task, supplied by +`tools/qualification/probes.init.gradle`, builds a packaged-server probe. Run +`mines_110.py --help` for its inputs. Use the exact public OreSpawn jar and an +official Forge server installation with an already accepted EULA. + +Each run requires a new output directory and retains failed worlds and logs. +The probe is not included in public jars or the ordinary Eclipse profiles. + +The checks include normal and flat terrain, generation in reversed and shuffled +chunk orders, reloads, unfinished sections, disabled settings, underwater sites, +known structure boxes and a chest obstructing a planned tunnel. The separate +`mines_upgrade_110.py` helper creates worlds with the published 0.1.1 alpha, +then upgrades disposable copies. It checks old terrain and chest contents on +first load, reload and when a previously unopened saved chunk is populated. diff --git a/tools/qualification/README.md b/tools/qualification/README.md index a9a976d..e0852c1 100644 --- a/tools/qualification/README.md +++ b/tools/qualification/README.md @@ -29,6 +29,29 @@ The fixture uses a stone-filled flat world. Ordinary iron remains below Y 96; the test deposits are at Y 100–119, so the two sources can be counted independently. These are test settings, not new gameplay defaults. +## Coalfield checks + +Coalfield checks use the published OreSpawn 4.1.4.110021 dependency and its +official checksum. The exact-host API is included in that release; a local +OreSpawn build is not needed. + +Use `qualify_110.py --resource coal` to run the same policy/import matrix for +coal. Its controlled beds are at Y 180-199 in a stone-filled test world, above +ordinary background coal. This separates the two sources without changing the +normal coalfield height range. + +`coal_110.py` checks normal and flat terrain, optional Mineralogy strata, inactive +strata, structure exclusion and coal workings through first load, reload and +later chunks. `coal_upgrade_110.py` compares disposable old iron-only worlds with +old-build controls, including legacy coal rock layers. Neither opens a player's +world. All helpers require new output directories and preserve failed evidence. + +`coal_survey_110.py` compares a widely spaced fixed sample with a matched world +whose coalfields are disabled. It also records regional selection, approximate +whole-field reserves and native Fortune III rewards. Its reserve estimate is +before cave, surface and host rejection, not a guarantee of recoverable coal. +See the [coal walkthrough](COAL_TESTING.md) for the isolated visual-test launch. + ## Run it For the imported-profile editor check, use the separate @@ -50,10 +73,11 @@ 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.3.110021; its official checksum is checked | +| `--orespawn` | Public OreSpawn 4.1.4.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 | +| `--base-port` | First loopback port; use different ranges if running coal and iron matrices together | Supply absolute paths. No third-party Python packages are needed. On Windows, servers run without console windows, with literal `nogui`, on separate diff --git a/tools/qualification/ci_runtime.py b/tools/qualification/ci_runtime.py index 8cdfbcd..93e7261 100644 --- a/tools/qualification/ci_runtime.py +++ b/tools/qualification/ci_runtime.py @@ -54,7 +54,7 @@ def main(): 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") - orespawn = output / "OreSpawn-4.1.3.110021.jar" + 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"]) diff --git a/tools/qualification/coal_110.py b/tools/qualification/coal_110.py new file mode 100644 index 0000000..9b62618 --- /dev/null +++ b/tools/qualification/coal_110.py @@ -0,0 +1,147 @@ +"""Check coal beds on disposable Forge servers with the checksum-verified OreSpawn jar.""" + +import argparse +import copy +import json +import shutil +import subprocess +from pathlib import Path + +from ci_runtime import FORGE_HASH +from qualify_110 import FORGE_JAR, checked_orespawn_sha256, digest, write_json + + +ROOT = Path(__file__).resolve().parents[2] + + +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.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: + process = subprocess.Popen(command, cwd=root, stdout=output, stderr=subprocess.STDOUT, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) + try: + code = process.wait(timeout=300) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + raise AssertionError(f"{root.name}/{phase} timed out; keep its evidence") + log = (root / f"{phase}.log").read_text(encoding="utf-8", errors="replace") + assert code == 0 and f"RD_COAL_PASS {phase}" in log, f"{root.name}/{phase} failed" + 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] + 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 {root.name}/{phase}", flush=True) + return json.loads((root / f"coal-{phase}.json").read_text(encoding="utf-8")) + + +def prepare(args, root, mineralogy=False, normal=False): + root.mkdir() + shutil.copytree(args.runtime / "libraries", root / "libraries") + for name in (FORGE_JAR, "minecraft_server.1.10.2.jar", "eula.txt"): + shutil.copy2(args.runtime / name, root / name) + mods = root / "mods" + mods.mkdir() + for source in (args.mod, args.orespawn, args.probe): + shutil.copy2(source, mods / source.name) + if mineralogy: + assert args.mineralogy, "Supply the exact compatible Mineralogy jar" + shutil.copy2(args.mineralogy, mods / args.mineralogy.name) + settings = ( + "level-name=coal-test-world\nlevel-seed=3346071746553592696\n" + f"level-type={'DEFAULT' if normal else 'FLAT'}\n" + "generator-settings=3;minecraft:bedrock,127*minecraft:stone,minecraft:grass;1;decoration\n" + f"server-ip=127.0.0.1\nserver-port={getattr(args, 'port', 48771)}\nonline-mode=false\ngenerate-structures=false\n" + "view-distance=3\nmax-tick-time=-1\nallow-nether=false\nspawn-animals=false\nspawn-monsters=false\n" + ) + (root / "server.properties").write_text(settings, encoding="utf-8", newline="\n") + for filename in ("ops.json", "whitelist.json", "banned-ips.json", "banned-players.json"): + write_json(root / filename, []) + return root + + +def same(left, right): + for key in ("terrain_sha256", "coal_sha256", "coal_in_body", "coal_outside_body", "planned_positions"): + assert left[key] == right[key], f"Reload 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("--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"), + default=("standalone", "normal", "mineralogy")) + 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) + assert (args.runtime / "eula.txt").read_text().strip() == "eula=true" + args.output.mkdir(parents=True) + inputs = args.output / "inputs" + inputs.mkdir() + for source in (args.mod, args.orespawn, args.probe, args.mineralogy): + if source: + shutil.copy2(source, inputs / source.name) + write_json(args.output / "artifacts.json", {path.name: digest(path) for path in inputs.iterdir()}) + # Every server uses the immutable copies, even if a later build refreshes build/libs. + for name in ("mod", "orespawn", "probe", "mineralogy"): + source = getattr(args, name) + if source: + setattr(args, name, inputs / source.name) + 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"): + coal = json.loads((ROOT / "src/main/resources/assets/realisticdeposits/defaults/coal.json").read_text(encoding="utf-8")) + if case == "workings": + coal["deposits"][0]["abandoned_mine"] = {"enabled": True, "chance": 1.0} + else: + coal["deposits"][0]["interaction"] = "replacement" + coal["deposits"][0].pop("background_generation_scale") + write_json(root / "config/realisticdeposits/deposits/coal.json", coal) + if case in ("inactive", "legacy"): + assert args.geology_profile, "Supply the sealed, read-only active-strata reference" + profile = json.loads(args.geology_profile.read_text(encoding="utf-8")) + if case == "inactive": + profile["terrain_dimensions"]["minecraft:overworld"]["enabled"] = False + else: + profile["geology_mode"] = "legacy" + profile["cyano"]["realistic_coal_layers"] = True + 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) + coal = json.loads((ROOT / "src/main/resources/assets/realisticdeposits/defaults/coal.json").read_text()) + # A legacy profile has one named geome, not the modern basin map. + coal["deposits"][0]["geomes"].append("orespawn:cyano") + write_json(root / "config/realisticdeposits/deposits/coal.json", coal) + write_json(root / "config/orespawn-worldgen.json", profile) + first = run(args.java, root, "first", case) + assert first["physical_strata"] == (case in ("mineralogy", "legacy")), "Unexpected terrain-replacement state" + assert first["legacy_coal_layers"] == (case == "legacy") + profile = root / "coal-test-world/serverconfig/orespawn-worldgen.json" + profile_hash = digest(profile) + for phase in ("reload", "later", "stable"): + report = run(args.java, root, phase, case) + same(first["first"], report["first"]) + assert digest(profile) == profile_hash, "The saved profile changed" + if phase == "later": + later = report["later"] + elif phase == "stable": + same(later, report["later"]) + results.append({"case": case, "passed": True, "coal_in_sample": first["first"]["coal_in_body"], + "profile_sha256": profile_hash}) + write_json(args.output / "results.json", {"passed": True, "cases": results}) + + +if __name__ == "__main__": + main() diff --git a/tools/qualification/coal_maps.py b/tools/qualification/coal_maps.py new file mode 100644 index 0000000..59a4ef8 --- /dev/null +++ b/tools/qualification/coal_maps.py @@ -0,0 +1,61 @@ +"""Draw labelled coalfield cross-sections from the packaged probe's saved reports.""" + +import argparse +import json +from pathlib import Path + +from PIL import Image, ImageDraw + + +COLOURS = { + "minecraft:air": "#eff5f8", "minecraft:coal_ore": "#101820", "minecraft:stone": "#96938e", + "minecraft:water": "#6086bf", "minecraft:flowing_water": "#6086bf", + "minecraft:lava": "#f08732", "minecraft:flowing_lava": "#f08732", + "minecraft:sandstone": "#dbc898", "minecraft:grass": "#78964e", "minecraft:dirt": "#957156", + "mineralogy:shale": "#71645d", "mineralogy:siltstone": "#aa866d", + "mineralogy:conglomerate": "#8b7965", "mineralogy:limestone": "#bbc5aa", + "mineralogy:dolomite": "#bdafa4", "mineralogy:rock_salt": "#d9ccd3", + "mineralogy:schist": "#747e7a", "mineralogy:slate": "#56616b", "mineralogy:basalt": "#45454a", +} + + +def draw(report_path, output): + report = json.loads(report_path.read_text(encoding="utf-8"))["first"] + cells = report["section"] + low = min(cell[0] for cell in cells) + high = max(cell[0] for cell in cells) + scale = 4 + left, top = 65, 55 + image = Image.new("RGB", ((high - low + 1) * scale + 95, 141 * scale + 180), "white") + paint = ImageDraw.Draw(image) + for x, y, block in cells: + px, py = left + (x - low) * scale, top + (140 - y) * scale + paint.rectangle((px, py, px + scale - 1, py + scale - 1), fill=COLOURS.get(block, "#aaa8a0")) + paint.text((left, 12), f"{report_path.parent.name}: coal beds and surrounding rock", fill="black") + paint.text((left, 29), f"X / height section at Z {report['centre_z']}; coal is black", fill="black") + for y in (128, 96, 64, 32, 8): + paint.text((12, top + (140 - y) * scale - 5), f"Y {y}", fill="black") + for x in (low, report["centre_x"], high): + paint.text((left + (x - low) * scale - 15, top + 141 * scale + 8), str(x), fill="black") + used = sorted({cell[2] for cell in cells if cell[2] in COLOURS}) + for index, block in enumerate(used): + x = 12 + (index % 3) * 195 + y = top + 141 * scale + 35 + (index // 3) * 17 + paint.rectangle((x, y, x + 10, y + 10), fill=COLOURS[block]) + paint.text((x + 15, y), block.split(":")[1], fill="black") + image.save(output) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--input", required=True, type=Path) + parser.add_argument("--output", required=True, type=Path) + args = parser.parse_args() + assert not args.output.exists(), "Keep earlier maps and choose a new directory" + args.output.mkdir(parents=True) + for report in sorted(args.input.glob("*/coal-first.json")): + draw(report, args.output / (report.parent.name + ".png")) + + +if __name__ == "__main__": + main() diff --git a/tools/qualification/coal_survey_110.py b/tools/qualification/coal_survey_110.py new file mode 100644 index 0000000..3d866d7 --- /dev/null +++ b/tools/qualification/coal_survey_110.py @@ -0,0 +1,50 @@ +"""Compare a fixed, widely spaced coal sample with an otherwise matched vanilla-coal control.""" + +import argparse +import json +import shutil +from pathlib import Path + +from coal_110 import ROOT, prepare, run +from qualify_110 import FORGE_JAR, checked_orespawn_sha256, digest, write_json +from ci_runtime import FORGE_HASH + + +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("--local-orespawn-sha256", help="Explicit checksum for an optional unpublished test candidate") + args = parser.parse_args() + args.mineralogy = None + args.port = 48775 + 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"): + source = getattr(args, name) + target = inputs / source.name + shutil.copy2(source, target) + setattr(args, name, target) + write_json(args.output / "artifacts.json", {path.name: digest(path) for path in inputs.iterdir()}) + results = {} + for enabled in (False, True): + label = "coalfields" if enabled else "vanilla-control" + root = prepare(args, args.output / label, normal=True) + coal = json.loads((ROOT / "src/main/resources/assets/realisticdeposits/defaults/coal.json").read_text()) + coal["deposits"][0]["enabled"] = enabled + write_json(root / "config/realisticdeposits/deposits/coal.json", coal) + run(args.java, root, "survey", "survey") + results[label] = json.loads((root / "coal-survey.json").read_text()) + for key in ("regions", "selected_before_geology", "accepted_fields", "estimated_field_reserves"): + assert results["coalfields"][key] == results["vanilla-control"][key] + assert results["coalfields"]["accepted_fields"] > 0 + assert results["vanilla-control"]["actual_sample_coal"] > 0 + write_json(args.output / "results.json", {"passed": True, "results": results}) + + +if __name__ == "__main__": + main() diff --git a/tools/qualification/coal_upgrade_110.py b/tools/qualification/coal_upgrade_110.py new file mode 100644 index 0000000..4d52c39 --- /dev/null +++ b/tools/qualification/coal_upgrade_110.py @@ -0,0 +1,110 @@ +"""Compare an old iron-only save with disposable control and upgraded copies.""" + +import argparse +import json +import shutil +import subprocess +from pathlib import Path + +from qualify_110 import FORGE_JAR, checked_orespawn_sha256, digest, world_fingerprint, write_json +from ci_runtime import FORGE_HASH + + +OLD_MINE_BUILD = "58BAA8A5A69B904ED18387C48D8E4CE63B1C411A6DF06C3056B720C6228604A5" + + +def run(java, root, phase): + command = [str(java), "-Xms256m", "-Xmx2G", "-XX:ActiveProcessorCount=2", + "-Drd.coal.upgrade=true", f"-Drd.coal.phase={phase}", "-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=240) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + raise AssertionError("Upgrade fixture timed out; retain its evidence") + text = (root / f"{phase}.log").read_text(encoding="utf-8", errors="replace") + assert code == 0 and f"RD_COAL_UPGRADE_PASS {phase}" in text, f"{root.name}/{phase} failed" + assert not [line for line in text.splitlines() if ("/ERROR]" in line or "/FATAL]" in line) + and "FML appears to be missing any signature data" not in line], text[-4000:] + assert not list((root / "crash-reports").glob("*")) + shutil.copy2(root / "logs/fml-server-latest.log", root / f"{phase}.fml.log") + print(f"PASS {root.name}/{phase}", flush=True) + return json.loads((root / f"coal-upgrade-{phase}.json").read_text(encoding="utf-8")) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + for name in ("java", "runtime", "mod", "old-mod", "orespawn", "probe", "output"): + parser.add_argument("--" + name, required=True, type=Path) + parser.add_argument("--local-orespawn-sha256", help="Explicit checksum for an optional unpublished test candidate") + 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") + args = parser.parse_args() + assert not args.output.exists(), "Keep old evidence and choose a new output" + assert digest(args.old_mod) == OLD_MINE_BUILD + assert digest(args.runtime / FORGE_JAR) == FORGE_HASH + checked_orespawn_sha256(args.orespawn, args.local_orespawn_sha256) + args.output.mkdir(parents=True) + root = args.output / "source" + root.mkdir() + shutil.copytree(args.runtime / "libraries", root / "libraries") + for name in (FORGE_JAR, "minecraft_server.1.10.2.jar", "eula.txt"): + shutil.copy2(args.runtime / name, root / name) + (root / "mods").mkdir() + for source in (args.old_mod, args.orespawn, args.probe): + shutil.copy2(source, root / "mods" / source.name) + if args.mineralogy: + shutil.copy2(args.mineralogy, root / "mods" / args.mineralogy.name) + if args.legacy_profile: + profile = json.loads(args.legacy_profile.read_text(encoding="utf-8")) + profile["ores"] = {key: value for key, value in profile["ores"].items() + if not key.startswith("realisticdeposits:")} + profile.pop("ore_sources", None) + profile["geology_mode"] = "legacy" + profile["cyano"].update(realistic_coal_layers=True, + sedimentary_rocks=["mineralogy:shale", "minecraft:coal_ore", + "mineralogy:siltstone", "minecraft:sandstone"]) + write_json(root / "config/orespawn-worldgen.json", profile) + (root / "server.properties").write_text( + "level-name=coal-upgrade-world\nlevel-seed=3346071746553592696\nlevel-type=FLAT\n" + "generator-settings=3;minecraft:bedrock,127*minecraft:stone,minecraft:grass;1;decoration\n" + "server-ip=127.0.0.1\nserver-port=48773\nonline-mode=false\ngenerate-structures=false\n" + "view-distance=3\nmax-tick-time=-1\nallow-nether=false\nspawn-animals=false\nspawn-monsters=false\n", + encoding="utf-8", newline="\n") + for name in ("ops.json", "whitelist.json", "banned-ips.json", "banned-players.json"): + write_json(root / name, []) + first = run(args.java, root, "first") + source_hash = world_fingerprint(root / "coal-upgrade-world") + profile_relative = "coal-upgrade-world/serverconfig/orespawn-worldgen.json" + original_profile = digest(root / profile_relative) + original_config = digest(root / "config/realisticdeposits/deposits/vanilla.json") + completed = {} + for name in ("control", "upgraded"): + target = args.output / name + shutil.copytree(root, target) + if name == "upgraded": + # Both local candidates have the same unreleased product version. + shutil.copy2(args.mod, target / "mods" / args.old_mod.name) + report = run(args.java, target, "complete") + reload = run(args.java, target, "reload") + assert first["old_terrain"] == report["old_terrain"] == reload["old_terrain"] + assert first["chest"] == report["chest"] == reload["chest"] + assert report["raw_marker"] and reload["raw_marker"] + assert report["later_terrain"] == reload["later_terrain"] + assert digest(target / profile_relative) == original_profile, "World settings were changed" + assert digest(target / "config/realisticdeposits/deposits/vanilla.json") == original_config + profile = json.loads((target / profile_relative).read_text(encoding="utf-8")) + assert not any("overworld_coal_measures" in key for key in profile["ores"]), "Coal was injected into an old world" + completed[name] = report + assert completed["control"] == completed["upgraded"], "The update changed later-generated terrain or coal supply" + assert world_fingerprint(root / "coal-upgrade-world") == source_hash + write_json(args.output / "results.json", {"passed": True, "boots": 5, "source_unchanged": True, + "control_matches_upgrade": True}) + + +if __name__ == "__main__": + main() diff --git a/tools/qualification/mines_110.py b/tools/qualification/mines_110.py new file mode 100644 index 0000000..7772a21 --- /dev/null +++ b/tools/qualification/mines_110.py @@ -0,0 +1,106 @@ +"""Check abandoned workings on disposable, exact packaged Forge 1.10.2 servers.""" + +import argparse +import json +import shutil +import subprocess +from pathlib import Path + +from qualify_110 import checked_orespawn_sha256, FORGE_JAR, digest, write_json +from ci_runtime import FORGE_HASH + + +ROOT = Path(__file__).resolve().parents[2] + + +def run(java, root, phase, properties=None): + command = [str(java), "-Xms256m", "-Xmx2G", "-XX:ActiveProcessorCount=2", + "-Drd.mines.enabled=true", f"-Drd.mines.phase={phase}"] + command.extend(f"-D{key}={value}" for key, value in (properties or {}).items()) + command.extend(["-jar", FORGE_JAR, "nogui"]) + with (root / f"{phase}.log").open("w", encoding="utf-8") as log: + process = subprocess.Popen(command, cwd=root, stdout=log, stderr=subprocess.STDOUT, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) + try: + code = process.wait(timeout=240) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + raise AssertionError(f"{root.name}/{phase} timed out; its evidence is retained") + + log = (root / f"{phase}.log").read_text(encoding="utf-8", errors="replace") + assert code == 0 and f"RD_MINES_PASS {phase}" in log, f"{root.name}/{phase} failed" + 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 and not list((root / "crash-reports").glob("*")), errors + assert (root / f"mine-{phase}-pass.properties").is_file(), "Missing packaged probe receipt" + fml = root / "logs/fml-server-latest.log" + if fml.is_file(): + shutil.copy2(fml, root / f"{phase}.fml.log") + print(f"PASS {root.name}/{phase}", flush=True) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + for name in ("java", "runtime", "mod", "orespawn", "probe", "output"): + parser.add_argument(f"--{name}", required=True, type=Path) + cases = ["flat", "normal", "off", "chance-zero", "wet", "order-reverse", "order-shuffle", "structure", "blocked"] + parser.add_argument("--cases", nargs="+", default=cases, choices=cases) + parser.add_argument("--local-orespawn-sha256", help="Exact checksum of the deliberately selected local candidate") + args = parser.parse_args() + output = args.output.resolve() + assert not output.exists(), "Choose a new evidence directory; previous results are never overwritten" + assert digest(args.runtime / FORGE_JAR) == FORGE_HASH, "Unexpected Forge server" + checked_orespawn_sha256(args.orespawn, args.local_orespawn_sha256) + assert (args.runtime / "eula.txt").read_text().strip() == "eula=true", "Use an already accepted runtime" + output.mkdir(parents=True) + sealed = output / "inputs" + sealed.mkdir() + for source in (args.mod, args.orespawn, args.probe): + shutil.copy2(source, sealed / source.name) + write_json(output / "artifacts.json", {path.name: digest(path) for path in sealed.iterdir()}) + + results = [] + for index, case in enumerate(args.cases): + root = output / case + root.mkdir() + shutil.copytree(args.runtime / "libraries", root / "libraries") + for name in (FORGE_JAR, "minecraft_server.1.10.2.jar", "eula.txt"): + shutil.copy2(args.runtime / name, root / name) + mods = root / "mods" + mods.mkdir() + for artifact in sealed.iterdir(): + shutil.copy2(artifact, mods / artifact.name) + layers = "minecraft:bedrock,127*minecraft:stone,minecraft:grass" + if case == "wet": + layers = "minecraft:bedrock,127*minecraft:stone,20*minecraft:water" + level_type = "DEFAULT" if case == "normal" else "FLAT" + generator_settings = "" if case == "normal" else f"3;{layers};1;" + settings = ( + f"level-name=mine-test-world\nlevel-seed=3346071746553592696\nlevel-type={level_type}\n" + f"generator-settings={generator_settings}\nserver-ip=127.0.0.1\nserver-port={48610 + index}\n" + f"online-mode=false\ngenerate-structures={'true' if case == 'normal' else 'false'}\nview-distance=3\nmax-tick-time=-1\n" + "allow-nether=false\nspawn-animals=false\nspawn-monsters=false\n" + ) + (root / "server.properties").write_text(settings, encoding="utf-8", newline="\n") + catalog = json.loads((ROOT / "src/main/resources/assets/realisticdeposits/defaults/vanilla.json") + .read_text(encoding="utf-8")) + catalog["deposits"][0]["abandoned_mine"] = { + "enabled": case != "off", "chance": 0.0 if case == "chance-zero" else 1.0} + write_json(root / "config/realisticdeposits/deposits/vanilla.json", catalog) + if case == "flat": + phases = ["first", "complete", "reload"] + elif case == "normal": + phases = ["default-first", "default-reload"] + elif case.startswith("order-") or case in ("structure", "blocked"): + phases = [case] + else: + phases = ["wet" if case == "wet" else "off"] + for phase in phases: + run(args.java, root, phase) + results.append({"case": case, "phase": phase, "passed": True}) + write_json(output / "results.json", {"passed": True, "boots": len(results), "results": results}) + + +if __name__ == "__main__": + main() diff --git a/tools/qualification/mines_upgrade_110.py b/tools/qualification/mines_upgrade_110.py new file mode 100644 index 0000000..1598d1b --- /dev/null +++ b/tools/qualification/mines_upgrade_110.py @@ -0,0 +1,147 @@ +"""Upgrade disposable old-alpha worlds without opening a user's saved world.""" + +import argparse +import json +import shutil +import subprocess +from pathlib import Path + +from qualify_110 import checked_orespawn_sha256, digest, world_fingerprint, write_json, FORGE_JAR +from ci_runtime import FORGE_HASH +from mines_110 import run + + +ROOT = Path(__file__).resolve().parents[2] +OLD_ALPHA_SHA256 = "ACBBE6BB44E72500262F7D43DA7720EF15FCF402BD1A37BA4263707F8CC185BB" + + +def properties(path): + return dict(line.strip().split("=", 1) for line in path.read_text().splitlines() + if "=" in line and not line.startswith("#")) + + +def edit_definition(value): + count = 0 + if isinstance(value, dict): + definition = value.get("definition") + if isinstance(definition, dict) and definition.get("id") == "realisticdeposits:overworld_iron_formation": + definition["abandoned_mine"] = {"enabled": True, "chance": 1.0} + count += 1 + for child in value.values(): + count += edit_definition(child) + elif isinstance(value, list): + for child in value: + count += edit_definition(child) + return count + + +def expected_upgrade_profile(value): + if isinstance(value, dict): + if value.get("owner") == "realisticdeposits" and value.get("owner_version") == "0.1.1.110021": + value["owner_version"] = "0.2.0.110021" + for child in value.values(): + expected_upgrade_profile(child) + elif isinstance(value, list): + for child in value: + expected_upgrade_profile(child) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + for name in ("java", "runtime", "mod", "old-mod", "orespawn", "probe", "legacy-probe", "reference", "output"): + parser.add_argument(f"--{name}", required=True, type=Path) + parser.add_argument("--local-orespawn-sha256") + args = parser.parse_args() + output = args.output.resolve() + assert not output.exists(), "Choose a new evidence directory" + assert digest(args.old_mod) == OLD_ALPHA_SHA256, "The old fixture must use the exact public 0.1.1 alpha" + assert digest(args.runtime / FORGE_JAR) == FORGE_HASH, "Unexpected Forge runtime" + checked_orespawn_sha256(args.orespawn, args.local_orespawn_sha256) + receipt = properties(args.reference) + marker = [int(part) for part in receipt["legacy_marker"].split(",")] + raw = [int(part) for part in receipt["legacy_raw_marker"].split(",")] + output.mkdir(parents=True) + inputs = output / "inputs" + inputs.mkdir() + for role, source in (("current", args.mod), ("old", args.old_mod), ("orespawn", args.orespawn), + ("probe", args.probe), ("legacy", args.legacy_probe)): + target = inputs / f"{role}.jar" + shutil.copy2(source, target) + write_json(output / "artifacts.json", {path.name: digest(path) for path in inputs.iterdir()}) + baseline = output / "old-alpha" + baseline.mkdir() + shutil.copytree(args.runtime / "libraries", baseline / "libraries") + for name in (FORGE_JAR, "minecraft_server.1.10.2.jar", "eula.txt"): + shutil.copy2(args.runtime / name, baseline / name) + mods = baseline / "mods" + mods.mkdir() + for name in ("old", "orespawn", "legacy"): + shutil.copy2(inputs / f"{name}.jar", mods / f"{name}.jar") + settings = ( + "level-name=mine-test-world\nlevel-seed=3346071746553592696\nlevel-type=FLAT\n" + "generator-settings=3;minecraft:bedrock,127*minecraft:stone,minecraft:grass;1;\n" + "server-ip=127.0.0.1\nserver-port=48640\nonline-mode=false\ngenerate-structures=false\n" + "view-distance=3\nmax-tick-time=-1\nallow-nether=false\nspawn-animals=false\nspawn-monsters=false\n" + ) + (baseline / "server.properties").write_text(settings, encoding="utf-8", newline="\n") + catalog = json.loads((ROOT / "src/main/resources/assets/realisticdeposits/defaults/vanilla.json") + .read_text(encoding="utf-8")) + catalog["deposits"][0].pop("abandoned_mine", None) + write_json(baseline / "config/realisticdeposits/deposits/vanilla.json", catalog) + command = [str(args.java), "-Xms256m", "-Xmx2G", "-XX:ActiveProcessorCount=2", "-Drd.mines.legacy=true"] + for key, value in zip(("markerX", "markerY", "markerZ", "rawX", "rawY", "rawZ"), marker + raw): + command.append(f"-Drd.mines.legacy.{key}={value}") + command.extend(["-jar", FORGE_JAR, "nogui"]) + with (baseline / "baseline.log").open("w", encoding="utf-8") as log: + process = subprocess.Popen(command, cwd=baseline, stdout=log, stderr=subprocess.STDOUT, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) + try: + code = process.wait(timeout=240) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + raise AssertionError("The old-alpha fixture timed out") + log = (baseline / "baseline.log").read_text(encoding="utf-8", errors="replace") + assert code == 0 and "RD_MINES_LEGACY_PASS" in log, "Old-alpha fixture failed" + 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 and not list((baseline / "crash-reports").glob("*")), errors + fml = baseline / "logs/fml-server-latest.log" + if fml.is_file(): + shutil.copy2(fml, baseline / "baseline.fml.log") + before = world_fingerprint(baseline) + results = [] + for explicit in (False, True): + name = "explicit-opt-in" if explicit else "inherited-profile" + target = output / name + shutil.copytree(baseline, target, ignore=shutil.ignore_patterns("mods", "logs", "*.log")) + mods = target / "mods" + mods.mkdir() + for role in ("current", "orespawn", "probe", "legacy"): + shutil.copy2(inputs / f"{role}.jar", mods / f"{role}.jar") + (target / "server.properties").write_text(settings.replace("48640", "48642" if explicit else "48641"), + encoding="utf-8", newline="\n") + changed = json.loads(json.dumps(catalog)) + changed["deposits"][0]["abandoned_mine"] = {"enabled": True, "chance": 1.0} + write_json(target / "config/realisticdeposits/deposits/vanilla.json", changed) + profile_path = target / "mine-test-world/serverconfig/orespawn-worldgen.json" + if explicit: + profile = json.loads(profile_path.read_text(encoding="utf-8")) + assert edit_definition(profile) == 1, "Expected one captured deposit definition" + write_json(profile_path, profile) + expected_profile = json.loads(profile_path.read_text(encoding="utf-8")) + expected_upgrade_profile(expected_profile) + profile_after_upgrade = None + for phase in ("upgrade-first", "upgrade-reload", "upgrade-later"): + run(args.java, target, phase, {"rd.mines.upgrade.optIn": str(explicit).lower()}) + assert json.loads(profile_path.read_text(encoding="utf-8")) == expected_profile, "Saved generation settings changed" + if profile_after_upgrade is None: + profile_after_upgrade = digest(profile_path) + assert digest(profile_path) == profile_after_upgrade, "Reload rewrote the upgraded profile" + results.append({"case": name, "phase": phase, "passed": True}) + assert world_fingerprint(baseline) == before, "The old source fixture was modified" + write_json(output / "results.json", {"passed": True, "boots": len(results) + 1, "results": results}) + + +if __name__ == "__main__": + main() diff --git a/tools/qualification/probes.init.gradle b/tools/qualification/probes.init.gradle index 269f7d7..21ac954 100644 --- a/tools/qualification/probes.init.gradle +++ b/tools/qualification/probes.init.gradle @@ -18,5 +18,72 @@ gradle.afterProject { project, state -> description = 'Builds the isolated packaged-server policy probe.' dependsOn packaged } + + def coalInput = project.tasks.register('coalQualificationProbeInput', Jar) { + dependsOn project.tasks.named('testClasses') + from project.sourceSets.test.output.classesDirs + include 'zone/moddev/mc/realisticdeposits/generation/CoalRuntimeProbe*.class' + archiveFileName = 'coal-probe-deobf.jar' + destinationDirectory = project.layout.buildDirectory.dir('qualification') + } + def coalPackaged = project.renamer.classes(coalInput) { + map.from project.minecraft.dependency.toSrgFile + output = project.layout.buildDirectory.file('qualification/coal-probe.jar') + } + project.tasks.register('prepareCoalQualification') { + group = 'verification' + description = 'Builds the isolated coalfield server probe.' + dependsOn coalPackaged + } + + def coalUpgradeInput = project.tasks.register('coalUpgradeProbeInput', Jar) { + dependsOn project.tasks.named('testClasses') + from project.sourceSets.test.output.classesDirs + include 'zone/moddev/mc/realisticdeposits/generation/CoalUpgradeProbe*.class' + archiveFileName = 'coal-upgrade-probe-deobf.jar' + destinationDirectory = project.layout.buildDirectory.dir('qualification') + } + def coalUpgradePackaged = project.renamer.classes(coalUpgradeInput) { + map.from project.minecraft.dependency.toSrgFile + output = project.layout.buildDirectory.file('qualification/coal-upgrade-probe.jar') + } + project.tasks.register('prepareCoalUpgradeQualification') { + group = 'verification' + description = 'Builds the old/new coal upgrade fixture without new product API links.' + dependsOn coalUpgradePackaged + } + + def mineInput = project.tasks.register('mineQualificationProbeInput', Jar) { + dependsOn project.tasks.named('testClasses') + from project.sourceSets.test.output.classesDirs + include 'zone/moddev/mc/realisticdeposits/generation/MineWorkingsRuntimeProbe*.class' + archiveFileName = 'mine-probe-deobf.jar' + destinationDirectory = project.layout.buildDirectory.dir('qualification') + } + + def minePackaged = project.renamer.classes(mineInput) { + map.from project.minecraft.dependency.toSrgFile + output = project.layout.buildDirectory.file('qualification/mine-probe.jar') + } + + def oldTerrainInput = project.tasks.register('legacyMineProbeInput', Jar) { + dependsOn project.tasks.named('testClasses') + from project.sourceSets.test.output.classesDirs + include 'zone/moddev/mc/realisticdeposits/generation/MineLegacyTerrainProbe*.class' + archiveFileName = 'legacy-mine-probe-deobf.jar' + destinationDirectory = project.layout.buildDirectory.dir('qualification') + } + + def oldTerrainPackaged = project.renamer.classes(oldTerrainInput) { + map.from project.minecraft.dependency.toSrgFile + output = project.layout.buildDirectory.file('qualification/legacy-mine-probe.jar') + } + + project.tasks.register('prepareMineQualification') { + group = 'verification' + description = 'Builds the isolated abandoned-mine server probe.' + dependsOn minePackaged + dependsOn oldTerrainPackaged + } } } diff --git a/tools/qualification/qualify_110.py b/tools/qualification/qualify_110.py index 95da4a8..5fcd8db 100644 --- a/tools/qualification/qualify_110.py +++ b/tools/qualification/qualify_110.py @@ -11,11 +11,12 @@ from pathlib import Path -ORESPAWN_SHA256 = "FA1F4CAE66374E32162EC9543FF2BFBA236934C9E44D3F6AD882BC00761C15EF" +ORESPAWN_SHA256 = "DB254E9E865685A74262DE9D469A006AB3C8053573CC6136FD4A8591AD806CA3" 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") DEPOSIT_ID = "realisticdeposits:deposit/realisticdeposits/qualification_iron" +RESOURCE_KEY = "iron_ore" def digest(path): @@ -47,7 +48,7 @@ def write_json(path, value): def imported_rule(frequency=6.5): return { "enabled": True, - "block": "minecraft:iron_ore", + "block": "minecraft:" + RESOURCE_KEY, "source_mod": "orespawn", "native_generation": False, "retrogen": False, @@ -76,7 +77,7 @@ def prepare(args, name, origin, policy, managed, port, bootstrap=False): settings = ( "level-name=world\nlevel-seed=3346071746553592696\nlevel-type=FLAT\n" - "generator-settings=3;minecraft:bedrock,127*minecraft:stone,minecraft:grass;1;decoration\n" + f"generator-settings=3;minecraft:bedrock,{239 if RESOURCE_KEY == 'coal_ore' else 127}*minecraft:stone,minecraft:grass;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" @@ -92,12 +93,26 @@ def prepare(args, name, origin, policy, managed, port, bootstrap=False): write_json(root / "config/orespawn-worldgen.json", global_config) catalog = json.loads((Path(__file__).parent / "deposit.json").read_text(encoding="utf-8")) definition = catalog["deposits"][0] + definition["id"] = "realisticdeposits:qualification_" + RESOURCE_KEY.removesuffix("_ore") + definition["resource"] = "minecraft:" + RESOURCE_KEY + if RESOURCE_KEY == "coal_ore": + # Keep the controlled deposit above vanilla coal so both supplies can be counted separately. + definition.update(min_y=180, max_y=199) + definition["shape"] = { + "type": "coal_measures", "strike_length": [64, 96], "down_dip_width": [16, 24], + "beds": [2, 2], "thickness": [1, 1], "rock_interval": [2, 2], + "maximum_dip_degrees": 0, "fold_amplitude": 0, "maximum_fault_throw": 0, + } definition["interaction"] = "hybrid" if policy.startswith("hybrid") else policy if policy == "hybrid-15": definition["background_generation_scale"] = 0.15 write_json(root / "config/realisticdeposits/deposits/vanilla.json", catalog) + coal = json.loads((Path(__file__).parents[2] / "src/main/resources/assets/realisticdeposits/defaults/coal.json") + .read_text(encoding="utf-8")) + coal["deposits"][0]["enabled"] = False + write_json(root / "config/realisticdeposits/deposits/coal.json", coal) sources = {} if origin != "native": @@ -111,7 +126,7 @@ def prepare(args, name, origin, policy, managed, port, bootstrap=False): if origin == "os1": source = root / "config/orespawn/orespawn.json" write_json(source, {"dimensions": [{"dimension": "0", "ores": [{ - "blockID": "minecraft:iron_ore", "size": 5, "variation": 0, + "blockID": "minecraft:" + RESOURCE_KEY, "size": 5, "variation": 0, "frequency": 3.25, "minHeight": 12, "maxHeight": 19, }]}]}) else: @@ -121,12 +136,12 @@ def prepare(args, name, origin, policy, managed, port, bootstrap=False): "dimensions": [0], "biomes": {"excludes": []}, "parameters": {"frequency": 3.25, "size": 5, "variation": 0, "minHeight": 12, "maxHeight": 19}, - "blocks": [{"name": "minecraft:iron_ore", "chance": 100}], + "blocks": [{"name": "minecraft:" + RESOURCE_KEY, "chance": 100}], } gold = copy.deepcopy(iron) gold["enabled"] = False gold["blocks"] = [{"name": "minecraft:gold_ore", "chance": 100}] - write_json(source, {"version": "2.0", "spawns": {"iron_ore": iron, "gold_ore": gold}}) + write_json(source, {"version": "2.0", "spawns": {RESOURCE_KEY: iron, "gold_ore": gold}}) sources[source] = digest(source) @@ -136,7 +151,7 @@ def prepare(args, name, origin, policy, managed, port, bootstrap=False): disabled.update(enabled=False, block="minecraft:gold_ore") write_json(override, { "schema_version": 4, "provider_modid": "orespawn", "provider_revision": 1, - "ores": {"orespawn:legacy/iron_ore": imported_rule(), + "ores": {"orespawn:legacy/" + RESOURCE_KEY: imported_rule(), "orespawn:legacy/gold_ore": disabled}, }) sources[override] = digest(override) @@ -145,7 +160,7 @@ def prepare(args, name, origin, policy, managed, port, bootstrap=False): def factory_config(args): - root, _ = prepare(args, "factory-defaults", "native", "additive", False, 25899, bootstrap=True) + root, _ = prepare(args, "factory-defaults", "native", "additive", False, args.base_port - 1, bootstrap=True) command = [str(args.java), "-Xms256m", "-Xmx2G", "-XX:ActiveProcessorCount=2", "-jar", FORGE_JAR, "nogui"] @@ -170,6 +185,7 @@ def run(args, root, phase, extra_properties=None, label=None): label = label or phase command = [str(args.java), "-Xms256m", "-Xmx2G", "-XX:ActiveProcessorCount=2", "-Drd.qualification.enabled=true", f"-Drd.qualification.phase={phase}"] + command.append("-Drd.qualification.resource=" + RESOURCE_KEY.removesuffix("_ore")) command.extend(f"-D{key}={value}" for key, value in (extra_properties or {}).items()) command.extend(["-jar", FORGE_JAR, "nogui"]) @@ -211,6 +227,9 @@ def check_profile(report, origin, policy, managed): profile = report["profile"] assert profile["manage_vanilla_ores"] == managed, "World management setting was replaced" owned = {key: value for key, value in profile["ores"].items() if key.startswith("realisticdeposits:")} + 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" assert set(owned) == {DEPOSIT_ID}, "Duplicate or missing Realistic Deposits rule" rule = owned[DEPOSIT_ID] assert rule["retrogen"] is False @@ -219,7 +238,7 @@ def check_profile(report, origin, policy, managed): assert dimension.get("background_generation_scale") == expected_scale if origin != "native": - imported_id = "orespawn:legacy/minecraft_iron_ore_0" if origin == "os1" else "orespawn:legacy/iron_ore" + imported_id = "orespawn:legacy/minecraft_" + RESOURCE_KEY + "_0" if origin == "os1" else "orespawn:legacy/" + RESOURCE_KEY imported = profile["ores"][imported_id] assert imported["enabled"] is True and imported["retrogen"] is False placement = imported["dimensions"]["minecraft:overworld"] @@ -379,14 +398,20 @@ def check_matrix(results): def main(): + global RESOURCE_KEY, DEPOSIT_ID 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("--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"), default="iron") parser.add_argument("--only", default="", help="Run matching case names for focused diagnostics") parser.add_argument("--local-orespawn-sha256", help="Explicit checksum for testing an unpublished local OreSpawn fix") args = parser.parse_args() + RESOURCE_KEY = args.resource + "_ore" + DEPOSIT_ID = "realisticdeposits:deposit/realisticdeposits/qualification_" + args.resource orespawn_sha256 = checked_orespawn_sha256(args.orespawn, args.local_orespawn_sha256) if "eula=true" not in (args.runtime / "eula.txt").read_text(encoding="utf-8"): @@ -409,7 +434,7 @@ def main(): cases = [case for case in cases if args.only in f"{case[0]}-{case[1]}-{'on' if case[2] else 'off'}"] with ThreadPoolExecutor(max_workers=args.jobs) as pool: - pending = {pool.submit(scenario, args, *case, 25900 + index): case + pending = {pool.submit(scenario, args, *case, args.base_port + index): case for index, case in enumerate(cases)} for future in as_completed(pending): diff --git a/tools/qualification/test_qualify_110.py b/tools/qualification/test_qualify_110.py index 9dd6541..6f4de4d 100644 --- a/tools/qualification/test_qualify_110.py +++ b/tools/qualification/test_qualify_110.py @@ -6,7 +6,8 @@ from pathlib import Path from unittest.mock import Mock -from qualify_110 import checked_orespawn_sha256, check_generation, check_legacy_flags, check_matrix, digest, same_sample +from qualify_110 import (ORESPAWN_SHA256, checked_orespawn_sha256, check_generation, + check_legacy_flags, check_matrix, digest, same_sample) def sample(ordinary, denied): @@ -26,6 +27,12 @@ def sample(ordinary, denied): class QualificationChecksTest(unittest.TestCase): + 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() + if "=" in line and not line.startswith("#")) + self.assertEqual(properties["orespawn_sha256"], ORESPAWN_SHA256) + def test_local_candidates_require_their_exact_explicit_checksum(self): with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "local-orespawn.jar" diff --git a/tools/release/test_release.py b/tools/release/test_release.py index 10725e8..3ac769b 100644 --- a/tools/release/test_release.py +++ b/tools/release/test_release.py @@ -9,15 +9,25 @@ class ReleaseContractTest(unittest.TestCase): - def test_public_dependency_and_alpha_version(self): + 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.1.1.110021", properties["mod_version"]) - self.assertEqual("4.1.3.110021", properties["orespawn_version"]) - self.assertEqual("9335434", properties["orespawn_curse_file_id"]) + self.assertEqual("0.2.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", + properties["orespawn_sha256"]) self.assertEqual("1689703", properties["curseforge_project_id"]) + def test_beta_is_the_default_curseforge_channel(self): + workflow = (ROOT / ".github/workflows/deploy-release.yml").read_text() + channel = workflow.split(" curseforge_release_level:", 1)[1].split( + " confirm_live_publication:", 1)[0] + self.assertRegex(channel, r"(?m)^ default: beta$") + self.assertIn(" - alpha", channel) + self.assertIn(" - release", channel) + def test_actions_are_pinned(self): workflows = list((ROOT / ".github/workflows").glob("*.yml")) self.assertGreaterEqual(len(workflows), 5) @@ -35,6 +45,7 @@ def test_publication_is_guarded_and_ordered(self): self.assertLess(workflow.index(" publish_maven:"), workflow.index(" publish_curseforge:")) self.assertLess(workflow.index(" publish_curseforge:"), workflow.index(" publish_github:")) self.assertIn("github-prerelease: true", workflow) + self.assertIn("GitHub Release type: prerelease", workflow) def test_codeql_observes_a_fresh_compilation(self): workflow = (ROOT / ".github/workflows/codeql-analysis.yml").read_text()