diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 09dac72..a481c66 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -143,9 +143,9 @@ jobs: if-no-files-found: error retention-days: 30 path: | - build/libs/RealisticDeposits-0.5.0.110021.jar - build/libs/RealisticDeposits-0.5.0.110021-sources.jar - build/libs/RealisticDeposits-0.5.0.110021-javadoc.jar + build/libs/RealisticDeposits-0.6.0.110021.jar + build/libs/RealisticDeposits-0.6.0.110021-sources.jar + build/libs/RealisticDeposits-0.6.0.110021-javadoc.jar build/release/SHA256SUMS CHANGELOG.md diff --git a/.github/workflows/deploy-release.yml b/.github/workflows/deploy-release.yml index 0bace85..e42af5d 100644 --- a/.github/workflows/deploy-release.yml +++ b/.github/workflows/deploy-release.yml @@ -1,6 +1,6 @@ name: Release Realistic Deposits -run-name: Release RealisticDeposits ${{ inputs.release_version }} (CurseForge ${{ inputs.curseforge_release_level }}) +run-name: Release RealisticDeposits ${{ inputs.release_version }} (${{ inputs.mode }}, ${{ inputs.publication_scope }}, CurseForge ${{ inputs.curseforge_release_level }}) on: workflow_dispatch: @@ -26,8 +26,16 @@ on: - release - beta - alpha + publication_scope: + description: Publish to all release destinations or to CurseForge only. + required: true + default: all + type: choice + options: + - all + - curseforge-only confirm_live_publication: - description: Confirm publication to Maven, CurseForge, and GitHub after the build passes. + description: Confirm live publication to the selected destinations after the build passes. required: true default: false type: boolean @@ -141,6 +149,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} REQUESTED_VERSION: ${{ inputs.release_version }} + PUBLICATION_SCOPE: ${{ inputs.publication_scope }} run: | set -euo pipefail @@ -248,6 +257,11 @@ jobs: fi release_sha="$(git rev-parse HEAD)" + if [[ "$PUBLICATION_SCOPE" == "curseforge-only" ]] && + [[ "$GITHUB_REF" != "refs/heads/${{ steps.route.outputs.target_branch }}" || "$release_sha" != "$GITHUB_SHA" ]]; then + echo "CurseForge-only releases must run on the current target branch commit." >&2 + exit 1 + fi release_tag="$release_version" echo "release_sha=$release_sha" >> "$GITHUB_OUTPUT" echo "release_version=$release_version" >> "$GITHUB_OUTPUT" @@ -419,6 +433,7 @@ jobs: env: RELEASE_BRANCH: ${{ needs.preflight.outputs.target_branch }} RELEASE_LEVEL: ${{ inputs.curseforge_release_level }} + PUBLICATION_SCOPE: ${{ inputs.publication_scope }} RELEASE_SHA: ${{ needs.preflight.outputs.release_sha }} RELEASE_VERSION: ${{ needs.preflight.outputs.release_version }} run: | @@ -429,7 +444,10 @@ jobs: echo "- Target branch: \`$RELEASE_BRANCH\`" echo "- Commit: \`$RELEASE_SHA\`" echo "- CurseForge release level: \`$RELEASE_LEVEL\`" - echo "- GitHub Release type: prerelease" + echo "- Publication scope: \`$PUBLICATION_SCOPE\`" + if [[ "$PUBLICATION_SCOPE" == "all" ]]; then + echo "- GitHub Release type: prerelease" + fi echo echo "### Public artifact checksums" echo '```text' @@ -449,10 +467,12 @@ jobs: - name: Confirm publication and release-secret access env: CONFIRM_LIVE_PUBLICATION: ${{ inputs.confirm_live_publication }} + PUBLICATION_SCOPE: ${{ inputs.publication_scope }} + EXPECTED_CHECKSUMS_SHA256: ${{ inputs.expected_checksums_sha256 }} CURSEFORGE_TOKEN: ${{ secrets.CURSEFORGE_TOKEN }} - MAVEN_UPLOAD_URL: ${{ secrets.MAVEN_UPLOAD_URL }} - MAVEN_UPLOAD_USERNAME: ${{ secrets.MAVEN_UPLOAD_USERNAME }} - MAVEN_UPLOAD_PASSWORD: ${{ secrets.MAVEN_UPLOAD_PASSWORD }} + MAVEN_UPLOAD_URL: ${{ inputs.publication_scope == 'all' && secrets.MAVEN_UPLOAD_URL || '' }} + MAVEN_UPLOAD_USERNAME: ${{ inputs.publication_scope == 'all' && secrets.MAVEN_UPLOAD_USERNAME || '' }} + MAVEN_UPLOAD_PASSWORD: ${{ inputs.publication_scope == 'all' && secrets.MAVEN_UPLOAD_PASSWORD || '' }} run: | set -euo pipefail if [[ "$CONFIRM_LIVE_PUBLICATION" != "true" ]]; then @@ -460,8 +480,19 @@ jobs: exit 1 fi echo "Live publication confirmed for ${{ needs.preflight.outputs.release_tag }}" + required=(CURSEFORGE_TOKEN) + case "$PUBLICATION_SCOPE" in + all) required+=(MAVEN_UPLOAD_URL MAVEN_UPLOAD_USERNAME MAVEN_UPLOAD_PASSWORD) ;; + curseforge-only) + if [[ ! "$EXPECTED_CHECKSUMS_SHA256" =~ ^[0-9a-fA-F]{64}$ ]]; then + echo "CurseForge-only publication requires the verified validation manifest SHA-256." >&2 + exit 1 + fi + ;; + *) echo "Unknown publication scope." >&2; exit 1 ;; + esac missing=() - for name in CURSEFORGE_TOKEN MAVEN_UPLOAD_URL MAVEN_UPLOAD_USERNAME MAVEN_UPLOAD_PASSWORD; do + for name in "${required[@]}"; do if [[ -z "${!name:-}" ]]; then missing+=("$name") fi @@ -470,10 +501,11 @@ jobs: printf 'Required release secret is unavailable: %s\n' "${missing[@]}" >&2 exit 1 fi - echo "All four required release secrets are available." + echo "The required secrets for the selected scope are available." create_release_tag: name: Create validated release tag + if: inputs.mode == 'publish' && inputs.publication_scope == 'all' needs: - preflight - build @@ -515,6 +547,7 @@ jobs: publish_maven: name: Publish Maven + if: inputs.mode == 'publish' && inputs.publication_scope == 'all' needs: - preflight - build @@ -620,9 +653,16 @@ jobs: publish_curseforge: name: Publish CurseForge + if: >- + ${{ !cancelled() && inputs.mode == 'publish' && + needs.preflight.result == 'success' && needs.build.result == 'success' && + needs.release_confirmation.result == 'success' && + ((inputs.publication_scope == 'curseforge-only' && needs.publish_maven.result == 'skipped') || + (inputs.publication_scope == 'all' && needs.publish_maven.result == 'success')) }} needs: - preflight - build + - release_confirmation - publish_maven runs-on: ubuntu-latest timeout-minutes: 20 @@ -646,6 +686,7 @@ jobs: fi - name: Publish RealisticDeposits + id: curseforge uses: Kira-NT/mc-publish@52307b03863581dec6b652b83e597aec02ebb075 # v3.3.1 with: curseforge-token: ${{ secrets.CURSEFORGE_TOKEN }} @@ -666,8 +707,20 @@ jobs: ${{ runner.temp }}/release-input/${{ needs.preflight.outputs.sources_jar }} ${{ runner.temp }}/release-input/${{ needs.preflight.outputs.javadoc_jar }} + - name: Record CurseForge upload receipt + env: + CURSEFORGE_FILE_ID: ${{ steps.curseforge.outputs.curseforge-version }} + run: | + if [[ "$CURSEFORGE_FILE_ID" =~ ^[0-9]+$ ]]; then + receipt="CurseForge accepted upload (file ID: $CURSEFORGE_FILE_ID)." + else + receipt="CurseForge upload succeeded; the publishing action did not return a numeric file ID." + fi + printf '%s\n' "$receipt" | tee -a "$GITHUB_STEP_SUMMARY" + publish_github: name: Publish GitHub Release + if: inputs.mode == 'publish' && inputs.publication_scope == 'all' needs: - preflight - build diff --git a/CHANGELOG.md b/CHANGELOG.md index 649bfa0..f769b32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## 0.6.0.110021 + +Beta for Minecraft 1.10.2. + +- Require MMD OreSpawn 4.1.6.110021 or newer in the 4.x line. +- Add rare emerald pockets in mountain-belt geology, with natural schist + when Mineralogy strata are active and natural vanilla stone otherwise. +- Keep all ordinary emerald generation by default, with vanilla drops, + Fortune and Silk Touch. +- Small emerald finds contain 25–60 planned ore blocks. Rare larger finds + can contain 70–120 across more pockets. A thin pale-rock trace helps you + follow the trend between pockets without turning it into an ore seam. +- Keep a single short crosscut at small finds. Larger finds can have + linked galleries leading to multiple pockets. Only build an entrance + where the whole route is safe, without cutting into the emeralds. +- Add exceptional emerald mines with 260–420 planned ore blocks in four or + five zones. Starter workings can expose two pockets, with pale rock traces + to follow into unworked zones. Small and rich finds keep their previous layouts. + Existing worlds and mine layouts are unchanged. + ## 0.5.0.110021 Alpha for Minecraft 1.10.2. diff --git a/README.md b/README.md index 2990a0a..47f07a0 100644 --- a/README.md +++ b/README.md @@ -7,17 +7,17 @@ Realistic Deposits makes finding ore a reason to explore, then build a proper mine. Rare iron districts, layered coalfields, diamond pipes, gold veins and -lapis lenses give you deposits worth returning to, with old workings leading -into some of them. +lapis lenses and emerald pockets give you deposits worth returning to, with +old workings leading into some of them. Small pockets of diamond- and gold-bearing river gravel offer another way to prospect. It runs on [MMD OreSpawn](https://github.com/MinecraftModDevelopmentMods/OreSpawn). The project starts with Minecraft 1.10.2 and is intended to follow the maintained OreSpawn branches across Forge and NeoForge. -Version 0.5.0.110021 is an alpha for Minecraft 1.10.2. It adds rare, compact -lapis lenses and short abandoned workings, with 15% of ordinary background -lapis retained by default. Try a fresh world to see the new rules: existing +Version 0.6.0.110021 is a local candidate for Minecraft 1.10.2. It adds rare +emerald pockets and occasional abandoned workings without reducing ordinary +emerald generation. Try a fresh world to see the new rules: existing worlds keep their settings, generated chunks and saved mine layouts. Back up important worlds before updating. @@ -44,15 +44,21 @@ important worlds before updating. - Compact lapis lenses with richer pockets and short workings. Natural marble hosts them when Mineralogy strata are active; ordinary stone is used otherwise. See [lapis deposits](docs/LAPIS.md). +- Rare emerald pockets along pale underground traces, with occasional larger + finds whose abandoned galleries can reach more than one pocket. Large + mines have several separated zones to prospect beyond the starter workings. They fit + natural schist in active Mineralogy strata, or natural vanilla stone + variants otherwise. + See [emerald deposits](docs/EMERALDS.md). - Configurable support for vanilla and modded resources. - Safe generation that avoids air, fluids, bedrock, block entities, structures, and undeclared host blocks. -- Live reload for iron and coal geometry. Lapis, gold, diamond and other structural +- Live reload for iron and coal geometry. Emerald, lapis, gold, diamond and other structural changes wait until restart. - An entry in both Forge's Mods configuration and OreSpawn's World Settings. - Abandoned workings with sloping, stepped entrances and short underground galleries. New defaults attempt them at every iron formation, coalfield, diamond pipe - and goldfield, plus each lapis district, where safe. River placers stay untouched. + and goldfield, plus each lapis or emerald district, where safe. River placers stay untouched. See the [mine settings](docs/ABANDONED_MINES.md). Coalfields can already use Mineralogy's existing shale, siltstone and @@ -65,7 +71,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.5.110021](https://www.curseforge.com/minecraft/mc-mods/mmd-orespawn/files/9397156) +- [MMD OreSpawn 4.1.6.110021](https://www.curseforge.com/minecraft/mc-mods/mmd-orespawn/files/9518063) or a newer compatible 1.10.2 release, but earlier than OreSpawn 5; - Java 8 to run Minecraft. @@ -83,6 +89,7 @@ config/realisticdeposits/deposits/coal.json config/realisticdeposits/deposits/diamonds.json config/realisticdeposits/deposits/gold.json config/realisticdeposits/deposits/lapis.json +config/realisticdeposits/deposits/emerald.json ``` The supplied definition describes one rare iron district using a 640–960 block @@ -160,7 +167,7 @@ formations: `reload` requires operator permission. For iron and coal it applies `enabled`, selection, shape, height, geome, family, and alignment changes. Diamond geometry -and source links require a restart, as do gold and lapis settings. Resource, +and source links require a restart, as do gold, lapis and emerald settings. Resource, dimension, host, biome-filter, interaction-policy, scale, added, and removed definition changes are structural and take effect after restart. Abandoned-mine settings are also captured with the world's deposit definition. New provider @@ -175,9 +182,9 @@ World Settings screen. The Gradle build runs on Java 17 and compiles the mod for Java 8 using the configured toolchain. ForgeGradle's Minecraft preparation utility uses Java 25. -OreSpawn 4.1.5.110021 is downloaded from Curse Maven, checked against its +OreSpawn 4.1.6.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:9397156`. No sibling checkout, local jar or +is `curse.maven:mmd-orespawn-245586:9518063`. No sibling checkout, local jar or `local.properties` file is needed. An optional local-candidate override is still available for development, but it @@ -205,6 +212,7 @@ separate Eclipse profile and disposable saves for the new workings. For coalfields, use `prepareCoalMeasuresManual` and the [coal-testing walkthrough](tools/qualification/COAL_TESTING.md). For lapis, see the [lapis walkthrough](tools/qualification/LAPIS_TESTING.md). +For emeralds, see the [emerald walkthrough](tools/qualification/EMERALD_TESTING.md). Hosted CI/CD keeps to builds, unit/helper tests, static checks, reproducible jars, dependency hashes and ordinary Eclipse launch definitions. It does not diff --git a/build.gradle b/build.gradle index 84e6cc0..42e3ea0 100644 --- a/build.gradle +++ b/build.gradle @@ -450,11 +450,11 @@ tasks.register('verifyProjectIdentity') { } if (project.minecraft_version != '1.10.2' || project.forge_version != '12.18.3.2511' - || project.orespawn_version != '4.1.5.110021' - || project.orespawn_version_range != '[4.1.5.110021,5.0.0)' - || (!oreSpawnCandidateMode && oreSpawnCoordinate != 'curse.maven:mmd-orespawn-245586:9397156') + || project.orespawn_version != '4.1.6.110021' + || project.orespawn_version_range != '[4.1.6.110021,5.0.0)' + || (!oreSpawnCandidateMode && oreSpawnCoordinate != 'curse.maven:mmd-orespawn-245586:9518063') || project.orespawn_sha256 - != '1E580EBB8D93B1256E2E81C7D0D815DEAAAC58A028CB5A77A52773B62048E010') { + != '05DB7CBFAADC815D6A713DACDFB9F0647BCED49CCFC357FB2FF010E95BD1379F') { throw new GradleException('Unexpected target or OreSpawn dependency identity') } if (project.mod_version.toString().contains('-')) { @@ -650,3 +650,4 @@ apply from: 'gradle/coal-manual.gradle' apply from: 'gradle/diamond-manual.gradle' apply from: 'gradle/gold-manual.gradle' apply from: 'gradle/lapis-manual.gradle' +apply from: 'gradle/emerald-manual.gradle' diff --git a/docs/EMERALDS.md b/docs/EMERALDS.md new file mode 100644 index 0000000..012a895 --- /dev/null +++ b/docs/EMERALDS.md @@ -0,0 +1,61 @@ +# Finding emerald pockets + +Rare underground deposits follow a short vein trend through OreSpawn's +mountain-belt geology. The surface biome doesn't need to be a mountain. +Most finds have two to four separated pockets, with 25–60 planned ore +blocks along a 24–48 block trend. A rich find can have four to six pockets +and 70–120 planned blocks along a 52–80 block trend. A large +mine has 260–420 planned blocks in four or five separated zones along a +120–168 block trend. Each zone has two pockets, with barren rock between +zones. A pale, narrow trace of diorite points onward without adding +emeralds to the gaps. +Caves, changes in host rock and protected features can interrupt the trace +or reduce the ore you find. The ore has normal vanilla drops, Fortune and +Silk Touch. + +The inspiration is the vein-adjacent mineralisation of the +[Kafubu emerald district](https://repository.naturalis.nl/pub/801803). +Active Mineralogy strata use only enabled, natural schist. If the host is +missing, the deposit is skipped. Without physical strata, natural vanilla +stone variants are a simpler approximation. Unclassified geology and +unsupported terrain are skipped rather than guessing or loading new chunks. + +A compact stepped descent and one short crosscut can expose a small pocket. +Rich and large finds can have timbered galleries leading from a single +stairway to two or more pockets. In a large mine these are starter workings: +follow the diorite out of the worked zone and dig your own headings into +the other zones. The complete starter workings must fit in one fresh chunk so the +game can check the route before carving. If the galleries cannot reach two +actual pockets, or if water, infested stone or a structure makes the route +unsafe, a large mine may try another route in that same chunk. If none fits +safely, the deposit can still generate without an entrance. Pockets beyond +the workings are left for prospecting. + +The fresh defaults use a 35% selection chance per 64-chunk region before +geology and host checks, at Y12–48 and with at least eight blocks of cover. +Eight percent of candidates that reach the layout stage try the larger +layout. If that does not fit the mountain-belt geology and eligible host, +the generator tries the ordinary layout instead. Large mines have a +separate 8% roll, only in regions not selected for small or rich finds. +They remain more geographically restricted than iron formations; all +their zones must fit the mountain-belt geology and eligible host rock. These are gameplay +ranges for individual finds, not estimates of a real mine's production. +The default additive mode does not scale down or replace ordinary emerald +rules. Pack authors can opt into hybrid or replacement mode. + +Settings are in `config/realisticdeposits/deposits/emerald.json`. Restart +after changes. The optional `shape.rich_find` sets the chance, pocket count, +strike and ore budget for larger finds. `shape.rich_find.workings` enables +the linked galleries, subject to the ordinary abandoned-mine setting. +`shape.vein_trace` enables the non-ore cue for small and rich finds. +The optional `shape.large_mine` sets the large-mine chance, zone count, +strike and ore budget. Large mines always plan their own prospecting clues; +the ordinary abandoned-mine setting still controls their starter workings. +Leave `shape.large_mine` absent to keep the two-tier layout. Older catalogues +without these fields and existing world +profiles keep their previous settings. Generated chunks are never rewritten. +Preview a catalogue candidate without writing blocks: + +```text +/realisticdeposits preview realisticdeposits:overworld_emerald_pockets +``` diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 75976c0..51dbc38 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -1,11 +1,11 @@ # Releasing Realistic Deposits -This candidate uses published OreSpawn 4.1.5.110021, pinned by file and checksum. +The 0.6 beta uses published OreSpawn 4.1.6.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.5.0.110021`; the final +branch. Enter the full numeric version, such as `0.6.0.110021`; the final component identifies the Minecraft version and loader. CurseForge's alpha, beta or release label is separate from the numeric version. @@ -14,19 +14,20 @@ 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 the intended CurseForge channel -(**alpha** for 0.5.0.110021), and tick the explicit publication confirmation. +(**beta** for 0.6.0.110021), and tick the explicit publication confirmation. Supply the SHA-256 of the verified validation bundle's `SHA256SUMS` file as `expected_checksums_sha256` to require an exact match before publication. -The workflow requires all four secrets: -- `CURSEFORGE_TOKEN` -- `MAVEN_UPLOAD_URL` -- `MAVEN_UPLOAD_USERNAME` -- `MAVEN_UPLOAD_PASSWORD` +- **curseforge-only**: Dispatch from the target branch. The manifest SHA-256 + and `CURSEFORGE_TOKEN` are required. This does not create a tag, publish to + Maven, or create a GitHub Release. +- **all** (default): Creates the release tag, then publishes to Maven, + CurseForge, and GitHub in that order. This also requires + `MAVEN_UPLOAD_URL`, `MAVEN_UPLOAD_USERNAME`, and `MAVEN_UPLOAD_PASSWORD`. -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`. +Each publication job downloads the same immutable bundle and checks its +hashes. OreSpawn is a required CurseForge dependency. The current project +ID is `1689703`. The channel does not change the numeric version or GitHub prerelease status. Validation mode is the default; changing the channel does not authorize an upload. diff --git a/gradle.properties b/gradle.properties index bfa9725..e68324c 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.5.0.110021 +mod_version=0.6.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.5.110021 -orespawn_version_range=[4.1.5.110021,5.0.0) +orespawn_version=4.1.6.110021 +orespawn_version_range=[4.1.6.110021,5.0.0) orespawn_curse_project_id=245586 -orespawn_curse_file_id=9397156 -orespawn_sha256=1E580EBB8D93B1256E2E81C7D0D815DEAAAC58A028CB5A77A52773B62048E010 +orespawn_curse_file_id=9518063 +orespawn_sha256=05DB7CBFAADC815D6A713DACDFB9F0647BCED49CCFC357FB2FF010E95BD1379F curseforge_project_slug=realisticdeposits diff --git a/gradle/emerald-manual.gradle b/gradle/emerald-manual.gradle new file mode 100644 index 0000000..de29277 --- /dev/null +++ b/gradle/emerald-manual.gradle @@ -0,0 +1,36 @@ +// Reuse the existing Eclipse project, but never launch into the ordinary saves. +def emeraldLaunches = ['runClient': 'runEmeraldDepositsClient', 'runServer': 'runEmeraldDepositsServer'] + +tasks.named('isolateEclipseProductionRuns') { + doLast { + emeraldLaunches.each { source, target -> + String contents = file("${source}.launch").getText('UTF-8') + String directory = file('run-emerald-deposits').absolutePath.replace('\\', '\\\\') + contents = contents.replaceAll( + /()/, + '$1' + directory + '$2') + file("${target}.launch").setText(contents.replace('\r\n', '\n'), 'UTF-8') + } + } +} + +tasks.named('verifyEclipseProductionClasspath') { + doLast { + emeraldLaunches.values().each { name -> + String contents = file("${name}.launch").getText('UTF-8') + if (!contents.contains('run-emerald-deposits') || !contents.contains('ATTR_EXCLUDE_TEST_CODE')) { + throw new GradleException("Emerald launch is not isolated: ${name}") + } + } + } +} + +tasks.register('prepareEmeraldDepositsManual') { + group = 'verification' + description = 'Prepares the disposable emerald launch directory in this Eclipse project.' + dependsOn tasks.named('verifyEclipseProductionClasspath') + doLast { + file('run-emerald-deposits').mkdirs() + logger.lifecycle('Use runEmeraldDepositsClient.launch in the existing workspace and create a fresh world.') + } +} diff --git a/gradle/release.gradle b/gradle/release.gradle index 6e08c13..b1961ea 100644 --- a/gradle/release.gradle +++ b/gradle/release.gradle @@ -81,7 +81,7 @@ tasks.register('verifyJavadocOrdering') { tasks.register('verifyReleaseConfiguration') { group = 'verification' doLast { - if (project.mod_version != '0.5.0.110021' + if (project.mod_version != '0.6.0.110021' || project.curseforge_project_id != '1689703' || project.mod_artifact != 'RealisticDeposits' || project.java_version != '8' || project.gradle_java_version != '17' diff --git a/src/main/java/zone/moddev/mc/realisticdeposits/RealisticDeposits.java b/src/main/java/zone/moddev/mc/realisticdeposits/RealisticDeposits.java index d44c889..e0b0029 100644 --- a/src/main/java/zone/moddev/mc/realisticdeposits/RealisticDeposits.java +++ b/src/main/java/zone/moddev/mc/realisticdeposits/RealisticDeposits.java @@ -8,6 +8,7 @@ import zone.moddev.mc.realisticdeposits.generation.DiamondPattern; import zone.moddev.mc.realisticdeposits.generation.GoldPattern; import zone.moddev.mc.realisticdeposits.generation.LapisPattern; +import zone.moddev.mc.realisticdeposits.generation.EmeraldPattern; import zone.moddev.mc.realisticdeposits.content.ModContent; import zone.moddev.mc.realisticdeposits.integration.OreSpawnIntegration; import zone.moddev.mc.realisticdeposits.proxy.CommonProxy; @@ -34,12 +35,12 @@ */ @Mod(modid = RealisticDeposits.MOD_ID, name = RealisticDeposits.NAME, version = RealisticDeposits.VERSION, acceptedMinecraftVersions = "[1.10.2]", - dependencies = "required-after:orespawn@[4.1.5.110021,5.0.0)", + dependencies = "required-after:orespawn@[4.1.6.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.5.0.110021"; + public static final String VERSION = "0.6.0.110021"; private static final OrePatternType STRATIFORM_SEAM = StratiformSeamPattern.type() .setRegistryName(OreSpawnIntegration.PATTERN_ID); private static final OrePatternType COAL_MEASURES = CoalMeasuresPattern.type() @@ -54,6 +55,8 @@ public final class RealisticDeposits { .setRegistryName(OreSpawnIntegration.GOLD_PLACER_ID); private static final OrePatternType LAPIS_LENS = LapisPattern.type() .setRegistryName(OreSpawnIntegration.LAPIS_LENS_ID); + private static final OrePatternType EMERALD_POCKETS = EmeraldPattern.type() + .setRegistryName(OreSpawnIntegration.EMERALD_POCKETS_ID); @SidedProxy(clientSide = "zone.moddev.mc.realisticdeposits.proxy.ClientProxy", serverSide = "zone.moddev.mc.realisticdeposits.proxy.CommonProxy") @@ -73,6 +76,7 @@ public void registerPatterns(RegistryEvent.Register event) { event.getRegistry().register(GOLD_LODE); event.getRegistry().register(GOLD_PLACER); event.getRegistry().register(LAPIS_LENS); + event.getRegistry().register(EMERALD_POCKETS); } @Mod.EventHandler @@ -97,6 +101,7 @@ public void serverAboutToStart(FMLServerAboutToStartEvent event) { DiamondPattern.newServerSession(); GoldPattern.newServerSession(); LapisPattern.newServerSession(); + EmeraldPattern.newServerSession(); } @Mod.EventHandler diff --git a/src/main/java/zone/moddev/mc/realisticdeposits/command/RealisticDepositsCommand.java b/src/main/java/zone/moddev/mc/realisticdeposits/command/RealisticDepositsCommand.java index 01dee49..c56e155 100644 --- a/src/main/java/zone/moddev/mc/realisticdeposits/command/RealisticDepositsCommand.java +++ b/src/main/java/zone/moddev/mc/realisticdeposits/command/RealisticDepositsCommand.java @@ -69,6 +69,14 @@ private static void preview(ICommandSender sender, String[] args) throws Command DepositDefinition definition = selectDefinition(state, args.length == 4 ? args[3] : null); World world = sender.getEntityWorld(); String dimension = dimensionId(world.provider.getDimension()); + if (definition.isEmerald()) { + if (!(world instanceof net.minecraft.world.WorldServer)) { + throw new CommandException("Emerald previews require a server world"); + } + sender.sendMessage(new TextComponentString(zone.moddev.mc.realisticdeposits.generation.EmeraldPattern + .preview(definition, (net.minecraft.world.WorldServer) world, regionX, regionZ))); + return; + } if (definition.isLapis()) { if (!(world instanceof net.minecraft.world.WorldServer)) { throw new CommandException("Lapis previews require a server world"); diff --git a/src/main/java/zone/moddev/mc/realisticdeposits/config/DepositCatalogLoader.java b/src/main/java/zone/moddev/mc/realisticdeposits/config/DepositCatalogLoader.java index 2a9a73d..3419699 100644 --- a/src/main/java/zone/moddev/mc/realisticdeposits/config/DepositCatalogLoader.java +++ b/src/main/java/zone/moddev/mc/realisticdeposits/config/DepositCatalogLoader.java @@ -25,6 +25,7 @@ import zone.moddev.mc.realisticdeposits.model.GoldLodeSettings; import zone.moddev.mc.realisticdeposits.model.GoldPlacerSettings; import zone.moddev.mc.realisticdeposits.model.LapisLensSettings; +import zone.moddev.mc.realisticdeposits.model.EmeraldPocketSettings; import zone.moddev.mc.realisticdeposits.model.IntRange; import zone.moddev.mc.realisticdeposits.model.InteractionPolicy; import zone.moddev.mc.realisticdeposits.model.StratiformSeamSettings; @@ -114,6 +115,32 @@ private static DepositDefinition definition(JsonObject json, String path, List warnings) { String type = string(shape, "type", path + " shape"); + if ("emerald_pockets".equals(type)) { + unknownKeys(shape, keys("type", "pockets", "strike_length", "ore_blocks", "surface_cover", "rich_find", "vein_trace", "large_mine"), + path + " shape", warnings); + EmeraldPocketSettings.RichFind rich = null; + if (shape.has("rich_find")) { + JsonObject spec = object(shape, "rich_find", path); + String richPath = path + " rich_find"; + unknownKeys(spec, keys("chance", "pockets", "strike_length", "ore_blocks", "workings"), richPath, warnings); + rich = new EmeraldPocketSettings.RichFind(number(spec, "chance", richPath), + integerRange(spec, "pockets", richPath), integerRange(spec, "strike_length", richPath), + integerRange(spec, "ore_blocks", richPath), bool(spec, "workings", false, richPath)); + } + EmeraldPocketSettings.LargeMine large = null; + if (shape.has("large_mine")) { + String largePath = path + " large_mine"; + JsonObject spec = object(shape, "large_mine", path); + unknownKeys(spec, keys("chance", "zones", "strike_length", "ore_blocks"), largePath, warnings); + large = new EmeraldPocketSettings.LargeMine(number(spec, "chance", largePath), + integerRange(spec, "zones", largePath), integerRange(spec, "strike_length", largePath), + integerRange(spec, "ore_blocks", largePath)); + } + return new EmeraldPocketSettings(integerRange(shape, "pockets", path), + integerRange(shape, "strike_length", path), integerRange(shape, "ore_blocks", path), + integerRange(shape, "surface_cover", path), rich, bool(shape, "vein_trace", false, path), large); + } + if ("lapis_lens".equals(type)) { unknownKeys(shape, keys("type", "lenses", "strike_length", "width", "thickness", "surface_cover", "legacy_selection_scale"), path + " shape", warnings); diff --git a/src/main/java/zone/moddev/mc/realisticdeposits/config/RealisticDepositsRuntime.java b/src/main/java/zone/moddev/mc/realisticdeposits/config/RealisticDepositsRuntime.java index 00c6798..f061dce 100644 --- a/src/main/java/zone/moddev/mc/realisticdeposits/config/RealisticDepositsRuntime.java +++ b/src/main/java/zone/moddev/mc/realisticdeposits/config/RealisticDepositsRuntime.java @@ -81,6 +81,7 @@ private static void reloadCatalogs(boolean diagnostics) { createDefault(new File(deposits, "diamonds.json"), "/assets/realisticdeposits/defaults/diamonds.json"); createDefault(new File(deposits, "gold.json"), "/assets/realisticdeposits/defaults/gold.json"); createDefault(new File(deposits, "lapis.json"), "/assets/realisticdeposits/defaults/lapis.json"); + createDefault(new File(deposits, "emerald.json"), "/assets/realisticdeposits/defaults/emerald.json"); List files = jsonFiles(deposits); List catalogs = new ArrayList<>(); List definitions = new ArrayList<>(); @@ -255,7 +256,8 @@ private static List jsonFiles(File directory) { List result = new ArrayList<>(); collectJson(directory, result); // Keep existing catalogue order; the new default must not become the first deposit. - result.sort(Comparator.comparingInt((File file) -> "lapis.json".equals(file.getName()) ? 4 + result.sort(Comparator.comparingInt((File file) -> "emerald.json".equals(file.getName()) ? 5 + : "lapis.json".equals(file.getName()) ? 4 : "gold.json".equals(file.getName()) ? 3 : "diamonds.json".equals(file.getName()) ? 2 : "coal.json".equals(file.getName()) ? 1 : 0) diff --git a/src/main/java/zone/moddev/mc/realisticdeposits/generation/EmeraldPattern.java b/src/main/java/zone/moddev/mc/realisticdeposits/generation/EmeraldPattern.java new file mode 100644 index 0000000..3cc9992 --- /dev/null +++ b/src/main/java/zone/moddev/mc/realisticdeposits/generation/EmeraldPattern.java @@ -0,0 +1,216 @@ +package zone.moddev.mc.realisticdeposits.generation; + +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.Set; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.mojang.serialization.Codec; +import net.minecraft.block.Block; +import net.minecraft.block.state.IBlockState; +import net.minecraft.init.Blocks; +import net.minecraft.server.MinecraftServer; +import net.minecraft.util.ResourceLocation; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.WorldServer; +import net.minecraft.world.chunk.Chunk; +import net.minecraftforge.fml.common.FMLCommonHandler; + +import zone.moddev.mc.orespawn.api.GeologyColumn; +import zone.moddev.mc.orespawn.api.GeologySampler; +import zone.moddev.mc.orespawn.api.OreGenerationContext; +import zone.moddev.mc.orespawn.api.OrePatternType; +import zone.moddev.mc.orespawn.api.OreSpawnApi; +import zone.moddev.mc.realisticdeposits.config.DepositCatalogLoader; +import zone.moddev.mc.realisticdeposits.config.RealisticDepositsRuntime; +import zone.moddev.mc.realisticdeposits.geometry.EmeraldDistrict; +import zone.moddev.mc.realisticdeposits.geometry.MinePoint; +import zone.moddev.mc.realisticdeposits.model.DepositDefinition; + +/** Writes the current chunk's share of a fitted schist or natural-stone deposit. */ +public final class EmeraldPattern { + public static final ResourceLocation SCHIST = new ResourceLocation("mineralogy", "schist"); + private static final String OVERWORLD = "minecraft:overworld"; + private static int epoch; + + private EmeraldPattern() { } + + public static void newServerSession() { epoch++; } + + public static OrePatternType type() { + return OrePatternType.create(Codec.of(input -> { + if (input == null || !input.isJsonObject() || !input.getAsJsonObject().has("definition")) { + throw new IllegalArgumentException("Emerald pockets need a captured definition"); + } + DepositDefinition captured = DepositCatalogLoader.loadDefinition( + input.getAsJsonObject().getAsJsonObject("definition"), "OreSpawn emerald profile"); + if (!captured.isEmerald()) throw new IllegalArgumentException("Emerald pattern and shape do not match"); + JsonObject saved = captured.toJson(); + // OreSpawn has already applied the enclosing saved rule's enabled switch. + saved.addProperty("enabled", true); + return DepositCatalogLoader.loadDefinition(saved, "enabled world emerald rule"); + }), definition -> { + Renderer renderer = new Renderer(definition); + return base -> base instanceof OreGenerationContext && renderer.place((OreGenerationContext) base); + }); + } + + static boolean permittedRock(String block, int meta, boolean physical) { + if (physical) return SCHIST.toString().equals(block) && meta == 0; + return "minecraft:stone".equals(block) && (meta == 0 || meta == 1 || meta == 3 || meta == 5); + } + + static Set exactHosts(DepositDefinition definition, JsonObject profile, boolean physical) { + Set hosts = new HashSet<>(); + if (!physical) { + if (definition.hosts().contains("minecraft:stone")) { + for (int metadata : new int[] {0, 1, 3, 5}) hosts.add(Blocks.STONE.getStateFromMeta(metadata)); + } + return hosts; + } + JsonObject rocks = profile.getAsJsonObject("rocks"); + if (rocks == null || !Block.REGISTRY.containsKey(SCHIST)) return hosts; + for (Map.Entry entry : rocks.entrySet()) { + JsonObject rock = entry.getValue().getAsJsonObject(); + if (!rock.has("block") || (rock.has("enabled") && !rock.get("enabled").getAsBoolean()) + || (rock.has("ore_replaceable") && !rock.get("ore_replaceable").getAsBoolean())) continue; + if (permittedRock(rock.get("block").getAsString(), + rock.has("metadata") ? rock.get("metadata").getAsInt() : 0, true)) { + hosts.add(Block.REGISTRY.getObject(SCHIST).getStateFromMeta(0)); + } + } + return hosts; + } + + static EmeraldDistrict.Site site(DepositDefinition definition, MineTerrainSampler terrain, + GeologySampler sampler, Set hosts, boolean physical) { + return new EmeraldDistrict.Site() { + private final Map heights = new HashMap<>(); + private final Map columns = new HashMap<>(); + + @Override + public boolean acceptsGeome(int x, int z) { + int height = terrain.naturalSurfaceY(x, z); + if (height < 0) return false; + String geome = sampler.sampleColumn(x, z, height).geome().toString(); + return !"orespawn:cyano".equals(geome) && definition.geomes().contains(geome); + } + + @Override + public boolean eligible(int x, int y, int z, int cover) { + long key = MinePoint.chunkKey(x, z); + int height = heights.computeIfAbsent(key, unused -> terrain.naturalSurfaceY(x, z)); + if (height < 0 || height - y < cover) return false; + IBlockState predicted = physical + ? columns.computeIfAbsent(key, unused -> sampler.sampleColumn(x, z, height)).rockAt(y) + : terrain.state(x, y, z); + return hosts.contains(predicted); + } + }; + } + + private static final class Renderer { + private final DepositDefinition definition; + private final Map> plans = + new LinkedHashMap>() { + @Override + protected boolean removeEldestEntry(Map.Entry> entry) { + return size() > 128; + } + }; + private int observedEpoch = -1; + private boolean physical; + private Set hosts; + + Renderer(DepositDefinition definition) { this.definition = definition; } + + boolean place(OreGenerationContext context) { + Optional sampler = context.geologySampler(); + if (!OVERWORLD.equals(context.dimension().toString()) || !sampler.isPresent()) return false; + MinecraftServer server = FMLCommonHandler.instance().getMinecraftServerInstance(); + WorldServer world = server == null ? null : server.worldServerForDimension(0); + if (world == null) return false; + Chunk chunk = world.getChunkProvider().getLoadedChunk(context.chunkX(), context.chunkZ()); + if (chunk == null) return false; + if (observedEpoch != epoch) { + plans.clear(); + JsonObject profile = OreSpawnApi.getActiveProfile(server).get().toJson(); + physical = LapisGeology.physicalStrata(profile); + hosts = exactHosts(definition, profile, physical); + observedEpoch = epoch; + } + if (hosts.isEmpty()) return false; + + int minX = context.chunkX() << 4, minZ = context.chunkZ() << 4; + int size = definition.regionSpacingChunks() * 16; + int radius = EmeraldDistrict.searchRadius(definition) + (definition.abandonedMine().enabled() ? 96 : 0); + BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos(); + List structures = null; + int placed = 0; + for (int rx = Math.floorDiv(minX - radius, size); rx <= Math.floorDiv(minX + 15 + radius, size); rx++) { + for (int rz = Math.floorDiv(minZ - radius, size); rz <= Math.floorDiv(minZ + 15 + radius, size); rz++) { + Optional plan = plans.computeIfAbsent(MinePoint.chunkKey(rx, rz), key -> { + MineTerrainSampler terrain; + try { + terrain = new MineTerrainSampler(world, world.getChunkProvider().chunkGenerator); + } catch (IllegalArgumentException unsupported) { + return Optional.empty(); + } + return EmeraldDistrict.plan(definition, context.worldSeed(), OVERWORLD, + (int) (key >> 32), (int) (long) key, site(definition, terrain, sampler.get(), hosts, physical)); + }); + if (!plan.isPresent()) continue; + EmeraldDistrict body = plan.get(); + MineWorkingsGenerator.offer(context, definition, body); + EmeraldVeinTrace.offer(world, MinePoint.chunkKey(context.chunkX(), context.chunkZ()), + body.traceInChunk(context.chunkX(), context.chunkZ()), hosts, body.surfaceCover()); + if (!body.bounds().intersectsChunk(context.chunkX(), context.chunkZ())) continue; + if (structures == null) structures = CoalStructureProtection.inChunk(context.chunkX(), context.chunkZ()); + for (MinePoint point : body.oreInChunk(context.chunkX(), context.chunkZ())) { + int x = point.x(), y = point.y(), z = point.z(); + if (!context.inside(x, y, z) || y > chunk.getHeightValue(x & 15, z & 15) - body.surfaceCover() + || CoalStructureProtection.contains(structures, x, y, z)) continue; + IBlockState state = chunk.getBlockState(cursor.setPos(x, y, z)); + // Physical hosts were predicted per position in the immutable plan; the actual + // state must still be the very same natural schist, not merely a rock family. + if (hosts.contains(state) && context.tryPlace(x, y, z, body.identity().seed())) placed++; + } + } + } + if (placed != 0) RealisticDepositsRuntime.reportFirstGeneration(definition.id(), OVERWORLD, + context.chunkX(), context.chunkZ(), placed); + return placed != 0; + } + } + + public static Optional plan(DepositDefinition definition, WorldServer world, int rx, int rz) { + if (world.provider.getDimension() != 0) return Optional.empty(); + Optional sampler = OreSpawnApi.createSampler(world); + if (!sampler.isPresent()) return Optional.empty(); + JsonObject profile = OreSpawnApi.getActiveProfile(world.getMinecraftServer()).get().toJson(); + boolean physical = LapisGeology.physicalStrata(profile); + Set hosts = exactHosts(definition, profile, physical); + if (hosts.isEmpty()) return Optional.empty(); + try { + MineTerrainSampler terrain = new MineTerrainSampler(world, world.getChunkProvider().chunkGenerator); + return EmeraldDistrict.plan(definition, world.getSeed(), OVERWORLD, rx, rz, + site(definition, terrain, sampler.get(), hosts, physical)); + } catch (IllegalArgumentException unsupported) { + return Optional.empty(); + } + } + + public static String preview(DepositDefinition definition, WorldServer world, int rx, int rz) { + return plan(definition, world, rx, rz).map(body -> (body.large() ? "Large emerald district near " + : body.rich() ? "Rich emerald pockets near " : "Emerald pockets near ") + + body.centerX() + "," + body.centerY() + "," + body.centerZ() + + (body.large() ? "; zones=" + body.zones() : "") + "; pockets=" + + body.pockets().size() + ", planned ore=" + body.ore().size() + ". Preview changes no blocks.") + .orElse("No suitable emerald pockets are selected in this region."); + } +} diff --git a/src/main/java/zone/moddev/mc/realisticdeposits/generation/EmeraldVeinTrace.java b/src/main/java/zone/moddev/mc/realisticdeposits/generation/EmeraldVeinTrace.java new file mode 100644 index 0000000..fe08e65 --- /dev/null +++ b/src/main/java/zone/moddev/mc/realisticdeposits/generation/EmeraldVeinTrace.java @@ -0,0 +1,77 @@ +package zone.moddev.mc.realisticdeposits.generation; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.WeakHashMap; + +import net.minecraft.block.state.IBlockState; +import net.minecraft.init.Blocks; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.WorldServer; +import net.minecraft.world.chunk.Chunk; +import net.minecraft.world.chunk.storage.ExtendedBlockStorage; + +import zone.moddev.mc.realisticdeposits.geometry.MinePoint; + +/** A pale, non-ore cue written after ores and workings in the current fresh chunk. */ +final class EmeraldVeinTrace { + private static final Map>> QUEUE = new WeakHashMap<>(); + + private EmeraldVeinTrace() { } + + static void offer(WorldServer world, long chunk, List cells, Set hosts, int cover) { + if (cells.isEmpty()) return; + Map> pending = QUEUE.computeIfAbsent(world, unused -> new LinkedHashMap<>()); + pending.computeIfAbsent(chunk, unused -> new ArrayList<>()).add(new Slice(cells, hosts, cover)); + if (pending.size() > 128) pending.remove(pending.keySet().iterator().next()); + } + + static List take(WorldServer world, long chunk) { + Map> pending = QUEUE.get(world); + List slices = pending == null ? null : pending.remove(chunk); + return slices == null ? Collections.emptyList() : slices; + } + + static void unload(WorldServer world) { QUEUE.remove(world); } + + static void place(WorldServer world, Chunk chunk, List slices) { + if (slices.isEmpty()) return; + IBlockState marker = Blocks.STONE.getStateFromMeta(3); // natural diorite + List structures = CoalStructureProtection.inChunk(chunk.xPosition, chunk.zPosition); + BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos(); + ExtendedBlockStorage[] sections = chunk.getBlockStorageArray(); + boolean modified = false; + for (Slice slice : slices) { + for (MinePoint point : slice.cells) { + int x = point.x(), y = point.y(), z = point.z(); + if (x >> 4 != chunk.xPosition || z >> 4 != chunk.zPosition + || y > chunk.getHeightValue(x & 15, z & 15) - slice.cover + || !world.getWorldBorder().contains(cursor.setPos(x, y, z)) + || CoalStructureProtection.contains(structures, x, y, z)) continue; + IBlockState current = chunk.getBlockState(cursor); + if (current == marker || !slice.hosts.contains(current) || current.getBlock().hasTileEntity(current)) continue; + ExtendedBlockStorage section = sections[y >> 4]; + if (section == null) continue; + section.set(x & 15, y & 15, z & 15, marker); + modified = true; + } + } + if (modified) chunk.setChunkModified(); + } + + static final class Slice { + private final List cells; + private final Set hosts; + private final int cover; + + private Slice(List cells, Set hosts, int cover) { + this.cells = cells; + this.hosts = hosts; + this.cover = cover; + } + } +} diff --git a/src/main/java/zone/moddev/mc/realisticdeposits/generation/MineWorkingsGenerator.java b/src/main/java/zone/moddev/mc/realisticdeposits/generation/MineWorkingsGenerator.java index c5609a1..6b998ee 100644 --- a/src/main/java/zone/moddev/mc/realisticdeposits/generation/MineWorkingsGenerator.java +++ b/src/main/java/zone/moddev/mc/realisticdeposits/generation/MineWorkingsGenerator.java @@ -50,9 +50,12 @@ 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.EmeraldDistrict; +import zone.moddev.mc.realisticdeposits.geometry.EmeraldMinePlanner; 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.geometry.RichEmeraldMinePlanner; import zone.moddev.mc.realisticdeposits.model.DepositDefinition; /** Builds abandoned workings after OreSpawn, without owning or replacing its ore pass. */ @@ -70,9 +73,15 @@ static void offer(OreGenerationContext context, DepositDefinition definition, Mi 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; + if (body instanceof EmeraldDistrict && ((EmeraldDistrict) body).large()) { + // A large deposit can start its workings in any zone, not just + // within 96 blocks of the first pocket. Only ore chunks can qualify. + if (((EmeraldDistrict) body).oreInChunk(context.chunkX(), context.chunkZ()).isEmpty()) return; + } else { + 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<>()); @@ -96,6 +105,7 @@ public void worldLoaded(WorldEvent.Load event) { @SubscribeEvent public void worldUnloaded(WorldEvent.Unload event) { SESSIONS.remove(event.getWorld()); + if (event.getWorld() instanceof WorldServer) EmeraldVeinTrace.unload((WorldServer) event.getWorld()); String prefix = event.getWorld().getSeed() + "/"; PENDING.keySet().removeIf(key -> key.startsWith(prefix)); } @@ -120,6 +130,8 @@ public void generate(Random ignored, int chunkX, int chunkZ, World baseWorld, if (!(baseWorld instanceof WorldServer) || baseWorld.provider.getDimension() != 0) return; WorldServer world = (WorldServer) baseWorld; + long chunkKey = MinePoint.chunkKey(chunkX, chunkZ); + List traces = EmeraldVeinTrace.take(world, chunkKey); Session session = SESSIONS.get(world); if (session == null) { // Spawn chunks can populate before Forge posts WorldEvent.Load. @@ -128,22 +140,25 @@ public void generate(Random ignored, int chunkX, int chunkZ, World baseWorld, 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; + if (candidates == null && session.data == null && traces.isEmpty()) 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 (candidates == null && session.data == null) { + EmeraldVeinTrace.place(world, chunk, traces); + return; + } 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); + if (!data.decided(candidate.key)) decide(candidate, data, session, world, generator, chunk); } } @@ -152,6 +167,9 @@ public void generate(Random ignored, int chunkX, int chunkZ, World baseWorld, place(data.plan(key), data.definition(key), chunk, world, session); data.sectionHandled(key, chunkX, chunkZ); } + if (!traces.isEmpty() && !session.existing.contains(chunkX, chunkZ)) { + EmeraldVeinTrace.place(world, chunk, traces); + } } catch (RuntimeException failure) { if (!session.warned) { LOG.warn("Abandoned workings were skipped: {}. No old chunks were excavated.", failure.getMessage()); @@ -161,7 +179,12 @@ public void generate(Random ignored, int chunkX, int chunkZ, World baseWorld, } private static void decide(Candidate candidate, MineWorldData data, Session session, - WorldServer world, IChunkGenerator generator, long currentChunk) { + WorldServer world, IChunkGenerator generator, Chunk chunk) { + long currentChunk = MinePoint.chunkKey(chunk.xPosition, chunk.zPosition); + if (candidate.body instanceof EmeraldDistrict) { + decideEmerald(candidate, data, session, world, generator, chunk, currentChunk); + return; + } MineTerrainSampler terrain = new MineTerrainSampler(world, generator); Optional proposed = AbandonedMinePlanner.plan(candidate.body, candidate.definition.abandonedMine(), terrain::naturalSurfaceY); @@ -190,6 +213,117 @@ private static void decide(Candidate candidate, MineWorldData data, Session sess } } + private static void decideEmerald(Candidate candidate, MineWorldData data, Session session, + WorldServer world, IChunkGenerator generator, Chunk chunk, long currentChunk) { + EmeraldDistrict body = (EmeraldDistrict) candidate.body; + boolean linked = body.linkedWorkings(candidate.definition); + AbandonedMinePlan plan = session.emeraldPlans.get(candidate.key); + if (plan == null) { + MineTerrainSampler terrain = new MineTerrainSampler(world, generator); + java.util.function.Predicate safe = + route -> safeEmeraldTerrain(route, terrain, candidate.definition, world); + Optional proposed = linked + ? RichEmeraldMinePlanner.plan(body, candidate.definition.abandonedMine(), + (x, z) -> terrain.surfaceY(new MinePoint(x, 0, z)), safe) + : EmeraldMinePlanner.plan(body, candidate.definition.abandonedMine(), terrain::naturalSurfaceY, safe); + if (!proposed.isPresent()) { + data.reject(candidate.key, linked ? "No safe access to multiple emerald pockets" : "No safe single-chunk access"); + return; + } + plan = proposed.get(); + if (session.emeraldPlans.size() >= 64) { + session.emeraldPlans.remove(session.emeraldPlans.keySet().iterator().next()); + } + session.emeraldPlans.put(candidate.key, plan); + } + + BlockBounds bounds = plan.bounds(); + if (session.existing.intersects(bounds) || crossesVisited(plan, session, currentChunk)) { + data.reject(candidate.key, "Existing terrain"); + } else if (plan.cellsInChunk(chunk.xPosition, chunk.zPosition).isEmpty()) { + // Do not expose even a doorway until its entire route can be checked in the + // one fresh, populated chunk. No neighbouring chunks are loaded or edited. + return; + } else { + String reason = emeraldRejection(body, plan, candidate.definition, chunk, world, session, data, true); + if (reason != null && body.large() && !"World border".equals(reason)) { + MineTerrainSampler terrain = new MineTerrainSampler(world, generator); + Optional alternative = RichEmeraldMinePlanner.planInChunk(body, + candidate.definition.abandonedMine(), chunk.xPosition, chunk.zPosition, + (x, z) -> terrain.surfaceY(new MinePoint(x, 0, z)), + route -> !session.existing.intersects(route.bounds()) && !crossesVisited(route, session, currentChunk) + && safeEmeraldTerrain(route, terrain, candidate.definition, world) + && emeraldRejection(body, route, candidate.definition, chunk, world, session, data, false) == null); + if (alternative.isPresent()) { + LOG.info("Rerouted large emerald starter workings for '{}' in the same fresh chunk: {}", + candidate.key, reason); + plan = alternative.get(); + reason = null; + } + } + if (reason != null) { + data.reject(candidate.key, reason); + } else { + data.accept(candidate.key, plan, candidate.definition); + writeSection(plan.cellsInChunk(chunk.xPosition, chunk.zPosition), chunk, world); + data.sectionHandled(candidate.key, chunk.xPosition, chunk.zPosition); + LOG.info(linked ? "Built linked emerald workings for '{}': surface={}, seam={}, blocks={}" + : "Built emerald workings for '{}': surface={}, seam={}, blocks={}", + candidate.key, plan.entrance(), plan.landing(), plan.blocks().size()); + } + } + session.emeraldPlans.remove(candidate.key); + } + + private static boolean safeEmeraldTerrain(AbandonedMinePlan route, MineTerrainSampler terrain, + DepositDefinition definition, WorldServer world) { + return terrain.surfaceY(route.entrance()) == route.entrance().y() && terrain.dry(route) + && terrain.covered(route) && biomesAllowed(route, definition, world); + } + + private static String emeraldRejection(EmeraldDistrict body, AbandonedMinePlan plan, DepositDefinition definition, + Chunk chunk, WorldServer world, Session session, MineWorldData data, boolean report) { + BlockBounds bounds = plan.bounds(); + if (!world.getWorldBorder().contains(new BlockPos(bounds.minimumX(), 64, bounds.minimumZ())) + || !world.getWorldBorder().contains(new BlockPos(bounds.maximumX(), 64, bounds.maximumZ()))) return "World border"; + if (intersectsStructure(bounds, world)) return "Another structure"; + if (overlapsSaved(plan, data)) return "Another abandoned mine"; + if (!canPlace(plan.cellsInChunk(chunk.xPosition, chunk.zPosition), definition, chunk, world, session, report)) { + return "Unsafe blocks in the approach"; + } + if (body.linkedWorkings(definition)) { + if (RichEmeraldMinePlanner.exposedPockets(body, plan, ore -> + (ore.x() >> 4) == chunk.xPosition && (ore.z() >> 4) == chunk.zPosition + && chunk.getBlockState(ore.x() & 15, ore.y(), ore.z() & 15).getBlock() == Blocks.EMERALD_ORE) < 2) { + return "Fewer than two exposed emerald pockets"; + } + } else if (!hasEmeraldFace(body, plan, chunk)) return "No exposed emerald face"; + return null; + } + + private static boolean hasEmeraldFace(EmeraldDistrict body, AbandonedMinePlan plan, Chunk chunk) { + MinePoint landing = plan.landing(); + for (MinePoint ore : body.ore()) { + int dx = ore.x() - landing.x(), dz = ore.z() - landing.z(); + int length = Math.abs(dx) + Math.abs(dz); + if ((ore.x() >> 4) != chunk.xPosition || (ore.z() >> 4) != chunk.zPosition + || ore.y() != landing.y() + 1 || (dx != 0 && dz != 0) || length < 6 || length > 12 + || chunk.getBlockState(ore.x() & 15, ore.y(), ore.z() & 15).getBlock() != Blocks.EMERALD_ORE) continue; + boolean exposed = true; + for (int step = 0; step < length && exposed; step++) { + int x = landing.x() + step * Integer.signum(dx), z = landing.z() + step * Integer.signum(dz); + exposed = plan.blocks().get(new MinePoint(x, ore.y(), z).packed()) == MineBlock.AIR + && plan.blocks().get(new MinePoint(x, ore.y() - 1, z).packed()) == MineBlock.AIR; + } + if (exposed) return true; + } + return false; + } + + private static boolean overlapsSaved(AbandonedMinePlan plan, MineWorldData data) { + return data.plans().values().stream().anyMatch(other -> overlaps(plan.bounds(), other.bounds())); + } + 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++) { @@ -264,7 +398,16 @@ private static boolean intersectsStructure(BlockBounds bounds, WorldServer world 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; + if (!cells.isEmpty() && canPlace(cells, definition, chunk, world, session)) writeSection(cells, chunk, world); + } + + private static boolean canPlace(List cells, DepositDefinition definition, + Chunk chunk, WorldServer world, Session session) { + return canPlace(cells, definition, chunk, world, session, true); + } + + private static boolean canPlace(List cells, DepositDefinition definition, + Chunk chunk, WorldServer world, Session session, boolean report) { String hostKey = definition == null ? "vanilla" : definition.id(); Set hosts = session.hosts.computeIfAbsent(hostKey, ignored -> naturalHosts(definition, world)); BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos(); @@ -273,11 +416,12 @@ private static void place(AbandonedMinePlan plan, DepositDefinition definition, 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) + if ((definition != null && definition.isEmerald() && current.getBlock() == Blocks.EMERALD_ORE) + || 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.", + if (report) LOG.info("Left abandoned workings sealed in chunk {},{}: {} at {} is not safe to replace.", chunk.xPosition, chunk.zPosition, current, point); - return; + return false; } // A newly generated lake or another mod's structure is allowed to block @@ -290,16 +434,19 @@ private static void place(AbandonedMinePlan plan, DepositDefinition definition, 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 {},{},{}.", + if (report) LOG.info("Left abandoned workings sealed in chunk {},{} to avoid opening a liquid at {},{},{}.", chunk.xPosition, chunk.zPosition, x, point.y() + dy, z); - return; + return false; } } } } } } + return true; + } + private static void writeSection(List cells, Chunk chunk, WorldServer world) { ExtendedBlockStorage[] sections = chunk.getBlockStorageArray(); for (AbandonedMinePlan.Cell cell : cells) { MinePoint point = cell.point(); @@ -401,6 +548,7 @@ private Candidate(DepositDefinition definition, MineableDepositBody body) { this.definition = definition; this.body = body; } + } private static final class Session { @@ -408,6 +556,7 @@ private static final class Session { private final Set visited = new HashSet<>(); private final Set loadedPopulated = new HashSet<>(); private final Map> hosts = new HashMap<>(); + private final Map emeraldPlans = new LinkedHashMap<>(); private boolean warned; private MineWorldData data; diff --git a/src/main/java/zone/moddev/mc/realisticdeposits/geometry/AbandonedMinePlan.java b/src/main/java/zone/moddev/mc/realisticdeposits/geometry/AbandonedMinePlan.java index 629db54..795412e 100644 --- a/src/main/java/zone/moddev/mc/realisticdeposits/geometry/AbandonedMinePlan.java +++ b/src/main/java/zone/moddev/mc/realisticdeposits/geometry/AbandonedMinePlan.java @@ -5,6 +5,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; /** A frozen, bounded set of workings, indexed by the chunks that build them. */ public final class AbandonedMinePlan { @@ -68,6 +69,7 @@ public AbandonedMinePlan(long identity, MinePoint entrance, MinePoint landing, public MinePoint landing() { return landing; } public Map blocks() { return blocks; } public BlockBounds bounds() { return bounds; } + public Set chunkKeys() { return chunks.keySet(); } public List cellsInChunk(int x, int z) { return chunks.getOrDefault(MinePoint.chunkKey(x, z), Collections.emptyList()); diff --git a/src/main/java/zone/moddev/mc/realisticdeposits/geometry/AbandonedMinePlanner.java b/src/main/java/zone/moddev/mc/realisticdeposits/geometry/AbandonedMinePlanner.java index 1ae0376..7466fbc 100644 --- a/src/main/java/zone/moddev/mc/realisticdeposits/geometry/AbandonedMinePlanner.java +++ b/src/main/java/zone/moddev/mc/realisticdeposits/geometry/AbandonedMinePlanner.java @@ -120,7 +120,7 @@ public static Optional plan(MineableDepositBody body, access.entrance(), landing, blocks)); } - private static boolean sealClosedPockets(Map blocks, MinePoint entrance, MinePoint landing) { + static boolean sealClosedPockets(Map blocks, MinePoint entrance, MinePoint landing) { Set connected = new LinkedHashSet<>(); ArrayDeque queue = new ArrayDeque<>(); queue.add(entrance); diff --git a/src/main/java/zone/moddev/mc/realisticdeposits/geometry/EmeraldDistrict.java b/src/main/java/zone/moddev/mc/realisticdeposits/geometry/EmeraldDistrict.java new file mode 100644 index 0000000..084a9e4 --- /dev/null +++ b/src/main/java/zone/moddev/mc/realisticdeposits/geometry/EmeraldDistrict.java @@ -0,0 +1,321 @@ +package zone.moddev.mc.realisticdeposits.geometry; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +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 zone.moddev.mc.realisticdeposits.model.DepositDefinition; +import zone.moddev.mc.realisticdeposits.model.EmeraldPocketSettings; + +/** A small, immutable set of ore positions shared by all intersecting chunks. */ +public final class EmeraldDistrict implements MineableDepositBody { + public interface Site { + boolean acceptsGeome(int x, int z); + boolean eligible(int x, int y, int z, int cover); + } + + private static final int POCKET_RADIUS = 3; + private static final long RICH_CHANCE_SALT = 0x793ABEE3DF7494D1L; + private static final long RICH_LAYOUT_SALT = 0x0B11A8D2984584B9L; + private static final long LARGE_CHANCE_SALT = 0xA153BE71D8EFA90BL; + private static final long LARGE_LAYOUT_SALT = 0x851A9B34679F03E1L; + private final DepositIdentity identity; + private final List> pockets; + private final List ore; + private final Map> oreChunks; + private final Set positions; + private final BlockBounds bounds; + private final int strike; + private final int cover; + private final boolean rich; + private final int zones; + private final Map> trace; + + private EmeraldDistrict(DepositIdentity identity, List> pockets, int strike, int cover, + boolean rich, int zones, boolean veinTrace, Site site) { + this.identity = identity; + this.pockets = Collections.unmodifiableList(new ArrayList<>(pockets)); + this.strike = strike; + this.cover = cover; + this.rich = rich; + this.zones = zones; + List points = new ArrayList<>(); + Map> slices = new LinkedHashMap<>(); + Set packed = new HashSet<>(); + int minX = Integer.MAX_VALUE, minY = 255, minZ = Integer.MAX_VALUE; + int maxX = Integer.MIN_VALUE, maxY = 0, maxZ = Integer.MIN_VALUE; + for (List pocket : pockets) { + for (MinePoint point : pocket) { + points.add(point); + slices.computeIfAbsent(MinePoint.chunkKey(point.x() >> 4, point.z() >> 4), key -> new ArrayList<>()).add(point); + packed.add(point.packed()); + 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()); + } + } + ore = Collections.unmodifiableList(points); + slices.replaceAll((key, cells) -> Collections.unmodifiableList(cells)); + oreChunks = Collections.unmodifiableMap(slices); + positions = Collections.unmodifiableSet(packed); + bounds = new BlockBounds(minX, minY, minZ, maxX, maxY, maxZ); + trace = veinTrace ? planTrace(site) : Collections.emptyMap(); + } + + public static int searchRadius(DepositDefinition definition) { + return definition.emerald().maximumStrike() / 2 + POCKET_RADIUS + 2; + } + + public static Optional plan(DepositDefinition definition, long worldSeed, + String dimension, int rx, int rz, Site site) { + if (!definition.isEmerald() || !definition.enabled() || !definition.dimensions().contains(dimension)) { + return Optional.empty(); + } + DepositIdentity identity = new DepositIdentity(worldSeed, dimension, definition.id(), rx, rz, definition.salt()); + if (StableHash.unit(identity.seed() ^ 0x48EA4B0F83C16B99L) >= definition.selectionChance()) { + EmeraldPocketSettings.LargeMine large = definition.emerald().large(); + return large != null && StableHash.unit(identity.seed() ^ LARGE_CHANCE_SALT) < large.chance() + ? fitLarge(definition, identity, rx, rz, large, site) : Optional.empty(); + } + Random random = new Random(identity.seed()); + EmeraldPocketSettings settings = definition.emerald(); + int size = definition.regionSpacingChunks() * 16; + long x = (long) rx * size + random.nextInt(size); + long z = (long) rz * size + random.nextInt(size); + // Keep coordinate packing and terrain sampling inside Minecraft's horizontal limits. + if (Math.abs(x) > 29_999_900 || Math.abs(z) > 29_999_900 + || !site.acceptsGeome((int) x, (int) z)) return Optional.empty(); + + int count = settings.pocketCount().sample(random); + int strike = settings.strikeLength().sample(random); + int budget = settings.oreBlocks().sample(random); + int cover = settings.surfaceCover().sample(random); + int targetY = definition.minimumY() + random.nextInt(definition.maximumY() - definition.minimumY() + 1); + double direction = random.nextDouble() * 2 * Math.PI; + EmeraldPocketSettings.RichFind rich = settings.rich(); + if (rich != null && StableHash.unit(identity.seed() ^ RICH_CHANCE_SALT) < rich.chance()) { + Random richer = new Random(identity.seed() ^ RICH_LAYOUT_SALT); + Optional larger = fitDistrict(definition, identity, site, (int) x, (int) z, + rich.pocketCount().sample(richer), rich.strikeLength().sample(richer), rich.oreBlocks().sample(richer), + cover, targetY, direction, richer, true); + if (larger.isPresent()) return larger; + } + return fitDistrict(definition, identity, site, (int) x, (int) z, count, strike, budget, + cover, targetY, direction, random, false); + } + + private static Optional fitDistrict(DepositDefinition definition, DepositIdentity identity, + Site site, int x, int z, int count, int strike, int budget, int cover, int targetY, + double direction, Random random, boolean rich) { + List> pockets = new ArrayList<>(); + Set reserved = new HashSet<>(); + for (int i = 0; i < count; i++) { + double offset = strike * (i / (double) (count - 1) - 0.5); + // Keep the full strike but bring one interior pair closer in the + // linked-workings variant. The normal and captured older layouts + // keep their original spacing and random sequence. + if (rich && definition.emerald().rich().workings() && count >= 4) { + int pair = (count - 2) / 2; + if (i == pair || i == pair + 1) { + double middle = strike * ((pair + 0.5) / (count - 1) - 0.5); + double halfGap = Math.min(4.0, strike / (2.0 * (count - 1))); + offset = middle + (i == pair ? -halfGap : halfGap); + } + } + int px = x + (int) Math.round(Math.cos(direction) * offset); + int pz = z + (int) Math.round(Math.sin(direction) * offset); + if (rich && !site.acceptsGeome(px, pz)) return Optional.empty(); + int py = targetY + random.nextInt(7) - 3; + int quota = budget / count + (i < budget % count ? 1 : 0); + List pocket = fit(definition, site, px, py, pz, cover, quota, + StableHash.combine(identity.seed(), i), reserved); + if (pocket.isEmpty()) return Optional.empty(); + pockets.add(Collections.unmodifiableList(pocket)); + reserve(pocket, reserved); + } + return Optional.of(new EmeraldDistrict(identity, pockets, strike, cover, rich, 0, + definition.emerald().veinTrace(), site)); + } + + private static Optional fitLarge(DepositDefinition definition, DepositIdentity identity, + int rx, int rz, EmeraldPocketSettings.LargeMine large, Site site) { + Random random = new Random(identity.seed() ^ LARGE_LAYOUT_SALT); + int strike = large.strikeLength().sample(random), zones = large.zones().sample(random); + int budget = large.oreBlocks().sample(random), cover = definition.emerald().surfaceCover().sample(random); + int size = definition.regionSpacingChunks() * 16; + // Keep every zone, cue and starter-working chunk clear of the region edge + // and of neighbouring small/medium deposits that may cross that edge. + int margin = strike / 2 + 32 + searchRadius(definition); + if (size <= 2 * margin) return Optional.empty(); + long centerX = (long) rx * size + margin + random.nextInt(size - 2 * margin); + long centerZ = (long) rz * size + margin + random.nextInt(size - 2 * margin); + if (Math.abs(centerX) > 29_999_800 || Math.abs(centerZ) > 29_999_800 + || !site.acceptsGeome((int) centerX, (int) centerZ)) return Optional.empty(); + int depth = definition.maximumY() - definition.minimumY() + 1; + int targetY = definition.minimumY() + depth / 3 + random.nextInt(Math.max(1, depth / 3)); + double direction = random.nextDouble() * 2 * Math.PI; + double alongX = Math.cos(direction), alongZ = Math.sin(direction); + boolean alongEastWest = Math.abs(alongX) >= Math.abs(alongZ); + int stepX = alongEastWest ? (alongX >= 0 ? 4 : -4) : 0; + int stepZ = alongEastWest ? 0 : (alongZ >= 0 ? 4 : -4); + List> pockets = new ArrayList<>(zones * 2); + Set reserved = new HashSet<>(); + for (int zone = 0; zone < zones; zone++) { + double offset = strike * (zone / (double) (zones - 1) - 0.5); + int bend = random.nextInt(11) - 5; + int x = (int) centerX + (int) Math.round(alongX * offset - alongZ * bend); + int z = (int) centerZ + (int) Math.round(alongZ * offset + alongX * bend); + int y = targetY + random.nextInt(13) - 6; + // At least the pocket centres share a chunk: a complete safe starter + // gallery may serve this zone without committing to a multi-chunk cut. + if (alongEastWest) x = clampZoneAxis(x); else z = clampZoneAxis(z); + for (int side = -1; side <= 1; side += 2) { + int px = x + stepX * side, pz = z + stepZ * side; + if (!site.acceptsGeome(px, pz)) return Optional.empty(); + int i = pockets.size(), count = zones * 2; + int quota = budget / count + (i < budget % count ? 1 : 0); + List pocket = fit(definition, site, px, y, pz, cover, quota, + StableHash.combine(identity.seed() ^ LARGE_LAYOUT_SALT, i), reserved); + if (pocket.isEmpty()) return Optional.empty(); + pockets.add(Collections.unmodifiableList(pocket)); + reserve(pocket, reserved); + } + } + return Optional.of(new EmeraldDistrict(identity, pockets, strike, cover, false, zones, true, site)); + } + + private static int clampZoneAxis(int coordinate) { + return (coordinate & ~15) + Math.max(5, Math.min(10, coordinate & 15)); + } + + private static void reserve(List pocket, Set reserved) { + for (MinePoint point : pocket) { + // Leave at least one block between pockets even after rounding the strike. + for (int dx = -1; dx <= 1; dx++) for (int dy = -1; dy <= 1; dy++) for (int dz = -1; dz <= 1; dz++) { + reserved.add(new MinePoint(point.x() + dx, point.y() + dy, point.z() + dz).packed()); + } + } + } + + // A one-block-wide non-ore trace joins the fitted pockets. Ore can interrupt + // the trace, but never gets replaced by it. + private Map> planTrace(Site site) { + Map> result = new LinkedHashMap<>(); + Set seen = new HashSet<>(); + MinePoint from = middle(pockets.get(0)); + for (int i = 1; i < pockets.size(); i++) { + MinePoint to = middle(pockets.get(i)); + int dx = to.x() - from.x(), dy = to.y() - from.y(), dz = to.z() - from.z(); + int steps = Math.max(Math.abs(dx), Math.max(Math.abs(dy), Math.abs(dz))); + int x = from.x(), y = from.y(), z = from.z(); + tracePoint(result, seen, site, x, y, z); + for (int s = 1; s <= steps; s++) { + int nx = from.x() + (int) Math.round(dx * (s / (double) steps)); + int ny = from.y() + (int) Math.round(dy * (s / (double) steps)); + int nz = from.z() + (int) Math.round(dz * (s / (double) steps)); + if (x != nx) tracePoint(result, seen, site, x = nx, y, z); + if (z != nz) tracePoint(result, seen, site, x, y, z = nz); + if (y != ny) tracePoint(result, seen, site, x, y = ny, z); + } + from = to; + } + result.replaceAll((chunk, cells) -> Collections.unmodifiableList(cells)); + return Collections.unmodifiableMap(result); + } + + private void tracePoint(Map> result, Set seen, Site site, int x, int y, int z) { + MinePoint point = new MinePoint(x, y, z); + long key = point.packed(); + if (!positions.contains(key) && seen.add(key) && site.eligible(x, y, z, cover)) { + result.computeIfAbsent(MinePoint.chunkKey(x >> 4, z >> 4), unused -> new ArrayList<>()).add(point); + } + } + + private static MinePoint middle(List pocket) { + long x = 0, y = 0, z = 0; + for (MinePoint point : pocket) { + x += point.x(); + y += point.y(); + z += point.z(); + } + int n = pocket.size(); + return new MinePoint((int) Math.round(x / (double) n), (int) Math.round(y / (double) n), + (int) Math.round(z / (double) n)); + } + + private static List fit(DepositDefinition definition, Site site, int x, int y, + int z, int cover, int quota, long salt, Set reserved) { + int target = Math.max(definition.minimumY(), Math.min(definition.maximumY(), y)); + List offsets = new ArrayList<>(); + for (int dx = -POCKET_RADIUS; dx <= POCKET_RADIUS; dx++) { + for (int dz = -POCKET_RADIUS; dz <= POCKET_RADIUS; dz++) { + for (int dy = -2; dy <= 2; dy++) { + if (dx * dx + dz * dz + 2 * dy * dy <= 11) offsets.add(new MinePoint(dx, dy, dz)); + } + } + } + offsets.sort(Comparator.comparingDouble(point -> point.x() * point.x() + point.z() * point.z() + + 2 * point.y() * point.y() + 7 * StableHash.unit(StableHash.combine(salt, point.packed())))); + for (int distance = 0; distance <= definition.maximumY() - definition.minimumY(); distance++) { + for (int direction = 1; direction >= -1; direction -= 2) { + int center = target + direction * distance; + if (center < definition.minimumY() || center > definition.maximumY()) continue; + List result = new ArrayList<>(quota); + for (MinePoint offset : offsets) { + MinePoint point = new MinePoint(x + offset.x(), center + offset.y(), z + offset.z()); + if (point.y() < definition.minimumY() || point.y() > definition.maximumY() + || reserved.contains(point.packed()) || !site.eligible(point.x(), point.y(), point.z(), cover)) { + continue; + } + result.add(point); + if (result.size() == quota) return result; + } + if (distance == 0) break; + } + } + return Collections.emptyList(); + } + + public List> pockets() { return pockets; } + public boolean rich() { return rich; } + public boolean large() { return zones != 0; } + public int zones() { return zones; } + public boolean linkedWorkings(DepositDefinition definition) { + return large() || rich && definition.emerald().rich().workings(); + } + public List oreInChunk(int chunkX, int chunkZ) { + return oreChunks.getOrDefault(MinePoint.chunkKey(chunkX, chunkZ), Collections.emptyList()); + } + public List traceInChunk(int chunkX, int chunkZ) { + return trace.getOrDefault(MinePoint.chunkKey(chunkX, chunkZ), Collections.emptyList()); + } + public List ore() { return ore; } + public int surfaceCover() { return cover; } + @Override public DepositIdentity identity() { return identity; } + @Override public int centerX() { return ore.get(0).x(); } + @Override public int centerY() { return ore.get(0).y(); } + @Override public int centerZ() { return ore.get(0).z(); } + @Override public int strikeLength() { return strike; } + @Override public BlockBounds bounds() { return bounds; } + @Override public int minimumCandidateYAt(int x, int z) { return bounds.minimumY(); } + @Override public int maximumCandidateYAt(int x, int z) { return bounds.maximumY(); } + @Override public boolean isOreAt(int x, int y, int z) { + return y >= bounds.minimumY() && y <= bounds.maximumY() && positions.contains(new MinePoint(x, y, z).packed()); + } + + @Override + public MinePoint miningRoutePoint(double along, double lateralOffset) { + // The generic planner uses this only to nominate nearby chunks. Emerald workings choose their own landing. + return new MinePoint(centerX(), Math.max(9, centerY() - 1), centerZ()); + } +} diff --git a/src/main/java/zone/moddev/mc/realisticdeposits/geometry/EmeraldMinePlanner.java b/src/main/java/zone/moddev/mc/realisticdeposits/geometry/EmeraldMinePlanner.java new file mode 100644 index 0000000..3f6ec53 --- /dev/null +++ b/src/main/java/zone/moddev/mc/realisticdeposits/geometry/EmeraldMinePlanner.java @@ -0,0 +1,126 @@ +package zone.moddev.mc.realisticdeposits.geometry; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Predicate; +import java.util.function.ToIntBiFunction; + +import zone.moddev.mc.realisticdeposits.model.AbandonedMineSettings; + +/** A single short approach to the edge of an emerald pocket. */ +public final class EmeraldMinePlanner { + private static final int[][] DIRECTIONS = {{1, 0}, {0, 1}, {-1, 0}, {0, -1}}; + + private EmeraldMinePlanner() { } + + public static Optional plan(EmeraldDistrict body, AbandonedMineSettings settings, + ToIntBiFunction surface) { + return plan(body, settings, surface, candidate -> true); + } + + public static Optional plan(EmeraldDistrict body, AbandonedMineSettings settings, + ToIntBiFunction surface, Predicate terrain) { + if (!AbandonedMinePlanner.selected(body, settings)) return Optional.empty(); + int lengths = 7; + int firstDirection = (int) (body.identity().seed() & 3); + for (List pocket : body.pockets()) { + for (int direction = 0; direction < DIRECTIONS.length; direction++) { + int[] step = DIRECTIONS[(firstDirection + direction) % DIRECTIONS.length]; + int dx = step[0], dz = step[1]; + Map edges = new LinkedHashMap<>(); + for (MinePoint point : pocket) { + long row = MinePoint.chunkKey(point.x() * dz + point.z() * dx, point.y()); + edges.merge(row, point, (left, right) -> left.x() * dx + left.z() * dz + <= right.x() * dx + right.z() * dz ? left : right); + } + for (MinePoint face : edges.values()) { + int firstLength = (int) Math.floorMod(StableHash.combine(body.identity().seed(), face.packed()), (long) lengths); + for (int attempt = 0; attempt < lengths; attempt++) { + int length = 6 + (firstLength + attempt) % lengths; + MinePoint landing = new MinePoint(face.x() - dx * length, face.y() - 1, face.z() - dz * length); + if ((landing.x() >> 4) != (face.x() >> 4) || (landing.z() >> 4) != (face.z() >> 4) + || !interior(landing.x() & 15) || !interior(landing.z() & 15)) continue; + Optional candidate = access(body, surface, terrain, landing, face, dx, dz, length); + if (candidate.isPresent()) return candidate; + } + } + } + } + return Optional.empty(); + } + + private static Optional access(EmeraldDistrict body, + ToIntBiFunction surface, Predicate terrain, + MinePoint landing, MinePoint face, int dx, int dz, int length) { + int initial = surface.applyAsInt(landing.x(), landing.z()); + if (initial < 0) return Optional.empty(); + int firstFlight = (int) ((body.identity().seed() >>> 2) & 3); + for (int span = 8; span >= 6; span -= 2) { + for (int attempt = 0; attempt < 4; attempt++) { + int forward = (firstFlight + attempt) % 4; + for (int turn = 1; turn <= 3; turn += 2) { + int[] a = DIRECTIONS[forward], b = DIRECTIONS[(forward + turn) % 4]; + int farX = (landing.x() & 15) + (a[0] + b[0]) * span; + int farZ = (landing.z() & 15) + (a[1] + b[1]) * span; + // Two blocks for supports, one more for the final liquid check. + if (!interior(landing.x() & 15) || !interior(landing.z() & 15) + || !interior(farX) || !interior(farZ)) continue; + for (int offset = 0; offset <= 16; offset++) { + for (int sign = 1; sign >= -1; sign -= 2) { + int height = initial + offset * sign; + if (height >= landing.y() + 6 && height <= 245 + && height - landing.y() <= AbandonedMinePlanner.MAXIMUM_SHAFT_DEPTH) { + MineDecline access = MineDecline.compact(landing, height, forward, turn, span); + MinePoint portal = access.entrance(); + if (surface.applyAsInt(portal.x(), portal.z()) == height) { + Optional candidate = build(body, landing, face, dx, dz, length, access); + if (candidate.isPresent() && terrain.test(candidate.get())) return candidate; + } + } + if (offset == 0) break; + } + } + } + } + } + return Optional.empty(); + } + + private static boolean interior(int local) { return local >= 3 && local <= 12; } + + private static Optional build(EmeraldDistrict body, MinePoint landing, + MinePoint face, int dx, int dz, int length, MineDecline access) { + Map blocks = new LinkedHashMap<>(); + for (int along = 0; along < length; along++) { + for (int across = -1; across <= 1; across++) { + int x = landing.x() + along * dx + across * dz; + int z = landing.z() + along * dz - across * dx; + blocks.put(new MinePoint(x, landing.y() - 1, z).packed(), MineBlock.PLANKS); + for (int height = 0; height < 3; height++) { + blocks.put(new MinePoint(x, landing.y() + height, z).packed(), MineBlock.AIR); + } + } + } + access.build(blocks); + if (!AbandonedMinePlanner.sealClosedPockets(blocks, access.entrance(), landing) + || !access.hasClearSteps(blocks)) return Optional.empty(); + for (int along = 0; along < length; along++) { + for (int height = 0; height < 2; height++) { + if (blocks.get(new MinePoint(landing.x() + along * dx, landing.y() + height, + landing.z() + along * dz).packed()) != MineBlock.AIR) return Optional.empty(); + } + } + for (long position : blocks.keySet()) { + MinePoint point = MinePoint.unpack(position); + int x = point.x() & 15, z = point.z() & 15; + if ((point.x() >> 4) != (face.x() >> 4) || (point.z() >> 4) != (face.z() >> 4) + || x == 0 || x == 15 || z == 0 || z == 15) return Optional.empty(); + } + for (MinePoint ore : body.ore()) { + if (blocks.containsKey(ore.packed())) return Optional.empty(); + } + return Optional.of(new AbandonedMinePlan(body.identity().seed(), access.entrance(), landing, blocks)); + } +} diff --git a/src/main/java/zone/moddev/mc/realisticdeposits/geometry/MineDecline.java b/src/main/java/zone/moddev/mc/realisticdeposits/geometry/MineDecline.java index 5c00dc5..fc82828 100644 --- a/src/main/java/zone/moddev/mc/realisticdeposits/geometry/MineDecline.java +++ b/src/main/java/zone/moddev/mc/realisticdeposits/geometry/MineDecline.java @@ -64,10 +64,42 @@ public List steps() { return steps; } + /** Compact four-turn flights for a mine that must be carved in one chunk. */ + static MineDecline compact(MinePoint landing, int surfaceY, int forward, int turn, int span) { + int x = landing.x(), y = landing.y(), z = landing.z(); + List steps = new ArrayList<>(); + steps.add(new Step(landing, forward, false)); + while (y < surfaceY) { + // Flat blocks on both sides of each turn keep the stairs out of the corner. + for (int run = 1; run <= span && y < surfaceY; run++) { + x += DX[forward]; + z += DZ[forward]; + boolean stair = run % 2 == 0 && run < span; + if (run > 2 && run % 2 != 0) y++; + steps.add(new Step(new MinePoint(x, y, z), forward, stair)); + } + forward = (forward + turn) % 4; + } + return new MineDecline(steps); + } + public MinePoint entrance() { return steps.get(steps.size() - 1).point(); } + boolean hasClearSteps(Map blocks) { + for (Step step : steps) { + MinePoint p = step.point; + int floor = p.y() - (step.stairs ? 0 : 1); + MineBlock expected = step.stairs ? stair(step.direction) : MineBlock.PLANKS; + if (blocks.get(new MinePoint(p.x(), floor, p.z()).packed()) != expected) return false; + for (int dy = 1; dy <= 2; dy++) { + if (blocks.get(new MinePoint(p.x(), floor + dy, p.z()).packed()) != MineBlock.AIR) return false; + } + } + return true; + } + void build(Map blocks) { Map floors = new LinkedHashMap<>(); Set air = new LinkedHashSet<>(); diff --git a/src/main/java/zone/moddev/mc/realisticdeposits/geometry/RichEmeraldMinePlanner.java b/src/main/java/zone/moddev/mc/realisticdeposits/geometry/RichEmeraldMinePlanner.java new file mode 100644 index 0000000..08278b1 --- /dev/null +++ b/src/main/java/zone/moddev/mc/realisticdeposits/geometry/RichEmeraldMinePlanner.java @@ -0,0 +1,259 @@ +package zone.moddev.mc.realisticdeposits.geometry; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Predicate; +import java.util.function.ToIntBiFunction; + +import zone.moddev.mc.realisticdeposits.model.AbandonedMineSettings; + +/** Extends compact access with timbered, level headings to two or three pockets. */ +public final class RichEmeraldMinePlanner { + private static final int[][] SIDES = {{1, 0}, {0, 1}, {-1, 0}, {0, -1}}; + private static final int MAX_GALLERY = 64; + + private RichEmeraldMinePlanner() { } + + public static Optional plan(EmeraldDistrict district, AbandonedMineSettings settings, + ToIntBiFunction portalSurface, Predicate safe) { + return plan(district, settings, portalSurface, level -> true, new CandidateChecks(safe, Integer.MAX_VALUE)); + } + + /** Recheck a bounded number of alternatives inside the already nominated fresh chunk. */ + public static Optional planInChunk(EmeraldDistrict district, AbandonedMineSettings settings, + int chunkX, int chunkZ, ToIntBiFunction portalSurface, Predicate safe) { + return plan(district, settings, portalSurface, level -> (level.x() >> 4) == chunkX && (level.z() >> 4) == chunkZ, + new CandidateChecks(safe, 48)); + } + + private static Optional plan(EmeraldDistrict district, AbandonedMineSettings settings, + ToIntBiFunction portalSurface, Predicate eligibleLevel, CandidateChecks checks) { + if (!(district.rich() || district.large()) || !AbandonedMinePlanner.selected(district, settings)) return Optional.empty(); + // Nominate only chunk levels where two distinct pockets could share a gallery. + Map levels = new LinkedHashMap<>(); + for (List pocket : district.pockets()) { + Set faces = new LinkedHashSet<>(); + for (MinePoint ore : pocket) { + int x = (ore.x() >> 4) << 4, z = (ore.z() >> 4) << 4; + faces.add(new MinePoint(x, ore.y() - 1, z)); + faces.add(new MinePoint(x, ore.y(), z)); + } + for (MinePoint face : faces) levels.merge(face, 1, Integer::sum); + } + for (Map.Entry level : levels.entrySet()) { + if (checks.remaining == 0) break; + if (level.getValue() < 2 || !eligibleLevel.test(level.getKey())) continue; + Optional plan = access(district, level.getKey(), portalSurface, checks); + if (plan.isPresent()) return plan; + } + return Optional.empty(); + } + + private static Optional access(EmeraldDistrict body, MinePoint level, + ToIntBiFunction portalSurface, CandidateChecks checks) { + int firstDirection = (int) (body.identity().seed() & 3); + int[][] heights = new int[13][13]; + for (int x = 3; x <= 12; x++) for (int z = 3; z <= 12; z++) { + heights[x][z] = portalSurface.applyAsInt(level.x() + x, level.z() + z); + } + // Project compact flights back from an eligible portal. Landings and + // headings are free to turn, unlike the small-find straight crosscut. + for (int x = 3; x <= 12; x++) for (int z = 3; z <= 12; z++) { + int surface = heights[x][z]; + if (surface < level.y() + 6 || surface > 245 + || surface - level.y() > AbandonedMinePlanner.MAXIMUM_SHAFT_DEPTH) continue; + for (int span : new int[] {8, 6}) for (int attempt = 0; attempt < 4; attempt++) { + int forward = (firstDirection + attempt) & 3; + for (int turn : new int[] {1, 3}) { + MinePoint origin = new MinePoint(0, level.y(), 0); + MinePoint relativePortal = MineDecline.compact(origin, surface, forward, turn, span).entrance(); + int lx = x - relativePortal.x(), lz = z - relativePortal.z(); + int across = (forward + turn) & 3; + int farX = lx + (SIDES[forward][0] + SIDES[across][0]) * span; + int farZ = lz + (SIDES[forward][1] + SIDES[across][1]) * span; + if (!interior(lx) || !interior(lz) || !interior(farX) || !interior(farZ)) continue; + MinePoint landing = new MinePoint(level.x() + lx, level.y(), level.z() + lz); + if (body.isOreAt(landing.x(), landing.y(), landing.z())) continue; + MineDecline stairs = MineDecline.compact(landing, surface, forward, turn, span); + Map blocks = new LinkedHashMap<>(); + stairs.build(blocks); + if (!AbandonedMinePlanner.sealClosedPockets(blocks, stairs.entrance(), landing) + || !stairs.hasClearSteps(blocks) || !validAccess(body, landing, blocks)) continue; + AbandonedMinePlan linked = extend(body, new AbandonedMinePlan(body.identity().seed(), + stairs.entrance(), landing, blocks)); + if (linked != null) { + if (checks.accept(linked)) return Optional.of(linked); + if (checks.remaining == 0) return Optional.empty(); + } + } + } + } + return Optional.empty(); + } + + private static final class CandidateChecks { + private final Predicate safe; + private int remaining; + + private CandidateChecks(Predicate safe, int remaining) { + this.safe = safe; + this.remaining = remaining; + } + + private boolean accept(AbandonedMinePlan plan) { + remaining--; + return safe.test(plan); + } + } + + private static boolean interior(int local) { return local >= 3 && local <= 12; } + + private static boolean validAccess(EmeraldDistrict body, MinePoint landing, Map blocks) { + for (long packed : blocks.keySet()) { + MinePoint point = MinePoint.unpack(packed); + int x = point.x() & 15, z = point.z() & 15; + if (!sameChunk(point, landing) || x == 0 || x == 15 || z == 0 || z == 15 + || body.isOreAt(point.x(), point.y(), point.z())) return false; + } + return true; + } + + private static AbandonedMinePlan extend(EmeraldDistrict body, AbandonedMinePlan base) { + MinePoint landing = base.landing(); + int y = landing.y(); + Map blocks = new LinkedHashMap<>(base.blocks()); + Predicate inMineChunk = ore -> sameChunk(ore, landing); + int length = 0; + while (length < MAX_GALLERY) { + Set floor = reachableFloor(blocks, landing); + if (exposedPockets(body, floor, y, inMineChunk) >= 3) break; + Set targets = new LinkedHashSet<>(); + for (List pocket : body.pockets()) { + if (exposes(pocket, floor, y, inMineChunk)) continue; + for (MinePoint ore : pocket) { + if (!sameChunk(ore, landing) || ore.y() != y && ore.y() != y + 1) continue; + for (int[] side : SIDES) { + targets.add(new MinePoint(ore.x() + side[0], y, ore.z() + side[1]).packed()); + } + } + } + List route = findRoute(body, landing, blocks, floor, targets, MAX_GALLERY - length); + if (route.isEmpty()) break; + for (MinePoint step : route) { + put(blocks, step, -1, MineBlock.PLANKS); + put(blocks, step, 0, MineBlock.AIR); + put(blocks, step, 1, MineBlock.AIR); + put(blocks, step, 2, ++length % 7 == 0 ? MineBlock.PLANKS : MineBlock.AIR); + } + } + if (length == 0 || exposedPockets(body, reachableFloor(blocks, landing), y, inMineChunk) < 2) return null; + return new AbandonedMinePlan(base.identity(), base.entrance(), base.landing(), blocks); + } + + private static void put(Map blocks, MinePoint step, int dy, MineBlock block) { + blocks.putIfAbsent(new MinePoint(step.x(), step.y() + dy, step.z()).packed(), block); + } + + private static List findRoute(EmeraldDistrict body, MinePoint landing, + Map blocks, Set floor, Set targets, int maximum) { + ArrayDeque queue = new ArrayDeque<>(floor); + Map parent = new LinkedHashMap<>(); + for (long root : floor) parent.put(root, root); + int depth = 0; + while (!queue.isEmpty() && depth <= maximum && !targets.isEmpty()) { + for (int width = queue.size(); width > 0; width--) { + long here = queue.removeFirst(); + if (targets.contains(here)) { + List path = new ArrayList<>(); + for (long cursor = here; parent.get(cursor) != cursor; cursor = parent.get(cursor)) { + path.add(MinePoint.unpack(cursor)); + } + Collections.reverse(path); + return path; + } + if (depth == maximum) continue; + MinePoint point = MinePoint.unpack(here); + for (int[] side : SIDES) { + int x = point.x() + side[0], z = point.z() + side[1]; + MinePoint next = new MinePoint(x, point.y(), z); + // Leave the existing one-block safety buffer at the chunk boundary. + if (!sameChunk(next, landing) || (x & 15) == 0 || (x & 15) == 15 + || (z & 15) == 0 || (z & 15) == 15) continue; + if (!parent.containsKey(next.packed()) && canRoute(body, blocks, next)) { + parent.put(next.packed(), here); + queue.addLast(next.packed()); + } + } + } + depth++; + } + return Collections.emptyList(); + } + + private static Set reachableFloor(Map blocks, MinePoint landing) { + Set floor = new LinkedHashSet<>(); + ArrayDeque queue = new ArrayDeque<>(); + floor.add(landing.packed()); + queue.add(landing); + while (!queue.isEmpty()) { + MinePoint here = queue.removeFirst(); + int y = here.y(); + for (int[] side : SIDES) { + int x = here.x() + side[0], z = here.z() + side[1]; + MinePoint p = new MinePoint(x, y, z); + if (blocks.get(new MinePoint(x, y - 1, z).packed()) == MineBlock.PLANKS + && blocks.get(p.packed()) == MineBlock.AIR + && blocks.get(new MinePoint(x, y + 1, z).packed()) == MineBlock.AIR + && floor.add(p.packed())) queue.addLast(p); + } + } + return floor; + } + + private static boolean sameChunk(MinePoint p, MinePoint q) { + return (p.x() >> 4) == (q.x() >> 4) && (p.z() >> 4) == (q.z() >> 4); + } + + private static boolean canRoute(EmeraldDistrict body, Map blocks, MinePoint p) { + for (int dy = -1; dy <= 2; dy++) { + int y = p.y() + dy; + if (body.isOreAt(p.x(), y, p.z())) return false; + MineBlock existing = blocks.get(new MinePoint(p.x(), y, p.z()).packed()); + if (existing != null && (dy == -1 ? existing != MineBlock.PLANKS + : dy < 2 ? existing != MineBlock.AIR : existing != MineBlock.AIR && existing != MineBlock.PLANKS)) { + return false; + } + } + return true; + } + + public static int exposedPockets(EmeraldDistrict body, AbandonedMinePlan plan, Predicate orePresent) { + return exposedPockets(body, reachableFloor(plan.blocks(), plan.landing()), plan.landing().y(), orePresent); + } + + private static int exposedPockets(EmeraldDistrict body, Set floor, int y, + Predicate orePresent) { + int count = 0; + for (List pocket : body.pockets()) if (exposes(pocket, floor, y, orePresent)) count++; + return count; + } + + private static boolean exposes(List pocket, Set floor, int y, + Predicate orePresent) { + for (MinePoint ore : pocket) { + if (ore.y() != y && ore.y() != y + 1) continue; + for (int[] side : SIDES) { + int x = ore.x() + side[0], z = ore.z() + side[1]; + if (floor.contains(new MinePoint(x, y, z).packed()) && orePresent.test(ore)) return true; + } + } + return false; + } +} 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 9ca0a9b..405d39e 100644 --- a/src/main/java/zone/moddev/mc/realisticdeposits/integration/OreSpawnIntegration.java +++ b/src/main/java/zone/moddev/mc/realisticdeposits/integration/OreSpawnIntegration.java @@ -25,6 +25,8 @@ public final class OreSpawnIntegration { new ResourceLocation("realisticdeposits", "gold_placer"); public static final ResourceLocation LAPIS_LENS_ID = new ResourceLocation("realisticdeposits", "lapis_lens"); + public static final ResourceLocation EMERALD_POCKETS_ID = + new ResourceLocation("realisticdeposits", "emerald_pockets"); private OreSpawnIntegration() { } @@ -62,6 +64,7 @@ private static ResourceLocation patternId(DepositDefinition definition) { case "gold_lode": return GOLD_LODE_ID; case "gold_placer": return GOLD_PLACER_ID; case "lapis_lens": return LAPIS_LENS_ID; + case "emerald_pockets": return EMERALD_POCKETS_ID; default: throw new IllegalArgumentException("Unsupported shape: " + definition.shape().type()); } } @@ -100,7 +103,8 @@ private static void add(WorldgenProvider.Builder provider, DepositDefinition def final DepositDefinition linkedSource = source; provider.ore(oreId, output, ore -> { ore.retrogen(false).suppressVanilla(false); - if (definition.isCoal() || definition.isDiamond() || definition.isGold() || definition.isLapis()) { + if (definition.isCoal() || definition.isDiamond() || definition.isGold() + || definition.isLapis() || definition.isEmerald()) { ore.enabled(definition.enabled() && (linkedSource == null || linkedSource.enabled())); } if (definition.isDiamondPlacer()) { @@ -121,12 +125,13 @@ private static void add(WorldgenProvider.Builder provider, DepositDefinition def if (net.minecraft.block.Block.REGISTRY.containsKey(id)) dimension.hostState(id, 0); if ((definition.isDiamondPlacer() || definition.isGoldPlacer()) && "minecraft:sand".equals(host)) dimension.hostState(id, 1); - } else if ((definition.isDiamondPipe() || definition.isGoldLode() || definition.isLapis()) + } else if ((definition.isDiamondPipe() || definition.isGoldLode() + || definition.isLapis() || definition.isEmerald()) && "minecraft:stone".equals(host)) { for (int metadata : new int[] {0, 1, 3, 5}) { dimension.hostState(new ResourceLocation(host), metadata); } - } else if (!definition.isLapis()) { + } else if (!definition.isLapis() && !definition.isEmerald()) { dimension.hostBlock(new ResourceLocation(host)); } } @@ -134,10 +139,14 @@ private static void add(WorldgenProvider.Builder provider, DepositDefinition def ResourceLocation marble = new ResourceLocation("mineralogy", "marble"); if (net.minecraft.block.Block.REGISTRY.containsKey(marble)) dimension.hostState(marble, 0); } + if (definition.isEmerald()) { + ResourceLocation schist = new ResourceLocation("mineralogy", "schist"); + if (net.minecraft.block.Block.REGISTRY.containsKey(schist)) dimension.hostState(schist, 0); + } // Every geology family is a safe outer host. ROCK_ALIGNED applies the // current live family selection per output block inside the pattern. if (!definition.isCoal() && !definition.isDiamondPlacer() - && !definition.isGoldPlacer() && !definition.isLapis()) { + && !definition.isGoldPlacer() && !definition.isLapis() && !definition.isEmerald()) { for (GeologyFamily family : GeologyFamily.values()) dimension.hostFamily(family); } for (String biome : definition.biomeIds()) dimension.biome(new ResourceLocation(biome)); diff --git a/src/main/java/zone/moddev/mc/realisticdeposits/model/DepositDefinition.java b/src/main/java/zone/moddev/mc/realisticdeposits/model/DepositDefinition.java index 528327e..ffb7277 100644 --- a/src/main/java/zone/moddev/mc/realisticdeposits/model/DepositDefinition.java +++ b/src/main/java/zone/moddev/mc/realisticdeposits/model/DepositDefinition.java @@ -113,6 +113,11 @@ public DepositDefinition(String id, boolean enabled, String resource, this.salt = salt; this.shape = Objects.requireNonNull(seam, "shape"); this.abandonedMine = Objects.requireNonNull(abandonedMine, "abandonedMine"); + if (isEmerald() && (!"minecraft:emerald_ore".equals(this.resource) + || !this.dimensions.equals(java.util.Collections.singleton("minecraft:overworld")) + || alignment != AlignmentMode.GEOME_ALIGNED)) { + throw new IllegalArgumentException("Emerald pockets require geome-aligned Overworld emerald ore: " + id); + } if ((isDiamondPlacer() || isGoldPlacer()) && abandonedMine.enabled()) { throw new IllegalArgumentException("River placers do not support underground workings: " + id); } @@ -184,6 +189,8 @@ public OptionalDouble effectiveBackgroundGenerationScale() { public boolean isGold() { return isGoldLode() || isGoldPlacer(); } public boolean isLapis() { return shape instanceof LapisLensSettings; } public LapisLensSettings lapis() { return (LapisLensSettings) shape; } + public boolean isEmerald() { return shape instanceof EmeraldPocketSettings; } + public EmeraldPocketSettings emerald() { return (EmeraldPocketSettings) shape; } public GoldLodeSettings lode() { return (GoldLodeSettings) shape; } public GoldPlacerSettings goldPlacer() { return (GoldPlacerSettings) shape; } public DiamondPipeSettings pipe() { return (DiamondPipeSettings) shape; } @@ -207,7 +214,8 @@ public String structuralFingerprint() { + (isCoal() ? "|shape:coal_measures" : "") + (isDiamond() ? "|diamond:" + toJson() : "") + (isGold() ? "|gold:" + toJson() : "") - + (isLapis() ? "|lapis:" + toJson() : ""); + + (isLapis() ? "|lapis:" + toJson() : "") + + (isEmerald() ? "|emerald:" + toJson() : ""); } /** Deterministic schema-1 representation embedded in OreSpawn pattern settings. */ diff --git a/src/main/java/zone/moddev/mc/realisticdeposits/model/EmeraldPocketSettings.java b/src/main/java/zone/moddev/mc/realisticdeposits/model/EmeraldPocketSettings.java new file mode 100644 index 0000000..5499c90 --- /dev/null +++ b/src/main/java/zone/moddev/mc/realisticdeposits/model/EmeraldPocketSettings.java @@ -0,0 +1,151 @@ +package zone.moddev.mc.realisticdeposits.model; + +import com.google.gson.JsonObject; + +/** Bounded geometry for a group of separated emerald pockets. */ +public final class EmeraldPocketSettings implements DepositShapeSettings { + private final IntRange pockets; + private final IntRange strike; + private final IntRange oreBlocks; + private final IntRange cover; + private final RichFind rich; + private final LargeMine large; + private final boolean veinTrace; + + public EmeraldPocketSettings(IntRange pockets, IntRange strike, IntRange oreBlocks, IntRange cover) { + this(pockets, strike, oreBlocks, cover, null, false); + } + + public EmeraldPocketSettings(IntRange pockets, IntRange strike, IntRange oreBlocks, IntRange cover, + RichFind rich, boolean veinTrace) { + this(pockets, strike, oreBlocks, cover, rich, veinTrace, null); + } + + public EmeraldPocketSettings(IntRange pockets, IntRange strike, IntRange oreBlocks, IntRange cover, + RichFind rich, boolean veinTrace, LargeMine large) { + this.pockets = checked(pockets, 2, 6, "pocket count"); + this.strike = checked(strike, 8, 96, "strike length"); + this.oreBlocks = checked(oreBlocks, 2, 120, "ore budget"); + this.cover = checked(cover, 1, 64, "surface cover"); + this.rich = rich; + this.large = large; + this.veinTrace = veinTrace; + validateSpacing(pockets, strike, oreBlocks); + if (rich != null && (rich.oreBlocks().minimum() <= oreBlocks.maximum() + || rich.strikeLength().minimum() < strike.maximum() || rich.pocketCount().minimum() < pockets.maximum())) { + throw new IllegalArgumentException("Rich emerald finds must increase the budget without shrinking the trend"); + } + } + + @Override public String type() { return "emerald_pockets"; } + public IntRange pocketCount() { return pockets; } + public IntRange strikeLength() { return strike; } + public IntRange oreBlocks() { return oreBlocks; } + public IntRange surfaceCover() { return cover; } + public RichFind rich() { return rich; } + public LargeMine large() { return large; } + public boolean veinTrace() { return veinTrace; } + public int maximumStrike() { return rich == null ? strike.maximum() : rich.strikeLength().maximum(); } + + @Override + public JsonObject toJson() { + JsonObject json = new JsonObject(); + json.addProperty("type", type()); + json.add("pockets", DiamondPipeSettings.array(pockets)); + json.add("strike_length", DiamondPipeSettings.array(strike)); + json.add("ore_blocks", DiamondPipeSettings.array(oreBlocks)); + json.add("surface_cover", DiamondPipeSettings.array(cover)); + if (rich != null) json.add("rich_find", rich.toJson()); + if (large != null) json.add("large_mine", large.toJson()); + if (veinTrace) json.addProperty("vein_trace", true); + return json; + } + + private static void validateSpacing(IntRange pockets, IntRange strike, IntRange oreBlocks) { + if (strike.minimum() < 8 * (pockets.maximum() - 1) || oreBlocks.minimum() < pockets.maximum()) { + throw new IllegalArgumentException("Emerald pockets need room to separate and ore in every pocket"); + } + } + + public static final class RichFind { + private final double chance; + private final IntRange pockets; + private final IntRange strike; + private final IntRange oreBlocks; + private final boolean workings; + + public RichFind(double chance, IntRange pockets, IntRange strike, IntRange oreBlocks) { + this(chance, pockets, strike, oreBlocks, false); + } + + public RichFind(double chance, IntRange pockets, IntRange strike, IntRange oreBlocks, boolean workings) { + if (!Double.isFinite(chance) || chance <= 0 || chance > 1) { + throw new IllegalArgumentException("Invalid rich emerald find chance"); + } + this.chance = chance; + this.pockets = checked(pockets, 2, 6, "rich pocket count"); + this.strike = checked(strike, 8, 96, "rich strike length"); + this.oreBlocks = checked(oreBlocks, 2, 120, "rich ore budget"); + this.workings = workings; + validateSpacing(pockets, strike, oreBlocks); + } + + public double chance() { return chance; } + public IntRange pocketCount() { return pockets; } + public IntRange strikeLength() { return strike; } + public IntRange oreBlocks() { return oreBlocks; } + public boolean workings() { return workings; } + + private JsonObject toJson() { + JsonObject json = new JsonObject(); + json.addProperty("chance", chance); + json.add("pockets", DiamondPipeSettings.array(pockets)); + json.add("strike_length", DiamondPipeSettings.array(strike)); + json.add("ore_blocks", DiamondPipeSettings.array(oreBlocks)); + if (workings) json.addProperty("workings", true); + return json; + } + } + + public static final class LargeMine { + private final double chance; + private final IntRange zones; + private final IntRange strike; + private final IntRange oreBlocks; + + public LargeMine(double chance, IntRange zones, IntRange strike, IntRange oreBlocks) { + if (!Double.isFinite(chance) || chance <= 0 || chance > 1) { + throw new IllegalArgumentException("Invalid large emerald mine chance"); + } + this.chance = chance; + this.zones = checked(zones, 3, 6, "large zone count"); + this.strike = checked(strike, 96, 192, "large strike length"); + this.oreBlocks = checked(oreBlocks, 200, 500, "large ore budget"); + if (strike.minimum() < 24 * (zones.maximum() - 1) + || oreBlocks.maximum() > 108 * zones.minimum()) { + throw new IllegalArgumentException("Large emerald zones need separation and at most 54 ore per pocket"); + } + } + + public double chance() { return chance; } + public IntRange zones() { return zones; } + public IntRange strikeLength() { return strike; } + public IntRange oreBlocks() { return oreBlocks; } + + private JsonObject toJson() { + JsonObject json = new JsonObject(); + json.addProperty("chance", chance); + json.add("zones", DiamondPipeSettings.array(zones)); + json.add("strike_length", DiamondPipeSettings.array(strike)); + json.add("ore_blocks", DiamondPipeSettings.array(oreBlocks)); + return json; + } + } + + private static IntRange checked(IntRange value, int minimum, int maximum, String name) { + if (value.minimum() < minimum || value.maximum() > maximum) { + throw new IllegalArgumentException("Invalid emerald " + name); + } + return value; + } +} diff --git a/src/main/resources/assets/realisticdeposits/defaults/emerald.json b/src/main/resources/assets/realisticdeposits/defaults/emerald.json new file mode 100644 index 0000000..f337f4a --- /dev/null +++ b/src/main/resources/assets/realisticdeposits/defaults/emerald.json @@ -0,0 +1,43 @@ +{ + "schema_version": 1, + "catalog": "realisticdeposits:emerald", + "deposits": [ + { + "id": "realisticdeposits:overworld_emerald_pockets", + "enabled": true, + "resource": "minecraft:emerald_ore", + "alignment": "geome_aligned", + "interaction": "additive", + "dimensions": ["minecraft:overworld"], + "hosts": ["minecraft:stone"], + "geomes": ["orespawn:mountain_belt"], + "min_y": 12, + "max_y": 48, + "region_spacing_chunks": 64, + "selection_chance": 0.35, + "salt": 4052198035515329307, + "abandoned_mine": {"enabled": true, "chance": 1.0}, + "shape": { + "type": "emerald_pockets", + "pockets": [2, 4], + "strike_length": [24, 48], + "ore_blocks": [25, 60], + "surface_cover": [8, 8], + "vein_trace": true, + "rich_find": { + "chance": 0.08, + "pockets": [4, 6], + "strike_length": [52, 80], + "ore_blocks": [70, 120], + "workings": true + }, + "large_mine": { + "chance": 0.08, + "zones": [4, 5], + "strike_length": [120, 168], + "ore_blocks": [260, 420] + } + } + } + ] +} diff --git a/src/test/java/zone/moddev/mc/realisticdeposits/ProjectIdentityTest.java b/src/test/java/zone/moddev/mc/realisticdeposits/ProjectIdentityTest.java index 5cdb821..670bea9 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.5.0.110021", RealisticDeposits.VERSION); + assertEquals("0.6.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.5.110021,5.0.0)", annotation.dependencies()); + assertEquals("required-after:orespawn@[4.1.6.110021,5.0.0)", annotation.dependencies()); } @Test @@ -31,11 +31,11 @@ void pinsThePublishedOreSpawnRelease() throws IOException { properties.load(input); } - assertEquals("4.1.5.110021", properties.getProperty("orespawn_version")); - assertEquals("[4.1.5.110021,5.0.0)", properties.getProperty("orespawn_version_range")); + assertEquals("4.1.6.110021", properties.getProperty("orespawn_version")); + assertEquals("[4.1.6.110021,5.0.0)", properties.getProperty("orespawn_version_range")); assertEquals("245586", properties.getProperty("orespawn_curse_project_id")); - assertEquals("9397156", properties.getProperty("orespawn_curse_file_id")); - assertEquals("1E580EBB8D93B1256E2E81C7D0D815DEAAAC58A028CB5A77A52773B62048E010", + assertEquals("9518063", properties.getProperty("orespawn_curse_file_id")); + assertEquals("05DB7CBFAADC815D6A713DACDFB9F0647BCED49CCFC357FB2FF010E95BD1379F", properties.getProperty("orespawn_sha256")); } } diff --git a/src/test/java/zone/moddev/mc/realisticdeposits/config/MineDefaultsTest.java b/src/test/java/zone/moddev/mc/realisticdeposits/config/MineDefaultsTest.java index 1d47301..3be93f8 100644 --- a/src/test/java/zone/moddev/mc/realisticdeposits/config/MineDefaultsTest.java +++ b/src/test/java/zone/moddev/mc/realisticdeposits/config/MineDefaultsTest.java @@ -16,7 +16,7 @@ class MineDefaultsTest { @Test void everyNewUndergroundDepositAttemptsWorkings() { - for (String name : new String[] {"vanilla", "coal", "diamonds", "gold", "lapis"}) { + for (String name : new String[] {"vanilla", "coal", "diamonds", "gold", "lapis", "emerald"}) { for (DepositDefinition definition : catalogue(name).definitions()) { if (definition.isDiamondPlacer() || definition.isGoldPlacer()) { assertFalse(definition.abandonedMine().enabled()); @@ -46,7 +46,7 @@ void diamondPipesCanCaptureWorkingsWithoutMovingTheDeposit() { @Test void oldProfilesAndExplicitOptOutsStayOff() { - for (String name : new String[] {"vanilla", "coal", "diamonds", "gold", "lapis"}) { + for (String name : new String[] {"vanilla", "coal", "diamonds", "gold", "lapis", "emerald"}) { for (DepositDefinition definition : catalogue(name).definitions()) { JsonObject legacy = definition.toJson(); legacy.remove("abandoned_mine"); diff --git a/src/test/java/zone/moddev/mc/realisticdeposits/generation/EmeraldClientProbe.java b/src/test/java/zone/moddev/mc/realisticdeposits/generation/EmeraldClientProbe.java new file mode 100644 index 0000000..91ab757 --- /dev/null +++ b/src/test/java/zone/moddev/mc/realisticdeposits/generation/EmeraldClientProbe.java @@ -0,0 +1,173 @@ +package zone.moddev.mc.realisticdeposits.generation; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiMainMenu; +import net.minecraft.entity.player.EntityPlayerMP; +import net.minecraft.world.GameType; +import net.minecraft.world.WorldServer; +import net.minecraft.world.WorldSettings; +import net.minecraft.world.WorldType; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.fml.common.Loader; +import net.minecraftforge.fml.common.Mod; +import net.minecraftforge.fml.common.event.FMLInitializationEvent; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; +import net.minecraftforge.fml.common.gameevent.TickEvent; + +import zone.moddev.mc.realisticdeposits.geometry.AbandonedMinePlan; +import zone.moddev.mc.realisticdeposits.geometry.EmeraldDistrict; +import zone.moddev.mc.realisticdeposits.geometry.EmeraldWalkProbe; +import zone.moddev.mc.realisticdeposits.geometry.MinePoint; +import zone.moddev.mc.realisticdeposits.config.RealisticDepositsRuntime; +import zone.moddev.mc.realisticdeposits.model.DepositDefinition; + +/** The ordinary client and natural player-driven chunk loading in a disposable save. */ +@Mod(modid = "rdemeraldclientprobe", name = "Emerald Client Checks", version = "1", clientSideOnly = true, + dependencies = "required-after:realisticdeposits") +public final class EmeraldClientProbe { + private static final String WORLD = "emerald-client-test-world"; + private int phase; + private int ticks; + private volatile boolean finished; + private volatile List targets; + private volatile int index; + + @Mod.EventHandler + public void initialize(FMLInitializationEvent event) { + if (System.getProperty("rd.emerald.client.target") != null) MinecraftForge.EVENT_BUS.register(this); + } + + @SubscribeEvent + public void tick(TickEvent.ClientTickEvent event) { + if (event.phase != TickEvent.Phase.END) return; + Minecraft mc = Minecraft.getMinecraft(); + if (finished || ++ticks > 2400) { + if (!finished) System.err.println("RD_EMERALD_CLIENT_TIMEOUT phase=" + phase); + mc.shutdown(); + return; + } + if (phase == 0 && mc.currentScreen instanceof GuiMainMenu) { + File game = Loader.instance().getConfigDir().getParentFile(); + if (!game.getName().startsWith("client-fixture-") || new File(game, "saves/" + WORLD).exists()) { + throw new IllegalStateException("Use an empty, disposable emerald client fixture"); + } + mc.gameSettings.pauseOnLostFocus = false; + mc.gameSettings.renderDistanceChunks = 12; + mc.launchIntegratedServer(WORLD, "Emerald Client Checks", + new WorldSettings(3346071746553592696L, GameType.CREATIVE, true, false, WorldType.DEFAULT).enableCommands()); + phase = 1; + ticks = 0; + } else if (phase == 1 && mc.player != null) { + final UUID playerId = mc.player.getUniqueID(); + mc.getIntegratedServer().addScheduledTask(() -> { + try { + WorldServer world = mc.getIntegratedServer().worldServerForDimension(0); + targets = findTargets(world); + movePlayer(mc, playerId, targets.get(0)); + } catch (Throwable failure) { + fail(failure); + } + }); + phase = 2; + ticks = 0; + } else if (phase == 2 && targets != null && ticks >= 200) { + final UUID playerId = mc.player.getUniqueID(); + phase = 3; + mc.getIntegratedServer().addScheduledTask(() -> { + try { + WorldServer world = mc.getIntegratedServer().worldServerForDimension(0); + int[] target = targets.get(index); + String key = "realisticdeposits:overworld_emerald_pockets@minecraft:overworld/" + target[3] + "," + target[4]; + MineWorldData data = MineWorldData.get(world); + AbandonedMinePlan plan = data.plan(key); + if (plan != null) { + DepositDefinition definition = definition(); + EmeraldDistrict body = EmeraldPattern.plan(definition, world, target[3], target[4]).get(); + int pockets = 1; + if (body.linkedWorkings(definition)) { + pockets = EmeraldWalkProbe.verifyLinked(world, plan, body); + } else { + EmeraldWalkProbe.verify(world, plan); + } + System.out.println("RD_EMERALD_CLIENT_PASS surface=" + plan.entrance() + " landing=" + + plan.landing() + " walked_pockets=" + pockets + " region=" + target[3] + "," + target[4] + + (body.large() ? " large_zones=" + body.zones() + " planned_ore=" + body.ore().size() : "")); + finished = true; + } else { + String reason = data.rejectionReason(key); + if (reason == null && ticks < 480) { + mc.addScheduledTask(() -> phase = 2); + return; + } + DepositDefinition definition = definition(); + EmeraldDistrict district = EmeraldPattern.plan(definition, world, target[3], target[4]).get(); + AbandonedMinePlan proposed = EmeraldRuntimeProbe.minePlan(world, definition, district).get(); + if (!EmeraldRuntimeProbe.noPartialEntrance(world, proposed)) { + throw new AssertionError("Partially carved mine: " + key); + } + System.out.println("RD_EMERALD_CLIENT_NO_PORTAL " + key + " reason=" + + (reason == null ? "not resolved during client smoke" : reason)); + if (++index == targets.size()) throw new AssertionError("No walkable route among surveyed sites"); + movePlayer(mc, playerId, targets.get(index)); + mc.addScheduledTask(() -> { + ticks = 0; + phase = 2; + }); + } + } catch (Throwable failure) { + fail(failure); + } + }); + } + } + + private void fail(Throwable failure) { + System.err.println("RD_EMERALD_CLIENT_FAILED"); + failure.printStackTrace(); + finished = true; + } + + private static void movePlayer(Minecraft mc, UUID playerId, int[] target) { + EntityPlayerMP player = mc.getIntegratedServer().getPlayerList().getPlayerByUUID(playerId); + player.connection.setPlayerLocation(target[0] + 0.5D, target[1], target[2] + 0.5D, 0, 0); + } + + private static DepositDefinition definition() { + return RealisticDepositsRuntime.state().definitions().stream().filter(DepositDefinition::isEmerald).findFirst().get(); + } + + private static List findTargets(WorldServer world) { + String mode = System.getProperty("rd.emerald.client.target"); + List targets = new ArrayList<>(); + boolean rich = "rich-survey".equals(mode); + boolean large = "large-survey".equals(mode); + if (!rich && !large && !"survey".equals(mode)) { + targets.add(java.util.Arrays.stream(mode.split(",")).mapToInt(Integer::parseInt).toArray()); + return targets; + } + if (!rich && !large) targets.add(new int[] {24168, 80, -9862, 23, -10}); + DepositDefinition definition = definition(); + int loaded = world.getChunkProvider().getLoadedChunkCount(); + int span = rich || large ? 128 : 40; + for (int rx = 16; rx < 16 + span && targets.size() < 8; rx++) { + for (int rz = -16; rz < -16 + span && targets.size() < 8; rz++) { + if (!rich && !large && rx == 23 && rz == -10) continue; + Optional body = EmeraldPattern.plan(definition, world, rx, rz); + if (!body.isPresent() || large != body.get().large() || rich && !body.get().rich()) continue; + Optional plan = EmeraldRuntimeProbe.minePlan(world, definition, body.get()); + if (!plan.isPresent()) continue; + MinePoint portal = plan.get().entrance(); + targets.add(new int[] {portal.x(), portal.y() + 1, portal.z(), rx, rz}); + } + } + if (world.getChunkProvider().getLoadedChunkCount() != loaded) throw new AssertionError("Preview loaded chunks"); + if (targets.isEmpty()) throw new AssertionError("No geometrically safe sites in the client survey"); + return targets; + } +} diff --git a/src/test/java/zone/moddev/mc/realisticdeposits/generation/EmeraldDepositTest.java b/src/test/java/zone/moddev/mc/realisticdeposits/generation/EmeraldDepositTest.java new file mode 100644 index 0000000..5b0558d --- /dev/null +++ b/src/test/java/zone/moddev/mc/realisticdeposits/generation/EmeraldDepositTest.java @@ -0,0 +1,325 @@ +package zone.moddev.mc.realisticdeposits.generation; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayDeque; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import com.google.gson.JsonObject; +import org.junit.jupiter.api.Test; +import zone.moddev.mc.realisticdeposits.config.DepositCatalogLoader; +import zone.moddev.mc.realisticdeposits.geometry.AbandonedMinePlan; +import zone.moddev.mc.realisticdeposits.geometry.EmeraldDistrict; +import zone.moddev.mc.realisticdeposits.geometry.EmeraldMinePlanner; +import zone.moddev.mc.realisticdeposits.geometry.MinePoint; +import zone.moddev.mc.realisticdeposits.geometry.RichEmeraldMinePlanner; +import zone.moddev.mc.realisticdeposits.integration.OreSpawnIntegration; +import zone.moddev.mc.realisticdeposits.model.DepositDefinition; + +public class EmeraldDepositTest { + private static final String OVERWORLD = "minecraft:overworld"; + static final String ID = "realisticdeposits:overworld_emerald_pockets"; + private static final EmeraldDistrict.Site STONE = new EmeraldDistrict.Site() { + @Override public boolean acceptsGeome(int x, int z) { return true; } + @Override public boolean eligible(int x, int y, int z, int cover) { return y + cover <= 64; } + }; + + static DepositDefinition defaults() { + return DepositCatalogLoader.load(new InputStreamReader(EmeraldDepositTest.class.getResourceAsStream( + "/assets/realisticdeposits/defaults/emerald.json"), StandardCharsets.UTF_8), + "emerald.json").definitions().get(0); + } + + @Test + void additiveCatalogueRoundTripsAndRegistersOnlyExactHosts() { + DepositDefinition definition = defaults(); + assertEquals(ID, definition.id()); + assertEquals(0.35, definition.selectionChance()); + assertFalse(definition.effectiveBackgroundGenerationScale().isPresent()); + assertEquals(definition.structuralFingerprint(), DepositCatalogLoader.loadDefinition( + definition.toJson(), ID).structuralFingerprint()); + JsonObject dimension = OreSpawnIntegration.provider(java.util.Collections.singletonList(definition)) + .toJson().getAsJsonObject("ores").getAsJsonObject("realisticdeposits:deposit/" + ID.replace(':', '/')) + .getAsJsonObject("dimensions").getAsJsonObject(OVERWORLD); + assertFalse(dimension.has("background_generation_scale")); + assertEquals(4, dimension.getAsJsonArray("host_blocks").size()); + assertEquals(0, dimension.getAsJsonArray("host_families").size()); + JsonObject invalid = definition.toJson(); + invalid.addProperty("resource", "minecraft:diamond_ore"); + assertThrows(IllegalArgumentException.class, () -> DepositCatalogLoader.loadDefinition(invalid, ID)); + } + + @Test + void hostStatesExcludeDecorativeAndFallbackRock() { + assertTrue(EmeraldPattern.permittedRock("mineralogy:schist", 0, true)); + assertFalse(EmeraldPattern.permittedRock("mineralogy:schist", 1, true)); + assertFalse(EmeraldPattern.permittedRock("mineralogy:marble", 0, true)); + assertFalse(EmeraldPattern.permittedRock("minecraft:stone", 0, true)); + for (int meta = 0; meta <= 6; meta++) { + assertEquals(meta == 0 || meta % 2 == 1, EmeraldPattern.permittedRock("minecraft:stone", meta, false)); + } + } + + @Test + void fitsSeparatedPocketsInNegativeRegionsWithoutChunkOrder() { + DepositDefinition definition = defaults(); + int planned = 0; + for (int rx = -40; rx < 0; rx++) { + Optional candidate = EmeraldDistrict.plan(definition, 19, OVERWORLD, rx, -1, STONE); + if (!candidate.isPresent()) continue; + planned++; + EmeraldDistrict body = candidate.get(); + if (body.large()) continue; // Covered by the district-specific geometry test. + assertEquals(body.ore(), EmeraldDistrict.plan(definition, 19, OVERWORLD, rx, -1, STONE).get().ore()); + assertTrue(body.rich() ? body.ore().size() >= 70 && body.ore().size() <= 120 + : body.ore().size() >= 25 && body.ore().size() <= 60); + assertEquals(body.ore().size(), new HashSet<>(body.ore()).size()); + assertTrue(body.rich() ? body.pockets().size() >= 4 && body.pockets().size() <= 6 + : body.pockets().size() >= 2 && body.pockets().size() <= 4); + assertTrue(body.ore().stream().allMatch(p -> p.y() >= 12 && p.y() <= 48 && body.isOreAt(p.x(), p.y(), p.z()))); + for (int i = 0; i < body.pockets().size(); i++) { + for (List other : body.pockets().subList(i + 1, body.pockets().size())) { + for (MinePoint a : body.pockets().get(i)) for (MinePoint b : other) { + assertTrue(Math.max(Math.abs(a.x() - b.x()), + Math.max(Math.abs(a.y() - b.y()), Math.abs(a.z() - b.z()))) > 1); + } + } + } + assertFalse(EmeraldDistrict.plan(definition, 19, "minecraft:the_nether", rx, -1, STONE).isPresent()); + } + assertTrue(planned >= 5); + } + + @Test + void rareFindsExtendTheTrendAndLeaveCapturedSmallFindsAlone() { + DepositDefinition definition = defaults(); + JsonObject legacyJson = definition.toJson(); + JsonObject shape = legacyJson.getAsJsonObject("shape"); + shape.remove("rich_find"); + shape.remove("vein_trace"); + shape.remove("large_mine"); + DepositDefinition legacy = DepositCatalogLoader.loadDefinition(legacyJson, ID); + assertFalse(legacy.toJson().getAsJsonObject("shape").has("rich_find")); + assertFalse(legacy.toJson().getAsJsonObject("shape").has("vein_trace")); + int total = 0, rich = 0; + for (int rx = -80; rx < 80; rx++) { + Optional sample = EmeraldDistrict.plan(definition, 19, OVERWORLD, rx, -1, STONE); + if (!sample.isPresent()) continue; + total++; + EmeraldDistrict body = sample.get(); + if (body.large()) continue; + EmeraldDistrict old = EmeraldDistrict.plan(legacy, 19, OVERWORLD, rx, -1, STONE).get(); + if (body.rich()) { + rich++; + assertTrue(body.strikeLength() >= 52 && body.strikeLength() <= 80); + assertTrue(body.ore().size() > old.ore().size()); + int[] anchor = {Integer.MAX_VALUE, Integer.MAX_VALUE}; + EmeraldDistrict.Site shortGeome = new EmeraldDistrict.Site() { + @Override public boolean acceptsGeome(int x, int z) { + if (anchor[0] == Integer.MAX_VALUE) { anchor[0] = x; anchor[1] = z; } + return x == anchor[0] && z == anchor[1]; + } + @Override public boolean eligible(int x, int y, int z, int cover) { return STONE.eligible(x, y, z, cover); } + }; + EmeraldDistrict fallback = EmeraldDistrict.plan(definition, 19, OVERWORLD, rx, -1, shortGeome).get(); + assertFalse(fallback.rich()); + assertEquals(old.ore(), fallback.ore()); + } else { + assertEquals(old.ore(), body.ore()); + } + Set trace = new HashSet<>(); + for (int x = body.bounds().minimumX() >> 4; x <= body.bounds().maximumX() >> 4; x++) { + for (int z = body.bounds().minimumZ() >> 4; z <= body.bounds().maximumZ() >> 4; z++) { + for (MinePoint point : body.traceInChunk(x, z)) { + assertTrue(trace.add(point)); + assertEquals(x, point.x() >> 4); + assertEquals(z, point.z() >> 4); + assertFalse(body.isOreAt(point.x(), point.y(), point.z())); + } + } + } + assertTrue(trace.size() >= 5); + } + assertTrue(rich > 0 && rich * 4 < total, "Larger finds should be possible but unusual"); + } + + @Test + void largeDistrictsUseEmptyRegionsAndKeepEarlierTiersByteForByte() { + DepositDefinition definition = defaults(); + JsonObject captured = definition.toJson(); + captured.getAsJsonObject("shape").remove("large_mine"); + DepositDefinition prior = DepositCatalogLoader.loadDefinition(captured, ID); + assertNull(prior.emerald().large()); + assertEquals(captured, prior.toJson()); + JsonObject originalChance = definition.toJson(); + originalChance.getAsJsonObject("shape").getAsJsonObject("large_mine").addProperty("chance", 0.008); + DepositDefinition rare = DepositCatalogLoader.loadDefinition(originalChance, ID); + int small = 0, medium = 0, large = 0, workings = 0, reroutes = 0, retained = 0; + for (long seed : new long[] {19L, -73L, 20261109L}) { + for (int rx = -180; rx < 180; rx++) { + Optional before = EmeraldDistrict.plan(prior, seed, OVERWORLD, rx, -3, STONE); + Optional after = EmeraldDistrict.plan(definition, seed, OVERWORLD, rx, -3, STONE); + if (before.isPresent()) { + EmeraldDistrict old = before.get(), updated = after.get(); + assertEquals(old.pockets(), updated.pockets()); + assertEquals(old.strikeLength(), updated.strikeLength()); + assertEquals(old.rich(), updated.rich()); + assertFalse(updated.large()); + assertEquals(trace(old), trace(updated)); + if (old.rich()) medium++; else small++; + } else if (after.isPresent()) { + EmeraldDistrict body = after.get(); + assertTrue(body.large()); + Optional previous = EmeraldDistrict.plan(rare, seed, OVERWORLD, rx, -3, STONE); + if (previous.isPresent()) { + assertTrue(previous.get().large()); + assertEquals(previous.get().pockets(), body.pockets()); + assertEquals(trace(previous.get()), trace(body)); + retained++; + } + assertTrue(body.zones() >= 4 && body.zones() <= 5); + assertEquals(body.zones() * 2, body.pockets().size()); + assertTrue(body.ore().size() >= 260 && body.ore().size() <= 420); + assertEquals(body.ore().size(), new HashSet<>(body.ore()).size()); + assertEquals(body.pockets(), EmeraldDistrict.plan(definition, seed, OVERWORLD, rx, -3, STONE).get().pockets()); + assertTrue(body.ore().stream().allMatch(p -> p.y() >= 12 && p.y() <= 48)); + assertTrue(body.ore().stream().allMatch(p -> Math.floorDiv(p.x(), 1024) == body.identity().regionX() + && Math.floorDiv(p.z(), 1024) == -3)); + assertCluesConnectZones(body); + Optional mine = RichEmeraldMinePlanner.plan(body, definition.abandonedMine(), + (a, b) -> 64, candidate -> true); + if (mine.isPresent()) { + assertEquals(1, mine.get().chunkKeys().size()); + assertTrue(body.ore().stream().noneMatch(p -> mine.get().blocks().containsKey(p.packed()))); + assertTrue(RichEmeraldMinePlanner.exposedPockets(body, mine.get(), p -> true) >= 2); + MinePoint portal = mine.get().entrance(); + int[] considered = {0}; + Optional alternative = RichEmeraldMinePlanner.planInChunk(body, + definition.abandonedMine(), portal.x() >> 4, portal.z() >> 4, + (a, b) -> 64, candidate -> { + considered[0]++; + return !candidate.blocks().containsKey(portal.packed()); + }); + assertTrue(considered[0] > 0 && considered[0] <= 48); + if (alternative.isPresent()) { + assertEquals(mine.get().chunkKeys(), alternative.get().chunkKeys()); + assertFalse(alternative.get().blocks().containsKey(portal.packed())); + assertTrue(RichEmeraldMinePlanner.exposedPockets(body, alternative.get(), p -> true) >= 2); + reroutes++; + } + workings++; + } + large++; + } + } + } + // All-stone terrain removes the stricter multi-zone geology fit. + // The actual rich/large ordering is checked on natural terrain. + assertTrue(large > retained && large < small && medium < small); + assertTrue(retained >= 2, "The earlier large-mine nominations must remain"); + assertTrue(workings > 0, "A district should fit a complete starter working in safe terrain"); + assertTrue(reroutes > 0, "A blocked starter route should have a safe in-chunk alternative at some sites"); + } + + private static Set trace(EmeraldDistrict body) { + Set result = new HashSet<>(); + for (int x = body.bounds().minimumX() >> 4; x <= body.bounds().maximumX() >> 4; x++) + for (int z = body.bounds().minimumZ() >> 4; z <= body.bounds().maximumZ() >> 4; z++) + result.addAll(body.traceInChunk(x, z)); + return result; + } + + private static void assertCluesConnectZones(EmeraldDistrict body) { + Set remaining = trace(body); + assertFalse(remaining.isEmpty()); + assertTrue(java.util.Collections.disjoint(remaining, body.ore())); + remaining.addAll(body.ore()); + Set reached = new HashSet<>(); + ArrayDeque queue = new ArrayDeque<>(); + queue.add(body.ore().get(0)); + int[][] directions = {{1,0,0},{-1,0,0},{0,1,0},{0,-1,0},{0,0,1},{0,0,-1}}; + while (!queue.isEmpty()) { + MinePoint current = queue.removeFirst(); + if (!remaining.remove(current)) continue; + reached.add(current); + for (int[] d : directions) queue.addLast(new MinePoint(current.x() + d[0], current.y() + d[1], current.z() + d[2])); + } + for (int i = 0; i < body.pockets().size(); i += 2) + assertTrue(body.pockets().get(i).stream().anyMatch(reached::contains), "Unconnected clue trail at zone " + (i / 2)); + } + + @Test + void noGeomeOrHostRejectsWithoutSubstitution() { + for (boolean geome : new boolean[] {false, true}) { + EmeraldDistrict.Site rejected = new EmeraldDistrict.Site() { + @Override public boolean acceptsGeome(int x, int z) { return geome; } + @Override public boolean eligible(int x, int y, int z, int cover) { + assertTrue(geome, "The planner must check geology before sampling pockets"); + return false; + } + }; + for (int rx = 0; rx < 16; rx++) { + assertFalse(EmeraldDistrict.plan(defaults(), 19, OVERWORLD, rx, 0, rejected).isPresent()); + } + } + } + + @Test + void linkedMineFitsOneChunkAndExposesMultiplePocketsWithoutCuttingOre() { + DepositDefinition definition = defaults(); + assertTrue(definition.emerald().rich().workings()); + JsonObject old = definition.toJson(); + old.getAsJsonObject("shape").getAsJsonObject("rich_find").remove("workings"); + assertFalse(DepositCatalogLoader.loadDefinition(old, ID).emerald().rich().workings()); + int found = 0; + for (int x = -50; x < 50; x++) { + for (int z = -2; z <= 2; z++) { + Optional district = EmeraldDistrict.plan(definition, 19, OVERWORLD, x, z, STONE); + if (!district.isPresent() || !district.get().rich()) continue; + EmeraldDistrict body = district.get(); + Optional proposed = RichEmeraldMinePlanner.plan(body, definition.abandonedMine(), + (a, b) -> 64, candidate -> true); + if (!proposed.isPresent()) continue; + AbandonedMinePlan mine = proposed.get(); + assertEquals(1, mine.chunkKeys().size()); + assertTrue(body.ore().stream().noneMatch(ore -> mine.blocks().containsKey(ore.packed()))); + assertTrue(RichEmeraldMinePlanner.exposedPockets(body, mine, p -> true) >= 2); + assertEquals(0, RichEmeraldMinePlanner.exposedPockets(body, mine, p -> false)); + found++; + if (found >= 2) return; + } + } + assertTrue(found >= 2, "Expected distinct rich sites with linked workings"); + } + + @Test + void workingsApproachAnOreFaceWithoutConsumingTheReserve() { + DepositDefinition definition = defaults(); + int safe = 0; + for (int rx = -20; rx < 20; rx++) { + Optional district = EmeraldDistrict.plan(definition, 19, OVERWORLD, rx, 0, STONE); + if (!district.isPresent()) continue; + EmeraldDistrict body = district.get(); + Optional candidate = EmeraldMinePlanner.plan(body, definition.abandonedMine(), (x, z) -> 64); + assertFalse(EmeraldMinePlanner.plan(body, definition.abandonedMine(), (x, z) -> -1).isPresent()); + if (!candidate.isPresent()) continue; + safe++; + AbandonedMinePlan plan = candidate.get(); + assertEquals(plan.blocks().size(), plan.cellsInChunk(plan.entrance().x() >> 4, plan.entrance().z() >> 4).size()); + assertTrue(plan.blocks().keySet().stream().map(MinePoint::unpack).allMatch(point -> + (point.x() & 15) > 0 && (point.x() & 15) < 15 && (point.z() & 15) > 0 && (point.z() & 15) < 15)); + Set written = plan.blocks().keySet(); + assertTrue(body.ore().stream().noneMatch(ore -> written.contains(ore.packed()))); + MinePoint landing = plan.landing(); + assertTrue(body.ore().stream().anyMatch(ore -> ore.y() == landing.y() + 1 + && Math.abs(ore.x() - landing.x()) + Math.abs(ore.z() - landing.z()) >= 6 + && Math.abs(ore.x() - landing.x()) + Math.abs(ore.z() - landing.z()) <= 12)); + } + assertTrue(safe >= 5); + } +} diff --git a/src/test/java/zone/moddev/mc/realisticdeposits/generation/EmeraldRuntimeProbe.java b/src/test/java/zone/moddev/mc/realisticdeposits/generation/EmeraldRuntimeProbe.java new file mode 100644 index 0000000..873594b --- /dev/null +++ b/src/test/java/zone/moddev/mc/realisticdeposits/generation/EmeraldRuntimeProbe.java @@ -0,0 +1,544 @@ +package zone.moddev.mc.realisticdeposits.generation; + +import java.io.FileReader; +import java.io.FileWriter; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Random; +import java.util.Set; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.JsonPrimitive; +import net.minecraft.block.state.IBlockState; +import net.minecraft.entity.item.EntityItem; +import net.minecraft.init.Blocks; +import net.minecraft.init.Enchantments; +import net.minecraft.init.Items; +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.server.MinecraftServer; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.AxisAlignedBB; +import net.minecraft.world.WorldServer; +import net.minecraft.world.biome.BiomeHills; +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.common.MinecraftForge; +import net.minecraftforge.common.util.FakePlayerFactory; +import net.minecraftforge.event.terraingen.OreGenEvent; +import net.minecraftforge.fml.common.event.FMLInitializationEvent; +import net.minecraftforge.fml.common.eventhandler.Event; +import net.minecraftforge.fml.common.eventhandler.EventPriority; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; + +import zone.moddev.mc.orespawn.api.GeologySampler; +import zone.moddev.mc.orespawn.api.OreSpawnApi; +import zone.moddev.mc.realisticdeposits.config.DepositCatalogLoader; +import zone.moddev.mc.realisticdeposits.config.RealisticDepositsRuntime; +import zone.moddev.mc.realisticdeposits.geometry.AbandonedMinePlan; +import zone.moddev.mc.realisticdeposits.geometry.EmeraldDistrict; +import zone.moddev.mc.realisticdeposits.geometry.EmeraldMinePlanner; +import zone.moddev.mc.realisticdeposits.geometry.EmeraldWalkProbe; +import zone.moddev.mc.realisticdeposits.geometry.RichEmeraldMinePlanner; +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.DepositDefinition; + +/** Packaged only in the opt-in local qualification mod. */ +@Mod(modid = "rdemeraldprobe", name = "Emerald Qualification", version = "1", + dependencies = "required-after:realisticdeposits") +public final class EmeraldRuntimeProbe { + private static final String OVERWORLD = "minecraft:overworld"; + private static final int[][] SIDES = {{1,0,0}, {-1,0,0}, {0,1,0}, {0,-1,0}, {0,0,1}, {0,0,-1}}; + private boolean observingBackground; + private int backgroundEvents; + private int deniedBackgroundEvents; + + @Mod.EventHandler + public void initialize(FMLInitializationEvent event) { + if (Boolean.getBoolean("rd.emerald.background")) MinecraftForge.ORE_GEN_BUS.register(this); + } + + @SubscribeEvent(priority = EventPriority.LOWEST, receiveCanceled = true) + public void observeEmerald(OreGenEvent.GenerateMinable event) { + if (!observingBackground || event.getType() != OreGenEvent.GenerateMinable.EventType.EMERALD) return; + backgroundEvents++; + if (event.getResult() == Event.Result.DENY) deniedBackgroundEvents++; + } + + @Mod.EventHandler + public void started(net.minecraftforge.fml.common.event.FMLServerStartedEvent event) throws Exception { + String phase = System.getProperty("rd.emerald.phase"); + if (phase == null) return; + MinecraftServer server = FMLCommonHandler.instance().getMinecraftServerInstance(); + try { + WorldServer world = server.worldServerForDimension(0); + require("emerald-test-world".equals(world.getSaveHandler().getWorldDirectory().getName()), + "Disposable emerald world required"); + world.getGameRules().setOrCreateGameRule("randomTickSpeed", "0"); + JsonObject profile = OreSpawnApi.getActiveProfile(server).get().toJson(); + DepositDefinition local = RealisticDepositsRuntime.state().definitions().stream() + .filter(DepositDefinition::isEmerald).findFirst().get(); + JsonObject enabled = local.toJson(); + enabled.addProperty("enabled", true); + DepositDefinition definition = DepositCatalogLoader.loadDefinition(enabled, "emerald fixture"); + boolean physical = LapisGeology.physicalStrata(profile); + Set hosts = EmeraldPattern.exactHosts(definition, profile, physical); + GeologySampler sampler = OreSpawnApi.createSampler(world).get(); + if ("rarity".equals(phase)) { + write("emerald-rarity.json", rarity(world, definition, sampler, hosts, physical)); + System.out.println("RD_EMERALD_PASS " + phase); + return; + } + JsonObject report = new JsonObject(); + String workings = System.getProperty("rd.emerald.workings", "off"); + report.add("profile", profile); + report.addProperty("physical", physical); + report.addProperty("eligible_states", hosts.size()); + if ("first".equals(phase)) { + JsonObject candidates = targets(world, definition, sampler, hosts, physical); + write("emerald-targets.json", candidates); + if (surveyWorkings(workings)) { + selectWalkableSite(world, definition, sampler, hosts, physical, candidates); + write("emerald-targets.json", candidates); + } + Random random = new Random(44123L); + int normal = 0, fortune = 0; + for (int i = 0; i < 4096; i++) { + normal += Blocks.EMERALD_ORE.quantityDropped(random); + fortune += Blocks.EMERALD_ORE.quantityDroppedWithBonus(3, random); + } + require(normal == 4096 && fortune > normal, "Unexpected native emerald drops"); + report.addProperty("fortune3_mean", fortune / 4096.0); + } + JsonObject targets; + try (FileReader reader = new FileReader("emerald-targets.json")) { + targets = new JsonParser().parse(reader).getAsJsonObject(); + } + report.add("targets", targets); + if (targets.has("first_rx")) { + EmeraldDistrict first = plan(world, definition, sampler, hosts, physical, + targets.get("first_rx").getAsInt(), targets.get("first_rz").getAsInt()).get(); + BlockPos obstruction = null; + if ("first".equals(phase)) { + if (workings.endsWith("wet") || "infested".equals(workings) || "structure".equals(workings)) { + obstruction = obstruct(world, definition, first, workings); + } + if (!surveyWorkings(workings)) populate(world, first); + } else if ("existing".equals(phase)) { + populateOutside(world, first); + } + report.add("first", sample(world, first, sampler, hosts, physical)); + if ("first".equals(phase) && !"off".equals(workings) && MineWorldData.get(world).plan(first.identity().toString()) == null) { + AbandonedMinePlan proposed = minePlan(world, definition, first).get(); + report.addProperty("no_partial_entrance", noPartialEntrance(world, proposed)); + } + if (obstruction != null) { + boolean preserved = "structure".equals(workings) + ? world.getMapStorage().getOrLoadData(MapGenStructureData.class, "Mineshaft") != null + : world.getBlockState(obstruction).getBlock() == (workings.endsWith("wet") + ? Blocks.WATER : Blocks.MONSTER_EGG); + report.addProperty("obstruction_preserved", preserved); + } + } + if ("later".equals(phase) && targets.has("later_rx")) { + EmeraldDistrict later = plan(world, definition, sampler, hosts, physical, + targets.get("later_rx").getAsInt(), targets.get("later_rz").getAsInt()).get(); + populate(world, later); + report.add("later", sample(world, later, sampler, hosts, physical)); + } + if ("first".equals(phase) && Boolean.getBoolean("rd.emerald.background")) { + observingBackground = true; + report.add("background", background(world)); + observingBackground = false; + report.addProperty("background_events", backgroundEvents); + report.addProperty("background_denied", deniedBackgroundEvents); + } + if ("first".equals(phase)) { + checkSilkTouch(world); + report.addProperty("silk_touch", true); + } + write("emerald-" + phase + ".json", report); + System.out.println("RD_EMERALD_PASS " + phase); + } finally { + server.initiateShutdown(); + } + } + + private static JsonObject rarity(WorldServer world, DepositDefinition definition, GeologySampler sampler, + Set hosts, boolean physical) { + double[] chances = {0.008, 0.04, 0.05, 0.06, 0.08, 0.10, 0.25}; + DepositDefinition[] variants = new DepositDefinition[chances.length]; + int[] counts = new int[chances.length]; + for (int i = 0; i < chances.length; i++) { + JsonObject json = definition.toJson(); + json.getAsJsonObject("shape").getAsJsonObject("large_mine").addProperty("chance", chances[i]); + variants[i] = DepositCatalogLoader.loadDefinition(json, "emerald rarity survey"); + } + JsonObject ironJson = RealisticDepositsRuntime.state().definitions().stream() + .filter(d -> "realisticdeposits:overworld_iron_formation".equals(d.id())).findFirst().get().toJson(); + ironJson.addProperty("enabled", true); + DepositDefinition iron = DepositCatalogLoader.loadDefinition(ironJson, "iron rarity survey"); + require(definition.regionSpacingChunks() == iron.regionSpacingChunks(), "Use equal survey areas"); + int small = 0, rich = 0, ironSelected = 0, ironBelt = 0; + int loaded = world.getChunkProvider().getLoadedChunkCount(); + for (int x = 16; x < 144; x++) for (int z = -16; z < 112; z++) { + Optional ironBody = RegionDepositPlanner.plan(iron, world.getSeed(), OVERWORLD, x, z); + if (ironBody.isPresent()) { + ironSelected++; + StratiformSeamBody body = ironBody.get(); + MineTerrainSampler terrain = new MineTerrainSampler(world, world.getChunkProvider().chunkGenerator); + if (EmeraldPattern.site(definition, terrain, sampler, hosts, physical) + .acceptsGeome(body.centerX(), body.centerZ())) ironBelt++; + } + int max = chances.length - 1; + Optional body = plan(world, variants[max], sampler, hosts, physical, x, z); + if (!body.isPresent()) continue; + if (body.get().large()) { + counts[max]++; + for (int i = max - 1; i >= 0; i--) { + if (!plan(world, variants[i], sampler, hosts, physical, x, z).isPresent()) break; + counts[i]++; + } + } else if (body.get().rich()) rich++; + else small++; + } + require(loaded == world.getChunkProvider().getLoadedChunkCount(), "Rarity survey loaded additional chunks"); + JsonObject result = new JsonObject(), variantsReport = new JsonObject(); + result.addProperty("seed", world.getSeed()); + result.addProperty("regions", 128 * 128); + result.addProperty("iron_nominated", ironSelected); + result.addProperty("iron_with_mountain_belt_anchor", ironBelt); + result.addProperty("emerald_small_fitted", small); + result.addProperty("emerald_rich_fitted", rich); + for (int i = 0; i < chances.length; i++) variantsReport.addProperty(Double.toString(chances[i]), counts[i]); + result.add("emerald_large_fitted", variantsReport); + return result; + } + + private static void checkSilkTouch(WorldServer world) { + BlockPos point = new BlockPos(8, 200, 8); + world.getChunkProvider().provideChunk(point.getX() >> 4, point.getZ() >> 4); + ItemStack tool = new ItemStack(Items.IRON_PICKAXE); + tool.addEnchantment(Enchantments.SILK_TOUCH, 1); + Blocks.EMERALD_ORE.harvestBlock(world, FakePlayerFactory.getMinecraft(world), point, + Blocks.EMERALD_ORE.getDefaultState(), null, tool); + int count = 0; + for (EntityItem entity : world.getEntitiesWithinAABB(EntityItem.class, + new AxisAlignedBB(7, 199, 7, 10, 203, 10), item -> !item.isDead)) { + ItemStack drop = entity.getEntityItem(); + require(drop.getItem() == Item.getItemFromBlock(Blocks.EMERALD_ORE), "Silk Touch returned an emerald item"); + count += drop.stackSize; + entity.setDead(); + } + require(count == 1, "Silk Touch must drop one vanilla emerald ore block"); + } + + private static JsonObject targets(WorldServer world, DepositDefinition definition, GeologySampler sampler, + Set hosts, boolean physical) { + JsonObject result = new JsonObject(); + String workings = System.getProperty("rd.emerald.workings", "off"); + boolean manual = !"off".equals(workings); + String find = System.getProperty("rd.emerald.find", ""); + int range = Integer.getInteger("rd.emerald.regions", "large".equals(find) ? 128 : manual && "rich".equals(find) ? 64 + : manual || !find.isEmpty() ? 40 : physical ? 24 : 12); + int eligible = 0, planned = 0, largeCount = 0, mediumCount = 0; + JsonArray mineCandidates = new JsonArray(); + boolean trySites = surveyWorkings(workings); + int loaded = world.getChunkProvider().getLoadedChunkCount(); + for (int rx = 16; rx < 16 + range && !hosts.isEmpty(); rx++) { + for (int rz = -16; rz < -16 + range; rz++) { + Optional option = plan(world, definition, sampler, hosts, physical, rx, rz); + if (!option.isPresent()) continue; + eligible++; + EmeraldDistrict body = option.get(); + planned += body.ore().size(); + // Use the already qualified ordinary trace as the later-chunk + // positive control; a geometry-only pick can be wholly clipped. + if ("large-mine".equals(workings) && rx == 32 && rz == 12) { + result.addProperty("later_rx", rx); + result.addProperty("later_rz", rz); + } + if (body.large()) largeCount++; + if (body.rich()) mediumCount++; + boolean requested = "large".equals(find) ? body.large() : "rich".equals(find) ? body.rich() : !body.large(); + // Keep qualifying the same known deposit when the default + // nomination rate rises and other sites appear earlier. + if ("large-mine".equals(workings)) requested = body.large() && rx == 36 && rz == 34; + if ("trace".equals(find)) requested = rx == 32 && rz == 12; + // Inject into the same site proved walkable without the obstruction. + if ("rich-wet".equals(workings)) requested = body.rich() && rx == 64 && rz == -15; + boolean candidate = requested && (trySites ? mineCandidates.size() < 12 : !result.has("first_rx")) + && (!manual || safeMine(world, definition, body)); + if (candidate && trySites) { + JsonObject point = new JsonObject(); + point.addProperty("rx", rx); + point.addProperty("rz", rz); + mineCandidates.add(point); + } + if (candidate && !result.has("first_rx")) { + result.addProperty("first_rx", rx); + result.addProperty("first_rz", rz); + } else if (result.has("first_rx") && !result.has("later_rx") && !body.large() && (!trySites || !body.rich()) + && rx != result.get("first_rx").getAsInt()) { + result.addProperty("later_rx", rx); + result.addProperty("later_rz", rz); + } + } + } + require(loaded == world.getChunkProvider().getLoadedChunkCount(), "Planning loaded neighbouring chunks"); + if (manual) require(result.has("first_rx"), "No safe mine fixture in the fixed search window"); + if (!find.isEmpty()) require(result.has("first_rx"), "No matching find in the fixed search window"); + result.addProperty("regions", range * range); + result.addProperty("accepted", eligible); + result.addProperty("large_count", largeCount); + result.addProperty("medium_count", mediumCount); + result.addProperty("planned_total", planned); + if (trySites) result.add("mine_candidates", mineCandidates); + return result; + } + + private static boolean surveyWorkings(String workings) { + return "rich-mine".equals(workings) || "large-mine".equals(workings); + } + + private static void selectWalkableSite(WorldServer world, DepositDefinition definition, GeologySampler sampler, + Set hosts, boolean physical, JsonObject targets) throws Exception { + MineWorldData data = MineWorldData.get(world); + for (com.google.gson.JsonElement candidate : targets.getAsJsonArray("mine_candidates")) { + JsonObject site = candidate.getAsJsonObject(); + int rx = site.get("rx").getAsInt(), rz = site.get("rz").getAsInt(); + EmeraldDistrict body = plan(world, definition, sampler, hosts, physical, rx, rz).get(); + populate(world, body); + String key = body.identity().toString(); + if (data.plan(key) != null) { + targets.addProperty("first_rx", rx); + targets.addProperty("first_rz", rz); + return; + } + site.addProperty("reason", data.rejectionReason(key)); + write("emerald-targets.json", targets); + } + throw new AssertionError("No walkable linked mine among the bounded natural candidates; see emerald-targets.json"); + } + + private static boolean safeMine(WorldServer world, DepositDefinition definition, EmeraldDistrict body) { + return minePlan(world, definition, body).isPresent(); + } + + static Optional minePlan(WorldServer world, DepositDefinition definition, EmeraldDistrict body) { + MineTerrainSampler terrain = new MineTerrainSampler(world, world.getChunkProvider().chunkGenerator); + java.util.function.Predicate safe = plan -> terrain.surfaceY(plan.entrance()) == plan.entrance().y() + && terrain.dry(plan) && terrain.covered(plan) && MineWorkingsGenerator.biomesAllowed(plan, definition, world); + return body.linkedWorkings(definition) + ? RichEmeraldMinePlanner.plan(body, definition.abandonedMine(), + (x, z) -> terrain.surfaceY(new MinePoint(x, 0, z)), safe) + : EmeraldMinePlanner.plan(body, definition.abandonedMine(), terrain::naturalSurfaceY, safe); + } + + static boolean noPartialEntrance(WorldServer world, AbandonedMinePlan proposed) { + return proposed.blocks().entrySet().stream().filter(cell -> cell.getValue().name().startsWith("STAIRS_")) + .map(cell -> MinePoint.unpack(cell.getKey())) + .noneMatch(p -> world.getBlockState(new BlockPos(p.x(), p.y(), p.z())).getBlock() == Blocks.OAK_STAIRS); + } + + private static BlockPos obstruct(WorldServer world, DepositDefinition definition, EmeraldDistrict body, + String scenario) { + AbandonedMinePlan plan = minePlan(world, definition, body).get(); + if ("structure".equals(scenario)) { + MinePoint p = plan.entrance(); + MapGenStructureData structures = new MapGenStructureData("Mineshaft"); + NBTTagCompound marker = new NBTTagCompound(); + marker.setIntArray("BB", new int[] {p.x(), p.y(), p.z(), p.x(), p.y() + 1, p.z()}); + structures.writeInstance(marker, p.x() >> 4, p.z() >> 4); + world.getMapStorage().setData("Mineshaft", structures); + return new BlockPos(p.x(), p.y(), p.z()); + } + MinePoint p = plan.landing(); + if ("rich-wet".equals(scenario)) { + // Linked galleries are appended after the compact shaft and crosscut. + p = plan.blocks().entrySet().stream().filter(cell -> cell.getValue() == MineBlock.AIR) + .map(cell -> MinePoint.unpack(cell.getKey())).filter(cell -> cell.y() == plan.landing().y()) + .reduce((a, b) -> b).get(); + p = new MinePoint(p.x(), p.y() - 1, p.z()); + } + BlockPos pos = new BlockPos(p.x(), p.y() + 1, p.z()); + net.minecraft.world.chunk.Chunk chunk = world.getChunkProvider().provideChunk(p.x() >> 4, p.z() >> 4); + require(!chunk.isTerrainPopulated(), "Inject obstruction before the mine pass"); + chunk.setBlockState(pos, (scenario.endsWith("wet") ? Blocks.WATER : Blocks.MONSTER_EGG).getDefaultState()); + return pos; + } + + private static void populateOutside(WorldServer world, EmeraldDistrict body) throws Exception { + ChunkProviderServer provider = world.getChunkProvider(); + ExistingMineTerrain existing = ExistingMineTerrain.read(new java.io.File( + world.getSaveHandler().getWorldDirectory(), "region")); + require(existing.intersects(body.bounds()), "The first pass must leave populated pocket chunks"); + MineWorldData data = MineWorldData.get(world); + int cx = body.centerX() >> 4, cz = body.centerZ() >> 4; + for (int radius = 1; radius <= 5; radius++) { + for (int dx = -radius; dx <= radius; dx++) for (int dz = -radius; dz <= radius; dz++) { + if (Math.max(Math.abs(dx), Math.abs(dz)) != radius || existing.contains(cx + dx, cz + dz)) continue; + provider.provideChunk(cx + dx, cz + dz).populateChunk(provider, provider.chunkGenerator); + if (data.decided(body.identity().toString())) return; + } + } + } + + private static Optional plan(WorldServer world, DepositDefinition definition, + GeologySampler sampler, Set hosts, boolean physical, int rx, int rz) { + MineTerrainSampler terrain = new MineTerrainSampler(world, world.getChunkProvider().chunkGenerator); + return EmeraldDistrict.plan(definition, world.getSeed(), OVERWORLD, rx, rz, + EmeraldPattern.site(definition, terrain, sampler, hosts, physical)); + } + + private static void populate(WorldServer world, EmeraldDistrict body) { + ChunkProviderServer provider = world.getChunkProvider(); + List cells = new ArrayList<>(); + int minX = (body.bounds().minimumX() >> 4) - 2, maxX = (body.bounds().maximumX() >> 4) + 2; + int minZ = (body.bounds().minimumZ() >> 4) - 2, maxZ = (body.bounds().maximumZ() >> 4) + 2; + for (int x = minX; x <= maxX; x++) for (int z = minZ; z <= maxZ; z++) cells.add(MinePoint.chunkKey(x, z)); + if (Boolean.getBoolean("rd.emerald.reverse")) Collections.reverse(cells); + for (long cell : cells) provider.provideChunk((int) (cell >> 32), (int) cell); + for (long cell : cells) provider.provideChunk((int) (cell >> 32), (int) cell) + .populateChunk(provider, provider.chunkGenerator); + + AbandonedMinePlan mine = MineWorldData.get(world).plan(body.identity().toString()); + if (mine == null) return; + for (long packed : mine.blocks().keySet()) { + MinePoint point = MinePoint.unpack(packed); + provider.provideChunk(point.x() >> 4, point.z() >> 4).populateChunk(provider, provider.chunkGenerator); + } + } + + private static JsonObject sample(WorldServer world, EmeraldDistrict body, GeologySampler sampler, + Set hosts, boolean physical) { + JsonObject result = new JsonObject(); + JsonArray positions = new JsonArray(); + for (MinePoint point : body.ore()) { + BlockPos pos = new BlockPos(point.x(), point.y(), point.z()); + if (world.getBlockState(pos).getBlock() != Blocks.EMERALD_ORE) continue; + if (physical) require(hosts.contains(sampler.sampleColumn(point.x(), point.z(), + world.getHeight(pos).getY()).rockAt(point.y())), "Emerald outside predicted natural schist"); + positions.add(new JsonPrimitive(point.toString())); + } + result.add("ore", positions); + result.addProperty("planned", body.ore().size()); + result.addProperty("rich", body.rich()); + if (body.large()) { + result.addProperty("large", true); + JsonArray zones = new JsonArray(); + for (int i = 0; i < body.pockets().size(); i += 2) { + int ore = 0; + for (List pocket : body.pockets().subList(i, i + 2)) for (MinePoint p : pocket) { + if (world.getBlockState(new BlockPos(p.x(), p.y(), p.z())).getBlock() == Blocks.EMERALD_ORE) ore++; + } + zones.add(new JsonPrimitive(ore)); + } + result.add("zone_ore", zones); + } + result.addProperty("pockets", body.pockets().size()); + result.addProperty("strike", body.strikeLength()); + JsonArray trace = new JsonArray(); + for (int x = body.bounds().minimumX() >> 4; x <= body.bounds().maximumX() >> 4; x++) { + for (int z = body.bounds().minimumZ() >> 4; z <= body.bounds().maximumZ() >> 4; z++) { + for (MinePoint p : body.traceInChunk(x, z)) { + if (world.getBlockState(new BlockPos(p.x(), p.y(), p.z())) == Blocks.STONE.getStateFromMeta(3)) { + trace.add(new JsonPrimitive(p.toString())); + } + } + } + } + result.add("trace", trace); + result.addProperty("center", body.centerX() + "," + body.centerY() + "," + body.centerZ()); + result.addProperty("surface_mountain", world.getBiomeProvider() + .getBiome(new BlockPos(body.centerX(), 0, body.centerZ())) instanceof BiomeHills); + MineWorldData data = MineWorldData.get(world); + String key = body.identity().toString(); + AbandonedMinePlan mine = data.plan(key); + if (mine != null) { + require(body.ore().stream().noneMatch(ore -> mine.blocks().containsKey(ore.packed())), + "Saved workings consume planned ore"); + result.addProperty("entrance", mine.entrance().toString()); + result.addProperty("landing", mine.landing().toString()); + boolean open = openRoute(world, mine); + result.addProperty("open_route", open); + if (open && (body.large() || body.rich() && System.getProperty("rd.emerald.workings", "").startsWith("rich-"))) { + result.addProperty("walked_pockets", EmeraldWalkProbe.verifyLinked(world, mine, body)); + } + if (open && System.getProperty("rd.emerald.workings", "off").startsWith("manual")) { + EmeraldWalkProbe.verify(world, mine); + } + } else if (data.rejectionReason(key) != null) { + result.addProperty("rejected_workings", data.rejectionReason(key)); + } + return result; + } + + private static boolean openRoute(WorldServer world, AbandonedMinePlan mine) { + Set visited = new HashSet<>(); + ArrayDeque pending = new ArrayDeque<>(); + pending.add(mine.entrance()); + while (!pending.isEmpty()) { + MinePoint p = pending.removeFirst(); + if (!visited.add(p.packed()) || !mine.blocks().containsKey(p.packed()) + || !mine.blocks().get(p.packed()).passable()) continue; + IBlockState state = world.getBlockState(new BlockPos(p.x(), p.y(), p.z())); + if (state.getMaterial().isLiquid() || (state.getMaterial().isSolid() + && state.getBlock() != Blocks.OAK_STAIRS)) continue; + if (p.equals(mine.landing())) return true; + for (int[] side : SIDES) pending.add(new MinePoint(p.x() + side[0], p.y() + side[1], p.z() + side[2])); + } + return false; + } + + private static JsonArray background(WorldServer world) { + ChunkProviderServer provider = world.getChunkProvider(); + // Match the auxiliary world RNG as well as the saved world seed. Forge + // decorators can advance it while the probe samples a distant pocket. + world.rand.setSeed(2718281828L); + for (int cx = -72; cx <= 72; cx += 4) { + for (int cz = -72; cz <= 72; cz += 4) { + if (!(world.getBiomeProvider().getBiome(new BlockPos(cx * 16 + 8, 0, cz * 16 + 8)) instanceof BiomeHills)) { + continue; + } + JsonArray ore = new JsonArray(); + for (int x = cx - 1; x <= cx + 4; x++) for (int z = cz - 1; z <= cz + 4; z++) { + provider.provideChunk(x, z); + } + for (int x = cx - 1; x <= cx + 4; x++) for (int z = cz - 1; z <= cz + 4; z++) { + provider.provideChunk(x, z).populateChunk(provider, provider.chunkGenerator); + } + BlockPos.MutableBlockPos p = new BlockPos.MutableBlockPos(); + for (int x = cx * 16; x < cx * 16 + 64; x++) for (int z = cz * 16; z < cz * 16 + 64; z++) { + for (int y = 0; y <= 40; y++) { + if (world.getBlockState(p.setPos(x, y, z)).getBlock() == Blocks.EMERALD_ORE) { + ore.add(new JsonPrimitive(new MinePoint(x, y, z).toString())); + } + } + } + return ore; + } + } + throw new AssertionError("Could not find a native hill biome for the background comparison"); + } + + private static void write(String path, JsonObject value) throws Exception { + try (FileWriter writer = new FileWriter(path)) { writer.write(value.toString()); } + } + + private static void require(boolean condition, String message) { + if (!condition) throw new AssertionError(message); + } +} diff --git a/src/test/java/zone/moddev/mc/realisticdeposits/geometry/EmeraldWalkProbe.java b/src/test/java/zone/moddev/mc/realisticdeposits/geometry/EmeraldWalkProbe.java new file mode 100644 index 0000000..ff74874 --- /dev/null +++ b/src/test/java/zone/moddev/mc/realisticdeposits/geometry/EmeraldWalkProbe.java @@ -0,0 +1,137 @@ +package zone.moddev.mc.realisticdeposits.geometry; + +import java.util.ArrayList; +import java.util.ArrayDeque; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import net.minecraft.entity.monster.EntityZombie; +import net.minecraft.init.Blocks; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.WorldServer; + +/** Walks the generated incline and crosscut with native collision and step height. */ +public final class EmeraldWalkProbe { + private EmeraldWalkProbe() { } + + public static void verify(WorldServer world, AbandonedMinePlan plan) { + List route = steps(plan); + MinePoint landing = plan.landing(); + int[][] sides = {{1, 0}, {0, 1}, {-1, 0}, {0, -1}}; + boolean face = false; + for (int[] side : sides) { + List crosscut = new ArrayList<>(); + for (int i = 1; i <= 12; i++) { + MinePoint p = new MinePoint(landing.x() + i * side[0], landing.y(), landing.z() + i * side[1]); + if (i >= 6 && world.getBlockState(new BlockPos(p.x(), p.y() + 1, p.z())).getBlock() == Blocks.EMERALD_ORE) { + route.addAll(crosscut); + face = true; + break; + } + if (plan.blocks().get(p.packed()) != MineBlock.AIR + || plan.blocks().get(new MinePoint(p.x(), p.y() + 1, p.z()).packed()) != MineBlock.AIR) break; + crosscut.add(p); + } + if (face) break; + } + if (!face) throw new AssertionError("The crosscut did not expose its emerald face"); + + int loaded = world.getChunkProvider().getLoadedChunkCount(); + walk(world, route); + Collections.reverse(route); + walk(world, route); + if (world.getChunkProvider().getLoadedChunkCount() != loaded) { + throw new AssertionError("Walking an emerald approach loaded another chunk"); + } + } + + public static int verifyLinked(WorldServer world, AbandonedMinePlan plan, EmeraldDistrict body) { + MinePoint landing = plan.landing(); + int y = landing.y(), loaded = world.getChunkProvider().getLoadedChunkCount(); + int[][] sides = {{1, 0}, {0, 1}, {-1, 0}, {0, -1}}; + Map parent = new LinkedHashMap<>(); + ArrayDeque queue = new ArrayDeque<>(); + parent.put(landing.packed(), landing.packed()); + queue.add(landing); + while (!queue.isEmpty()) { + MinePoint current = queue.removeFirst(); + for (int[] side : sides) { + int x = current.x() + side[0], z = current.z() + side[1]; + MinePoint next = new MinePoint(x, y, z); + if (parent.containsKey(next.packed()) + || plan.blocks().get(new MinePoint(x, y - 1, z).packed()) != MineBlock.PLANKS + || plan.blocks().get(next.packed()) != MineBlock.AIR + || plan.blocks().get(new MinePoint(x, y + 1, z).packed()) != MineBlock.AIR) continue; + parent.put(next.packed(), current.packed()); + queue.addLast(next); + } + } + + int visited = 0; + for (List pocket : body.pockets()) { + Long target = null; + for (MinePoint ore : pocket) { + if ((ore.y() != y && ore.y() != y + 1) || (ore.x() >> 4) != (landing.x() >> 4) + || (ore.z() >> 4) != (landing.z() >> 4) + || world.getBlockState(new BlockPos(ore.x(), ore.y(), ore.z())).getBlock() != Blocks.EMERALD_ORE) continue; + for (int[] side : sides) { + long foot = new MinePoint(ore.x() + side[0], y, ore.z() + side[1]).packed(); + if (parent.containsKey(foot)) target = foot; + } + } + if (target == null) continue; + List gallery = new ArrayList<>(); + for (long p = target; p != landing.packed(); p = parent.get(p)) gallery.add(MinePoint.unpack(p)); + Collections.reverse(gallery); + List route = steps(plan); + route.addAll(gallery); + walk(world, route); + Collections.reverse(route); + walk(world, route); + visited++; + } + if (visited < 2 || world.getChunkProvider().getLoadedChunkCount() != loaded) { + throw new AssertionError("Expected two walkable emerald pockets without loading any chunks"); + } + return visited; + } + + private static List steps(AbandonedMinePlan plan) { + for (int span = 8; span >= 6; span -= 2) { + for (int forward = 0; forward < 4; forward++) { + for (int turn = 1; turn <= 3; turn += 2) { + MineDecline access = MineDecline.compact(plan.landing(), plan.entrance().y(), forward, turn, span); + if (!access.entrance().equals(plan.entrance()) || !access.hasClearSteps(plan.blocks())) continue; + List route = new ArrayList<>(); + for (MineDecline.Step step : access.steps()) route.add(step.point()); + Collections.reverse(route); + return route; + } + } + } + throw new AssertionError("No matching compact stair route"); + } + + private static void walk(WorldServer world, List route) { + MinePoint start = route.get(0); + EntityZombie walker = new EntityZombie(world); + walker.stepHeight = 0.6F; + walker.setPosition(start.x() + 0.5D, start.y(), start.z() + 0.5D); + for (int index = 1; index < route.size(); index++) { + MinePoint target = route.get(index); + for (int tick = 0; tick < 12; tick++) { + double dx = Math.max(-0.125D, Math.min(0.125D, target.x() + 0.5D - walker.posX)); + double dz = Math.max(-0.125D, Math.min(0.125D, target.z() + 0.5D - walker.posZ)); + walker.move(dx, -0.15D, dz); + } + if (Math.abs(walker.posX - target.x() - 0.5D) >= 0.2D + || Math.abs(walker.posZ - target.z() - 0.5D) >= 0.2D + || walker.posY < target.y() - 0.05D || walker.posY > target.y() + 1.05D) { + throw new AssertionError("Cannot walk compact mine step " + target + ": " + + walker.posX + "," + walker.posY + "," + walker.posZ); + } + } + } +} diff --git a/tools/qualification/EMERALD_TESTING.md b/tools/qualification/EMERALD_TESTING.md new file mode 100644 index 0000000..79a88e8 --- /dev/null +++ b/tools/qualification/EMERALD_TESTING.md @@ -0,0 +1,125 @@ +# Try the emerald update + +Use the existing **RealisticDeposits 110** Eclipse workspace. Refresh the +Gradle project and start the regular **runClient** launch. No Emerald-specific +launch is required. Fresh installations get the updated emerald catalogue. +If your local config was created for an earlier candidate, back it up and +copy the shipped `emerald.json` over it if it is uncustomised, or copy just +`shape.vein_trace`, `shape.rich_find` and `shape.large_mine` if those +settings are missing. Keep any custom values you already have. +OreSpawn may also have the old rule in `run/config/orespawn-worldgen.json`. +If so, back up that file and add the missing fields under +`ores` → `realisticdeposits:deposit/realisticdeposits/overworld_emerald_pockets` +→ `dimensions` → `minecraft:overworld` → `pattern.settings.definition.shape`. +If the rule already has the larger-find settings, just add +`"workings": true` inside its `rich_find` object and inside the matching +catalogue object. Keep your existing chance, yield and other values. +If you were already testing the linked rich workings, only +`shape.large_mine` is new. It goes alongside `rich_find`, not inside it. +If you have the earlier large-mine settings, the revised shipped value +for `shape.large_mine.chance` is `0.08`. Changing a template affects +new worlds; the prepared test save and other saved profiles are unchanged. +Leave the other rules and your existing saves alone. Restart Minecraft after +updating the files. + +## Walk the short crosscut + +Create a fresh Creative world with cheats, the normal world type, structures +on, and seed **3346071746553592696**. For this walk, leave physical Mineralogy +strata off. Don't reuse the world from the earlier walkthrough: its generated +chunks and saved mine layout won't be rewritten. + +```text +/tp @p 32869 85 12728 +``` + +Follow the stairs around the turns to the landing at **32875, 38, 12729**. +The short crosscut leads to an exposed emerald face. Mine that pocket, then +look for pale diorite where the ore ends and follow it without using +Spectator or the preview command. The trace can step up or down with the +pockets. A site can have emerald pockets without any entrance: unsafe +approaches are skipped, including the site from the earlier walkthrough. + +## Check a rich (medium) find + +In a fresh world with the same settings and seed, use the regular +`runClient` launch and teleport above the entrance: + +```text +/tp @p 22043 89 86234 +``` + +Take the stairs down to **22035, 46, 86227**. Follow the timbered headings +to two separate exposed pockets; both routes were walked in the ordinary +client. Look for pale diorite where the workings end if you want to +prospect for more. This is region `21 84` in the preview command. + +### Separate packaged-server example + +With the same seed, structures on and physical strata off, the packaged +server generated a different open mine. These coordinates are not the +regular-client walkthrough: infested stone blocked this route in the +ordinary client test. + +```text +/tp @p 66019 80 -14364 +``` + +On the server, take the stairs to the landing at **66019, 19, -14365**. Follow the timbered +headings to two separate exposed faces. The packaged test found **111 of +116 planned ore blocks** across four pockets on a 69-block trend. The +workings reach two of those pockets; use the diorite trace to prospect for +the other two. The preview is useful if you want to check the plan: + +```text +/realisticdeposits preview 64 -15 realisticdeposits:overworld_emerald_pockets +``` + +It describes the plan, not the ore that survives generation or whether +an entrance is safe to open. + +## Prospect an exceptional large mine + +If **Emerald Large Mine - Test** appears in Singleplayer, open it from the +standard `runClient` launch. This optional disposable save starts you in +Creative at the verified entrance, facing the stairs. No teleport, new +world, or configuration change is needed to use that save. + +To generate the example yourself: + +Use regular `runClient` and a fresh Creative world with the same seed +**3346071746553592696**, normal world type, structures on and physical +Mineralogy strata off. Make sure the new `shape.large_mine` is present. + +```text +/tp @p 37193 79 35107 +``` + +Take the stairs to **37194, 30, 35107** and explore both headings. Look +for pale diorite behind the ore on the north side of the worked zone. +The trace trends north-west toward an unworked zone around +**37175, 29, 35066**. Try following the cue before using those coordinates. +If a cave breaks the trace, look for it on the far side rather than +expecting a continuous emerald seam. + +This is region `36 34`: five zones, ten pockets and **369 planned ore +blocks**. The regular client walked to two pockets, and its saved chunks +contain an intact clue route out of the worked zone. The other zones are +not pre-excavated. Natural decorations or chunk order can shift the +portal slightly; the packaged-server entrance was one block to the east. + +## See a pocket in schist + +Use a fresh disposable world with compatible Mineralogy installed and +OreSpawn's physical Overworld strata active. Keep natural schist enabled. +The packaged strata check used Mineralogy **6.0.2.110021** and the same +seed. The verified pocket is near **24192, 35, -9837**; use Spectator to +inspect it. This is an ore-inspection site, not a promise of a clear mine +entrance. Different geology profiles can move or remove the pocket. + +```text +/realisticdeposits preview 23 -10 realisticdeposits:overworld_emerald_pockets +``` + +Try an iron pickaxe and Fortune, or use Silk Touch to collect the vanilla +ore block. The new deposits do not reduce the normal emerald supply. diff --git a/tools/qualification/README.md b/tools/qualification/README.md index 5e020b8..836ef2b 100644 --- a/tools/qualification/README.md +++ b/tools/qualification/README.md @@ -33,7 +33,7 @@ independently. These are test settings, not new gameplay defaults. ## Coalfield checks -Coalfield checks use the published OreSpawn 4.1.5.110021 dependency and its +Coalfield checks use the published OreSpawn 4.1.6.110021 dependency and its official checksum. The exact-host API is included in that release; a local OreSpawn build is not needed. @@ -121,6 +121,21 @@ of completed fixtures without changing their source worlds. See [the lapis walkthrough](LAPIS_TESTING.md) for the isolated Eclipse launch. +## Emerald checks + +`prepareEmeraldQualification` builds the separate test-only probe. +`emerald_110.py` checks the exact packaged jar on disposable local servers: +ordinary-ore controls, native drops, stone and schist hosts, saved source +switches, late chunks and working obstructions. `emerald_upgrade_110.py` +checks copies of a completed Lapis mine against an old-build control; +pass the current public engine with `--orespawn`, leaving the archived +engine in the source fixture. +Ordinary ore comparisons keep the saved rules and Forge gate identical and +report realised counts separately. Legacy OreSpawn shares its decoration +random stream with the added rule, so individual ordinary ore coordinates +aren't required to match in a fresh chunk. +See the [emerald walkthrough](EMERALD_TESTING.md) for fixed-seed coordinates. + ## Running the policy matrix For the imported-profile editor check, use the separate @@ -142,7 +157,7 @@ Then run `python tools/qualification/qualify_110.py` with these arguments: | `--java` | Java 8 executable | | `--runtime` | An official Forge 1.10.2-12.18.3.2511 server installation, including libraries and an already accepted EULA | | `--mod` | The built Realistic Deposits release jar | -| `--orespawn` | Public OreSpawn 4.1.5.110021; its official checksum is checked | +| `--orespawn` | Public OreSpawn 4.1.6.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 | diff --git a/tools/qualification/emerald_110.py b/tools/qualification/emerald_110.py new file mode 100644 index 0000000..bbd3402 --- /dev/null +++ b/tools/qualification/emerald_110.py @@ -0,0 +1,318 @@ +"""Check exact packaged emerald generation and ordinary-ore controls locally.""" + +import argparse +import concurrent.futures +import json +import os +from pathlib import Path +import re +import shutil +import subprocess + +from ci_runtime import FORGE_HASH, check_runtime_log, require_local_execution +from coal_110 import prepare +from qualify_110 import FORGE_JAR, check_ports, checked_orespawn_sha256, digest, write_json + + +CATALOGUES = ("vanilla", "coal", "diamonds", "gold", "lapis", "emerald") +ROOT = Path(__file__).resolve().parents[2] +RULE = "realisticdeposits:deposit/realisticdeposits/overworld_emerald_pockets" +CASES = ( + "native-off", "native-off-control", "native-on", "native-on-control", "reverse", + "os1-on", "os1-on-control", "os3-off", "os3-off-control", "saved-off", + "schist", "inactive", "no-schist", "legacy", "manual", "manual-defaults", "wet", "infested", "structure", "existing", + "survey-b", "survey-c", "rich", "trace", "rich-mine", "rich-wet", "large-mine", + "rarity-a", "rarity-b", "rarity-c", +) +STRATA = {"schist", "inactive", "no-schist"} +WORKINGS = {"manual", "manual-defaults", "wet", "infested", "structure", "existing", "rich-mine", "rich-wet", "large-mine"} +SEEDS = {"survey-b": "-481973256913825465", "survey-c": "7028280810328432262", + "rarity-b": "-481973256913825465", "rarity-c": "7028280810328432262"} + + +def run(java, root, phase, case): + background = case.startswith(("native-", "os1-", "os3-")) + find = "large" if case == "large-mine" else "rich" if case.startswith("rich") else case if case == "trace" else "" + command = [str(java), "-Xms256m", "-Xmx2G", "-XX:ActiveProcessorCount=2", + f"-Drd.emerald.phase={phase}", f"-Drd.emerald.reverse={str(case == 'reverse').lower()}", + f"-Drd.emerald.workings={case if case in WORKINGS else 'off'}", + f"-Drd.emerald.find={find}", + f"-Drd.emerald.background={str(background).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"Timed out: {root}/{phase}; evidence retained") + log = (root / f"{phase}.log").read_text(encoding="utf-8", errors="replace") + assert code == 0 and f"RD_EMERALD_PASS {phase}" in log, f"{root.name}/{phase}: see console log" + check_runtime_log(log) + assert not list((root / "crash-reports").glob("*")), f"Crash report in {root}" + full = root / "logs/fml-server-latest.log" + if full.is_file(): + shutil.copy2(full, root / f"{phase}.fml.log") + check_runtime_log(full.read_text(encoding="utf-8", errors="replace")) + print(f"PASS {root.name}/{phase}", flush=True) + return json.loads((root / f"emerald-{phase}.json").read_text()) + + +def scenario(args, case, port): + from copy import copy + fixture = copy(args) + fixture.port = port + flat = case.startswith(("native-", "os1-", "os3-")) or case == "reverse" + root = prepare(fixture, args.output / case, mineralogy=case in STRATA, normal=not flat) + properties = (root / "server.properties").read_text().replace("coal-test-world", "emerald-test-world") + if flat: + properties = properties.replace("127*minecraft:stone,minecraft:grass;1;decoration", + "143*minecraft:stone,minecraft:grass;3;decoration") + if case.startswith("manual") or case in {"rich-mine", "rich-wet", "large-mine"}: + properties = properties.replace("generate-structures=false", "generate-structures=true") + if case in SEEDS: + properties = properties.replace("3346071746553592696", SEEDS[case]) + (root / "server.properties").write_text(properties, encoding="utf-8", newline="\n") + control = case.endswith("-control") + managed = case.startswith(("native-on", "os1-on")) + for name in CATALOGUES: + if case == "manual-defaults": + continue + data = json.loads((ROOT / f"src/main/resources/assets/realisticdeposits/defaults/{name}.json").read_text()) + for definition in data["deposits"]: + definition["enabled"] = name == "emerald" and not control and case != "existing" + if name == "emerald" and case not in WORKINGS: + definition["abandoned_mine"]["enabled"] = False + if name == "emerald" and flat: + # Separate the proof pockets from native/imported ore and + # from vanilla's cross-chunk dirt and gravel decoration. + definition.update(min_y=100, max_y=120) + write_json(root / f"config/realisticdeposits/deposits/{name}.json", data) + + profile = json.loads((args.strata_profile if case in STRATA else args.base_profile).read_text()) + profile["manage_vanilla_ores"] = managed + if flat: + profile["place_fluid_deposits"] = False + profile["ores"] = {key: value for key, value in profile["ores"].items() + if not key.startswith("realisticdeposits:")} + for directory in ("providers", "ore_providers"): + profile.get(directory, {}).pop("realisticdeposits", None) + profile.pop("ore_sources", None) + profile.pop("ore_source_policies", None) + if case == "inactive": + profile["terrain_dimensions"]["minecraft:overworld"]["enabled"] = False + elif case == "no-schist": + for rock in profile["rocks"].values(): + if rock.get("block") == "mineralogy:schist": + rock.update(enabled=False, ore_replaceable=False) + elif case == "legacy": + profile["geology_mode"] = "legacy" + if case != "manual-defaults": + write_json(root / "config/orespawn-worldgen.json", profile) + + if case.startswith("rarity-"): + return run(args.java, root, "rarity", case) + + imported = None + if case.startswith(("os1-", "os3-")): + (root / "config/orespawn.cfg").write_text( + 'general {\n B:"Replace Vanilla Oregen"=' + str(managed).lower() + + '\n B:Retrogen=false\n B:"Force Retrogen"=false\n}\n', encoding="utf-8") + if case.startswith("os1-"): + imported = root / "config/orespawn/orespawn.json" + write_json(imported, {"dimensions": [{"dimension": "0", "ores": [{ + "blockID": "minecraft:emerald_ore", "size": 5, "variation": 0, + "frequency": 3.25, "minHeight": 12, "maxHeight": 19, + }]}]}) + else: + imported = root / "config/orespawn3/orespawn.json" + write_json(imported, {"version": "2.0", "spawns": {"emerald_ore": { + "enabled": True, "retrogen": False, "feature": "default", "replaces": "default", + "dimensions": [0], "biomes": {"excludes": []}, + "parameters": {"frequency": 3.25, "size": 5, "variation": 0, "minHeight": 12, "maxHeight": 19}, + "blocks": [{"name": "minecraft:emerald_ore", "chance": 100}], + }}}) + import_hash = digest(imported) if imported else None + first = run(args.java, root, "first", case) + assert first["silk_touch"] and first["fortune3_mean"] > 1.0 + assert first["profile"]["manage_vanilla_ores"] == managed + assert "background_generation_scale" not in first["profile"]["ores"][RULE]["dimensions"]["minecraft:overworld"] + if imported: + assert digest(imported) == import_hash + assert any(key.startswith("orespawn:legacy/") and rule.get("block") == "minecraft:emerald_ore" + for key, rule in first["profile"]["ores"].items()), "Legacy emerald source was not imported" + if case in {"no-schist", "legacy"}: + assert first["targets"]["accepted"] == 0 + elif not control and case != "existing": + assert first["first"]["ore"], f"{case}: no emeralds at the selected pocket" + if case in STRATA: + assert first["physical"] == (case != "inactive") + assert first["eligible_states"] == {"schist": 1, "inactive": 4, "no-schist": 0}[case] + if case == "manual": + assert first["first"].get("open_route"), f"Manual route not open: {first['first']}" + if case == "rich-mine": + assert first["first"]["walked_pockets"] >= 2 + if case == "large-mine": + find = first["first"] + assert find["large"] and 260 <= find["planned"] <= 420 + assert len(find["ore"]) >= 200 and 4 <= len(find["zone_ore"]) <= 5 and min(find["zone_ore"]) > 0 + assert len(find["trace"]) >= find["strike"] // 2 and find["walked_pockets"] >= 2 + first["reachable_prospecting_zones"] = followed_zones(find) + assert len(first["reachable_prospecting_zones"]) >= 2, "No intact clue route out of the worked zone" + assert 0 < first["targets"]["large_count"] < first["targets"]["medium_count"] + if case == "rich-wet": + assert first["first"]["rejected_workings"] == "Unsafe blocks in the approach" + assert first["no_partial_entrance"] and first["obstruction_preserved"] + assert re.search(r"Left abandoned workings sealed .*?(?:minecraft:(?:flowing_)?water|avoid opening a liquid)", + (root / "first.log").read_text(encoding="utf-8", errors="replace")), ( + "The linked workings must be rejected by the injected water itself") + if case == "rich": + find = first["first"] + assert find["rich"] and 70 <= find["planned"] <= 120 and len(find["ore"]) > 60 + assert 4 <= find["pockets"] <= 6 and 52 <= find["strike"] <= 80 and find["trace"] + if case == "trace": + find = first["first"] + assert not find["rich"] and find["planned"] == 26 and len(find["ore"]) == 26 + assert find["pockets"] == 3 and find["strike"] == 38 and len(find["trace"]) >= 20 + assert not set(find["trace"]).intersection(find["ore"]) + if case == "manual-defaults" and not first["first"].get("open_route"): + assert first["first"]["rejected_workings"] == "Unsafe blocks in the approach" + assert first["no_partial_entrance"], "The natural obstruction left a partial entrance" + if case in {"wet", "infested", "structure"}: + assert not first["first"].get("open_route"), "Unsafe workings were opened" + assert "entrance" not in first["first"], "An unsafe site kept a mine entrance" + assert first["no_partial_entrance"], "An unsafe site carved stairs before rejecting the route" + assert first["obstruction_preserved"], "The placed obstruction was modified" + if case == "structure": + assert first["first"]["rejected_workings"] == "Another structure" + else: + assert first["first"]["rejected_workings"] == "Unsafe blocks in the approach" + if case == "existing": + saved = root / "emerald-test-world/serverconfig/orespawn-worldgen.json" + changed = json.loads(saved.read_text()) + changed["ores"][RULE]["enabled"] = True + write_json(saved, changed) + existing = run(args.java, root, "existing", case) + assert existing["first"]["rejected_workings"] == "Existing terrain" + assert existing["first"]["ore"] == first["first"]["ore"], "Existing terrain received emeralds" + first["existing"] = existing["first"] + if case in {"native-off", "saved-off", "schist", "manual", "rich-mine", "large-mine"}: + saved = root / "emerald-test-world/serverconfig/orespawn-worldgen.json" + if case == "saved-off": + changed = json.loads(saved.read_text()) + changed["ores"][RULE]["enabled"] = False + write_json(saved, changed) + reload = run(args.java, root, "reload", case) + assert reload["first"] == first["first"], "Existing pocket or mine changed across reload" + profile_hash = digest(saved) + later = run(args.java, root, "later", case) + assert later["first"] == first["first"], "Opening later chunks changed the saved pocket" + assert bool(later["later"]["ore"]) == (case != "saved-off"), "Saved source switch was ignored" + assert digest(saved) == profile_hash, "Captured profile changed after the later chunks" + first["later"] = later["later"] + if case.startswith(("native-", "os1-", "os3-")): + ordinary = {key: value for key, value in first["profile"]["ores"].items() + if not key.startswith("realisticdeposits:") and "emerald" in key} + first["ordinary_rules"] = ordinary + assert first["background_events"] > 0, f"No vanilla emerald callbacks in the hill fixture: {case}" + assert first["background_denied"] == (first["background_events"] if managed else 0) + first.pop("profile") + return first + + +def followed_zones(find): + """Walk the generated ore-and-diorite clues from the zone beside the landing.""" + def point(value): + return tuple(map(int, value.split(","))) + + ore = list(map(point, find["ore"])) + zones = {} + offset = 0 + for zone, count in enumerate(find["zone_ore"], start=1): + zones.update((cell, zone) for cell in ore[offset:offset + count]) + offset += count + assert offset == len(ore) + landing = point(find["landing"]) + start = zones[min(ore, key=lambda cell: sum(abs(a - b) for a, b in zip(cell, landing)))] + remaining = set(ore).union(map(point, find["trace"])) + queue = [cell for cell in ore if zones[cell] == start] + remaining.difference_update(queue) + reached = {start} + for x, y, z in queue: + if (x, y, z) in zones: + reached.add(zones[x, y, z]) + for cell in ((x-1, y, z), (x+1, y, z), (x, y-1, z), (x, y+1, z), (x, y, z-1), (x, y, z+1)): + if cell in remaining: + remaining.remove(cell) + queue.append(cell) + return sorted(reached) + + +def compare_background(active, control): + """Check supply rules and gates; record realised positions without asserting identity.""" + assert active["ordinary_rules"] == control["ordinary_rules"], "Ordinary emerald definitions changed" + for key in ("background_events", "background_denied"): + assert active[key] == control[key], f"Ordinary emerald gate changed: {key}" + assert active["background"] and control["background"], "Empty ordinary emerald sample" + assert len(active["first"]["ore"]) > len(control["first"]["ore"]), "No additional ore in the high pocket" + left, right = set(active["background"]), set(control["background"]) + # The public 1.10 engine shares the decoration RNG with new ore rules. + # A matched run checks ordinary availability, not identical coordinates. + return {"active": len(left), "control": len(right), "matching_positions": len(left & right), + "vanilla_events": active["background_events"], "denied": active["background_denied"]} + + +def main(): + require_local_execution(os.environ) + parser = argparse.ArgumentParser(description=__doc__) + for name in ("java", "runtime", "mod", "orespawn", "probe", "output", "base-profile"): + parser.add_argument("--" + name, required=True, type=Path) + parser.add_argument("--mineralogy", type=Path) + parser.add_argument("--strata-profile", type=Path) + parser.add_argument("--base-port", type=int, default=29200) + parser.add_argument("--workers", type=int, choices=(1, 2), default=2) + parser.add_argument("--cases", nargs="+", choices=CASES, default=CASES) + args = parser.parse_args() + assert len(set(args.cases)) == len(args.cases), "Choose each case once" + if STRATA.intersection(args.cases): + assert args.mineralogy and args.strata_profile, "Strata cases need sealed reference inputs" + check_ports(range(args.base_port, args.base_port + len(args.cases))) + assert not args.output.exists(), "Use a new evidence directory" + assert digest(args.runtime / FORGE_JAR) == FORGE_HASH + checked_orespawn_sha256(args.orespawn) + assert "eula=true" in (args.runtime / "eula.txt").read_text() + inputs = args.output / "inputs" + inputs.mkdir(parents=True) + for name in ("mod", "orespawn", "probe", "mineralogy", "base_profile", "strata_profile"): + source = getattr(args, name) + if source: + target = inputs / (name + "-" + source.name if name.endswith("profile") else source.name) + shutil.copy2(source, target) + setattr(args, name, target) + write_json(args.output / "artifacts.json", {p.name: digest(p) for p in inputs.iterdir()}) + results = {} + failures = {} + with concurrent.futures.ThreadPoolExecutor(max_workers=args.workers) as pool: + cases = {pool.submit(scenario, args, case, args.base_port + i): case for i, case in enumerate(args.cases)} + for future in concurrent.futures.as_completed(cases): + case = cases[future] + try: + results[case] = future.result() + except Exception as failure: + failures[case] = str(failure) + print(f"FAIL {case}: {failure}", flush=True) + write_json(args.output / "results.json", {"passed": False, "cases": results, "failures": failures}) + comparisons = {} + for key in ("native-off", "native-on", "os1-on", "os3-off"): + if key in results and key + "-control" in results: + comparisons[key] = compare_background(results[key], results[key + "-control"]) + if "reverse" in results and "native-off" in results: + assert results["reverse"]["first"] == results["native-off"]["first"], "Chunk order changed the pocket" + assert not failures, failures + write_json(args.output / "results.json", {"passed": True, "cases": results, + "ordinary_comparisons": comparisons}) + + +if __name__ == "__main__": + main() diff --git a/tools/qualification/emerald_upgrade_110.py b/tools/qualification/emerald_upgrade_110.py new file mode 100644 index 0000000..689914f --- /dev/null +++ b/tools/qualification/emerald_upgrade_110.py @@ -0,0 +1,90 @@ +"""Read a Lapis mine on matched disposable 0.5 and 0.6 world copies.""" + +import argparse +import json +import os +from pathlib import Path +import shutil + +from ci_runtime import FORGE_HASH, require_local_execution +from frozen_mines_110 import check_saved_profile +from mines_110 import run +from qualify_110 import FORGE_JAR, check_ports, checked_orespawn_sha256, digest, world_fingerprint, write_json + + +OLD = "RealisticDeposits-0.5.0.110021.jar" +OLD_HASH = "E79FBDC996703E6B31703D31F1DEF7E5690F71C4E4CE3DB406624A736A41AA93" +OLD_ENGINE = "OreSpawn-4.1.5.110021.jar" +OLD_ENGINE_HASH = "1E580EBB8D93B1256E2E81C7D0D815DEAAAC58A028CB5A77A52773B62048E010" +NEW_ENGINE_VERSION = "4.1.6.110021" +WORLD = "mine-test-world" +PROFILE = Path(WORLD) / "serverconfig/orespawn-worldgen.json" +MINE = Path(WORLD) / "data/realisticdeposits_mines.dat" + + +def main(): + require_local_execution(os.environ) + parser = argparse.ArgumentParser(description=__doc__) + for name in ("java", "source", "mod", "orespawn", "output"): + parser.add_argument("--" + name, required=True, type=Path) + parser.add_argument("--base-port", type=int, default=29400) + args = parser.parse_args() + assert not args.output.exists(), "Use a new evidence directory" + check_ports(range(args.base_port, args.base_port + 2)) + assert digest(args.source / FORGE_JAR) == FORGE_HASH + assert digest(args.source / "mods" / OLD) == OLD_HASH + checked_orespawn_sha256(args.source / "mods" / OLD_ENGINE, OLD_ENGINE_HASH) + checked_orespawn_sha256(args.orespawn) + baseline = world_fingerprint(args.source / WORLD) + before = json.loads((args.source / PROFILE).read_text()) + mine_hash = digest(args.source / MINE) + catalogue_dir = Path("config/realisticdeposits/deposits") + catalogues = {file.name: digest(file) for file in (args.source / catalogue_dir).glob("*.json")} + args.output.mkdir(parents=True) + write_json(args.output / "artifacts.json", + {args.mod.name: digest(args.mod), args.orespawn.name: digest(args.orespawn), + **{p.name: digest(p) for p in (args.source / "mods").glob("*.jar")}}) + for index, name in enumerate(("old-control", "upgraded")): + root = args.output / name + root.mkdir() + for directory in ("config", "libraries", "mods", WORLD): + shutil.copytree(args.source / directory, root / directory) + for file in (FORGE_JAR, "minecraft_server.1.10.2.jar", "eula.txt", "lapis-target.properties", + "ops.json", "whitelist.json", "banned-ips.json", "banned-players.json"): + shutil.copy2(args.source / file, root / file) + settings = (args.source / "server.properties").read_text() + (root / "server.properties").write_text("\n".join( + f"server-port={args.base_port + index}" if line.startswith("server-port=") else line + for line in settings.splitlines()) + "\n", encoding="utf-8") + if index: + (root / "mods" / OLD).rename(root / "retained-old-mod.jar") + (root / "mods" / OLD_ENGINE).rename(root / "retained-old-orespawn.jar") + shutil.copy2(args.mod, root / "mods" / args.mod.name) + shutil.copy2(args.orespawn, root / "mods" / args.orespawn.name) + run(args.java, root, "stable", {"rd.mines.lapis": "true", "rd.mines.scenario": "normal-structures"}) + after = json.loads((root / PROFILE).read_text()) + if index: + # The engine may refresh its displayed version, but only on + # the same existing OreSpawn-owned source records. + for key, policy in before.get("ore_source_policies", {}).items(): + old_sources = policy.get("candidates", []) + new_sources = after["ore_source_policies"][key].get("candidates", []) + assert len(old_sources) == len(new_sources) + for old, new in zip(old_sources, new_sources): + if (old.get("owner") == "orespawn" and "owner_version" in old + and new.get("owner_version") == NEW_ENGINE_VERSION): + new["owner_version"] = old["owner_version"] + check_saved_profile(before, after, "0.6.0.110021") + else: + assert before == after + assert not any(key.endswith("overworld_emerald_pockets") for key in after["ores"]) + assert digest(root / MINE) == mine_hash, "Existing Lapis mine data was rewritten" + for file, expected in catalogues.items(): + assert digest(root / catalogue_dir / file) == expected, f"Existing catalogue changed: {file}" + assert world_fingerprint(args.source / WORLD) == baseline, "Source Lapis world changed" + write_json(args.output / "results.json", {"passed": True, "boots": 2, "saved_mine_sha256": mine_hash, + "old_profile_unchanged_except_display_version": True}) + + +if __name__ == "__main__": + main() diff --git a/tools/qualification/gold_client_110.ps1 b/tools/qualification/gold_client_110.ps1 index 5a9d62e..b5109e8 100644 --- a/tools/qualification/gold_client_110.ps1 +++ b/tools/qualification/gold_client_110.ps1 @@ -76,7 +76,7 @@ New-Item -ItemType Directory -Path $nativeDir -Force | Out-Null Copy-Item -LiteralPath $orespawnJar -Destination (Join-Path $runDir 'mods') Copy-Item -LiteralPath $realisticDepositsJar -Destination (Join-Path $runDir 'mods') Copy-Item -LiteralPath $clientProbe -Destination (Join-Path $runDir 'mods') -$expectedOreSpawn = '1E580EBB8D93B1256E2E81C7D0D815DEAAAC58A028CB5A77A52773B62048E010' +$expectedOreSpawn = '05DB7CBFAADC815D6A713DACDFB9F0647BCED49CCFC357FB2FF010E95BD1379F' if ($LocalOreSpawnSha256) { if ($LocalOreSpawnSha256 -cnotmatch '^[0-9A-Fa-f]{64}$') { throw 'Supply the complete candidate SHA-256' } $expectedOreSpawn = $LocalOreSpawnSha256.ToUpperInvariant() diff --git a/tools/qualification/gold_survey_110.py b/tools/qualification/gold_survey_110.py index 3109953..e04cbcf 100644 --- a/tools/qualification/gold_survey_110.py +++ b/tools/qualification/gold_survey_110.py @@ -21,7 +21,7 @@ def load_catalogue(name): def control_catalogues(): - catalogues = {name: load_catalogue(name) for name in ("vanilla", "coal", "diamonds", "gold", "lapis")} + catalogues = {name: load_catalogue(name) for name in ("vanilla", "coal", "diamonds", "gold", "lapis", "emerald")} for catalogue in catalogues.values(): for definition in catalogue["deposits"]: definition["enabled"] = False diff --git a/tools/qualification/lapis_110.py b/tools/qualification/lapis_110.py index 2c7d8c9..d0bebc5 100644 --- a/tools/qualification/lapis_110.py +++ b/tools/qualification/lapis_110.py @@ -52,7 +52,7 @@ def same(left, right): def configure(root, case): natural = case in ("normal", "mineralogy", "inactive", "legacy") - for name in ("vanilla", "coal", "diamonds", "gold", "lapis"): + for name in ("vanilla", "coal", "diamonds", "gold", "lapis", "emerald"): catalogue = json.loads((ROOT / f"src/main/resources/assets/realisticdeposits/defaults/{name}.json").read_text()) for definition in catalogue["deposits"]: definition["enabled"] = name == "lapis" and case not in ("off", "world-enable") diff --git a/tools/qualification/lapis_mines_110.py b/tools/qualification/lapis_mines_110.py index 48a07c4..071cd23 100644 --- a/tools/qualification/lapis_mines_110.py +++ b/tools/qualification/lapis_mines_110.py @@ -74,7 +74,7 @@ def main(): properties = properties.replace(";1;decoration", ";1;") (root / "server.properties").write_text(properties, encoding="utf-8", newline="\n") - for name in ("vanilla", "coal", "diamonds", "gold", "lapis"): + for name in ("vanilla", "coal", "diamonds", "gold", "lapis", "emerald"): location = ROOT / (f"run/config/realisticdeposits/deposits/{name}.json" if case == "regular" else f"src/main/resources/assets/realisticdeposits/defaults/{name}.json") catalogue = json.loads(location.read_text()) diff --git a/tools/qualification/lapis_survey_110.py b/tools/qualification/lapis_survey_110.py index c9f3cb0..da3e255 100644 --- a/tools/qualification/lapis_survey_110.py +++ b/tools/qualification/lapis_survey_110.py @@ -48,7 +48,7 @@ def main(): settings = settings.replace("3346071746553592696", str(SEEDS[index])) (root / "server.properties").write_text(settings, encoding="utf-8", newline="\n") - for name in ("vanilla", "coal", "diamonds", "gold", "lapis"): + for name in ("vanilla", "coal", "diamonds", "gold", "lapis", "emerald"): catalogue = json.loads((ROOT / f"src/main/resources/assets/realisticdeposits/defaults/{name}.json").read_text()) for definition in catalogue["deposits"]: definition["enabled"] = name == "lapis" and not args.control diff --git a/tools/qualification/probes.init.gradle b/tools/qualification/probes.init.gradle index 961eee6..d9f3269 100644 --- a/tools/qualification/probes.init.gradle +++ b/tools/qualification/probes.init.gradle @@ -1,5 +1,24 @@ gradle.afterProject { project, state -> if (project == project.rootProject && state.failure == null) { + def emeraldInput = project.tasks.register('emeraldQualificationProbeInput', Jar) { + dependsOn project.tasks.named('testClasses') + from project.sourceSets.test.output.classesDirs + include 'zone/moddev/mc/realisticdeposits/generation/EmeraldRuntimeProbe*.class' + include 'zone/moddev/mc/realisticdeposits/generation/EmeraldClientProbe*.class' + include 'zone/moddev/mc/realisticdeposits/geometry/EmeraldWalkProbe*.class' + archiveFileName = 'emerald-probe-deobf.jar' + destinationDirectory = project.layout.buildDirectory.dir('qualification') + } + def emeraldPackaged = project.renamer.classes(emeraldInput) { + map.from project.minecraft.dependency.toSrgFile + output = project.layout.buildDirectory.file('qualification/emerald-probe.jar') + } + project.tasks.register('prepareEmeraldQualification') { + group = 'verification' + description = 'Builds the isolated local emerald qualification probe.' + dependsOn emeraldPackaged + } + def lapisInput = project.tasks.register('lapisQualificationProbeInput', Jar) { dependsOn project.tasks.named('testClasses') from project.sourceSets.test.output.classesDirs diff --git a/tools/qualification/qualify_110.py b/tools/qualification/qualify_110.py index 2d5370c..6818ddd 100644 --- a/tools/qualification/qualify_110.py +++ b/tools/qualification/qualify_110.py @@ -12,7 +12,7 @@ from pathlib import Path -ORESPAWN_SHA256 = "1E580EBB8D93B1256E2E81C7D0D815DEAAAC58A028CB5A77A52773B62048E010" +ORESPAWN_SHA256 = "05DB7CBFAADC815D6A713DACDFB9F0647BCED49CCFC357FB2FF010E95BD1379F" 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") @@ -165,6 +165,10 @@ def prepare(args, name, origin, policy, managed, port, bootstrap=False): .read_text(encoding="utf-8")) lapis["deposits"][0]["enabled"] = False write_json(root / "config/realisticdeposits/deposits/lapis.json", lapis) + emerald = json.loads((Path(__file__).parents[2] / "src/main/resources/assets/realisticdeposits/defaults/emerald.json") + .read_text(encoding="utf-8")) + emerald["deposits"][0]["enabled"] = False + write_json(root / "config/realisticdeposits/deposits/emerald.json", emerald) sources = {} if origin != "native": @@ -283,7 +287,7 @@ def check_profile(report, origin, policy, managed): if default_coal is not None: assert default_coal["enabled"] is False, "The unrelated default coalfield must remain disabled" for name in ("overworld_diamond_pipes", "overworld_diamond_placers", "overworld_gold_lodes", "overworld_gold_placers", - "overworld_lapis_lenses"): + "overworld_lapis_lenses", "overworld_emerald_pockets"): default = owned.pop("realisticdeposits:deposit/realisticdeposits/" + name, None) if default is not None: assert default["enabled"] is False, "An unrelated default deposit rule became active" diff --git a/tools/qualification/test_emerald.py b/tools/qualification/test_emerald.py new file mode 100644 index 0000000..fb25b14 --- /dev/null +++ b/tools/qualification/test_emerald.py @@ -0,0 +1,29 @@ +"""An emerald policy pass must prove more than the presence of some ore.""" + +import copy +import unittest + +from emerald_110 import compare_background, followed_zones + + +class EmeraldPolicyReceiptTest(unittest.TestCase): + def test_prospecting_counts_only_face_connected_generated_clues(self): + mine = {"ore": ["0,20,0", "3,21,0", "7,21,0"], "zone_ore": [1, 1, 1], + "landing": "0,20,1", "trace": ["1,20,0", "2,20,0", "2,21,0", "6,21,0"]} + self.assertEqual([1, 2], followed_zones(mine)) + mine["trace"].remove("2,20,0") + self.assertEqual([1], followed_zones(mine)) + + def test_distinguishes_shared_rng_from_changed_rules_or_gates(self): + control = {"ordinary_rules": {"emerald": {"frequency": 1.0}}, "background_events": 25, + "background_denied": 0, "background": ["0,12,0", "1,13,2"], + "first": {"ore": []}} + active = copy.deepcopy(control) + active["background"][1] = "3,13,2" + active["first"]["ore"].append("12,105,10") + self.assertEqual(1, compare_background(active, control)["matching_positions"]) + for field, value in (("ordinary_rules", {}), ("background_denied", 1), ("background", [])): + with self.subTest(field=field): + broken = dict(active, **{field: value}) + with self.assertRaises(AssertionError): + compare_background(broken, control) diff --git a/tools/qualification/test_gold_survey.py b/tools/qualification/test_gold_survey.py index b31c217..5c812cf 100644 --- a/tools/qualification/test_gold_survey.py +++ b/tools/qualification/test_gold_survey.py @@ -11,7 +11,7 @@ class GoldSurveyChecksTest(unittest.TestCase): def test_controls_disable_every_catalogue_without_editing_the_defaults(self): catalogues = survey.control_catalogues() - self.assertEqual({"vanilla", "coal", "diamonds", "gold", "lapis"}, set(catalogues)) + self.assertEqual({"vanilla", "coal", "diamonds", "gold", "lapis", "emerald"}, set(catalogues)) self.assertTrue(all(not definition["enabled"] for catalogue in catalogues.values() for definition in catalogue["deposits"])) self.assertTrue(any(definition["enabled"] for definition in @@ -25,7 +25,7 @@ def test_seed_indices_must_be_unique_and_inside_the_fixed_survey(self): def test_all_surveys_must_use_the_same_public_engine_mod_and_probe(self): inputs = {"RealisticDeposits-0.4.0.110021.jar": "A" * 64, - "OreSpawn-4.1.5.110021.jar": ORESPAWN_SHA256, + "OreSpawn-4.1.6.110021.jar": ORESPAWN_SHA256, "gold-survey.jar": "B" * 64} self.assertEqual("A" * 64, balance.checked_candidate([inputs, inputs, inputs])) for key in inputs: diff --git a/tools/qualification/test_lapis.py b/tools/qualification/test_lapis.py index f94303b..8ae1831 100644 --- a/tools/qualification/test_lapis.py +++ b/tools/qualification/test_lapis.py @@ -35,7 +35,7 @@ def test_rewards_count_native_lapis_pieces_not_ore_blocks(self): def test_surveys_require_matching_public_inputs(self): record = {"RealisticDeposits-0.5.0.110021.jar": "A" * 64, - "OreSpawn-4.1.5.110021.jar": ORESPAWN_SHA256, "lapis-probe.jar": "B" * 64} + "OreSpawn-4.1.6.110021.jar": ORESPAWN_SHA256, "lapis-probe.jar": "B" * 64} self.assertEqual("A" * 64, balance.checked_inputs([record, record])) for key in record: changed = copy.deepcopy(record) diff --git a/tools/release/test_release.py b/tools/release/test_release.py index 64dbf7d..2c453f3 100644 --- a/tools/release/test_release.py +++ b/tools/release/test_release.py @@ -13,10 +13,10 @@ def test_public_dependency_and_candidate_version(self): properties = dict(line.split("=", 1) for line in (ROOT / "gradle.properties").read_text().splitlines() if "=" in line and not line.startswith("#")) - self.assertEqual("0.5.0.110021", properties["mod_version"]) - self.assertEqual("4.1.5.110021", properties["orespawn_version"]) - self.assertEqual("9397156", properties["orespawn_curse_file_id"]) - self.assertEqual("1E580EBB8D93B1256E2E81C7D0D815DEAAAC58A028CB5A77A52773B62048E010", + self.assertEqual("0.6.0.110021", properties["mod_version"]) + self.assertEqual("4.1.6.110021", properties["orespawn_version"]) + self.assertEqual("9518063", properties["orespawn_curse_file_id"]) + self.assertEqual("05DB7CBFAADC815D6A713DACDFB9F0647BCED49CCFC357FB2FF010E95BD1379F", properties["orespawn_sha256"]) self.assertEqual("1689703", properties["curseforge_project_id"]) @@ -52,6 +52,23 @@ def test_codeql_observes_a_fresh_compilation(self): self.assertIn("build-mode: manual", workflow) self.assertIn("--no-build-cache --rerun-tasks", workflow) + def test_curseforge_only_publication_still_requires_confirmation_and_verified_build(self): + workflow = (ROOT / ".github/workflows/deploy-release.yml").read_text() + for job in ("create_release_tag", "publish_maven", "publish_github"): + definition = workflow.split(f" {job}:\n", 1)[1].split(" needs:", 1)[0] + self.assertIn("inputs.mode == 'publish' && inputs.publication_scope == 'all'", definition) + curseforge = workflow.split(" publish_curseforge:\n", 1)[1].split(" publish_github:\n", 1)[0] + for guard in ("!cancelled()", "inputs.mode == 'publish'", + "needs.preflight.result == 'success'", "needs.build.result == 'success'", + "needs.release_confirmation.result == 'success'", + "inputs.publication_scope == 'curseforge-only' && needs.publish_maven.result == 'skipped'", + "inputs.publication_scope == 'all' && needs.publish_maven.result == 'success'"): + self.assertIn(guard, curseforge) + confirmation = workflow.split(" release_confirmation:\n", 1)[1].split(" create_release_tag:\n", 1)[0] + self.assertIn('required=(CURSEFORGE_TOKEN)', confirmation) + self.assertIn('! "$EXPECTED_CHECKSUMS_SHA256" =~ ^[0-9a-fA-F]{64}$', confirmation) + self.assertIn("steps.curseforge.outputs.curseforge-version", curseforge) + def test_verified_manifest_is_checked_before_publication(self): workflow = (ROOT / ".github/workflows/deploy-release.yml").read_text() staging = workflow.split(" - name: Stage the immutable release artifact", 1)[1]