diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7fc461d3..fe27444a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,24 +1,100 @@ -name: CI +name: OreSpawn 26.2 NeoForge CI -on: [push, pull_request] -#on: -# push: -# branches: [ master-1.12 ] -# pull_request: -# # The branches below must be a subset of the branches above -# branches: [ master-1.12 ] -# types: [opened, synchronize, reopened] +on: + push: + branches: + - master-26.2-neo + - 'feature/**' + pull_request: + branches: + - master-26.2-neo + +permissions: + contents: read + +concurrency: + group: orespawn-26.2-neo-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: + cold-bootstrap: + name: Cold NeoForge bootstrap + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - name: Install exact Java 25 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '25.0.3+9.0.LTS' + - name: Bootstrap an isolated empty NeoGradle cache + shell: bash + run: | + set -euo pipefail + chmod +x ./gradlew + test ! -d .gradle + export GRADLE_USER_HOME="$RUNNER_TEMP/orespawn-neoforge-cold-cache" + rm -rf "$GRADLE_USER_HOME" + gradle_jdk_args=( + "-Dorg.gradle.java.installations.paths=$JAVA_HOME" + -Dorg.gradle.java.installations.auto-detect=false + -Dorg.gradle.java.installations.auto-download=false + ) + ./gradlew clean --no-daemon --no-build-cache --stacktrace --max-workers=2 "${gradle_jdk_args[@]}" + ./gradlew classes verifyLegacyFixtures --no-daemon --no-build-cache --stacktrace --max-workers=2 "${gradle_jdk_args[@]}" + ./gradlew clean --offline --no-daemon --no-build-cache --stacktrace --max-workers=2 "${gradle_jdk_args[@]}" + ./gradlew classes verifyLegacyFixtures --offline --no-daemon --no-build-cache --stacktrace --max-workers=2 "${gradle_jdk_args[@]}" + build: + name: Build, test, and audit runs-on: ubuntu-latest - name: Build + timeout-minutes: 60 steps: - - uses: actions/checkout@v2 - - uses: actions/setup-java@v1 + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - name: Install exact Java 25 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '25.0.3+9.0.LTS' + - name: Set up Gradle + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6 + - name: Build, test, and audit release artifacts + shell: bash + run: | + set -euo pipefail + chmod +x ./gradlew + gradle_jdk_args=( + "-Dorg.gradle.java.installations.paths=$JAVA_HOME" + -Dorg.gradle.java.installations.auto-detect=false + -Dorg.gradle.java.installations.auto-download=false + ) + ./gradlew clean --no-daemon --stacktrace --max-workers=2 "${gradle_jdk_args[@]}" + ./gradlew check build javadoc verifyReleaseArtifacts writeReleaseChecksums eclipse verifyEclipseProductionClasspath --no-daemon --stacktrace --max-workers=2 "${gradle_jdk_args[@]}" + - name: Upload audited release candidate + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: OreSpawn-26.2-neoforge-${{ github.sha }} + if-no-files-found: error + retention-days: 30 + path: | + build/libs/OreSpawn-4.0.16.2602002.jar + build/libs/OreSpawn-4.0.16.2602002-sources.jar + build/libs/OreSpawn-4.0.16.2602002-javadoc.jar + build/release/SHA256SUMS + CHANGELOG.txt + - name: Upload diagnostics on failure + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - java-version: 8 - - run: chmod a+x gradlew - - run: ./gradlew --version --no-daemon - - run: ./gradlew setupCIWorkspace -S - - run: ./gradlew clean build -S + name: OreSpawn-26.2-neoforge-diagnostics-${{ github.sha }} + if-no-files-found: ignore + retention-days: 14 + path: | + build/test-results/** + build/reports/** + build/*-run/logs/** + build/surface-integration-run/**/*.properties + build/problems/** diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index d5a02752..04ad565b 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -1,73 +1,64 @@ -# 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-26.2-neo + - 'feature/**' + pull_request: + branches: + - master-26.2-neo + schedule: + - cron: '43 7 * * 4' + +permissions: + actions: read + contents: read + security-events: write jobs: analyze: - name: Analyze + name: Analyze Java runs-on: ubuntu-latest - permissions: - actions: read - contents: read - security-events: write - - strategy: - fail-fast: false - matrix: - language: [ 'java' ] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] - # Learn more: - # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed - + timeout-minutes: 45 steps: - - name: Checkout repository - uses: actions/checkout@v2 - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v1 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - # queries: ./path/to/local/query, your-org/your-repo/queries@main - - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v1 - - # â„šī¸ Command-line programs to run using the OS shell. - # 📚 https://git.io/JvXDl - - # âœī¸ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language - - #- run: | - # make bootstrap - # make release - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - name: Install exact Java 25 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '25.0.3+9.0.LTS' + - name: Set up Gradle + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6 + - name: Initialize CodeQL + uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4 + with: + languages: java-kotlin + - name: Compile production code + shell: bash + run: | + set -euo pipefail + chmod +x ./gradlew + gradle_jdk_args=( + "-Dorg.gradle.java.installations.paths=$JAVA_HOME" + -Dorg.gradle.java.installations.auto-detect=false + -Dorg.gradle.java.installations.auto-download=false + ) + ./gradlew clean --no-daemon --stacktrace --max-workers=2 "${gradle_jdk_args[@]}" + gradle_args=( + classes --no-daemon --stacktrace --max-workers=2 + "${gradle_jdk_args[@]}" + ) + for attempt in 1 2 3; do + if ./gradlew "${gradle_args[@]}"; then + exit 0 + fi + if [ "$attempt" -eq 3 ]; then + echo "CodeQL compilation failed after $attempt attempts." >&2 + exit 1 + fi + echo "::warning::CodeQL compilation attempt $attempt failed; retrying with the preserved NeoGradle cache." + done + - name: Analyze + uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4 diff --git a/.github/workflows/release-on-tag.yml b/.github/workflows/release-on-tag.yml new file mode 100644 index 00000000..83bc858f --- /dev/null +++ b/.github/workflows/release-on-tag.yml @@ -0,0 +1,94 @@ +name: Start OreSpawn release from tag + +on: + push: + tags: + - '*.*.*.*' + +permissions: + actions: read + contents: read + +concurrency: + group: orespawn-release-starter-${{ github.ref_name }} + cancel-in-progress: false + +jobs: + validate-release-tag: + name: Validate tag for manual release confirmation + if: github.repository == 'MinecraftModDevelopmentMods/OreSpawn' + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Check out tagged source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + + - name: Validate release tag, target metadata, and prior CI + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + value() { sed -n "s/^$1=//p" gradle.properties; } + release_version="$(value mod_version)" + minecraft_version="$(value minecraft_version)" + loader_name="$(value loader_name)" + loader_code="$(value loader_code)" + + IFS=. read -r mc_major mc_minor mc_patch extra <<<"$minecraft_version" + if [[ -n "${extra:-}" || -z "${mc_major:-}" || -z "${mc_minor:-}" ]]; then + echo "Invalid minecraft_version=$minecraft_version" >&2 + exit 1 + fi + mc_patch="${mc_patch:-0}" + if [[ ! "$mc_major" =~ ^[0-9]+$ || ! "$mc_minor" =~ ^[0-9]+$ || ! "$mc_patch" =~ ^[0-9]+$ ]]; then + echo "Invalid minecraft_version=$minecraft_version" >&2 + exit 1 + fi + case "$loader_name:$loader_code" in + forge:1|neoforge:2) ;; + *) echo "Invalid loader metadata $loader_name/$loader_code" >&2; exit 1 ;; + esac + printf -v minor_padded '%02d' "$((10#$mc_minor))" + printf -v patch_padded '%02d' "$((10#$mc_patch))" + target_suffix="${mc_major}${minor_padded}${patch_padded}${loader_code}" + + if [[ ! "$release_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.${target_suffix}$ ]]; then + echo "mod_version $release_version does not match $minecraft_version $loader_name target $target_suffix" >&2 + exit 1 + fi + if [[ "$GITHUB_REF_NAME" != "$release_version" ]]; then + echo "Release tag must equal mod_version $release_version; found $GITHUB_REF_NAME" >&2 + exit 1 + fi + + successful_ci="$(gh api \ + "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/check-runs?per_page=100" \ + --jq '[.check_runs[] | select(.name == "Build, test, and audit" and .conclusion == "success")] | length')" + if [[ "$successful_ci" -lt 1 ]]; then + echo "The tagged commit has no successful Build, test, and audit check" >&2 + exit 1 + fi + + - name: Record the required manual publication step + env: + RELEASE_WORKFLOW_URL: https://github.com/${{ github.repository }}/actions/workflows/deploy-release.yml + run: | + { + echo "## Release candidate validated" + echo + echo "Tag \`$GITHUB_REF_NAME\` matches the selected target and has a successful Build, test, and audit check." + echo + echo "**Nothing has been published.**" + echo + echo "To continue, open [Deploy OreSpawn release]($RELEASE_WORKFLOW_URL), select **Run workflow**, and enter:" + echo + echo "- release_version: \`$GITHUB_REF_NAME\`" + echo "- curseforge_release_level: \`release\`, \`beta\`, or \`alpha\`" + echo "- confirm_live_publication: \`true\`" + echo + echo "The dispatcher builds and audits the immutable bundle before the separate \`release\` environment approval gate." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml deleted file mode 100644 index c9c52a54..00000000 --- a/.github/workflows/sonarqube.yml +++ /dev/null @@ -1,30 +0,0 @@ -on: [push, pull_request] -#on: -# push: -# branches: -# - master-1.12 -# pull_request: -# types: [opened, synchronize, reopened] -# -name: SonarCloud -jobs: - sonarcloud: - name: SonarCloud Scan - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - with: - # Disabling shallow clone is recommended for improving relevancy of reporting - fetch-depth: 0 - - name: SonarCloud Scan - uses: SonarSource/sonarcloud-github-action@master - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} -# SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} - - name: SonarCloud Quality Gate check - uses: SonarSource/sonarqube-quality-gate-action@master - # Force to fail step after specific time - timeout-minutes: 5 - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.github/workflows/validate-gradle-build.yml b/.github/workflows/validate-gradle-build.yml index 528f4b5a..28b30740 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-26.2-neo + - 'feature/**' + pull_request: + branches: + - master-26.2-neo + +permissions: + contents: read jobs: validation: - name: "Validation" + name: Validation runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: gradle/wrapper-validation-action@v1 + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - name: Validate wrapper integrity + uses: gradle/actions/wrapper-validation@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6 diff --git a/.gitignore b/.gitignore index e84eddae..dc855b3a 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,8 @@ run classes logs /mcmodsrepo/ +/src/generated/resources/META-INF/orespawn/docs/ +/config/orespawn-worldgen.json # machine-specific agent context (public integration notes live under /docs) /AGENTS.md @@ -45,6 +47,8 @@ logs # local regression, benchmark, and profiling evidence /run-*/ /benchmark-*/ +/preferred-seed-*/ +/validation/ /regression-*/ /evidence/ /evidence-*/ diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 4a3a15ca..89b01d86 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,69 @@ +Version 4.0.16.2602002 + +* Leave benchmark shutdown to the GameTest harness when a benchmark is run + through Minecraft's GameTest server, preventing a null test-tracker crash and + allowing the harness to report its real test result. +* Ordinary dedicated benchmark servers still stop automatically when requested. + +Version 4.0.15.2602002 + +* 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.2602002 + +* 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.2602002 + +* 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.2602002 + +* 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.2602002 + +* 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.2602002 + +* Evaluate Stable Layers rock minimum and maximum heights against actual world Y while preserving shifted formation identity. +* Deterministically choose another eligible configured rock before using the vanilla Stone fallback. + +Version 4.0.9.2602002 + +* Replace provider-declared natural terrain hosts during the existing geology scan before structure and vegetation features can author matching blocks. +* Keep air, fluids, bedrock and block entities protected even when their IDs are mistakenly declared as terrain hosts. +* Apply the correction only while generating new chunks; existing chunks and saved profiles remain unchanged. + +Version 4.0.8.2602002 + +* Preserve long host, tag and biome-list values when Ore and Fluid editors reopen by applying their existing maximum lengths before saved text is restored. +* Add deterministic, audited release artifacts, checksum output, guarded Maven preparation and pinned build, wrapper, CodeQL and release-tag workflows. +* Make the Mineralogy 1.18.2 5.4.0 compatibility oracle mandatory, checksum-sealed and isolated from production artifacts and Eclipse launches. +* Export and verify the complete 21-file documentation set, including VERSIONS.md. +* Standardize NeoForge 26.2 builds on NeoGradle 7.1.38, Gradle 9.2.1 and Temurin 25.0.3+9. + Version 4.0.6.2602002 * Adopt target-qualified four-component versions so Minecraft and loader compatibility can be identified from the mod version. diff --git a/Jenkinsfile b/Jenkinsfile deleted file mode 100644 index a3081bfe..00000000 --- a/Jenkinsfile +++ /dev/null @@ -1,128 +0,0 @@ -pipeline { - agent any - environment { - GRADLE_OPTS = '-Dorg.gradle.caching=true -Dorg.gradle.configureondemand=true -Dorg.gradle.warning.mode=all' -// JAVA_OPTS = '' - } - options { - ansiColor('xterm') - } - tools { -// git 'Git' - gradle 'Gradle 4.9' - jdk 'oraclejdk8' - } - stages { - stage('prebuild') { - steps { - sh 'rm -rf build/libs' - sh 'chmod +x gradlew' - sh 'java -version' - sh 'gradle -version' - sh './gradlew -version' - sh 'export' - } - } - stage('CIWorkspace') { - steps { - withGradle { - sh './gradlew clean setupCiWorkspace -S' - } - } - } - stage('build') { - steps { - withGradle { - sh './gradlew build -S' - } - } - } - stage('test') { - steps { - withGradle { - sh './gradlew test -S' - } - } - } - stage('publish') { - steps { - withCredentials([file(credentialsId: 'secret.json', variable: 'SECRET_FILE')]) { - withGradle { - sh './gradlew publish -S' - } - } - } - } - stage('CurseForge') { - steps { - withCredentials([file(credentialsId: 'secret.json', variable: 'SECRET_FILE')]) { - withGradle { - sh './gradlew -x publish curseforge -S' - } - } - } - } - stage('SonarQube') { - tools { - jdk "oraclejdk11" - } - environment { - scannerHome = tool 'SonarQube' - } - steps { -// withCredentials([file(credentialsId: 'secret.json', variable: 'SECRET_FILE')]) { -// withGradle { -// sh './gradlew sonarqube -S' -// } -// } - withSonarQubeEnv(installationName: 'SonarCloud', , envOnly: false) { - sh "${scannerHome}/bin/sonar-scanner -Dsonar.java.jdkHome=${JAVA_HOME}" - } - } - } - stage('postbuild') { - steps { - archiveArtifacts artifacts: 'build/libs/*.jar', followSymlinks: false - javadoc javadocDir: 'build/docs/javadoc', keepAll: false - fingerprint 'build/libs/*.zip' - junit allowEmptyResults: true, testResults: '**/build/test-results/junit-platform/*.xml' - jacoco classPattern: '**/build/classes/java', execPattern: '**/build/jacoco/**.exec', sourceInclusionPattern: '**/*.java', sourcePattern: '**/src/main/java' - findBuildScans() - recordIssues(tools: [java()]) - recordIssues(tools: [javaDoc()]) -// if (fileExists('')) { -// recordIssues(tools: [errorProne(pattern: 'ReportFilePattern', reportEncoding: 'UTF-8')]) -// } else { -// echo 'No ErrorProne report available' -// } - if (fileExists('**/build/reports/checkstyle/*.xml')) { - recordIssues(tools: [checkStyle(pattern: '**/build/reports/checkstyle/*.xml')]) - } else { - echo 'No CheckStyle report available' - } - if (fileExists('**/build/reports/pmd/*.xml')) { - recordIssues(tools: [pmdParser(pattern: '**/build/reports/pmd/*.xml')]) - } else { - echo 'No PMD report available' - } - if (fileExists('*/build/reports/findbugs/*.xml')) { - recordIssues(tools: [findBugs(pattern: '*/build/reports/findbugs/*.xml', useRankAsPriority: true)]) - } else { - echo 'No FindBugs report available' - } - } - when { expression { fileExists('**/build/reports/spotbugs/*.xml') } } - steps { - recordIssues(tools: [spotBugs(pattern: '**/build/reports/spotbugs/*.xml', useRankAsPriority: true)]) - } - when { expression { fileExists('**/build/test-results/junit-platform/*.xml') } } - steps { - recordIssues(tools: [junitParser(pattern: '**/build/test-results/junit-platform/*.xml')]) - } - when { expression { fileExists('**/sonar-report.json') } } - steps { - recordIssues(tools: [sonarQube(pattern: '**/sonar-report.json')]) - } - } - } -} diff --git a/README.md b/README.md index a4f52040..dd28dd31 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # MMD OreSpawn +[![Discord](https://img.shields.io/discord/303440391124942858?label=Discord&logo=discord)](https://discord.moddev.zone) +[![CurseForge downloads](https://cf.way2muchnoise.eu/full_245586_downloads.svg)](https://www.curseforge.com/minecraft/mc-mods/orespawn) +[![Minecraft](https://cf.way2muchnoise.eu/versions/245586.svg)](https://www.curseforge.com/minecraft/mc-mods/orespawn/files) +[![CI](https://github.com/MinecraftModDevelopmentMods/OreSpawn/actions/workflows/ci.yml/badge.svg?branch=master-26.2-neo)](https://github.com/MinecraftModDevelopmentMods/OreSpawn/actions/workflows/ci.yml?query=branch%3Amaster-26.2-neo) + OreSpawn 4 is a provider-driven world-generation engine for Minecraft 26.2. It gives mods and modpacks one place to configure ores, deposit shapes, optional rock strata and geomes, provider-owned underground fluid deposits, biome @@ -89,11 +94,15 @@ exported to `config/orespawn-guide/` without overwriting existing files. ## Building -Use Java 25 from the repository root: +This branch builds target-qualified version `4.0.16.2602002`: the OreSpawn 4.0.16 +feature set for Minecraft 26.2 and NeoForge. + +OreSpawn 4.0.16.2602002 uses NeoGradle 7.1.38 and Gradle 9.2.1. Use exact +Temurin 25.0.3+9 from the repository root: ```powershell -.\gradlew.bat clean build javadoc --no-daemon -.\gradlew.bat eclipse --no-daemon +.\gradlew.bat clean check build javadoc verifyReleaseArtifacts writeReleaseChecksums --no-daemon --max-workers=2 +.\gradlew.bat eclipse verifyEclipseProductionClasspath --no-daemon --max-workers=2 ``` `build` runs the standard `check` lifecycle. In addition to the JUnit suite, @@ -108,6 +117,10 @@ Import or refresh the project with Eclipse Buildship. NeoGradle supplies the Eclipse model and run configurations through the `eclipse` task; this branch does not use ForgeGradle's `genEclipseRuns` task. +The audited public outputs are `OreSpawn-4.0.16.2602002.jar`, its sources jar, +and its Javadoc jar. CI additionally proves a cache-cold NeoGradle bootstrap +and a same-cache offline rerun. + Machine-specific `AGENTS.md` and `agent-notes/` files are intentionally ignored. Public developer and AI integration guidance lives in `docs/` and is included in the built jar. diff --git a/build.gradle b/build.gradle index 07a008e4..a049be7f 100644 --- a/build.gradle +++ b/build.gradle @@ -1,3 +1,10 @@ +import groovy.xml.XmlSlurper +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.util.jar.Manifest +import java.util.zip.ZipFile +import org.apache.tools.ant.filters.FixCrLfFilter + plugins { id 'eclipse' id 'idea' @@ -10,25 +17,87 @@ version = mod_version group = mod_group_id base { - archivesName = "OreSpawn-${minecraft_version}-neoforge" + archivesName = 'OreSpawn' +} + +def versionParts = project.mod_version.toString().tokenize('.') +if (versionParts.size() != 4 || !versionParts.every { it ==~ /\d+/ }) { + throw new GradleException("mod_version must use Major.Minor.Bug.Target numeric form: ${project.mod_version}") +} +def minecraftVersionParts = project.minecraft_version.toString().tokenize('.') +def minecraftPatch = minecraftVersionParts.size() == 3 ? minecraftVersionParts[2] : '0' +def expectedTargetVersion = "${minecraftVersionParts[0]}" + + "${minecraftVersionParts[1].padLeft(2, '0')}" + + "${minecraftPatch.padLeft(2, '0')}" + project.loader_code +if (versionParts[3] != expectedTargetVersion) { + throw new GradleException("mod_version target ${versionParts[3]} does not match " + + "Minecraft ${project.minecraft_version} ${project.loader_name} target ${expectedTargetVersion}") } +ext.functional_version = versionParts[0..2].join('.') +ext.display_version = project.mod_version +ext.release_tag = project.mod_version +def expectedMavenGroup = 'zone.moddev.mc.orespawn' +def expectedMavenArtifact = 'OreSpawn' +def expectedMavenCoordinate = "${expectedMavenGroup}:${expectedMavenArtifact}:${project.version}" java { - toolchain.languageVersion = JavaLanguageVersion.of(25) + toolchain { + languageVersion = JavaLanguageVersion.of(25) + vendor = JvmVendorSpec.ADOPTIUM + } withSourcesJar() withJavadocJar() } +tasks.withType(AbstractArchiveTask).configureEach { + preserveFileTimestamps = false + reproducibleFileOrder = true +} + +def archiveTextSuffixes = [ + '.cfg', '.css', '.html', '.info', '.java', '.js', '.json', '.lang', + '.mcmeta', '.md', '.properties', '.toml', '.txt', '.xml' +] +def archiveTextPatterns = archiveTextSuffixes.collect { "**/*${it}".toString() } +archiveTextPatterns.addAll(['**/element-list', '**/package-list']) +def normalizeArchiveLineEndings = { details -> + details.filter(FixCrLfFilter, + eol: FixCrLfFilter.CrLf.newInstance('lf'), + eof: FixCrLfFilter.AddAsisRemove.newInstance('asis')) +} + println "Java: ${System.getProperty 'java.version'}, JVM: ${System.getProperty 'java.vm.version'} (${System.getProperty 'java.vendor'}), Arch: ${System.getProperty 'os.arch'}" sourceSets.main.resources { srcDir 'src/generated/resources' } +// NeoGradle stores the downloaded vanilla executables in its project-local +// cache. Gradle's --rerun-tasks would otherwise delete those valid inputs +// before an offline build and then ask NeoGradle to download them again. +// Preserve complete cached executables only while offline; a missing cache +// entry still executes the provider task and fails with NeoGradle's normal +// diagnostic instead of hiding an incomplete bootstrap. +tasks.matching { task -> task.name.startsWith('cacheVersionExecutable') }.configureEach { + onlyIf { + Set cachedOutputs = outputs.files.files + !gradle.startParameter.offline || cachedOutputs.isEmpty() + || !cachedOutputs.every { it.exists() } + } +} + +def bundledDocumentationDirectory = layout.projectDirectory.dir( + 'src/generated/resources/META-INF/orespawn/docs') +def prepareBundledDocumentation = tasks.register('prepareBundledDocumentation', Sync) { + group = 'build' + description = 'Stages public documentation as a generated production resource tree.' + from 'docs' + into bundledDocumentationDirectory +} + minecraft.accessTransformers.file rootProject.file('src/main/resources/META-INF/accesstransformer.cfg') -def configureBenchmark = { run, String radius, String repetitions, String center, - String stopServer -> +def configureBenchmark = { run, String radius, String repetitions, String center -> if (!providers.gradleProperty('orespawnBenchmarkMode').isPresent()) { return } @@ -64,8 +133,7 @@ def configureBenchmark = { run, String radius, String repetitions, String center run.systemProperty 'orespawn.worldgenBenchmarkBiomeType', providers.gradleProperty('orespawnBenchmarkBiomeType').get() } - run.systemProperty 'orespawn.worldgenBenchmarkStopServer', - providers.gradleProperty('orespawnBenchmarkStopServer').getOrElse(stopServer) + run.systemProperty 'orespawn.worldgenBenchmarkStopServer', 'true' if (providers.gradleProperty('worldgenJfrFile').isPresent()) { run.jvmArgument "-XX:StartFlightRecording=filename=${providers.gradleProperty('worldgenJfrFile').get()},settings=profile,dumponexit=true" } @@ -83,7 +151,7 @@ runs { systemProperty 'neoforge.enabledGameTestNamespaces', mod_id // The GameTest server always uses Minecraft's flat test preset, so // real terrain benchmarks must run through a normal integrated world. - configureBenchmark(delegate, '8', '5', '1024', 'false') + configureBenchmark(delegate, '8', '5', '1024') if (providers.gradleProperty('orespawnBenchmarkWorld').isPresent()) { arguments.addAll '--quickPlaySingleplayer', providers.gradleProperty('orespawnBenchmarkWorld').get() @@ -93,11 +161,19 @@ runs { server { systemProperty 'neoforge.enabledGameTestNamespaces', mod_id argument '--nogui' - configureBenchmark(delegate, '8', '5', '1024', 'true') + configureBenchmark(delegate, '8', '5', '1024') } gameTestServer { - systemProperty 'neoforge.enabledGameTestNamespaces', mod_id + systemProperty 'neoforge.enabledGameTestNamespaces', + providers.gradleProperty('orespawnBenchmarkMode').isPresent() + ? "${mod_id},minecraft" : mod_id + if (providers.gradleProperty('orespawnBenchmarkMode').isPresent()) { + workingDirectory layout.buildDirectory.dir('gametest-benchmark-run') + arguments.addAll '--tests', 'minecraft:always_pass', '--report', + 'benchmark-gametest-results.xml' + } + configureBenchmark(delegate, '4', '3', '256') } clientData { @@ -126,6 +202,34 @@ configurations { repositories { } +def fixtureRoot = file("${rootDir}/ci-fixtures") +def mineralogy5OracleJar = new File(fixtureRoot, + 'artifacts/Mineralogy-1.18.2-5.4.0.jar') +def mineralogy5OracleSha256 = + 'CCA84E9270585478B08F54BA091AD56AB2CB390386C650ED78B2673DC57403EB' + +tasks.register('verifyLegacyFixtures') { + group = 'verification' + description = 'Verifies the sealed Mineralogy 1.18.2 5.4.0 oracle used only by isolated tests.' + inputs.file mineralogy5OracleJar + doLast { + if (!mineralogy5OracleJar.isFile()) { + throw new GradleException("Missing mandatory Mineralogy oracle: ${mineralogy5OracleJar}") + } + MessageDigest digest = MessageDigest.getInstance('SHA-256') + mineralogy5OracleJar.withInputStream { input -> + byte[] buffer = new byte[8192] + for (int read = input.read(buffer); read >= 0; read = input.read(buffer)) { + if (read > 0) digest.update(buffer, 0, read) + } + } + String actual = digest.digest().encodeHex().toString().toUpperCase() + if (actual != mineralogy5OracleSha256) { + throw new GradleException("Mineralogy oracle checksum mismatch: ${actual}") + } + } +} + dependencies { implementation "net.neoforged:neoforge:${neo_version}" @@ -155,53 +259,65 @@ tasks.withType(ProcessResources).configureEach { } tasks.named('processResources', ProcessResources).configure { + dependsOn prepareBundledDocumentation from('docs/AGENTS.md') { into '' rename { 'AGENTS.md' } } - from('docs') { - into 'META-INF/orespawn/docs' - } } tasks.named('jar', Jar).configure { + archiveClassifier = '' + destinationDirectory = layout.buildDirectory.dir('libs') + filteringCharset = 'UTF-8' + includeEmptyDirs = false + filesMatching(archiveTextPatterns, normalizeArchiveLineEndings) manifest { attributes([ 'Specification-Title' : 'OreSpawn', 'Specification-Vendor' : 'SkyBlade1978', 'Specification-Version' : '1', - 'Implementation-Title' : project.name, - 'Implementation-Version' : project.jar.archiveVersion, + 'Implementation-Title' : base.archivesName.get(), + 'Implementation-Version' : project.version, 'Implementation-Vendor' : 'SkyBlade1978', - 'Implementation-Timestamp' : new Date().format("yyyy-MM-dd'T'HH:mm:ssZ"), 'OreSpawn-API-Version' : '1', - 'OreSpawn-Loader' : 'neoforge' + 'OreSpawn-Loader' : 'neoforge', + 'FMLAT' : 'accesstransformer.cfg', + 'Maven-Artifact' : expectedMavenCoordinate, + 'Built-On-Java' : '25', + 'Built-On' : "${project.minecraft_version}-${project.neo_version}" ]) } } -publishing { - publications { - register('mavenJava', MavenPublication) { - from components.java - artifactId = "OreSpawn-${minecraft_version}-neoforge" - } - } - repositories { - maven { - url "file://${project.projectDir}/mcmodsrepo" - } - } +tasks.named('sourcesJar', Jar).configure { + dependsOn prepareBundledDocumentation + filteringCharset = 'UTF-8' + includeEmptyDirs = false + filesMatching(archiveTextPatterns, normalizeArchiveLineEndings) +} +tasks.named('javadocJar', Jar).configure { + filteringCharset = 'UTF-8' + includeEmptyDirs = false + filesMatching(archiveTextPatterns, normalizeArchiveLineEndings) } tasks.withType(JavaCompile).configureEach { + javaCompiler = javaToolchains.compilerFor { + languageVersion = JavaLanguageVersion.of(25) + vendor = JvmVendorSpec.ADOPTIUM + } + sourceCompatibility = JavaVersion.VERSION_25 + targetCompatibility = JavaVersion.VERSION_25 options.encoding = 'UTF-8' } tasks.named('javadoc', Javadoc).configure { + failOnError = false options.encoding = 'UTF-8' options.addStringOption('Xdoclint:none', '-quiet') options.addBooleanOption('-no-fonts', true) + options.addBooleanOption('notimestamp', true) } tasks.named('test', Test).configure { @@ -213,9 +329,24 @@ tasks.named('test', Test).configure { // Loaded only through an isolated URLClassLoader by the parity test. This // is deliberately not a Gradle dependency and cannot leak into Eclipse or // a published OreSpawn jar. - File mineralogy5Oracle = file('../../MinecraftMineralogy 118/MinecraftMineralogy/build/libs/Mineralogy-1.18.2-5.4.0.jar') - if (mineralogy5Oracle.isFile()) { - systemProperty 'orespawn.mineralogy5Oracle', mineralogy5Oracle.absolutePath + dependsOn tasks.named('verifyLegacyFixtures') + systemProperty 'orespawn.mineralogy5Oracle', mineralogy5OracleJar.absolutePath +} + +tasks.named('compileTestJava', JavaCompile).configure { + dependsOn tasks.named('verifyLegacyFixtures') + options.compilerArgs.add('-proc:none') +} + +tasks.register('verifyLegacyOracleIsolation') { + group = 'verification' + dependsOn tasks.named('verifyLegacyFixtures') + doLast { + configurations.findAll { it.canBeResolved }.each { configuration -> + if (configuration.files.any { it.canonicalFile == mineralogy5OracleJar.canonicalFile }) { + throw new GradleException("Mineralogy oracle leaked into ${configuration.name}") + } + } } } @@ -360,6 +491,12 @@ tasks.named('check') { tasks.matching { it.name == taskName }.configureEach { JavaExec runTask -> doFirst { new File(runTask.workingDir, 'mods').mkdirs() + if (taskName == 'runGameTestServer') { + File serverProperties = new File(runTask.workingDir, 'server.properties') + if (!serverProperties.isFile()) { + serverProperties.setText('# Generated for the OreSpawn GameTest gate\n', 'UTF-8') + } + } runTask.ext.oreSpawnCrashSnapshot = runtimeCrashSnapshot(runTask.workingDir) } doLast { @@ -446,6 +583,432 @@ tasks.named('check') { dependsOn surfaceIntegrationTest } +def clientIntegrationClasses = layout.buildDirectory.dir('client-integration-fixture/classes') +def compileClientIntegrationTestMod = tasks.register('compileClientIntegrationTestMod', JavaCompile) { + dependsOn tasks.named('classes') + source fileTree('src/clientIntegrationTest/java') + classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) + destinationDirectory.set(clientIntegrationClasses) + options.release = 25 + options.encoding = 'UTF-8' +} +def clientIntegrationTestModJar = tasks.register('clientIntegrationTestModJar', Jar) { + dependsOn compileClientIntegrationTestMod + archiveFileName = 'clientprobe.jar' + destinationDirectory = layout.buildDirectory.dir('client-integration-fixture') + from clientIntegrationClasses + from 'src/clientIntegrationTest/resources' +} + +def releaseJar = tasks.named('jar', Jar) +def expectedReleaseFiles = providers.provider { + String prefix = "${base.archivesName.get()}-${project.version}" + ["${prefix}.jar", "${prefix}-sources.jar", "${prefix}-javadoc.jar"] +} +def preparedReleaseDir = providers.gradleProperty('preparedReleaseDir') + +tasks.named('assemble') { + dependsOn releaseJar + dependsOn tasks.named('sourcesJar') + dependsOn tasks.named('javadocJar') +} + +tasks.register('verifyReleaseConfiguration') { + group = 'verification' + doLast { + if (project.mod_version != '4.0.16.2602002' + || project.mod_group_id != expectedMavenGroup + || project.minecraft_version != '26.2' + || project.neo_version != '26.2.0.45-beta') { + throw new GradleException('Unexpected OreSpawn NeoForge 26.2 release identity') + } + if (project.loader_name != 'neoforge' || project.loader_code != '2' + || project.java_version != '25' || project.gradle_java_version != '25' + || project.java_toolchain_version != '25.0.3+9' + || project.java_setup_version != '25.0.3+9.0.LTS') { + throw new GradleException('Unexpected dispatcher or Java target metadata') + } + List expectedPublicArtifacts = [ + 'OreSpawn-4.0.16.2602002.jar', + 'OreSpawn-4.0.16.2602002-sources.jar', + 'OreSpawn-4.0.16.2602002-javadoc.jar' + ] + if (base.archivesName.get() != expectedMavenArtifact + || expectedReleaseFiles.get().collect { it.toString() } != expectedPublicArtifacts) { + throw new GradleException('Public artifacts must use the version-only OreSpawn filename contract') + } + String ciWorkflow = file('.github/workflows/ci.yml').getText('UTF-8') + expectedPublicArtifacts.each { artifactName -> + if (!ciWorkflow.contains("build/libs/${artifactName}")) { + throw new GradleException("CI does not upload expected public artifact ${artifactName}") + } + } + [ + 'src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java', + 'src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java', + 'README.md', 'CHANGELOG.txt' + ].each { path -> + if (!file(path).getText('UTF-8').contains(project.mod_version)) { + throw new GradleException("Release identity missing from ${path}") + } + } + if (!file('docs/API.md').getText('UTF-8').contains('versionRange="[4.0.6,5.0.0)"')) { + throw new GradleException('Consumer compatibility floor must remain [4.0.6,5.0.0)') + } + } +} + +def trackedDocumentationDirectory = file('docs') +def documentationFiles = { + fileTree(trackedDocumentationDirectory).files.findAll { it.isFile() }.collect { + trackedDocumentationDirectory.canonicalFile.toPath().relativize(it.canonicalFile.toPath()) + .toString().replace('\\', '/') }.sort() +} +def assertDocumentationTree = { File root, List expected, String label -> + List actual = root.isDirectory() ? fileTree(root).files.findAll { it.isFile() } + .collect { root.canonicalFile.toPath().relativize(it.canonicalFile.toPath()) + .toString().replace('\\', '/') }.sort() : [] + if (actual != expected) { + throw new GradleException("${label} documentation set ${actual} does not match tracked ${expected}") + } + expected.each { relative -> + if (!java.util.Arrays.equals(new File(trackedDocumentationDirectory, relative).bytes, + new File(root, relative).bytes)) { + throw new GradleException("${label}/${relative} differs from tracked documentation") + } + } +} + +def prepareEclipseResources = tasks.register('prepareEclipseResources', Sync) { + dependsOn tasks.named('processResources') + from layout.buildDirectory.dir('resources/main') + into file('bin/main') +} + +def verifyDocumentationParity = tasks.register('verifyDocumentationParity') { + group = 'verification' + dependsOn prepareBundledDocumentation + dependsOn tasks.named('processResources') + dependsOn prepareEclipseResources + dependsOn releaseJar + doLast { + List expected = documentationFiles() + if (expected.size() != 21 || !expected.contains('VERSIONS.md')) { + throw new GradleException("Expected exactly 21 tracked guide files including VERSIONS.md, found ${expected}") + } + assertDocumentationTree(bundledDocumentationDirectory.asFile, expected, 'generated resources') + assertDocumentationTree(new File(layout.buildDirectory.dir('resources/main').get().asFile, + 'META-INF/orespawn/docs'), expected, 'processed resources') + assertDocumentationTree(file('bin/main/META-INF/orespawn/docs'), expected, 'Eclipse bin/main') + new ZipFile(releaseJar.get().archiveFile.get().asFile).withCloseable { zip -> + expected.each { relative -> + def entry = zip.getEntry("META-INF/orespawn/docs/${relative}") + if (entry == null || !java.util.Arrays.equals( + new File(trackedDocumentationDirectory, relative).bytes, + zip.getInputStream(entry).withCloseable { it.bytes })) { + throw new GradleException("Release jar documentation differs at ${relative}") + } + } + } + } +} + +tasks.register('verifyReleaseArtifacts') { + group = 'verification' + dependsOn tasks.named('verifyReleaseConfiguration') + dependsOn verifyDocumentationParity + dependsOn tasks.named('assemble') + dependsOn tasks.named('verifyLegacyOracleIsolation') + doLast { + File libs = layout.buildDirectory.dir('libs').get().asFile + List jars = (libs.listFiles() ?: [] as File[]) + .findAll { it.name.endsWith('.jar') }.sort { it.name } + List expected = expectedReleaseFiles.get().collect { it.toString() }.sort() + if (jars.collect { it.name } != expected) { + throw new GradleException("Expected exactly ${expected}, found ${jars*.name}") + } + jars.each { candidate -> + if (candidate.length() == 0L) throw new GradleException("Empty artifact ${candidate}") + new ZipFile(candidate).withCloseable { zip -> + zip.entries().findAll { entry -> + !entry.isDirectory() && (archiveTextSuffixes.any { entry.name.endsWith(it) } + || entry.name.endsWith('/element-list') + || entry.name.endsWith('/package-list')) + }.each { entry -> + boolean cr = zip.getInputStream(entry).withCloseable { + input -> input.bytes.any { value -> value == 13 } + } + if (cr) throw new GradleException("${candidate.name}!/${entry.name} is not LF-normalized") + } + [ + 'src/test/', 'src/biomeIntegrationTest/', 'src/clientIntegrationTest/', + 'src/benchmarkIntegrationTest/', 'agent-notes/', 'surfaceprobe', + 'clientprobe', 'benchmarkprobe', 'ci-fixtures/', 'net/minecraftforge/', + 'META-INF/mods.toml', 'org/junit/', 'org/mockito/', 'net/bytebuddy/', + 'Mineralogy-1.18.2-5.4.0.jar' + ].each { forbidden -> + if (zip.entries().any { it.name.contains(forbidden) }) { + throw new GradleException("${candidate.name} contains forbidden ${forbidden}") + } + } + } + } + + File mainJar = new File(libs, expectedReleaseFiles.get()[0]) + new ZipFile(mainJar).withCloseable { zip -> + List names = zip.entries().collect { it.name } + [ + 'META-INF/neoforge.mods.toml', + 'META-INF/accesstransformer.cfg', + 'zone/moddev/mc/orespawn/api/OreSpawnApi.class', + 'META-INF/orespawn/docs/VERSIONS.md', + 'META-INF/orespawn/docs/schemas/orespawn-provider.schema.json', + 'AGENTS.md' + ].each { required -> + if (!names.contains(required)) throw new GradleException("Release jar is missing ${required}") + } + String metadata = zip.getInputStream(zip.getEntry('META-INF/neoforge.mods.toml')) + .getText(StandardCharsets.UTF_8.name()) + if (!metadata.contains('modId="orespawn"') + || !metadata.contains("version=\"${project.version}\"") + || !metadata.contains('versionRange="[26.2.0.45-beta,26.2.1)"')) { + throw new GradleException('Packaged NeoForge metadata is incorrect') + } + String transformer = zip.getInputStream(zip.getEntry('META-INF/accesstransformer.cfg')) + .getText(StandardCharsets.UTF_8.name()) + List actualRules = transformer.readLines() + .collect { it.replaceFirst(/\s*#.*/, '').trim() }.findAll { !it.isEmpty() } + List expectedRules = [ + 'public-f net.minecraft.world.level.chunk.ChunkGenerator biomeSource', + 'public-f net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator globalFluidPicker', + 'public-f net.minecraft.world.level.biome.Biome generationSettings', + 'public-f net.minecraft.world.level.levelgen.feature.configurations.SpringConfiguration validBlocks', + 'public net.minecraft.client.gui.screens.worldselection.CreateWorldScreen tabManager', + 'public net.minecraft.client.gui.screens.worldselection.CreateWorldScreen tabNavigationBar' + ] + if (actualRules != expectedRules) { + throw new GradleException("Unexpected packaged official-named access transformer: ${actualRules}") + } + def manifestEntry = zip.getEntry('META-INF/MANIFEST.MF') + def manifest = manifestEntry == null ? null : + new Manifest(zip.getInputStream(manifestEntry)).mainAttributes + if (manifest == null + || manifest.getValue('Implementation-Version') != project.mod_version + || manifest.getValue('OreSpawn-API-Version') != '1' + || manifest.getValue('OreSpawn-Loader') != 'neoforge' + || manifest.getValue('Maven-Artifact') != expectedMavenCoordinate + || manifest.getValue('Implementation-Timestamp') != null) { + throw new GradleException('Release manifest is incorrect or volatile') + } + zip.entries().findAll { it.name.endsWith('.class') }.each { entry -> + byte[] header = zip.getInputStream(entry).withCloseable { it.readNBytes(8) } + int major = ((header[6] & 0xff) << 8) | (header[7] & 0xff) + if (major != 69) throw new GradleException("${entry.name} uses class major ${major}, expected 69") + } + } + new ZipFile(new File(libs, expectedReleaseFiles.get()[1])).withCloseable { zip -> + if (zip.getEntry('zone/moddev/mc/orespawn/OreSpawn.java') == null) { + throw new GradleException('Sources jar is missing OreSpawn.java') + } + } + new ZipFile(new File(libs, expectedReleaseFiles.get()[2])).withCloseable { zip -> + if (zip.getEntry('index.html') == null + || zip.getEntry('zone/moddev/mc/orespawn/api/OreSpawnApi.html') == null) { + throw new GradleException('Javadoc jar is missing its index or public OreSpawn API page') + } + } + } +} + +tasks.register('writeReleaseChecksums') { + group = 'verification' + dependsOn tasks.named('verifyReleaseArtifacts') + def outputFile = layout.buildDirectory.file('release/SHA256SUMS') + outputs.file outputFile + doLast { + File output = outputFile.get().asFile + output.parentFile.mkdirs() + File libs = layout.buildDirectory.dir('libs').get().asFile + String contents = expectedReleaseFiles.get().sort().collect { name -> + MessageDigest digest = MessageDigest.getInstance('SHA-256') + new File(libs, name).withInputStream { input -> + byte[] buffer = new byte[8192] + for (int read = input.read(buffer); read >= 0; read = input.read(buffer)) { + if (read > 0) digest.update(buffer, 0, read) + } + } + "${digest.digest().encodeHex().toString().toUpperCase()} ${name}" + }.join('\n') + '\n' + output.setText(contents, 'UTF-8') + } +} + +tasks.register('verifyPreparedReleaseArtifacts') { + group = 'verification' + doLast { + if (!preparedReleaseDir.isPresent()) throw new GradleException('preparedReleaseDir is required') + File prepared = file(preparedReleaseDir.get()) + List expected = expectedReleaseFiles.get().collect { it.toString() }.sort() + List jars = (prepared.listFiles() ?: [] as File[]) + .findAll { it.name.endsWith('.jar') }.sort { it.name } + if (jars.collect { it.name } != expected || jars.any { it.length() == 0L }) { + throw new GradleException("Prepared release jars ${jars*.name} do not match ${expected}") + } + File checksums = new File(prepared, 'SHA256SUMS') + if (!checksums.isFile() || !new File(prepared, 'CHANGELOG.txt').isFile()) { + throw new GradleException('Prepared release is missing checksums or changelog') + } + } +} + +def mavenUploadUrl = providers.environmentVariable('MAVEN_UPLOAD_URL') + .orElse('https://invalid.invalid/missing-maven-upload-url') +def mavenUploadUsername = providers.environmentVariable('MAVEN_UPLOAD_USERNAME') +def mavenUploadPassword = providers.environmentVariable('MAVEN_UPLOAD_PASSWORD') +publishing { + publications { + mavenJava(MavenPublication) { + groupId = expectedMavenGroup + artifactId = expectedMavenArtifact + version = project.version.toString() + if (preparedReleaseDir.isPresent()) { + File prepared = file(preparedReleaseDir.get()) + artifact(new File(prepared, expectedReleaseFiles.get()[0])) + artifact(new File(prepared, expectedReleaseFiles.get()[1])) { classifier = 'sources' } + artifact(new File(prepared, expectedReleaseFiles.get()[2])) { classifier = 'javadoc' } + } else { + from components.java + } + pom { + name = 'MMD OreSpawn' + description = project.mod_description + url = 'https://github.com/MinecraftModDevelopmentMods/OreSpawn' + licenses { + license { + name = 'GNU Lesser General Public License, Version 2.1' + url = 'https://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt' + } + } + } + } + } + repositories { + maven { + name = 'release' + url = uri(mavenUploadUrl.get()) + credentials { + username = mavenUploadUsername.orNull ?: '' + password = mavenUploadPassword.orNull ?: '' + } + } + } +} + +tasks.register('verifyMavenCoordinates') { + group = 'verification' + dependsOn tasks.named('generatePomFileForMavenJavaPublication') + doLast { + File pomFile = layout.buildDirectory.file( + 'publications/mavenJava/pom-default.xml').get().asFile + def pom = new XmlSlurper(false, false).parse(pomFile) + def actual = [pom.groupId.text(), pom.artifactId.text(), pom.version.text()] + def expected = [expectedMavenGroup, expectedMavenArtifact, project.version.toString()] + if (project.group.toString() != expectedMavenGroup || actual != expected) { + throw new GradleException("Expected Maven coordinate ${expected.join(':')}, found ${actual.join(':')}") + } + } +} + +tasks.register('validateMavenReleaseCredentials') { + group = 'publishing' + doLast { + if (!providers.environmentVariable('MAVEN_UPLOAD_URL').isPresent() + || !mavenUploadUsername.isPresent() || !mavenUploadPassword.isPresent()) { + throw new GradleException( + 'MAVEN_UPLOAD_URL, MAVEN_UPLOAD_USERNAME, and MAVEN_UPLOAD_PASSWORD are required') + } + if (providers.environmentVariable('MAVEN_UPLOAD_URL').get().startsWith('file:')) { + throw new GradleException('Release publication must use a remote repository') + } + } +} +tasks.withType(PublishToMavenRepository).configureEach { + dependsOn tasks.named('validateMavenReleaseCredentials') + dependsOn tasks.named('verifyMavenCoordinates') + dependsOn preparedReleaseDir.isPresent() + ? tasks.named('verifyPreparedReleaseArtifacts') + : tasks.named('verifyReleaseArtifacts') +} + +tasks.register('verifyCommandPortability') { + group = 'verification' + description = 'Rejects shell-specific wrappers in generic Gradle task execution.' + doLast { + List commandSources = [file('build.gradle')] + commandSources.addAll(fileTree('.github/workflows') { include '*.yml', '*.yaml' }.files) + commandSources.each { File source -> + String text = source.getText('UTF-8') + if (text =~ /(?i)(?:commandLine|executable)\s*[^\n]*(?:cmd(?:\.exe)?\s+\/c|powershell(?:\.exe)?\s+-command|(?:bash|sh)\s+-c)/) { + throw new GradleException("Shell-specific command wrapper in ${source}") + } + } + } +} + +tasks.register('verifyColdNeoForgeBootstrapContract') { + group = 'verification' + description = 'Verifies that CI qualifies NeoGradle from an isolated Java 25 cache without Forge tooling or forced source decompilation.' + doLast { + Properties gradleProperties = new Properties() + file('gradle.properties').withInputStream { gradleProperties.load(it) } + if (gradleProperties.getProperty('neogradle.subsystems.decompiler.enabled') != null) { + throw new GradleException('NeoForge 26.2 must retain its validated binary userdev path') + } + + String workflow = file('.github/workflows/ci.yml').getText('UTF-8') + [ + 'Cold NeoForge bootstrap', + 'orespawn-neoforge-cold-cache', + '--no-build-cache', + '--offline', + './gradlew classes verifyLegacyFixtures --no-daemon', + './gradlew classes verifyLegacyFixtures --offline --no-daemon', + './gradlew check build javadoc', + "java-version: '25.0.3+9.0.LTS'" + ].each { required -> + if (!workflow.contains(required)) { + throw new GradleException("Cold NeoForge bootstrap is missing ${required}") + } + } + ['MinecraftMavenizer', 'mavenizer', 'net.minecraftforge.gradle', + 'neogradle.subsystems.decompiler.enabled=true', + "java-version: '21", "java-version: '17", "java-version: '8"].each { forbidden -> + if (workflow.contains(forbidden)) { + throw new GradleException("Cold NeoForge bootstrap contains Forge-only tooling: ${forbidden}") + } + } + ['./gradlew clean check', './gradlew clean classes'].each { combined -> + if (workflow.contains(combined)) { + throw new GradleException( + "NeoGradle clean must run separately from model consumers: ${combined}") + } + } + + String codeQlWorkflow = file('.github/workflows/codeql-analysis.yml').getText('UTF-8') + if (!codeQlWorkflow.contains('./gradlew clean --no-daemon') + || codeQlWorkflow.contains('clean classes')) { + throw new GradleException( + 'CodeQL must clean once before its cache-preserving compilation retry loop') + } + } +} + +tasks.named('check') { + dependsOn tasks.named('verifyCommandPortability') + dependsOn tasks.named('verifyMavenCoordinates') + dependsOn tasks.named('verifyColdNeoForgeBootstrapContract') +} + idea { module { downloadSources = true @@ -459,6 +1022,9 @@ eclipse { buildCommand 'org.eclipse.buildship.core.gradleprojectbuilder' } classpath { + downloadSources = true + downloadJavadoc = true + defaultOutputDir = file('bin/default') file { whenMerged { classpath -> def libraryPaths = new LinkedHashSet() @@ -466,12 +1032,36 @@ eclipse { !(entry instanceof org.gradle.plugins.ide.eclipse.model.Library) || libraryPaths.add(entry.path) } + 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) + } + if (!classpath.entries.any { entry -> entry.path == 'build/resources/main' }) { + classpath.entries.add(new org.gradle.plugins.ide.eclipse.model.SourceFolder( + 'build/resources/main', 'bin/main')) + } + classpath.entries.findAll { entry -> + entry instanceof org.gradle.plugins.ide.eclipse.model.SourceFolder + }.each { entry -> + if (entry.path == 'build/resources/main' || entry.path == 'src/main/java') { + entry.output = 'bin/main' + } else if (entry.path.startsWith('src/test/')) { + entry.output = 'bin/test' + } + } } } } + synchronizationTasks 'prepareEclipseResources' } tasks.named('eclipse').configure { + dependsOn prepareEclipseResources doLast { String requestedGradleHome = System.getenv('GRADLE_USER_HOME') if (requestedGradleHome == null || requestedGradleHome.trim().isEmpty()) { @@ -496,3 +1086,80 @@ tasks.named('eclipse').configure { } } } + +tasks.register('verifyEclipseProductionClasspath') { + group = 'verification' + dependsOn tasks.named('eclipse') + dependsOn tasks.named('verifyLegacyOracleIsolation') + doLast { + File prefs = file('.settings/org.eclipse.buildship.core.prefs') + if (!prefs.isFile()) throw new GradleException('Missing Buildship preferences') + Properties preferences = new Properties() + prefs.withInputStream { preferences.load(it) } + if (preferences.getProperty('override.workspace.settings') != 'true') { + throw new GradleException('Eclipse Buildship workspace override is not active') + } + File eclipseClasspath = file('.classpath') + if (!eclipseClasspath.isFile()) throw new GradleException('Eclipse .classpath was not generated') + String classpathText = eclipseClasspath.getText('UTF-8') + if (!classpathText.contains('path="build/resources/main"') + || classpathText.contains('path="src/main/resources"') + || classpathText.contains('path="src/generated/resources"')) { + throw new GradleException('Eclipse must consume only Gradle-processed production resources') + } + def classpathXml = new XmlSlurper(false, false).parse(eclipseClasspath) + String defaultOutput = classpathXml.classpathentry + .find { it.@kind.text() == 'output' }.@path.text() + Map expectedSourceOutputs = [ + 'src/main/java' : 'bin/main', + 'build/resources/main' : 'bin/main', + 'src/test/java' : 'bin/test', + 'src/test/resources' : 'bin/test' + ] + List invalidSourceOutputs = classpathXml.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 = classpathXml.classpathentry + .findAll { it.@kind.text() == 'src' && !it.@output.text().isEmpty() } + .any { entry -> + String output = entry.@output.text() + output == defaultOutput || output.startsWith(defaultOutput + '/') + } + if (defaultOutput != 'bin/default' || !invalidSourceOutputs.isEmpty() + || nestedOutput || classpathText.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/neoforge.mods.toml', 'META-INF/orespawn/docs/README.md', + 'META-INF/orespawn/docs/VERSIONS.md'].each { relative -> + if (!new File('bin/main', relative).isFile()) { + throw new GradleException("Eclipse output is missing ${relative}") + } + } + File metadata = file('bin/main/META-INF/neoforge.mods.toml') + String metadataText = metadata.getText('UTF-8') + if (metadataText.contains('\${mod_version}') + || !metadataText.contains("version=\"${project.version}\"")) { + throw new GradleException('Eclipse output contains unexpanded NeoForge metadata') + } + List forbidden = [ + 'src/test', 'bin/test', 'build/classes/java/test', + 'biomeIntegrationTest', 'clientIntegrationTest', 'benchmarkIntegrationTest', + 'surfaceprobe', 'clientprobe', 'benchmarkprobe', 'junit-', 'opentest4j-', + 'Mineralogy-1.18.2-5.4.0.jar', 'net.minecraftforge', 'META-INF/mods.toml', + 'C:\\Users\\John' + ] + fileTree(project.projectDir) { include 'run*.launch' }.files.each { launch -> + List leaked = forbidden.findAll { launch.getText('UTF-8').contains(it) } + if (!leaked.isEmpty()) { + throw new GradleException("${launch.name} exposes test/Forge/local content: ${leaked}") + } + } + } +} diff --git a/ci-fixtures/README.md b/ci-fixtures/README.md new file mode 100644 index 00000000..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/docs/AGENTS.md b/docs/AGENTS.md index 8adc7353..756056ab 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -13,5 +13,5 @@ Use the focused guides for implementation details: - [TEMPLATES.md](TEMPLATES.md) for selectable world styles; - [CONFIGURATION.md](CONFIGURATION.md) for configuration behavior; - [VERSIONS.md](VERSIONS.md) for the shared four-component target-qualified - versioning and branch-release convention; + versioning, skipped functional releases, and branch-release convention; - [README.md](README.md) for schemas, examples, and the complete documentation index. diff --git a/docs/API.md b/docs/API.md index 940e17e3..d498a6b0 100644 --- a/docs/API.md +++ b/docs/API.md @@ -12,7 +12,7 @@ runtime. In `neoforge.mods.toml` use a required dependency, for example: [[dependencies.examplemod]] modId="orespawn" type="required" -versionRange="[4.0.0,5.0.0)" +versionRange="[4.0.6,5.0.0)" ordering="AFTER" side="BOTH" ``` @@ -77,6 +77,13 @@ WorldgenProvider provider = WorldgenProvider.builder("examplemod", 1) `OilDefinition` and template `.oil(...)` remain deprecated migration adapters for one legacy oil rule. New integrations should use `FluidDepositDefinition`. +Ore dimension builders expose the same biome filters as provider JSON and +fluid-deposit builders. Use `.biome(...)` and `.biomeDictionary(...)` for +inclusions, with `.excludeBiome(...)` and `.excludeBiomeDictionary(...)` for +exclusions. These methods work on both explicit `.dimension(...)` rules and +`.dimensionSelector(...)` fallbacks; built definitions and their returned +filter sets are immutable. + Minecraft 26.2 biomes are data-driven registry entries. Package biome JSON under `data//worldgen/biome/`, or generate it with a `DatapackBuiltinEntriesProvider`. `OreSpawnBiomes.copyAndRegister` is an @@ -134,10 +141,14 @@ OreSpawnApi.createSampler(server.overworld()).ifPresent(sampler -> { ``` `sampleColumn` performs one biome/dominant-geome classification and reuses its -transition scores for every Y query. `rockAt` therefore matches Stable Layers -when a close geome transition is staggered by layer. Sampling is read-only and -is intended for gameplay decisions, diagnostics, and compatible generation -outside OreSpawn's block loops. +transition scores for every Y query. Pass the first-free surface height returned +by `Level.getHeight`; OreSpawn classifies the stable quart-biome cell at the +highest occupied block immediately below it, matching chunk geology generation +without display-oriented fuzzy biome zoom. `rockAt` therefore matches Stable +Layers when a close geome transition is staggered by layer, even when later +surface work changes the final heightmap slightly. Sampling is read-only and is +intended for gameplay decisions, diagnostics, and compatible generation outside +OreSpawn's block loops. Callbacks inside OreSpawn generation loops are intentionally unsupported. Custom pattern mods create a NeoForge `DeferredRegister` using diff --git a/docs/BIOMES.md b/docs/BIOMES.md index 9d31c2f3..78124244 100644 --- a/docs/BIOMES.md +++ b/docs/BIOMES.md @@ -132,6 +132,13 @@ lets OreSpawn replace the actual exposed ground while preserving later trees, plants, authored structures, and block entities. In ceiling dimensions, `ceiling_block` applies to the roof underside and does not replace the roof top. +Provider-declared `terrain_dimensions.host_blocks` are resolved by one terrain +scan at the start of `LOCAL_MODIFICATIONS`, immediately before provider +surfaces. Matching natural blocks already present in base terrain are eligible +for geology; matching blocks authored by later structure or vegetation stages +are not. Air, fluids, bedrock, and block-entity states remain protected even if +a provider mistakenly lists their block IDs as terrain hosts. + Surface correction is generation-only. Installing or updating OreSpawn does not rewrite already generated chunks; travel into new terrain to see a changed provider surface definition. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index c342e1a9..33235d97 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -117,7 +117,9 @@ is omitted. `dimensions` limits membership, and `geomes` multiplies selection weight by province. A weight of zero prevents selection in that context. Geomes contain a non-negative `base` weight and non-negative weights for each -rock family. Biome and biome-dictionary maps multiply those geome weights. +rock family. Keys may retain the legacy unnamespaced form or use a provider +resource ID such as `examplemod:crystal_basin`; the creation editor preserves +both forms. Biome and biome-dictionary maps multiply those geome weights. Missing optional-mod biome IDs are ignored during baking. Exact biome-ID maps remain effective when the target uses a dynamic biome registry. With Stable Layers, a close contest between two geomes transitions diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md index 7f0b50ad..d6d65513 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.fromNamespaceAndPath("minecraft", "stone_ore_replaceables")))) .build(); diff --git a/docs/README.md b/docs/README.md index 085eeb61..e1462cb8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,6 +17,7 @@ Choose the guide that matches what you are doing: - [Dimensions](DIMENSIONS.md) - [Migration](MIGRATION.md) - [Troubleshooting](TROUBLESHOOTING.md) +- [Versioning and release conventions](VERSIONS.md) - [Compact instructions for coding agents](AGENTS.md) Validated examples are in `examples/`; JSON Schemas are in `schemas/`. diff --git a/docs/VERSIONS.md b/docs/VERSIONS.md index 76b2e324..3b39fabb 100644 --- a/docs/VERSIONS.md +++ b/docs/VERSIONS.md @@ -49,14 +49,26 @@ 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` | -| 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` | +| 1.14.4 | Forge | `114041` | `4.0.8.114041` | +| 1.15.2 | Forge | `115021` | `4.0.9.115021` | +| 1.16.5 | Forge | `116051` | `4.0.9.116051` | +| 1.17.1 | Forge | `117011` | `4.0.9.117011` | +| 1.18.2 | Forge | `118021` | `4.0.16.118021` | +| 1.19.4 | Forge | `119041` | `4.0.16.119041` | +| 1.20.1 | Forge | `120011` | `4.0.16.120011` | +| 1.20.6 | Forge | `120061` | `4.0.16.120061` | +| 1.20.6 | NeoForge | `120062` | `4.0.16.120062` | +| 1.21.1 | Forge | `121011` | `4.0.16.121011` | +| 1.21.1 | NeoForge | `121012` | `4.0.16.121012` | +| 1.21.11 | Forge | `121111` | `4.0.16.121111` | +| 1.21.11 | NeoForge | `121112` | `4.0.16.121112` | +| 26.1.2 | Forge | `2601021` | `4.0.16.2601021` | +| 26.1.2 | NeoForge | `2601022` | `4.0.16.2601022` | +| 26.2 | Forge | `2602001` | `4.0.16.2602001` | +| 26.2 | NeoForge | `2602002` | `4.0.16.2602002` | Historical MMD releases may also have four numeric components but may have used the fourth component differently. This policy applies prospectively; it does @@ -138,12 +150,12 @@ same `Major.Minor.Bug` may be shared by functionally equivalent ports. If a released branch receives a bug fix that other branches do not require, only the affected branch's Bug number is incremented. For example, Forge -1.13.2 may move from `4.0.6.113021` to `4.0.7.113021` while unaffected branches -remain on their target-qualified 4.0.6 versions. +1.12.2 moved to `4.0.7.112021` for its packaged access-transformer repair while +unaffected branches remained on their target-qualified 4.0.6 versions. -If a different branch later receives a separate fix, it uses the next unused -Bug number, such as `4.0.8`, even if the `4.0.7` fix was not applicable to it. -A branch may therefore legitimately skip functional version numbers. +If a different branch later receives a shared fix, it uses the next unused +Bug number, such as Forge 1.14.4's `4.0.8.114041`, even though the 4.0.7 repair +was not applicable there. A branch may therefore skip functional versions. This provides three useful guarantees: @@ -199,4 +211,3 @@ When assigning a version, ask the following questions in order: The objective is to make versions useful to players, pack developers, mod integrators, release automation, and support teams while allowing each maintained Minecraft branch to receive only the changes it actually needs. - diff --git a/gradle.properties b/gradle.properties index 728e6cbd..49987ba2 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,8 +5,6 @@ org.gradle.daemon=false org.gradle.parallel=false org.gradle.caching=true org.gradle.configuration-cache=false - - ## Environment Properties # The Minecraft version must agree with the NeoForge version to get a valid artifact @@ -18,6 +16,13 @@ minecraft_version_range=[26.2] # NeoForge 26.2.0.45-beta targets Minecraft 26.2. neo_version=26.2.0.45-beta neo_version_range=[26.2.0.45-beta,26.2.1) +loader_name=neoforge +loader_code=2 +java_version=25 +java_toolchain_version=25.0.3+9 +java_setup_version=25.0.3+9.0.LTS +gradle_java_version=25 +curseforge_project_id=245586 ## Mod Properties @@ -30,7 +35,7 @@ mod_name=MMD OreSpawn # The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default. mod_license=LGPL-2.1 # The mod version. See https://semver.org/ -mod_version=4.0.6.2602002 +mod_version=4.0.16.2602002 # The group ID for the mod. It is only important when publishing as an artifact to a Maven repository. # This should match the base package used for the mod sources. # See https://maven.apache.org/guides/mini/guide-naming-conventions.html diff --git a/settings.gradle b/settings.gradle index 3a3e5b92..00440736 100644 --- a/settings.gradle +++ b/settings.gradle @@ -8,3 +8,5 @@ pluginManagement { plugins { id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0' } + +rootProject.name = 'OreSpawn' diff --git a/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java b/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java index 3ea42e96..a9c9596c 100644 --- a/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java +++ b/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java @@ -18,7 +18,10 @@ import zone.moddev.mc.orespawn.api.BiomeRegionSize; import zone.moddev.mc.orespawn.api.BiomeReplacementScope; import zone.moddev.mc.orespawn.api.GeologyFamily; +import zone.moddev.mc.orespawn.api.GeologySampler; import zone.moddev.mc.orespawn.api.OreSpawnApi; +import zone.moddev.mc.orespawn.api.OreHeightDistribution; +import zone.moddev.mc.orespawn.api.OrePattern; import zone.moddev.mc.orespawn.api.ProviderStatus; import zone.moddev.mc.orespawn.api.WorldgenProvider; import zone.moddev.mc.orespawn.api.WorldgenProvider.BiomeSurfaceDefinition; @@ -75,7 +78,19 @@ public final class SurfaceProbeTestMod { private static final 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.DYED_TERRACOTTA.pick(DyeColor.WHITE), + Blocks.DYED_TERRACOTTA.pick(DyeColor.ORANGE), Blocks.DYED_TERRACOTTA.pick(DyeColor.RED) + }; + 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 +101,13 @@ public final class SurfaceProbeTestMod { private static final int MAXIMUM_CHUNK = 65; private static final int EXPECTED_COLUMNS = 9 * 16 * 16; private static final int EXPECTED_FILLER = EXPECTED_COLUMNS * 3; + private static final int EXPECTED_NATURAL_SOURCES = 9 * NATURAL_SOURCES.length; private static final String PHASE_PROPERTY = "surfaceprobe.integrationPhase"; private static final String MARKER_NAME = "surfaceprobe-integration.properties"; private static final String CHEST_ITEM_NAME = "surfaceprobe sentinel"; + private static final String RAW_CHEST_ITEM_NAME = "surfaceprobe raw block entity sentinel"; + private static final BlockState WEATHER_SNOW_REPLACEMENT = Blocks.WOOL.pick(DyeColor.WHITE).defaultBlockState(); + private static final BlockState WEATHER_ICE_REPLACEMENT = Blocks.BLUE_ICE.defaultBlockState(); static { FEATURES.register("terrain_setup", () -> new ProbeFeature(ProbeStage.TERRAIN)); @@ -106,6 +125,22 @@ public SurfaceProbeTestMod(IEventBus modBus) { private void enqueueProvider(InterModEnqueueEvent event) { WorldgenProvider.Builder provider = WorldgenProvider.builder(MODID, 1); addDynamicBiomeGeology(provider); + provider.ore(DYNAMIC_ORE, blockId(Blocks.DIAMOND_BLOCK), ore -> ore + .retrogen(false) + .dimension(OPEN_ID, placement -> placement + .yRange(16, 48) + .attempts(16.0D) + .quantity(8) + .pattern(OrePattern.CLUSTER) + .heightDistribution(OreHeightDistribution.UNIFORM) + .discardChanceOnAirExposure(0.0D) + .spread(4, 3) + .nodeSize(3) + .hostBlock(blockId(Blocks.CALCITE)) + .biome(BIOME_A) + .biomeDictionary("COLD") + .excludeBiome(BIOME_B) + .excludeBiomeDictionary("SPOOKY"))); provider.fluidDeposit(DYNAMIC_FLUID, blockId(Blocks.WATER), deposit -> deposit .dimension(OPEN_ID, placement -> placement .yRange(16, 24) @@ -115,9 +150,13 @@ private void enqueueProvider(InterModEnqueueEvent event) { .maxLobes(1) .minSolidCover(1) .minSolidShell(1) - .hostBlock(blockId(Blocks.CALCITE)))); + .hostBlock(blockId(Blocks.CALCITE)) + .hostBlock(blockId(Blocks.BASALT)))); addPalette(provider, "open_palette", OPEN_ID, false); addPalette(provider, "roofed_palette", ROOFED_ID, true); + provider.dimensionMaterials(Identifier.parse(MODID + ":materials/end"), OPEN_ID, + materials -> materials.snowBlock(blockId(Blocks.WOOL.pick(DyeColor.WHITE))) + .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())) { @@ -128,21 +167,39 @@ private void enqueueProvider(InterModEnqueueEvent event) { private static void addDynamicBiomeGeology(WorldgenProvider.Builder provider) { provider.geome(PROBE_GEOME, geome -> geome .baseWeight(0.0D) - .familyWeight(GeologyFamily.SEDIMENTARY, 1.0D)); + .familyWeight(GeologyFamily.SEDIMENTARY, 1.0D) + .familyWeight(GeologyFamily.IGNEOUS_INTRUSIVE, 1.0D)); + provider.geome(PROBE_GEOME_ALTERNATIVE, geome -> geome + .baseWeight(0.0D) + .familyWeight(GeologyFamily.SEDIMENTARY, 1.0D) + .familyWeight(GeologyFamily.IGNEOUS_INTRUSIVE, 1.0D)); provider.rock(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) { @@ -156,6 +213,18 @@ private void enableGeologyProbe(ServerAboutToStartEvent event) { } try { root.addProperty("place_fluid_deposits", true); + root.addProperty("place_ores", true); + JsonObject dictionary = root.getAsJsonObject("biome_dictionary"); + if (dictionary == null) { + dictionary = new JsonObject(); + root.add("biome_dictionary", dictionary); + } + JsonObject cold = dictionary.getAsJsonObject("COLD"); + if (cold == null) { + cold = new JsonObject(); + dictionary.add("COLD", cold); + } + cold.addProperty(PROBE_GEOME.toString(), 8.0D); JsonObject terrain = root.getAsJsonObject("terrain_dimensions"); if (terrain == null) { terrain = new JsonObject(); @@ -169,6 +238,8 @@ private void enableGeologyProbe(ServerAboutToStartEvent event) { end.add("biome_namespaces", namespaces); JsonArray hosts = new JsonArray(); hosts.add(blockId(Blocks.END_STONE).toString()); + for (Block source : NATURAL_SOURCES) hosts.add(blockId(source).toString()); + for (Block source : INVALID_TERRAIN_HOSTS) hosts.add(blockId(source).toString()); end.add("host_blocks", hosts); end.add("host_tags", new JsonArray()); terrain.add(OPEN_ID.toString(), end); @@ -283,6 +354,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++) { @@ -292,6 +378,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; @@ -321,8 +411,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++; } } @@ -339,28 +436,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) { @@ -476,6 +795,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; } @@ -570,9 +910,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(); } @@ -593,6 +964,7 @@ private static boolean placeStructureSentinels(WorldGenLevel world, ChunkAccess chest.setItem(0, sentinel); chest.setChanged(); } + placeAuthoredNaturalSources(world, chunk, pos, minX, minZ, 16); return true; } @@ -608,9 +980,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()); @@ -619,7 +1038,30 @@ private static int markedGround(ChunkAccess chunk, BlockPos.MutableBlockPos pos, private record Material(BlockState top, BlockState filler, BlockState underwater, BlockState ceiling) { } + private record NaturalSourceAudit(long rawConverted, long structurePreserved, + long vegetationPreserved, long cavePreserved, long underwaterPreserved, + long bedrockPreserved, long blockEntityPreserved) { } + + private record WeatherMaterialAudit(long exposedSnowConverted, + long surfaceIceConverted, long buriedSnowPreserved, + long buriedIcePreserved, long unconfiguredSnowPreserved, + long unconfiguredIcePreserved) { } + + private record AttributionAudit(long sedimentaryHosts, long intrusiveHosts, + long biomeAHosts, long biomeBHosts, long mismatches) { + private static final AttributionAudit EMPTY = new AttributionAudit(0L, 0L, 0L, 0L, 0L); + } + private record AuditResult(long top, long underwater, long filler, long geology, long ceiling, long roofTop, int biomeA, int biomeB, - int edgeChanges, int sentinels, long aquiferFluid) { } + int edgeChanges, int sentinels, long aquiferFluid, + long rawNaturalSources, long structureNaturalSources, + long vegetationNaturalSources, long cavePockets, long underwaterPockets, + long rawBedrock, long rawBlockEntities, + long dictionaryPrimary, long dictionaryAlternative, long dynamicBiomeOre, + long exposedSnowConverted, long surfaceIceConverted, + long buriedSnowPreserved, long buriedIcePreserved, + long unconfiguredSnowPreserved, long unconfiguredIcePreserved, + long attributionSedimentary, long attributionIntrusive, + long attributionBiomeA, long attributionBiomeB, long attributionMismatches) { } } diff --git a/src/biomeIntegrationTest/resources/data/c/tags/worldgen/biome/is_cold.json b/src/biomeIntegrationTest/resources/data/c/tags/worldgen/biome/is_cold.json new file mode 100644 index 00000000..0c73c984 --- /dev/null +++ b/src/biomeIntegrationTest/resources/data/c/tags/worldgen/biome/is_cold.json @@ -0,0 +1,6 @@ +{ + "replace": false, + "values": [ + "surfaceprobe:surface_a" + ] +} diff --git a/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/clientprobe/ClientProbeTestMod.java b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/clientprobe/ClientProbeTestMod.java new file mode 100644 index 00000000..2c30dd31 --- /dev/null +++ b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/clientprobe/ClientProbeTestMod.java @@ -0,0 +1,551 @@ +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.MenuTabBar; +import net.minecraft.client.gui.components.tabs.TabNavigationBar; +import net.minecraft.client.gui.components.tabs.TabManager; +import net.minecraft.client.gui.screens.AccessibilityOnboardingScreen; +import net.minecraft.client.gui.screens.BackupConfirmScreen; +import net.minecraft.client.gui.screens.ConfirmScreen; +import net.minecraft.client.gui.screens.Screen; +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.neoforged.api.distmarker.Dist; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.fml.common.Mod; +import net.neoforged.neoforge.client.event.ClientTickEvent; +import net.neoforged.neoforge.client.event.RenderLevelStageEvent; +import net.neoforged.neoforge.client.event.ScreenEvent; +import zone.moddev.mc.orespawn.client.OreSpawnWorldSettingsScreen; +import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfile; + +/** Build-only isolated client probe. It is compiled and packaged outside every release artifact. */ +@Mod(ClientProbeTestMod.MODID) +@EventBusSubscriber(modid = ClientProbeTestMod.MODID, value = Dist.CLIENT) +public final class ClientProbeTestMod { + static final String MODID = "clientprobe"; + private static final String WORLD_DIRECTORY = "New World"; + private static final String WORLD_SEED = "-4965128775892001975"; + private static volatile ClientProbeTestMod instance; + private final Set editorRoutes = new HashSet<>(); + private final Set attemptedButtons = new HashSet<>(); + private int state; + private int stateTicks; + private int firstWorldFrames; + private int reloadWorldFrames; + private int editorFrames; + private boolean worldSettingsOpened; + private boolean longEditorRoundTrip; + private List worldCreationButtons; + private static final InputWithModifiers NO_MODIFIERS = new InputWithModifiers() { + @Override public int input() { return 0; } + @Override public int modifiers() { return 0; } + }; + + public ClientProbeTestMod() { + instance = this; + } + + @SubscribeEvent + public static void onScreenInitialized(ScreenEvent.Init.Post event) { + ClientProbeTestMod probe = instance; + if (probe == null || !Boolean.getBoolean("clientprobe.enabled")) return; + if (!(event.getScreen() instanceof CreateWorldScreen)) return; + probe.worldCreationButtons = event.getListenersList(); + } + + @SubscribeEvent + public static void onScreenDrawn(ScreenEvent.Render.Post event) { + ClientProbeTestMod probe = instance; + if (probe != null && Boolean.getBoolean("clientprobe.enabled") + && isOreSpawnEditor(event.getScreen())) probe.editorFrames++; + } + + @SubscribeEvent + public static void onWorldRendered(RenderLevelStageEvent.AfterLevel event) { + ClientProbeTestMod probe = instance; + if (probe == null || !Boolean.getBoolean("clientprobe.enabled")) return; + if (probe.state == 6) probe.firstWorldFrames++; + if (probe.state == 8) probe.reloadWorldFrames++; + } + + @SubscribeEvent + public static void onClientTick(ClientTickEvent.Post event) { + ClientProbeTestMod probe = instance; + if (probe == null || !Boolean.getBoolean("clientprobe.enabled")) return; + probe.handleClientTick(); + } + + private void handleClientTick() { + Minecraft minecraft = Minecraft.getInstance(); + if (++stateTicks > 3600) fail(minecraft, "Timed out in client probe state " + state + + " on screen " + (minecraft.gui.screen() == null ? "" : minecraft.gui.screen().getClass().getName())); + try { + switch (state) { + case 0: + if (minecraft.gui.screen() instanceof AccessibilityOnboardingScreen) { + minecraft.gui.screen().onClose(); + } + if (minecraft.gui.screen() instanceof TitleScreen) { + Screen parent = minecraft.gui.screen(); + CreateWorldScreen.openFresh(minecraft, () -> minecraft.gui.setScreen(parent)); + nextState(1); + } + break; + case 1: + if (minecraft.gui.screen() instanceof CreateWorldScreen + && activateWorldSettings(minecraft.gui.screen(), worldCreationButtons)) { + worldSettingsOpened = true; + nextState(2); + } + break; + case 2: + if (minecraft.gui.screen() instanceof CreateWorldScreen && stateTicks >= 2) { + validateCaptions(minecraft.gui.screen()); + validateLongEditorRoundTrip(minecraft, minecraft.gui.screen()); + nextState(3); + } + break; + case 3: + if (minecraft.gui.screen() instanceof CreateWorldScreen) { + CreateWorldScreen root = (CreateWorldScreen) minecraft.gui.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.gui.screen(); + press(target); + if (minecraft.gui.screen() != before && isOreSpawnEditor(minecraft.gui.screen())) { + editorRoutes.add(minecraft.gui.screen().getClass().getSimpleName()); + editorFrames = 0; + nextState(4); + } + } + } + break; + case 4: + if (isOreSpawnEditor(minecraft.gui.screen()) && editorFrames >= 2) { + validateCaptions(minecraft.gui.screen()); + minecraft.gui.screen().onClose(); + nextState(3); + } + break; + case 5: + break; + case 6: + if (minecraft.gui.screen() instanceof ConfirmScreen) { + pressWorldCreationConfirmation((ConfirmScreen) minecraft.gui.screen()); + } + if (minecraft.gui.screen() instanceof ConfirmExperimentalFeaturesScreen) { + pressExperimentalProceed((ConfirmExperimentalFeaturesScreen) minecraft.gui.screen()); + } + if (minecraft.level != null && minecraft.player != null && firstWorldFrames >= 8 + && stateTicks >= 100) { + nextState(7); + stopIntegratedServer(minecraft); + } + break; + case 7: + if (minecraft.level == null && !minecraft.hasSingleplayerServer() && stateTicks >= 100) { + minecraft.createWorldOpenFlows().openWorld(WORLD_DIRECTORY, + () -> minecraft.gui.setScreen(new TitleScreen())); + nextState(8); + } + break; + case 8: + if (minecraft.gui.screen() instanceof BackupConfirmScreen) { + pressBackupConfirmation((BackupConfirmScreen) minecraft.gui.screen()); + } + if (minecraft.gui.screen() instanceof ConfirmScreen) { + pressWorldCreationConfirmation((ConfirmScreen) minecraft.gui.screen()); + } + if (minecraft.gui.screen() instanceof ConfirmExperimentalFeaturesScreen) { + pressExperimentalProceed((ConfirmExperimentalFeaturesScreen) minecraft.gui.screen()); + } + if (minecraft.level != null && minecraft.player != null && reloadWorldFrames >= 8 + && stateTicks >= 100) { + nextState(9); + stopIntegratedServer(minecraft); + } + 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)) { + // Minecraft 26.2's create-world menu uses an empty-captioned composite + // container; its visible tab controls are checked independently. + if (widget instanceof MenuTabBar) continue; + 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 on " + + screen.getClass().getName() + " widget " + widget.getClass().getName() + + ": " + 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.gui.setScreen(screen); + if (minecraft.gui.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 = ((CreateWorldScreen) screen).tabManager; + if (manager != null && manager.getCurrentTab() != null) { + manager.getCurrentTab().visitChildren(widget -> { + if (!result.contains(widget)) result.add(widget); + }); + } + } + return result; + } + + private static void stopIntegratedServer(Minecraft minecraft) { + // Minecraft 26.2 waits for its integrated server after closing the client + // connection. Stop it first so the fixture neither double-closes the local + // channel nor waits forever for a server that still considers itself running. + 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 NeoForge 26.2 client integration gate"); + } + } + + private void nextState(int next) { + state = next; + stateTicks = 0; + } + + private static void fail(Minecraft minecraft, String message) { + try { + Properties values = new Properties(); values.setProperty("failure", message); + try (FileOutputStream output = new FileOutputStream(new File("client-smoke-failure.properties"))) { + values.store(output, "OreSpawn client probe failure"); + } + } catch (IOException ignored) { + } + minecraft.stop(); + throw new IllegalStateException(message); + } +} diff --git a/src/clientIntegrationTest/resources/META-INF/neoforge.mods.toml b/src/clientIntegrationTest/resources/META-INF/neoforge.mods.toml new file mode 100644 index 00000000..e57223e8 --- /dev/null +++ b/src/clientIntegrationTest/resources/META-INF/neoforge.mods.toml @@ -0,0 +1,21 @@ +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="orespawn" +type="required" +versionRange="[4.0.6,5.0.0)" +ordering="AFTER" +side="CLIENT" + +[[dependencies.clientprobe]] +modId="minecraft" +type="required" +versionRange="[26.2]" +ordering="NONE" +side="CLIENT" diff --git a/src/clientIntegrationTest/resources/pack.mcmeta b/src/clientIntegrationTest/resources/pack.mcmeta new file mode 100644 index 00000000..3485fc2c --- /dev/null +++ b/src/clientIntegrationTest/resources/pack.mcmeta @@ -0,0 +1,10 @@ +{ + "pack": { + "description": "OreSpawn client qualification fixture", + "max_format": 107, + "min_format": [ + 107, + 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 0caf7da0..d38b5a90 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 cc4b916f..8bc8ebc9 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java @@ -636,7 +636,7 @@ JsonObject weightMap(String section, String id) { void addGeome(String id) { String normalized = id.trim().toLowerCase(Locale.ROOT); - if (!normalized.matches("[a-z0-9_.-]+") || section("geomes").has(normalized)) { + if (!validGeomeId(normalized) || section("geomes").has(normalized)) { return; } JsonObject geome = new JsonObject(); @@ -696,7 +696,7 @@ List validate() { } for (Entry entry : terrainActive ? geomes.entrySet() : Collections.>emptySet()) { - if (!entry.getKey().matches("[a-z0-9_.-]+") || !entry.getValue().isJsonObject()) { + if (!validGeomeId(entry.getKey()) || !entry.getValue().isJsonObject()) { errors.add("Invalid geome: " + entry.getKey()); continue; } @@ -1155,6 +1155,13 @@ private static boolean validBlock(String id) { return block != null && block != Blocks.AIR; } + private static boolean validGeomeId(String id) { + if (id == null || id.isEmpty()) return false; + if (id.indexOf(':') < 0) return id.matches("[a-z0-9_.-]+"); + if (!validResource(id)) return false; + return id.equals(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 42b13554..3139999e 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 fd6f22b0..f4b815a6 100644 --- a/src/main/java/zone/moddev/mc/orespawn/documentation/DocumentationExporter.java +++ b/src/main/java/zone/moddev/mc/orespawn/documentation/DocumentationExporter.java @@ -30,6 +30,7 @@ public final class DocumentationExporter { "MIGRATION.md", "TROUBLESHOOTING.md", "AGENTS.md", + "VERSIONS.md", "examples/examplemod-orespawn.json", "examples/orespawn-global.json", "examples/orespawn-world.json", diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/BakedGeomeConfig.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/BakedGeomeConfig.java index c82213d4..fbf2c37f 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/BakedGeomeConfig.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/BakedGeomeConfig.java @@ -34,6 +34,7 @@ public final class BakedGeomeConfig { private final Map biomeWeights; private final Map biomeWeightsById; private final double[] fallbackWeights; + private final RockEntry[] rocks; private final BlockState[] rockStates; private final Set sedimentaryBlocks; private final Set oreReplaceableBlocks; @@ -44,6 +45,9 @@ public final class BakedGeomeConfig { private WeightedBlockPicker[][][] legacyRockPickers; private byte[] stableFamilyChoices; private int[] stableRockChoices; + private int[][] familyRockIndexes; + private double[][][] stableRockLogWeights; + private double[][][] stableRockPriorities; BakedGeomeConfig(GeomeDefinition[] geomes, double geomeScale, double biomeInfluence, double regionalNoiseInfluence, double boundaryNoiseInfluence, Map biomeWeights, @@ -60,7 +64,7 @@ public final class BakedGeomeConfig { for (Map.Entry entry : biomeWeights.entrySet()) { Identifier biomeId = BiomeRegistryAccess.id(entry.getKey()); if (biomeId != null) { - biomeWeightsById.put(biomeId, entry.getValue()); + this.biomeWeightsById.putIfAbsent(biomeId, entry.getValue()); } } this.fallbackWeights = defaultWeights(geomes.length); @@ -71,6 +75,7 @@ public final class BakedGeomeConfig { noiseOffsetZ[i] = -((i + 1) * 6151); } + this.rocks = rocks.clone(); rockStates = new BlockState[rocks.length]; for (int i = 0; i < rocks.length; i++) { rockStates[i] = rocks[i].state; @@ -151,15 +156,69 @@ RockFamily pickFamily(int geomeIndex, int y, int formationValue, int diversitySl return RockFamily.SEDIMENTARY; } + RockFamily pickStableFamilyAtWorldY(int geomeIndex, int worldY, int formationY, + int formationValue, int diversitySlot) { + RockFamily preferred = pickFamily(geomeIndex, formationY, formationValue, diversitySlot); + if (hasEligibleStableRock(geomeIndex, preferred, worldY, formationY)) { + return preferred; + } + + int bucket = formationValue & 0xFF; + int boundedFormationY = clampStableValue(formationY); + double bestScore = Double.NEGATIVE_INFINITY; + RockFamily bestFamily = preferred; + for (RockFamily family : RockFamily.values()) { + if (!hasEligibleStableRock(geomeIndex, family, worldY, formationY)) { + continue; + } + double weight = Math.pow(geomes[geomeIndex].familyWeights[family.ordinal()], 2.5D) + * familyDepthWeight(family, boundedFormationY); + if (weight <= 0.0D) { + continue; + } + double score = Math.log(weight) + gumbelPriority(bucket, geomeIndex, family.ordinal(), + isStableBucket(bucket, -1), 0x6A09E667F3BCC909L); + if (score > bestScore) { + bestScore = score; + bestFamily = family; + } + } + return bestFamily; + } + public BlockState pickRock(int geomeIndex, RockFamily family, int y, int formationValue) { if (formations.usesStableLayers()) { - int index = stableRockIndex(geomeIndex, family.ordinal(), clampStableY(y), formationValue & 0xFF); - int rockIndex = stableRockChoices[index]; - return rockIndex < 0 ? FALLBACK : rockStates[rockIndex]; + return pickStableRockAtWorldY(geomeIndex, family, y, y, formationValue); } return legacyRockPickers[geomeIndex][family.ordinal()][clampLegacyY(y)].pick(formationValue); } + BlockState pickStableRockAtWorldY(int geomeIndex, RockFamily family, int worldY, + int formationY, int formationValue) { + int yIndex = clampStableY(formationY); + int bucket = formationValue & 0xFF; + int choiceIndex = stableRockIndex(geomeIndex, family.ordinal(), yIndex, bucket); + int selectedRock = stableRockChoices[choiceIndex]; + if (isEligibleStableRock(geomeIndex, selectedRock, worldY, yIndex)) { + return rockStates[selectedRock]; + } + + double bestScore = Double.NEGATIVE_INFINITY; + int bestRock = -1; + for (int rockIndex : familyRockIndexes[family.ordinal()]) { + if (!isEligibleStableRock(geomeIndex, rockIndex, worldY, yIndex)) { + continue; + } + double score = stableRockLogWeights[geomeIndex][rockIndex][yIndex] + + stableRockPriorities[geomeIndex][rockIndex][bucket]; + if (score > bestScore) { + bestScore = score; + bestRock = rockIndex; + } + } + return bestRock < 0 ? FALLBACK : rockStates[bestRock]; + } + public String geomeName(int geomeIndex) { return geomes[geomeIndex].name; } @@ -226,12 +285,12 @@ int familyDiversitySlots() { } String describeBiomeWeights(Biome biome) { - double[] weights = biomeWeights.get(biome); - String source = "identity"; + Identifier biomeId = BiomeRegistryAccess.id(biome); + double[] weights = biomeId == null ? null : biomeWeightsById.get(biomeId); + String source = "registry-id"; if (weights == null) { - Identifier biomeId = BiomeRegistryAccess.id(biome); - weights = biomeId == null ? null : biomeWeightsById.get(biomeId); - source = "registry-id"; + weights = biomeWeights.get(biome); + source = "identity"; } if (weights == null) { weights = fallbackWeights; @@ -270,10 +329,8 @@ boolean hasDistinctBiomeWeights(Biome biome) { } private double[] biomeWeightsFor(Biome biome, 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; } @@ -282,9 +339,9 @@ private void buildStablePickers(RockEntry[] rocks) { stableRockChoices = new int[geomes.length * RockFamily.values().length * HEIGHT * FORMATION_BUCKETS]; Arrays.fill(stableRockChoices, -1); int familyCount = RockFamily.values().length; - int[][] familyRockIndexes = groupRockIndexes(rocks); - double[][][] rockLogWeights = new double[geomes.length][rocks.length][HEIGHT]; - double[][][] rockPriorities = new double[geomes.length][rocks.length][FORMATION_BUCKETS]; + familyRockIndexes = groupRockIndexes(rocks); + stableRockLogWeights = new double[geomes.length][rocks.length][HEIGHT]; + stableRockPriorities = new double[geomes.length][rocks.length][FORMATION_BUCKETS]; double[][][] familyLogWeights = new double[geomes.length][familyCount][HEIGHT]; double[][][] familyWeights = new double[geomes.length][familyCount][HEIGHT]; double[][][] familyPriorities = new double[geomes.length][familyCount][FORMATION_BUCKETS]; @@ -293,14 +350,13 @@ private void buildStablePickers(RockEntry[] rocks) { for (int rockIndex = 0; rockIndex < rocks.length; rockIndex++) { RockEntry rock = rocks[rockIndex]; for (int y = MIN_Y; y <= MAX_Y; y++) { - double rawWeight = y < rock.minY || y > rock.maxY ? 0.0D - : rock.weight * rock.geomeWeights[geome] - * depthWeight(y, rock.depthPeak, rock.depthSpread); - rockLogWeights[geome][rockIndex][y - MIN_Y] = rawWeight > 0.0D + double rawWeight = rock.weight * rock.geomeWeights[geome] + * depthWeight(y, rock.depthPeak, rock.depthSpread); + stableRockLogWeights[geome][rockIndex][y - MIN_Y] = rawWeight > 0.0D ? Math.log(rawWeight) : Double.NEGATIVE_INFINITY; } for (int bucket = 0; bucket < FORMATION_BUCKETS; bucket++) { - rockPriorities[geome][rockIndex][bucket] = gumbelPriority(bucket, geome, rockIndex, + stableRockPriorities[geome][rockIndex][bucket] = gumbelPriority(bucket, geome, rockIndex, isStableBucket(bucket, rock.family.ordinal()), 0xBB67AE8584CAA73BL ^ ((long) rock.family.ordinal() << 32)); } @@ -312,7 +368,9 @@ private void buildStablePickers(RockEntry[] rocks) { int yIndex = y - MIN_Y; boolean available = false; for (int rockIndex : familyRockIndexes[familyIndex]) { - if (rockLogWeights[geome][rockIndex][yIndex] != Double.NEGATIVE_INFINITY) { + RockEntry rock = rocks[rockIndex]; + if (y >= rock.minY && y <= rock.maxY + && stableRockLogWeights[geome][rockIndex][yIndex] != Double.NEGATIVE_INFINITY) { available = true; break; } @@ -362,8 +420,12 @@ private void buildStablePickers(RockEntry[] rocks) { double bestRockScore = Double.NEGATIVE_INFINITY; int bestRock = -1; for (int rockIndex : familyRockIndexes[familyIndex]) { - double rockScore = rockLogWeights[geome][rockIndex][yIndex] - + rockPriorities[geome][rockIndex][bucket]; + RockEntry rock = rocks[rockIndex]; + if (y < rock.minY || y > rock.maxY) { + continue; + } + double rockScore = stableRockLogWeights[geome][rockIndex][yIndex] + + stableRockPriorities[geome][rockIndex][bucket]; if (rockScore > bestRockScore) { bestRockScore = rockScore; bestRock = rockIndex; @@ -376,6 +438,25 @@ private void buildStablePickers(RockEntry[] rocks) { } } + private boolean hasEligibleStableRock(int geomeIndex, RockFamily family, int worldY, int formationY) { + int yIndex = clampStableY(formationY); + for (int rockIndex : familyRockIndexes[family.ordinal()]) { + if (isEligibleStableRock(geomeIndex, rockIndex, worldY, yIndex)) { + return true; + } + } + return false; + } + + private boolean isEligibleStableRock(int geomeIndex, int rockIndex, int worldY, int formationYIndex) { + if (rockIndex < 0) { + return false; + } + RockEntry rock = rocks[rockIndex]; + return worldY >= rock.minY && worldY <= rock.maxY + && stableRockLogWeights[geomeIndex][rockIndex][formationYIndex] != Double.NEGATIVE_INFINITY; + } + private void fillBalancedFamilyCycle(int geome, int yIndex, int bucket, double[][][] familyWeights, double[][][] familyPriorities, int[] quotas, int[] remaining, double[] remainders, boolean[] bonusAwarded) { @@ -630,6 +711,10 @@ private static int clampStableY(int y) { return Math.max(MIN_Y, Math.min(MAX_Y, y)) - MIN_Y; } + private static int clampStableValue(int y) { + return Math.max(MIN_Y, Math.min(MAX_Y, y)); + } + private static int clampLegacyY(int y) { return Math.max(0, Math.min(LEGACY_MAX_Y, y)); } diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/BakedTerrainDimension.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/BakedTerrainDimension.java index 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 7268b6be..46adcea1 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeTypeCompatibility.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeTypeCompatibility.java @@ -61,6 +61,10 @@ static boolean hasType(ResourceKey key, String type) { .map(holder -> matches(holder, tags(type))).orElse(false); } + static Biome biome(ResourceKey key) { + return zone.moddev.mc.orespawn.worldgen.BiomeRegistryAccess.get(key.identifier()); + } + static boolean hasType(Biome biome, String type) { return matches(zone.moddev.mc.orespawn.worldgen.BiomeRegistryAccess.holder(biome), tags(type)); } diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/Geology.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/Geology.java index 80a9763e..0dce7dc9 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; StoneReplacer.setRockState(chunk, cursor, current, replacement); 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 011c244c..80ff5537 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 fdda0ef3..44c561e5 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,7 @@ public void replaceStoneInChunk(LevelAccessor world, ChunkAccess chunk, BakedTer for (int y = surfaceY; y >= chunk.getMinY(); y--) { cursor.set(x, y, z); BlockState current = chunk.getBlockState(cursor); - if (terrain.isReplaceable(current)) { + if (terrain.isReplaceable(current) && chunk.getBlockEntity(cursor) == null) { StoneReplacer.setRockState(chunk, cursor, current, pickReplacement(geomeIndex, baseRockValue, formationRegion, x, y, z)); changed = true; @@ -141,7 +140,6 @@ private boolean replaceStableColumn(ChunkAccess chunk, BlockPos.MutableBlockPos int layerStart = layerIndex * layerThickness; int layerGeome = pickStableLayerGeome(geomeScores, geomeIndex, secondGeome, layerIndex, geomeTransitionPhase); - BlockState replacement = pickStableReplacement(layerGeome, formationRegion, layerIndex); boolean changed = false; cursor.set(x, surfaceY, z); @@ -152,12 +150,12 @@ private boolean replaceStableColumn(ChunkAccess chunk, BlockPos.MutableBlockPos layerStart -= layerThickness; layerGeome = pickStableLayerGeome(geomeScores, geomeIndex, secondGeome, layerIndex, geomeTransitionPhase); - replacement = pickStableReplacement(layerGeome, formationRegion, layerIndex); } cursor.setY(y); BlockState current = chunk.getBlockState(cursor); - if (terrain.isReplaceable(current)) { - StoneReplacer.setRockState(chunk, cursor, current, replacement); + if (terrain.isReplaceable(current) && chunk.getBlockEntity(cursor) == null) { + StoneReplacer.setRockState(chunk, cursor, current, + pickStableReplacement(layerGeome, formationRegion, layerIndex, y)); changed = true; } } @@ -250,7 +248,7 @@ private net.minecraft.world.level.block.state.BlockState pickReplacement(int geo int stratum = baseRockValue + y; int layerIndex = Math.floorDiv(stratum, layerThickness); if (stableLayers) { - return pickStableReplacement(geomeIndex, formationRegion, layerIndex); + return pickStableReplacement(geomeIndex, formationRegion, layerIndex, y); } int layerY = y + (layerThickness / 2) - Math.floorMod(stratum, layerThickness); @@ -260,7 +258,7 @@ private net.minecraft.world.level.block.state.BlockState pickReplacement(int geo return config.pickRock(geomeIndex, family, layerY, rockHash); } - private BlockState pickStableReplacement(int geomeIndex, long formationRegion, int layerIndex) { + private BlockState pickStableReplacement(int geomeIndex, long formationRegion, int layerIndex, int worldY) { // A dipping or uplifted layer keeps the depth identity it had in stratum space. int formationY = (layerIndex * layerThickness) + (layerThickness / 2); int layerBucket = layerIndex & 0xFF; @@ -286,8 +284,9 @@ private BlockState pickStableReplacement(int geomeIndex, long formationRegion, i // from collapsing onto one exact rock. rockBucket ^= LITHOLOGY_ROCK_SALTS[familySlot]; } - RockFamily family = config.pickFamily(geomeIndex, formationY, familyBucket, familySlot); - return config.pickRock(geomeIndex, family, formationY, rockBucket); + RockFamily family = config.pickStableFamilyAtWorldY(geomeIndex, worldY, formationY, + familyBucket, familySlot); + return config.pickStableRockAtWorldY(geomeIndex, family, worldY, formationY, rockBucket); } int stratumOffsetAt(int x, int z) { diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java index 18043166..c4cf7615 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.2602002 Upgrade Report"); + lines.add("OreSpawn 4.0.16.2602002 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 ee31eeb0..5851e251 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.2602002 Upgrade Report"); + lines.add("OreSpawn 4.0.16.2602002 Upgrade Report"); lines.add("================================"); lines.add(""); lines.add("RESULT: Existing Mineralogy " + identity.version + " world detected."); diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnBiomeModifier.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnBiomeModifier.java index 02277e43..f5a00cac 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnBiomeModifier.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnBiomeModifier.java @@ -41,7 +41,6 @@ static boolean apply(BiomeGenerationSettingsBuilder generation) { generation.getFeatures(GenerationStep.Decoration.UNDERGROUND_ORES); changed |= StoneReplacer.wrapVanillaMatchingStoneFeatures(underground); changed |= VanillaOreFeatureGate.wrapFeatureList(underground); - changed |= addUnique(underground, StoneReplacer.placedFeature()); changed |= addUnique(underground, OreSpawnOreGeneration.placedFeature()); changed |= addUnique(underground, FluidDepositFeature.placedFeature()); @@ -51,7 +50,8 @@ static boolean apply(BiomeGenerationSettingsBuilder generation) { List> local = generation.getFeatures(GenerationStep.Decoration.LOCAL_MODIFICATIONS); - changed |= addUnique(local, BiomeSurfaceFeature.placedFeature()); + changed |= StoneReplacer.placeUniqueAt(local, StoneReplacer.placedFeature(), 0); + changed |= StoneReplacer.placeUniqueAt(local, BiomeSurfaceFeature.placedFeature(), 1); List> top = generation.getFeatures(GenerationStep.Decoration.TOP_LAYER_MODIFICATION); diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGeneration.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGeneration.java index b44bfed6..7fc8244d 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 : BiomeRegistryAccess.get(id); - if (biome != null) result.add(biome); + if (id != null) result.add(ResourceKey.create(Registries.BIOME, id)); } } if (rule.has(dictionaryKey) && rule.get(dictionaryKey).isJsonArray()) { for (JsonElement element : rule.getAsJsonArray(dictionaryKey)) { try { - result.addAll(BiomeTypeCompatibility.biomes(element.getAsString())); + result.addAll(dictionaryResolver.apply(element.getAsString())); } catch (RuntimeException ignored) { } } @@ -505,6 +510,13 @@ private static Set resolveBiomes(JsonObject rule, String idsKey, String d return result; } + static boolean acceptsBiome(Set> includedBiomes, + Set> excludedBiomes, ResourceKey biome) { + if (biome == null) return includedBiomes.isEmpty() && excludedBiomes.isEmpty(); + return !excludedBiomes.contains(biome) + && (includedBiomes.isEmpty() || includedBiomes.contains(biome)); + } + private static Set resolveTag(TagKey tag) { Set result = Collections.newSetFromMap(new IdentityHashMap()); for (Block block : BuiltInRegistries.BLOCK.stream().toList()) { @@ -609,8 +621,8 @@ private static final class BakedOre { final Map hostBlocks; final int familyMask; final double[] geomeWeights; - final Set includedBiomes; - final Set excludedBiomes; + final Set> includedBiomes; + final Set> excludedBiomes; final boolean retrogen; BakedOre(BlockState output, BlockState deepOutput, int deepOutputMaxY, BakedOutput[] outputs, @@ -619,7 +631,8 @@ private static final class BakedOre { double discardChanceOnAirExposure, int spread, int verticalSpread, int nodeSize, Map hostBlocks, int familyMask, double[] geomeWeights, - Set includedBiomes, Set excludedBiomes, boolean retrogen) { + Set> includedBiomes, Set> excludedBiomes, + boolean retrogen) { this.output = output; this.deepOutput = deepOutput; this.deepOutputMaxY = deepOutputMaxY; @@ -667,9 +680,8 @@ boolean accepts(BlockState state, Random random, BakedGeomeConfig config) { && (familyMask & (1 << family.ordinal())) != 0; } - boolean acceptsBiome(Biome biome) { - return !excludedBiomes.contains(biome) - && (includedBiomes.isEmpty() || includedBiomes.contains(biome)); + boolean acceptsBiome(ResourceKey biome) { + return OreSpawnOreGeneration.acceptsBiome(includedBiomes, excludedBiomes, biome); } } diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/StoneReplacer.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/StoneReplacer.java index a9a70f94..52d18afd 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/StoneReplacer.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/StoneReplacer.java @@ -64,6 +64,23 @@ static Holder placedFeature() { return placedFeature; } + static boolean placeUniqueAt(List> features, + Holder feature, int index) { + if (feature == null) return false; + int current = -1; + for (int candidate = 0; candidate < features.size(); candidate++) { + if (features.get(candidate).value() == feature.value()) { + current = candidate; + break; + } + } + int target = Math.min(index, features.size() - (current >= 0 ? 1 : 0)); + if (current == target) return false; + if (current >= 0) features.remove(current); + features.add(target, feature); + return true; + } + static boolean removeVanillaMatchingStoneFeatures(List> features) { return features.removeIf(StoneReplacer::isVanillaMatchingStoneFeature); } diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/TerrainBiomeLookup.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/TerrainBiomeLookup.java new file mode 100644 index 00000000..387beea9 --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/TerrainBiomeLookup.java @@ -0,0 +1,21 @@ +package zone.moddev.mc.orespawn.worldgen; + +import net.minecraft.core.Holder; +import net.minecraft.core.QuartPos; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.biome.BiomeManager; + +/** + * Internal generation-time biome lookup shared by geology and its public + * read-only sampler. + */ +public final class TerrainBiomeLookup { + private TerrainBiomeLookup() { + } + + public static Holder atBlock(BiomeManager.NoiseBiomeSource source, + int blockX, int blockY, int blockZ) { + return source.getNoiseBiome(QuartPos.fromBlock(blockX), + QuartPos.fromBlock(blockY), QuartPos.fromBlock(blockZ)); + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldMaterialWeather.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldMaterialWeather.java index a1f809aa..8521e9c8 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 c4be8b98..3cc99f2a 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; @@ -88,7 +90,8 @@ private static void onServerStarted(ServerStartedEvent event) { if (level == null) { throw new IllegalStateException("Benchmark dimension is unavailable: " + dimensionName); } - if (level.getChunkSource().getGenerator() instanceof FlatLevelSource) { + if (level.getChunkSource().getGenerator() instanceof FlatLevelSource + && !allowsFlatBenchmark(event.getServer().getClass())) { throw new IllegalStateException("OreSpawn worldgen benchmarks require a normal terrain world; " + "Minecraft's GameTest server always uses the flat test preset"); } @@ -145,19 +148,23 @@ MODE, chunks, repetitions, format(median), format(median / chunks), throw new IllegalStateException("Benchmark fluid audit found no successful deposits"); } if (Boolean.getBoolean("orespawn.worldgenBenchmarkStopServer")) { - if (Boolean.getBoolean("neoforge.gameTestServer") - || Boolean.getBoolean("forge.gameTestServer")) { - // GameTestServer owns its exit code. Halting it from ServerStartedEvent - // leaves its tracker uninitialised, which 26.1 reports as exit -1 even - // though the benchmark completed. Let the empty test run finish normally. - LOGGER.info("ORESPAWN_BENCHMARK completed; allowing GameTest server to exit normally"); - } else { + 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 boolean allowsFlatBenchmark(Class serverType) { + return !ownsServerShutdown(serverType); + } + static ResourceKey benchmarkDimensionKey(String configured) { String dimensionName = configured.trim().toLowerCase(Locale.ROOT); return switch (dimensionName) { diff --git a/src/test/java/com/mcmoddev/mineralogy/MineralogyConfig.java b/src/test/java/com/mcmoddev/mineralogy/MineralogyConfig.java new file mode 100644 index 00000000..08c791bf --- /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 26.2 because several Minecraft and NeoForge 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..6e906f1e --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/ReleaseWorkflowContractTest.java @@ -0,0 +1,116 @@ +package zone.moddev.mc.orespawn; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Properties; + +import org.junit.jupiter.api.Test; + +class ReleaseWorkflowContractTest { + @Test + void usesOreSpawnSpecificMavenNamespace() throws Exception { + Properties properties = new Properties(); + try (InputStream input = Files.newInputStream(Paths.get("gradle.properties"))) { + properties.load(input); + } + assertEquals("zone.moddev.mc.orespawn", properties.getProperty("mod_group_id")); + } + + @Test + void declaresTheHostedSelectorForTheExactTemurinToolchain() throws Exception { + Properties properties = new Properties(); + try (InputStream input = Files.newInputStream(Paths.get("gradle.properties"))) { + properties.load(input); + } + assertEquals("25.0.3+9", properties.getProperty("java_toolchain_version")); + assertEquals("25.0.3+9.0.LTS", properties.getProperty("java_setup_version")); + } + + @Test + void verifiesGeneratedMavenCoordinatesBeforeCheckAndPublication() throws Exception { + Path buildFile = Paths.get("build.gradle"); + String build = new String(Files.readAllBytes(buildFile), StandardCharsets.UTF_8); + assertTrue(build.contains("tasks.register('verifyMavenCoordinates')")); + assertTrue(build.contains("generatePomFileForMavenJavaPublication")); + assertTrue(build.contains("dependsOn tasks.named('verifyMavenCoordinates')")); + assertTrue(build.contains("expectedMavenCoordinate")); + } + + @Test + void hostedWorkflowsUseOnlyThePinnedTemurinJdk() throws Exception { + for (String workflow : new String[] { "ci.yml", "codeql-analysis.yml" }) { + String text = new String(Files.readAllBytes( + Paths.get(".github", "workflows", workflow)), StandardCharsets.UTF_8); + int jobCount = workflow.equals("ci.yml") ? 2 : 1; + assertEquals(jobCount, occurrences(text, "actions/setup-java@")); + assertEquals(jobCount, occurrences(text, "distribution: temurin")); + assertEquals(jobCount, occurrences(text, "java-version: '25.0.3+9.0.LTS'")); + assertEquals(jobCount, occurrences(text, + "-Dorg.gradle.java.installations.paths=$JAVA_HOME")); + assertEquals(jobCount, occurrences(text, + "-Dorg.gradle.java.installations.auto-detect=false")); + assertEquals(jobCount, occurrences(text, + "-Dorg.gradle.java.installations.auto-download=false")); + if (workflow.equals("ci.yml")) { + assertEquals(6, occurrences(text, "\"${gradle_jdk_args[@]}\"")); + } else { + assertEquals(2, occurrences(text, "\"${gradle_jdk_args[@]}\"")); + } + assertFalse(text.contains("21.0.7")); + assertFalse(text.contains("8.0.502")); + assertFalse(text.contains("MinecraftMavenizer")); + } + } + + @Test + void codeQlUsesABoundedCachePreservingCompileRetry() throws Exception { + String text = new String(Files.readAllBytes( + Paths.get(".github", "workflows", "codeql-analysis.yml")), StandardCharsets.UTF_8); + assertTrue(text.contains("./gradlew clean --no-daemon")); + assertTrue(text.contains("gradle_args=(")); + assertTrue(text.contains("for attempt in 1 2 3; do")); + assertTrue(text.contains("./gradlew \"${gradle_args[@]}\"")); + assertTrue(text.contains("failed after $attempt attempts")); + assertFalse(text.contains("clean classes")); + } + + @Test + void neoGradleCleanRunsSeparatelyFromModelConsumers() throws Exception { + Properties properties = new Properties(); + try (InputStream input = Files.newInputStream(Paths.get("gradle.properties"))) { + properties.load(input); + } + assertFalse(properties.containsKey("neogradle.subsystems.decompiler.enabled")); + + String build = new String(Files.readAllBytes( + Paths.get("build.gradle")), StandardCharsets.UTF_8); + assertTrue(build.contains("task.name.startsWith('cacheVersionExecutable')")); + assertTrue(build.contains("!gradle.startParameter.offline || cachedOutputs.isEmpty()")); + + String text = new String(Files.readAllBytes( + Paths.get(".github", "workflows", "ci.yml")), StandardCharsets.UTF_8); + assertEquals(3, occurrences(text, "./gradlew clean ")); + assertTrue(text.contains("./gradlew classes verifyLegacyFixtures --no-daemon")); + assertTrue(text.contains("./gradlew classes verifyLegacyFixtures --offline --no-daemon")); + assertTrue(text.contains("./gradlew check build javadoc")); + assertFalse(text.contains("./gradlew clean check")); + assertFalse(text.contains("./gradlew clean classes")); + } + + 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 f07d330d..6c644f7f 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.GuiGraphicsExtractor; @@ -27,6 +31,17 @@ void worldCreationReflectionTargetsMatchTheUnobfuscatedClient() throws NoSuchFie .getDeclaredField(WorldCreationScreenHandler.TAB_NAVIGATION_BAR_FIELD).getType()); } + @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 extractRenderState("); + int backgroundAndWidgets = source.indexOf("super.extractRenderState(graphics", render); + int foreground = source.indexOf("renderForeground(graphics", render); + assertTrue(render >= 0 && backgroundAndWidgets > render && foreground > backgroundAndWidgets, + "The 26.2 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 14dc4115..7fab737c 100644 --- a/src/test/java/zone/moddev/mc/orespawn/worldgen/BiomeSurfaceFeatureOrderTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/BiomeSurfaceFeatureOrderTest.java @@ -14,6 +14,7 @@ class BiomeSurfaceFeatureOrderTest { @Test void surfacesRunBeforeStructuresAndVegetationWhileFlatBedrockStaysLast() { + StoneReplacer.registerConfiguredFeature(); BiomeSurfaceFeature.registerConfiguredFeature(); FlatBedrockFeature.registerConfiguredFeature(); BiomeGenerationSettingsBuilder generation = @@ -25,7 +26,9 @@ void surfacesRunBeforeStructuresAndVegetationWhileFlatBedrockStaysLast() { Holder bedrock = FlatBedrockFeature.placedFeature(); var local = generation.getFeatures(GenerationStep.Decoration.LOCAL_MODIFICATIONS); var top = generation.getFeatures(GenerationStep.Decoration.TOP_LAYER_MODIFICATION); - assertTrue(local.stream().anyMatch(feature -> feature.value() == surfaces.value())); + assertTrue(local.size() >= 2); + assertTrue(local.get(0).value() == StoneReplacer.placedFeature().value()); + assertTrue(local.get(1).value() == surfaces.value()); assertFalse(local.stream().anyMatch(feature -> feature.value() == bedrock.value())); assertTrue(top.stream().anyMatch(feature -> feature.value() == bedrock.value())); assertFalse(top.stream().anyMatch(feature -> feature.value() == surfaces.value())); diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/GeomeTransitionTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/GeomeTransitionTest.java index 05c283fe..cbc936a4 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.fromNamespaceAndPath("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.fromNamespaceAndPath("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 6f5c3680..68708546 100644 --- a/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyGeologyParityTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyGeologyParityTest.java @@ -45,38 +45,35 @@ void cyanoSamplerMatchesPublishedMineralogy540AndSealedVectors() throws Exceptio MessageDigest sealed = MessageDigest.getInstance("SHA-256"); String configuredPath = System.getProperty("orespawn.mineralogy5Oracle", ""); - Path oracle = configuredPath.trim().isEmpty() ? null : Paths.get(configuredPath); - PublishedMineralogy published = oracle != null && Files.isRegularFile(oracle) - ? PublishedMineralogy.open(oracle) : null; + assertTrue(!configuredPath.trim().isEmpty(), + "The direct published Mineralogy 5.4.0 oracle is mandatory"); + Path oracle = Paths.get(configuredPath); + assertTrue(Files.isRegularFile(oracle), "Configured Mineralogy oracle is missing: " + oracle); + PublishedMineralogy published = PublishedMineralogy.open(oracle); try { - if (published != null) published.configure(9, igneous, metamorphic, sedimentary); + published.configure(9, igneous, metamorphic, sedimentary); for (long seed : new long[] { 0L, -4965128775892001975L }) { Geology os4 = new Geology(seed, 128.0D, 37.25D, 9, false, states(igneous), states(metamorphic), states(sedimentary)); - PublishedSampler sampler = published == null ? null : published.newSampler(seed, 128.0D, 37.25D); + PublishedSampler sampler = published.newSampler(seed, 128.0D, 37.25D); for (int x : new int[] { -1025, -257, -1, 0, 1, 255, 1024 }) { for (int z : new int[] { -1025, -257, -1, 0, 1, 255, 1024 }) { for (int y = 0; y < 256; y += 7) { Block actual = os4.getStoneAt(x, y, z); update(sealed, seed, x, y, z, actual); - if (sampler != null) { - assertEquals(sampler.getStoneAt(x, y, z), actual, - "Published Mineralogy 5.4.0 mismatch at " - + seed + ":" + x + ":" + y + ":" + z); - } + assertEquals(sampler.getStoneAt(x, y, z), actual, + "Published Mineralogy 5.4.0 mismatch at " + + seed + ":" + x + ":" + y + ":" + z); } } } } } finally { - if (published != null) published.close(); + published.close(); } assertEquals(SEALED_VECTOR_SHA256, hex(sealed.digest()), "The sealed vector digest is generated from the exact published Mineralogy 5.4.0 sampler"); - if (oracle != null) { - assertTrue(Files.isRegularFile(oracle), "Configured Mineralogy oracle is missing: " + oracle); - } } private static void update(MessageDigest digest, long seed, int x, int y, int z, Block block) { diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGenerationTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGenerationTest.java index 68aa8f3b..bfd9a432 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.fromNamespaceAndPath("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.fromNamespaceAndPath("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.fromNamespaceAndPath("cakeworld", "candy_plains")))); + assertFalse(OreSpawnOreGeneration.acceptsBiome(Set.of(), resolved, sodaOcean)); + assertTrue(OreSpawnOreGeneration.acceptsBiome(Set.of(), resolved, ResourceKey.create( + Registries.BIOME, Identifier.fromNamespaceAndPath("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 92f210ed..c20b28a0 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,16 @@ 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 { @@ -53,4 +57,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..066a9a3b 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,12 @@ 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)); + assertEquals(true, WorldgenBenchmark.allowsFlatBenchmark(GameTestServer.class)); + assertEquals(false, WorldgenBenchmark.allowsFlatBenchmark(MinecraftServer.class)); + } }