diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7fc461d3..b56856eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,24 +1,93 @@ -name: CI +name: OreSpawn 1.20.6 NeoForge CI -on: [push, pull_request] -#on: -# push: -# branches: [ master-1.12 ] -# pull_request: -# # The branches below must be a subset of the branches above -# branches: [ master-1.12 ] -# types: [opened, synchronize, reopened] +on: + push: + branches: + - master-1.20.6-neo + - 'feature/**' + pull_request: + branches: + - master-1.20.6-neo + +permissions: + contents: read + +concurrency: + group: orespawn-1.20.6-neo-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: + cold-bootstrap: + name: Cold NeoForge bootstrap + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - name: Install exact Java 21 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '21.0.7+6.0.LTS' + - name: Bootstrap an isolated empty NeoGradle cache + shell: bash + run: | + set -euo pipefail + chmod +x ./gradlew + test ! -d .gradle + export GRADLE_USER_HOME="$RUNNER_TEMP/orespawn-neoforge-cold-cache" + rm -rf "$GRADLE_USER_HOME" + gradle_jdk_args=( + "-Dorg.gradle.java.installations.paths=$JAVA_HOME" + -Dorg.gradle.java.installations.auto-detect=false + -Dorg.gradle.java.installations.auto-download=false + ) + ./gradlew help verifyLegacyFixtures --no-daemon --no-build-cache --stacktrace --max-workers=2 "${gradle_jdk_args[@]}" + ./gradlew help verifyLegacyFixtures --offline --no-daemon --no-build-cache --stacktrace --max-workers=2 "${gradle_jdk_args[@]}" + build: + name: Build, test, and audit runs-on: ubuntu-latest - name: Build + timeout-minutes: 60 steps: - - uses: actions/checkout@v2 - - uses: actions/setup-java@v1 + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - name: Install exact Java 21 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '21.0.7+6.0.LTS' + - name: Set up Gradle + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6 + - name: Build, test, and audit release artifacts + run: | + chmod +x ./gradlew + ./gradlew clean check build javadoc verifyReleaseArtifacts writeReleaseChecksums eclipse verifyEclipseProductionClasspath --no-daemon --stacktrace --max-workers=2 \ + "-Dorg.gradle.java.installations.paths=$JAVA_HOME" \ + -Dorg.gradle.java.installations.auto-detect=false \ + -Dorg.gradle.java.installations.auto-download=false + - name: Upload audited release candidate + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: OreSpawn-1.20.6-neoforge-${{ github.sha }} + if-no-files-found: error + retention-days: 30 + path: | + build/libs/OreSpawn-4.0.16.120062.jar + build/libs/OreSpawn-4.0.16.120062-sources.jar + build/libs/OreSpawn-4.0.16.120062-javadoc.jar + build/release/SHA256SUMS + CHANGELOG.txt + - name: Upload diagnostics on failure + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - java-version: 8 - - run: chmod a+x gradlew - - run: ./gradlew --version --no-daemon - - run: ./gradlew setupCIWorkspace -S - - run: ./gradlew clean build -S + name: OreSpawn-1.20.6-neoforge-diagnostics-${{ github.sha }} + if-no-files-found: ignore + retention-days: 14 + path: | + build/test-results/** + build/reports/** + build/*-run/logs/** + build/surface-integration-run/**/*.properties + build/problems/** diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index d5a02752..5254ebfb 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -1,73 +1,60 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# -name: "CodeQL" - -on: [push, pull_request] -#on: -# push: -# branches: [ master-1.12 ] -# pull_request: -# # The branches below must be a subset of the branches above -# branches: [ master-1.12 ] -# types: [opened, synchronize, reopened] -# schedule: -# - cron: '43 7 * * 4' +name: CodeQL + +on: + push: + branches: + - master-1.20.6-neo + - 'feature/**' + pull_request: + branches: + - master-1.20.6-neo + schedule: + - cron: '43 7 * * 4' + +permissions: + actions: read + contents: read + security-events: write jobs: analyze: - name: Analyze + name: Analyze Java runs-on: ubuntu-latest - permissions: - actions: read - contents: read - security-events: write - - strategy: - fail-fast: false - matrix: - language: [ 'java' ] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] - # Learn more: - # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed - + timeout-minutes: 45 steps: - - name: Checkout repository - uses: actions/checkout@v2 - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v1 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - # queries: ./path/to/local/query, your-org/your-repo/queries@main - - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v1 - - # â„šī¸ Command-line programs to run using the OS shell. - # 📚 https://git.io/JvXDl - - # âœī¸ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language - - #- run: | - # make bootstrap - # make release - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - name: Install exact Java 21 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '21.0.7+6.0.LTS' + - name: Set up Gradle + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6 + - name: Initialize CodeQL + uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4 + with: + languages: java-kotlin + - name: Compile production code + shell: bash + run: | + set -euo pipefail + chmod +x ./gradlew + gradle_args=( + clean classes --no-daemon --stacktrace --max-workers=2 + "-Dorg.gradle.java.installations.paths=$JAVA_HOME" + -Dorg.gradle.java.installations.auto-detect=false + -Dorg.gradle.java.installations.auto-download=false + ) + for attempt in 1 2 3; do + if ./gradlew "${gradle_args[@]}"; then + exit 0 + fi + if [ "$attempt" -eq 3 ]; then + echo "CodeQL compilation failed after $attempt attempts." >&2 + exit 1 + fi + echo "::warning::CodeQL compilation attempt $attempt failed; retrying with the preserved NeoGradle cache." + done + - name: Analyze + uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4 diff --git a/.github/workflows/release-on-tag.yml b/.github/workflows/release-on-tag.yml new file mode 100644 index 00000000..83bc858f --- /dev/null +++ b/.github/workflows/release-on-tag.yml @@ -0,0 +1,94 @@ +name: Start OreSpawn release from tag + +on: + push: + tags: + - '*.*.*.*' + +permissions: + actions: read + contents: read + +concurrency: + group: orespawn-release-starter-${{ github.ref_name }} + cancel-in-progress: false + +jobs: + validate-release-tag: + name: Validate tag for manual release confirmation + if: github.repository == 'MinecraftModDevelopmentMods/OreSpawn' + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Check out tagged source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + + - name: Validate release tag, target metadata, and prior CI + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + value() { sed -n "s/^$1=//p" gradle.properties; } + release_version="$(value mod_version)" + minecraft_version="$(value minecraft_version)" + loader_name="$(value loader_name)" + loader_code="$(value loader_code)" + + IFS=. read -r mc_major mc_minor mc_patch extra <<<"$minecraft_version" + if [[ -n "${extra:-}" || -z "${mc_major:-}" || -z "${mc_minor:-}" ]]; then + echo "Invalid minecraft_version=$minecraft_version" >&2 + exit 1 + fi + mc_patch="${mc_patch:-0}" + if [[ ! "$mc_major" =~ ^[0-9]+$ || ! "$mc_minor" =~ ^[0-9]+$ || ! "$mc_patch" =~ ^[0-9]+$ ]]; then + echo "Invalid minecraft_version=$minecraft_version" >&2 + exit 1 + fi + case "$loader_name:$loader_code" in + forge:1|neoforge:2) ;; + *) echo "Invalid loader metadata $loader_name/$loader_code" >&2; exit 1 ;; + esac + printf -v minor_padded '%02d' "$((10#$mc_minor))" + printf -v patch_padded '%02d' "$((10#$mc_patch))" + target_suffix="${mc_major}${minor_padded}${patch_padded}${loader_code}" + + if [[ ! "$release_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.${target_suffix}$ ]]; then + echo "mod_version $release_version does not match $minecraft_version $loader_name target $target_suffix" >&2 + exit 1 + fi + if [[ "$GITHUB_REF_NAME" != "$release_version" ]]; then + echo "Release tag must equal mod_version $release_version; found $GITHUB_REF_NAME" >&2 + exit 1 + fi + + successful_ci="$(gh api \ + "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/check-runs?per_page=100" \ + --jq '[.check_runs[] | select(.name == "Build, test, and audit" and .conclusion == "success")] | length')" + if [[ "$successful_ci" -lt 1 ]]; then + echo "The tagged commit has no successful Build, test, and audit check" >&2 + exit 1 + fi + + - name: Record the required manual publication step + env: + RELEASE_WORKFLOW_URL: https://github.com/${{ github.repository }}/actions/workflows/deploy-release.yml + run: | + { + echo "## Release candidate validated" + echo + echo "Tag \`$GITHUB_REF_NAME\` matches the selected target and has a successful Build, test, and audit check." + echo + echo "**Nothing has been published.**" + echo + echo "To continue, open [Deploy OreSpawn release]($RELEASE_WORKFLOW_URL), select **Run workflow**, and enter:" + echo + echo "- release_version: \`$GITHUB_REF_NAME\`" + echo "- curseforge_release_level: \`release\`, \`beta\`, or \`alpha\`" + echo "- confirm_live_publication: \`true\`" + echo + echo "The dispatcher builds and audits the immutable bundle before the separate \`release\` environment approval gate." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml deleted file mode 100644 index c9c52a54..00000000 --- a/.github/workflows/sonarqube.yml +++ /dev/null @@ -1,30 +0,0 @@ -on: [push, pull_request] -#on: -# push: -# branches: -# - master-1.12 -# pull_request: -# types: [opened, synchronize, reopened] -# -name: SonarCloud -jobs: - sonarcloud: - name: SonarCloud Scan - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - with: - # Disabling shallow clone is recommended for improving relevancy of reporting - fetch-depth: 0 - - name: SonarCloud Scan - uses: SonarSource/sonarcloud-github-action@master - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} -# SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} - - name: SonarCloud Quality Gate check - uses: SonarSource/sonarqube-quality-gate-action@master - # Force to fail step after specific time - timeout-minutes: 5 - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.github/workflows/validate-gradle-build.yml b/.github/workflows/validate-gradle-build.yml index 528f4b5a..8097929a 100644 --- a/.github/workflows/validate-gradle-build.yml +++ b/.github/workflows/validate-gradle-build.yml @@ -1,11 +1,23 @@ name: Validate Gradle Wrapper -on: [push, pull_request] +on: + push: + branches: + - master-1.20.6-neo + - 'feature/**' + pull_request: + branches: + - master-1.20.6-neo + +permissions: + contents: read jobs: validation: - name: "Validation" + name: Validation runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: gradle/wrapper-validation-action@v1 + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - name: Validate wrapper integrity + uses: gradle/actions/wrapper-validation@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6 diff --git a/.gitignore b/.gitignore index e84eddae..dc855b3a 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,8 @@ run classes logs /mcmodsrepo/ +/src/generated/resources/META-INF/orespawn/docs/ +/config/orespawn-worldgen.json # machine-specific agent context (public integration notes live under /docs) /AGENTS.md @@ -45,6 +47,8 @@ logs # local regression, benchmark, and profiling evidence /run-*/ /benchmark-*/ +/preferred-seed-*/ +/validation/ /regression-*/ /evidence/ /evidence-*/ diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 7d622d15..6c71313a 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,69 @@ +Version 4.0.16.120062 + +* Leave benchmark shutdown to the GameTest harness when a benchmark is run + through Minecraft's GameTest server, preventing a null test-tracker crash and + allowing the harness to report its real test result. +* Ordinary dedicated benchmark servers still stop automatically when requested. + +Version 4.0.15.120062 + +* Classify generated geology and public geology samples through the same + stable quart-biome cell at three-dimensional biome boundaries. +* Keep ore family-host filters and sampler predictions consistent when later + surface features alter the final heightmap by a small amount. +* Existing chunks, profiles, API signatures, and schemas are unchanged. + +Version 4.0.14.120062 + +* Convert naturally exposed one-layer Snow at the first free block above the + motion-blocking surface while retaining the existing Snow and Ice scan. +* Preserve buried or authored Snow and Ice, unconfigured dimensions, fluids, + bedrock, block entities, profiles, schemas, and existing chunks. + +Version 4.0.13.120062 + +* Give the public ore-dimension builder the exact biome include/exclude and + biome-dictionary filter support already available in provider JSON. +* Accept valid namespaced geome IDs in both creation-editor validation paths + while preserving legacy unnamespaced geome keys. +* API major 1, schemas, existing profiles, generated chunks, and worldgen + behaviour are unchanged. + +Version 4.0.12.120062 + +* Classify public geology samples at the same highest occupied block used by + chunk geology generation, rather than the first free block above it. +* Keep public sampler predictions consistent with generated rock at vertical + biome seams without changing existing chunks, profiles, or generation. + +Version 4.0.11.120062 + +* Preserve biome-dictionary geome weights when a data-driven biome is reached + through its stable registry key rather than the object baked at startup. +* Apply ore biome include and exclude filters by stable registry key so + dynamic-registry biome instances with the same ID are treated consistently. +* Existing chunks and profile formats are unchanged; the corrections apply to + generation in affected provider biomes. + +Version 4.0.10.120062 + +* Evaluate Stable Layers rock minimum and maximum heights against actual world Y while preserving shifted formation identity. +* Deterministically choose another eligible configured rock before using the vanilla Stone fallback. + +Version 4.0.9.120062 + +* Replace provider-declared natural terrain hosts during the existing geology scan before structure and vegetation features can author matching blocks. +* Keep air, fluids, bedrock and block entities protected even when their IDs are mistakenly declared as terrain hosts. +* Apply the correction only while generating new chunks; existing chunks and saved profiles remain unchanged. + +Version 4.0.8.120062 + +* Preserve long host, tag and biome-list values when Ore and Fluid editors reopen by applying their existing maximum lengths before saved text is restored. +* Add deterministic, audited release artifacts, checksum output, guarded Maven preparation and pinned build, wrapper, CodeQL and release-tag workflows. +* Make the Mineralogy 1.18.2 5.4.0 compatibility oracle mandatory, checksum-sealed and isolated from production artifacts and Eclipse launches. +* Export and verify the complete 21-file documentation set, including VERSIONS.md. +* Standardize NeoForge 1.20.6 builds on NeoGradle 7.1.38, Gradle 9.2.1 and Temurin 21.0.7+6. + Version 4.0.6.120062 * Adopt target-qualified four-component versions so Minecraft and loader compatibility can be identified from the mod version. diff --git a/Jenkinsfile b/Jenkinsfile deleted file mode 100644 index a3081bfe..00000000 --- a/Jenkinsfile +++ /dev/null @@ -1,128 +0,0 @@ -pipeline { - agent any - environment { - GRADLE_OPTS = '-Dorg.gradle.caching=true -Dorg.gradle.configureondemand=true -Dorg.gradle.warning.mode=all' -// JAVA_OPTS = '' - } - options { - ansiColor('xterm') - } - tools { -// git 'Git' - gradle 'Gradle 4.9' - jdk 'oraclejdk8' - } - stages { - stage('prebuild') { - steps { - sh 'rm -rf build/libs' - sh 'chmod +x gradlew' - sh 'java -version' - sh 'gradle -version' - sh './gradlew -version' - sh 'export' - } - } - stage('CIWorkspace') { - steps { - withGradle { - sh './gradlew clean setupCiWorkspace -S' - } - } - } - stage('build') { - steps { - withGradle { - sh './gradlew build -S' - } - } - } - stage('test') { - steps { - withGradle { - sh './gradlew test -S' - } - } - } - stage('publish') { - steps { - withCredentials([file(credentialsId: 'secret.json', variable: 'SECRET_FILE')]) { - withGradle { - sh './gradlew publish -S' - } - } - } - } - stage('CurseForge') { - steps { - withCredentials([file(credentialsId: 'secret.json', variable: 'SECRET_FILE')]) { - withGradle { - sh './gradlew -x publish curseforge -S' - } - } - } - } - stage('SonarQube') { - tools { - jdk "oraclejdk11" - } - environment { - scannerHome = tool 'SonarQube' - } - steps { -// withCredentials([file(credentialsId: 'secret.json', variable: 'SECRET_FILE')]) { -// withGradle { -// sh './gradlew sonarqube -S' -// } -// } - withSonarQubeEnv(installationName: 'SonarCloud', , envOnly: false) { - sh "${scannerHome}/bin/sonar-scanner -Dsonar.java.jdkHome=${JAVA_HOME}" - } - } - } - stage('postbuild') { - steps { - archiveArtifacts artifacts: 'build/libs/*.jar', followSymlinks: false - javadoc javadocDir: 'build/docs/javadoc', keepAll: false - fingerprint 'build/libs/*.zip' - junit allowEmptyResults: true, testResults: '**/build/test-results/junit-platform/*.xml' - jacoco classPattern: '**/build/classes/java', execPattern: '**/build/jacoco/**.exec', sourceInclusionPattern: '**/*.java', sourcePattern: '**/src/main/java' - findBuildScans() - recordIssues(tools: [java()]) - recordIssues(tools: [javaDoc()]) -// if (fileExists('')) { -// recordIssues(tools: [errorProne(pattern: 'ReportFilePattern', reportEncoding: 'UTF-8')]) -// } else { -// echo 'No ErrorProne report available' -// } - if (fileExists('**/build/reports/checkstyle/*.xml')) { - recordIssues(tools: [checkStyle(pattern: '**/build/reports/checkstyle/*.xml')]) - } else { - echo 'No CheckStyle report available' - } - if (fileExists('**/build/reports/pmd/*.xml')) { - recordIssues(tools: [pmdParser(pattern: '**/build/reports/pmd/*.xml')]) - } else { - echo 'No PMD report available' - } - if (fileExists('*/build/reports/findbugs/*.xml')) { - recordIssues(tools: [findBugs(pattern: '*/build/reports/findbugs/*.xml', useRankAsPriority: true)]) - } else { - echo 'No FindBugs report available' - } - } - when { expression { fileExists('**/build/reports/spotbugs/*.xml') } } - steps { - recordIssues(tools: [spotBugs(pattern: '**/build/reports/spotbugs/*.xml', useRankAsPriority: true)]) - } - when { expression { fileExists('**/build/test-results/junit-platform/*.xml') } } - steps { - recordIssues(tools: [junitParser(pattern: '**/build/test-results/junit-platform/*.xml')]) - } - when { expression { fileExists('**/sonar-report.json') } } - steps { - recordIssues(tools: [sonarQube(pattern: '**/sonar-report.json')]) - } - } - } -} diff --git a/README.md b/README.md index abf7a557..b6dac10e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # MMD OreSpawn +[![Discord](https://img.shields.io/discord/303440391124942858?label=Discord&logo=discord)](https://discord.moddev.zone) +[![CurseForge downloads](https://cf.way2muchnoise.eu/full_245586_downloads.svg)](https://www.curseforge.com/minecraft/mc-mods/orespawn) +[![Minecraft](https://cf.way2muchnoise.eu/versions/245586.svg)](https://www.curseforge.com/minecraft/mc-mods/orespawn/files) +[![CI](https://github.com/MinecraftModDevelopmentMods/OreSpawn/actions/workflows/ci.yml/badge.svg?branch=master-1.20.6-neo)](https://github.com/MinecraftModDevelopmentMods/OreSpawn/actions/workflows/ci.yml?query=branch%3Amaster-1.20.6-neo) + OreSpawn 4 is a provider-driven world-generation engine for Minecraft 1.20.6. It gives mods and modpacks one place to configure ores, deposit shapes, optional rock strata and geomes, provider-owned underground fluid deposits, biome @@ -89,11 +94,15 @@ exported to `config/orespawn-guide/` without overwriting existing files. ## Building -Use Java 21 from the repository root: +This branch builds target-qualified version `4.0.16.120062`: the OreSpawn 4.0.16 +feature set for Minecraft 1.20.6 and NeoForge. + +OreSpawn 4.0.16.120062 uses NeoGradle 7.1.38 and Gradle 9.2.1. Use exact +Temurin 21.0.7+6 from the repository root: ```powershell -.\gradlew.bat clean build javadoc --no-daemon -.\gradlew.bat eclipse --no-daemon +.\gradlew.bat clean check build javadoc verifyReleaseArtifacts writeReleaseChecksums --no-daemon --max-workers=2 +.\gradlew.bat eclipse verifyEclipseProductionClasspath --no-daemon --max-workers=2 ``` `build` runs the standard `check` lifecycle. In addition to the JUnit suite, @@ -108,6 +117,10 @@ Import or refresh the project with Eclipse Buildship. NeoGradle supplies the Eclipse model and run configurations through the `eclipse` task; this branch does not use ForgeGradle's `genEclipseRuns` task. +The audited public outputs are `OreSpawn-4.0.16.120062.jar`, its sources jar, +and its Javadoc jar. CI additionally proves a cache-cold NeoGradle bootstrap +and a same-cache offline rerun. + Machine-specific `AGENTS.md` and `agent-notes/` files are intentionally ignored. Public developer and AI integration guidance lives in `docs/` and is included in the built jar. diff --git a/build.gradle b/build.gradle index d450015b..c6a019e0 100644 --- a/build.gradle +++ b/build.gradle @@ -1,3 +1,10 @@ +import groovy.xml.XmlSlurper +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.util.jar.Manifest +import java.util.zip.ZipFile +import org.apache.tools.ant.filters.FixCrLfFilter + plugins { id 'eclipse' id 'idea' @@ -10,21 +17,70 @@ version = mod_version group = mod_group_id base { - archivesName = "OreSpawn-${minecraft_version}-neoforge" + archivesName = 'OreSpawn' +} + +def versionParts = project.mod_version.toString().tokenize('.') +if (versionParts.size() != 4 || !versionParts.every { it ==~ /\d+/ }) { + throw new GradleException("mod_version must use Major.Minor.Bug.Target numeric form: ${project.mod_version}") } +def minecraftVersionParts = project.minecraft_version.toString().tokenize('.') +def minecraftPatch = minecraftVersionParts.size() == 3 ? minecraftVersionParts[2] : '0' +def expectedTargetVersion = "${minecraftVersionParts[0]}" + + "${minecraftVersionParts[1].padLeft(2, '0')}" + + "${minecraftPatch.padLeft(2, '0')}" + project.loader_code +if (versionParts[3] != expectedTargetVersion) { + throw new GradleException("mod_version target ${versionParts[3]} does not match " + + "Minecraft ${project.minecraft_version} ${project.loader_name} target ${expectedTargetVersion}") +} +ext.functional_version = versionParts[0..2].join('.') +ext.display_version = project.mod_version +ext.release_tag = project.mod_version +def expectedMavenGroup = 'zone.moddev.mc.orespawn' +def expectedMavenArtifact = 'OreSpawn' +def expectedMavenCoordinate = "${expectedMavenGroup}:${expectedMavenArtifact}:${project.version}" java { - toolchain.languageVersion = JavaLanguageVersion.of(21) + toolchain { + languageVersion = JavaLanguageVersion.of(21) + vendor = JvmVendorSpec.ADOPTIUM + } withSourcesJar() withJavadocJar() } +tasks.withType(AbstractArchiveTask).configureEach { + preserveFileTimestamps = false + reproducibleFileOrder = true +} + +def archiveTextSuffixes = [ + '.cfg', '.css', '.html', '.info', '.java', '.js', '.json', '.lang', + '.mcmeta', '.md', '.properties', '.toml', '.txt', '.xml' +] +def archiveTextPatterns = archiveTextSuffixes.collect { "**/*${it}".toString() } +archiveTextPatterns.addAll(['**/element-list', '**/package-list']) +def normalizeArchiveLineEndings = { details -> + details.filter(FixCrLfFilter, + eol: FixCrLfFilter.CrLf.newInstance('lf'), + eof: FixCrLfFilter.AddAsisRemove.newInstance('asis')) +} + println "Java: ${System.getProperty 'java.version'}, JVM: ${System.getProperty 'java.vm.version'} (${System.getProperty 'java.vendor'}), Arch: ${System.getProperty 'os.arch'}" sourceSets.main.resources { srcDir 'src/generated/resources' } +def bundledDocumentationDirectory = layout.projectDirectory.dir( + 'src/generated/resources/META-INF/orespawn/docs') +def prepareBundledDocumentation = tasks.register('prepareBundledDocumentation', Sync) { + group = 'build' + description = 'Stages public documentation as a generated production resource tree.' + from 'docs' + into bundledDocumentationDirectory +} + minecraft.accessTransformers.file rootProject.file('src/main/resources/META-INF/accesstransformer.cfg') def configureBenchmark = { run, String radius, String repetitions, String center -> @@ -88,7 +144,9 @@ runs { } gameTestServer { - systemProperty 'neoforge.enabledGameTestNamespaces', mod_id + systemProperty 'neoforge.enabledGameTestNamespaces', + providers.gradleProperty('orespawnBenchmarkMode').isPresent() + ? "${mod_id},minecraft" : mod_id configureBenchmark(delegate, '4', '3', '256') } @@ -118,6 +176,34 @@ configurations { repositories { } +def fixtureRoot = file("${rootDir}/ci-fixtures") +def mineralogy5OracleJar = new File(fixtureRoot, + 'artifacts/Mineralogy-1.18.2-5.4.0.jar') +def mineralogy5OracleSha256 = + 'CCA84E9270585478B08F54BA091AD56AB2CB390386C650ED78B2673DC57403EB' + +tasks.register('verifyLegacyFixtures') { + group = 'verification' + description = 'Verifies the sealed Mineralogy 1.18.2 5.4.0 oracle used only by isolated tests.' + inputs.file mineralogy5OracleJar + doLast { + if (!mineralogy5OracleJar.isFile()) { + throw new GradleException("Missing mandatory Mineralogy oracle: ${mineralogy5OracleJar}") + } + MessageDigest digest = MessageDigest.getInstance('SHA-256') + mineralogy5OracleJar.withInputStream { input -> + byte[] buffer = new byte[8192] + for (int read = input.read(buffer); read >= 0; read = input.read(buffer)) { + if (read > 0) digest.update(buffer, 0, read) + } + } + String actual = digest.digest().encodeHex().toString().toUpperCase() + if (actual != mineralogy5OracleSha256) { + throw new GradleException("Mineralogy oracle checksum mismatch: ${actual}") + } + } +} + dependencies { implementation "net.neoforged:neoforge:${neo_version}" @@ -148,52 +234,64 @@ tasks.withType(ProcessResources).configureEach { } tasks.named('processResources', ProcessResources).configure { + dependsOn prepareBundledDocumentation from('docs/AGENTS.md') { into '' rename { 'AGENTS.md' } } - from('docs') { - into 'META-INF/orespawn/docs' - } } tasks.named('jar', Jar).configure { + archiveClassifier = '' + destinationDirectory = layout.buildDirectory.dir('libs') + filteringCharset = 'UTF-8' + includeEmptyDirs = false + filesMatching(archiveTextPatterns, normalizeArchiveLineEndings) manifest { attributes([ 'Specification-Title' : 'OreSpawn', 'Specification-Vendor' : 'SkyBlade1978', 'Specification-Version' : '1', - 'Implementation-Title' : project.name, - 'Implementation-Version' : project.jar.archiveVersion, + 'Implementation-Title' : base.archivesName.get(), + 'Implementation-Version' : project.version, 'Implementation-Vendor' : 'SkyBlade1978', - 'Implementation-Timestamp' : new Date().format("yyyy-MM-dd'T'HH:mm:ssZ"), 'OreSpawn-API-Version' : '1', - 'OreSpawn-Loader' : 'neoforge' + 'OreSpawn-Loader' : 'neoforge', + 'FMLAT' : 'accesstransformer.cfg', + 'Maven-Artifact' : expectedMavenCoordinate, + 'Built-On-Java' : '21', + 'Built-On' : "${project.minecraft_version}-${project.neo_version}" ]) } } -publishing { - publications { - register('mavenJava', MavenPublication) { - from components.java - artifactId = "OreSpawn-${minecraft_version}-neoforge" - } - } - repositories { - maven { - url "file://${project.projectDir}/mcmodsrepo" - } - } +tasks.named('sourcesJar', Jar).configure { + dependsOn prepareBundledDocumentation + filteringCharset = 'UTF-8' + includeEmptyDirs = false + filesMatching(archiveTextPatterns, normalizeArchiveLineEndings) +} +tasks.named('javadocJar', Jar).configure { + filteringCharset = 'UTF-8' + includeEmptyDirs = false + filesMatching(archiveTextPatterns, normalizeArchiveLineEndings) } tasks.withType(JavaCompile).configureEach { + javaCompiler = javaToolchains.compilerFor { + languageVersion = JavaLanguageVersion.of(21) + vendor = JvmVendorSpec.ADOPTIUM + } + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 options.encoding = 'UTF-8' } tasks.named('javadoc', Javadoc).configure { + failOnError = false options.encoding = 'UTF-8' options.addStringOption('Xdoclint:none', '-quiet') + options.addBooleanOption('notimestamp', true) } tasks.named('test', Test).configure { @@ -205,9 +303,24 @@ tasks.named('test', Test).configure { // Loaded only through an isolated URLClassLoader by the parity test. This // is deliberately not a Gradle dependency and cannot leak into Eclipse or // a published OreSpawn jar. - File mineralogy5Oracle = file('../../MinecraftMineralogy 118/MinecraftMineralogy/build/libs/Mineralogy-1.18.2-5.4.0.jar') - if (mineralogy5Oracle.isFile()) { - systemProperty 'orespawn.mineralogy5Oracle', mineralogy5Oracle.absolutePath + dependsOn tasks.named('verifyLegacyFixtures') + systemProperty 'orespawn.mineralogy5Oracle', mineralogy5OracleJar.absolutePath +} + +tasks.named('compileTestJava', JavaCompile).configure { + dependsOn tasks.named('verifyLegacyFixtures') + options.compilerArgs.add('-proc:none') +} + +tasks.register('verifyLegacyOracleIsolation') { + group = 'verification' + dependsOn tasks.named('verifyLegacyFixtures') + doLast { + configurations.findAll { it.canBeResolved }.each { configuration -> + if (configuration.files.any { it.canonicalFile == mineralogy5OracleJar.canonicalFile }) { + throw new GradleException("Mineralogy oracle leaked into ${configuration.name}") + } + } } } @@ -378,6 +491,12 @@ tasks.named('check') { tasks.matching { it.name == taskName }.configureEach { JavaExec runTask -> doFirst { new File(runTask.workingDir, 'mods').mkdirs() + if (taskName == 'runGameTestServer') { + File serverProperties = new File(runTask.workingDir, 'server.properties') + if (!serverProperties.isFile()) { + serverProperties.setText('# Generated for the OreSpawn GameTest gate\n', 'UTF-8') + } + } runTask.ext.oreSpawnCrashSnapshot = runtimeCrashSnapshot(runTask.workingDir) } doLast { @@ -474,6 +593,425 @@ tasks.named('check') { dependsOn surfaceIntegrationTest } +def clientIntegrationClasses = layout.buildDirectory.dir('client-integration-fixture/classes') +def compileClientIntegrationTestMod = tasks.register('compileClientIntegrationTestMod', JavaCompile) { + dependsOn tasks.named('classes') + source fileTree('src/clientIntegrationTest/java') + classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) + destinationDirectory.set(clientIntegrationClasses) + options.release = 21 + options.encoding = 'UTF-8' +} +def clientIntegrationTestModJar = tasks.register('clientIntegrationTestModJar', Jar) { + dependsOn compileClientIntegrationTestMod + archiveFileName = 'clientprobe.jar' + destinationDirectory = layout.buildDirectory.dir('client-integration-fixture') + from clientIntegrationClasses + from 'src/clientIntegrationTest/resources' +} + +def benchmarkIntegrationClasses = layout.buildDirectory.dir('benchmark-integration-fixture/classes') +def compileBenchmarkIntegrationTestMod = tasks.register('compileBenchmarkIntegrationTestMod', JavaCompile) { + dependsOn tasks.named('classes') + source fileTree('src/benchmarkIntegrationTest/java') + classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) + destinationDirectory.set(benchmarkIntegrationClasses) + options.release = 21 + options.encoding = 'UTF-8' +} +def benchmarkIntegrationTestModJar = tasks.register('benchmarkIntegrationTestModJar', Jar) { + dependsOn compileBenchmarkIntegrationTestMod + archiveFileName = 'benchmarkprobe.jar' + destinationDirectory = layout.buildDirectory.dir('benchmark-integration-fixture') + from benchmarkIntegrationClasses + from 'src/benchmarkIntegrationTest/resources' +} + +def releaseJar = tasks.named('jar', Jar) +def expectedReleaseFiles = providers.provider { + String prefix = "${base.archivesName.get()}-${project.version}" + ["${prefix}.jar", "${prefix}-sources.jar", "${prefix}-javadoc.jar"] +} +def preparedReleaseDir = providers.gradleProperty('preparedReleaseDir') + +tasks.named('assemble') { + dependsOn releaseJar + dependsOn tasks.named('sourcesJar') + dependsOn tasks.named('javadocJar') +} + +tasks.register('verifyReleaseConfiguration') { + group = 'verification' + doLast { + if (project.mod_version != '4.0.16.120062' + || project.mod_group_id != expectedMavenGroup + || project.minecraft_version != '1.20.6' + || project.neo_version != '20.6.139') { + throw new GradleException('Unexpected OreSpawn NeoForge 1.20.6 release identity') + } + if (project.loader_name != 'neoforge' || project.loader_code != '2' + || project.java_version != '21' || project.gradle_java_version != '21' + || project.java_toolchain_version != '21.0.7+6' + || project.java_setup_version != '21.0.7+6.0.LTS') { + throw new GradleException('Unexpected dispatcher or Java target metadata') + } + List expectedPublicArtifacts = [ + 'OreSpawn-4.0.16.120062.jar', + 'OreSpawn-4.0.16.120062-sources.jar', + 'OreSpawn-4.0.16.120062-javadoc.jar' + ] + if (base.archivesName.get() != expectedMavenArtifact + || expectedReleaseFiles.get().collect { it.toString() } != expectedPublicArtifacts) { + throw new GradleException('Public artifacts must use the version-only OreSpawn filename contract') + } + String ciWorkflow = file('.github/workflows/ci.yml').getText('UTF-8') + expectedPublicArtifacts.each { artifactName -> + if (!ciWorkflow.contains("build/libs/${artifactName}")) { + throw new GradleException("CI does not upload expected public artifact ${artifactName}") + } + } + [ + 'src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java', + 'src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java', + 'README.md', 'CHANGELOG.txt' + ].each { path -> + if (!file(path).getText('UTF-8').contains(project.mod_version)) { + throw new GradleException("Release identity missing from ${path}") + } + } + if (!file('docs/API.md').getText('UTF-8').contains('versionRange="[4.0.6,5.0.0)"')) { + throw new GradleException('Consumer compatibility floor must remain [4.0.6,5.0.0)') + } + } +} + +def trackedDocumentationDirectory = file('docs') +def documentationFiles = { + fileTree(trackedDocumentationDirectory).files.findAll { it.isFile() }.collect { + trackedDocumentationDirectory.canonicalFile.toPath().relativize(it.canonicalFile.toPath()) + .toString().replace('\\', '/') }.sort() +} +def assertDocumentationTree = { File root, List expected, String label -> + List actual = root.isDirectory() ? fileTree(root).files.findAll { it.isFile() } + .collect { root.canonicalFile.toPath().relativize(it.canonicalFile.toPath()) + .toString().replace('\\', '/') }.sort() : [] + if (actual != expected) { + throw new GradleException("${label} documentation set ${actual} does not match tracked ${expected}") + } + expected.each { relative -> + if (!java.util.Arrays.equals(new File(trackedDocumentationDirectory, relative).bytes, + new File(root, relative).bytes)) { + throw new GradleException("${label}/${relative} differs from tracked documentation") + } + } +} + +def prepareEclipseResources = tasks.register('prepareEclipseResources', Sync) { + dependsOn tasks.named('processResources') + from layout.buildDirectory.dir('resources/main') + into file('bin/main') +} + +def verifyDocumentationParity = tasks.register('verifyDocumentationParity') { + group = 'verification' + dependsOn prepareBundledDocumentation + dependsOn tasks.named('processResources') + dependsOn prepareEclipseResources + dependsOn releaseJar + doLast { + List expected = documentationFiles() + if (expected.size() != 21 || !expected.contains('VERSIONS.md')) { + throw new GradleException("Expected exactly 21 tracked guide files including VERSIONS.md, found ${expected}") + } + assertDocumentationTree(bundledDocumentationDirectory.asFile, expected, 'generated resources') + assertDocumentationTree(new File(layout.buildDirectory.dir('resources/main').get().asFile, + 'META-INF/orespawn/docs'), expected, 'processed resources') + assertDocumentationTree(file('bin/main/META-INF/orespawn/docs'), expected, 'Eclipse bin/main') + new ZipFile(releaseJar.get().archiveFile.get().asFile).withCloseable { zip -> + expected.each { relative -> + def entry = zip.getEntry("META-INF/orespawn/docs/${relative}") + if (entry == null || !java.util.Arrays.equals( + new File(trackedDocumentationDirectory, relative).bytes, + zip.getInputStream(entry).withCloseable { it.bytes })) { + throw new GradleException("Release jar documentation differs at ${relative}") + } + } + } + } +} + +tasks.register('verifyReleaseArtifacts') { + group = 'verification' + dependsOn tasks.named('verifyReleaseConfiguration') + dependsOn verifyDocumentationParity + dependsOn tasks.named('assemble') + dependsOn tasks.named('verifyLegacyOracleIsolation') + doLast { + File libs = layout.buildDirectory.dir('libs').get().asFile + List jars = (libs.listFiles() ?: [] as File[]) + .findAll { it.name.endsWith('.jar') }.sort { it.name } + List expected = expectedReleaseFiles.get().collect { it.toString() }.sort() + if (jars.collect { it.name } != expected) { + throw new GradleException("Expected exactly ${expected}, found ${jars*.name}") + } + jars.each { candidate -> + if (candidate.length() == 0L) throw new GradleException("Empty artifact ${candidate}") + new ZipFile(candidate).withCloseable { zip -> + zip.entries().findAll { entry -> + !entry.isDirectory() && (archiveTextSuffixes.any { entry.name.endsWith(it) } + || entry.name.endsWith('/element-list') + || entry.name.endsWith('/package-list')) + }.each { entry -> + boolean cr = zip.getInputStream(entry).withCloseable { + input -> input.bytes.any { value -> value == 13 } + } + if (cr) throw new GradleException("${candidate.name}!/${entry.name} is not LF-normalized") + } + [ + 'src/test/', 'src/biomeIntegrationTest/', 'src/clientIntegrationTest/', + 'src/benchmarkIntegrationTest/', 'agent-notes/', 'surfaceprobe', + 'clientprobe', 'benchmarkprobe', 'ci-fixtures/', 'net/minecraftforge/', + 'META-INF/mods.toml', 'org/junit/', 'org/mockito/', 'net/bytebuddy/', + 'Mineralogy-1.18.2-5.4.0.jar' + ].each { forbidden -> + if (zip.entries().any { it.name.contains(forbidden) }) { + throw new GradleException("${candidate.name} contains forbidden ${forbidden}") + } + } + } + } + + File mainJar = new File(libs, expectedReleaseFiles.get()[0]) + new ZipFile(mainJar).withCloseable { zip -> + List names = zip.entries().collect { it.name } + [ + 'META-INF/neoforge.mods.toml', + 'META-INF/accesstransformer.cfg', + 'zone/moddev/mc/orespawn/api/OreSpawnApi.class', + 'META-INF/orespawn/docs/VERSIONS.md', + 'META-INF/orespawn/docs/schemas/orespawn-provider.schema.json', + 'AGENTS.md' + ].each { required -> + if (!names.contains(required)) throw new GradleException("Release jar is missing ${required}") + } + String metadata = zip.getInputStream(zip.getEntry('META-INF/neoforge.mods.toml')) + .getText(StandardCharsets.UTF_8.name()) + if (!metadata.contains('modId="orespawn"') + || !metadata.contains("version=\"${project.version}\"") + || !metadata.contains('versionRange="[20.6.139,20.7)"')) { + throw new GradleException('Packaged NeoForge metadata is incorrect') + } + String transformer = zip.getInputStream(zip.getEntry('META-INF/accesstransformer.cfg')) + .getText(StandardCharsets.UTF_8.name()) + List actualRules = transformer.readLines() + .collect { it.replaceFirst(/\s*#.*/, '').trim() }.findAll { !it.isEmpty() } + List expectedRules = [ + 'public-f net.minecraft.world.level.chunk.ChunkGenerator biomeSource', + 'public-f net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator globalFluidPicker', + 'public-f net.minecraft.world.level.biome.Biome generationSettings', + 'public-f net.minecraft.world.level.levelgen.feature.configurations.SpringConfiguration validBlocks', + 'public net.minecraft.client.gui.screens.worldselection.CreateWorldScreen tabManager', + 'public net.minecraft.client.gui.screens.worldselection.CreateWorldScreen tabNavigationBar' + ] + if (actualRules != expectedRules) { + throw new GradleException("Unexpected packaged official-named access transformer: ${actualRules}") + } + def manifestEntry = zip.getEntry('META-INF/MANIFEST.MF') + def manifest = manifestEntry == null ? null : + new Manifest(zip.getInputStream(manifestEntry)).mainAttributes + if (manifest == null + || manifest.getValue('Implementation-Version') != project.mod_version + || manifest.getValue('OreSpawn-API-Version') != '1' + || manifest.getValue('OreSpawn-Loader') != 'neoforge' + || manifest.getValue('Maven-Artifact') != expectedMavenCoordinate + || manifest.getValue('Implementation-Timestamp') != null) { + throw new GradleException('Release manifest is incorrect or volatile') + } + zip.entries().findAll { it.name.endsWith('.class') }.each { entry -> + byte[] header = zip.getInputStream(entry).withCloseable { it.readNBytes(8) } + int major = ((header[6] & 0xff) << 8) | (header[7] & 0xff) + if (major != 65) throw new GradleException("${entry.name} uses class major ${major}, expected 65") + } + } + new ZipFile(new File(libs, expectedReleaseFiles.get()[1])).withCloseable { zip -> + if (zip.getEntry('zone/moddev/mc/orespawn/OreSpawn.java') == null) { + throw new GradleException('Sources jar is missing OreSpawn.java') + } + } + new ZipFile(new File(libs, expectedReleaseFiles.get()[2])).withCloseable { zip -> + if (zip.getEntry('index.html') == null + || zip.getEntry('zone/moddev/mc/orespawn/api/OreSpawnApi.html') == null) { + throw new GradleException('Javadoc jar is missing its index or public OreSpawn API page') + } + } + } +} + +tasks.register('writeReleaseChecksums') { + group = 'verification' + dependsOn tasks.named('verifyReleaseArtifacts') + def outputFile = layout.buildDirectory.file('release/SHA256SUMS') + outputs.file outputFile + doLast { + File output = outputFile.get().asFile + output.parentFile.mkdirs() + File libs = layout.buildDirectory.dir('libs').get().asFile + String contents = expectedReleaseFiles.get().sort().collect { name -> + MessageDigest digest = MessageDigest.getInstance('SHA-256') + new File(libs, name).withInputStream { input -> + byte[] buffer = new byte[8192] + for (int read = input.read(buffer); read >= 0; read = input.read(buffer)) { + if (read > 0) digest.update(buffer, 0, read) + } + } + "${digest.digest().encodeHex().toString().toUpperCase()} ${name}" + }.join('\n') + '\n' + output.setText(contents, 'UTF-8') + } +} + +tasks.register('verifyPreparedReleaseArtifacts') { + group = 'verification' + doLast { + if (!preparedReleaseDir.isPresent()) throw new GradleException('preparedReleaseDir is required') + File prepared = file(preparedReleaseDir.get()) + List expected = expectedReleaseFiles.get().collect { it.toString() }.sort() + List jars = (prepared.listFiles() ?: [] as File[]) + .findAll { it.name.endsWith('.jar') }.sort { it.name } + if (jars.collect { it.name } != expected || jars.any { it.length() == 0L }) { + throw new GradleException("Prepared release jars ${jars*.name} do not match ${expected}") + } + File checksums = new File(prepared, 'SHA256SUMS') + if (!checksums.isFile() || !new File(prepared, 'CHANGELOG.txt').isFile()) { + throw new GradleException('Prepared release is missing checksums or changelog') + } + } +} + +def mavenUploadUrl = providers.environmentVariable('MAVEN_UPLOAD_URL') + .orElse('https://invalid.invalid/missing-maven-upload-url') +def mavenUploadUsername = providers.environmentVariable('MAVEN_UPLOAD_USERNAME') +def mavenUploadPassword = providers.environmentVariable('MAVEN_UPLOAD_PASSWORD') +publishing { + publications { + mavenJava(MavenPublication) { + groupId = expectedMavenGroup + artifactId = expectedMavenArtifact + version = project.version.toString() + if (preparedReleaseDir.isPresent()) { + File prepared = file(preparedReleaseDir.get()) + artifact(new File(prepared, expectedReleaseFiles.get()[0])) + artifact(new File(prepared, expectedReleaseFiles.get()[1])) { classifier = 'sources' } + artifact(new File(prepared, expectedReleaseFiles.get()[2])) { classifier = 'javadoc' } + } else { + from components.java + } + pom { + name = 'MMD OreSpawn' + description = project.mod_description + url = 'https://github.com/MinecraftModDevelopmentMods/OreSpawn' + licenses { + license { + name = 'GNU Lesser General Public License, Version 2.1' + url = 'https://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt' + } + } + } + } + } + repositories { + maven { + name = 'release' + url = uri(mavenUploadUrl.get()) + credentials { + username = mavenUploadUsername.orNull ?: '' + password = mavenUploadPassword.orNull ?: '' + } + } + } +} + +tasks.register('verifyMavenCoordinates') { + group = 'verification' + dependsOn tasks.named('generatePomFileForMavenJavaPublication') + doLast { + File pomFile = layout.buildDirectory.file( + 'publications/mavenJava/pom-default.xml').get().asFile + def pom = new XmlSlurper(false, false).parse(pomFile) + def actual = [pom.groupId.text(), pom.artifactId.text(), pom.version.text()] + def expected = [expectedMavenGroup, expectedMavenArtifact, project.version.toString()] + if (project.group.toString() != expectedMavenGroup || actual != expected) { + throw new GradleException("Expected Maven coordinate ${expected.join(':')}, found ${actual.join(':')}") + } + } +} + +tasks.register('validateMavenReleaseCredentials') { + group = 'publishing' + doLast { + if (!providers.environmentVariable('MAVEN_UPLOAD_URL').isPresent() + || !mavenUploadUsername.isPresent() || !mavenUploadPassword.isPresent()) { + throw new GradleException( + 'MAVEN_UPLOAD_URL, MAVEN_UPLOAD_USERNAME, and MAVEN_UPLOAD_PASSWORD are required') + } + if (providers.environmentVariable('MAVEN_UPLOAD_URL').get().startsWith('file:')) { + throw new GradleException('Release publication must use a remote repository') + } + } +} +tasks.withType(PublishToMavenRepository).configureEach { + dependsOn tasks.named('validateMavenReleaseCredentials') + dependsOn tasks.named('verifyMavenCoordinates') + dependsOn preparedReleaseDir.isPresent() + ? tasks.named('verifyPreparedReleaseArtifacts') + : tasks.named('verifyReleaseArtifacts') +} + +tasks.register('verifyCommandPortability') { + group = 'verification' + description = 'Rejects shell-specific wrappers in generic Gradle task execution.' + doLast { + List commandSources = [file('build.gradle')] + commandSources.addAll(fileTree('.github/workflows') { include '*.yml', '*.yaml' }.files) + commandSources.each { File source -> + String text = source.getText('UTF-8') + if (text =~ /(?i)(?:commandLine|executable)\s*[^\n]*(?:cmd(?:\.exe)?\s+\/c|powershell(?:\.exe)?\s+-command|(?:bash|sh)\s+-c)/) { + throw new GradleException("Shell-specific command wrapper in ${source}") + } + } + } +} + +tasks.register('verifyColdNeoForgeBootstrapContract') { + group = 'verification' + description = 'Verifies that CI qualifies NeoGradle from an isolated Java 21 cache without Forge tooling.' + doLast { + String workflow = file('.github/workflows/ci.yml').getText('UTF-8') + [ + 'Cold NeoForge bootstrap', + 'orespawn-neoforge-cold-cache', + '--no-build-cache', + '--offline', + "java-version: '21.0.7+6.0.LTS'" + ].each { required -> + if (!workflow.contains(required)) { + throw new GradleException("Cold NeoForge bootstrap is missing ${required}") + } + } + ['MinecraftMavenizer', 'mavenizer', 'java-version: \'25', 'net.minecraftforge.gradle'].each { forbidden -> + if (workflow.contains(forbidden)) { + throw new GradleException("Cold NeoForge bootstrap contains Forge-only tooling: ${forbidden}") + } + } + } +} + +tasks.named('check') { + dependsOn tasks.named('verifyCommandPortability') + dependsOn tasks.named('verifyMavenCoordinates') + dependsOn tasks.named('verifyColdNeoForgeBootstrapContract') +} + idea { module { downloadSources = true @@ -487,6 +1025,8 @@ eclipse { buildCommand 'org.eclipse.buildship.core.gradleprojectbuilder' } classpath { + downloadSources = true + downloadJavadoc = true file { whenMerged { classpath -> def libraryPaths = new LinkedHashSet() @@ -494,12 +1034,22 @@ eclipse { !(entry instanceof org.gradle.plugins.ide.eclipse.model.Library) || libraryPaths.add(entry.path) } + classpath.entries.removeAll { entry -> + entry instanceof org.gradle.plugins.ide.eclipse.model.SourceFolder + && ['src/main/resources', 'src/generated/resources'].contains(entry.path) + } + if (!classpath.entries.any { entry -> entry.path == 'build/resources/main' }) { + classpath.entries.add(new org.gradle.plugins.ide.eclipse.model.SourceFolder( + 'build/resources/main', 'bin/main')) + } } } } + synchronizationTasks 'prepareEclipseResources' } tasks.named('eclipse').configure { + dependsOn prepareEclipseResources doLast { String requestedGradleHome = System.getenv('GRADLE_USER_HOME') if (requestedGradleHome == null || requestedGradleHome.trim().isEmpty()) { @@ -524,3 +1074,51 @@ tasks.named('eclipse').configure { } } } + +tasks.register('verifyEclipseProductionClasspath') { + group = 'verification' + dependsOn tasks.named('eclipse') + dependsOn tasks.named('verifyLegacyOracleIsolation') + doLast { + File prefs = file('.settings/org.eclipse.buildship.core.prefs') + if (!prefs.isFile()) throw new GradleException('Missing Buildship preferences') + Properties preferences = new Properties() + prefs.withInputStream { preferences.load(it) } + if (preferences.getProperty('override.workspace.settings') != 'true') { + throw new GradleException('Eclipse Buildship workspace override is not active') + } + File eclipseClasspath = file('.classpath') + if (!eclipseClasspath.isFile()) throw new GradleException('Eclipse .classpath was not generated') + String classpathText = eclipseClasspath.getText('UTF-8') + if (!classpathText.contains('path="build/resources/main"') + || classpathText.contains('path="src/main/resources"') + || classpathText.contains('path="src/generated/resources"')) { + throw new GradleException('Eclipse must consume only Gradle-processed production resources') + } + ['META-INF/neoforge.mods.toml', 'META-INF/orespawn/docs/README.md', + 'META-INF/orespawn/docs/VERSIONS.md'].each { relative -> + if (!new File('bin/main', relative).isFile()) { + throw new GradleException("Eclipse output is missing ${relative}") + } + } + File metadata = file('bin/main/META-INF/neoforge.mods.toml') + String metadataText = metadata.getText('UTF-8') + if (metadataText.contains('\${mod_version}') + || !metadataText.contains("version=\"${project.version}\"")) { + throw new GradleException('Eclipse output contains unexpanded NeoForge metadata') + } + List forbidden = [ + 'src/test', 'bin/test', 'build/classes/java/test', + 'biomeIntegrationTest', 'clientIntegrationTest', 'benchmarkIntegrationTest', + 'surfaceprobe', 'clientprobe', 'benchmarkprobe', 'junit-', 'opentest4j-', + 'Mineralogy-1.18.2-5.4.0.jar', 'net.minecraftforge', 'META-INF/mods.toml', + 'C:\\Users\\John' + ] + fileTree(project.projectDir) { include 'run*.launch' }.files.each { launch -> + List leaked = forbidden.findAll { launch.getText('UTF-8').contains(it) } + if (!leaked.isEmpty()) { + throw new GradleException("${launch.name} exposes test/Forge/local content: ${leaked}") + } + } + } +} diff --git a/ci-fixtures/README.md b/ci-fixtures/README.md new file mode 100644 index 00000000..9474b707 --- /dev/null +++ b/ci-fixtures/README.md @@ -0,0 +1,13 @@ +# OreSpawn 1.20.6 CI fixtures + +These immutable inputs make the legacy-Mineralogy compatibility gate +self-contained. They are test oracles only and must never enter a Gradle +dependency configuration, Eclipse launch, or published OreSpawn artifact. + +Minecraft 1.20.6 has no published Mineralogy lineage of its own. The last +published legacy engine, `Mineralogy-1.18.2-5.4.0.jar`, was reproduced from the exact historical +MinecraftMineralogy source commit +`6675bac3cb9c1df138ce9b359c0b47d7a797cdfc` using Java 17 and the original +ForgeGradle 6 / Gradle 8.8 build. Its checksum is sealed in `SHA256SUMS` +and validated before the oracle is loaded through the isolated test +classloader as the mandatory legacy-configuration oracle for this target. diff --git a/ci-fixtures/SHA256SUMS b/ci-fixtures/SHA256SUMS new file mode 100644 index 00000000..b7c6864e --- /dev/null +++ b/ci-fixtures/SHA256SUMS @@ -0,0 +1 @@ +CCA84E9270585478B08F54BA091AD56AB2CB390386C650ED78B2673DC57403EB artifacts/Mineralogy-1.18.2-5.4.0.jar diff --git a/ci-fixtures/artifacts/Mineralogy-1.18.2-5.4.0.jar b/ci-fixtures/artifacts/Mineralogy-1.18.2-5.4.0.jar new file mode 100644 index 00000000..3a6dc189 Binary files /dev/null and b/ci-fixtures/artifacts/Mineralogy-1.18.2-5.4.0.jar differ diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 8adc7353..756056ab 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -13,5 +13,5 @@ Use the focused guides for implementation details: - [TEMPLATES.md](TEMPLATES.md) for selectable world styles; - [CONFIGURATION.md](CONFIGURATION.md) for configuration behavior; - [VERSIONS.md](VERSIONS.md) for the shared four-component target-qualified - versioning and branch-release convention; + versioning, skipped functional releases, and branch-release convention; - [README.md](README.md) for schemas, examples, and the complete documentation index. diff --git a/docs/API.md b/docs/API.md index 772eec7c..5154abdf 100644 --- a/docs/API.md +++ b/docs/API.md @@ -12,7 +12,7 @@ runtime. In `neoforge.mods.toml` use a required dependency, for example: [[dependencies.examplemod]] modId="orespawn" type="required" -versionRange="[4.0.0,5.0.0)" +versionRange="[4.0.6,5.0.0)" ordering="AFTER" side="BOTH" ``` @@ -77,6 +77,13 @@ WorldgenProvider provider = WorldgenProvider.builder("examplemod", 1) `OilDefinition` and template `.oil(...)` remain deprecated migration adapters for one legacy oil rule. New integrations should use `FluidDepositDefinition`. +Ore dimension builders expose the same biome filters as provider JSON and +fluid-deposit builders. Use `.biome(...)` and `.biomeDictionary(...)` for +inclusions, with `.excludeBiome(...)` and `.excludeBiomeDictionary(...)` for +exclusions. These methods work on both explicit `.dimension(...)` rules and +`.dimensionSelector(...)` fallbacks; built definitions and their returned +filter sets are immutable. + NeoForge 20.6 biomes are data-driven registry entries. Package biome JSON under `data//worldgen/biome/`, or generate it with a `DatapackBuiltinEntriesProvider`. `OreSpawnBiomes.copyAndRegister` is an @@ -134,10 +141,14 @@ OreSpawnApi.createSampler(server.overworld()).ifPresent(sampler -> { ``` `sampleColumn` performs one biome/dominant-geome classification and reuses its -transition scores for every Y query. `rockAt` therefore matches Stable Layers -when a close geome transition is staggered by layer. Sampling is read-only and -is intended for gameplay decisions, diagnostics, and compatible generation -outside OreSpawn's block loops. +transition scores for every Y query. Pass the first-free surface height returned +by `Level.getHeight`; OreSpawn classifies the stable quart-biome cell at the +highest occupied block immediately below it, matching chunk geology generation +without display-oriented fuzzy biome zoom. `rockAt` therefore matches Stable +Layers when a close geome transition is staggered by layer, even when later +surface work changes the final heightmap slightly. Sampling is read-only and is +intended for gameplay decisions, diagnostics, and compatible generation outside +OreSpawn's block loops. Callbacks inside OreSpawn generation loops are intentionally unsupported. Custom pattern mods create a NeoForge `DeferredRegister` using diff --git a/docs/BIOMES.md b/docs/BIOMES.md index 865e6a0d..c3e23fc8 100644 --- a/docs/BIOMES.md +++ b/docs/BIOMES.md @@ -132,6 +132,13 @@ lets OreSpawn replace the actual exposed ground while preserving later trees, plants, authored structures, and block entities. In ceiling dimensions, `ceiling_block` applies to the roof underside and does not replace the roof top. +Provider-declared `terrain_dimensions.host_blocks` are resolved by one terrain +scan at the start of `LOCAL_MODIFICATIONS`, immediately before provider +surfaces. Matching natural blocks already present in base terrain are eligible +for geology; matching blocks authored by later structure or vegetation stages +are not. Air, fluids, bedrock, and block-entity states remain protected even if +a provider mistakenly lists their block IDs as terrain hosts. + Surface correction is generation-only. Installing or updating OreSpawn does not rewrite already generated chunks; travel into new terrain to see a changed provider surface definition. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index c342e1a9..33235d97 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -117,7 +117,9 @@ is omitted. `dimensions` limits membership, and `geomes` multiplies selection weight by province. A weight of zero prevents selection in that context. Geomes contain a non-negative `base` weight and non-negative weights for each -rock family. Biome and biome-dictionary maps multiply those geome weights. +rock family. Keys may retain the legacy unnamespaced form or use a provider +resource ID such as `examplemod:crystal_basin`; the creation editor preserves +both forms. Biome and biome-dictionary maps multiply those geome weights. Missing optional-mod biome IDs are ignored during baking. Exact biome-ID maps remain effective when the target uses a dynamic biome registry. With Stable Layers, a close contest between two geomes transitions diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md index 85c95fad..61602dec 100644 --- a/docs/DEVELOPER_GUIDE.md +++ b/docs/DEVELOPER_GUIDE.md @@ -100,6 +100,10 @@ private void enqueueWorldgen(InterModEnqueueEvent event) { .quantityRange(4, 11) .pattern(OrePattern.VEIN) .heightDistribution(OreHeightDistribution.TRIANGLE) + .biome(new ResourceLocation("minecraft", "plains")) + .biomeDictionary("FOREST") + .excludeBiome(new ResourceLocation("minecraft", "dark_forest")) + .excludeBiomeDictionary("SPOOKY") .hostTag(new ResourceLocation("minecraft", "stone_ore_replaceables")))) .build(); diff --git a/docs/README.md b/docs/README.md index 085eeb61..e1462cb8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,6 +17,7 @@ Choose the guide that matches what you are doing: - [Dimensions](DIMENSIONS.md) - [Migration](MIGRATION.md) - [Troubleshooting](TROUBLESHOOTING.md) +- [Versioning and release conventions](VERSIONS.md) - [Compact instructions for coding agents](AGENTS.md) Validated examples are in `examples/`; JSON Schemas are in `schemas/`. diff --git a/docs/VERSIONS.md b/docs/VERSIONS.md index 59bb67a3..ee535dfd 100644 --- a/docs/VERSIONS.md +++ b/docs/VERSIONS.md @@ -49,11 +49,18 @@ version. Examples: -| Minecraft | Loader | Target | Full OreSpawn 4.0.6 version | +| Minecraft | Loader | Target | Example full OreSpawn version | | --- | --- | ---: | --- | | 1.13.2 | Forge | `113021` | `4.0.6.113021` | +| 1.14.4 | Forge | `114041` | `4.0.8.114041` | +| 1.15.2 | Forge | `115021` | `4.0.9.115021` | +| 1.16.5 | Forge | `116051` | `4.0.9.116051` | +| 1.17.1 | Forge | `117011` | `4.0.9.117011` | +| 1.18.2 | Forge | `118021` | `4.0.16.118021` | +| 1.19.4 | Forge | `119041` | `4.0.16.119041` | +| 1.20.1 | Forge | `120011` | `4.0.16.120011` | | 1.20.6 | Forge | `120061` | `4.0.6.120061` | -| 1.20.6 | NeoForge | `120062` | `4.0.6.120062` | +| 1.20.6 | NeoForge | `120062` | `4.0.16.120062` | | 1.21.1 | Forge | `121011` | `4.0.6.121011` | | 1.21.1 | NeoForge | `121012` | `4.0.6.121012` | | 1.21.11 | Forge | `121111` | `4.0.6.121111` | @@ -150,12 +157,12 @@ same `Major.Minor.Bug` may be shared by functionally equivalent ports. If a released branch receives a bug fix that other branches do not require, only the affected branch's Bug number is incremented. For example, Forge -1.13.2 may move from `4.0.6.113021` to `4.0.7.113021` while unaffected branches -remain on their target-qualified 4.0.6 versions. +1.12.2 moved to `4.0.7.112021` for its packaged access-transformer repair while +unaffected branches remained on their target-qualified 4.0.6 versions. -If a different branch later receives a separate fix, it uses the next unused -Bug number, such as `4.0.8`, even if the `4.0.7` fix was not applicable to it. -A branch may therefore legitimately skip functional version numbers. +If a different branch later receives a shared fix, it uses the next unused +Bug number, such as Forge 1.14.4's `4.0.8.114041`, even though the 4.0.7 repair +was not applicable there. A branch may therefore skip functional versions. This provides three useful guarantees: diff --git a/gradle.properties b/gradle.properties index fba1eabd..059bafa9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -20,6 +20,13 @@ neo_version=20.6.139 neo_version_range=[20.6.139,20.7) # NeoForge 20.6 uses FML loader major 2. loader_version_range=[2,) +loader_name=neoforge +loader_code=2 +java_version=21 +java_toolchain_version=21.0.7+6 +java_setup_version=21.0.7+6.0.LTS +gradle_java_version=21 +curseforge_project_id=245586 ## Mod Properties @@ -32,7 +39,7 @@ mod_name=MMD OreSpawn # The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default. mod_license=LGPL-2.1 # The mod version. See https://semver.org/ -mod_version=4.0.6.120062 +mod_version=4.0.16.120062 # The group ID for the mod. It is only important when publishing as an artifact to a Maven repository. # This should match the base package used for the mod sources. # See https://maven.apache.org/guides/mini/guide-naming-conventions.html diff --git a/settings.gradle b/settings.gradle index 3a3e5b92..00440736 100644 --- a/settings.gradle +++ b/settings.gradle @@ -8,3 +8,5 @@ pluginManagement { plugins { id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0' } + +rootProject.name = 'OreSpawn' diff --git a/src/benchmarkIntegrationTest/java/zone/moddev/mc/orespawn/benchmarkprobe/BenchmarkHarnessTestMod.java b/src/benchmarkIntegrationTest/java/zone/moddev/mc/orespawn/benchmarkprobe/BenchmarkHarnessTestMod.java new file mode 100644 index 00000000..91f3b601 --- /dev/null +++ b/src/benchmarkIntegrationTest/java/zone/moddev/mc/orespawn/benchmarkprobe/BenchmarkHarnessTestMod.java @@ -0,0 +1,20 @@ +package zone.moddev.mc.orespawn.benchmarkprobe; + +import net.minecraft.gametest.framework.GameTest; +import net.minecraft.gametest.framework.GameTestHelper; +import net.neoforged.fml.common.Mod; +import net.neoforged.neoforge.gametest.GameTestHolder; +import net.neoforged.neoforge.gametest.PrefixGameTestTemplate; + +/** Build-only test that gives NeoForge 20.6's benchmark GameTest server a harness-owned result. */ +@Mod(BenchmarkHarnessTestMod.MODID) +@GameTestHolder(BenchmarkHarnessTestMod.MODID) +public final class BenchmarkHarnessTestMod { + static final String MODID = "benchmarkprobe"; + + @PrefixGameTestTemplate(false) + @GameTest(templateNamespace = "minecraft", template = "igloo/top") + public static void benchmarkLifecycle(GameTestHelper helper) { + helper.succeed(); + } +} diff --git a/src/benchmarkIntegrationTest/resources/META-INF/neoforge.mods.toml b/src/benchmarkIntegrationTest/resources/META-INF/neoforge.mods.toml new file mode 100644 index 00000000..65004039 --- /dev/null +++ b/src/benchmarkIntegrationTest/resources/META-INF/neoforge.mods.toml @@ -0,0 +1,30 @@ +modLoader="javafml" +loaderVersion="[2,)" +license="LGPL-2.1" + +[[mods]] +modId="benchmarkprobe" +version="1" +displayName="OreSpawn Benchmark Harness Test" +description='''Build-only GameTest used to qualify OreSpawn benchmark shutdown ownership.''' + +[[dependencies.benchmarkprobe]] +modId="neoforge" +type="required" +versionRange="[20.6.139,20.7)" +ordering="NONE" +side="SERVER" + +[[dependencies.benchmarkprobe]] +modId="orespawn" +type="required" +versionRange="[4.0.6,5.0.0)" +ordering="AFTER" +side="SERVER" + +[[dependencies.benchmarkprobe]] +modId="minecraft" +type="required" +versionRange="[1.20.6,1.21)" +ordering="NONE" +side="SERVER" diff --git a/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java b/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java index 811d7558..541fab74 100644 --- a/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java +++ b/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java @@ -18,7 +18,10 @@ import zone.moddev.mc.orespawn.api.BiomeRegionSize; import zone.moddev.mc.orespawn.api.BiomeReplacementScope; import zone.moddev.mc.orespawn.api.GeologyFamily; +import zone.moddev.mc.orespawn.api.GeologySampler; import zone.moddev.mc.orespawn.api.OreSpawnApi; +import zone.moddev.mc.orespawn.api.OreHeightDistribution; +import zone.moddev.mc.orespawn.api.OrePattern; import zone.moddev.mc.orespawn.api.ProviderStatus; import zone.moddev.mc.orespawn.api.WorldgenProvider; import zone.moddev.mc.orespawn.api.WorldgenProvider.BiomeSurfaceDefinition; @@ -75,7 +78,19 @@ public final class SurfaceProbeTestMod { private static final ResourceLocation BIOME_A = id(MODID + ":surface_a"); private static final ResourceLocation BIOME_B = id(MODID + ":surface_b"); private static final ResourceLocation PROBE_GEOME = id(MODID + ":dynamic_biome_geome"); + private static final ResourceLocation PROBE_GEOME_ALTERNATIVE = + id(MODID + ":dynamic_biome_geome_alternative"); private static final ResourceLocation DYNAMIC_FLUID = id(MODID + ":fluid/dynamic_water"); + private static final ResourceLocation DYNAMIC_ORE = id(MODID + ":ore/dynamic_biome_filter"); + private static final Block[] NATURAL_SOURCES = { + Blocks.DIRT, Blocks.GRASS_BLOCK, Blocks.COARSE_DIRT, Blocks.PODZOL, + Blocks.ROOTED_DIRT, Blocks.GRAVEL, Blocks.SAND, Blocks.RED_SAND, + Blocks.CLAY, Blocks.TERRACOTTA, Blocks.WHITE_TERRACOTTA, + Blocks.ORANGE_TERRACOTTA, Blocks.RED_TERRACOTTA + }; + private static final Block[] INVALID_TERRAIN_HOSTS = { + Blocks.AIR, Blocks.WATER, Blocks.BEDROCK, Blocks.CHEST + }; private static final ResourceLocation[] BUILT_IN_GEOMES = { id("orespawn:stable_craton"), id("orespawn:mountain_belt"), id("orespawn:volcanic_arc"), id("orespawn:sedimentary_basin"), @@ -86,9 +101,13 @@ public final class SurfaceProbeTestMod { private static final int MAXIMUM_CHUNK = 65; private static final int EXPECTED_COLUMNS = 9 * 16 * 16; private static final int EXPECTED_FILLER = EXPECTED_COLUMNS * 3; + private static final int EXPECTED_NATURAL_SOURCES = 9 * NATURAL_SOURCES.length; private static final String PHASE_PROPERTY = "surfaceprobe.integrationPhase"; private static final String MARKER_NAME = "surfaceprobe-integration.properties"; private static final String CHEST_ITEM_NAME = "surfaceprobe sentinel"; + private static final String RAW_CHEST_ITEM_NAME = "surfaceprobe raw block entity sentinel"; + private static final BlockState WEATHER_SNOW_REPLACEMENT = Blocks.WHITE_WOOL.defaultBlockState(); + private static final BlockState WEATHER_ICE_REPLACEMENT = Blocks.BLUE_ICE.defaultBlockState(); static { FEATURES.register("terrain_setup", () -> new ProbeFeature(ProbeStage.TERRAIN)); @@ -106,6 +125,22 @@ public SurfaceProbeTestMod(IEventBus modBus) { private void enqueueProvider(InterModEnqueueEvent event) { WorldgenProvider.Builder provider = WorldgenProvider.builder(MODID, 1); addDynamicBiomeGeology(provider); + provider.ore(DYNAMIC_ORE, blockId(Blocks.DIAMOND_BLOCK), ore -> ore + .retrogen(false) + .dimension(OPEN_ID, placement -> placement + .yRange(16, 48) + .attempts(16.0D) + .quantity(8) + .pattern(OrePattern.CLUSTER) + .heightDistribution(OreHeightDistribution.UNIFORM) + .discardChanceOnAirExposure(0.0D) + .spread(4, 3) + .nodeSize(3) + .hostBlock(blockId(Blocks.CALCITE)) + .biome(BIOME_A) + .biomeDictionary("COLD") + .excludeBiome(BIOME_B) + .excludeBiomeDictionary("SPOOKY"))); provider.fluidDeposit(DYNAMIC_FLUID, blockId(Blocks.WATER), deposit -> deposit .dimension(OPEN_ID, placement -> placement .yRange(16, 24) @@ -115,9 +150,13 @@ private void enqueueProvider(InterModEnqueueEvent event) { .maxLobes(1) .minSolidCover(1) .minSolidShell(1) - .hostBlock(blockId(Blocks.CALCITE)))); + .hostBlock(blockId(Blocks.CALCITE)) + .hostBlock(blockId(Blocks.BASALT)))); addPalette(provider, "open_palette", OPEN_ID, false); addPalette(provider, "roofed_palette", ROOFED_ID, true); + provider.dimensionMaterials(id(MODID + ":materials/end"), OPEN_ID, + materials -> materials.snowBlock(blockId(Blocks.WHITE_WOOL)) + .iceBlock(blockId(Blocks.BLUE_ICE))); provider.dimensionMaterials(id(MODID + ":materials/nether"), ROOFED_ID, materials -> materials.defaultFluid(blockId(Blocks.WATER))); if (!OreSpawnApi.enqueue(provider.build())) { @@ -128,21 +167,39 @@ private void enqueueProvider(InterModEnqueueEvent event) { private static void addDynamicBiomeGeology(WorldgenProvider.Builder provider) { provider.geome(PROBE_GEOME, geome -> geome .baseWeight(0.0D) - .familyWeight(GeologyFamily.SEDIMENTARY, 1.0D)); + .familyWeight(GeologyFamily.SEDIMENTARY, 1.0D) + .familyWeight(GeologyFamily.IGNEOUS_INTRUSIVE, 1.0D)); + provider.geome(PROBE_GEOME_ALTERNATIVE, geome -> geome + .baseWeight(0.0D) + .familyWeight(GeologyFamily.SEDIMENTARY, 1.0D) + .familyWeight(GeologyFamily.IGNEOUS_INTRUSIVE, 1.0D)); provider.rock(id(MODID + ":rock/dynamic_biome"), blockId(Blocks.CALCITE), GeologyFamily.SEDIMENTARY, rock -> { rock.dimensions(java.util.Collections.singleton(OPEN_ID)); rock.geomeWeight(PROBE_GEOME, 1.0D); + rock.geomeWeight(PROBE_GEOME_ALTERNATIVE, 1.0D); for (ResourceLocation geome : BUILT_IN_GEOMES) rock.geomeWeight(geome, 0.0D); }); - provider.rock(id(MODID + ":rock/fallback"), blockId(Blocks.BASALT), + provider.rock(id(MODID + ":rock/dynamic_biome_alternative"), blockId(Blocks.BASALT), + GeologyFamily.IGNEOUS_INTRUSIVE, rock -> { + rock.dimensions(java.util.Collections.singleton(OPEN_ID)); + rock.yRange(16, 48); + rock.geomeWeight(PROBE_GEOME, 0.0D); + rock.geomeWeight(PROBE_GEOME_ALTERNATIVE, 1.0D); + for (ResourceLocation geome : BUILT_IN_GEOMES) rock.geomeWeight(geome, 0.0D); + }); + provider.rock(id(MODID + ":rock/fallback"), blockId(Blocks.DEEPSLATE), GeologyFamily.SEDIMENTARY, rock -> { rock.dimensions(java.util.Collections.singleton(OPEN_ID)); rock.geomeWeight(PROBE_GEOME, 0.0D); + rock.geomeWeight(PROBE_GEOME_ALTERNATIVE, 0.0D); for (ResourceLocation geome : BUILT_IN_GEOMES) rock.geomeWeight(geome, 1.0D); }); - provider.biome(BIOME_A, java.util.Collections.singletonMap(PROBE_GEOME, 100.0D)); - provider.biome(BIOME_B, java.util.Collections.singletonMap(PROBE_GEOME, 100.0D)); + Map biomeAWeights = new LinkedHashMap<>(); + biomeAWeights.put(PROBE_GEOME, 6.0D); + biomeAWeights.put(PROBE_GEOME_ALTERNATIVE, 14.0D); + provider.biome(BIOME_A, biomeAWeights); + provider.biome(BIOME_B, java.util.Collections.singletonMap(PROBE_GEOME_ALTERNATIVE, 100.0D)); } private void enableGeologyProbe(ServerAboutToStartEvent event) { @@ -156,6 +213,18 @@ private void enableGeologyProbe(ServerAboutToStartEvent event) { } try { root.addProperty("place_fluid_deposits", true); + root.addProperty("place_ores", true); + JsonObject dictionary = root.getAsJsonObject("biome_dictionary"); + if (dictionary == null) { + dictionary = new JsonObject(); + root.add("biome_dictionary", dictionary); + } + JsonObject cold = dictionary.getAsJsonObject("COLD"); + if (cold == null) { + cold = new JsonObject(); + dictionary.add("COLD", cold); + } + cold.addProperty(PROBE_GEOME.toString(), 8.0D); JsonObject terrain = root.getAsJsonObject("terrain_dimensions"); if (terrain == null) { terrain = new JsonObject(); @@ -169,6 +238,8 @@ private void enableGeologyProbe(ServerAboutToStartEvent event) { end.add("biome_namespaces", namespaces); JsonArray hosts = new JsonArray(); hosts.add(blockId(Blocks.END_STONE).toString()); + for (Block source : NATURAL_SOURCES) hosts.add(blockId(source).toString()); + for (Block source : INVALID_TERRAIN_HOSTS) hosts.add(blockId(source).toString()); end.add("host_blocks", hosts); end.add("host_tags", new JsonArray()); terrain.add(OPEN_ID.toString(), end); @@ -288,6 +359,21 @@ private static AuditResult auditDimension(ServerLevel level, boolean roofed) { int biomeB = 0; int edgeChanges = 0; int sentinels = 0; + long rawNaturalSources = 0L; + long structureNaturalSources = 0L; + long vegetationNaturalSources = 0L; + long cavePockets = 0L; + long underwaterPockets = 0L; + long rawBedrock = 0L; + long rawBlockEntities = 0L; + long dictionaryPrimary = 0L; + long dictionaryAlternative = 0L; + long exposedSnowConverted = 0L; + long surfaceIceConverted = 0L; + long buriedSnowPreserved = 0L; + long buriedIcePreserved = 0L; + long unconfiguredSnowPreserved = 0L; + long unconfiguredIcePreserved = 0L; BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(); for (int chunkZ = MINIMUM_CHUNK; chunkZ <= MAXIMUM_CHUNK; chunkZ++) { @@ -297,6 +383,10 @@ private static AuditResult auditDimension(ServerLevel level, boolean roofed) { LevelChunk chunk = level.getChunk(chunkX, chunkZ); int chunkMinX = chunkX << 4; int chunkMinZ = chunkZ << 4; + int centerGroundY = findMarkedGround(chunk, pos, chunkMinX + 8, chunkMinZ + 8, + level.getMinBuildHeight(), level.getMaxBuildHeight()); + ResourceLocation generationBiomeId = biomeId(level.getBiome( + pos.set(chunkMinX + 8, centerGroundY, chunkMinZ + 8))); for (int localZ = 0; localZ < 16; localZ++) { for (int localX = 0; localX < 16; localX++) { int x = chunkMinX + localX; @@ -327,8 +417,15 @@ private static AuditResult auditDimension(ServerLevel level, boolean roofed) { } if (!roofed) { for (int depth = 6; depth <= 8; depth++) { - assertBlock(chunk, pos, x, groundY - depth, z, - Blocks.CALCITE.defaultBlockState(), "dynamic-biome geome rock"); + BlockState geologyState = chunk.getBlockState(pos.set(x, groundY - depth, z)); + if (geologyState.is(Blocks.BASALT)) { + dictionaryAlternative++; + } else if (geologyState.is(Blocks.CALCITE)) { + dictionaryPrimary++; + } else { + throw new IllegalStateException("Unexpected dynamic-biome geome rock at " + + pos + " in " + biomeId + ": " + geologyState); + } geology++; } } @@ -345,28 +442,250 @@ private static AuditResult auditDimension(ServerLevel level, boolean roofed) { } } } - ResourceLocation centerBiome = biomeId(level.getBiome(pos.set(chunkMinX + 8, - findMarkedGround(chunk, pos, chunkMinX + 8, chunkMinZ + 8, - level.getMinBuildHeight(), level.getMaxBuildHeight()), chunkMinZ + 8))); - if (previousChunkBiome != null && !previousChunkBiome.equals(centerBiome)) edgeChanges++; - previousChunkBiome = centerBiome; + if (previousChunkBiome != null && !previousChunkBiome.equals(generationBiomeId)) edgeChanges++; + previousChunkBiome = generationBiomeId; sentinels += auditSentinels(level, chunk, pos, chunkMinX, chunkMinZ); + if (!roofed) { + NaturalSourceAudit natural = auditNaturalSources(level, chunk, pos, + chunkMinX, chunkMinZ); + rawNaturalSources += natural.rawConverted(); + structureNaturalSources += natural.structurePreserved(); + vegetationNaturalSources += natural.vegetationPreserved(); + cavePockets += natural.cavePreserved(); + underwaterPockets += natural.underwaterPreserved(); + rawBedrock += natural.bedrockPreserved(); + rawBlockEntities += natural.blockEntityPreserved(); + } + WeatherMaterialAudit weather = auditWeatherMaterials(chunk, pos, + chunkMinX, chunkMinZ, level.getMinBuildHeight(), level.getMaxBuildHeight(), roofed); + exposedSnowConverted += weather.exposedSnowConverted(); + surfaceIceConverted += weather.surfaceIceConverted(); + buriedSnowPreserved += weather.buriedSnowPreserved(); + buriedIcePreserved += weather.buriedIcePreserved(); + unconfiguredSnowPreserved += weather.unconfiguredSnowPreserved(); + unconfiguredIcePreserved += weather.unconfiguredIcePreserved(); } } + AttributionAudit attribution = roofed ? AttributionAudit.EMPTY : auditStableBiomeAttribution(level); + if (!roofed) { + LOGGER.info("Surface probe stable attribution: sedimentary={}, intrusive={}, biomeA={}, biomeB={}, mismatches={}", + attribution.sedimentaryHosts(), attribution.intrusiveHosts(), + attribution.biomeAHosts(), attribution.biomeBHosts(), attribution.mismatches()); + } + long dynamicBiomeOre = roofed ? 0L : auditDynamicBiomeOre(level); if (top != EXPECTED_COLUMNS - 9 || underwater != 9 || filler != EXPECTED_FILLER || biomeA == 0 || biomeB == 0 || edgeChanges == 0 || sentinels != 9 * 4 || geology != (roofed ? 0 : EXPECTED_FILLER) - || (roofed && (ceiling != EXPECTED_COLUMNS || roofTop != EXPECTED_COLUMNS))) { + || (roofed && (ceiling != EXPECTED_COLUMNS || roofTop != EXPECTED_COLUMNS + || unconfiguredSnowPreserved != 9 || unconfiguredIcePreserved != 9 + || exposedSnowConverted != 0 || surfaceIceConverted != 0 + || buriedSnowPreserved != 0 || buriedIcePreserved != 0)) + || (!roofed && (rawNaturalSources != EXPECTED_NATURAL_SOURCES + || structureNaturalSources != EXPECTED_NATURAL_SOURCES + || vegetationNaturalSources != EXPECTED_NATURAL_SOURCES + || cavePockets != 54 || underwaterPockets != 63 + || rawBedrock != 9 || rawBlockEntities != 9 + || dictionaryPrimary != EXPECTED_FILLER || dictionaryAlternative != 0 + || exposedSnowConverted != 9 || surfaceIceConverted != 9 + || buriedSnowPreserved != 9 || buriedIcePreserved != 9 + || unconfiguredSnowPreserved != 0 || unconfiguredIcePreserved != 0 + || attribution.sedimentaryHosts() == 0 || attribution.intrusiveHosts() == 0 + || attribution.biomeAHosts() == 0 || attribution.biomeBHosts() == 0 + || attribution.mismatches() != 0 + || dynamicBiomeOre == 0))) { throw new IllegalStateException("Incomplete surface audit for " + level.dimension().location() + ": top=" + top + ", underwater=" + underwater + ", filler=" + filler + ", biomeA=" + biomeA + ", biomeB=" + biomeB + ", edges=" + edgeChanges + ", sentinels=" + sentinels + ", geology=" + geology - + ", ceiling=" + ceiling + ", roofTop=" + roofTop); + + ", ceiling=" + ceiling + ", roofTop=" + roofTop + + ", rawNatural=" + rawNaturalSources + + ", structureNatural=" + structureNaturalSources + + ", vegetationNatural=" + vegetationNaturalSources + + ", cavePockets=" + cavePockets + + ", underwaterPockets=" + underwaterPockets + + ", rawBedrock=" + rawBedrock + + ", rawBlockEntities=" + rawBlockEntities + + ", dictionaryPrimary=" + dictionaryPrimary + + ", dictionaryAlternative=" + dictionaryAlternative + + ", exposedSnowConverted=" + exposedSnowConverted + + ", surfaceIceConverted=" + surfaceIceConverted + + ", buriedSnowPreserved=" + buriedSnowPreserved + + ", buriedIcePreserved=" + buriedIcePreserved + + ", unconfiguredSnowPreserved=" + unconfiguredSnowPreserved + + ", unconfiguredIcePreserved=" + unconfiguredIcePreserved + + ", attributionSedimentary=" + attribution.sedimentaryHosts() + + ", attributionIntrusive=" + attribution.intrusiveHosts() + + ", attributionBiomeA=" + attribution.biomeAHosts() + + ", attributionBiomeB=" + attribution.biomeBHosts() + + ", attributionMismatches=" + attribution.mismatches() + + ", dynamicBiomeOre=" + dynamicBiomeOre); } long aquiferFluid = roofed ? 0L : auditDynamicFluid(level); return new AuditResult(top, underwater, filler, geology, ceiling, roofTop, - biomeA, biomeB, edgeChanges, sentinels, aquiferFluid); + biomeA, biomeB, edgeChanges, sentinels, aquiferFluid, + rawNaturalSources, structureNaturalSources, vegetationNaturalSources, + cavePockets, underwaterPockets, rawBedrock, rawBlockEntities, + dictionaryPrimary, dictionaryAlternative, dynamicBiomeOre, + exposedSnowConverted, surfaceIceConverted, + buriedSnowPreserved, buriedIcePreserved, + unconfiguredSnowPreserved, unconfiguredIcePreserved, + attribution.sedimentaryHosts(), attribution.intrusiveHosts(), + attribution.biomeAHosts(), attribution.biomeBHosts(), attribution.mismatches()); + } + + private static AttributionAudit auditStableBiomeAttribution(ServerLevel level) { + GeologySampler sampler = OreSpawnApi.createSampler(level) + .orElseThrow(() -> new IllegalStateException("Surface probe geology sampler unavailable")); + BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(); + long sedimentary = 0L; + long intrusive = 0L; + long biomeA = 0L; + long biomeB = 0L; + long mismatches = 0L; + for (int chunkZ = MINIMUM_CHUNK; chunkZ <= MAXIMUM_CHUNK; chunkZ++) { + for (int chunkX = MINIMUM_CHUNK; chunkX <= MAXIMUM_CHUNK; chunkX++) { + LevelChunk chunk = level.getChunk(chunkX, chunkZ); + for (int x = chunk.getPos().getMinBlockX(); x <= chunk.getPos().getMaxBlockX(); x++) { + for (int z = chunk.getPos().getMinBlockZ(); z <= chunk.getPos().getMaxBlockZ(); z++) { + var column = sampler.sampleColumn(x, z, + level.getHeight(Heightmap.Types.WORLD_SURFACE, x, z)); + for (int y = 16; y <= 48; y++) { + BlockState state = chunk.getBlockState(pos.set(x, y, z)); + GeologyFamily expected; + if (state.is(Blocks.CALCITE)) { + expected = GeologyFamily.SEDIMENTARY; + sedimentary++; + } else if (state.is(Blocks.BASALT)) { + expected = GeologyFamily.IGNEOUS_INTRUSIVE; + intrusive++; + } else { + continue; + } + if (BIOME_A.equals(column.biome())) biomeA++; + if (BIOME_B.equals(column.biome())) biomeB++; + if (!column.familyAt(y).filter(expected::equals).isPresent()) mismatches++; + } + } + } + } + } + return new AttributionAudit(sedimentary, intrusive, biomeA, biomeB, mismatches); + } + + private static WeatherMaterialAudit auditWeatherMaterials(ChunkAccess chunk, + BlockPos.MutableBlockPos pos, int minX, int minZ, int minY, int maxY, + boolean roofed) { + int snowGroundY = findMarkedGround(chunk, pos, minX + 2, minZ + 2, minY, maxY); + int iceGroundY = findMarkedGround(chunk, pos, minX + 3, minZ + 2, minY, maxY); + if (roofed) { + return new WeatherMaterialAudit(0L, 0L, 0L, 0L, + assertState(chunk, pos.set(minX + 2, snowGroundY + 11, minZ + 2), + Blocks.SNOW.defaultBlockState(), "unconfigured exposed Snow preservation"), + assertState(chunk, pos.set(minX + 3, iceGroundY + 11, minZ + 2), + Blocks.ICE.defaultBlockState(), "unconfigured surface Ice preservation")); + } + int buriedSnowGroundY = findMarkedGround(chunk, pos, minX + 2, minZ + 3, minY, maxY); + int buriedIceGroundY = findMarkedGround(chunk, pos, minX + 3, minZ + 3, minY, maxY); + return new WeatherMaterialAudit( + assertState(chunk, pos.set(minX + 2, snowGroundY + 1, minZ + 2), + WEATHER_SNOW_REPLACEMENT, "exposed Snow weather replacement"), + assertState(chunk, pos.set(minX + 3, iceGroundY + 1, minZ + 2), + WEATHER_ICE_REPLACEMENT, "surface Ice weather replacement"), + assertState(chunk, pos.set(minX + 2, buriedSnowGroundY - 24, minZ + 3), + Blocks.SNOW.defaultBlockState(), "buried authored Snow preservation"), + assertState(chunk, pos.set(minX + 3, buriedIceGroundY - 24, minZ + 3), + Blocks.ICE.defaultBlockState(), "buried authored Ice preservation"), + 0L, 0L); + } + + private static long assertState(ChunkAccess chunk, BlockPos pos, + BlockState expected, String label) { + BlockState actual = chunk.getBlockState(pos); + if (!actual.equals(expected)) { + throw new IllegalStateException(label + " changed at " + pos + + ": expected " + expected + " but found " + actual); + } + return 1L; + } + + private static long auditDynamicBiomeOre(ServerLevel level) { + GeologySampler sampler = OreSpawnApi.createSampler(level) + .orElseThrow(() -> new IllegalStateException("Dynamic ore geology sampler unavailable")); + BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(); + long count = 0L; + for (int chunkZ = MINIMUM_CHUNK; chunkZ <= MAXIMUM_CHUNK; chunkZ++) { + for (int chunkX = MINIMUM_CHUNK; chunkX <= MAXIMUM_CHUNK; chunkX++) { + LevelChunk chunk = level.getChunk(chunkX, chunkZ); + for (int x = chunk.getPos().getMinBlockX(); x <= chunk.getPos().getMaxBlockX(); x++) { + for (int z = chunk.getPos().getMinBlockZ(); z <= chunk.getPos().getMaxBlockZ(); z++) { + for (int y = 16; y <= 48; y++) { + if (chunk.getBlockState(pos.set(x, y, z)).is(Blocks.DIAMOND_BLOCK)) { + var column = sampler.sampleColumn(x, z, + level.getHeight(Heightmap.Types.WORLD_SURFACE, x, z)); + if (!column.familyAt(y).filter(GeologyFamily.SEDIMENTARY::equals).isPresent()) { + throw new IllegalStateException("Managed ore escaped its sedimentary biome host at " + pos + + ": biome=" + column.biome() + ", family=" + column.familyAt(y)); + } + count++; + } + } + } + } + } + } + if (count == 0L) { + throw new IllegalStateException("Dynamic-registry biome filter produced no managed ore"); + } + return count; + } + + private static NaturalSourceAudit auditNaturalSources(ServerLevel level, LevelChunk chunk, + BlockPos.MutableBlockPos pos, int minX, int minZ) { + long rawConverted = 0L; + long structurePreserved = 0L; + long vegetationPreserved = 0L; + long cavePreserved = 0L; + long underwaterPreserved = 0L; + long bedrockPreserved = 0L; + long blockEntityPreserved = 0L; + for (int index = 0; index < NATURAL_SOURCES.length; index++) { + int x = naturalX(minX, index); + int z = naturalZ(minZ, index); + int groundY = findMarkedGround(chunk, pos, x, z, + level.getMinBuildHeight(), level.getMaxBuildHeight()); + BlockState converted = chunk.getBlockState(pos.set(x, groundY - 12, z)); + if (converted.is(Blocks.CALCITE) || converted.is(Blocks.BASALT)) rawConverted++; + Block pocket = chunk.getBlockState(pos.set(x, groundY - 11, z)).getBlock(); + if (index < NATURAL_SOURCES.length / 2) { + if (pocket == Blocks.AIR) cavePreserved++; + } else if (pocket == Blocks.WATER) { + underwaterPreserved++; + } + if (chunk.getBlockState(pos.set(x, groundY - 16, z)).is(NATURAL_SOURCES[index])) { + structurePreserved++; + } + if (chunk.getBlockState(pos.set(x, groundY - 20, z)).is(NATURAL_SOURCES[index])) { + vegetationPreserved++; + } + } + int bedrockGroundY = findMarkedGround(chunk, pos, minX + 11, minZ + 12, + level.getMinBuildHeight(), level.getMaxBuildHeight()); + if (chunk.getBlockState(pos.set(minX + 11, bedrockGroundY - 24, minZ + 12)).is(Blocks.BEDROCK)) { + bedrockPreserved++; + } + int chestGroundY = findMarkedGround(chunk, pos, minX + 12, minZ + 12, + level.getMinBuildHeight(), level.getMaxBuildHeight()); + pos.set(minX + 12, chestGroundY - 24, minZ + 12); + if (chunk.getBlockState(pos).is(Blocks.CHEST) + && level.getBlockEntity(pos) instanceof ChestBlockEntity chest + && chest.getItem(0).is(Items.EMERALD) + && RAW_CHEST_ITEM_NAME.equals(chest.getItem(0).getHoverName().getString())) { + blockEntityPreserved++; + } + return new NaturalSourceAudit(rawConverted, structurePreserved, + vegetationPreserved, cavePreserved, underwaterPreserved, + bedrockPreserved, blockEntityPreserved); } private static long auditDynamicFluid(ServerLevel level) { @@ -503,6 +822,27 @@ private static Properties properties(long seed, Map results values.setProperty(prefix + "edge_changes", Integer.toString(result.edgeChanges())); values.setProperty(prefix + "sentinels", Integer.toString(result.sentinels())); values.setProperty(prefix + "aquifer_fluid", Long.toString(result.aquiferFluid())); + values.setProperty(prefix + "raw_natural_sources", Long.toString(result.rawNaturalSources())); + values.setProperty(prefix + "structure_natural_sources", Long.toString(result.structureNaturalSources())); + values.setProperty(prefix + "vegetation_natural_sources", Long.toString(result.vegetationNaturalSources())); + values.setProperty(prefix + "cave_pockets", Long.toString(result.cavePockets())); + values.setProperty(prefix + "underwater_pockets", Long.toString(result.underwaterPockets())); + values.setProperty(prefix + "raw_bedrock", Long.toString(result.rawBedrock())); + values.setProperty(prefix + "raw_block_entities", Long.toString(result.rawBlockEntities())); + values.setProperty(prefix + "dictionary_primary", Long.toString(result.dictionaryPrimary())); + values.setProperty(prefix + "dictionary_alternative", Long.toString(result.dictionaryAlternative())); + values.setProperty(prefix + "dynamic_biome_ore", Long.toString(result.dynamicBiomeOre())); + values.setProperty(prefix + "exposed_snow_converted", Long.toString(result.exposedSnowConverted())); + values.setProperty(prefix + "surface_ice_converted", Long.toString(result.surfaceIceConverted())); + values.setProperty(prefix + "buried_snow_preserved", Long.toString(result.buriedSnowPreserved())); + values.setProperty(prefix + "buried_ice_preserved", Long.toString(result.buriedIcePreserved())); + values.setProperty(prefix + "unconfigured_snow_preserved", Long.toString(result.unconfiguredSnowPreserved())); + values.setProperty(prefix + "unconfigured_ice_preserved", Long.toString(result.unconfiguredIcePreserved())); + values.setProperty(prefix + "attribution_sedimentary", Long.toString(result.attributionSedimentary())); + values.setProperty(prefix + "attribution_intrusive", Long.toString(result.attributionIntrusive())); + values.setProperty(prefix + "attribution_biome_a", Long.toString(result.attributionBiomeA())); + values.setProperty(prefix + "attribution_biome_b", Long.toString(result.attributionBiomeB())); + values.setProperty(prefix + "attribution_mismatches", Long.toString(result.attributionMismatches())); } return values; } @@ -597,9 +937,40 @@ private static boolean prepareTerrain(WorldGenLevel world, ChunkAccess chunk) { } } } + if (!roofed) placeRawNaturalSources(world, chunk, pos, minX, minZ); return true; } + private static void placeRawNaturalSources(WorldGenLevel world, ChunkAccess chunk, + BlockPos.MutableBlockPos pos, int minX, int minZ) { + for (int index = 0; index < NATURAL_SOURCES.length; index++) { + int x = naturalX(minX, index); + int z = naturalZ(minZ, index); + int groundY = findMarkedGround(chunk, pos, x, z, + world.getMinBuildHeight(), world.getMaxBuildHeight()); + chunk.setBlockState(pos.set(x, groundY - 12, z), + NATURAL_SOURCES[index].defaultBlockState(), false); + chunk.setBlockState(pos.set(x, groundY - 11, z), + (index < NATURAL_SOURCES.length / 2 ? Blocks.AIR : Blocks.WATER) + .defaultBlockState(), false); + } + int bedrockGroundY = findMarkedGround(chunk, pos, minX + 11, minZ + 12, + world.getMinBuildHeight(), world.getMaxBuildHeight()); + chunk.setBlockState(pos.set(minX + 11, bedrockGroundY - 24, minZ + 12), + Blocks.BEDROCK.defaultBlockState(), false); + int chestGroundY = findMarkedGround(chunk, pos, minX + 12, minZ + 12, + world.getMinBuildHeight(), world.getMaxBuildHeight()); + world.setBlock(pos.set(minX + 12, chestGroundY - 24, minZ + 12), + Blocks.CHEST.defaultBlockState(), 2); + if (world.getBlockEntity(pos) instanceof ChestBlockEntity chest) { + ItemStack sentinel = new ItemStack(Items.EMERALD); + sentinel.set(net.minecraft.core.component.DataComponents.CUSTOM_NAME, + Component.literal(RAW_CHEST_ITEM_NAME)); + chest.setItem(0, sentinel); + chest.setChanged(); + } + } + private static boolean solid(BlockState state) { return !state.isAir() && state.getFluidState().isEmpty(); } @@ -620,6 +991,7 @@ private static boolean placeStructureSentinels(WorldGenLevel world, ChunkAccess chest.setItem(0, sentinel); chest.setChanged(); } + placeAuthoredNaturalSources(world, chunk, pos, minX, minZ, 16); return true; } @@ -635,9 +1007,56 @@ private static boolean placeVegetationSentinels(WorldGenLevel world, ChunkAccess int vegetationY = markedGround(chunk, pos, minX + 6, minZ + 6, world); world.setBlock(pos.set(minX + 6, vegetationY + 1, minZ + 6), Blocks.DIRT.defaultBlockState(), 2); world.setBlock(pos.set(minX + 6, vegetationY + 2, minZ + 6), Blocks.OAK_SAPLING.defaultBlockState(), 2); + placeAuthoredNaturalSources(world, chunk, pos, minX, minZ, 20); + placeWeatherMaterialSentinels(world, chunk, pos, minX, minZ); return true; } + private static void placeWeatherMaterialSentinels(WorldGenLevel world, ChunkAccess chunk, + BlockPos.MutableBlockPos pos, int minX, int minZ) { + int snowGroundY = markedGround(chunk, pos, minX + 2, minZ + 2, world); + int iceGroundY = markedGround(chunk, pos, minX + 3, minZ + 2, world); + if (world.getLevel().dimension().equals(ROOFED)) { + world.setBlock(pos.set(minX + 2, snowGroundY + 11, minZ + 2), + Blocks.SNOW.defaultBlockState(), 2); + world.setBlock(pos.set(minX + 3, iceGroundY + 11, minZ + 2), + Blocks.ICE.defaultBlockState(), 2); + return; + } + if (!world.getLevel().dimension().equals(OPEN)) return; + world.setBlock(pos.set(minX + 2, snowGroundY + 1, minZ + 2), + Blocks.SNOW.defaultBlockState(), 2); + world.setBlock(pos.set(minX + 3, iceGroundY + 1, minZ + 2), + Blocks.ICE.defaultBlockState(), 2); + int buriedSnowGroundY = markedGround(chunk, pos, minX + 2, minZ + 3, world); + int buriedIceGroundY = markedGround(chunk, pos, minX + 3, minZ + 3, world); + world.setBlock(pos.set(minX + 2, buriedSnowGroundY - 24, minZ + 3), + Blocks.SNOW.defaultBlockState(), 2); + world.setBlock(pos.set(minX + 3, buriedIceGroundY - 24, minZ + 3), + Blocks.ICE.defaultBlockState(), 2); + } + + private static void placeAuthoredNaturalSources(WorldGenLevel world, ChunkAccess chunk, + BlockPos.MutableBlockPos pos, int minX, int minZ, int depth) { + if (!world.getLevel().dimension().equals(OPEN)) return; + for (int index = 0; index < NATURAL_SOURCES.length; index++) { + int x = naturalX(minX, index); + int z = naturalZ(minZ, index); + int groundY = findMarkedGround(chunk, pos, x, z, + world.getMinBuildHeight(), world.getMaxBuildHeight()); + world.setBlock(pos.set(x, groundY - depth, z), + NATURAL_SOURCES[index].defaultBlockState(), 2); + } + } + + private static int naturalX(int minX, int index) { + return minX + 12 + index % 4; + } + + private static int naturalZ(int minZ, int index) { + return minZ + 1 + index / 4; + } + private static int markedGround(ChunkAccess chunk, BlockPos.MutableBlockPos pos, int x, int z, WorldGenLevel world) { return findMarkedGround(chunk, pos, x, z, @@ -647,7 +1066,30 @@ private static int markedGround(ChunkAccess chunk, BlockPos.MutableBlockPos pos, private record Material(BlockState top, BlockState filler, BlockState underwater, BlockState ceiling) { } + private record NaturalSourceAudit(long rawConverted, long structurePreserved, + long vegetationPreserved, long cavePreserved, long underwaterPreserved, + long bedrockPreserved, long blockEntityPreserved) { } + + private record WeatherMaterialAudit(long exposedSnowConverted, + long surfaceIceConverted, long buriedSnowPreserved, + long buriedIcePreserved, long unconfiguredSnowPreserved, + long unconfiguredIcePreserved) { } + + private record AttributionAudit(long sedimentaryHosts, long intrusiveHosts, + long biomeAHosts, long biomeBHosts, long mismatches) { + private static final AttributionAudit EMPTY = new AttributionAudit(0L, 0L, 0L, 0L, 0L); + } + private record AuditResult(long top, long underwater, long filler, long geology, long ceiling, long roofTop, int biomeA, int biomeB, - int edgeChanges, int sentinels, long aquiferFluid) { } + int edgeChanges, int sentinels, long aquiferFluid, + long rawNaturalSources, long structureNaturalSources, + long vegetationNaturalSources, long cavePockets, long underwaterPockets, + long rawBedrock, long rawBlockEntities, + long dictionaryPrimary, long dictionaryAlternative, long dynamicBiomeOre, + long exposedSnowConverted, long surfaceIceConverted, + long buriedSnowPreserved, long buriedIcePreserved, + long unconfiguredSnowPreserved, long unconfiguredIcePreserved, + long attributionSedimentary, long attributionIntrusive, + long attributionBiomeA, long attributionBiomeB, long attributionMismatches) { } } diff --git a/src/biomeIntegrationTest/resources/data/c/tags/worldgen/biome/is_cold.json b/src/biomeIntegrationTest/resources/data/c/tags/worldgen/biome/is_cold.json new file mode 100644 index 00000000..0c73c984 --- /dev/null +++ b/src/biomeIntegrationTest/resources/data/c/tags/worldgen/biome/is_cold.json @@ -0,0 +1,6 @@ +{ + "replace": false, + "values": [ + "surfaceprobe:surface_a" + ] +} diff --git a/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/clientprobe/ClientProbeTestMod.java b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/clientprobe/ClientProbeTestMod.java new file mode 100644 index 00000000..a00f1e4d --- /dev/null +++ b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/clientprobe/ClientProbeTestMod.java @@ -0,0 +1,543 @@ +package zone.moddev.mc.orespawn.clientprobe; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.lang.reflect.Method; +import java.util.HashSet; +import java.util.List; +import java.util.Properties; +import java.util.Set; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; + +import net.minecraft.ChatFormatting; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.CycleButton; +import net.minecraft.client.gui.components.TabButton; +import net.minecraft.client.gui.components.events.GuiEventListener; +import net.minecraft.client.gui.components.tabs.TabNavigationBar; +import net.minecraft.client.gui.components.tabs.TabManager; +import net.minecraft.client.gui.screens.AccessibilityOnboardingScreen; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.gui.screens.BackupConfirmScreen; +import net.minecraft.client.gui.screens.ConfirmScreen; +import net.minecraft.client.gui.screens.TitleScreen; +import net.minecraft.client.gui.screens.worldselection.ConfirmExperimentalFeaturesScreen; +import net.minecraft.client.gui.screens.worldselection.CreateWorldScreen; +import net.neoforged.api.distmarker.Dist; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.fml.common.Mod; +import net.neoforged.neoforge.client.event.ClientTickEvent; +import net.neoforged.neoforge.client.event.RenderLevelStageEvent; +import net.neoforged.neoforge.client.event.ScreenEvent; +import zone.moddev.mc.orespawn.client.OreSpawnWorldSettingsScreen; +import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfile; + +/** Build-only isolated client probe. It is compiled and packaged outside every release artifact. */ +@Mod(ClientProbeTestMod.MODID) +@EventBusSubscriber(modid = ClientProbeTestMod.MODID, value = Dist.CLIENT) +public final class ClientProbeTestMod { + static final String MODID = "clientprobe"; + private static final String WORLD_DIRECTORY = "New World"; + private static final String WORLD_SEED = "-4965128775892001975"; + private static volatile ClientProbeTestMod instance; + private final Set editorRoutes = new HashSet<>(); + private final Set attemptedButtons = new HashSet<>(); + private int state; + private int stateTicks; + private int firstWorldFrames; + private int reloadWorldFrames; + private int editorFrames; + private boolean worldSettingsOpened; + private boolean longEditorRoundTrip; + private List worldCreationButtons; + + public ClientProbeTestMod() { + instance = this; + } + + @SubscribeEvent + public static void onScreenInitialized(ScreenEvent.Init.Post event) { + ClientProbeTestMod probe = instance; + if (probe == null || !Boolean.getBoolean("clientprobe.enabled")) return; + if (!(event.getScreen() instanceof CreateWorldScreen)) return; + probe.worldCreationButtons = event.getListenersList(); + } + + @SubscribeEvent + public static void onScreenDrawn(ScreenEvent.Render.Post event) { + ClientProbeTestMod probe = instance; + if (probe != null && Boolean.getBoolean("clientprobe.enabled") + && isOreSpawnEditor(event.getScreen())) probe.editorFrames++; + } + + @SubscribeEvent + public static void onWorldRendered(RenderLevelStageEvent event) { + if (event.getStage() != RenderLevelStageEvent.Stage.AFTER_LEVEL) return; + ClientProbeTestMod probe = instance; + if (probe == null || !Boolean.getBoolean("clientprobe.enabled")) return; + if (probe.state == 6) probe.firstWorldFrames++; + if (probe.state == 8) probe.reloadWorldFrames++; + } + + @SubscribeEvent + public static void onClientTick(ClientTickEvent.Post event) { + ClientProbeTestMod probe = instance; + if (probe == null || !Boolean.getBoolean("clientprobe.enabled")) return; + probe.handleClientTick(); + } + + private void handleClientTick() { + Minecraft minecraft = Minecraft.getInstance(); + if (++stateTicks > 3600) fail(minecraft, "Timed out in client probe state " + state + + " on screen " + (minecraft.screen == null ? "" : minecraft.screen.getClass().getName())); + try { + switch (state) { + case 0: + if (minecraft.screen instanceof AccessibilityOnboardingScreen) { + minecraft.setScreen(new TitleScreen()); + break; + } + if (minecraft.screen instanceof TitleScreen) { + CreateWorldScreen.openFresh(minecraft, minecraft.screen); + nextState(1); + } + break; + case 1: + if (minecraft.screen instanceof CreateWorldScreen + && activateWorldSettings(minecraft.screen, worldCreationButtons)) { + worldSettingsOpened = true; + nextState(2); + } + break; + case 2: + if (minecraft.screen instanceof CreateWorldScreen && stateTicks >= 2) { + validateCaptions(minecraft.screen); + validateLongEditorRoundTrip(minecraft, minecraft.screen); + nextState(3); + } + break; + case 3: + if (minecraft.screen instanceof CreateWorldScreen) { + CreateWorldScreen root = (CreateWorldScreen) minecraft.screen; + Button target = nextNavigationButton(root); + if (target == null) { + if (editorRoutes.size() < 5) fail(minecraft, + "Only exercised " + editorRoutes.size() + " editor routes: " + editorRoutes); + root.getUiState().setSeed(WORLD_SEED); + pressCreateWorld(root); + nextState(6); + } else { + Screen before = minecraft.screen; + target.onPress(); + if (minecraft.screen != before && isOreSpawnEditor(minecraft.screen)) { + editorRoutes.add(minecraft.screen.getClass().getSimpleName()); + editorFrames = 0; + nextState(4); + } + } + } + break; + case 4: + if (isOreSpawnEditor(minecraft.screen) && editorFrames >= 2) { + validateCaptions(minecraft.screen); + minecraft.screen.onClose(); + nextState(3); + } + break; + case 5: + break; + case 6: + if (minecraft.screen instanceof ConfirmScreen) { + pressWorldCreationConfirmation((ConfirmScreen) minecraft.screen); + } + if (minecraft.screen instanceof ConfirmExperimentalFeaturesScreen) { + pressExperimentalProceed((ConfirmExperimentalFeaturesScreen) minecraft.screen); + } + if (minecraft.level != null && minecraft.player != null && firstWorldFrames >= 8 + && stateTicks >= 100) { + stopIntegratedServer(minecraft); + nextState(7); + } + break; + case 7: + if (minecraft.level == null && !minecraft.hasSingleplayerServer() && stateTicks >= 100) { + minecraft.createWorldOpenFlows().openWorld(WORLD_DIRECTORY, + () -> minecraft.setScreen(new TitleScreen())); + nextState(8); + } + break; + case 8: + if (minecraft.screen instanceof BackupConfirmScreen) { + pressBackupConfirmation((BackupConfirmScreen) minecraft.screen); + } + if (minecraft.screen instanceof ConfirmScreen) { + pressWorldCreationConfirmation((ConfirmScreen) minecraft.screen); + } + if (minecraft.screen instanceof ConfirmExperimentalFeaturesScreen) { + pressExperimentalProceed((ConfirmExperimentalFeaturesScreen) minecraft.screen); + } + if (minecraft.level != null && minecraft.player != null && reloadWorldFrames >= 8 + && stateTicks >= 100) { + stopIntegratedServer(minecraft); + nextState(9); + } + break; + case 9: + if (minecraft.level == null && !minecraft.hasSingleplayerServer()) { + writeMarker(); + minecraft.stop(); + nextState(10); + } + break; + default: + break; + } + } catch (RuntimeException | IOException failure) { + fail(minecraft, failure.toString()); + } + } + + private Button nextNavigationButton(CreateWorldScreen root) { + for (AbstractWidget widget : widgets(root)) { + if (!(widget instanceof Button) || widget instanceof CycleButton + || !widget.visible || !widget.active) continue; + Button button = (Button) widget; + String caption = ChatFormatting.stripFormatting(button.getMessage().getString()); + if (!attemptedButtons.add(caption)) continue; + String lower = caption.toLowerCase(java.util.Locale.ROOT); + if (lower.equals("done") || lower.equals("cancel") || lower.equals("game") + || lower.equals("world") || lower.equals("more") || lower.equals("orespawn") + || lower.contains("create new world") || lower.contains("recommended")) continue; + return button; + } + return null; + } + + private static void validateCaptions(Screen screen) { + for (AbstractWidget widget : widgets(screen)) { + String caption = ChatFormatting.stripFormatting(widget.getMessage().getString()); + if (caption == null || caption.trim().isEmpty() + || caption.contains("options.generic_value") + || caption.startsWith("button.orespawn.") + || caption.startsWith("option.orespawn.")) { + throw new IllegalStateException("Invalid client caption: " + widget.getMessage()); + } + } + } + + private void validateLongEditorRoundTrip(Minecraft minecraft, Screen parent) { + JsonObject root = WorldGeologyProfile.recommended(true).rootCopy(); + JsonObject ores = new JsonObject(); + JsonObject ore = new JsonObject(); + ore.addProperty("enabled", true); + ore.addProperty("block", "minecraft:diamond_ore"); + JsonObject oreDimensions = new JsonObject(); + JsonObject oreRule = new JsonObject(); + oreRule.addProperty("enabled", true); + oreRule.addProperty("min_y", 0); + oreRule.addProperty("max_y", 64); + oreRule.addProperty("frequency", 1.0D); + oreRule.addProperty("quantity", 8); + oreRule.addProperty("discard_chance_on_air_exposure", 0.0D); + oreRule.addProperty("pattern", "vein"); + oreRule.addProperty("height_distribution", "uniform"); + oreRule.addProperty("spread", 8); + oreRule.addProperty("vertical_spread", 4); + oreRule.addProperty("node_size", 4); + oreRule.add("host_families", new JsonArray()); + oreRule.add("host_blocks", values( + "example:ore_host_block_identifier_longer_than_thirty_two_characters")); + oreRule.add("host_tags", values( + "forge:ore_host_tag_identifier_longer_than_thirty_two_characters", + "forge:second_ore_host_tag_in_the_same_comma_separated_list")); + oreDimensions.add("minecraft:overworld", oreRule); + ore.add("dimensions", oreDimensions); + ores.add("example:long_editor_ore", ore); + root.add("ores", ores); + + JsonObject deposits = new JsonObject(); + JsonObject deposit = new JsonObject(); + deposit.addProperty("enabled", true); + deposit.addProperty("block", "minecraft:water"); + JsonObject fluidDimensions = new JsonObject(); + JsonObject fluidRule = new JsonObject(); + fluidRule.addProperty("enabled", true); + fluidRule.addProperty("min_y", 0); + fluidRule.addProperty("max_y", 48); + fluidRule.addProperty("frequency", 0.08D); + fluidRule.addProperty("min_radius", 5); + fluidRule.addProperty("max_radius", 12); + fluidRule.addProperty("min_vertical_radius", 2); + fluidRule.addProperty("max_vertical_radius", 5); + fluidRule.addProperty("max_lobes", 4); + fluidRule.addProperty("min_solid_cover", 2); + fluidRule.addProperty("min_solid_shell", 1); + fluidRule.add("host_families", new JsonArray()); + fluidRule.add("host_blocks", values( + "example:fluid_host_block_identifier_longer_than_thirty_two_characters")); + fluidRule.add("host_tags", values( + "forge:fluid_host_tag_identifier_longer_than_thirty_two_characters", + "forge:second_fluid_host_tag_in_the_same_comma_separated_list")); + fluidRule.add("biome_ids", values( + "example:included_biome_identifier_longer_than_thirty_two_characters")); + fluidRule.add("excluded_biome_ids", values( + "example:excluded_biome_identifier_longer_than_thirty_two_characters")); + fluidRule.add("biome_dictionary", values( + "INCLUDED_DICTIONARY_VALUE_LONGER_THAN_THIRTY_TWO_CHARACTERS", + "SECOND_INCLUDED_DICTIONARY_VALUE_IN_THE_COMMA_LIST")); + fluidRule.add("excluded_biome_dictionary", values( + "EXCLUDED_DICTIONARY_VALUE_LONGER_THAN_THIRTY_TWO_CHARACTERS")); + fluidRule.add("geomes", new JsonObject()); + fluidDimensions.add("minecraft:overworld", fluidRule); + deposit.add("dimensions", fluidDimensions); + deposits.add("example:long_editor_deposit", deposit); + root.add("fluid_deposits", deposits); + // Keep the synthetic profile in the editor's canonical shape so this + // assertion is about preservation of the eight long text fields rather + // than the session adding an unrelated optional empty section. + root.add("geomes", new JsonObject()); + + Object session = newEditorSession(root); + String before = editorSessionRoot(session); + + Screen oreScreen = newDimensionScreen("OreDimensionScreen", parent, session, + "example:long_editor_ore", "minecraft:overworld"); + initializeScreen(oreScreen, minecraft); + pressDone(oreScreen); + + Screen fluidScreen = newDimensionScreen("FluidDepositDimensionScreen", parent, session, + "example:long_editor_deposit", "minecraft:overworld"); + initializeScreen(fluidScreen, minecraft); + pressDone(fluidScreen); + + String after = editorSessionRoot(session); + if (!before.equals(after)) { + throw new IllegalStateException("Opening and saving long editor values changed profile JSON\nBefore: " + + before + "\nAfter: " + after); + } + longEditorRoundTrip = true; + } + + private static Object newEditorSession(JsonObject root) { + try { + Class sessionClass = Class.forName( + "zone.moddev.mc.orespawn.client.GeologyEditorSession"); + java.lang.reflect.Constructor constructor = sessionClass.getDeclaredConstructor( + WorldGeologyProfile.class); + constructor.setAccessible(true); + return constructor.newInstance(WorldGeologyProfile.recommended(true).withRoot(root)); + } catch (ReflectiveOperationException failure) { + throw new IllegalStateException("Could not create the target-native editor session", failure); + } + } + + private static Screen newDimensionScreen(String simpleName, Screen parent, Object session, + String ruleId, String dimensionId) { + try { + Class sessionClass = session.getClass(); + Class screenClass = Class.forName( + "zone.moddev.mc.orespawn.client." + simpleName); + java.lang.reflect.Constructor constructor = screenClass.getDeclaredConstructor( + Screen.class, sessionClass, String.class, String.class); + constructor.setAccessible(true); + return (Screen) constructor.newInstance(parent, session, ruleId, dimensionId); + } catch (ReflectiveOperationException failure) { + throw new IllegalStateException("Could not create target-native editor " + simpleName, failure); + } + } + + private static String editorSessionRoot(Object session) { + try { + Method root = session.getClass().getDeclaredMethod("root"); + root.setAccessible(true); + return root.invoke(session).toString(); + } catch (ReflectiveOperationException failure) { + throw new IllegalStateException("Could not read the target-native editor session", failure); + } + } + + private static JsonArray values(String... entries) { + JsonArray result = new JsonArray(); + for (String entry : entries) result.add(new JsonPrimitive(entry)); + return result; + } + + private static void initializeScreen(Screen screen, Minecraft minecraft) { + for (Method method : Screen.class.getDeclaredMethods()) { + Class[] parameters = method.getParameterTypes(); + if (parameters.length != 3 || parameters[0] != Minecraft.class + || parameters[1] != int.class || parameters[2] != int.class + || method.getReturnType() != void.class + || !java.lang.reflect.Modifier.isFinal(method.getModifiers())) continue; + try { + method.setAccessible(true); + method.invoke(screen, minecraft, 640, 480); + return; + } catch (ReflectiveOperationException failure) { + throw new IllegalStateException("Could not initialize target-native editor", failure); + } + } + throw new IllegalStateException("Could not locate Forge 50 Screen initialization method"); + } + + private static void pressDone(Screen screen) { + for (AbstractWidget widget : widgets(screen)) { + if (!(widget instanceof Button)) continue; + String caption = ChatFormatting.stripFormatting(((Button) widget).getMessage().getString()); + if ("done".equalsIgnoreCase(caption)) { + ((Button) widget).onPress(); + return; + } + } + throw new IllegalStateException("Editor did not expose its Done action: " + + screen.getClass().getSimpleName()); + } + + private static boolean isOreSpawnEditor(Screen screen) { + return screen != null && screen.getClass().getName().startsWith( + "zone.moddev.mc.orespawn.client."); + } + + private static boolean isWorldSettingsControl(AbstractWidget widget) { + return (widget instanceof Button || widget instanceof TabButton) + && ChatFormatting.stripFormatting(widget.getMessage().getString()) + .toLowerCase(java.util.Locale.ROOT).contains("orespawn"); + } + + private static boolean activateWorldSettings(GuiEventListener listener) { + if (listener instanceof TabNavigationBar) { + TabNavigationBar navigation = (TabNavigationBar) listener; + List children = navigation.children(); + for (int index = 0; index < children.size(); index++) { + GuiEventListener child = children.get(index); + if (child instanceof AbstractWidget && isWorldSettingsControl((AbstractWidget) child)) { + navigation.selectTab(index, true); + return true; + } + } + } + if (listener instanceof Button && isWorldSettingsControl((AbstractWidget) listener)) { + ((Button) listener).onPress(); + return true; + } + return false; + } + + private static boolean activateWorldSettings(Screen screen, List initializedListeners) { + for (GuiEventListener child : screen.children()) { + if (activateWorldSettings(child)) return true; + } + if (initializedListeners != null) { + for (GuiEventListener child : initializedListeners) { + if (activateWorldSettings(child)) return true; + } + } + return false; + } + + private static void pressCreateWorld(CreateWorldScreen screen) { + for (AbstractWidget widget : widgets(screen)) { + if (!(widget instanceof Button)) continue; + String caption = ChatFormatting.stripFormatting(widget.getMessage().getString()); + if (caption != null && caption.toLowerCase(java.util.Locale.ROOT).contains("create new world")) { + ((Button) widget).onPress(); + return; + } + } + throw new IllegalStateException("Create World screen did not expose its Create New World action"); + } + + private static void pressExperimentalProceed(ConfirmExperimentalFeaturesScreen screen) { + for (AbstractWidget widget : widgets(screen)) { + if (!(widget instanceof Button) || !widget.visible || !widget.active) continue; + String caption = ChatFormatting.stripFormatting(widget.getMessage().getString()); + if (caption != null && caption.equalsIgnoreCase("Proceed")) { + ((Button) widget).onPress(); + return; + } + } + throw new IllegalStateException("Experimental world confirmation did not expose its Proceed action"); + } + + private static void pressWorldCreationConfirmation(ConfirmScreen screen) { + pressAffirmativeConfirmation(screen, "World creation confirmation"); + } + + private static void pressBackupConfirmation(BackupConfirmScreen screen) { + pressAffirmativeConfirmation(screen, "World backup confirmation"); + } + + private static void pressAffirmativeConfirmation(Screen screen, String description) { + for (AbstractWidget widget : widgets(screen)) { + if (!(widget instanceof Button) || !widget.visible || !widget.active) continue; + String caption = ChatFormatting.stripFormatting(widget.getMessage().getString()); + String lower = caption == null ? "" : caption.toLowerCase(java.util.Locale.ROOT); + if (!lower.equals("no") && !lower.equals("cancel") && !lower.equals("back")) { + ((Button) widget).onPress(); + return; + } + } + throw new IllegalStateException(description + " did not expose an affirmative action"); + } + + private static java.util.List widgets(Screen screen) { + java.util.List result = new java.util.ArrayList<>(); + for (GuiEventListener child : screen.children()) { + if (child instanceof AbstractWidget) result.add((AbstractWidget) child); + } + if (screen instanceof CreateWorldScreen) { + TabManager manager = ((CreateWorldScreen) screen).tabManager; + if (manager != null && manager.getCurrentTab() != null) { + manager.getCurrentTab().visitChildren(widget -> { + if (!result.contains(widget)) result.add(widget); + }); + } + } + return result; + } + + private static void stopIntegratedServer(Minecraft minecraft) { + // Tell the integrated server to stop through the client connection first, then + // use NeoForge 20.6's disconnect path to wait for shutdown and clear the client level. + if (minecraft.level != null) minecraft.level.disconnect(); + minecraft.disconnect(new TitleScreen()); + } + + private void writeMarker() throws IOException { + Properties values = new Properties(); + values.setProperty("world_settings_opened", Boolean.toString(worldSettingsOpened)); + values.setProperty("long_editor_roundtrip", Boolean.toString(longEditorRoundTrip)); + values.setProperty("editor_routes", Integer.toString(editorRoutes.size())); + values.setProperty("editor_classes", editorRoutes.toString()); + values.setProperty("first_world_rendered", Boolean.toString(firstWorldFrames >= 8)); + values.setProperty("reload_rendered", Boolean.toString(reloadWorldFrames >= 8)); + values.setProperty("world_directory", WORLD_DIRECTORY); + try (FileOutputStream output = new FileOutputStream(new File("client-smoke-pass.properties"))) { + values.store(output, "OreSpawn NeoForge 1.20.6 client integration gate"); + } + } + + private void nextState(int next) { + state = next; + stateTicks = 0; + } + + private static void fail(Minecraft minecraft, String message) { + try { + Properties values = new Properties(); values.setProperty("failure", message); + try (FileOutputStream output = new FileOutputStream(new File("client-smoke-failure.properties"))) { + values.store(output, "OreSpawn client probe failure"); + } + } catch (IOException ignored) { + } + minecraft.stop(); + throw new IllegalStateException(message); + } +} diff --git a/src/clientIntegrationTest/resources/META-INF/neoforge.mods.toml b/src/clientIntegrationTest/resources/META-INF/neoforge.mods.toml new file mode 100644 index 00000000..9deaca96 --- /dev/null +++ b/src/clientIntegrationTest/resources/META-INF/neoforge.mods.toml @@ -0,0 +1,30 @@ +modLoader="javafml" +loaderVersion="[2,)" +license="LGPL-2.1" + +[[mods]] +modId="clientprobe" +version="1" +displayName="OreSpawn Client Probe" +description='''Build-only OreSpawn client editor and world reload fixture.''' + +[[dependencies.clientprobe]] +modId="neoforge" +type="required" +versionRange="[20.6.139,20.7)" +ordering="NONE" +side="CLIENT" + +[[dependencies.clientprobe]] +modId="orespawn" +type="required" +versionRange="[4.0.6,5.0.0)" +ordering="AFTER" +side="CLIENT" + +[[dependencies.clientprobe]] +modId="minecraft" +type="required" +versionRange="[1.20.6,1.21)" +ordering="NONE" +side="CLIENT" diff --git a/src/clientIntegrationTest/resources/pack.mcmeta b/src/clientIntegrationTest/resources/pack.mcmeta new file mode 100644 index 00000000..7f9a97b2 --- /dev/null +++ b/src/clientIntegrationTest/resources/pack.mcmeta @@ -0,0 +1,8 @@ +{ + "pack": { + "description": "OreSpawn client qualification fixture", + "forge:resource_pack_format": 13, + "forge:data_pack_format": 32, + "pack_format": 32 + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/api/GeologySampler.java b/src/main/java/zone/moddev/mc/orespawn/api/GeologySampler.java index f029d69f..8d5ad3db 100644 --- a/src/main/java/zone/moddev/mc/orespawn/api/GeologySampler.java +++ b/src/main/java/zone/moddev/mc/orespawn/api/GeologySampler.java @@ -4,7 +4,10 @@ public interface GeologySampler { /** * Classifies one column. The returned column reuses that biome/geome - * classification for all subsequent Y queries. + * classification for all subsequent Y queries. {@code surfaceY} is the first + * free block returned by {@code Level.getHeight}; OreSpawn classifies the + * stable quart biome at the highest occupied block, matching chunk geology + * generation without Minecraft's display-oriented fuzzy biome zoom. */ GeologyColumn sampleColumn(int blockX, int blockZ, int surfaceY); } diff --git a/src/main/java/zone/moddev/mc/orespawn/api/OreSpawnGeologySampler.java b/src/main/java/zone/moddev/mc/orespawn/api/OreSpawnGeologySampler.java index 40f0454b..25ffbe89 100644 --- a/src/main/java/zone/moddev/mc/orespawn/api/OreSpawnGeologySampler.java +++ b/src/main/java/zone/moddev/mc/orespawn/api/OreSpawnGeologySampler.java @@ -9,10 +9,10 @@ import zone.moddev.mc.orespawn.worldgen.GeomeConfig; import zone.moddev.mc.orespawn.worldgen.GeomeGeology; import zone.moddev.mc.orespawn.worldgen.RockFamily; +import zone.moddev.mc.orespawn.worldgen.TerrainBiomeLookup; import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfile; import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfileManager; -import net.minecraft.core.BlockPos; import net.minecraft.core.Holder; import net.minecraft.resources.ResourceKey; import net.minecraft.resources.ResourceLocation; @@ -56,8 +56,8 @@ static GeologySampler create(ServerLevel level) { @Override public GeologyColumn sampleColumn(int blockX, int blockZ, int surfaceY) { - BlockPos position = new BlockPos(blockX, surfaceY, blockZ); - Holder holder = level.getBiome(position); + int biomeY = generationBiomeY(surfaceY, level.getMinBuildHeight()); + Holder holder = TerrainBiomeLookup.atBlock(level, blockX, biomeY, blockZ); ResourceLocation biomeId = holder.unwrapKey().map(ResourceKey::location) .orElse(new ResourceLocation("orespawn", "unregistered_biome")); if (mode == GeologyMode.LEGACY) { @@ -67,6 +67,10 @@ public GeologyColumn sampleColumn(int blockX, int blockZ, int surfaceY) { return new SkyColumn(biomeId, blockX, blockZ, surfaceY, sample); } + static int generationBiomeY(int firstFreeY, int minBuildHeight) { + return firstFreeY <= minBuildHeight ? minBuildHeight : firstFreeY - 1; + } + private abstract class BaseColumn implements GeologyColumn { private final ResourceLocation biome; private final int x; diff --git a/src/main/java/zone/moddev/mc/orespawn/api/WorldgenProvider.java b/src/main/java/zone/moddev/mc/orespawn/api/WorldgenProvider.java index d64133d6..a44c8b6a 100644 --- a/src/main/java/zone/moddev/mc/orespawn/api/WorldgenProvider.java +++ b/src/main/java/zone/moddev/mc/orespawn/api/WorldgenProvider.java @@ -526,6 +526,10 @@ public static final class OreDimensionDefinition implements JsonDefinition { private final Map geomes; private final Set hostBlocks; private final Set hostTags; + private final Set biomeIds; + private final Set excludedBiomeIds; + private final Set biomeDictionary; + private final Set excludedBiomeDictionary; private final Map hostBlockWeights; private final Map hostTagWeights; @@ -549,6 +553,11 @@ private OreDimensionDefinition(Builder builder) { geomes = immutableMap(builder.geomes); hostBlocks = immutableSet(builder.hostBlocks); hostTags = immutableSet(builder.hostTags); + biomeIds = immutableSet(builder.biomeIds); + excludedBiomeIds = immutableSet(builder.excludedBiomeIds); + biomeDictionary = Collections.unmodifiableSet(new LinkedHashSet<>(builder.biomeDictionary)); + excludedBiomeDictionary = Collections.unmodifiableSet( + new LinkedHashSet<>(builder.excludedBiomeDictionary)); hostBlockWeights = immutableMap(builder.hostBlockWeights); hostTagWeights = immutableMap(builder.hostTagWeights); } @@ -575,6 +584,10 @@ private OreDimensionDefinition(Builder builder) { public Map geomes() { return geomes; } public Set hostBlocks() { return hostBlocks; } public Set hostTags() { return hostTags; } + public Set biomeIds() { return biomeIds; } + public Set excludedBiomeIds() { return excludedBiomeIds; } + public Set biomeDictionary() { return biomeDictionary; } + public Set excludedBiomeDictionary() { return excludedBiomeDictionary; } public Map hostBlockWeights() { return hostBlockWeights; } public Map hostTagWeights() { return hostTagWeights; } @@ -610,6 +623,10 @@ public JsonObject toJson() { json.add("geomes", weights(geomes)); json.add("host_blocks", weightedIds(hostBlocks, hostBlockWeights, "block")); json.add("host_tags", weightedIds(hostTags, hostTagWeights, "tag")); + json.add("biome_ids", ids(biomeIds)); + json.add("excluded_biome_ids", ids(excludedBiomeIds)); + json.add("biome_dictionary", strings(biomeDictionary)); + json.add("excluded_biome_dictionary", strings(excludedBiomeDictionary)); return json; } @@ -633,6 +650,10 @@ public static final class Builder { private final Map geomes = new LinkedHashMap<>(); private final Set hostBlocks = new LinkedHashSet<>(); private final Set hostTags = new LinkedHashSet<>(); + private final Set biomeIds = new LinkedHashSet<>(); + private final Set excludedBiomeIds = new LinkedHashSet<>(); + private final Set biomeDictionary = new LinkedHashSet<>(); + private final Set excludedBiomeDictionary = new LinkedHashSet<>(); private final Map hostBlockWeights = new LinkedHashMap<>(); private final Map hostTagWeights = new LinkedHashMap<>(); @@ -663,6 +684,12 @@ public Builder pattern(ResourceLocation type, JsonObject settings) { public Builder geomeWeight(ResourceLocation geome, double value) { geomes.put(geome, value); return this; } public Builder hostBlock(ResourceLocation value) { hostBlocks.add(value); return this; } public Builder hostTag(ResourceLocation value) { hostTags.add(value); return this; } + public Builder biome(ResourceLocation value) { biomeIds.add(value); return this; } + public Builder excludeBiome(ResourceLocation value) { excludedBiomeIds.add(value); return this; } + public Builder biomeDictionary(String value) { biomeDictionary.add(nonBlank(value)); return this; } + public Builder excludeBiomeDictionary(String value) { + excludedBiomeDictionary.add(nonBlank(value)); return this; + } public Builder hostBlock(ResourceLocation value, double weight) { hostBlocks.add(value); hostBlockWeights.put(value, replacementWeight(weight)); diff --git a/src/main/java/zone/moddev/mc/orespawn/client/FluidDepositDimensionScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/FluidDepositDimensionScreen.java index ba54e999..7b9b00a2 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/FluidDepositDimensionScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/FluidDepositDimensionScreen.java @@ -156,7 +156,7 @@ private EditBox placementField(int index, String key, String value) { int fieldWidth = Math.min(72, Math.max(58, columnWidth / 3)); EditBox box = new EditBox(font, groupX + columnWidth - fieldWidth, 90 + (row * 24), fieldWidth, 20, Component.literal(key)); - box.setValue(value); box.setMaxLength(32); + box.setMaxLength(32); box.setValue(value); OreSpawnScreenLayout.explain(box, placementHelp(key)); placementWidgets.add(addRenderableWidget(box)); return box; @@ -165,7 +165,7 @@ private EditBox placementField(int index, String key, String value) { private EditBox hostField(int index, String key, String value) { int x = index == 0 ? left : left + columnWidth + 5; EditBox box = new EditBox(font, x, 106, columnWidth, 20, Component.literal(key)); - box.setValue(value); box.setMaxLength(1024); + box.setMaxLength(1024); box.setValue(value); OreSpawnScreenLayout.explain(box, "tooltip.orespawn." + key); hostWidgets.add(addRenderableWidget(box)); return box; @@ -175,7 +175,7 @@ private EditBox biomeField(int index, String key, String value) { int x = (index & 1) == 0 ? left : left + columnWidth + 5; int y = 106 + ((index / 2) * 44); EditBox box = new EditBox(font, x, y, columnWidth, 20, Component.literal(key)); - box.setValue(value); box.setMaxLength(1024); + box.setMaxLength(1024); box.setValue(value); OreSpawnScreenLayout.explain(box, "tooltip.orespawn.fluid." + key); biomeWidgets.add(addRenderableWidget(box)); return box; diff --git a/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java b/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java index 66d644bc..eeb2d7dc 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java @@ -636,7 +636,7 @@ JsonObject weightMap(String section, String id) { void addGeome(String id) { String normalized = id.trim().toLowerCase(Locale.ROOT); - if (!normalized.matches("[a-z0-9_.-]+") || section("geomes").has(normalized)) { + if (!validGeomeId(normalized) || section("geomes").has(normalized)) { return; } JsonObject geome = new JsonObject(); @@ -696,7 +696,7 @@ List validate() { } for (Entry entry : terrainActive ? geomes.entrySet() : Collections.>emptySet()) { - if (!entry.getKey().matches("[a-z0-9_.-]+") || !entry.getValue().isJsonObject()) { + if (!validGeomeId(entry.getKey()) || !entry.getValue().isJsonObject()) { errors.add("Invalid geome: " + entry.getKey()); continue; } @@ -1155,6 +1155,13 @@ private static boolean validBlock(String id) { return block != null && block != Blocks.AIR; } + private static boolean validGeomeId(String id) { + if (id == null || id.isEmpty()) return false; + if (id.indexOf(':') < 0) return id.matches("[a-z0-9_.-]+"); + if (!validResource(id)) return false; + return id.equals(new ResourceLocation(id).toString()); + } + private static String safePath(String registryId) { return registryId.toLowerCase(Locale.ROOT).replace(':', '/') .replaceAll("[^a-z0-9_./-]", "_"); diff --git a/src/main/java/zone/moddev/mc/orespawn/client/OreDimensionScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/OreDimensionScreen.java index 53928255..3702b019 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/OreDimensionScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreDimensionScreen.java @@ -238,8 +238,8 @@ protected void init() { private EditBox addPlacementField(int x, int y, String key, String value) { EditBox box = new EditBox(font, x, y, columnWidth, 20, Component.literal(key)); - box.setValue(value); box.setMaxLength(32); + box.setValue(value); OreSpawnScreenLayout.explain(box, placementHelp(key)); placementWidgets.add(addRenderableWidget(box)); return box; @@ -251,8 +251,8 @@ private int compactPlacementFieldY(int row) { private EditBox addHostField(int x, int y, String key, String value) { EditBox box = new EditBox(font, x, y, contentWidth, 20, Component.literal(key)); - box.setValue(value); box.setMaxLength(1024); + box.setValue(value); OreSpawnScreenLayout.explain(box, "tooltip.orespawn." + key); hostWidgets.add(addRenderableWidget(box)); return box; @@ -260,8 +260,8 @@ private EditBox addHostField(int x, int y, String key, String value) { private EditBox addPatternField(int x, int y, String key, String value) { EditBox box = new EditBox(font, x, y, columnWidth, 20, Component.literal(key)); - box.setValue(value); box.setMaxLength(32); + box.setValue(value); OreSpawnScreenLayout.explain(box, "tooltip.orespawn.ore." + key); patternWidgets.add(addRenderableWidget(box)); return box; diff --git a/src/main/java/zone/moddev/mc/orespawn/documentation/DocumentationExporter.java b/src/main/java/zone/moddev/mc/orespawn/documentation/DocumentationExporter.java index fd6f22b0..f4b815a6 100644 --- a/src/main/java/zone/moddev/mc/orespawn/documentation/DocumentationExporter.java +++ b/src/main/java/zone/moddev/mc/orespawn/documentation/DocumentationExporter.java @@ -30,6 +30,7 @@ public final class DocumentationExporter { "MIGRATION.md", "TROUBLESHOOTING.md", "AGENTS.md", + "VERSIONS.md", "examples/examplemod-orespawn.json", "examples/orespawn-global.json", "examples/orespawn-world.json", diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/BakedGeomeConfig.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/BakedGeomeConfig.java index 18b8bffe..261dbb34 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/BakedGeomeConfig.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/BakedGeomeConfig.java @@ -34,6 +34,7 @@ public final class BakedGeomeConfig { private final Map biomeWeights; private final Map biomeWeightsById; private final double[] fallbackWeights; + private final RockEntry[] rocks; private final BlockState[] rockStates; private final Set sedimentaryBlocks; private final Set oreReplaceableBlocks; @@ -44,6 +45,9 @@ public final class BakedGeomeConfig { private WeightedBlockPicker[][][] legacyRockPickers; private byte[] stableFamilyChoices; private int[] stableRockChoices; + private int[][] familyRockIndexes; + private double[][][] stableRockLogWeights; + private double[][][] stableRockPriorities; BakedGeomeConfig(GeomeDefinition[] geomes, double geomeScale, double biomeInfluence, double regionalNoiseInfluence, double boundaryNoiseInfluence, Map biomeWeights, @@ -60,7 +64,7 @@ public final class BakedGeomeConfig { for (Map.Entry entry : biomeWeights.entrySet()) { ResourceLocation biomeId = BiomeRegistryAccess.id(entry.getKey()); if (biomeId != null) { - biomeWeightsById.put(biomeId, entry.getValue()); + this.biomeWeightsById.putIfAbsent(biomeId, entry.getValue()); } } this.fallbackWeights = defaultWeights(geomes.length); @@ -71,6 +75,7 @@ public final class BakedGeomeConfig { noiseOffsetZ[i] = -((i + 1) * 6151); } + this.rocks = rocks.clone(); rockStates = new BlockState[rocks.length]; for (int i = 0; i < rocks.length; i++) { rockStates[i] = rocks[i].state; @@ -151,15 +156,69 @@ RockFamily pickFamily(int geomeIndex, int y, int formationValue, int diversitySl return RockFamily.SEDIMENTARY; } + RockFamily pickStableFamilyAtWorldY(int geomeIndex, int worldY, int formationY, + int formationValue, int diversitySlot) { + RockFamily preferred = pickFamily(geomeIndex, formationY, formationValue, diversitySlot); + if (hasEligibleStableRock(geomeIndex, preferred, worldY, formationY)) { + return preferred; + } + + int bucket = formationValue & 0xFF; + int boundedFormationY = clampStableValue(formationY); + double bestScore = Double.NEGATIVE_INFINITY; + RockFamily bestFamily = preferred; + for (RockFamily family : RockFamily.values()) { + if (!hasEligibleStableRock(geomeIndex, family, worldY, formationY)) { + continue; + } + double weight = Math.pow(geomes[geomeIndex].familyWeights[family.ordinal()], 2.5D) + * familyDepthWeight(family, boundedFormationY); + if (weight <= 0.0D) { + continue; + } + double score = Math.log(weight) + gumbelPriority(bucket, geomeIndex, family.ordinal(), + isStableBucket(bucket, -1), 0x6A09E667F3BCC909L); + if (score > bestScore) { + bestScore = score; + bestFamily = family; + } + } + return bestFamily; + } + public BlockState pickRock(int geomeIndex, RockFamily family, int y, int formationValue) { if (formations.usesStableLayers()) { - int index = stableRockIndex(geomeIndex, family.ordinal(), clampStableY(y), formationValue & 0xFF); - int rockIndex = stableRockChoices[index]; - return rockIndex < 0 ? FALLBACK : rockStates[rockIndex]; + return pickStableRockAtWorldY(geomeIndex, family, y, y, formationValue); } return legacyRockPickers[geomeIndex][family.ordinal()][clampLegacyY(y)].pick(formationValue); } + BlockState pickStableRockAtWorldY(int geomeIndex, RockFamily family, int worldY, + int formationY, int formationValue) { + int yIndex = clampStableY(formationY); + int bucket = formationValue & 0xFF; + int choiceIndex = stableRockIndex(geomeIndex, family.ordinal(), yIndex, bucket); + int selectedRock = stableRockChoices[choiceIndex]; + if (isEligibleStableRock(geomeIndex, selectedRock, worldY, yIndex)) { + return rockStates[selectedRock]; + } + + double bestScore = Double.NEGATIVE_INFINITY; + int bestRock = -1; + for (int rockIndex : familyRockIndexes[family.ordinal()]) { + if (!isEligibleStableRock(geomeIndex, rockIndex, worldY, yIndex)) { + continue; + } + double score = stableRockLogWeights[geomeIndex][rockIndex][yIndex] + + stableRockPriorities[geomeIndex][rockIndex][bucket]; + if (score > bestScore) { + bestScore = score; + bestRock = rockIndex; + } + } + return bestRock < 0 ? FALLBACK : rockStates[bestRock]; + } + public String geomeName(int geomeIndex) { return geomes[geomeIndex].name; } @@ -226,12 +285,12 @@ int familyDiversitySlots() { } String describeBiomeWeights(Biome biome) { - double[] weights = biomeWeights.get(biome); - String source = "identity"; + ResourceLocation biomeId = BiomeRegistryAccess.id(biome); + double[] weights = biomeId == null ? null : biomeWeightsById.get(biomeId); + String source = "registry-id"; if (weights == null) { - ResourceLocation biomeId = BiomeRegistryAccess.id(biome); - weights = biomeId == null ? null : biomeWeightsById.get(biomeId); - source = "registry-id"; + weights = biomeWeights.get(biome); + source = "identity"; } if (weights == null) { weights = fallbackWeights; @@ -270,10 +329,8 @@ boolean hasDistinctBiomeWeights(Biome biome) { } private double[] biomeWeightsFor(Biome biome, ResourceLocation biomeId) { - double[] weights = biomeWeights.get(biome); - if (weights == null && biomeId != null) { - weights = biomeWeightsById.get(biomeId); - } + double[] weights = biomeId == null ? null : biomeWeightsById.get(biomeId); + if (weights == null) weights = biomeWeights.get(biome); return weights == null ? fallbackWeights : weights; } @@ -282,9 +339,9 @@ private void buildStablePickers(RockEntry[] rocks) { stableRockChoices = new int[geomes.length * RockFamily.values().length * HEIGHT * FORMATION_BUCKETS]; Arrays.fill(stableRockChoices, -1); int familyCount = RockFamily.values().length; - int[][] familyRockIndexes = groupRockIndexes(rocks); - double[][][] rockLogWeights = new double[geomes.length][rocks.length][HEIGHT]; - double[][][] rockPriorities = new double[geomes.length][rocks.length][FORMATION_BUCKETS]; + familyRockIndexes = groupRockIndexes(rocks); + stableRockLogWeights = new double[geomes.length][rocks.length][HEIGHT]; + stableRockPriorities = new double[geomes.length][rocks.length][FORMATION_BUCKETS]; double[][][] familyLogWeights = new double[geomes.length][familyCount][HEIGHT]; double[][][] familyWeights = new double[geomes.length][familyCount][HEIGHT]; double[][][] familyPriorities = new double[geomes.length][familyCount][FORMATION_BUCKETS]; @@ -293,14 +350,13 @@ private void buildStablePickers(RockEntry[] rocks) { for (int rockIndex = 0; rockIndex < rocks.length; rockIndex++) { RockEntry rock = rocks[rockIndex]; for (int y = MIN_Y; y <= MAX_Y; y++) { - double rawWeight = y < rock.minY || y > rock.maxY ? 0.0D - : rock.weight * rock.geomeWeights[geome] - * depthWeight(y, rock.depthPeak, rock.depthSpread); - rockLogWeights[geome][rockIndex][y - MIN_Y] = rawWeight > 0.0D + double rawWeight = rock.weight * rock.geomeWeights[geome] + * depthWeight(y, rock.depthPeak, rock.depthSpread); + stableRockLogWeights[geome][rockIndex][y - MIN_Y] = rawWeight > 0.0D ? Math.log(rawWeight) : Double.NEGATIVE_INFINITY; } for (int bucket = 0; bucket < FORMATION_BUCKETS; bucket++) { - rockPriorities[geome][rockIndex][bucket] = gumbelPriority(bucket, geome, rockIndex, + stableRockPriorities[geome][rockIndex][bucket] = gumbelPriority(bucket, geome, rockIndex, isStableBucket(bucket, rock.family.ordinal()), 0xBB67AE8584CAA73BL ^ ((long) rock.family.ordinal() << 32)); } @@ -312,7 +368,9 @@ private void buildStablePickers(RockEntry[] rocks) { int yIndex = y - MIN_Y; boolean available = false; for (int rockIndex : familyRockIndexes[familyIndex]) { - if (rockLogWeights[geome][rockIndex][yIndex] != Double.NEGATIVE_INFINITY) { + RockEntry rock = rocks[rockIndex]; + if (y >= rock.minY && y <= rock.maxY + && stableRockLogWeights[geome][rockIndex][yIndex] != Double.NEGATIVE_INFINITY) { available = true; break; } @@ -362,8 +420,12 @@ private void buildStablePickers(RockEntry[] rocks) { double bestRockScore = Double.NEGATIVE_INFINITY; int bestRock = -1; for (int rockIndex : familyRockIndexes[familyIndex]) { - double rockScore = rockLogWeights[geome][rockIndex][yIndex] - + rockPriorities[geome][rockIndex][bucket]; + RockEntry rock = rocks[rockIndex]; + if (y < rock.minY || y > rock.maxY) { + continue; + } + double rockScore = stableRockLogWeights[geome][rockIndex][yIndex] + + stableRockPriorities[geome][rockIndex][bucket]; if (rockScore > bestRockScore) { bestRockScore = rockScore; bestRock = rockIndex; @@ -376,6 +438,25 @@ private void buildStablePickers(RockEntry[] rocks) { } } + private boolean hasEligibleStableRock(int geomeIndex, RockFamily family, int worldY, int formationY) { + int yIndex = clampStableY(formationY); + for (int rockIndex : familyRockIndexes[family.ordinal()]) { + if (isEligibleStableRock(geomeIndex, rockIndex, worldY, yIndex)) { + return true; + } + } + return false; + } + + private boolean isEligibleStableRock(int geomeIndex, int rockIndex, int worldY, int formationYIndex) { + if (rockIndex < 0) { + return false; + } + RockEntry rock = rocks[rockIndex]; + return worldY >= rock.minY && worldY <= rock.maxY + && stableRockLogWeights[geomeIndex][rockIndex][formationYIndex] != Double.NEGATIVE_INFINITY; + } + private void fillBalancedFamilyCycle(int geome, int yIndex, int bucket, double[][][] familyWeights, double[][][] familyPriorities, int[] quotas, int[] remaining, double[] remainders, boolean[] bonusAwarded) { @@ -630,6 +711,10 @@ private static int clampStableY(int y) { return Math.max(MIN_Y, Math.min(MAX_Y, y)) - MIN_Y; } + private static int clampStableValue(int y) { + return Math.max(MIN_Y, Math.min(MAX_Y, y)); + } + private static int clampLegacyY(int y) { return Math.max(0, Math.min(LEGACY_MAX_Y, y)); } diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/BakedTerrainDimension.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/BakedTerrainDimension.java index eabe8b11..2f0cea9e 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/BakedTerrainDimension.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/BakedTerrainDimension.java @@ -8,6 +8,8 @@ import net.minecraft.resources.ResourceLocation; import net.minecraft.world.level.Level; import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.LiquidBlock; import net.minecraft.world.level.block.state.BlockState; /** Immutable setup-time resolution of one terrain replacement dimension. */ @@ -38,6 +40,11 @@ boolean hasBiomeFilter() { } boolean isReplaceable(BlockState state) { + if (state.isAir() || state.getBlock() instanceof LiquidBlock + || !state.getFluidState().isEmpty() + || state.getBlock() == Blocks.BEDROCK) { + return false; + } if (smallHostSet != null) { Block block = state.getBlock(); for (Block host : smallHostSet) { diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeTypeCompatibility.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeTypeCompatibility.java index 9d50c576..a381ea13 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeTypeCompatibility.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeTypeCompatibility.java @@ -61,6 +61,10 @@ static boolean hasType(ResourceKey key, String type) { .map(holder -> matches(holder, tags(type))).orElse(false); } + static Biome biome(ResourceKey key) { + return zone.moddev.mc.orespawn.worldgen.BiomeRegistryAccess.get(key.location()); + } + static boolean hasType(Biome biome, String type) { return matches(zone.moddev.mc.orespawn.worldgen.BiomeRegistryAccess.holder(biome), tags(type)); } diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/Geology.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/Geology.java index e0ea9a9e..9e8209d6 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/Geology.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/Geology.java @@ -112,8 +112,9 @@ public void replaceStoneInChunk(LevelAccessor world, ChunkAccess chunk, BakedTer for (; y >= chunk.getMinBuildHeight(); y--) { cursor.set(x, y, z); BlockState current = chunk.getBlockState(cursor); - if (terrain.isReplaceable(current) - || (realisticCoalLayers && current.getBlock() == Blocks.COAL_ORE)) { + if ((terrain.isReplaceable(current) + || (realisticCoalLayers && current.getBlock() == Blocks.COAL_ORE)) + && chunk.getBlockEntity(cursor) == null) { BlockState replacement = pickReplacement(baseRockVal, geomeBase, y); if (current.equals(replacement)) continue; chunk.setBlockState(cursor, replacement, false); diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeConfig.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeConfig.java index bff811cd..39b6b715 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeConfig.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeConfig.java @@ -288,7 +288,8 @@ private static BakedGeomeConfig bake(JsonObject root, ResourceLocation dimension return null; } Map biomeWeights = bakeBiomeWeights(geomeIndexes, biomeRules, dictionaryRules); - Map biomeWeightsById = bakeBiomeIdentifierWeights(geomeIndexes, biomeRules); + Map biomeWeightsById = bakeBiomeIdentifierWeights( + geomeIndexes, biomeRules, dictionaryRules); LOGGER.info("Baked OreSpawn geome config for '{}' with {} geomes, {} rock entries, " + "{} resolved biome profiles, {} identifier profiles, and {} formations", @@ -1040,22 +1041,52 @@ private static Map bakeBiomeWeights(Map geomeI static Map bakeBiomeIdentifierWeights(Map geomeIndexes, Map biomeRules) { + return bakeBiomeIdentifierWeights(geomeIndexes, biomeRules, Collections.emptyMap()); + } + + static Map bakeBiomeIdentifierWeights(Map geomeIndexes, + Map biomeRules, Map dictionaryRules) { + return bakeBiomeIdentifierWeights(geomeIndexes, biomeRules, dictionaryRules, + BiomeTypeCompatibility::biomeKeys); + } + + static Map bakeBiomeIdentifierWeights(Map geomeIndexes, + Map biomeRules, Map dictionaryRules, + java.util.function.Function>> dictionaryResolver) { Map result = new LinkedHashMap<>(); for (Entry entry : biomeRules.entrySet()) { try { ResourceLocation biomeId = new ResourceLocation(entry.getKey()); - double[] weights = new double[geomeIndexes.size()]; - Arrays.fill(weights, 1.0D); - merge(weights, entry.getValue()); - applyBiomeHeuristic(weights, geomeIndexes, biomeId, Float.NaN, Float.NaN); - result.put(biomeId, weights); + merge(identifierWeights(result, biomeId, geomeIndexes.size()), entry.getValue()); } catch (RuntimeException e) { LOGGER.warn("Ignoring invalid OreSpawn biome rule ID '{}'", entry.getKey()); } } + for (Entry entry : dictionaryRules.entrySet()) { + for (ResourceKey biomeKey : dictionaryResolver.apply(entry.getKey())) { + merge(identifierWeights(result, biomeKey.location(), geomeIndexes.size()), entry.getValue()); + } + } + for (Entry entry : result.entrySet()) { + Biome biome = BiomeTypeCompatibility.biome(ResourceKey.create(Registries.BIOME, entry.getKey())); + if (biome == null) { + applyBiomeHeuristic(entry.getValue(), geomeIndexes, entry.getKey(), Float.NaN, Float.NaN); + } else { + applyBiomeHeuristic(entry.getValue(), geomeIndexes, entry.getKey(), biome); + } + } return result; } + private static double[] identifierWeights(Map result, + ResourceLocation biomeId, int geomeCount) { + return result.computeIfAbsent(biomeId, ignored -> { + double[] weights = new double[geomeCount]; + Arrays.fill(weights, 1.0D); + return weights; + }); + } + private static void applyBiomeHeuristic(double[] weights, Map geomeIndexes, ResourceLocation biomeId, Biome biome) { applyBiomeHeuristic(weights, geomeIndexes, biomeId, diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeGeology.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeGeology.java index daf5e9e0..d560e941 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeGeology.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeGeology.java @@ -99,8 +99,7 @@ public void replaceStoneInChunk(LevelAccessor world, ChunkAccess chunk, BakedTer for (int dz = 0; dz < 16; dz++) { int z = zOffset + dz; int surfaceY = chunk.getHeight(Heightmap.Types.WORLD_SURFACE_WG, dx, dz); - cursor.set(x, surfaceY, z); - Holder biomeHolder = world.getBiome(cursor); + Holder biomeHolder = TerrainBiomeLookup.atBlock(chunk, x, surfaceY, z); Biome biome = biomeHolder.value(); Optional> biomeKey = biomeHolder.unwrapKey(); ResourceLocation biomeId = biomeKey.isPresent() ? biomeKey.get().location() : null; @@ -118,7 +117,8 @@ public void replaceStoneInChunk(LevelAccessor world, ChunkAccess chunk, BakedTer } else { for (int y = surfaceY; y >= chunk.getMinBuildHeight(); y--) { cursor.set(x, y, z); - if (terrain.isReplaceable(chunk.getBlockState(cursor))) { + if (terrain.isReplaceable(chunk.getBlockState(cursor)) + && chunk.getBlockEntity(cursor) == null) { chunk.setBlockState(cursor, pickReplacement(geomeIndex, baseRockValue, formationRegion, x, y, z), false); changed = true; @@ -140,7 +140,6 @@ private boolean replaceStableColumn(ChunkAccess chunk, BlockPos.MutableBlockPos int layerStart = layerIndex * layerThickness; int layerGeome = pickStableLayerGeome(geomeScores, geomeIndex, secondGeome, layerIndex, geomeTransitionPhase); - BlockState replacement = pickStableReplacement(layerGeome, formationRegion, layerIndex); boolean changed = false; cursor.set(x, surfaceY, z); @@ -151,11 +150,12 @@ private boolean replaceStableColumn(ChunkAccess chunk, BlockPos.MutableBlockPos layerStart -= layerThickness; layerGeome = pickStableLayerGeome(geomeScores, geomeIndex, secondGeome, layerIndex, geomeTransitionPhase); - replacement = pickStableReplacement(layerGeome, formationRegion, layerIndex); } cursor.setY(y); - if (terrain.isReplaceable(chunk.getBlockState(cursor))) { - chunk.setBlockState(cursor, replacement, false); + if (terrain.isReplaceable(chunk.getBlockState(cursor)) + && chunk.getBlockEntity(cursor) == null) { + chunk.setBlockState(cursor, + pickStableReplacement(layerGeome, formationRegion, layerIndex, y), false); changed = true; } } @@ -248,7 +248,7 @@ private net.minecraft.world.level.block.state.BlockState pickReplacement(int geo int stratum = baseRockValue + y; int layerIndex = Math.floorDiv(stratum, layerThickness); if (stableLayers) { - return pickStableReplacement(geomeIndex, formationRegion, layerIndex); + return pickStableReplacement(geomeIndex, formationRegion, layerIndex, y); } int layerY = y + (layerThickness / 2) - Math.floorMod(stratum, layerThickness); @@ -258,7 +258,7 @@ private net.minecraft.world.level.block.state.BlockState pickReplacement(int geo return config.pickRock(geomeIndex, family, layerY, rockHash); } - private BlockState pickStableReplacement(int geomeIndex, long formationRegion, int layerIndex) { + private BlockState pickStableReplacement(int geomeIndex, long formationRegion, int layerIndex, int worldY) { // A dipping or uplifted layer keeps the depth identity it had in stratum space. int formationY = (layerIndex * layerThickness) + (layerThickness / 2); int layerBucket = layerIndex & 0xFF; @@ -284,8 +284,9 @@ private BlockState pickStableReplacement(int geomeIndex, long formationRegion, i // from collapsing onto one exact rock. rockBucket ^= LITHOLOGY_ROCK_SALTS[familySlot]; } - RockFamily family = config.pickFamily(geomeIndex, formationY, familyBucket, familySlot); - return config.pickRock(geomeIndex, family, formationY, rockBucket); + RockFamily family = config.pickStableFamilyAtWorldY(geomeIndex, worldY, formationY, + familyBucket, familySlot); + return config.pickStableRockAtWorldY(geomeIndex, family, worldY, formationY, rockBucket); } int stratumOffsetAt(int x, int z) { diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java index 2c433147..d25d7548 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java @@ -358,7 +358,7 @@ private static void writeReport(Path config, List lines) { private static void writeUpgradeReport(Path config, int imported, List detail) { List lines = new ArrayList<>(); - lines.add("OreSpawn 4.0.6.120062 Upgrade Report"); + lines.add("OreSpawn 4.0.16.120062 Upgrade Report"); lines.add("================================"); lines.add(""); lines.add("RESULT: Legacy OreSpawn settings were imported into the OS4 profile."); diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java index 5117361a..e1a5c771 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java @@ -205,7 +205,7 @@ private static void writeUpgradeReport(Path worldRoot, Path configPath, Path report = worldRoot.resolve("serverconfig/orespawn-upgrade-report.txt"); List missing = missingBlocks(igneous, metamorphic, sedimentary); List lines = new ArrayList<>(); - lines.add("OreSpawn 4.0.6.120062 Upgrade Report"); + lines.add("OreSpawn 4.0.16.120062 Upgrade Report"); lines.add("================================"); lines.add(""); lines.add("RESULT: Existing Mineralogy " + identity.version + " world detected."); diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnBiomeModifier.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnBiomeModifier.java index 02277e43..f5a00cac 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnBiomeModifier.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnBiomeModifier.java @@ -41,7 +41,6 @@ static boolean apply(BiomeGenerationSettingsBuilder generation) { generation.getFeatures(GenerationStep.Decoration.UNDERGROUND_ORES); changed |= StoneReplacer.wrapVanillaMatchingStoneFeatures(underground); changed |= VanillaOreFeatureGate.wrapFeatureList(underground); - changed |= addUnique(underground, StoneReplacer.placedFeature()); changed |= addUnique(underground, OreSpawnOreGeneration.placedFeature()); changed |= addUnique(underground, FluidDepositFeature.placedFeature()); @@ -51,7 +50,8 @@ static boolean apply(BiomeGenerationSettingsBuilder generation) { List> local = generation.getFeatures(GenerationStep.Decoration.LOCAL_MODIFICATIONS); - changed |= addUnique(local, BiomeSurfaceFeature.placedFeature()); + changed |= StoneReplacer.placeUniqueAt(local, StoneReplacer.placedFeature(), 0); + changed |= StoneReplacer.placeUniqueAt(local, BiomeSurfaceFeature.placedFeature(), 1); List> top = generation.getFeatures(GenerationStep.Decoration.TOP_LAYER_MODIFICATION); diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGeneration.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGeneration.java index 6fe90cff..f513a190 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGeneration.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGeneration.java @@ -138,9 +138,10 @@ private static boolean generateChunk(WorldGenLevel world, ChunkAccess chunk, Hol ChunkPos chunkPos = chunk.getPos(); int centerX = chunkPos.getMinBlockX() + 8; int centerZ = chunkPos.getMinBlockZ() + 8; + ResourceKey biomeKey = biome.unwrapKey().orElse(null); int geome = -1; if (Level.OVERWORLD.equals(dimension)) { - ResourceLocation biomeId = biome.unwrapKey().map(ResourceKey::location).orElse(null); + ResourceLocation biomeId = biomeKey == null ? null : biomeKey.location(); geome = classifier(worldSeed).classifyColumn(biome.value(), biomeId, centerX, centerZ, scratch.geomeValues(geomeConfig.geomeCount())); } @@ -148,7 +149,7 @@ private static boolean generateChunk(WorldGenLevel world, ChunkAccess chunk, Hol boolean changed = false; for (BakedOre ore : ores) { if (retrogenOnly && !ore.retrogen) continue; - if (!ore.acceptsBiome(biome.value())) { + if (!ore.acceptsBiome(biomeKey)) { continue; } double frequency = ore.frequency; @@ -420,8 +421,8 @@ private static BakedOre bakeOre(BlockState output, BlockState deepOutput, int de } } } - Set includedBiomes = resolveBiomes(json, "biome_ids", "biome_dictionary"); - Set excludedBiomes = resolveBiomes(json, "excluded_biome_ids", "excluded_biome_dictionary"); + Set> includedBiomes = resolveBiomes(json, "biome_ids", "biome_dictionary"); + Set> excludedBiomes = resolveBiomes(json, "excluded_biome_ids", "excluded_biome_dictionary"); return new BakedOre(output, deepOutput, deepOutputMaxY, outputs, minY, maxY, Math.min(64.0D, frequency), minQuantity, maxQuantity, pattern, heightDistribution, discardChanceOnAirExposure, @@ -485,19 +486,23 @@ private static void addTags(Map target, JsonElement element, } } - private static Set resolveBiomes(JsonObject rule, String idsKey, String dictionaryKey) { - Set result = Collections.newSetFromMap(new IdentityHashMap()); + static Set> resolveBiomes(JsonObject rule, String idsKey, String dictionaryKey) { + return resolveBiomes(rule, idsKey, dictionaryKey, BiomeTypeCompatibility::biomeKeys); + } + + static Set> resolveBiomes(JsonObject rule, String idsKey, String dictionaryKey, + java.util.function.Function>> dictionaryResolver) { + Set> result = new HashSet<>(); if (rule.has(idsKey) && rule.get(idsKey).isJsonArray()) { for (JsonElement element : rule.getAsJsonArray(idsKey)) { ResourceLocation id = resource(element.getAsString()); - Biome biome = id == null ? null : BiomeRegistryAccess.get(id); - if (biome != null) result.add(biome); + if (id != null) result.add(ResourceKey.create(Registries.BIOME, id)); } } if (rule.has(dictionaryKey) && rule.get(dictionaryKey).isJsonArray()) { for (JsonElement element : rule.getAsJsonArray(dictionaryKey)) { try { - result.addAll(BiomeTypeCompatibility.biomes(element.getAsString())); + result.addAll(dictionaryResolver.apply(element.getAsString())); } catch (RuntimeException ignored) { } } @@ -505,6 +510,13 @@ private static Set resolveBiomes(JsonObject rule, String idsKey, String d return result; } + static boolean acceptsBiome(Set> includedBiomes, + Set> excludedBiomes, ResourceKey biome) { + if (biome == null) return includedBiomes.isEmpty() && excludedBiomes.isEmpty(); + return !excludedBiomes.contains(biome) + && (includedBiomes.isEmpty() || includedBiomes.contains(biome)); + } + private static Set resolveTag(TagKey tag) { Set result = Collections.newSetFromMap(new IdentityHashMap()); for (Block block : BuiltInRegistries.BLOCK.stream().toList()) { @@ -609,8 +621,8 @@ private static final class BakedOre { final Map hostBlocks; final int familyMask; final double[] geomeWeights; - final Set includedBiomes; - final Set excludedBiomes; + final Set> includedBiomes; + final Set> excludedBiomes; final boolean retrogen; BakedOre(BlockState output, BlockState deepOutput, int deepOutputMaxY, BakedOutput[] outputs, @@ -619,7 +631,8 @@ private static final class BakedOre { double discardChanceOnAirExposure, int spread, int verticalSpread, int nodeSize, Map hostBlocks, int familyMask, double[] geomeWeights, - Set includedBiomes, Set excludedBiomes, boolean retrogen) { + Set> includedBiomes, Set> excludedBiomes, + boolean retrogen) { this.output = output; this.deepOutput = deepOutput; this.deepOutputMaxY = deepOutputMaxY; @@ -667,9 +680,8 @@ boolean accepts(BlockState state, Random random, BakedGeomeConfig config) { && (familyMask & (1 << family.ordinal())) != 0; } - boolean acceptsBiome(Biome biome) { - return !excludedBiomes.contains(biome) - && (includedBiomes.isEmpty() || includedBiomes.contains(biome)); + boolean acceptsBiome(ResourceKey biome) { + return OreSpawnOreGeneration.acceptsBiome(includedBiomes, excludedBiomes, biome); } } diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/StoneReplacer.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/StoneReplacer.java index 0e9cadd4..f0d4870b 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/StoneReplacer.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/StoneReplacer.java @@ -60,6 +60,23 @@ static Holder placedFeature() { return placedFeature; } + static boolean placeUniqueAt(List> features, + Holder feature, int index) { + if (feature == null) return false; + int current = -1; + for (int candidate = 0; candidate < features.size(); candidate++) { + if (features.get(candidate).value() == feature.value()) { + current = candidate; + break; + } + } + int target = Math.min(index, features.size() - (current >= 0 ? 1 : 0)); + if (current == target) return false; + if (current >= 0) features.remove(current); + features.add(target, feature); + return true; + } + static boolean removeVanillaMatchingStoneFeatures(List> features) { return features.removeIf(StoneReplacer::isVanillaMatchingStoneFeature); } diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/TerrainBiomeLookup.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/TerrainBiomeLookup.java new file mode 100644 index 00000000..387beea9 --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/TerrainBiomeLookup.java @@ -0,0 +1,21 @@ +package zone.moddev.mc.orespawn.worldgen; + +import net.minecraft.core.Holder; +import net.minecraft.core.QuartPos; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.biome.BiomeManager; + +/** + * Internal generation-time biome lookup shared by geology and its public + * read-only sampler. + */ +public final class TerrainBiomeLookup { + private TerrainBiomeLookup() { + } + + public static Holder atBlock(BiomeManager.NoiseBiomeSource source, + int blockX, int blockY, int blockZ) { + return source.getNoiseBiome(QuartPos.fromBlock(blockX), + QuartPos.fromBlock(blockY), QuartPos.fromBlock(blockZ)); + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldMaterialWeather.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldMaterialWeather.java index 937c2dd3..6ebcd611 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldMaterialWeather.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldMaterialWeather.java @@ -54,6 +54,14 @@ private static void convertChunk(ChunkAccess chunk, DimensionMaterials materials for (int localX = 0; localX < 16; localX++) { for (int localZ = 0; localZ < 16; localZ++) { int top = chunk.getHeight(Heightmap.Types.MOTION_BLOCKING, localX, localZ); + // A one-layer Snow block is non-motion-blocking and therefore occupies + // the first free cell immediately above this heightmap's surface. + if (materials.snow != null && top + 1 < chunk.getMaxBuildHeight()) { + cursor.set(minX + localX, top + 1, minZ + localZ); + if (chunk.getBlockState(cursor).is(Blocks.SNOW)) { + chunk.setBlockState(cursor, materials.snow, false); + } + } for (int offset = 0; offset <= 2; offset++) { cursor.set(minX + localX, top - offset, minZ + localZ); BlockState state = chunk.getBlockState(cursor); diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldgenBenchmark.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldgenBenchmark.java index 0ceaeae3..3afc2945 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldgenBenchmark.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldgenBenchmark.java @@ -15,8 +15,10 @@ import net.minecraft.core.Registry; import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.core.registries.Registries; +import net.minecraft.gametest.framework.GameTestServer; import net.minecraft.resources.ResourceKey; import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerLevel; import net.minecraft.world.level.Level; import net.minecraft.world.level.block.Block; @@ -140,11 +142,19 @@ MODE, chunks, repetitions, format(median), format(median / chunks), throw new IllegalStateException("Benchmark fluid audit found no successful deposits"); } if (Boolean.getBoolean("orespawn.worldgenBenchmarkStopServer")) { - LOGGER.info("ORESPAWN_BENCHMARK stopping server after completed benchmark"); - event.getServer().halt(false); + if (ownsServerShutdown(event.getServer().getClass())) { + LOGGER.info("ORESPAWN_BENCHMARK stopping server after completed benchmark"); + event.getServer().halt(false); + } else { + LOGGER.info("ORESPAWN_BENCHMARK leaving shutdown to the GameTest harness"); + } } } + static boolean ownsServerShutdown(Class serverType) { + return !GameTestServer.class.isAssignableFrom(serverType); + } + static ResourceKey benchmarkDimensionKey(String configured) { String dimensionName = configured.trim().toLowerCase(Locale.ROOT); return switch (dimensionName) { diff --git a/src/test/java/com/mcmoddev/mineralogy/MineralogyConfig.java b/src/test/java/com/mcmoddev/mineralogy/MineralogyConfig.java new file mode 100644 index 00000000..a3529ea5 --- /dev/null +++ b/src/test/java/com/mcmoddev/mineralogy/MineralogyConfig.java @@ -0,0 +1,18 @@ +package com.mcmoddev.mineralogy; + +/** + * Test-only ABI bridge for the one configuration value read by the exact + * Mineralogy 5.4.0 Geology bytecode. The published configuration class cannot + * link on Minecraft 1.20.6 because several Minecraft and Forge types changed; + * the geology implementation itself is loaded unchanged from the sealed jar. + */ +public final class MineralogyConfig { + private static int geomLayerThickness = 1; + + private MineralogyConfig() { + } + + public static int geomLayerThickness() { + return geomLayerThickness; + } +} diff --git a/src/test/java/zone/moddev/mc/orespawn/ReleaseWorkflowContractTest.java b/src/test/java/zone/moddev/mc/orespawn/ReleaseWorkflowContractTest.java new file mode 100644 index 00000000..e9091b0f --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/ReleaseWorkflowContractTest.java @@ -0,0 +1,89 @@ +package zone.moddev.mc.orespawn; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Properties; + +import org.junit.jupiter.api.Test; + +class ReleaseWorkflowContractTest { + @Test + void usesOreSpawnSpecificMavenNamespace() throws Exception { + Properties properties = new Properties(); + try (InputStream input = Files.newInputStream(Paths.get("gradle.properties"))) { + properties.load(input); + } + assertEquals("zone.moddev.mc.orespawn", properties.getProperty("mod_group_id")); + } + + @Test + void declaresTheHostedSelectorForTheExactTemurinToolchain() throws Exception { + Properties properties = new Properties(); + try (InputStream input = Files.newInputStream(Paths.get("gradle.properties"))) { + properties.load(input); + } + assertEquals("21.0.7+6", properties.getProperty("java_toolchain_version")); + assertEquals("21.0.7+6.0.LTS", properties.getProperty("java_setup_version")); + } + + @Test + void verifiesGeneratedMavenCoordinatesBeforeCheckAndPublication() throws Exception { + Path buildFile = Paths.get("build.gradle"); + String build = new String(Files.readAllBytes(buildFile), StandardCharsets.UTF_8); + assertTrue(build.contains("tasks.register('verifyMavenCoordinates')")); + assertTrue(build.contains("generatePomFileForMavenJavaPublication")); + assertTrue(build.contains("dependsOn tasks.named('verifyMavenCoordinates')")); + assertTrue(build.contains("expectedMavenCoordinate")); + } + + @Test + void hostedWorkflowsUseOnlyThePinnedTemurinJdk() throws Exception { + for (String workflow : new String[] { "ci.yml", "codeql-analysis.yml" }) { + String text = new String(Files.readAllBytes( + Paths.get(".github", "workflows", workflow)), StandardCharsets.UTF_8); + int jobCount = workflow.equals("ci.yml") ? 2 : 1; + assertEquals(jobCount, occurrences(text, "actions/setup-java@")); + assertEquals(jobCount, occurrences(text, "distribution: temurin")); + assertEquals(jobCount, occurrences(text, "java-version: '21.0.7+6.0.LTS'")); + assertEquals(jobCount, occurrences(text, + "-Dorg.gradle.java.installations.paths=$JAVA_HOME")); + assertEquals(jobCount, occurrences(text, + "-Dorg.gradle.java.installations.auto-detect=false")); + assertEquals(jobCount, occurrences(text, + "-Dorg.gradle.java.installations.auto-download=false")); + if (workflow.equals("ci.yml")) { + assertEquals(2, occurrences(text, "\"${gradle_jdk_args[@]}\"")); + } + assertFalse(text.contains("25.0.3")); + assertFalse(text.contains("8.0.502")); + assertFalse(text.contains("MinecraftMavenizer")); + } + } + + @Test + void codeQlUsesABoundedCachePreservingCompileRetry() throws Exception { + String text = new String(Files.readAllBytes( + Paths.get(".github", "workflows", "codeql-analysis.yml")), StandardCharsets.UTF_8); + assertTrue(text.contains("gradle_args=(")); + assertTrue(text.contains("for attempt in 1 2 3; do")); + assertTrue(text.contains("./gradlew \"${gradle_args[@]}\"")); + assertTrue(text.contains("failed after $attempt attempts")); + } + + private static int occurrences(String text, String needle) { + int count = 0; + int offset = 0; + while ((offset = text.indexOf(needle, offset)) >= 0) { + count++; + offset += needle.length(); + } + return count; + } +} diff --git a/src/test/java/zone/moddev/mc/orespawn/api/OreSpawnGeologySamplerTest.java b/src/test/java/zone/moddev/mc/orespawn/api/OreSpawnGeologySamplerTest.java new file mode 100644 index 00000000..ec243a1a --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/api/OreSpawnGeologySamplerTest.java @@ -0,0 +1,19 @@ +package zone.moddev.mc.orespawn.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class OreSpawnGeologySamplerTest { + @Test + void convertsLevelHeightToTheGenerationBiomeHeight() { + assertEquals(96, OreSpawnGeologySampler.generationBiomeY(97, -64)); + assertEquals(-1, OreSpawnGeologySampler.generationBiomeY(0, -64)); + } + + @Test + void clampsAnEmptyColumnToTheLevelFloor() { + assertEquals(-64, OreSpawnGeologySampler.generationBiomeY(-64, -64)); + assertEquals(-64, OreSpawnGeologySampler.generationBiomeY(Integer.MIN_VALUE, -64)); + } +} diff --git a/src/test/java/zone/moddev/mc/orespawn/api/WorldgenProviderTest.java b/src/test/java/zone/moddev/mc/orespawn/api/WorldgenProviderTest.java index b690f7b1..ff7aa819 100644 --- a/src/test/java/zone/moddev/mc/orespawn/api/WorldgenProviderTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/api/WorldgenProviderTest.java @@ -13,6 +13,33 @@ import net.minecraft.resources.ResourceLocation; class WorldgenProviderTest { + @Test + void terrainHostContractRetainsNaturalSourceOrder() { + ResourceLocation dimension = id("surfaceprobe:the_end"); + WorldgenProvider provider = WorldgenProvider.builder("surfaceprobe", 1) + .terrainDimension(dimension, terrain -> terrain + .hostBlock(id("minecraft:dirt")) + .hostBlock(id("minecraft:grass_block")) + .hostBlock(id("minecraft:coarse_dirt")) + .hostBlock(id("minecraft:podzol")) + .hostBlock(id("minecraft:rooted_dirt")) + .hostBlock(id("minecraft:gravel")) + .hostBlock(id("minecraft:sand")) + .hostBlock(id("minecraft:red_sand")) + .hostBlock(id("minecraft:clay")) + .hostBlock(id("minecraft:terracotta"))) + .build(); + + assertEquals("[\"minecraft:dirt\",\"minecraft:grass_block\"," + + "\"minecraft:coarse_dirt\",\"minecraft:podzol\"," + + "\"minecraft:rooted_dirt\",\"minecraft:gravel\"," + + "\"minecraft:sand\",\"minecraft:red_sand\"," + + "\"minecraft:clay\",\"minecraft:terracotta\"]", + provider.toJson().getAsJsonObject("terrain_dimensions") + .getAsJsonObject(dimension.toString()) + .getAsJsonArray("host_blocks").toString()); + } + @Test void serializesTypedSchemaFourProvider() { ResourceLocation overworld = id("minecraft:overworld"); @@ -242,6 +269,68 @@ void serializesRangedQuantityAndBroadDimensionSelector() { assertFalse(rule.has("quantity")); } + @Test + void oreBiomeFiltersMatchFluidBuilderForDimensionsAndSelectors() { + ResourceLocation overworld = id("minecraft:overworld"); + ResourceLocation plains = id("minecraft:plains"); + ResourceLocation darkForest = id("minecraft:dark_forest"); + WorldgenProvider.OreDimensionDefinition explicit = WorldgenProvider.OreDimensionDefinition + .builder(overworld) + .enabled(false) + .hostTag(id("minecraft:stone_ore_replaceables")) + .biome(plains) + .biomeDictionary("FOREST") + .excludeBiome(darkForest) + .excludeBiomeDictionary("SPOOKY") + .build(); + WorldgenProvider.OreDimensionDefinition selector = WorldgenProvider.OreDimensionDefinition + .builder(OreDimensionSelector.ALL_EXCEPT_NETHER_AND_END.id()) + .hostTag(id("minecraft:stone_ore_replaceables")) + .biome(plains) + .biomeDictionary("FOREST") + .excludeBiome(darkForest) + .excludeBiomeDictionary("SPOOKY") + .build(); + + assertEquals(Collections.singleton(plains), explicit.biomeIds()); + assertEquals(Collections.singleton(darkForest), explicit.excludedBiomeIds()); + assertEquals(Collections.singleton("FOREST"), explicit.biomeDictionary()); + assertEquals(Collections.singleton("SPOOKY"), explicit.excludedBiomeDictionary()); + assertThrows(UnsupportedOperationException.class, + () -> explicit.biomeIds().add(id("minecraft:forest"))); + + WorldgenProvider provider = WorldgenProvider.builder("examplemod", 1) + .ore(id("examplemod:filtered_ore"), ore -> ore + .dimension(explicit) + .dimensionSelector(OreDimensionSelector.ALL_EXCEPT_NETHER_AND_END, + selector)) + .build(); + JsonObject ore = provider.toJson().getAsJsonObject("ores") + .getAsJsonObject("examplemod:ore/examplemod/filtered_ore"); + assertFalse(ore.getAsJsonObject("dimensions").getAsJsonObject(overworld.toString()) + .get("enabled").getAsBoolean()); + assertTrue(ore.getAsJsonObject("dimension_selectors").getAsJsonObject( + OreDimensionSelector.ALL_EXCEPT_NETHER_AND_END.id().toString()) + .get("enabled").getAsBoolean()); + for (JsonObject rule : new JsonObject[] { + ore.getAsJsonObject("dimensions").getAsJsonObject(overworld.toString()), + ore.getAsJsonObject("dimension_selectors").getAsJsonObject( + OreDimensionSelector.ALL_EXCEPT_NETHER_AND_END.id().toString()) }) { + assertEquals("[\"minecraft:plains\"]", rule.getAsJsonArray("biome_ids").toString()); + assertEquals("[\"minecraft:dark_forest\"]", + rule.getAsJsonArray("excluded_biome_ids").toString()); + assertEquals("[\"FOREST\"]", rule.getAsJsonArray("biome_dictionary").toString()); + assertEquals("[\"SPOOKY\"]", + rule.getAsJsonArray("excluded_biome_dictionary").toString()); + } + ore.getAsJsonObject("dimensions").getAsJsonObject(overworld.toString()) + .getAsJsonArray("biome_ids").add("minecraft:forest"); + assertEquals("[\"minecraft:plains\"]", provider.toJson().getAsJsonObject("ores") + .getAsJsonObject("examplemod:ore/examplemod/filtered_ore") + .getAsJsonObject("dimensions").getAsJsonObject(overworld.toString()) + .getAsJsonArray("biome_ids").toString()); + } + @Test void rejectsInvalidQuantityRangesEarly() { assertThrows(IllegalStateException.class, () -> WorldgenProvider.OreDimensionDefinition diff --git a/src/test/java/zone/moddev/mc/orespawn/client/ClientTextFieldPersistenceTest.java b/src/test/java/zone/moddev/mc/orespawn/client/ClientTextFieldPersistenceTest.java new file mode 100644 index 00000000..f468dc11 --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/client/ClientTextFieldPersistenceTest.java @@ -0,0 +1,54 @@ +package zone.moddev.mc.orespawn.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; + +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.network.chat.Component; + +class ClientTextFieldPersistenceTest { + private static final Path CLIENT_SOURCE = Paths.get( + "src", "main", "java", "zone", "moddev", "mc", "orespawn", "client"); + private static final Pattern VALUE_BEFORE_MAX_LENGTH = Pattern.compile( + "(?s)\\b([A-Za-z_$][A-Za-z0-9_$]*)\\.setValue\\([^;]*;" + + "\\s*\\1\\.setMaxLength\\("); + + @Test + void everyTextFieldSetsItsMaximumBeforeLoadingSavedText() throws Exception { + List unsafe = new ArrayList<>(); + try (Stream files = Files.list(CLIENT_SOURCE)) { + for (Path source : (Iterable) files + .filter(path -> path.getFileName().toString().endsWith(".java"))::iterator) { + String text = new String(Files.readAllBytes(source), StandardCharsets.UTF_8); + if (VALUE_BEFORE_MAX_LENGTH.matcher(text).find()) { + unsafe.add(source.getFileName().toString()); + } + } + } + + assertTrue(unsafe.isEmpty(), + "Text fields must set their maximum length before loading saved text: " + unsafe); + } + + @Test + void targetTextFieldRetainsAValueLongerThanTheVanillaDefault() { + String value = "minecraft:stone,minecraft:granite,minecraft:diorite,minecraft:andesite"; + EditBox field = new EditBox(null, 0, 0, 200, 20, Component.literal("host_blocks")); + + field.setMaxLength(1024); + field.setValue(value); + + assertEquals(value, field.getValue()); + } +} diff --git a/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java b/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java index 2f7f9548..d3f0cd47 100644 --- a/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java @@ -28,6 +28,29 @@ void emptyStandaloneProfileIsValidAndFirstRockActivatesOverworldTerrain() { assertTrue(overworld.getAsJsonArray("host_blocks").toString().contains("minecraft:deepslate")); } + @Test + void namespacedGeomesCanBeAddedValidatedAndRoundTripped() { + String geomeId = "cakeworld:cocoa_basin"; + GeologyEditorSession session = new GeologyEditorSession(WorldGeologyProfile.recommended(false)); + session.configureDefaultVanillaStrata(); + session.addGeome(geomeId); + + assertTrue(session.section("geomes").has(geomeId)); + session.weightMap("biomes", "minecraft:plains").addProperty(geomeId, 2.0D); + session.rock("minecraft:stone").getAsJsonObject("geomes").addProperty(geomeId, 3.0D); + java.util.List errors = session.validate(); + assertTrue(errors.isEmpty(), errors.toString()); + + WorldGeologyProfile saved = session.profile(); + GeologyEditorSession reopened = new GeologyEditorSession(saved); + assertEquals(saved.rootCopy(), reopened.profile().rootCopy()); + assertTrue(reopened.validate().isEmpty(), reopened.validate().toString()); + assertEquals(2.0D, reopened.weightMap("biomes", "minecraft:plains") + .get(geomeId).getAsDouble()); + assertEquals(3.0D, reopened.rock("minecraft:stone").getAsJsonObject("geomes") + .get(geomeId).getAsDouble()); + } + @Test void firstUseStrataStartsWithBalancedVanillaRocks() { GeologyEditorSession session = new GeologyEditorSession(WorldGeologyProfile.recommended(false)); diff --git a/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java b/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java index 74b2f522..f946fe62 100644 --- a/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java @@ -4,9 +4,13 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.ArrayList; +import java.util.List; + import org.junit.jupiter.api.Test; import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.network.chat.Component; class OreSpawnScreenLayoutTest { @Test @@ -16,6 +20,27 @@ void sharedScreenOwnsTheFinalRenderOrder() throws NoSuchMethodException { .getModifiers())); } + @Test + void sharedScreenUsesTargetNativeBackgroundBeforeForeground() { + List passes = new ArrayList<>(); + OreSpawnScreen screen = new OreSpawnScreen(Component.empty()) { + @Override + public void renderBackground(GuiGraphics graphics, int mouseX, int mouseY, + float partialTick) { + passes.add("background"); + } + + @Override + protected void renderForeground(GuiGraphics graphics, int mouseX, int mouseY, + float partialTick) { + passes.add("foreground"); + } + }; + + screen.render(null, 0, 0, 0.0F); + assertEquals(List.of("background", "foreground"), passes); + } + @Test void customScreenTextColorsAreFullyOpaque() { int[] colors = { diff --git a/src/test/java/zone/moddev/mc/orespawn/documentation/DocumentationExporterTest.java b/src/test/java/zone/moddev/mc/orespawn/documentation/DocumentationExporterTest.java index f477f277..f3cf94ad 100644 --- a/src/test/java/zone/moddev/mc/orespawn/documentation/DocumentationExporterTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/documentation/DocumentationExporterTest.java @@ -1,11 +1,14 @@ package zone.moddev.mc.orespawn.documentation; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -17,16 +20,23 @@ class DocumentationExporterTest { @Test void exportsCompleteGuideAndDoesNotOverwriteExistingFiles() throws Exception { int firstExport = DocumentationExporter.exportMissing(temporaryDirectory); - assertTrue(firstExport >= 19); - assertTrue(Files.isRegularFile(temporaryDirectory.resolve("README.md"))); - assertTrue(Files.isRegularFile(temporaryDirectory.resolve("DEVELOPER_GUIDE.md"))); - assertTrue(Files.isRegularFile(temporaryDirectory.resolve("BIOMES.md"))); - assertTrue(Files.isRegularFile(temporaryDirectory.resolve("examples/examplemod-orespawn.json"))); - assertTrue(Files.isRegularFile(temporaryDirectory.resolve("schemas/orespawn-provider.schema.json"))); + Set trackedFiles = relativeFiles(Paths.get("docs"), Paths.get("docs")); + Set exportedFiles = relativeFiles(temporaryDirectory, temporaryDirectory); + assertEquals(trackedFiles.size(), firstExport); + assertEquals(trackedFiles, exportedFiles); Path readme = temporaryDirectory.resolve("README.md"); Files.write(readme, "local note".getBytes(StandardCharsets.UTF_8)); assertEquals(0, DocumentationExporter.exportMissing(temporaryDirectory)); assertEquals("local note", new String(Files.readAllBytes(readme), StandardCharsets.UTF_8)); } + + private static Set relativeFiles(Path root, Path current) throws Exception { + try (Stream paths = Files.walk(current)) { + return paths.filter(Files::isRegularFile) + .map(root::relativize) + .map(path -> path.toString().replace('\\', '/')) + .collect(Collectors.toSet()); + } + } } diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/BiomeSurfaceFeatureOrderTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/BiomeSurfaceFeatureOrderTest.java index 14dc4115..7fab737c 100644 --- a/src/test/java/zone/moddev/mc/orespawn/worldgen/BiomeSurfaceFeatureOrderTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/BiomeSurfaceFeatureOrderTest.java @@ -14,6 +14,7 @@ class BiomeSurfaceFeatureOrderTest { @Test void surfacesRunBeforeStructuresAndVegetationWhileFlatBedrockStaysLast() { + StoneReplacer.registerConfiguredFeature(); BiomeSurfaceFeature.registerConfiguredFeature(); FlatBedrockFeature.registerConfiguredFeature(); BiomeGenerationSettingsBuilder generation = @@ -25,7 +26,9 @@ void surfacesRunBeforeStructuresAndVegetationWhileFlatBedrockStaysLast() { Holder bedrock = FlatBedrockFeature.placedFeature(); var local = generation.getFeatures(GenerationStep.Decoration.LOCAL_MODIFICATIONS); var top = generation.getFeatures(GenerationStep.Decoration.TOP_LAYER_MODIFICATION); - assertTrue(local.stream().anyMatch(feature -> feature.value() == surfaces.value())); + assertTrue(local.size() >= 2); + assertTrue(local.get(0).value() == StoneReplacer.placedFeature().value()); + assertTrue(local.get(1).value() == surfaces.value()); assertFalse(local.stream().anyMatch(feature -> feature.value() == bedrock.value())); assertTrue(top.stream().anyMatch(feature -> feature.value() == bedrock.value())); assertFalse(top.stream().anyMatch(feature -> feature.value() == surfaces.value())); diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/GeomeTransitionTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/GeomeTransitionTest.java index e8496fb9..466a0834 100644 --- a/src/test/java/zone/moddev/mc/orespawn/worldgen/GeomeTransitionTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/GeomeTransitionTest.java @@ -6,10 +6,17 @@ import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; +import java.util.Set; import org.junit.jupiter.api.Test; import net.minecraft.resources.ResourceLocation; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.biome.BiomeGenerationSettings; +import net.minecraft.world.level.biome.BiomeSpecialEffects; +import net.minecraft.world.level.biome.MobSpawnSettings; import net.minecraft.world.level.block.Blocks; import zone.moddev.mc.orespawn.worldgen.BakedGeomeConfig.GeomeDefinition; @@ -30,6 +37,35 @@ void configuredBiomeWeightsWorkWithoutAForgeBiomeRegistryEntry() { assertEquals(1, config.pickGeome(null, WINDSWEPT_HILLS, new double[2], 0.0D)); } + @Test + void explicitBiomeIdentifierWinsOverAliasedBiomeObjectIdentity() { + Biome aliasedBiome = testBiome(); + ResourceLocation dynamicId = new ResourceLocation("cakeworld", "peppermint_pinewoods"); + double[] identityWeights = { 12.0D, 1.0D }; + double[] identifierWeights = { 1.0D, 12.0D }; + BakedGeomeConfig config = config(Map.of(aliasedBiome, identityWeights), + Map.of(dynamicId, identifierWeights)); + + assertEquals(1, config.pickGeome(aliasedBiome, dynamicId, new double[2], 0.0D), + "a stable dynamic biome key must override a conflicting object-identity alias"); + } + + @Test + void identifierFallbackRetainsDictionaryWeightContributions() { + Map indexes = new LinkedHashMap<>(); + indexes.put("cakeworld:peppermint_fold", 0); + indexes.put("cakeworld:rock_candy_uplift", 1); + ResourceLocation marshmallowPeaks = new ResourceLocation("cakeworld", "marshmallow_peaks"); + Map weights = GeomeConfig.bakeBiomeIdentifierWeights(indexes, + Map.of(marshmallowPeaks.toString(), new double[] { 6.0D, 14.0D }), + Map.of("COLD", new double[] { 8.0D, 0.0D }), + type -> Set.of(ResourceKey.create(Registries.BIOME, marshmallowPeaks))); + + // The COLD dictionary rule contributes another 8 to Peppermint Fold. + assertEquals(15.0D, weights.get(marshmallowPeaks)[0]); + assertEquals(15.0D, weights.get(marshmallowPeaks)[1]); + } + @Test void savedWorldBoundaryUsesItsConfiguredBiomeInsteadOfEqualFallbackWeights() { BakedGeomeConfig config = observedWorldConfig(); @@ -77,6 +113,11 @@ void transitionBandUsesBothGeomesButKeepsClearDominanceOutsideIt() { } private static BakedGeomeConfig config(Map biomeWeightsById) { + return config(Collections.emptyMap(), biomeWeightsById); + } + + private static BakedGeomeConfig config(Map biomeWeights, + Map biomeWeightsById) { double[] familyWeights = { 1.0D, 1.0D, 1.0D, 1.0D }; GeomeDefinition[] geomes = { new GeomeDefinition("orespawn:first", 1.0D, familyWeights.clone()), @@ -89,7 +130,24 @@ private static BakedGeomeConfig config(Map biomeWeig FormationSettings formations = new FormationSettings(FormationSettings.Algorithm.STABLE_LAYERS, 256.0D, 100.0D, 8, 48.0D, 64.0D, 12.0D, 2, 0.85D); return new BakedGeomeConfig(geomes, 384.0D, 1.15D, 0.9D, 0.45D, - Collections.emptyMap(), biomeWeightsById, rocks, formations); + biomeWeights, biomeWeightsById, rocks, formations); + } + + private static Biome testBiome() { + BiomeSpecialEffects effects = new BiomeSpecialEffects.Builder() + .fogColor(0xC0D8FF) + .waterColor(0x3F76E4) + .waterFogColor(0x050533) + .skyColor(0x78A7FF) + .build(); + return new Biome.BiomeBuilder() + .hasPrecipitation(false) + .temperature(0.5F) + .downfall(0.5F) + .specialEffects(effects) + .mobSpawnSettings(MobSpawnSettings.EMPTY) + .generationSettings(BiomeGenerationSettings.EMPTY) + .build(); } private static BakedGeomeConfig observedWorldConfig() { diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyGeologyParityTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyGeologyParityTest.java index 6f5c3680..68708546 100644 --- a/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyGeologyParityTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyGeologyParityTest.java @@ -45,38 +45,35 @@ void cyanoSamplerMatchesPublishedMineralogy540AndSealedVectors() throws Exceptio MessageDigest sealed = MessageDigest.getInstance("SHA-256"); String configuredPath = System.getProperty("orespawn.mineralogy5Oracle", ""); - Path oracle = configuredPath.trim().isEmpty() ? null : Paths.get(configuredPath); - PublishedMineralogy published = oracle != null && Files.isRegularFile(oracle) - ? PublishedMineralogy.open(oracle) : null; + assertTrue(!configuredPath.trim().isEmpty(), + "The direct published Mineralogy 5.4.0 oracle is mandatory"); + Path oracle = Paths.get(configuredPath); + assertTrue(Files.isRegularFile(oracle), "Configured Mineralogy oracle is missing: " + oracle); + PublishedMineralogy published = PublishedMineralogy.open(oracle); try { - if (published != null) published.configure(9, igneous, metamorphic, sedimentary); + published.configure(9, igneous, metamorphic, sedimentary); for (long seed : new long[] { 0L, -4965128775892001975L }) { Geology os4 = new Geology(seed, 128.0D, 37.25D, 9, false, states(igneous), states(metamorphic), states(sedimentary)); - PublishedSampler sampler = published == null ? null : published.newSampler(seed, 128.0D, 37.25D); + PublishedSampler sampler = published.newSampler(seed, 128.0D, 37.25D); for (int x : new int[] { -1025, -257, -1, 0, 1, 255, 1024 }) { for (int z : new int[] { -1025, -257, -1, 0, 1, 255, 1024 }) { for (int y = 0; y < 256; y += 7) { Block actual = os4.getStoneAt(x, y, z); update(sealed, seed, x, y, z, actual); - if (sampler != null) { - assertEquals(sampler.getStoneAt(x, y, z), actual, - "Published Mineralogy 5.4.0 mismatch at " - + seed + ":" + x + ":" + y + ":" + z); - } + assertEquals(sampler.getStoneAt(x, y, z), actual, + "Published Mineralogy 5.4.0 mismatch at " + + seed + ":" + x + ":" + y + ":" + z); } } } } } finally { - if (published != null) published.close(); + published.close(); } assertEquals(SEALED_VECTOR_SHA256, hex(sealed.digest()), "The sealed vector digest is generated from the exact published Mineralogy 5.4.0 sampler"); - if (oracle != null) { - assertTrue(Files.isRegularFile(oracle), "Configured Mineralogy oracle is missing: " + oracle); - } } private static void update(MessageDigest digest, long seed, int x, int y, int z, Block block) { diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGenerationTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGenerationTest.java index 920b6155..f4a66545 100644 --- a/src/test/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGenerationTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGenerationTest.java @@ -10,6 +10,9 @@ import java.util.Map; import java.util.Set; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + import org.junit.jupiter.api.Test; import net.minecraft.resources.ResourceKey; @@ -18,6 +21,43 @@ import net.minecraft.world.level.Level; class OreSpawnOreGenerationTest { + @Test + void biomeFiltersRetainUnknownDynamicRegistryKeys() { + ResourceKey sodaOcean = ResourceKey.create(Registries.BIOME, + new ResourceLocation("cakeworld", "soda_ocean")); + JsonObject rule = new JsonObject(); + JsonArray ids = new JsonArray(); + ids.add("cakeworld:soda_ocean"); + rule.add("biome_ids", ids); + + Set resolved = OreSpawnOreGeneration.resolveBiomes( + rule, "biome_ids", "biome_dictionary"); + + assertEquals(Set.of(sodaOcean), resolved); + } + + @Test + void biomeFiltersMergeDictionaryKeys() { + ResourceKey sodaOcean = ResourceKey.create( + Registries.BIOME, new ResourceLocation("cakeworld", "soda_ocean")); + JsonObject rule = new JsonObject(); + JsonArray dictionary = new JsonArray(); + dictionary.add("OCEAN"); + rule.add("biome_dictionary", dictionary); + + Set> resolved = + OreSpawnOreGeneration.resolveBiomes(rule, "biome_ids", "biome_dictionary", + type -> Set.of(sodaOcean)); + + assertEquals(Set.of(sodaOcean), resolved); + assertTrue(OreSpawnOreGeneration.acceptsBiome(resolved, Set.of(), sodaOcean)); + assertFalse(OreSpawnOreGeneration.acceptsBiome(resolved, Set.of(), ResourceKey.create( + Registries.BIOME, new ResourceLocation("cakeworld", "candy_plains")))); + assertFalse(OreSpawnOreGeneration.acceptsBiome(Set.of(), resolved, sodaOcean)); + assertTrue(OreSpawnOreGeneration.acceptsBiome(Set.of(), resolved, ResourceKey.create( + Registries.BIOME, new ResourceLocation("cakeworld", "candy_plains")))); + } + @Test void fixedQuantityDoesNotConsumeRandomState() { CountingRandom random = new CountingRandom(0); diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/StableLayerHeightEligibilityTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/StableLayerHeightEligibilityTest.java new file mode 100644 index 00000000..3e33d9f5 --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/StableLayerHeightEligibilityTest.java @@ -0,0 +1,49 @@ +package zone.moddev.mc.orespawn.worldgen; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Collections; + +import org.junit.jupiter.api.Test; + +import net.minecraft.SharedConstants; +import net.minecraft.server.Bootstrap; +import net.minecraft.world.level.block.Blocks; + +import zone.moddev.mc.orespawn.worldgen.BakedGeomeConfig.GeomeDefinition; +import zone.moddev.mc.orespawn.worldgen.BakedGeomeConfig.RockEntry; + +class StableLayerHeightEligibilityTest { + static { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + void rockBoundsUseActualWorldYWhileFormationIdentityRemainsShifted() { + BakedGeomeConfig config = netherFloorConfig(); + GeomeGeology geology = new GeomeGeology(0L, config); + double[] geomeScores = { 1.0D }; + + assertEquals(Blocks.BASALT, + geology.getStoneAt(0, geomeScores, -64, 0L, 0, 1, 0), + "a legal Nether Y must not fall back to Stone when waviness shifts its formation below min_y"); + assertEquals(Blocks.STONE, + geology.getStoneAt(0, geomeScores, 64, 0L, 0, -1, 0), + "a shifted formation inside the range must not make an illegal actual Y eligible"); + } + + private static BakedGeomeConfig netherFloorConfig() { + GeomeDefinition[] geomes = { + new GeomeDefinition("test:nether", 1.0D, new double[] { 0.0D, 0.0D, 0.0D, 1.0D }) + }; + RockEntry[] rocks = { + new RockEntry(Blocks.BASALT.defaultBlockState(), RockFamily.IGNEOUS_VOLCANIC, + 24, 68, 0, 127, 1.0D, true, new double[] { 1.0D }) + }; + FormationSettings formations = new FormationSettings(FormationSettings.Algorithm.STABLE_LAYERS, + 32.0D, 8192.0D, 8, 512.0D, 96.0D, 24.0D, 3, 0.85D); + return new BakedGeomeConfig(geomes, 384.0D, 1.15D, 0.9D, 0.45D, + Collections.emptyMap(), Collections.emptyMap(), rocks, formations); + } +} diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/StoneReplacerTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/StoneReplacerTest.java index f54290d8..7f30157d 100644 --- a/src/test/java/zone/moddev/mc/orespawn/worldgen/StoneReplacerTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/StoneReplacerTest.java @@ -3,12 +3,17 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.Collections; +import java.util.LinkedHashSet; + import org.junit.jupiter.api.Test; import net.minecraft.core.registries.Registries; import net.minecraft.resources.ResourceKey; import net.minecraft.resources.ResourceLocation; import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; class StoneReplacerTest { @Test @@ -38,4 +43,22 @@ void explicitlyConfiguredCustomDimensionsCanSuppressMatchingStoneFeatures() { assertTrue(TerrainFeaturePolicy.shouldSuppressVanillaMatchingStoneFeature( moon, true, true)); } + + @Test + void invalidTerrainHostsRemainUnsafeEvenWhenDeclared() { + LinkedHashSet hosts = new LinkedHashSet<>(); + hosts.add(Blocks.AIR); + hosts.add(Blocks.WATER); + hosts.add(Blocks.BEDROCK); + hosts.add(Blocks.DIRT); + BakedTerrainDimension terrain = new BakedTerrainDimension( + ResourceKey.create(Registries.DIMENSION, + new ResourceLocation("surfaceprobe", "the_end")), + Collections.emptySet(), Collections.emptySet(), hosts); + + assertFalse(terrain.isReplaceable(Blocks.AIR.defaultBlockState())); + assertFalse(terrain.isReplaceable(Blocks.WATER.defaultBlockState())); + assertFalse(terrain.isReplaceable(Blocks.BEDROCK.defaultBlockState())); + assertTrue(terrain.isReplaceable(Blocks.DIRT.defaultBlockState())); + } } diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/TerrainBiomeLookupTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/TerrainBiomeLookupTest.java new file mode 100644 index 00000000..9979e613 --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/TerrainBiomeLookupTest.java @@ -0,0 +1,27 @@ +package zone.moddev.mc.orespawn.worldgen; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.Test; + +class TerrainBiomeLookupTest { + @Test + void geologyAndSamplerHeightsResolveThroughTheSameQuartBiome() { + AtomicReference coordinates = new AtomicReference<>(); + assertNull(TerrainBiomeLookup.atBlock((x, y, z) -> { + coordinates.set(x + "," + y + "," + z); + return null; + }, 13, 62, -32)); + assertEquals("3,15,-8", coordinates.get()); + + assertNull(TerrainBiomeLookup.atBlock((x, y, z) -> { + coordinates.set(x + "," + y + "," + z); + return null; + }, 13, 63, -32)); + assertEquals("3,15,-8", coordinates.get(), + "later surface work must not move an adjacent height into a fuzzy biome cell"); + } +} diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/WorldgenBenchmarkTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/WorldgenBenchmarkTest.java index da17219b..eb13ece9 100644 --- a/src/test/java/zone/moddev/mc/orespawn/worldgen/WorldgenBenchmarkTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/WorldgenBenchmarkTest.java @@ -5,9 +5,11 @@ import org.junit.jupiter.api.Test; +import net.minecraft.gametest.framework.GameTestServer; import net.minecraft.core.registries.Registries; import net.minecraft.resources.ResourceKey; import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.MinecraftServer; import net.minecraft.world.level.Level; class WorldgenBenchmarkTest { @@ -26,4 +28,10 @@ void rejectsInvalidCustomDimensionIds() { assertThrows(IllegalArgumentException.class, () -> WorldgenBenchmark.benchmarkDimensionKey("not a dimension")); } + + @Test + void leavesGameTestHarnessInControlOfServerShutdown() { + assertEquals(false, WorldgenBenchmark.ownsServerShutdown(GameTestServer.class)); + assertEquals(true, WorldgenBenchmark.ownsServerShutdown(MinecraftServer.class)); + } }