diff --git a/.gitattributes b/.gitattributes index d8b984a8..1d4bd30d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -11,6 +11,7 @@ mcmod.info text *.lang text *.mcmeta text *.md text +*.patch text eol=lf -diff *.properties text gradlew text eol=lf *.sh text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7fc461d3..977a28e0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,24 +1,153 @@ -name: CI +name: OreSpawn 1.21.11 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.21.11 + - 'feature/**' + pull_request: + branches: + - master-1.21.11 + +permissions: + contents: read + +concurrency: + group: orespawn-1.21.11-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: + cold-forge-bootstrap: + name: Cold Forge bootstrap + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Install pinned Java 25 Mavenizer runtime + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '25.0.3+9.0.LTS' + + - name: Install pinned Java 8 launcher toolchain + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '8.0.502+7' + + - name: Install pinned Java 21 runtime and toolchain + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '21.0.7+6.0.LTS' + + - name: Bootstrap Forge from an empty cache + shell: bash + env: + GRADLE_USER_HOME: ${{ runner.temp }}/orespawn-cold-gradle + run: | + set -euo pipefail + test ! -e .gradle + test ! -e "$GRADLE_USER_HOME" + mkdir -p "$GRADLE_USER_HOME" + chmod +x ./gradlew + ./gradlew verifyMavenizerCompatibilityFixture help \ + --no-daemon --no-build-cache --stacktrace --max-workers=2 \ + -Dorg.gradle.java.installations.paths="$JAVA_HOME,$JAVA_HOME_8_X64,$JAVA_HOME_25_X64" \ + -Dorg.gradle.java.installations.auto-detect=false \ + -Dorg.gradle.java.installations.auto-download=false \ + | tee "$RUNNER_TEMP/cold-forge-bootstrap.log" + grep -F "OreSpawn Mavenizer compatibility runtime: Java 25.0.3" \ + "$RUNNER_TEMP/cold-forge-bootstrap.log" + grep -F "OreSpawn Mavenizer compatibility: applied 0 rule(s) for net.minecraftforge:forge:1.21.11-61.1.0 (explicit no-op)" \ + "$RUNNER_TEMP/cold-forge-bootstrap.log" + find "$GRADLE_USER_HOME" .gradle/mavenizer \ + -name '*.orespawn-compatibility' -type f -print0 \ + | sort -z | xargs -0 -r sha256sum > "$RUNNER_TEMP/markers-before.sha256" + test -s "$RUNNER_TEMP/markers-before.sha256" + + - name: Verify same-cache preparation is idempotent + shell: bash + env: + GRADLE_USER_HOME: ${{ runner.temp }}/orespawn-cold-gradle + run: | + set -euo pipefail + ./gradlew verifyMavenizerCompatibilityFixture help \ + --no-daemon --no-build-cache --stacktrace --max-workers=2 \ + -Dorg.gradle.java.installations.paths="$JAVA_HOME,$JAVA_HOME_8_X64,$JAVA_HOME_25_X64" \ + -Dorg.gradle.java.installations.auto-detect=false \ + -Dorg.gradle.java.installations.auto-download=false + find "$GRADLE_USER_HOME" .gradle/mavenizer \ + -name '*.orespawn-compatibility' -type f -print0 \ + | sort -z | xargs -0 -r sha256sum > "$RUNNER_TEMP/markers-after.sha256" + diff -u "$RUNNER_TEMP/markers-before.sha256" "$RUNNER_TEMP/markers-after.sha256" + 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 - with: - java-version: 8 - - run: chmod a+x gradlew - - run: ./gradlew --version --no-daemon - - run: ./gradlew setupCIWorkspace -S - - run: ./gradlew clean build -S + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Install pinned Java 25 Mavenizer runtime + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '25.0.3+9.0.LTS' + + - name: Install pinned Java 8 launcher toolchain + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '8.0.502+7' + + - name: Install pinned Java 21 runtime and toolchain + 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: Make the wrapper executable + run: chmod +x ./gradlew + + - name: Build, test, and audit release artifacts + run: >- + ./gradlew clean check build javadoc verifyReleaseArtifacts writeReleaseChecksums + verifyEclipseProductionClasspath --no-daemon --stacktrace + -Dorg.gradle.java.installations.paths="$JAVA_HOME,$JAVA_HOME_8_X64,$JAVA_HOME_25_X64" + -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.21.11-${{ github.sha }} + if-no-files-found: error + retention-days: 30 + path: | + build/libs/OreSpawn-4.0.16.121111.jar + build/libs/OreSpawn-4.0.16.121111-sources.jar + build/libs/OreSpawn-4.0.16.121111-javadoc.jar + build/release/SHA256SUMS + CHANGELOG.txt + + - name: Upload diagnostics on failure + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: OreSpawn-1.21.11-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..8bcc9782 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -1,73 +1,76 @@ -# 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" +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' +on: + push: + branches: + - master-1.21.11 + - 'feature/**' + pull_request: + branches: + - master-1.21.11 + 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 + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Install pinned Java 25 Mavenizer runtime + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '25.0.3+9.0.LTS' - # 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 + - name: Install pinned Java 8 launcher toolchain + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '8.0.502+7' - # 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 + - name: Install pinned Java 21 runtime and toolchain + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '21.0.7+6.0.LTS' - # â„šī¸ Command-line programs to run using the OS shell. - # 📚 https://git.io/JvXDl + - name: Set up Gradle + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6 - # âœī¸ 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 + - name: Initialize CodeQL + uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4 + with: + languages: java-kotlin - #- run: | - # make bootstrap - # make release + - name: Compile production code + run: | + chmod +x ./gradlew + gradle_args=( + clean classes --no-daemon --stacktrace + "-Dorg.gradle.java.installations.paths=$JAVA_HOME,$JAVA_HOME_8_X64,$JAVA_HOME_25_X64" + -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 Forge cache." + done - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 + - 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..b37dfc94 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.21.11 + - 'feature/**' + pull_request: + branches: + - master-1.21.11 + +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 eb4b7ce6..d7c2e5b2 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,79 @@ +Version 4.0.16.121111 + +* Leave benchmark shutdown to the GameTest harness when a benchmark is run + through Forge'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.121111 + +* 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.121111 + +* 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.121111 + +* 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.121111 + +* 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.121111 + +* 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.121111 + +* Evaluate Stable Layers rock min_y and max_y bounds against actual world Y + instead of the vertically shifted formation coordinate. +* Preserve the shifted formation identity for layer, family, and rock choice + while preventing vanilla Stone fallback near dimension floors and ceilings. +* Apply the correction only while generating new chunks; existing chunks and + saved profiles remain unchanged. + +Version 4.0.9.121111 + +* 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 block + 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.121111 + +* Preserve long host, tag, and biome-list values when OreSpawn editors load + and save an existing profile without user changes. +* Add reproducible ForgeGradle 7 builds, audited release artifacts, SHA-256 + checksums, Buildship launches, and guarded release automation. +* Export the complete bundled guide, including the shared version policy, to + the player-facing configuration folder. +* Forge 1.12.2's 4.0.7 packaged access-transformer repair is target-specific + and is not applicable to Forge 1.21.11. + Version 4.0.6.121111 * Adopt target-qualified four-component versions so Minecraft and loader compatibility can be identified from the mod version. @@ -8,15 +84,13 @@ Version 4.0.6.121111 * Fix provider top and filler materials being generated one block below exposed ground. * Apply underwater materials from the corrected ground and ceiling materials to roof undersides. * Preserve trees, vegetation, structures and block entities by running surface replacement before late features. -* Honour exact biome-to-geome weights on dynamic biome registries. -* Stagger close Stable Layers geome transitions by layer instead of changing a whole rock column at one boundary. -* Recalibrate Stable Layers edge-detail presets so Average retains natural variation at later rock contacts. * Existing chunks are not rewritten; the correction applies while generating new chunks. Version 4.0.5 * Complete native translations for every shipped non-English locale * Add automatic fresh-and-reload validation for provider-owned custom biomes +* Verify both public biome-registration helpers on Forge 61 * Correct Minecraft 1.21.11 Identifier documentation and ForgeGradle workflow * Preserve public API major 1 and provider/global/world schemas 4/6/5 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 143b0610..ed920c19 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,8 @@ +[![Discord](https://img.shields.io/badge/Discord-MMD-green.svg?style=flat&logo=Discord)](https://discord.moddev.zone) +[![CurseForge downloads](https://cf.way2muchnoise.eu/full_mmd-orespawn_downloads.svg)](https://www.curseforge.com/minecraft/mc-mods/mmd-orespawn) +[![Supported Minecraft versions](https://cf.way2muchnoise.eu/versions/Minecraft_mmd-orespawn_all.svg)](https://www.curseforge.com/minecraft/mc-mods/mmd-orespawn) +[![Build, test, and audit](https://github.com/MinecraftModDevelopmentMods/OreSpawn/actions/workflows/ci.yml/badge.svg?branch=master-1.21.11)](https://github.com/MinecraftModDevelopmentMods/OreSpawn/actions/workflows/ci.yml?query=branch%3Amaster-1.21.11) + # MMD OreSpawn OreSpawn 4 is a provider-driven world-generation engine for Minecraft 1.21.11. @@ -12,6 +17,10 @@ End" policy used by mods such as Base Metals. This is not the unrelated mod that adds mobs and dimensions under the same name. +This branch builds target-qualified version `4.0.16.121111`: the OreSpawn 4.0.16 +feature set for Minecraft 1.21.11 and Forge. See the +[versioning policy](docs/VERSIONS.md) for the encoding and release convention. + ## What Happens When It Is Installed? OreSpawn is deliberately passive on its own. It does not replace stone, remove @@ -44,9 +53,6 @@ Important files: Profile edits affect newly generated chunks. Ore and flat-bedrock retrogen are separate opt-in features; OreSpawn never retro-generates rock strata. -Stable Layers honours exact biome-ID geome influences on dynamic biome -registries and spreads close geome transitions across layers rather than -changing an entire vertical rock column at one boundary. When an already-generated world has saved Mineralogy 1.10, 1.12, or 5.x mod metadata but no OreSpawn world profile, OreSpawn reads the matching published @@ -89,23 +95,29 @@ exported to `config/orespawn-guide/` without overwriting existing files. ## Building -Use Java 21 from the repository root: +Run Gradle with Java 21 from the repository root. Install the exact Temurin +`21.0.7+6` toolchain used to compile production code and test fixtures for +Minecraft 1.21.11; the build rejects a different Java 21 toolchain: ```powershell -.\gradlew.bat clean build javadoc --no-daemon -.\gradlew.bat genEclipseRuns --no-daemon +.\gradlew.bat clean check build javadoc verifyReleaseArtifacts writeReleaseChecksums --no-daemon +.\gradlew.bat genEclipseRuns verifyEclipseProductionClasspath --no-daemon ``` `build` runs the standard `check` lifecycle. In addition to the JUnit suite, that lifecycle packages a test-only provider mod and verifies exposed, underwater, filler, and ceiling surfaces in open and ceiling normal-noise dimensions. It also proves later vegetation, structures, and block entities -survive, verifies identifier-weighted geology in a dynamic custom biome, then -reopens and checks the exact saved world. The fixture is not included in -OreSpawn's published jars. - -Import or refresh the project with Eclipse Buildship. ForgeGradle 7's legacy -`eclipse` task produces Java-only metadata and must not be used for this branch. +survive, then reopens and checks the exact saved world. The fixture is not +included in OreSpawn's published jars. + +Import or refresh the project with Eclipse Buildship, then run +`genEclipseRuns` and `verifyEclipseProductionClasspath`. This branch uses +ForgeGradle 7.0.34, the Gradle 9.6.1 wrapper, Forge 61.1.0, official Minecraft +1.21.11 mappings, data pack format 94.1, and resource pack format 75.0. Ordinary +Eclipse launches exclude tests and fixtures. Published jars are deterministic and retain Forge 61's official +runtime names, are audited for their four access-transformer rules and contents, and +accompanied by SHA-256 checksums. Machine-specific `AGENTS.md` and `agent-notes/` files are intentionally ignored. Public developer and AI integration guidance lives in `docs/` and is included diff --git a/build.gradle b/build.gradle index 936d563e..1361d281 100644 --- a/build.gradle +++ b/build.gradle @@ -1,32 +1,150 @@ +import groovy.json.JsonSlurper +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 'java' id 'eclipse' id 'idea' id 'maven-publish' - id 'net.minecraftforge.gradle' version '[7.0.3,8)' + id 'net.minecraftforge.accesstransformers' version '2.0.0' + id 'net.minecraftforge.gradle' version '7.0.34' +} + +def sha256Of = { File inputFile -> + MessageDigest digest = MessageDigest.getInstance('SHA-256') + inputFile.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() +} + +def mavenizerToolsDirectory = layout.projectDirectory.dir('ci-fixtures/tools') +def mavenizerCompatibilityJar = mavenizerToolsDirectory.file( + 'minecraft-mavenizer-0.5.21-orespawn-compat.jar') +def mavenizerRuleManifest = mavenizerToolsDirectory.file( + 'minecraft-source-compatibility.json') +def mavenizerSourcePatch = mavenizerToolsDirectory.file( + 'minecraft-mavenizer-0.5.21-orespawn-compat.patch') +def mavenizerLicense = mavenizerToolsDirectory.file('LICENSE-MAVENIZER.txt') +def mavenizerReadme = mavenizerToolsDirectory.file('README.md') +def mavenizerFixtureChecksums = [ + (mavenizerCompatibilityJar.asFile): + 'B36CA046DB72E30F27BD38E02B4AE34274ADB51AE1D65BAF9E1D07763755682E', + (mavenizerRuleManifest.asFile): + '88F2EE4C6B7C903EB1C7D693C0BEEFC0EFC601C1023FB197F7CBA187B1142DB7', + (mavenizerSourcePatch.asFile): + '88F9035C8545CF1CBC7342C14F2F95076C4E415EDB7A74D585409CFC3F1BDD5C', + (mavenizerLicense.asFile): + '20C17D8B8C48A600800DFD14F95D5CB9FF47066A9641DDEAB48DC54AEC96E331', + (mavenizerReadme.asFile): + 'A097E126C606BDC86B34F5DDFB9580228A7C5BC866C6509C6BB362152EBACED7' +] + +// ForgeGradle invokes Mavenizer during project configuration. Verify the +// executable and its exact target rules before any Mavenizer code can run. +[mavenizerCompatibilityJar.asFile, mavenizerRuleManifest.asFile].each { fixture -> + if (!fixture.isFile()) { + throw new GradleException("Missing Mavenizer compatibility fixture: ${fixture}") + } + String actual = sha256Of(fixture) + String expected = mavenizerFixtureChecksums[fixture] + if (actual != expected) { + throw new GradleException("Mavenizer compatibility fixture checksum mismatch for " + + "${fixture.name}: expected ${expected}, found ${actual}") + } +} + +fgtools.configure('mavenizer') { + classpath.from(mavenizerCompatibilityJar) + mainClass.set('net.minecraftforge.mcmaven.cli.Main') + javaLauncher.set(javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(25) + vendor = JvmVendorSpec.ADOPTIUM + }) } -version = mod_version -group = mod_group_id +group = project.mod_group +version = project.mod_version +base.archivesName = 'OreSpawn' -base { - archivesName = "OreSpawn-${minecraft_version}" +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() } -println "Java: ${System.getProperty 'java.version'}, JVM: ${System.getProperty 'java.vm.version'} (${System.getProperty 'java.vendor'}), Arch: ${System.getProperty 'os.arch'}" +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' + options.compilerArgs.addAll(['-Xmaxerrs', '1000']) +} +tasks.named('compileTestJava', JavaCompile) { options.compilerArgs.add('-proc:none') } +tasks.withType(Test).configureEach { + useJUnitPlatform() + workingDir = project.projectDir +} +tasks.withType(Javadoc).configureEach { + failOnError = false + options.encoding = 'UTF-8' + options.addStringOption('Xdoclint:none', '-quiet') + options.addBooleanOption('notimestamp', true) +} +tasks.withType(AbstractArchiveTask).configureEach { + preserveFileTimestamps = false + reproducibleFileOrder = true +} + +def archiveTextSuffixes = [ + '.cfg', '.css', '.html', '.info', '.java', '.js', '.json', '.lang', + '.mcmeta', '.md', '.properties', '.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')) +} def applyBenchmarkProperties = { run, String defaultRadius, String defaultRepetitions, String defaultCenter -> - if (!providers.gradleProperty('orespawnBenchmarkMode').isPresent()) { - return - } - + if (!providers.gradleProperty('orespawnBenchmarkMode').isPresent()) return run.systemProperty 'orespawn.worldgenBenchmarkMode', providers.gradleProperty('orespawnBenchmarkMode').get() run.systemProperty 'orespawn.worldgenBenchmarkRadius', @@ -63,11 +181,10 @@ def applyBenchmarkProperties = { run, String defaultRadius, String defaultRepeti } minecraft { - mappings channel: mapping_channel, version: mapping_version - - // ForgeGradle 7 discovers the standard META-INF access transformer when enabled. - accessTransformer = true - + mappings channel: project.mapping_channel, version: project.mapping_version + // Forge 61 uses official runtime naming. ForgeGradle 7 therefore needs no + // reobfuscation task. + accessTransformer = 'META-INF/accesstransformer.cfg' runs { configureEach { workingDir = layout.projectDirectory.dir('run') @@ -76,18 +193,16 @@ minecraft { systemProperty 'forge.logging.console.level', 'debug' systemProperty 'forge.enabledGameTestNamespaces', mod_id } - register('client') - register('server') { args '--nogui' applyBenchmarkProperties(delegate, '8', '5', '1024') } - register('gameTestServer') { + workingDir = layout.buildDirectory.dir('gametest-benchmark-run') + args '--tests', 'minecraft:always_pass', '--report', 'benchmark-gametest-results.xml' applyBenchmarkProperties(delegate, '4', '3', '256') } - ['Fresh', 'Reload'].each { String phase -> register("surfaceIntegration${phase}") { mainClass = 'net.minecraftforge.bootstrap.ForgeBootstrap' @@ -98,14 +213,9 @@ minecraft { systemProperty 'forge.enabledGameTestNamespaces', 'surfaceprobe' systemProperty 'forge.logging.console.level', 'info' systemProperty 'surfaceprobe.integrationPhase', phase.toLowerCase(Locale.ROOT) - mods { - create(mod_id) { - source sourceSets.main - } - } + mods { create(mod_id) { source sourceSets.main } } } } - register('data') { workingDir = layout.projectDirectory.dir('run-data') args '--mod', mod_id, '--all', @@ -115,11 +225,27 @@ minecraft { } } +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) +} + sourceSets.main.resources { + // Eclipse rebuilds bin/main from declared resource source folders. Keeping + // the generated documentation in the source set prevents a Buildship + // refresh from silently removing the guide copied by processResources. srcDir 'src/generated/resources' } +def processedMainResourcesDirectory = { sourceSets.main.output.resourcesDir } +def processedMainResourcesPath = { + project.relativePath(processedMainResourcesDirectory()).replace('\\', '/') +} -// ForgeGradle's merged Eclipse output and Gradle 9.3 cannot safely share the +// ForgeGradle's merged Eclipse output and Gradle 9 cannot safely share the // JavaCompile and ProcessResources output directory. Compile into staging, // then copy classes into the merged mod root after resources are ready. def compiledMainClasses = layout.buildDirectory.dir('compiled-classes/main') @@ -135,9 +261,7 @@ def mergeMainClasses = tasks.register('mergeMainClasses') { } } } -tasks.named('classes').configure { - dependsOn mergeMainClasses -} +tasks.named('classes').configure { dependsOn mergeMainClasses } def compiledTestClasses = layout.buildDirectory.dir('compiled-classes/test') tasks.named('compileTestJava', JavaCompile).configure { @@ -152,93 +276,220 @@ def mergeTestClasses = tasks.register('mergeTestClasses') { } } } -tasks.named('testClasses').configure { - dependsOn mergeTestClasses -} +tasks.named('testClasses').configure { dependsOn mergeTestClasses } repositories { minecraft.mavenizer(it) maven fg.forgeMaven maven fg.minecraftLibsMaven + exclusiveContent { + forRepository { maven { url = 'https://repo.spongepowered.org/repository/maven-public' } } + filter { includeGroupAndSubgroups('org.spongepowered') } + } mavenCentral() + maven { url = 'https://libraries.minecraft.net/' } } -dependencies { - implementation minecraft.dependency("net.minecraftforge:forge:${minecraft_version}-${forge_version}") - annotationProcessor 'net.minecraftforge:eventbus-validator:7.0.1' +def fixtureRoot = file("${rootDir}/ci-fixtures") +def mineralogy5OracleJar = new File(fixtureRoot, + 'artifacts/Mineralogy-1.18.2-5.4.0.jar') +def mineralogy5OracleSha256 = + 'CCA84E9270585478B08F54BA091AD56AB2CB390386C650ED78B2673DC57403EB' - testImplementation platform('org.junit:junit-bom:5.10.2') - testImplementation 'org.junit.jupiter:junit-jupiter' - testImplementation 'org.junit.platform:junit-platform-launcher' +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}") + } + } } -tasks.named('jar', Jar).configure { - manifest { - attributes([ - 'Specification-Title' : 'OreSpawn', - 'Specification-Vendor' : 'SkyBlade1978', - 'Specification-Version' : '1', - 'Implementation-Title' : project.name, - 'Implementation-Version' : project.jar.archiveVersion, - 'Implementation-Vendor' : 'SkyBlade1978', - 'Implementation-Timestamp' : new Date().format("yyyy-MM-dd'T'HH:mm:ssZ"), - 'OreSpawn-API-Version' : '1' - ]) +tasks.register('verifyMavenizerCompatibilityFixture') { + group = 'verification' + description = 'Verifies the sealed target-aware ForgeGradle Mavenizer compatibility tool.' + inputs.files mavenizerFixtureChecksums.keySet() + doLast { + mavenizerFixtureChecksums.each { fixture, expected -> + if (!fixture.isFile()) { + throw new GradleException("Missing Mavenizer compatibility fixture: ${fixture}") + } + String actual = sha256Of(fixture) + if (actual != expected) { + throw new GradleException("Mavenizer compatibility fixture checksum mismatch for " + + "${fixture.name}: expected ${expected}, found ${actual}") + } + } + + def parsed = new JsonSlurper().parse(mavenizerRuleManifest.asFile) + String target = 'net.minecraftforge:forge:1.21.11-61.1.0' + if (parsed.schema != 1 || parsed.targets.keySet() != [target] as Set) { + throw new GradleException('Mavenizer target-rule manifest has unexpected targets or schema') + } + def forgeRules = parsed.targets[target] + if (forgeRules.expectedApplications != 0 || !forgeRules.rules.isEmpty()) { + throw new GradleException('Forge 61 Mavenizer target must be an explicit zero-rule qualification') + } + + ZipFile fixtureJar = new ZipFile(mavenizerCompatibilityJar.asFile) + try { + def embeddedEntry = fixtureJar.getEntry( + 'META-INF/orespawn/minecraft-source-compatibility.json') + if (embeddedEntry == null) { + throw new GradleException('Mavenizer fixture is missing its embedded target-rule manifest') + } + byte[] embedded = fixtureJar.getInputStream(embeddedEntry).withCloseable { it.readAllBytes() } + byte[] external = mavenizerRuleManifest.asFile.bytes + if (!Arrays.equals(embedded, external)) { + throw new GradleException('Embedded and externally audited Mavenizer manifests differ') + } + + def patcherEntry = fixtureJar.getEntry( + 'net/minecraftforge/mcmaven/impl/util/SourceCompatibilityPatcher.class') + if (patcherEntry == null) { + throw new GradleException('Mavenizer fixture is missing SourceCompatibilityPatcher') + } + byte[] classHeader = fixtureJar.getInputStream(patcherEntry).withCloseable { + it.readNBytes(8) + } + if (classHeader.length != 8 || classHeader[0..3] != + [0xCA, 0xFE, 0xBA, 0xBE].collect { (byte) it }) { + throw new GradleException('Mavenizer compatibility class has an invalid class header') + } + int classMajor = ((classHeader[6] & 0xff) << 8) | (classHeader[7] & 0xff) + if (classMajor != 69) { + throw new GradleException("Mavenizer compatibility tool must use Java 25 bytecode, found ${classMajor}") + } + + def jarManifestEntry = fixtureJar.getEntry('META-INF/MANIFEST.MF') + Manifest jarManifest = new Manifest(fixtureJar.getInputStream(jarManifestEntry)) + if (jarManifest.mainAttributes.getValue('Main-Class') != + 'net.minecraftforge.mcmaven.cli.Main') { + throw new GradleException('Mavenizer compatibility fixture has an unexpected main class') + } + } finally { + fixtureJar.close() + } + + String readme = mavenizerReadme.asFile.getText('UTF-8') + String patchText = mavenizerSourcePatch.asFile.getText('UTF-8') + String licenseText = mavenizerLicense.asFile.getText('UTF-8') + if (!readme.contains('6968241ce7a0a902cdc1c534b976e8373a423091') + || !readme.contains('Temurin `25.0.3+9`') + || !readme.contains('1.21.11-61.1.0')) { + throw new GradleException('Mavenizer provenance, target, or Java 25 build instructions are incomplete') + } + if (!patchText.contains('SourceCompatibilityPatcher.java') + || !patchText.contains('minecraft-source-compatibility.json')) { + throw new GradleException('Mavenizer source patch is incomplete') + } + if (!licenseText.contains('GNU LESSER GENERAL PUBLIC LICENSE')) { + throw new GradleException('Mavenizer LGPL-2.1 licence fixture is incomplete') + } } } -tasks.named('processResources', ProcessResources).configure { - from('docs/AGENTS.md') { - into '' - rename { 'AGENTS.md' } - } - from('docs') { - into 'META-INF/orespawn/docs' - } +dependencies { + implementation minecraft.dependency( + "net.minecraftforge:forge:${project.minecraft_version}-${project.forge_version}") + testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.2' + testImplementation 'org.junit.jupiter:junit-jupiter-params:5.10.2' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.10.2' + testImplementation 'org.junit.platform:junit-platform-launcher:1.10.2' } -publishing { - publications { - register('mavenJava', MavenPublication) { - artifact jar - artifact sourcesJar - artifact javadocJar +tasks.named('compileTestJava', JavaCompile) { dependsOn tasks.named('verifyLegacyFixtures') } +tasks.named('test', Test) { + dependsOn tasks.named('verifyLegacyFixtures') + systemProperty 'orespawn.mineralogy5Oracle', mineralogy5OracleJar.absolutePath +} + +tasks.register('verifyLegacyOracleIsolation') { + group = 'verification' + description = 'Keeps the sealed Mineralogy oracle test-visible but production-invisible.' + 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}") + } } } - repositories { - maven { - url "file://${project.projectDir}/mcmodsrepo" +} + +def java21Launcher = javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(21) + vendor = JvmVendorSpec.ADOPTIUM +} +tasks.register('verifyJava21Toolchain') { + group = 'verification' + doLast { + def metadata = java21Launcher.get().metadata + String runtimeVersion = metadata.javaRuntimeVersion + if (project.java_toolchain_version != '21.0.7+6' + || metadata.vendor.toString() != 'Eclipse Temurin' + || !(runtimeVersion == '21.0.7+6' || runtimeVersion == '21.0.7+6-LTS')) { + throw new GradleException("Expected Temurin ${project.java_toolchain_version}, found " + + "${metadata.vendor} ${runtimeVersion} at ${metadata.installationPath}") } } } +tasks.named('check') { + dependsOn tasks.named('verifyLegacyOracleIsolation') + dependsOn tasks.named('verifyJava21Toolchain') + dependsOn tasks.named('verifyMavenizerCompatibilityFixture') +} -tasks.withType(JavaCompile).configureEach { - options.encoding = 'UTF-8' +tasks.named('processResources', ProcessResources) { + dependsOn prepareBundledDocumentation + filteringCharset = 'UTF-8' + inputs.property('version', project.version) + inputs.property('minecraft_version', project.minecraft_version) + inputs.property('forge_version_range', project.forge_version_range) + inputs.property('loader_version_range', project.loader_version_range) + inputs.property('minecraft_version_range', project.minecraft_version_range) + filesMatching('META-INF/mods.toml') { + expand([ + version: project.version, + minecraft_version: project.minecraft_version, + forge_version_range: project.forge_version_range, + loader_version_range: project.loader_version_range, + minecraft_version_range: project.minecraft_version_range + ]) + } + from('docs/AGENTS.md') { + into '' + rename { 'AGENTS.md' } + } + filesMatching(archiveTextPatterns, normalizeArchiveLineEndings) } -tasks.named('javadoc', Javadoc).configure { - options.encoding = 'UTF-8' - options.addStringOption('Xdoclint:none', '-quiet') +def prepareEclipseResources = tasks.register('prepareEclipseResources') { + group = 'ide' + dependsOn tasks.named('processResources') + doLast { + project.copy { + from(processedMainResourcesDirectory()) + into(layout.projectDirectory.dir('bin/main')) + } + } } -tasks.named('test', Test).configure { - useJUnitPlatform() - // Unit tests inspect target files relative to the checkout, but they do - // not need Forge's rolling runtime files. A console-only test logger keeps - // them from contending with Eclipse/client logs in this working directory. - systemProperty 'log4j.configurationFile', file('src/test/resources/log4j2-test.xml').absolutePath - // 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 - } -} - -// Several registry-focused tests initialize the real global config singleton. -// Keep that target-native coverage without creating or changing a developer's -// checkout config as a side effect of `test` or `build`. +// Registry-focused tests initialize the real global config singleton. Keep +// that target-native coverage without leaving a generated checkout config. def unitTestWorldgenConfig = file('config/orespawn-worldgen.json') def unitTestWorldgenConfigWasPresent = false byte[] unitTestWorldgenConfigBytes = null @@ -256,25 +507,34 @@ def preserveDeveloperWorldgenConfig = tasks.register('preserveDeveloperWorldgenC if (after == null || !java.util.Arrays.equals(unitTestWorldgenConfigBytes, after)) { unitTestWorldgenConfig.parentFile.mkdirs() unitTestWorldgenConfig.bytes = unitTestWorldgenConfigBytes - throw new GradleException('Unit tests changed config/orespawn-worldgen.json; the original was restored') } } else if (unitTestWorldgenConfig.isFile()) { delete unitTestWorldgenConfig } } } -tasks.named('test') { +tasks.named('test', Test).configure { finalizedBy preserveDeveloperWorldgenConfig } // A Forge process is not green merely because it returns exit code zero. The // loader can log a worldgen/linkage failure and still shut down normally. -def acceptedForge40LogNoise = [ +def acceptedForge61LogNoise = [ ~/FML appears to be missing any signature data/, ~/Found multiple arguments for option fml\.mcVersion/, ~/Found multiple arguments for option fml\.forgeVersion/, + ~/\/ERROR\] \[net\.minecraft\.client\.Minecraft\/\]: Failed to verify authentication$/, + ~/\/ERROR\] \[net\.minecraft\.client\.Minecraft\/\]: Failed to fetch user properties$/, + ~/\/ERROR\] \[com\.mojang\.realmsclient\.client\.RealmsClient\/\]: Failed to fetch Realms feature flags$/, + ~/\/ERROR\] \[com\.mojang\.realmsclient\.RealmsAvailability\/\]: Couldn't connect to realms$/, + ~/\/(?:ERROR|FATAL)\] \[net\.minecraftforge\.fml\.network\.simple\.IndexedMessageCodec\/SIMPLENET\]: Received empty payload on channel fml:handshake$/, ~/\/FATAL\] \[net\.minecraftforge\.common\.ForgeConfig\/CORE\]: Forge config just got changed on the file system!$/, - ~/\/FATAL\] \[net\.minecraftforge\.fml\.packs\.ModFileResourcePack\/\]: Failed to clean up tempdir / + ~/\/FATAL\] \[net\.minecraftforge\.fml\.packs\.ModFileResourcePack\/\]: Failed to clean up tempdir /, + ~/^java\.lang\.UnsupportedOperationException: Reflective setAccessible\(true\) disabled$/, + ~/^java\.lang\.IllegalAccessException: symbolic reference class is not accessible: class jdk\.internal\.misc\.Unsafe, from class io\.netty\.util\.internal\.PlatformDependent0 \(module io\.netty\.common\)$/, + ~/^com\.mojang\.authlib\.exceptions\.InvalidCredentialsException: Status: 401$/, + ~/^Caused by: com\.mojang\.authlib\.exceptions\.MinecraftClientHttpException: Status: 401$/, + ~/^com\.mojang\.realmsclient\.exception\.RealmsServiceException: Realms authentication error with message 'java\.lang\.RuntimeException: Failed to parse into SignedJWT: validation-token'$/ ] def runtimeCrashSnapshot = { File runDirectory -> @@ -303,7 +563,8 @@ def assertRuntimeLogsClean = { File runDirectory, String context, Set priorCrash log.eachLine('UTF-8') { String line -> lineNumber++ boolean unexpectedSeverity = line ==~ /.*\/(?:ERROR|FATAL)\].*/ - boolean knownNoise = acceptedForge40LogNoise.any { line =~ it } + boolean knownNoise = acceptedForge61LogNoise.any { line =~ it } + boolean exceptionRoot = line ==~ /^(?:Caused by: )?[A-Za-z_$][A-Za-z0-9_.$]*(?:Exception|Error)(?::.*)?$/ boolean fatalText = line.contains('Encountered an unexpected exception') || line.contains('Exception stopping the server') || line.contains('Migration audit failed') || @@ -313,7 +574,7 @@ def assertRuntimeLogsClean = { File runDirectory, String context, Set priorCrash line.contains('ExceptionInInitializerError') || line.contains('Tried to assign a mutable BlockPos') || line.contains('causing cascading worldgen lag') - if ((unexpectedSeverity && !knownNoise) || fatalText) { + if ((unexpectedSeverity || exceptionRoot || fatalText) && !knownNoise) { failures.add("${log.name}:${lineNumber}: ${line}") } } @@ -333,8 +594,22 @@ task runtimeLogScannerTest { File logs = new File(probe, 'logs'); logs.mkdirs() new File(logs, 'latest.log').setText( '[main/ERROR] [FML]: FML appears to be missing any signature data\n' + + '[Render thread/ERROR] [net.minecraft.client.Minecraft/]: Failed to verify authentication\n' + + '[Download-2/ERROR] [net.minecraft.client.Minecraft/]: Failed to fetch user properties\n' + + 'com.mojang.authlib.exceptions.InvalidCredentialsException: Status: 401\n' + + 'Caused by: com.mojang.authlib.exceptions.MinecraftClientHttpException: Status: 401\n' + + '[Download-2/ERROR] [com.mojang.realmsclient.client.RealmsClient/]: Failed to fetch Realms feature flags\n' + + "com.mojang.realmsclient.exception.RealmsServiceException: Realms authentication error with message 'java.lang.RuntimeException: Failed to parse into SignedJWT: validation-token'\n" + + '[IO-Worker-1/ERROR] [com.mojang.realmsclient.RealmsAvailability/]: Couldn\'t connect to realms\n' + + '[Client thread/ERROR] [net.minecraftforge.fml.network.simple.IndexedMessageCodec/SIMPLENET]: Received empty payload on channel fml:handshake\n' + '[Server thread/INFO] [FML]: Done\n', 'UTF-8') assertRuntimeLogsClean(probe, 'scanner-accepted-noise-probe', [] as Set) + new File(logs, 'debug.log').setText( + 'java.lang.UnsupportedOperationException: Reflective setAccessible(true) disabled\n' + + 'java.lang.IllegalAccessException: symbolic reference class is not accessible: class jdk.internal.misc.Unsafe, from class io.netty.util.internal.PlatformDependent0 (module io.netty.common)\n', + 'UTF-8') + assertRuntimeLogsClean(probe, 'scanner-forge61-netty-noise-probe', [] as Set) + new File(logs, 'debug.log').delete() new File(logs, 'latest.log').setText( '[Server thread/WARN]: Tried to assign a mutable BlockPos to tick data...\n', 'UTF-8') boolean rejected = false @@ -353,6 +628,12 @@ task runtimeLogScannerTest { try { assertRuntimeLogsClean(probe, 'scanner-severity-probe', [] as Set) } catch (GradleException expected) { rejected = true } if (!rejected) throw new GradleException('Runtime log scanner accepted an unexpected ERROR line') + new File(logs, 'latest.log').setText( + 'java.lang.IllegalStateException: Unexpected unlogged fixture failure\n', 'UTF-8') + rejected = false + try { assertRuntimeLogsClean(probe, 'scanner-exception-root-probe', [] as Set) } + catch (GradleException expected) { rejected = true } + if (!rejected) throw new GradleException('Runtime log scanner accepted an unexpected exception root') delete probe } } @@ -374,36 +655,75 @@ task verifyMineralogyOracleIsolation { check.dependsOn verifyMineralogyOracleIsolation -['runClient', 'runServer', 'runData'].each { String taskName -> +['runClient', 'runServer', 'runGameTestServer', 'runData'].each { String taskName -> tasks.matching { it.name == taskName }.all { JavaExec runTask -> + File runtimeDirectory = taskName == 'runGameTestServer' + ? layout.buildDirectory.dir('gametest-benchmark-run').get().asFile + : taskName == 'runData' + ? layout.projectDirectory.dir('run-data').asFile + : layout.projectDirectory.dir('run').asFile + // Capture preserved developer crash reports during configuration as + // well as immediately before launch. ForgeGradle may prepare the run + // directory in an earlier doFirst action, so relying on only the + // latter snapshot can incorrectly classify historic reports as new. + Set configuredCrashSnapshot = runtimeCrashSnapshot(runtimeDirectory) doFirst { - new File(runTask.workingDir, 'mods').mkdirs() - runTask.ext.oreSpawnCrashSnapshot = runtimeCrashSnapshot(runTask.workingDir) + new File(runtimeDirectory, 'mods').mkdirs() + if (taskName == 'runGameTestServer') { + new File(runtimeDirectory, 'server.properties').setText('''\ +level-name=gametest-benchmark-world +level-seed=-4965128775892001975 +level-type=default +online-mode=false +server-port=0 +allow-nether=true +generate-structures=false +spawn-protection=0 +max-tick-time=-1 +''', 'UTF-8') + } + runTask.ext.oreSpawnCrashSnapshot = configuredCrashSnapshot + + runtimeCrashSnapshot(runtimeDirectory) } doLast { - assertRuntimeLogsClean(runTask.workingDir, taskName, + assertRuntimeLogsClean(runtimeDirectory, taskName, runTask.ext.oreSpawnCrashSnapshot as Set) } } } -def surfaceIntegrationClasses = layout.buildDirectory.dir('surface-integration-fixture/classes') -def compileSurfaceIntegrationTestMod = tasks.register('compileSurfaceIntegrationTestMod', JavaCompile) { +def clientIntegrationClasses = file("${buildDir}/client-integration-fixture/classes") +tasks.register('compileClientIntegrationTestMod', JavaCompile) { dependsOn tasks.named('classes') + source fileTree('src/clientIntegrationTest/java') + classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) + destinationDirectory = clientIntegrationClasses + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + options.encoding = 'UTF-8' +} +tasks.register('clientIntegrationTestModJar', Jar) { + dependsOn tasks.named('compileClientIntegrationTestMod') + archiveFileName = 'clientprobe.jar' + destinationDirectory = file("${buildDir}/client-integration-fixture") + from clientIntegrationClasses + from 'src/clientIntegrationTest/resources' +} +def packagedClientProbeJar = tasks.named('clientIntegrationTestModJar', Jar) + +def surfaceIntegrationClasses = file("${buildDir}/surface-integration-fixture/classes") +task compileSurfaceIntegrationTestMod(type: JavaCompile, dependsOn: classes) { source fileTree('src/biomeIntegrationTest/java') classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) - destinationDirectory.set(surfaceIntegrationClasses) - javaCompiler.set(javaToolchains.compilerFor { - languageVersion = JavaLanguageVersion.of(21) - }) - options.release = 21 + destinationDirectory = surfaceIntegrationClasses + sourceCompatibility = '21' + targetCompatibility = '21' options.encoding = 'UTF-8' } -def surfaceIntegrationTestModJar = tasks.register('surfaceIntegrationTestModJar', Jar) { - dependsOn compileSurfaceIntegrationTestMod +task surfaceIntegrationTestModJar(type: Jar, dependsOn: compileSurfaceIntegrationTestMod) { archiveFileName = 'surfaceprobe.jar' - destinationDirectory = layout.buildDirectory.dir('surface-integration-fixture') + destinationDirectory = file("${buildDir}/surface-integration-fixture") manifest { attributes 'MixinConfigs': 'surfaceprobe.mixins.json' } @@ -411,93 +731,1124 @@ def surfaceIntegrationTestModJar = tasks.register('surfaceIntegrationTestModJar' from 'src/biomeIntegrationTest/resources' } -def surfaceIntegrationRunDirectory = layout.buildDirectory.dir('surface-integration-run') -def prepareSurfaceIntegrationTest = tasks.register('prepareSurfaceIntegrationTest') { - dependsOn surfaceIntegrationTestModJar +def surfaceIntegrationRunDirectory = file("${buildDir}/surface-integration-run") +task prepareSurfaceIntegrationTest(dependsOn: surfaceIntegrationTestModJar) { doLast { delete surfaceIntegrationRunDirectory + surfaceIntegrationRunDirectory.mkdirs() copy { - from surfaceIntegrationTestModJar.flatMap { it.archiveFile } - into surfaceIntegrationRunDirectory.map { it.dir('mods') } + from surfaceIntegrationTestModJar.archiveFile + into new File(surfaceIntegrationRunDirectory, 'mods') } + new File(surfaceIntegrationRunDirectory, 'server.properties').setText('''\ +level-name=surface-integration-world +level-seed=zsjpxah +level-type=default +online-mode=false +server-port=0 +allow-nether=true +generate-structures=false +spawn-protection=0 +max-tick-time=-1 +''', 'UTF-8') + new File(surfaceIntegrationRunDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') } } -tasks.configureEach { - if (name == 'runSurfaceIntegrationFresh') { - dependsOn prepareSurfaceIntegrationTest - } else if (name == 'runSurfaceIntegrationReload') { - dependsOn 'runSurfaceIntegrationFresh' - } - if (name == 'runSurfaceIntegrationFresh' || name == 'runSurfaceIntegrationReload') { - doFirst { - ext.oreSpawnCrashSnapshot = runtimeCrashSnapshot(workingDir) - } - doLast { - assertRuntimeLogsClean(workingDir, "Forge 61 ${name}", - ext.oreSpawnCrashSnapshot as Set) +def surfaceIntegrationFreshProcess = tasks.register('surfaceIntegrationFreshProcess') { + group = 'verification' + dependsOn 'runSurfaceIntegrationFresh' +} +tasks.matching { it.name == 'runSurfaceIntegrationFresh' }.all { + dependsOn prepareSurfaceIntegrationTest +} +tasks.matching { it.name == 'runSurfaceIntegrationReload' }.all { + mustRunAfter surfaceIntegrationFreshProcess +} +surfaceIntegrationFreshProcess.configure { + doLast { + File marker = new File(surfaceIntegrationRunDirectory, + 'gametestserver/gametestworld/surfaceprobe-integration.properties') + if (!marker.isFile()) { + throw new GradleException("Fresh surface integration completion marker is missing: ${marker}") } + assertRuntimeLogsClean(surfaceIntegrationRunDirectory, + 'surface integration fresh phase', [] as Set) } } -def surfaceIntegrationTest = tasks.register('surfaceIntegrationTest') { +def surfaceIntegrationReloadProcess = tasks.register('surfaceIntegrationReloadProcess') { group = 'verification' - description = 'Verifies provider surfaces and dynamic-biome geology across fresh and reloaded normal terrain.' + dependsOn surfaceIntegrationFreshProcess dependsOn 'runSurfaceIntegrationReload' +} +surfaceIntegrationReloadProcess.configure { doLast { - File marker = surfaceIntegrationRunDirectory.get().file( - 'gametestserver/gametestworld/surfaceprobe-integration.properties').asFile - if (!marker.isFile()) { - throw new GradleException("Surface integration completion marker is missing: ${marker}") - } + assertRuntimeLogsClean(surfaceIntegrationRunDirectory, + 'surface integration reload phase', [] as Set) + } +} + +task surfaceIntegrationTest(dependsOn: surfaceIntegrationReloadProcess) { + group = 'verification' + doLast { + File marker = new File(surfaceIntegrationRunDirectory, + 'gametestserver/gametestworld/surfaceprobe-integration.properties') Properties result = new Properties() marker.withInputStream { result.load(it) } if (result.getProperty('reload_verified') != 'true') { throw new GradleException("Surface integration reload was not verified: ${marker}") } - logger.lifecycle('Provider surfaces and dynamic-biome geology verified: {} dimensions, {} columns each, fresh + reload', + logger.lifecycle('Provider surfaces and exact-biome geology verified: {} dimensions, {} columns each, fresh + reload', result.getProperty('dimensions'), result.getProperty('columns_per_dimension')) } } -tasks.named('check') { - dependsOn surfaceIntegrationTest -} +check.dependsOn surfaceIntegrationTest -// ForgeGradle 7 generates a launch for every source set, but its ordinary -// Eclipse launches currently omit JDT's test-code exclusion. Without this, -// Eclipse adds build/sourceSets/test to the Java module path and Java rejects -// the split OreSpawn packages before Minecraft starts. -tasks.named('genEclipseRuns').configure { +task syncForge61EclipseLaunches(dependsOn: compileSurfaceIntegrationTestMod) { + group = 'ide' doLast { - String exclusionKey = 'org.eclipse.jdt.launching.ATTR_EXCLUDE_TEST_CODE' - String exclusion = - "" + String mainOutput = new File(projectDir, 'bin/main').absolutePath + String ordinaryModClasses = "${mod_id}%%${mainOutput}" + String fixtureOutput = surfaceIntegrationClasses.absolutePath + String fixtureResources = new File(projectDir, 'src/biomeIntegrationTest/resources').absolutePath + String fixtureModClasses = "${mod_id}%%${mainOutput}${File.pathSeparator}" + + "surfaceprobe%%${fixtureOutput}${File.pathSeparator}" + + "surfaceprobe%%${fixtureResources}" + ['Client', 'Server', 'GameTestServer', 'Data'].each { String runName -> + File launch = file("run${runName}.launch") + if (!launch.isFile()) { + throw new GradleException("Missing generated Eclipse launch: ${launch}") + } + String text = launch.getText('UTF-8') + if (text.contains('')) { + text = text.replace( + '', + '\r\n' + + " \r\n" + + '') + } + if (!text.contains('\r?\n/, + { String environmentHeader -> + environmentHeader + + " \r\n" + }) + } + text = text.replaceFirst( + //, + java.util.regex.Matcher.quoteReplacement( + "")) + if (!text.contains('org.eclipse.jdt.launching.ATTR_EXCLUDE_TEST_CODE')) { + text = text.replace('', + ' \r\n' + + '') + } + launch.setText(text, 'UTF-8') + } + ['Fresh', 'Reload'].each { String phase -> + File launch = file("runSurfaceIntegration${phase}.launch") + if (!launch.isFile()) { + throw new GradleException("Missing generated Eclipse launch: ${launch}") + } + String text = launch.getText('UTF-8') + text = text.replaceFirst( + //, + java.util.regex.Matcher.quoteReplacement( + "")) + text = text.replaceFirst( + //, + { String match, String arguments -> + "" + }) + text = text.replaceFirst( + //, + java.util.regex.Matcher.quoteReplacement( + "")) + launch.setText(text, 'UTF-8') + } + } +} +task verifyForge61EclipseLaunchIsolation { + group = 'verification' + doLast { ['runClient.launch', 'runServer.launch', 'runGameTestServer.launch', 'runData.launch'].each { String launchName -> - File launchFile = file(launchName) - if (!launchFile.isFile()) { - throw new GradleException( - "ForgeGradle did not generate expected Eclipse launch: ${launchFile}") + File launch = file(launchName) + String launchText = launch.getText('UTF-8') + if (launchText.contains('Mineralogy-') || launchText.contains('biomeIntegrationTest') || + !launchText.contains('org.eclipse.jdt.launching.ATTR_EXCLUDE_TEST_CODE')) { + throw new GradleException("Ordinary Eclipse launch is not isolated from test oracles: ${launch}") + } + } + } +} + +syncForge61EclipseLaunches.finalizedBy verifyForge61EclipseLaunchIsolation + +tasks.matching { it.name == 'genEclipseRuns' }.all { + finalizedBy syncForge61EclipseLaunches +} + +def packagedSurfaceProbeJar = tasks.named('surfaceIntegrationTestModJar', Jar) + +tasks.named('jar', Jar) { + archiveClassifier = '' + destinationDirectory = layout.buildDirectory.dir('libs') + manifest { + attributes([ + 'Specification-Title' : 'OreSpawn', + 'Specification-Vendor' : 'SkyBlade1978', + 'Specification-Version' : '1', + 'Implementation-Title' : base.archivesName.get(), + 'Implementation-Version' : project.version, + 'Implementation-Vendor' : 'SkyBlade1978', + 'OreSpawn-API-Version' : '1', + 'FMLAT' : 'accesstransformer.cfg', + 'Maven-Artifact' : expectedMavenCoordinate, + 'Built-On-Java' : '21', + 'Built-On' : "${project.minecraft_version}-${project.forge_version}" + ]) + } +} + +def releaseJar = tasks.named('jar', Jar) + +tasks.named('sourcesJar', Jar) { + dependsOn prepareBundledDocumentation + filteringCharset = 'UTF-8' + includeEmptyDirs = false + filesMatching(archiveTextPatterns, normalizeArchiveLineEndings) + manifest { + attributes([ + 'Implementation-Title' : 'OreSpawn-sources', + 'Implementation-Version': project.version + ]) + } +} +tasks.named('javadocJar', Jar) { + filteringCharset = 'UTF-8' + filesMatching(archiveTextPatterns, normalizeArchiveLineEndings) + manifest { + attributes([ + 'Implementation-Title' : 'OreSpawn-javadoc', + 'Implementation-Version': project.version + ]) + } +} +['apiElements', 'runtimeElements'].each { configurationName -> + configurations.named(configurationName) { artifacts.clear() } + artifacts { add(configurationName, releaseJar) } +} +tasks.named('assemble') { + dependsOn releaseJar + dependsOn tasks.named('sourcesJar') + dependsOn tasks.named('javadocJar') +} + +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.register('verifyReleaseConfiguration') { + group = 'verification' + doLast { + if (project.mod_version != '4.0.16.121111' + || project.mod_group != expectedMavenGroup + || project.minecraft_version != '1.21.11' + || project.forge_version != '61.1.0' + || project.mapping_channel != 'official' + || project.mapping_version != '1.21.11') { + throw new GradleException('Unexpected OreSpawn 1.21.11 release identity') + } + if (project.loader_name != 'forge' || project.loader_code != '1' + || project.java_version != '21' || project.gradle_java_version != '21' + || project.java_toolchain_version != '21.0.7+6') { + throw new GradleException('Unexpected dispatcher or Java target metadata') + } + List expectedPublicArtifacts = [ + 'OreSpawn-4.0.16.121111.jar', + 'OreSpawn-4.0.16.121111-sources.jar', + 'OreSpawn-4.0.16.121111-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('4.0.16.121111')) { + 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)') + } + if (!file('src/main/java/zone/moddev/mc/orespawn/api/OreSpawnApi.java') + .getText('UTF-8').contains('API_VERSION = 1')) { + throw new GradleException('OreSpawn API major must remain 1') + } + if (!file('src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeConfig.java') + .getText('UTF-8').contains('SCHEMA_VERSION = 6') + || !file('src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java') + .getText('UTF-8').contains('SCHEMA_VERSION = 5')) { + throw new GradleException('Global/world schemas must remain 6/5') + } + def schema = new JsonSlurper().parse(file('docs/schemas/orespawn-provider.schema.json')) + if (!(schema.properties.schema_version.enum as List).contains(4)) { + throw new GradleException('Provider schema must remain version 4') + } + } +} - String launch = launchFile.getText('UTF-8') - if (launch.contains("key=\"${exclusionKey}\"")) { - launch = launch.replaceFirst( - //, - exclusion) +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 -> + byte[] tracked = new File(trackedDocumentationDirectory, relative).bytes + byte[] candidate = new File(root, relative).bytes + if (!java.util.Arrays.equals(tracked, candidate)) { + throw new GradleException("${label}/${relative} differs from tracked documentation") + } + } +} + +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(processedMainResourcesDirectory(), + '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') + doLast { + File libs = layout.buildDirectory.dir('libs').get().asFile + List jars = (libs.listFiles() ?: [] as File[]) + .findAll { it.name.endsWith('.jar') }.sort { it.name } + // Provider interpolation yields GString values; normalize them before + // comparing with real filesystem String names. + 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/', + '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/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/mods.toml')) + .getText(StandardCharsets.UTF_8.name()) + if (!metadata.contains('modId="orespawn"') + || !metadata.contains("version=\"${project.version}\"") + || !metadata.contains('versionRange="[1.21.11,1.22)"')) { + throw new GradleException('Packaged Forge 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' + ] + 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('FMLAT') != 'accesstransformer.cfg' + || 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 = new byte[8] + zip.getInputStream(entry).withCloseable { input -> + if (input.read(header) != 8) throw new GradleException("Cannot inspect ${entry.name}") + } + 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()) + // Provider interpolation yields GString values; normalize them before + // comparing with real filesystem String names. + List expected = expectedReleaseFiles.get() + .collect { it.toString() }.sort() + List jars = (prepared.listFiles() ?: [] as File[]) + .findAll { it.name.endsWith('.jar') }.sort { it.name } + List actualNames = jars.collect { it.name.toString() }.sort() + if (actualNames != expected) { + throw new GradleException( + "Prepared release jars ${actualNames} do not match ${expected}") + } + if (jars.any { it.length() == 0L }) { + throw new GradleException('Prepared release contains an empty jar') + } + 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') + } + List actual = jars.collect { candidate -> + MessageDigest digest = MessageDigest.getInstance('SHA-256') + candidate.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()} ${candidate.name}" + }.sort() + if (actual != checksums.readLines('UTF-8').findAll { !it.trim().isEmpty() }.sort()) { + throw new GradleException('Prepared release checksums do not match') + } + } +} + +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 { - int headerEnd = launch.indexOf('\n', launch.indexOf(' + // ForgeGradle 7 models Forge 61's merged Gradle output as + // build/sourceSets with main/test children. Eclipse rejects a + // source output nested below the default output, so keep Gradle's + // merged directories as inputs while compiling Eclipse sources to + // the conventional disjoint bin outputs. + classpath.entries.findAll { entry -> + entry instanceof org.gradle.plugins.ide.eclipse.model.Output + }.each { entry -> + entry.path = 'bin/default' + } + classpath.entries.removeAll { entry -> + entry instanceof org.gradle.plugins.ide.eclipse.model.SourceFolder + && ['src/main/resources', 'src/generated/resources'].contains(entry.path) + } + String resourcesPath = processedMainResourcesPath() + if (!classpath.entries.any { entry -> entry.path == resourcesPath }) { + classpath.entries.add(new org.gradle.plugins.ide.eclipse.model.SourceFolder( + resourcesPath, 'bin/main')) + } + classpath.entries.findAll { entry -> + entry instanceof org.gradle.plugins.ide.eclipse.model.SourceFolder + }.each { entry -> + if (entry.path == resourcesPath || entry.path == 'src/main/java') { + entry.output = 'bin/main' + } else if (entry.path.startsWith('src/test/')) { + entry.output = 'bin/test' + } + } + } + } + synchronizationTasks 'isolateEclipseProductionRuns' +} +idea { + module { + downloadSources = true + downloadJavadoc = true + } +} +tasks.register('configureEclipseBuildship') { + group = 'ide' + doLast { + File preferencesFile = file('.settings/org.eclipse.buildship.core.prefs') + Properties preferences = new Properties() + [ + 'eclipse.preferences.version' : '1', + 'connection.gradle.distribution': 'GRADLE_DISTRIBUTION(WRAPPER)', + 'connection.gradle.user.home' : gradle.gradleUserHomeDir.canonicalPath, + 'connection.project.dir' : '', + 'gradle.user.home' : gradle.gradleUserHomeDir.canonicalPath, + 'override.workspace.settings' : 'true' + ].each { key, value -> preferences.setProperty(key, value) } + preferencesFile.parentFile.mkdirs() + preferencesFile.withOutputStream { + preferences.store(it, 'Generated by OreSpawn Buildship configuration.') + } + } +} +tasks.register('isolateEclipseProductionRuns') { + group = 'ide' + dependsOn tasks.named('genEclipseRuns') + dependsOn syncForge61EclipseLaunches + dependsOn tasks.named('configureEclipseBuildship') + dependsOn prepareEclipseResources + doLast { + [ + 'OreSpawn_Client.launch': 'GradleStart', + 'OreSpawn_Server.launch': 'GradleStartServer' + ].each { String name, String mainClass -> + File launch = file(name) + if (launch.isFile() && launch.getText('UTF-8').contains(mainClass) + && !launch.delete()) { + throw new GradleException("Could not remove obsolete launch ${name}") + } + } + fileTree(project.projectDir) { include 'run*.launch' }.files.each { launch -> + String contents = launch.getText('UTF-8') + contents = contents.replace( + 'key="MC_VERSION" value="${MC_VERSION}"', + "key=\"MC_VERSION\" value=\"${minecraft_version}\"") + launch.setText(contents.replace('\r\n', '\n'), 'UTF-8') + } + } +} +tasks.register('verifyEclipseProductionClasspath') { + group = 'verification' + dependsOn tasks.named('eclipseClasspath') + dependsOn tasks.named('isolateEclipseProductionRuns') + dependsOn tasks.named('verifyLegacyOracleIsolation') + doLast { + File prefs = file('.settings/org.eclipse.buildship.core.prefs') + if (!prefs.isFile()) throw new GradleException('Missing Buildship preferences') + Properties buildshipPreferences = new Properties() + prefs.withInputStream { buildshipPreferences.load(it) } + String expectedGradleHome = gradle.gradleUserHomeDir.canonicalPath + if (buildshipPreferences.getProperty('connection.gradle.user.home') != expectedGradleHome + || buildshipPreferences.getProperty('gradle.user.home') != expectedGradleHome + || buildshipPreferences.getProperty('override.workspace.settings') != 'true') { + throw new GradleException( + "Eclipse Buildship must use the validated Gradle home ${expectedGradleHome}") + } + Set legacyLwjglArtifacts = configurations.compileClasspath.resolvedConfiguration + .resolvedArtifacts + .findAll { it.moduleVersion.id.group == 'org.lwjgl.lwjgl' } + .collect { "${it.moduleVersion.id.group}:${it.name}:${it.moduleVersion.id.version}" } + .toSet() + if (!legacyLwjglArtifacts.isEmpty()) { + throw new GradleException( + "Forge 1.21.11 Eclipse classpath contains legacy LWJGL 2 artifacts: ${legacyLwjglArtifacts}") + } + File eclipseClasspath = file('.classpath') + if (!eclipseClasspath.isFile()) { + throw new GradleException('Eclipse .classpath was not generated') + } + String eclipseClasspathText = eclipseClasspath.getText('UTF-8') + if (!eclipseClasspathText.contains("path=\"${processedMainResourcesPath()}\"") + || eclipseClasspathText.contains('path="src/main/resources"') + || eclipseClasspathText.contains('path="src/generated/resources"')) { + throw new GradleException( + 'Eclipse must consume only Gradle-processed production resources') + } + def eclipseClasspathXml = new XmlSlurper(false, false).parse(eclipseClasspath) + String defaultOutput = eclipseClasspathXml.classpathentry + .find { it.@kind.text() == 'output' }.@path.text() + Map expectedSourceOutputs = [ + 'src/main/java' : 'bin/main', + (processedMainResourcesPath()) : 'bin/main', + 'src/test/java' : 'bin/test', + 'src/test/resources' : 'bin/test' + ] + List invalidSourceOutputs = eclipseClasspathXml.classpathentry + .findAll { it.@kind.text() == 'src' } + .collect { [it.@path.text(), it.@output.text()] } + .findAll { pair -> + String expected = expectedSourceOutputs[pair[0]] + expected != null && pair[1] != expected + } + .collect { pair -> "${pair[0]} -> ${pair[1]}" } + boolean nestedOutput = eclipseClasspathXml.classpathentry + .findAll { it.@kind.text() == 'src' && !it.@output.text().isEmpty() } + .any { entry -> + String output = entry.@output.text() + output == defaultOutput || output.startsWith(defaultOutput + '/') } - String lineSeparator = launch.contains('\r\n') ? '\r\n' : '\n' - launch = "${launch.substring(0, headerEnd + 1)}" + - " ${exclusion}${lineSeparator}" + - launch.substring(headerEnd + 1) + if (defaultOutput != 'bin/default' || !invalidSourceOutputs.isEmpty() + || nestedOutput || eclipseClasspathText.contains('output="build/sourceSets')) { + throw new GradleException( + "Eclipse outputs must be disjoint bin/default, bin/main and bin/test " + + "directories; default=${defaultOutput}, invalid=${invalidSourceOutputs}") + } + [ + 'META-INF/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 processedModMetadata = new File(sourceSets.main.output.resourcesDir, + 'META-INF/mods.toml') + File eclipseModMetadata = file('bin/main/META-INF/mods.toml') + if (!java.util.Arrays.equals(processedModMetadata.bytes, eclipseModMetadata.bytes) + || eclipseModMetadata.getText('UTF-8').contains('${version}') + || !eclipseModMetadata.getText('UTF-8').contains( + "version=\"${project.version}\"")) { + throw new GradleException( + 'Eclipse output contains unexpanded or stale Forge mod 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', 'C:\\Users\\John' + ] + String mainOutput = new File(projectDir, 'bin/main').absolutePath + String expectedModClasses = "${mod_id}%%${mainOutput}" + ['runClient.launch', 'runServer.launch', 'runData.launch'].each { name -> + File launch = file(name) + if (!launch.isFile()) throw new GradleException("Missing ${name}") + String contents = launch.getText('UTF-8') + List leaked = forbidden.findAll { contents.contains(it) } + if (!leaked.isEmpty()) { + throw new GradleException("${name} exposes test/local content: ${leaked}") + } + if (!contents.contains('ATTR_EXCLUDE_TEST_CODE') + || !contents.contains('PROJECT_ATTR" value="OreSpawn"')) { + throw new GradleException("${name} is not a production-only OreSpawn launch") + } + if (!contents.contains("MOD_CLASSES\" value=\"${expectedModClasses}\"")) { + throw new GradleException("${name} does not use Forge 61's merged Buildship output") + } + } + } +} + +tasks.register('verifyCommandPortability') { + group = 'verification' + description = 'Rejects shell-specific launch wrappers and hard-coded classpath separators.' + doLast { + String gradleSource = file('build.gradle').getText('UTF-8') + 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|run:)\s*[^\n]*(?:cmd(?:\.exe)?\s+\/c|powershell(?:\.exe)?\s+-command|(?:bash|sh)\s+-c)/) { + throw new GradleException("Shell-specific command wrapper in ${source}") } - launchFile.setText(launch, 'UTF-8') } + if (!gradleSource.contains('commandLine(([javaExecutable.absolutePath] + arguments) as List)') + || !gradleSource.contains('join(File.pathSeparator)') + || !gradleSource.contains('${File.pathSeparator}')) { + throw new GradleException('Runtime commands and exploded-mod paths must use native argument/path APIs') + } + } +} + +tasks.named('check') { dependsOn tasks.named('verifyCommandPortability') } +tasks.named('check') { dependsOn tasks.named('verifyMavenCoordinates') } + +def packagedForgeServerRuntime = providers.gradleProperty('packagedForgeServerRuntime') +def packagedForgeClientRuntime = providers.gradleProperty('packagedForgeClientRuntime') +def packagedClientJavaExecutable = providers.gradleProperty('packagedClientJavaExecutable') + +def requireRuntimeDirectory = { Provider configuredPath, String propertyName -> + if (!configuredPath.isPresent()) { + throw new GradleException("Pass -P${propertyName}=") + } + File runtime = file(configuredPath.get()) + if (!runtime.isDirectory()) { + throw new GradleException("${propertyName} does not name a directory: ${runtime}") + } + runtime +} + +def stagePackagedServerLibraries = { File runtime, File runDirectory -> + File sourceLibraries = new File(runtime, 'libraries') + if (!sourceLibraries.isDirectory()) { + throw new GradleException("Official server runtime has no libraries directory: ${sourceLibraries}") + } + File targetLibraries = new File(runDirectory, 'libraries') + sourceLibraries.eachFileRecurse(groovy.io.FileType.FILES) { File source -> + java.nio.file.Path relative = sourceLibraries.toPath().relativize(source.toPath()) + File target = targetLibraries.toPath().resolve(relative).toFile() + target.parentFile.mkdirs() + try { + java.nio.file.Files.createLink(target.toPath(), source.toPath()) + } catch (IOException | UnsupportedOperationException | SecurityException ignored) { + java.nio.file.Files.copy(source.toPath(), target.toPath(), + java.nio.file.StandardCopyOption.REPLACE_EXISTING) + } + } +} + +def packagedSurfaceRunDirectory = file("${buildDir}/packaged-surface-run") +tasks.register('preparePackagedSurfaceIntegration') { + dependsOn releaseJar + dependsOn packagedSurfaceProbeJar + doLast { + File runtime = requireRuntimeDirectory(packagedForgeServerRuntime, + 'packagedForgeServerRuntime') + delete packagedSurfaceRunDirectory + packagedSurfaceRunDirectory.mkdirs() + stagePackagedServerLibraries(runtime, packagedSurfaceRunDirectory) + copy { + from new File(runtime, "forge-${minecraft_version}-${forge_version}-shim.jar") + into packagedSurfaceRunDirectory + } + copy { + from releaseJar + from packagedSurfaceProbeJar + into new File(packagedSurfaceRunDirectory, 'mods') + } + new File(packagedSurfaceRunDirectory, 'server.properties').setText('''\ +level-name=surface-integration-world +level-seed=zsjpxah +level-type=default +online-mode=false +server-port=0 +allow-nether=true +generate-structures=false +spawn-protection=0 +max-tick-time=-1 +''', 'UTF-8') + new File(packagedSurfaceRunDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') + } +} +def packagedSurfaceFresh = tasks.register('packagedSurfaceFresh', Exec) { + group = 'verification' + dependsOn tasks.named('preparePackagedSurfaceIntegration') + doFirst { + File forgeDirectory = new File(packagedSurfaceRunDirectory, + 'libraries/net/minecraftforge/forge/1.21.11-61.1.0') + File launcher = new File(forgeDirectory, 'forge-1.21.11-61.1.0-server.jar') + File sourceArguments = new File(forgeDirectory, + System.getProperty('os.name').toLowerCase(Locale.ROOT).contains('windows') + ? 'win_args.txt' : 'unix_args.txt') + [launcher, sourceArguments].each { + if (!it.exists()) throw new GradleException("Incomplete official server runtime: ${it}") + } + workingDir packagedSurfaceRunDirectory + commandLine java21Launcher.get().executablePath.asFile.absolutePath, + '-Xms512m', '-Xmx2g', '-Dsurfaceprobe.integrationPhase=fresh', + "@libraries/net/minecraftforge/forge/1.21.11-61.1.0/${sourceArguments.name}", + 'nogui' } + doLast { + File marker = new File(packagedSurfaceRunDirectory, + 'surface-integration-world/surfaceprobe-integration.properties') + if (!marker.isFile()) throw new GradleException('Packaged fresh surface marker is missing') + assertRuntimeLogsClean(packagedSurfaceRunDirectory, + 'packaged surface fresh', [] as Set) + } +} +def packagedSurfaceReload = tasks.register('packagedSurfaceReload', Exec) { + group = 'verification' + dependsOn packagedSurfaceFresh + doFirst { + File forgeDirectory = new File(packagedSurfaceRunDirectory, + 'libraries/net/minecraftforge/forge/1.21.11-61.1.0') + File sourceArguments = new File(forgeDirectory, + System.getProperty('os.name').toLowerCase(Locale.ROOT).contains('windows') + ? 'win_args.txt' : 'unix_args.txt') + workingDir packagedSurfaceRunDirectory + commandLine java21Launcher.get().executablePath.asFile.absolutePath, + '-Xms512m', '-Xmx2g', '-Dsurfaceprobe.integrationPhase=reload', + "@libraries/net/minecraftforge/forge/1.21.11-61.1.0/${sourceArguments.name}", + 'nogui' + } + doLast { + assertRuntimeLogsClean(packagedSurfaceRunDirectory, + 'packaged surface reload', [] as Set) + } +} +tasks.register('packagedSurfaceIntegrationTest') { + group = 'verification' + dependsOn packagedSurfaceReload + doLast { + File marker = new File(packagedSurfaceRunDirectory, + 'surface-integration-world/surfaceprobe-integration.properties') + Properties values = new Properties() + marker.withInputStream { values.load(it) } + if (values.getProperty('reload_verified') != 'true') { + throw new GradleException('Packaged surface reload was not verified') + } + } +} + +def packagedClientRunDirectory = file("${buildDir}/packaged-client-run") +tasks.register('preparePackagedClientIntegration') { + dependsOn releaseJar + dependsOn packagedClientProbeJar + doLast { + delete packagedClientRunDirectory + packagedClientRunDirectory.mkdirs() + copy { + from releaseJar + from packagedClientProbeJar + into new File(packagedClientRunDirectory, 'mods') + } + new File(packagedClientRunDirectory, 'options.txt').setText( + 'fullscreen:false\nlang:en_us\n', 'UTF-8') + File configDirectory = new File(packagedClientRunDirectory, 'config') + configDirectory.mkdirs() + new File(configDirectory, 'orespawn-common.toml').setText('''\ +# Bootstrap fallbacks. Detailed settings live in orespawn-worldgen.json. +[worldgen] + # Master switch for configured terrain replacement. + place_terrain = true + # Allowed Values: GEOME, LEGACY + fallback_geology_mode = "GEOME" + # Range: 4 ~ 32767 + cyano_region_size = 256 + # Range: 1.0 ~ 32767.0 + cyano_layer_reach = 32.0 + # Range: 1 ~ 255 + cyano_layer_thickness = 8 +''', 'UTF-8') + } +} +def packagedClientProcess = tasks.register('packagedClientProcess', Exec) { + group = 'verification' + dependsOn tasks.named('preparePackagedClientIntegration') + doFirst { + File runtime = requireRuntimeDirectory(packagedForgeClientRuntime, + 'packagedForgeClientRuntime') + String forgeVersionId = ['1.21.11-forge-61.1.0', 'forge-61.1.0'].find { candidate -> + new File(runtime, "versions/${candidate}/${candidate}.json").isFile() + } + if (forgeVersionId == null) { + throw new GradleException('Official client runtime has no Forge 61.1.0 profile') + } + File forgeJsonFile = new File(runtime, + "versions/${forgeVersionId}/${forgeVersionId}.json") + File baseJsonFile = new File(runtime, 'versions/1.21.11/1.21.11.json') + File baseJar = new File(runtime, 'versions/1.21.11/1.21.11.jar') + File nativesDirectory = new File(runtime, "natives/${forgeVersionId}") + [forgeJsonFile, baseJsonFile, baseJar, new File(runtime, 'libraries'), + new File(runtime, 'assets'), nativesDirectory].each { + if (!it.exists()) throw new GradleException("Incomplete official client runtime: ${it}") + } + + def slurper = new groovy.json.JsonSlurper() + Map forgeJson = (Map) slurper.parse(forgeJsonFile) + Map baseJson = (Map) slurper.parse(baseJsonFile) + Map classpathByModule = new LinkedHashMap<>() + [baseJson, forgeJson].each { Map metadata -> + ((List) metadata.libraries).each { Map library -> + List rules = (List) library.rules + boolean allowed = rules == null || rules.isEmpty() + if (rules != null) { + rules.each { Map rule -> + Map os = (Map) rule.os + boolean matches = os == null + || (os.name == 'windows' + && (os.arch == null || os.arch == System.getProperty('os.arch'))) + if (matches) allowed = rule.action == 'allow' + } + } + if (!allowed) return + String relative = (String) ((Map) ((Map) library.downloads).artifact).path + File artifact = new File(runtime, "libraries/${relative}") + if (!artifact.isFile()) { + throw new GradleException("Missing official client library: ${artifact}") + } + List coordinates = ((String) library.name).split(':') as List + String module = coordinates.size() >= 2 + ? "${coordinates[0]}:${coordinates[1]}" : (String) library.name + if (coordinates.size() >= 4) { + // Since 1.19 Mojang lists native classifiers as separate libraries. + // Keep each classifier alongside its base module instead of replacing it. + module += ":${coordinates.subList(3, coordinates.size()).join(':')}" + } + classpathByModule.put(module, artifact) + } + } + List classpathFiles = new ArrayList<>(classpathByModule.values()) + classpathFiles.add(baseJar) + + String libraryDirectory = new File(runtime, 'libraries').absolutePath + List forgeJvmArguments = ((List) ((Map) forgeJson.arguments).jvm) + .collect { String argument -> + argument.replace('${library_directory}', libraryDirectory) + .replace('${classpath_separator}', File.pathSeparator) + .replace('${version_name}', (String) forgeJson.inheritsFrom) + } + + List gameArguments = [] + gameArguments.addAll((List) ((Map) forgeJson.arguments).game) + gameArguments.addAll([ + '--username', 'OSValidation', + '--version', (String) forgeJson.id, + '--gameDir', packagedClientRunDirectory.absolutePath, + '--assetsDir', new File(runtime, 'assets').absolutePath, + '--assetIndex', (String) ((Map) baseJson.assetIndex).id, + '--uuid', '00000000-0000-0000-0000-000000000001', + '--accessToken', 'validation-token', + '--userType', 'legacy', + '--versionType', 'release', + '--width', '854', '--height', '480' + ]) + workingDir packagedClientRunDirectory + File clientJava = packagedClientJavaExecutable.isPresent() + ? file(packagedClientJavaExecutable.get()) + : java21Launcher.get().executablePath.asFile + if (!clientJava.isFile()) { + throw new GradleException("packagedClientJavaExecutable does not name a Java executable: ${clientJava}") + } + List clientCommand = [ + clientJava.absolutePath, + '-Xms512m', '-Xmx2g', '-Dclientprobe.enabled=true', + "-Djava.library.path=${nativesDirectory.absolutePath}", + '-Dminecraft.launcher.brand=orespawn-validation', + '-Dminecraft.launcher.version=1' + ] + clientCommand.addAll(forgeJvmArguments) + clientCommand.addAll([ + '-cp', classpathFiles.collect { it.absolutePath }.join(File.pathSeparator), + (String) forgeJson.mainClass + ]) + clientCommand.addAll(gameArguments) + commandLine(clientCommand) + } +} +tasks.register('packagedClientIntegrationTest') { + group = 'verification' + dependsOn packagedClientProcess + doLast { + File marker = new File(packagedClientRunDirectory, 'client-smoke-pass.properties') + if (!marker.isFile()) { + throw new GradleException('Packaged client completion marker is missing') + } + Properties values = new Properties() + marker.withInputStream { values.load(it) } + ['world_settings_opened', 'long_editor_roundtrip', + 'first_world_rendered', 'reload_rendered'].each { key -> + if (values.getProperty(key) != 'true') { + throw new GradleException("Packaged client failed ${key}: ${values}") + } + } + assertDocumentationTree(new File(packagedClientRunDirectory, + 'config/orespawn-guide'), documentationFiles(), 'runtime guide export') + assertRuntimeLogsClean(packagedClientRunDirectory, + 'packaged client', [] as Set) + } +} + +tasks.register('packagedRuntimeIntegrationTest') { + group = 'verification' + description = 'Runs the exact official-named jars in official Forge 61 server and client runtimes.' + dependsOn tasks.named('packagedSurfaceIntegrationTest') + dependsOn tasks.named('packagedClientIntegrationTest') } diff --git a/ci-fixtures/README.md b/ci-fixtures/README.md new file mode 100644 index 00000000..47f65da1 --- /dev/null +++ b/ci-fixtures/README.md @@ -0,0 +1,13 @@ +# OreSpawn 1.21.1 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.21.1 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/ci-fixtures/tools/LICENSE-MAVENIZER.txt b/ci-fixtures/tools/LICENSE-MAVENIZER.txt new file mode 100644 index 00000000..8000a6fa --- /dev/null +++ b/ci-fixtures/tools/LICENSE-MAVENIZER.txt @@ -0,0 +1,504 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 2.1, February 1999 + + Copyright (C) 1991, 1999 Free Software Foundation, Inc. + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts + as the successor of the GNU Library Public License, version 2, hence + the version number 2.1.] + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +Licenses are intended to guarantee your freedom to share and change +free software--to make sure the software is free for all its users. + + This license, the Lesser General Public License, applies to some +specially designated software packages--typically libraries--of the +Free Software Foundation and other authors who decide to use it. You +can use it too, but we suggest you first think carefully about whether +this license or the ordinary General Public License is the better +strategy to use in any particular case, based on the explanations below. + + When we speak of free software, we are referring to freedom of use, +not price. Our General Public Licenses are designed to make sure that +you have the freedom to distribute copies of free software (and charge +for this service if you wish); that you receive source code or can get +it if you want it; that you can change the software and use pieces of +it in new free programs; and that you are informed that you can do +these things. + + To protect your rights, we need to make restrictions that forbid +distributors to deny you these rights or to ask you to surrender these +rights. These restrictions translate to certain responsibilities for +you if you distribute copies of the library or if you modify it. + + For example, if you distribute copies of the library, whether gratis +or for a fee, you must give the recipients all the rights that we gave +you. You must make sure that they, too, receive or can get the source +code. If you link other code with the library, you must provide +complete object files to the recipients, so that they can relink them +with the library after making changes to the library and recompiling +it. And you must show them these terms so they know their rights. + + We protect your rights with a two-step method: (1) we copyright the +library, and (2) we offer you this license, which gives you legal +permission to copy, distribute and/or modify the library. + + To protect each distributor, we want to make it very clear that +there is no warranty for the free library. Also, if the library is +modified by someone else and passed on, the recipients should know +that what they have is not the original version, so that the original +author's reputation will not be affected by problems that might be +introduced by others. + + Finally, software patents pose a constant threat to the existence of +any free program. We wish to make sure that a company cannot +effectively restrict the users of a free program by obtaining a +restrictive license from a patent holder. Therefore, we insist that +any patent license obtained for a version of the library must be +consistent with the full freedom of use specified in this license. + + Most GNU software, including some libraries, is covered by the +ordinary GNU General Public License. This license, the GNU Lesser +General Public License, applies to certain designated libraries, and +is quite different from the ordinary General Public License. We use +this license for certain libraries in order to permit linking those +libraries into non-free programs. + + When a program is linked with a library, whether statically or using +a shared library, the combination of the two is legally speaking a +combined work, a derivative of the original library. The ordinary +General Public License therefore permits such linking only if the +entire combination fits its criteria of freedom. The Lesser General +Public License permits more lax criteria for linking other code with +the library. + + We call this license the "Lesser" General Public License because it +does Less to protect the user's freedom than the ordinary General +Public License. It also provides other free software developers Less +of an advantage over competing non-free programs. These disadvantages +are the reason we use the ordinary General Public License for many +libraries. However, the Lesser license provides advantages in certain +special circumstances. + + For example, on rare occasions, there may be a special need to +encourage the widest possible use of a certain library, so that it becomes +a de-facto standard. To achieve this, non-free programs must be +allowed to use the library. A more frequent case is that a free +library does the same job as widely used non-free libraries. In this +case, there is little to gain by limiting the free library to free +software only, so we use the Lesser General Public License. + + In other cases, permission to use a particular library in non-free +programs enables a greater number of people to use a large body of +free software. For example, permission to use the GNU C Library in +non-free programs enables many more people to use the whole GNU +operating system, as well as its variant, the GNU/Linux operating +system. + + Although the Lesser General Public License is Less protective of the +users' freedom, it does ensure that the user of a program that is +linked with the Library has the freedom and the wherewithal to run +that program using a modified version of the Library. + + The precise terms and conditions for copying, distribution and +modification follow. Pay close attention to the difference between a +"work based on the library" and a "work that uses the library". The +former contains code derived from the library, whereas the latter must +be combined with the library in order to run. + + GNU LESSER GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License Agreement applies to any software library or other +program which contains a notice placed by the copyright holder or +other authorized party saying it may be distributed under the terms of +this Lesser General Public License (also called "this License"). +Each licensee is addressed as "you". + + A "library" means a collection of software functions and/or data +prepared so as to be conveniently linked with application programs +(which use some of those functions and data) to form executables. + + The "Library", below, refers to any such software library or work +which has been distributed under these terms. A "work based on the +Library" means either the Library or any derivative work under +copyright law: that is to say, a work containing the Library or a +portion of it, either verbatim or with modifications and/or translated +straightforwardly into another language. (Hereinafter, translation is +included without limitation in the term "modification".) + + "Source code" for a work means the preferred form of the work for +making modifications to it. For a library, complete source code means +all the source code for all modules it contains, plus any associated +interface definition files, plus the scripts used to control compilation +and installation of the library. + + Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running a program using the Library is not restricted, and output from +such a program is covered only if its contents constitute a work based +on the Library (independent of the use of the Library in a tool for +writing it). Whether that is true depends on what the Library does +and what the program that uses the Library does. + + 1. You may copy and distribute verbatim copies of the Library's +complete source code as you receive it, in any medium, provided that +you conspicuously and appropriately publish on each copy an +appropriate copyright notice and disclaimer of warranty; keep intact +all the notices that refer to this License and to the absence of any +warranty; and distribute a copy of this License along with the +Library. + + You may charge a fee for the physical act of transferring a copy, +and you may at your option offer warranty protection in exchange for a +fee. + + 2. You may modify your copy or copies of the Library or any portion +of it, thus forming a work based on the Library, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices + stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no + charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a + table of data to be supplied by an application program that uses + the facility, other than as an argument passed when the facility + is invoked, then you must make a good faith effort to ensure that, + in the event an application does not supply such function or + table, the facility still operates, and performs whatever part of + its purpose remains meaningful. + + (For example, a function in a library to compute square roots has + a purpose that is entirely well-defined independent of the + application. Therefore, Subsection 2d requires that any + application-supplied function or table used by this function must + be optional: if the application does not supply it, the square + root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Library, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Library, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote +it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library +with the Library (or with a work based on the Library) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may opt to apply the terms of the ordinary GNU General Public +License instead of this License to a given copy of the Library. To do +this, you must alter all the notices that refer to this License, so +that they refer to the ordinary GNU General Public License, version 2, +instead of to this License. (If a newer version than version 2 of the +ordinary GNU General Public License has appeared, then you can specify +that version instead if you wish.) Do not make any other change in +these notices. + + Once this change is made in a given copy, it is irreversible for +that copy, so the ordinary GNU General Public License applies to all +subsequent copies and derivative works made from that copy. + + This option is useful when you wish to copy part of the code of +the Library into a program that is not a library. + + 4. You may copy and distribute the Library (or a portion or +derivative of it, under Section 2) in object code or executable form +under the terms of Sections 1 and 2 above provided that you accompany +it with the complete corresponding machine-readable source code, which +must be distributed under the terms of Sections 1 and 2 above on a +medium customarily used for software interchange. + + If distribution of object code is made by offering access to copy +from a designated place, then offering equivalent access to copy the +source code from the same place satisfies the requirement to +distribute the source code, even though third parties are not +compelled to copy the source along with the object code. + + 5. A program that contains no derivative of any portion of the +Library, but is designed to work with the Library by being compiled or +linked with it, is called a "work that uses the Library". Such a +work, in isolation, is not a derivative work of the Library, and +therefore falls outside the scope of this License. + + However, linking a "work that uses the Library" with the Library +creates an executable that is a derivative of the Library (because it +contains portions of the Library), rather than a "work that uses the +library". The executable is therefore covered by this License. +Section 6 states terms for distribution of such executables. + + When a "work that uses the Library" uses material from a header file +that is part of the Library, the object code for the work may be a +derivative work of the Library even though the source code is not. +Whether this is true is especially significant if the work can be +linked without the Library, or if the work is itself a library. The +threshold for this to be true is not precisely defined by law. + + If such an object file uses only numerical parameters, data +structure layouts and accessors, and small macros and small inline +functions (ten lines or less in length), then the use of the object +file is unrestricted, regardless of whether it is legally a derivative +work. (Executables containing this object code plus portions of the +Library will still fall under Section 6.) + + Otherwise, if the work is a derivative of the Library, you may +distribute the object code for the work under the terms of Section 6. +Any executables containing that work also fall under Section 6, +whether or not they are linked directly with the Library itself. + + 6. As an exception to the Sections above, you may also combine or +link a "work that uses the Library" with the Library to produce a +work containing portions of the Library, and distribute that work +under terms of your choice, provided that the terms permit +modification of the work for the customer's own use and reverse +engineering for debugging such modifications. + + You must give prominent notice with each copy of the work that the +Library is used in it and that the Library and its use are covered by +this License. You must supply a copy of this License. If the work +during execution displays copyright notices, you must include the +copyright notice for the Library among them, as well as a reference +directing the user to the copy of this License. Also, you must do one +of these things: + + a) Accompany the work with the complete corresponding + machine-readable source code for the Library including whatever + changes were used in the work (which must be distributed under + Sections 1 and 2 above); and, if the work is an executable linked + with the Library, with the complete machine-readable "work that + uses the Library", as object code and/or source code, so that the + user can modify the Library and then relink to produce a modified + executable containing the modified Library. (It is understood + that the user who changes the contents of definitions files in the + Library will not necessarily be able to recompile the application + to use the modified definitions.) + + b) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (1) uses at run time a + copy of the library already present on the user's computer system, + rather than copying library functions into the executable, and (2) + will operate properly with a modified version of the library, if + the user installs one, as long as the modified version is + interface-compatible with the version that the work was made with. + + c) Accompany the work with a written offer, valid for at + least three years, to give the same user the materials + specified in Subsection 6a, above, for a charge no more + than the cost of performing this distribution. + + d) If distribution of the work is made by offering access to copy + from a designated place, offer equivalent access to copy the above + specified materials from the same place. + + e) Verify that the user has already received a copy of these + materials or that you have already sent this user a copy. + + For an executable, the required form of the "work that uses the +Library" must include any data and utility programs needed for +reproducing the executable from it. However, as a special exception, +the materials to be distributed need not include anything that is +normally distributed (in either source or binary form) with the major +components (compiler, kernel, and so on) of the operating system on +which the executable runs, unless that component itself accompanies +the executable. + + It may happen that this requirement contradicts the license +restrictions of other proprietary libraries that do not normally +accompany the operating system. Such a contradiction means you cannot +use both them and the Library together in an executable that you +distribute. + + 7. You may place library facilities that are a work based on the +Library side-by-side in a single library together with other library +facilities not covered by this License, and distribute such a combined +library, provided that the separate distribution of the work based on +the Library and of the other library facilities is otherwise +permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work + based on the Library, uncombined with any other library + facilities. This must be distributed under the terms of the + Sections above. + + b) Give prominent notice with the combined library of the fact + that part of it is a work based on the Library, and explaining + where to find the accompanying uncombined form of the same work. + + 8. You may not copy, modify, sublicense, link with, or distribute +the Library except as expressly provided under this License. Any +attempt otherwise to copy, modify, sublicense, link with, or +distribute the Library is void, and will automatically terminate your +rights under this License. However, parties who have received copies, +or rights, from you under this License will not have their licenses +terminated so long as such parties remain in full compliance. + + 9. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Library or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Library (or any work based on the +Library), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Library or works based on it. + + 10. Each time you redistribute the Library (or any work based on the +Library), the recipient automatically receives a license from the +original licensor to copy, distribute, link with or modify the Library +subject to these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties with +this License. + + 11. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Library at all. For example, if a patent +license would not permit royalty-free redistribution of the Library by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any +particular circumstance, the balance of the section is intended to apply, +and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 12. If the distribution and/or use of the Library is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Library under this License may add +an explicit geographical distribution limitation excluding those countries, +so that distribution is permitted only in or among countries not thus +excluded. In such case, this License incorporates the limitation as if +written in the body of this License. + + 13. The Free Software Foundation may publish revised and/or new +versions of the Lesser General Public License from time to time. +Such new versions will be similar in spirit to the present version, +but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library +specifies a version number of this License which applies to it and +"any later version", you have the option of following the terms and +conditions either of that version or of any later version published by +the Free Software Foundation. If the Library does not specify a +license version number, you may choose any version ever published by +the Free Software Foundation. + + 14. If you wish to incorporate parts of the Library into other free +programs whose distribution conditions are incompatible with these, +write to the author to ask for permission. For software which is +copyrighted by the Free Software Foundation, write to the Free +Software Foundation; we sometimes make exceptions for this. Our +decision will be guided by the two goals of preserving the free status +of all derivatives of our free software and of promoting the sharing +and reuse of software generally. + + NO WARRANTY + + 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO +WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. +EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY +KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE +LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME +THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN +WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY +AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU +FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR +CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE +LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING +RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A +FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF +SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Libraries + + If you develop a new library, and you want it to be of the greatest +possible use to the public, we recommend making it free software that +everyone can redistribute and change. You can do so by permitting +redistribution under these terms (or, alternatively, under the terms of the +ordinary General Public License). + + To apply these terms, attach the following notices to the library. It is +safest to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least the +"copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 + USA + +Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the library, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the + library `Frob' (a library for tweaking knobs) written by James Random + Hacker. + + , 1 April 1990 + Ty Coon, President of Vice + +That's all there is to it! diff --git a/ci-fixtures/tools/README.md b/ci-fixtures/tools/README.md new file mode 100644 index 00000000..c115eb9e --- /dev/null +++ b/ci-fixtures/tools/README.md @@ -0,0 +1,47 @@ +# ForgeGradle 7 Mavenizer compatibility fixture + +This directory contains a build-only derivative of MinecraftForge's +MinecraftMavenizer `0.5.21`. It is used only while ForgeGradle prepares the +exact Forge `1.21.11-61.1.0` development dependency and is excluded from every +OreSpawn publication artifact. + +## Provenance and licence + +- Upstream: +- Exact source commit: `6968241ce7a0a902cdc1c534b976e8373a423091` +- Upstream version: `0.5.21` +- Licence: LGPL-2.1-only; see `LICENSE-MAVENIZER.txt` +- OreSpawn derivative patch: `minecraft-mavenizer-0.5.21-orespawn-compat.patch` +- Embedded/external rule manifest: `minecraft-source-compatibility.json` + +The patch adds a target-aware compatibility stage before Mavenizer recompiles +decompiled sources. Rules run only for an exact Maven artifact listed in the +manifest. Each rule requires one exact source file, one exact record +declaration and one accessor in that record. Missing, partial, duplicate or +ambiguous states fail preparation. Targets without an explicit rule set are +left unchanged. + +Forge `1.21.11-61.1.0` compiles without source compatibility edits. Its explicit +zero-rule entry proves that result is intentional and target-qualified rather +than an accidental fall-through. A marker beside Mavenizer's output records +the target and manifest SHA-256. Reprocessing the same source is idempotent. + +The derivative also propagates Gradle offline mode when the build sets +`ORESPAWN_MAVENIZER_OFFLINE=true`. Mavenizer itself runs on Java 25; OreSpawn +and Minecraft 1.21.11 continue to compile for Java 21. + +## Rebuild + +1. Clone the upstream repository and detach at + `6968241ce7a0a902cdc1c534b976e8373a423091`. +2. Apply `minecraft-mavenizer-0.5.21-orespawn-compat.patch` with `git am`. +3. Set `JAVA_HOME` to Temurin Java 25. +4. Run `./gradlew clean build --no-daemon` (or `gradlew.bat` on Windows). +5. Copy `build/libs/minecraft-mavenizer-0.5.21.jar` to + `minecraft-mavenizer-0.5.21-orespawn-compat.jar`. +6. Run OreSpawn's `verifyMavenizerCompatibilityFixture` task. The build script + contains the authoritative checksums and also verifies the embedded + manifest, Java class version and licence/provenance files. + +The sealed derivative was built with Eclipse Temurin `25.0.3+9` and Gradle +`9.1.0` from the upstream wrapper. diff --git a/ci-fixtures/tools/minecraft-mavenizer-0.5.21-orespawn-compat.jar b/ci-fixtures/tools/minecraft-mavenizer-0.5.21-orespawn-compat.jar new file mode 100644 index 00000000..06743774 Binary files /dev/null and b/ci-fixtures/tools/minecraft-mavenizer-0.5.21-orespawn-compat.jar differ diff --git a/ci-fixtures/tools/minecraft-mavenizer-0.5.21-orespawn-compat.patch b/ci-fixtures/tools/minecraft-mavenizer-0.5.21-orespawn-compat.patch new file mode 100644 index 00000000..06773643 --- /dev/null +++ b/ci-fixtures/tools/minecraft-mavenizer-0.5.21-orespawn-compat.patch @@ -0,0 +1,381 @@ +From 96468d9931e7f1448d9fb00f13ec0a6559ee0054 Mon Sep 17 00:00:00 2001 +From: OreSpawn Build Fixture +Date: Fri, 28 Aug 2026 15:21:10 +0100 +Subject: [PATCH] Add target-aware source compatibility rules + +--- + build.gradle | 2 +- + .../net/minecraftforge/mcmaven/cli/Main.java | 18 +- + .../minecraftforge/mcmaven/cli/MavenTask.java | 3 + + .../mcmaven/impl/util/ProcessUtils.java | 2 + + .../impl/util/SourceCompatibilityPatcher.java | 247 ++++++++++++++++++ + .../minecraft-source-compatibility.json | 9 + + 6 files changed, 279 insertions(+), 2 deletions(-) + create mode 100644 src/main/java/net/minecraftforge/mcmaven/impl/util/SourceCompatibilityPatcher.java + create mode 100644 src/main/resources/META-INF/orespawn/minecraft-source-compatibility.json + +diff --git a/build.gradle b/build.gradle +index 439c211..9d99d81 100644 +--- a/build.gradle ++++ b/build.gradle +@@ -17,7 +17,7 @@ plugins { + gradleutils.displayName = 'Minecraft Mavenizer' + description = 'A pure-blooded Java tool to generate a maven repository for Minecraft artifacts.' + group = 'net.minecraftforge' +-version = gitversion.tagOffset ++version = '0.5.21' + + println "Version: $version" + +diff --git a/src/main/java/net/minecraftforge/mcmaven/cli/Main.java b/src/main/java/net/minecraftforge/mcmaven/cli/Main.java +index eeda8a0..ad0fe93 100644 +--- a/src/main/java/net/minecraftforge/mcmaven/cli/Main.java ++++ b/src/main/java/net/minecraftforge/mcmaven/cli/Main.java +@@ -12,6 +12,7 @@ import static net.minecraftforge.mcmaven.impl.Mavenizer.LOGGER; + + import java.time.Duration; + import java.util.ArrayList; ++import java.util.Arrays; + + public class Main { + private static final String DISPLAY_NAME = "Minecraft Mavenizer"; +@@ -20,6 +21,8 @@ public class Main { + try { + LOGGER.capture(); + LOGGER.info(JarVersionInfo.of(DISPLAY_NAME, Main.class).implementation()); ++ LOGGER.info("OreSpawn Mavenizer compatibility runtime: Java " + Runtime.version() ++ + " (" + System.getProperty("java.vendor") + ")"); + run(args); + } catch (Throwable e) { + LOGGER.release(); +@@ -36,7 +39,8 @@ public class Main { + LOGGER.getInfo().printf(", took %d:%02d.%03d%n", time.toMinutesPart(), time.toSecondsPart(), time.toMillisPart()); + } + +- private static void run(String[] args) throws Exception { ++ private static void run(String[] suppliedArgs) throws Exception { ++ var args = withOfflinePropagation(suppliedArgs); + var parser = new OptionParser(); + parser.allowsUnrecognizedOptions(); + var tasks = Tasks.values(); +@@ -88,4 +92,16 @@ public class Main { + Tasks.MAVEN.callback.run(args, false); + } + } ++ ++ private static String[] withOfflinePropagation(String[] suppliedArgs) { ++ if (!"true".equalsIgnoreCase(System.getenv("ORESPAWN_MAVENIZER_OFFLINE")) ++ || Arrays.asList(suppliedArgs).contains("--offline")) { ++ return suppliedArgs; ++ } ++ ++ var propagated = Arrays.copyOf(suppliedArgs, suppliedArgs.length + 1); ++ propagated[suppliedArgs.length] = "--offline"; ++ LOGGER.info("OreSpawn Mavenizer compatibility: propagated Gradle offline mode"); ++ return propagated; ++ } + } +diff --git a/src/main/java/net/minecraftforge/mcmaven/cli/MavenTask.java b/src/main/java/net/minecraftforge/mcmaven/cli/MavenTask.java +index 825820e..39bb9b3 100644 +--- a/src/main/java/net/minecraftforge/mcmaven/cli/MavenTask.java ++++ b/src/main/java/net/minecraftforge/mcmaven/cli/MavenTask.java +@@ -21,6 +21,7 @@ import net.minecraftforge.mcmaven.impl.cache.Cache; + import net.minecraftforge.mcmaven.impl.mappings.Mappings; + import net.minecraftforge.mcmaven.impl.util.Artifact; + import net.minecraftforge.mcmaven.impl.util.Constants; ++import net.minecraftforge.mcmaven.impl.util.SourceCompatibilityPatcher; + + import static net.minecraftforge.mcmaven.impl.Mavenizer.LOGGER; + +@@ -194,6 +195,8 @@ class MavenTask { + if (artifact.getVersion() == null) + artifact = artifact.withVersion(options.valueOf(versionO)); + ++ SourceCompatibilityPatcher.selectTarget(artifact); ++ + var mappings = getMappings(options, mappingsO, parchmentO); + + var foreignRepositories = new HashMap(); +diff --git a/src/main/java/net/minecraftforge/mcmaven/impl/util/ProcessUtils.java b/src/main/java/net/minecraftforge/mcmaven/impl/util/ProcessUtils.java +index 9fa2888..d8c14fd 100644 +--- a/src/main/java/net/minecraftforge/mcmaven/impl/util/ProcessUtils.java ++++ b/src/main/java/net/minecraftforge/mcmaven/impl/util/ProcessUtils.java +@@ -305,6 +305,8 @@ public final class ProcessUtils { + throw new RuntimeException("Failed to extract source jar: " + sourcesJar.getAbsolutePath(), e); + } + ++ SourceCompatibilityPatcher.apply(sourcesOutput.toPath(), outputJar.toPath()); ++ + // track source files + var sourcePath = new StringBuilder(); + var nonSourceFiles = new ArrayList(); +diff --git a/src/main/java/net/minecraftforge/mcmaven/impl/util/SourceCompatibilityPatcher.java b/src/main/java/net/minecraftforge/mcmaven/impl/util/SourceCompatibilityPatcher.java +new file mode 100644 +index 0000000..4d72d34 +--- /dev/null ++++ b/src/main/java/net/minecraftforge/mcmaven/impl/util/SourceCompatibilityPatcher.java +@@ -0,0 +1,247 @@ ++/* ++ * Copyright (c) Forge Development LLC and contributors ++ * SPDX-License-Identifier: LGPL-2.1-only ++ */ ++package net.minecraftforge.mcmaven.impl.util; ++ ++import com.google.gson.Gson; ++import java.io.IOException; ++import java.io.InputStream; ++import java.nio.charset.StandardCharsets; ++import java.nio.file.Files; ++import java.nio.file.Path; ++import java.nio.file.StandardCopyOption; ++import java.security.MessageDigest; ++import java.security.NoSuchAlgorithmException; ++import java.util.HashSet; ++import java.util.LinkedHashMap; ++import java.util.List; ++import java.util.Map; ++import java.util.Objects; ++import java.util.stream.Collectors; ++ ++import static net.minecraftforge.mcmaven.impl.Mavenizer.LOGGER; ++ ++/** Applies exact, target-scoped source compatibility rules before recompilation. */ ++public final class SourceCompatibilityPatcher { ++ public static final String MANIFEST_RESOURCE = "META-INF/orespawn/minecraft-source-compatibility.json"; ++ private static final Gson GSON = new Gson(); ++ private static volatile String selectedArtifact; ++ ++ private SourceCompatibilityPatcher() {} ++ ++ public static void selectTarget(Artifact artifact) { ++ selectedArtifact = artifact.getDescriptor(); ++ } ++ ++ public static void apply(Path sourceRoot, Path outputJar) { ++ var artifact = selectedArtifact; ++ if (artifact == null) ++ throw new IllegalStateException("No Mavenizer target artifact was selected before source recompilation"); ++ ++ var manifestBytes = readManifest(); ++ var manifestHash = sha256(manifestBytes); ++ var manifest = GSON.fromJson(new String(manifestBytes, StandardCharsets.UTF_8), RuleManifest.class); ++ if (manifest == null || manifest.schema != 1 || manifest.targets == null) ++ throw new IllegalStateException("Unsupported or incomplete OreSpawn Mavenizer compatibility manifest"); ++ ++ var target = manifest.targets.get(artifact); ++ if (target == null) { ++ LOGGER.info("OreSpawn Mavenizer compatibility: no rules for " + artifact); ++ return; ++ } ++ ++ validateTarget(target); ++ if (target.expectedApplications == 0) { ++ writeMarker(outputJar, artifact, manifestHash, target.rules); ++ LOGGER.info("OreSpawn Mavenizer compatibility: applied 0 rule(s) for " + artifact ++ + " (explicit no-op) manifest SHA-256 " + manifestHash); ++ return; ++ } ++ var sourceByFile = new LinkedHashMap(); ++ var present = 0; ++ var absent = 0; ++ for (var rule : target.rules) { ++ var file = sourceRoot.resolve(rule.file).normalize(); ++ if (!file.startsWith(sourceRoot.normalize())) ++ throw new IllegalStateException("Compatibility rule escapes source root: " + rule.id); ++ if (!Files.isRegularFile(file)) ++ throw new IllegalStateException("Compatibility source file is missing for " + rule.id + ": " + rule.file); ++ ++ var source = sourceByFile.computeIfAbsent(file, ++ ignored -> readUtf8(file).replace("\r\n", "\n")); ++ requireCount(source, rule.recordDeclaration, 1, "record declaration", rule); ++ var accessorCount = countInRecord(source, rule); ++ if (accessorCount == 1) ++ present++; ++ else if (accessorCount == 0) ++ absent++; ++ else ++ throw new IllegalStateException("Ambiguous accessor match for " + rule.id + ": expected at most 1, found " + accessorCount); ++ } ++ ++ if (present != 0 && absent != 0) ++ throw new IllegalStateException("Partial compatibility state for " + artifact + ": " + present + " present, " + absent + " absent"); ++ ++ if (present == target.expectedApplications) { ++ for (var rule : target.rules) { ++ var file = sourceRoot.resolve(rule.file).normalize(); ++ var patched = replaceInRecord(sourceByFile.get(file), rule); ++ sourceByFile.put(file, patched); ++ } ++ sourceByFile.forEach(SourceCompatibilityPatcher::writeUtf8); ++ writeMarker(outputJar, artifact, manifestHash, target.rules); ++ LOGGER.info("OreSpawn Mavenizer compatibility: applied " + present + " rule(s) for " + artifact ++ + " [" + ruleIds(target.rules) + "] manifest SHA-256 " + manifestHash); ++ } else if (absent == target.expectedApplications) { ++ writeMarker(outputJar, artifact, manifestHash, target.rules); ++ LOGGER.info("OreSpawn Mavenizer compatibility: verified " + absent + " rule(s) already applied for " + artifact ++ + " [" + ruleIds(target.rules) + "] manifest SHA-256 " + manifestHash); ++ } else { ++ throw new IllegalStateException("Compatibility manifest expected " + target.expectedApplications ++ + " applications for " + artifact + " but defines " + target.rules.size()); ++ } ++ } ++ ++ private static void validateTarget(TargetRuleSet target) { ++ if (target.expectedApplications < 0 || target.rules == null ++ || target.rules.size() != target.expectedApplications) { ++ throw new IllegalStateException("Compatibility target has an invalid expected application count"); ++ } ++ var ids = new HashSet(); ++ for (var rule : target.rules) { ++ Objects.requireNonNull(rule.id, "Compatibility rule id"); ++ Objects.requireNonNull(rule.file, "Compatibility rule file"); ++ Objects.requireNonNull(rule.recordDeclaration, "Compatibility record declaration"); ++ Objects.requireNonNull(rule.accessor, "Compatibility accessor"); ++ if (!ids.add(rule.id)) ++ throw new IllegalStateException("Duplicate compatibility rule id: " + rule.id); ++ } ++ } ++ ++ private static byte[] readManifest() { ++ try (InputStream in = SourceCompatibilityPatcher.class.getClassLoader().getResourceAsStream(MANIFEST_RESOURCE)) { ++ if (in == null) ++ throw new IllegalStateException("Missing embedded compatibility manifest: " + MANIFEST_RESOURCE); ++ return in.readAllBytes(); ++ } catch (IOException e) { ++ throw new IllegalStateException("Could not read embedded compatibility manifest", e); ++ } ++ } ++ ++ private static String readUtf8(Path file) { ++ try { ++ return Files.readString(file, StandardCharsets.UTF_8); ++ } catch (IOException e) { ++ throw new IllegalStateException("Could not read compatibility source " + file, e); ++ } ++ } ++ ++ private static void writeUtf8(Path file, String content) { ++ try { ++ Files.writeString(file, content, StandardCharsets.UTF_8); ++ } catch (IOException e) { ++ throw new IllegalStateException("Could not write compatibility source " + file, e); ++ } ++ } ++ ++ private static void writeMarker(Path outputJar, String artifact, String manifestHash, List rules) { ++ var marker = Path.of(outputJar.toString() + ".orespawn-compatibility"); ++ var temp = Path.of(marker.toString() + ".tmp"); ++ var content = "artifact=" + artifact + "\nmanifestSha256=" + manifestHash + "\nrules=" + ruleIds(rules) + "\n"; ++ try { ++ Files.writeString(temp, content, StandardCharsets.UTF_8); ++ try { ++ Files.move(temp, marker, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); ++ } catch (java.nio.file.AtomicMoveNotSupportedException ignored) { ++ Files.move(temp, marker, StandardCopyOption.REPLACE_EXISTING); ++ } ++ } catch (IOException e) { ++ throw new IllegalStateException("Could not write compatibility marker " + marker, e); ++ } ++ } ++ ++ private static String ruleIds(List rules) { ++ return rules.stream().map(rule -> rule.id).collect(Collectors.joining(",")); ++ } ++ ++ private static void requireCount(String source, String needle, int expected, String description, Rule rule) { ++ var actual = count(source, needle); ++ if (actual != expected) ++ throw new IllegalStateException("Invalid " + description + " match for " + rule.id ++ + ": expected " + expected + ", found " + actual); ++ } ++ ++ private static int count(String source, String needle) { ++ var count = 0; ++ var from = 0; ++ while ((from = source.indexOf(needle, from)) >= 0) { ++ count++; ++ from += needle.length(); ++ } ++ return count; ++ } ++ ++ private static int countInRecord(String source, Rule rule) { ++ var bounds = recordBounds(source, rule); ++ return count(source.substring(bounds[0], bounds[1]), rule.accessor); ++ } ++ ++ private static String replaceInRecord(String source, Rule rule) { ++ var bounds = recordBounds(source, rule); ++ var body = source.substring(bounds[0], bounds[1]); ++ var count = count(body, rule.accessor); ++ if (count != 1) ++ throw new IllegalStateException("Accessor state changed while applying " + rule.id ++ + ": expected 1, found " + count); ++ var patchedBody = body.replace(rule.accessor, ""); ++ if (count(patchedBody, rule.accessor) != 0) ++ throw new IllegalStateException("Accessor remained after applying " + rule.id); ++ return source.substring(0, bounds[0]) + patchedBody + source.substring(bounds[1]); ++ } ++ ++ private static int[] recordBounds(String source, Rule rule) { ++ var declaration = source.indexOf(rule.recordDeclaration); ++ var open = source.indexOf('{', declaration + rule.recordDeclaration.length()); ++ if (declaration < 0 || open < 0) ++ throw new IllegalStateException("Could not locate record body for " + rule.id); ++ var depth = 0; ++ for (var index = open; index < source.length(); index++) { ++ var character = source.charAt(index); ++ if (character == '{') ++ depth++; ++ else if (character == '}' && --depth == 0) ++ return new int[] {open, index + 1}; ++ } ++ throw new IllegalStateException("Unterminated record body for " + rule.id); ++ } ++ ++ private static String sha256(byte[] bytes) { ++ try { ++ var digest = MessageDigest.getInstance("SHA-256").digest(bytes); ++ var result = new StringBuilder(digest.length * 2); ++ for (byte value : digest) ++ result.append(String.format("%02X", value)); ++ return result.toString(); ++ } catch (NoSuchAlgorithmException e) { ++ throw new IllegalStateException("SHA-256 is unavailable", e); ++ } ++ } ++ ++ private static final class RuleManifest { ++ int schema; ++ Map targets; ++ } ++ ++ private static final class TargetRuleSet { ++ int expectedApplications; ++ List rules; ++ } ++ ++ private static final class Rule { ++ String id; ++ String file; ++ String recordDeclaration; ++ String accessor; ++ } ++} +diff --git a/src/main/resources/META-INF/orespawn/minecraft-source-compatibility.json b/src/main/resources/META-INF/orespawn/minecraft-source-compatibility.json +new file mode 100644 +index 0000000..702d759 +--- /dev/null ++++ b/src/main/resources/META-INF/orespawn/minecraft-source-compatibility.json +@@ -0,0 +1,9 @@ ++{ ++ "schema": 1, ++ "targets": { ++ "net.minecraftforge:forge:1.21.11-61.1.0": { ++ "expectedApplications": 0, ++ "rules": [] ++ } ++ } ++} +-- +2.55.0.windows.3 diff --git a/ci-fixtures/tools/minecraft-source-compatibility.json b/ci-fixtures/tools/minecraft-source-compatibility.json new file mode 100644 index 00000000..4d6c9e88 --- /dev/null +++ b/ci-fixtures/tools/minecraft-source-compatibility.json @@ -0,0 +1,9 @@ +{ + "schema": 1, + "targets": { + "net.minecraftforge:forge:1.21.11-61.1.0": { + "expectedApplications": 0, + "rules": [] + } + } +} diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 04e7b789..a62c49fb 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -12,5 +12,6 @@ Use the focused guides for implementation details: - [BIOMES.md](BIOMES.md) and [DIMENSIONS.md](DIMENSIONS.md) for world integration; - [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; +- [VERSIONS.md](VERSIONS.md) for the shared four-component target-qualified + 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 4d7f6602..14f1ce83 100644 --- a/docs/API.md +++ b/docs/API.md @@ -12,7 +12,7 @@ runtime. In `mods.toml` use a mandatory dependency, for example: [[dependencies.examplemod]] modId="orespawn" mandatory=true -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. + Register custom biomes with Forge as usual. `OreSpawnBiomes.copyAndRegister` provides a small optional convenience for cloning a known biome: @@ -126,10 +133,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 Forge `DeferredRegister` using diff --git a/docs/BIOMES.md b/docs/BIOMES.md index a2654ef6..88c009f5 100644 --- a/docs/BIOMES.md +++ b/docs/BIOMES.md @@ -124,6 +124,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 d35473bf..8cf34978 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 f5f0c1a1..ad0e54ca 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(Identifier.fromNamespaceAndPath("minecraft", "plains")) + .biomeDictionary("FOREST") + .excludeBiome(Identifier.fromNamespaceAndPath("minecraft", "dark_forest")) + .excludeBiomeDictionary("SPOOKY") .hostTag(Identifier.parse("minecraft:stone_ore_replaceables")))) .build(); @@ -114,6 +118,8 @@ Use `.quantity(8)` when every attempt should have a fixed budget. The selector above preserves old OS3 behavior in every ordinary dimension except Nether and End. Add an explicit `.dimension(overworld, ...)` as well when the Overworld needs different settings; the explicit rule overrides the selector there. +Ore dimension builders support the same exact-ID and biome-dictionary include +and exclude filters as provider JSON and fluid-deposit builders. ## Pack Override Quick Start diff --git a/docs/README.md b/docs/README.md index e63cf93c..9fe8ad40 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 005f28db..1625cc42 100644 --- a/docs/VERSIONS.md +++ b/docs/VERSIONS.md @@ -49,11 +49,19 @@ 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.20.6 | Forge | `120061` | `4.0.6.120061` | -| 1.21.11 | Forge | `121111` | `4.0.6.121111` | +| 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.10.118021` | +| 1.19.4 | Forge | `119041` | `4.0.10.119041` | +| 1.20.1 | Forge | `120011` | `4.0.16.120011` | +| 1.20.6 | Forge | `120061` | `4.0.16.120061` | +| 1.21.1 | Forge | `121011` | `4.0.16.121011` | +| 1.21.11 | Forge | `121111` | `4.0.16.121111` | | 26.1.2 | Forge | `2601021` | `4.0.6.2601021` | | 26.2 | Forge | `2602001` | `4.0.6.2602001` | | 26.2 | NeoForge | `2602002` | `4.0.6.2602002` | @@ -138,12 +146,27 @@ 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. - -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. +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 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. Forge 1.15.2 through 1.20.1 then advanced to their +target-qualified 4.0.9 releases for the provider terrain-host ordering repair. +Forge 1.18.2 through 1.20.1 then advanced to their target-qualified 4.0.10 +releases for the distinct Stable Layers actual-height eligibility repair. A +branch may therefore legitimately skip functional version numbers. + +Forge 1.20.1 then advanced to `4.0.11.120011` to retain biome-dictionary +weights and ore biome filters when a data-driven biome is represented by a +different runtime object with the same stable registry key, to +`4.0.12.120011` so public geology samples classify the same highest occupied +block as chunk generation at vertical biome seams, to `4.0.13.120011` to +restore API biome-filter parity and accept provider-namespaced geomes in the +creation editor, and to `4.0.14.120011` to convert exposed one-layer Snow +without touching buried or authored weather materials. It then advanced to +`4.0.15.120011` so generated geology and public samples use the same stable +quart-biome cell at three-dimensional biome boundaries. This provides three useful guarantees: diff --git a/gradle.properties b/gradle.properties index 60e94344..f20398f9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,30 +1,56 @@ +# Sets default memory used for gradle commands. Can be overridden by user or command line properties. +# This is required to provide enough memory for the Minecraft decompilation process. org.gradle.jvmargs=-Xmx3G org.gradle.daemon=false +org.gradle.configuration-cache=false org.gradle.caching=true org.gradle.parallel=false -org.gradle.configureondemand=false -org.gradle.configuration-cache=false - net.minecraftforge.gradle.merge-source-sets=true - ## Environment Properties +# The Minecraft version must agree with the Forge version to get a valid artifact minecraft_version=1.21.11 +# The Minecraft version range can use any release version of Minecraft as bounds. +# Snapshots, pre-releases, and release candidates are not guaranteed to sort properly +# as they do not follow standard versioning conventions. minecraft_version_range=[1.21.11,1.22) +# The Forge version must agree with the Minecraft version to get a valid artifact forge_version=61.1.0 +# The Forge version range can use any version of Forge as bounds or match the loader version range forge_version_range=[61,) +# The loader version range can only use the major version of Forge/FML as bounds loader_version_range=[61,) +# The mapping channel to use for mappings. +# The default set of supported mapping channels are ["official", "snapshot", "snapshot_nodoc", "stable", "stable_nodoc"]. +# Additional mapping channels can be registered through the "channelProviders" extension in a Gradle plugin. +# +# | Channel | Version | | +# |-----------|----------------------|--------------------------------------------------------------------------------| +# | official | MCVersion | Official field/method names from Mojang mapping files | +# | parchment | YYYY.MM.DD-MCVersion | Open community-sourced parameter names and javadocs layered on top of official | +# +# You must be aware of the Mojang license when using the 'official' or 'parchment' mappings. +# See more information here: https://github.com/MinecraftForge/MCPConfig/blob/master/Mojang.md +# +# Parchment is an unofficial project maintained by ParchmentMC, separate from Minecraft Forge. +# Additional setup is needed to use their mappings, see https://parchmentmc.org/docs/getting-started mapping_channel=official +# The mapping version to query from the mapping channel. +# This must match the format required by the mapping channel. mapping_version=1.21.11 - -## Mod Properties - mod_id=orespawn mod_name=MMD OreSpawn mod_license=LGPL-2.1 -mod_version=4.0.6.121111 -mod_group_id=zone.moddev.mc.orespawn +mod_version=4.0.16.121111 +mod_group=zone.moddev.mc.orespawn mod_authors=SkyBlade1978, dshadowwolf, the MMD Team mod_description=Configurable, provider-driven terrain, ore, and deposit generation. + +loader_name=forge +loader_code=1 +java_version=21 +java_toolchain_version=21.0.7+6 +gradle_java_version=21 +curseforge_project_id=245586 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index a4b76b95..0d4a9516 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 8d9046d0..2c68b418 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip -validateDistributionUrl=true +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip +distributionSha256Sum=9c0f7faeeb306cb14e4279a3e084ca6b596894089a0638e68a07c945a32c9e14 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/settings.gradle b/settings.gradle index 1617e80d..e7080f2e 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,5 +1,5 @@ plugins { - id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0' + id('org.gradle.toolchains.foojay-resolver-convention') version '1.0.0' } rootProject.name = 'OreSpawn' 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 e8e48f50..a4c91ce3 100644 --- a/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java +++ b/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java @@ -18,13 +18,17 @@ 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; import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfileManager; import net.minecraft.core.BlockPos; +import net.minecraft.gametest.framework.GameTestServer; import net.minecraft.network.chat.Component; import net.minecraft.resources.Identifier; import net.minecraft.resources.ResourceKey; @@ -75,7 +79,20 @@ public final class SurfaceProbeTestMod { private static final Identifier BIOME_A = Identifier.parse(MODID + ":surface_a"); private static final Identifier BIOME_B = Identifier.parse(MODID + ":surface_b"); private static final Identifier PROBE_GEOME = Identifier.parse(MODID + ":dynamic_biome_geome"); + private static final Identifier PROBE_GEOME_ALTERNATIVE = + Identifier.parse(MODID + ":dynamic_biome_geome_alternative"); private static final Identifier DYNAMIC_FLUID = Identifier.parse(MODID + ":fluid/dynamic_water"); + private static final Identifier DYNAMIC_ORE = + Identifier.parse(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 Identifier[] BUILT_IN_GEOMES = { Identifier.parse("orespawn:stable_craton"), Identifier.parse("orespawn:mountain_belt"), Identifier.parse("orespawn:volcanic_arc"), Identifier.parse("orespawn:sedimentary_basin"), @@ -86,9 +103,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)); @@ -107,6 +128,22 @@ public SurfaceProbeTestMod(FMLJavaModLoadingContext context) { 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) @@ -116,9 +153,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(Identifier.parse(MODID + ":materials/end"), OPEN_ID, + materials -> materials.snowBlock(blockId(Blocks.WHITE_WOOL)) + .iceBlock(blockId(Blocks.BLUE_ICE))); provider.dimensionMaterials(Identifier.parse(MODID + ":materials/nether"), ROOFED_ID, materials -> materials.defaultFluid(blockId(Blocks.WATER))); if (!OreSpawnApi.enqueue(provider.build())) { @@ -129,21 +170,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(Identifier.parse(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 (Identifier geome : BUILT_IN_GEOMES) rock.geomeWeight(geome, 0.0D); }); - provider.rock(Identifier.parse(MODID + ":rock/fallback"), blockId(Blocks.BASALT), + provider.rock(Identifier.parse(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 (Identifier geome : BUILT_IN_GEOMES) rock.geomeWeight(geome, 0.0D); + }); + provider.rock(Identifier.parse(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 (Identifier 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) { @@ -157,6 +216,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(); @@ -170,6 +241,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); @@ -262,6 +335,9 @@ private void auditGeneratedSurfaces(ServerStartedEvent event) { LOGGER.info("SURFACEPROBE PASS phase={} open={} roofed={}", phase, results.get("open"), results.get("roofed")); + if (!(event.getServer() instanceof GameTestServer)) { + event.getServer().execute(() -> event.getServer().halt(false)); + } } private static ServerLevel requireLevel(ServerStartedEvent event, ResourceKey key) { @@ -284,6 +360,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++) { @@ -293,6 +384,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.getMinY(), level.getMaxY()); + Identifier 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; @@ -322,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++; } } @@ -340,28 +442,250 @@ private static AuditResult auditDimension(ServerLevel level, boolean roofed) { } } } - Identifier centerBiome = biomeId(level.getBiome(pos.set(chunkMinX + 8, - findMarkedGround(chunk, pos, chunkMinX + 8, chunkMinZ + 8, - level.getMinY(), level.getMaxY()), 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.getMinY(), level.getMaxY(), 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().identifier() + ": 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.getMinY(), level.getMaxY()); + 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.getMinY(), level.getMaxY()); + 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.getMinY(), level.getMaxY()); + 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) { @@ -498,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; } @@ -592,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.getMinY(), world.getMaxY()); + chunk.setBlockState(pos.set(x, groundY - 12, z), + NATURAL_SOURCES[index].defaultBlockState(), 0); + chunk.setBlockState(pos.set(x, groundY - 11, z), + (index < NATURAL_SOURCES.length / 2 ? Blocks.AIR : Blocks.WATER) + .defaultBlockState(), 0); + } + int bedrockGroundY = findMarkedGround(chunk, pos, minX + 11, minZ + 12, + world.getMinY(), world.getMaxY()); + chunk.setBlockState(pos.set(minX + 11, bedrockGroundY - 24, minZ + 12), + Blocks.BEDROCK.defaultBlockState(), 0); + int chestGroundY = findMarkedGround(chunk, pos, minX + 12, minZ + 12, + world.getMinY(), world.getMaxY()); + 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(); } @@ -615,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; } @@ -630,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.getMinY(), world.getMaxY()); + 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, world.getMinY(), world.getMaxY()); @@ -641,7 +1065,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/forge/tags/worldgen/biome/is_cold.json b/src/biomeIntegrationTest/resources/data/forge/tags/worldgen/biome/is_cold.json new file mode 100644 index 00000000..0c73c984 --- /dev/null +++ b/src/biomeIntegrationTest/resources/data/forge/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..d263c9ee --- /dev/null +++ b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/clientprobe/ClientProbeTestMod.java @@ -0,0 +1,539 @@ +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 java.util.concurrent.CompletableFuture; + +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.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.minecraft.client.input.InputWithModifiers; +import net.minecraftforge.client.event.ScreenEvent; +import net.minecraftforge.fml.common.Mod; +import net.minecraftforge.event.TickEvent; +import net.minecraftforge.fml.util.ObfuscationReflectionHelper; +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) +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; + private static final InputWithModifiers NO_MODIFIERS = new InputWithModifiers() { + @Override public int input() { return 0; } + @Override public int modifiers() { return 0; } + }; + + public ClientProbeTestMod() { + instance = this; + ScreenEvent.Init.Post.BUS.addListener(ClientProbeTestMod::onScreenInitialized); + ScreenEvent.Render.Post.BUS.addListener(ClientProbeTestMod::onScreenDrawn); + TickEvent.RenderTickEvent.Post.BUS.addListener(ClientProbeTestMod::onWorldRendered); + TickEvent.ClientTickEvent.Post.BUS.addListener(ClientProbeTestMod::onClientTick); + } + + 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(); + } + + public static void onScreenDrawn(ScreenEvent.Render.Post event) { + ClientProbeTestMod probe = instance; + if (probe != null && Boolean.getBoolean("clientprobe.enabled") + && isOreSpawnEditor(event.getScreen())) probe.editorFrames++; + } + + public static void onWorldRendered(TickEvent.RenderTickEvent.Post event) { + ClientProbeTestMod probe = instance; + if (probe == null || !Boolean.getBoolean("clientprobe.enabled")) return; + if (probe.state == 6) probe.firstWorldFrames++; + if (probe.state == 8) probe.reloadWorldFrames++; + } + + public static void onClientTick(TickEvent.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 TitleScreen) { + Screen parent = minecraft.screen; + CreateWorldScreen.openFresh(minecraft, () -> minecraft.setScreen(parent)); + 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; + press(target); + 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) { + minecraft.setScreen(screen); + if (minecraft.screen != screen) { + throw new IllegalStateException("Could not initialize target-native editor"); + } + } + + 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)) { + press((Button) widget); + 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)) { + press((Button) listener); + 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")) { + press((Button) widget); + 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")) { + press((Button) widget); + 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")) { + press((Button) widget); + 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 = ObfuscationReflectionHelper.getPrivateValue( + CreateWorldScreen.class, (CreateWorldScreen) screen, "f_267424_"); + 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) { + // Forge 61 cannot safely tear down an integrated server from inside the + // post-client-tick callback, and disconnect no longer requests the server + // halt by itself. Signal the server first, then queue the disconnect from a + // different thread so the event returns before the save-and-stop loop. + if (minecraft.getSingleplayerServer() != null) { + minecraft.getSingleplayerServer().halt(false); + } + CompletableFuture.runAsync(() -> minecraft.execute( + () -> minecraft.disconnect(new TitleScreen(), false))); + } + + private static void press(Button button) { + button.onPress(NO_MODIFIERS); + } + + 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 Forge 1.21.11 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/mods.toml b/src/clientIntegrationTest/resources/META-INF/mods.toml new file mode 100644 index 00000000..df514258 --- /dev/null +++ b/src/clientIntegrationTest/resources/META-INF/mods.toml @@ -0,0 +1,30 @@ +modLoader="javafml" +loaderVersion="[61,)" +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="forge" +mandatory=true +versionRange="[61.1.0,62)" +ordering="NONE" +side="CLIENT" + +[[dependencies.clientprobe]] +modId="orespawn" +mandatory=true +versionRange="[4.0.6,5.0.0)" +ordering="AFTER" +side="CLIENT" + +[[dependencies.clientprobe]] +modId="minecraft" +mandatory=true +versionRange="[1.21.11,1.22)" +ordering="NONE" +side="CLIENT" diff --git a/src/clientIntegrationTest/resources/pack.mcmeta b/src/clientIntegrationTest/resources/pack.mcmeta new file mode 100644 index 00000000..08520310 --- /dev/null +++ b/src/clientIntegrationTest/resources/pack.mcmeta @@ -0,0 +1,10 @@ +{ + "pack": { + "description": "OreSpawn client qualification fixture", + "max_format": 94, + "min_format": [ + 94, + 1 + ] + } +} 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 4fe64529..8b917f2a 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.Identifier; @@ -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.getMinY()); + Holder holder = TerrainBiomeLookup.atBlock(level, blockX, biomeY, blockZ); Identifier biomeId = holder.unwrapKey().map(ResourceKey::identifier) .orElse(Identifier.fromNamespaceAndPath("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 Identifier 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 9d1484ab..733fad88 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(Identifier type, JsonObject settings) { public Builder geomeWeight(Identifier geome, double value) { geomes.put(geome, value); return this; } public Builder hostBlock(Identifier value) { hostBlocks.add(value); return this; } public Builder hostTag(Identifier value) { hostTags.add(value); return this; } + public Builder biome(Identifier value) { biomeIds.add(value); return this; } + public Builder excludeBiome(Identifier 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(Identifier 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 97391fc8..4b53af6b 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 8c7d57b1..a601c5c5 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java @@ -637,7 +637,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(); @@ -697,7 +697,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; } @@ -1156,6 +1156,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(Identifier.parse(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 f7521821..060e7684 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/OreDimensionScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreDimensionScreen.java @@ -236,8 +236,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; @@ -249,8 +249,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; @@ -258,8 +258,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 b8192939..54a9de16 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 4d563a12..fec924ff 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/BakedGeomeConfig.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/BakedGeomeConfig.java @@ -35,6 +35,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; @@ -45,6 +46,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, @@ -61,7 +65,7 @@ public final class BakedGeomeConfig { for (Map.Entry entry : biomeWeights.entrySet()) { Identifier biomeId = ForgeRegistries.BIOMES.getKey(entry.getKey()); if (biomeId != null) { - biomeWeightsById.put(biomeId, entry.getValue()); + this.biomeWeightsById.putIfAbsent(biomeId, entry.getValue()); } } this.fallbackWeights = defaultWeights(geomes.length); @@ -72,6 +76,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; @@ -152,15 +157,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; } @@ -227,12 +286,12 @@ int familyDiversitySlots() { } String describeBiomeWeights(Biome biome) { - double[] weights = biomeWeights.get(biome); - String source = "identity"; + Identifier biomeId = ForgeRegistries.BIOMES.getKey(biome); + double[] weights = biomeId == null ? null : biomeWeightsById.get(biomeId); + String source = "registry-id"; if (weights == null) { - Identifier biomeId = ForgeRegistries.BIOMES.getKey(biome); - weights = biomeId == null ? null : biomeWeightsById.get(biomeId); - source = "registry-id"; + weights = biomeWeights.get(biome); + source = "identity"; } if (weights == null) { weights = fallbackWeights; @@ -271,10 +330,8 @@ boolean hasDistinctBiomeWeights(Biome biome) { } private double[] biomeWeightsFor(Biome biome, Identifier 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; } @@ -283,9 +340,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]; @@ -294,14 +351,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)); } @@ -313,7 +369,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; } @@ -363,8 +421,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; @@ -377,6 +439,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) { @@ -631,6 +712,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 3e5c549b..4e6c8922 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.Identifier; 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 c0924c1b..01c4ddfc 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeTypeCompatibility.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeTypeCompatibility.java @@ -11,6 +11,7 @@ import java.util.Set; import net.minecraft.core.Holder; +import net.minecraft.core.Registry; import net.minecraft.core.registries.Registries; import net.minecraft.resources.ResourceKey; import net.minecraft.resources.Identifier; @@ -26,10 +27,19 @@ */ final class BiomeTypeCompatibility { private static final Map>> TYPES = types(); + private static volatile Registry activeRegistry; private BiomeTypeCompatibility() { } + static void useRegistry(Registry registry) { + activeRegistry = registry; + } + + static void clearRegistry() { + activeRegistry = null; + } + static Set types(Biome biome) { Holder holder = ForgeRegistries.BIOMES.getHolder(biome).orElse(null); if (holder == null) return Collections.emptySet(); @@ -50,6 +60,16 @@ static Set biomes(String type) { static Set> biomeKeys(String type) { Set> result = new LinkedHashSet<>(); + Registry registry = activeRegistry; + if (registry != null) { + for (Map.Entry, Biome> entry : registry.entrySet()) { + if (registry.get(entry.getKey()) + .map(holder -> matches(holder, tags(type))).orElse(false)) { + result.add(entry.getKey()); + } + } + return result; + } for (Biome biome : biomes(type)) { Identifier id = ForgeRegistries.BIOMES.getKey(biome); if (id != null) result.add(ResourceKey.create(Registries.BIOME, id)); @@ -58,10 +78,21 @@ static Set> biomeKeys(String type) { } static boolean hasType(ResourceKey key, String type) { + Registry registry = activeRegistry; + if (registry != null) { + return registry.get(key) + .map(holder -> matches(holder, tags(type))).orElse(false); + } return ForgeRegistries.BIOMES.getHolder(key) .map(holder -> matches(holder, tags(type))).orElse(false); } + static Biome biome(ResourceKey key) { + Registry registry = activeRegistry; + return registry == null ? ForgeRegistries.BIOMES.getValue(key.identifier()) + : registry.getValue(key.identifier()); + } + static boolean hasType(Biome biome, String type) { return ForgeRegistries.BIOMES.getHolder(biome) .map(holder -> matches(holder, tags(type))).orElse(false); 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 fbffbd96..dc746e31 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.getMinY(); 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, 0); 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 d5fca55e..99b03971 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, Identifier 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,53 @@ 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 { Identifier biomeId = Identifier.parse(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.identifier(), 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, + Identifier 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, Identifier 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 948f9df6..7232e67a 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(); Identifier biomeId = biomeKey.isPresent() ? biomeKey.get().identifier() : null; @@ -119,7 +118,8 @@ public void replaceStoneInChunk(LevelAccessor world, ChunkAccess chunk, BakedTer } else { for (int y = surfaceY; y >= chunk.getMinY(); 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), 0); changed = true; @@ -142,7 +142,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); @@ -153,11 +152,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, 0); + if (terrain.isReplaceable(chunk.getBlockState(cursor)) + && chunk.getBlockEntity(cursor) == null) { + chunk.setBlockState(cursor, + pickStableReplacement(layerGeome, formationRegion, layerIndex, y), 0); changed = true; } } @@ -250,7 +250,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); @@ -260,7 +260,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; @@ -286,8 +286,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 d45539aa..33685939 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.121111 Upgrade Report"); + lines.add("OreSpawn 4.0.16.121111 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 18c20ba2..c8872fc6 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.121111 Upgrade Report"); + lines.add("OreSpawn 4.0.16.121111 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 5dc0b43e..170b5268 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(BiomeGenerationSettings.PlainBuilder 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(BiomeGenerationSettings.PlainBuilder 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 a227a2e9..a341bc3b 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)) { - Identifier biomeId = biome.unwrapKey().map(ResourceKey::identifier).orElse(null); + Identifier biomeId = biomeKey == null ? null : biomeKey.identifier(); 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)) { Identifier id = resource(element.getAsString()); - Biome biome = id == null ? null : ForgeRegistries.BIOMES.getValue(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 : ForgeRegistries.BLOCKS.getValues()) { @@ -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 0effed17..6c516cfc 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/WorldGeologyProfileManager.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileManager.java index 8e849305..96cbeaaf 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileManager.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileManager.java @@ -22,6 +22,7 @@ import zone.moddev.mc.orespawn.integration.WorldgenIntegrationManager; import net.minecraft.server.MinecraftServer; +import net.minecraft.core.registries.Registries; import net.minecraft.world.level.Level; import net.minecraft.world.level.storage.LevelResource; import net.minecraftforge.event.server.ServerAboutToStartEvent; @@ -118,6 +119,8 @@ public static synchronized boolean reloadActiveProfile() { public static void onServerAboutToStart(ServerAboutToStartEvent event) { activeServer = event.getServer(); + BiomeTypeCompatibility.useRegistry(event.getServer().registryAccess() + .lookupOrThrow(Registries.BIOME)); Path worldRoot = event.getServer().getWorldPath(LevelResource.ROOT).normalize(); Path profilePath = worldRoot.resolve("serverconfig").resolve(PROFILE_FILE_NAME); WorldGeologyProfile fallback = globalProfile(); @@ -174,6 +177,7 @@ public static void onServerStopped(ServerStoppedEvent event) { VanillaSpringCompatibility.clear(event.getServer().registryAccess()); activeServer = null; activeProfile = null; + BiomeTypeCompatibility.clearRegistry(); GeomeConfig.applyWorldProfile(globalProfile()); BiomeWorldgenManager.clear(); StoneReplacer.refreshWorldConfig(); 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 72330110..6fefa814 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.getMaxY()) { + cursor.set(minX + localX, top + 1, minZ + localZ); + if (chunk.getBlockState(cursor).is(Blocks.SNOW)) { + chunk.setBlockState(cursor, materials.snow, 0); + } + } 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 83d12248..b4e29958 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.Identifier; +import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerLevel; import net.minecraft.world.level.Level; import net.minecraft.world.level.block.Block; @@ -139,11 +141,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/main/resources/META-INF/mods.toml b/src/main/resources/META-INF/mods.toml index d9a99892..d00a284d 100644 --- a/src/main/resources/META-INF/mods.toml +++ b/src/main/resources/META-INF/mods.toml @@ -6,7 +6,7 @@ # The name of the mod loader type to load - for regular FML @Mod mods it should be javafml modLoader="javafml" #mandatory # A version range to match for said mod loader - for regular FML @Mod it will be the forge version -loaderVersion="[61,)" #mandatory This is typically bumped every Minecraft version by Forge. See our download page for lists of versions. +loaderVersion="${loader_version_range}" #mandatory This is typically bumped every Minecraft version by Forge. See our download page for lists of versions. # The license for you mod. This is mandatory metadata and allows for easier comprehension of your redistributive properties. # Review your options at https://choosealicense.com/. All rights reserved is the default copyright stance, and is thus the default here. license="LGPL-2.1" @@ -17,7 +17,7 @@ issueTrackerURL="https://github.com/SkyBlade1978/OreSpawn/issues" #optional # The modid of the mod modId="orespawn" #mandatory # The version number of the mod -version="${file.jarVersion}" #mandatory +version="${version}" #mandatory # A display name for the mod displayName="MMD OreSpawn" #mandatory # A URL to query for updates for this mod. See the JSON update specification https://docs.minecraftforge.net/en/latest/misc/updatechecker/ @@ -47,7 +47,7 @@ modId="forge" #mandatory # Does this dependency have to exist - if not, ordering below must be specified mandatory=true #mandatory # The version range of the dependency -versionRange="[61,)" #mandatory +versionRange="${forge_version_range}" #mandatory # An ordering relationship for the dependency - BEFORE or AFTER required if the dependency is not mandatory # BEFORE - This mod is loaded BEFORE the dependency # AFTER - This mod is loaded AFTER the dependency @@ -59,6 +59,6 @@ side="BOTH" modId="minecraft" mandatory=true # This version range declares a minimum of the current minecraft version up to but not including the next major version -versionRange="[1.21.11,1.22)" +versionRange="${minecraft_version_range}" ordering="NONE" side="BOTH" 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..175a9913 --- /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.21.1 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..9965c465 --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/ReleaseWorkflowContractTest.java @@ -0,0 +1,109 @@ +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")); + } + + @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 hostedWorkflowsUseThePinnedTemurinJdkForGradleAndCompilation() 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; + int gradleInvocationCount = workflow.equals("ci.yml") ? 3 : 1; + assertEquals(jobCount * 3, occurrences(text, "actions/setup-java@"), + workflow + " must install Mavenizer, launcher, and production JDKs per job"); + assertTrue(text.contains("distribution: temurin"), workflow + " must use Temurin"); + assertEquals(jobCount, occurrences(text, "java-version: '25.0.3+9.0.LTS'"), + workflow + " must install the exact Mavenizer Java runtime"); + assertEquals(jobCount, occurrences(text, "java-version: '8.0.502+7'"), + workflow + " must install the exact legacy launcher toolchain"); + assertEquals(jobCount, occurrences(text, "java-version: '21.0.7+6.0.LTS'"), + workflow + " must install the exact qualified Java runtime"); + assertTrue(text.lastIndexOf("java-version: '21.0.7+6.0.LTS'") + > text.lastIndexOf("java-version: '25.0.3+9.0.LTS'"), + workflow + " must leave Java 21 as JAVA_HOME"); + assertTrue(text.lastIndexOf("java-version: '21.0.7+6.0.LTS'") + > text.lastIndexOf("java-version: '8.0.502+7'"), + workflow + " must install Java 21 last so it remains JAVA_HOME"); + assertEquals(gradleInvocationCount, occurrences(text, + "-Dorg.gradle.java.installations.paths="), + workflow + " must limit Gradle discovery to the pinned JDKs"); + assertEquals(gradleInvocationCount, occurrences(text, + "$JAVA_HOME,$JAVA_HOME_8_X64,$JAVA_HOME_25_X64"), + workflow + " must use only the explicit pinned JDK paths"); + assertEquals(gradleInvocationCount, occurrences(text, + "-Dorg.gradle.java.installations.auto-detect=false"), + workflow + " must reject preinstalled runner toolchains"); + assertEquals(gradleInvocationCount, occurrences(text, + "-Dorg.gradle.java.installations.auto-download=false"), + workflow + " must not silently replace pinned toolchains"); + assertFalse(text.contains("distribution: microsoft"), + workflow + " must not replace the exact Temurin Gradle runtime"); + } + } + + @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=("), "CodeQL must pass Gradle options as an argument vector"); + assertTrue(text.contains("for attempt in 1 2 3; do"), + "CodeQL must bound transient Mavenizer download retries"); + assertTrue(text.contains("./gradlew \"${gradle_args[@]}\""), + "CodeQL retries must preserve exact Gradle arguments"); + assertTrue(text.contains("failed after $attempt attempts"), + "CodeQL must fail rather than hide a persistent bootstrap defect"); + } + + @Test + void eclipseOutputsCannotNestInsideForgeMergedOutput() throws Exception { + String build = new String(Files.readAllBytes(Paths.get("build.gradle")), StandardCharsets.UTF_8); + assertTrue(build.contains("defaultOutputDir = file('bin/default')")); + assertTrue(build.contains("entry instanceof org.gradle.plugins.ide.eclipse.model.Output")); + assertTrue(build.contains("entry.path = 'bin/default'")); + assertTrue(build.contains("entry.output = 'bin/main'")); + assertTrue(build.contains("entry.output = 'bin/test'")); + assertTrue(build.contains("Eclipse outputs must be disjoint"), + "The real generated classpath must reject nested Buildship outputs"); + } + + 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 5a789e53..3e11e8da 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.Identifier; class WorldgenProviderTest { + @Test + void terrainHostContractRetainsNaturalSourceOrder() { + Identifier 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() { Identifier overworld = id("minecraft:overworld"); @@ -242,6 +269,68 @@ void serializesRangedQuantityAndBroadDimensionSelector() { assertFalse(rule.has("quantity")); } + @Test + void oreBiomeFiltersMatchFluidBuilderForDimensionsAndSelectors() { + Identifier overworld = id("minecraft:overworld"); + Identifier plains = id("minecraft:plains"); + Identifier 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..b4d7b914 100644 --- a/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java @@ -4,6 +4,10 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + import org.junit.jupiter.api.Test; import net.minecraft.client.gui.GuiGraphics; @@ -16,6 +20,16 @@ void sharedScreenOwnsTheFinalRenderOrder() throws NoSuchMethodException { .getModifiers())); } + @Test + void sharedScreenUsesTargetNativeBackgroundBeforeForeground() throws IOException { + String source = Files.readString(Path.of("src/main/java/zone/moddev/mc/orespawn/client/OreSpawnScreen.java")); + int render = source.indexOf("public final void render("); + int backgroundAndWidgets = source.indexOf("super.render(graphics", render); + int foreground = source.indexOf("renderForeground(graphics", render); + assertTrue(render >= 0 && backgroundAndWidgets > render && foreground > backgroundAndWidgets, + "The 1.21.11 Screen render pass must finish before OreSpawn foreground text"); + } + @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 768e1106..d38ea5ba 100644 --- a/src/test/java/zone/moddev/mc/orespawn/worldgen/BiomeSurfaceFeatureOrderTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/BiomeSurfaceFeatureOrderTest.java @@ -13,6 +13,7 @@ class BiomeSurfaceFeatureOrderTest { @Test void surfacesRunBeforeStructuresAndVegetationWhileFlatBedrockStaysLast() { + StoneReplacer.registerConfiguredFeature(); BiomeSurfaceFeature.registerConfiguredFeature(); FlatBedrockFeature.registerConfiguredFeature(); BiomeGenerationSettings.PlainBuilder generation = @@ -24,7 +25,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 05c283fe..9aced1e5 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.Identifier; +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(); + Identifier dynamicId = Identifier.parse("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); + Identifier marshmallowPeaks = Identifier.parse("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,21 @@ private static BakedGeomeConfig config(Map biomeWeightsByI 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() + .waterColor(0x3F76E4) + .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 9bdef311..812c48db 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 68aa8f3b..ee21a7a0 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, + Identifier.parse("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, Identifier.parse("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, Identifier.parse("cakeworld:candy_plains")))); + assertFalse(OreSpawnOreGeneration.acceptsBiome(Set.of(), resolved, sodaOcean)); + assertTrue(OreSpawnOreGeneration.acceptsBiome(Set.of(), resolved, ResourceKey.create( + Registries.BIOME, Identifier.parse("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 97b594e5..3b80d924 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.Identifier; 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, + Identifier.fromNamespaceAndPath("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 cad08007..e67fbf2a 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.Identifier; +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)); + } }