diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7fc461d3..bf489061 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,24 +1,71 @@ -name: CI +name: OreSpawn 1.18.2 CI -on: [push, pull_request] -#on: -# push: -# branches: [ master-1.12 ] -# pull_request: -# # The branches below must be a subset of the branches above -# branches: [ master-1.12 ] -# types: [opened, synchronize, reopened] +on: + push: + branches: + - master-1.18 + - 'feature/**' + pull_request: + branches: + - master-1.18 + +permissions: + contents: read + +concurrency: + group: orespawn-1.18-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: 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 pinned Java 17 runtime and toolchain + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '17.0.1+12' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6 + + - name: Make the wrapper executable + run: chmod +x ./gradlew + + - name: Build, test, and audit release artifacts + run: >- + ./gradlew clean check build javadoc verifyReleaseArtifacts writeReleaseChecksums + verifyEclipseProductionClasspath --no-daemon --stacktrace + + - name: Upload audited release candidate + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: OreSpawn-1.18.2-${{ github.sha }} + if-no-files-found: error + retention-days: 30 + path: | + build/libs/OreSpawn-4.0.10.118021.jar + build/libs/OreSpawn-4.0.10.118021-sources.jar + build/libs/OreSpawn-4.0.10.118021-javadoc.jar + build/release/SHA256SUMS + CHANGELOG.txt + + - name: Upload diagnostics on failure + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - java-version: 8 - - run: chmod a+x gradlew - - run: ./gradlew --version --no-daemon - - run: ./gradlew setupCIWorkspace -S - - run: ./gradlew clean build -S + name: OreSpawn-1.18.2-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..f9ed9ff2 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -1,73 +1,49 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# -name: "CodeQL" - -on: [push, pull_request] -#on: -# push: -# branches: [ master-1.12 ] -# pull_request: -# # The branches below must be a subset of the branches above -# branches: [ master-1.12 ] -# types: [opened, synchronize, reopened] -# schedule: -# - cron: '43 7 * * 4' +name: CodeQL + +on: + push: + branches: + - master-1.18 + - 'feature/**' + pull_request: + branches: + - master-1.18 + 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 pinned Java 17 runtime and toolchain + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '17.0.1+12' + + - 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 + run: | + chmod +x ./gradlew + ./gradlew clean classes --no-daemon --stacktrace + + - 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..e37b1ad2 100644 --- a/.github/workflows/validate-gradle-build.yml +++ b/.github/workflows/validate-gradle-build.yml @@ -1,11 +1,23 @@ name: Validate Gradle Wrapper -on: [push, pull_request] +on: + push: + branches: + - master-1.18 + - 'feature/**' + pull_request: + branches: + - master-1.18 + +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 8ef90b58..9618fc90 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 dd1cf229..868f0318 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,13 +1,44 @@ +Version 4.0.10.118021 + +* Evaluate Stable Layers rock min_y and max_y bounds against actual world Y + instead of the vertically shifted formation coordinate. +* Preserve the shifted formation identity for layer, family, and rock choice + while preventing vanilla Stone fallback near dimension floors and ceilings. +* Apply the correction only while generating new chunks; existing chunks and + saved profiles remain unchanged. + +Version 4.0.9.118021 + +* Replace provider-declared natural terrain hosts during the existing geology + scan before structure and vegetation features can author matching blocks. +* Keep air, fluids, bedrock, and block entities protected even when their block + IDs are mistakenly declared as terrain hosts. +* Apply the correction only while generating new chunks; existing chunks and + saved profiles remain unchanged. + +Version 4.0.8.118021 + +* Preserve long host, tag, and biome-list values when OreSpawn editors load + and save an existing profile without user changes. +* Add reproducible ForgeGradle 7 builds, audited release artifacts, SHA-256 + checksums, Buildship launches, and guarded release automation. +* Export the complete bundled guide, including the shared version policy, to + the player-facing configuration folder. +* Forge 1.12.2's 4.0.7 packaged access-transformer repair is target-specific + and is not applicable to Forge 1.18.2. + Version 4.0.6.118021 * Adopt target-qualified four-component versions so Minecraft and loader compatibility can be identified from the mod version. * Preserve generated worlds made with Mineralogy 1.10, 1.12, or 5.x by reading their saved mod metadata and exact legacy configuration before creating the OreSpawn world profile. * Write human-readable, idempotent upgrade reports for legacy OreSpawn and Mineralogy imports while retaining source files and existing chunks unchanged. * Audit automated runtime logs and dynamic-fluid generation so logged worldgen failures cannot pass merely because the process exits normally. -* Qualify existing OreSpawn 4.0.4 global and per-world profiles without changing explicit Custom values or provider definitions. +* Port OreSpawn 4.0.6.118021 to Minecraft 1.18.2 and Forge 40.3.0 using Java 17. * Fix provider top and filler materials being generated one block below exposed ground. * Apply underwater materials from the corrected ground and ceiling materials to roof undersides. * Preserve trees, vegetation, structures and block entities by running surface replacement before late features. +* Preserve exact datapack-biome rules and stagger close Stable Layers geome boundaries. +* Recalibrate Edge Detail and the managed vanilla-ore defaults for Minecraft 1.18.2. * Existing chunks are not rewritten; the correction applies while generating new chunks. Version 4.0.5 @@ -15,7 +46,7 @@ Version 4.0.5 * Complete native translations for every shipped non-English locale * Add automatic fresh-and-reload validation for provider-owned custom biomes * Verify both public biome-registration helpers on Forge 40 -* Confirm Minecraft 1.18.2 ResourceLocation documentation and ForgeGradle workflow +* Confirm Minecraft 1.18.2 ResourceLocation documentation and ForgeGradle 7 workflow * Preserve public API major 1 and provider/global/world schemas 4/6/5 Version 3.3.1 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 155c1163..f2ee7be0 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,8 @@ +[![Discord](https://img.shields.io/badge/Discord-MMD-green.svg?style=flat&logo=Discord)](https://discord.moddev.zone) +[![CurseForge downloads](https://cf.way2muchnoise.eu/full_mmd-orespawn_downloads.svg)](https://www.curseforge.com/minecraft/mc-mods/mmd-orespawn) +[![Supported Minecraft versions](https://cf.way2muchnoise.eu/versions/Minecraft_mmd-orespawn_all.svg)](https://www.curseforge.com/minecraft/mc-mods/mmd-orespawn) +[![Build, test, and audit](https://github.com/MinecraftModDevelopmentMods/OreSpawn/actions/workflows/ci.yml/badge.svg?branch=master-1.18)](https://github.com/MinecraftModDevelopmentMods/OreSpawn/actions/workflows/ci.yml?query=branch%3Amaster-1.18) + # MMD OreSpawn OreSpawn 4 is a provider-driven world-generation engine for Minecraft 1.18.2. @@ -12,6 +17,10 @@ End" policy used by mods such as Base Metals. This is not the unrelated mod that adds mobs and dimensions under the same name. +This branch builds target-qualified version `4.0.10.118021`: the OreSpawn 4.0.10 +feature set for Minecraft 1.18.2 and Forge. See the +[versioning policy](docs/VERSIONS.md) for the encoding and release convention. + ## What Happens When It Is Installed? OreSpawn is deliberately passive on its own. It does not replace stone, remove @@ -86,11 +95,13 @@ exported to `config/orespawn-guide/` without overwriting existing files. ## Building -Use Java 17 from the repository root: +Run Gradle with Java 17 from the repository root. Install the exact Temurin +`17.0.1+12` toolchain used to compile production code and test fixtures for +Minecraft 1.18.2; the build rejects a different Java 17 toolchain: ```powershell -.\gradlew.bat clean build javadoc --no-daemon -.\gradlew.bat genEclipseRuns eclipse --no-daemon +.\gradlew.bat clean check build javadoc verifyReleaseArtifacts writeReleaseChecksums --no-daemon +.\gradlew.bat genEclipseRuns verifyEclipseProductionClasspath --no-daemon ``` `build` runs the standard `check` lifecycle. In addition to the JUnit suite, @@ -100,8 +111,13 @@ dimensions. It also proves later vegetation, structures, and block entities survive, then reopens and checks the exact saved world. The fixture is not included in OreSpawn's published jars. -Run both `genEclipseRuns` and `eclipse` after importing or refreshing this -ForgeGradle 6 project in Eclipse. +Import or refresh the project with Eclipse Buildship, then run +`genEclipseRuns` and `verifyEclipseProductionClasspath`. This branch uses +ForgeGradle 7.0.34, the Gradle 9.6.1 wrapper, Forge 40.3.0, official Minecraft +1.18.2 mappings, resource pack format 8, and data pack format 9. Ordinary Eclipse launches exclude tests +and fixtures. Published jars are deterministic, SRG-reobfuscated for the Forge +40 runtime, audited for their six access-transformer rules and contents, and +accompanied by SHA-256 checksums. Machine-specific `AGENTS.md` and `agent-notes/` files are intentionally ignored. Public developer and AI integration guidance lives in `docs/` and is included diff --git a/build.gradle b/build.gradle index 9e57827e..9ac28828 100644 --- a/build.gradle +++ b/build.gradle @@ -1,333 +1,358 @@ +import groovy.json.JsonSlurper +import groovy.xml.XmlSlurper +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.util.jar.Manifest +import java.util.zip.ZipFile +import org.apache.tools.ant.filters.FixCrLfFilter + plugins { + id 'java' id 'eclipse' id 'idea' id 'maven-publish' - id 'net.minecraftforge.gradle' version '[6.0,6.2)' + id 'net.minecraftforge.renamer' version '1.1.5' + id 'net.minecraftforge.accesstransformers' version '2.0.0' + id 'net.minecraftforge.gradle' version '7.0.34' +} + +def mavenizerCompatibilityJar = layout.projectDirectory.file( + 'ci-fixtures/tools/minecraft-mavenizer-0.5.21-orespawn-1.18.jar') +def mavenizerCompatibilitySha256 = + '28A6697BDF6D9500EC0AF9C1C949B9F3ED7DDFDA649CB5CAC4BEEA2E9567570A' + +// ForgeGradle 7.0.34's Mavenizer recompilation rejects a redundant accessor +// emitted by Forge 40's 1.18.2 decompiler. This checksum-sealed build-only +// variant removes that accessor; the implicit record accessor is identical. +// The project and every shipped class still use the pinned Java 17 toolchain. +fgtools.configure('mavenizer') { + classpath.from(mavenizerCompatibilityJar) + mainClass.set('net.minecraftforge.mcmaven.cli.Main') + javaLauncher.set(javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(25) + vendor = JvmVendorSpec.ADOPTIUM + }) } -version = mod_version -group = mod_group_id +group = project.mod_group +version = project.mod_version +base.archivesName = 'OreSpawn' -base { - archivesName = "OreSpawn-${minecraft_version}" +def versionParts = project.mod_version.toString().tokenize('.') +if (versionParts.size() != 4 || !versionParts.every { it ==~ /\d+/ }) { + throw new GradleException("mod_version must use Major.Minor.Bug.Target numeric form: ${project.mod_version}") } +def minecraftVersionParts = project.minecraft_version.toString().tokenize('.') +def minecraftPatch = minecraftVersionParts.size() == 3 ? minecraftVersionParts[2] : '0' +def expectedTargetVersion = "${minecraftVersionParts[0]}" + + "${minecraftVersionParts[1].padLeft(2, '0')}" + + "${minecraftPatch.padLeft(2, '0')}" + project.loader_code +if (versionParts[3] != expectedTargetVersion) { + throw new GradleException("mod_version target ${versionParts[3]} does not match " + + "Minecraft ${project.minecraft_version} ${project.loader_name} target ${expectedTargetVersion}") +} +ext.functional_version = versionParts[0..2].join('.') +ext.display_version = project.mod_version +ext.release_tag = project.mod_version +def expectedMavenGroup = 'zone.moddev.mc.orespawn' +def expectedMavenArtifact = 'OreSpawn' +def expectedMavenCoordinate = "${expectedMavenGroup}:${expectedMavenArtifact}:${project.version}" -// Mojang ships Java 17 to end users in 1.18+, so your mod should target Java 17. java { - toolchain.languageVersion = JavaLanguageVersion.of(17) + toolchain { + languageVersion = JavaLanguageVersion.of(17) + vendor = JvmVendorSpec.ADOPTIUM + } withSourcesJar() withJavadocJar() } -println "Java: ${System.getProperty 'java.version'}, JVM: ${System.getProperty 'java.vm.version'} (${System.getProperty 'java.vendor'}), Arch: ${System.getProperty 'os.arch'}" +tasks.withType(JavaCompile).configureEach { + javaCompiler = javaToolchains.compilerFor { + languageVersion = JavaLanguageVersion.of(17) + vendor = JvmVendorSpec.ADOPTIUM + } + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + options.encoding = 'UTF-8' + options.compilerArgs.addAll(['-Xmaxerrs', '1000']) +} +tasks.named('compileTestJava', JavaCompile) { options.compilerArgs.add('-proc:none') } +tasks.withType(Test).configureEach { + useJUnitPlatform() + workingDir = project.projectDir +} +tasks.withType(Javadoc).configureEach { + failOnError = false + options.encoding = 'UTF-8' + options.addStringOption('Xdoclint:none', '-quiet') + options.addBooleanOption('notimestamp', true) +} +tasks.withType(AbstractArchiveTask).configureEach { + preserveFileTimestamps = false + reproducibleFileOrder = true +} + +def archiveTextSuffixes = [ + '.cfg', '.css', '.html', '.info', '.java', '.js', '.json', '.lang', + '.mcmeta', '.md', '.properties', '.txt', '.xml' +] +def archiveTextPatterns = archiveTextSuffixes.collect { "**/*${it}".toString() } +archiveTextPatterns.addAll(['**/element-list', '**/package-list']) +def normalizeArchiveLineEndings = { details -> + details.filter(FixCrLfFilter, + eol: FixCrLfFilter.CrLf.newInstance('lf'), + eof: FixCrLfFilter.AddAsisRemove.newInstance('asis')) +} + minecraft { - // The mappings can be changed at any time and must be in the following format. - // Channel: Version: - // official MCVersion Official field/method names from Mojang mapping files - // parchment YYYY.MM.DD-MCVersion Open community-sourced parameter names and javadocs layered on top of official - // - // You must be aware of the Mojang license when using the 'official' or 'parchment' mappings. - // See more information here: https://github.com/MinecraftForge/MCPConfig/blob/master/Mojang.md - // - // Parchment is an unofficial project maintained by ParchmentMC, separate from MinecraftForge - // Additional setup is needed to use their mappings: https://github.com/ParchmentMC/Parchment/wiki/Getting-Started - // - // Use non-default mappings at your own risk. They may not always work. - // Simply re-run your setup task after changing the mappings to update your workspace. - mappings channel: mapping_channel, version: mapping_version - - // When true, this property will have all Eclipse/IntelliJ IDEA run configurations run the "prepareX" task for the given run configuration before launching the game. - // In most cases, it is not necessary to enable. - // enableEclipsePrepareRuns = true - // enableIdeaPrepareRuns = true - - // This property allows configuring Gradle's ProcessResources task(s) to run on IDE output locations before launching the game. - // It is REQUIRED to be set to true for this template to function. - // See https://docs.gradle.org/current/dsl/org.gradle.language.jvm.tasks.ProcessResources.html - copyIdeResources = true - - // When true, this property will add the folder name of all declared run configurations to generated IDE run configurations. - // The folder name can be set on a run configuration using the "folderName" property. - // By default, the folder name of a run configuration is the name of the Gradle project containing it. - // generateRunFolders = true - - // This property enables access transformers for use in development. - // They will be applied to the Minecraft artifact. - // The access transformer file can be anywhere in the project. - // However, it must be at "META-INF/accesstransformer.cfg" in the final mod jar to be loaded by Forge. - // This default location is a best practice to automatically put the file in the right place in the final jar. - // See https://docs.minecraftforge.net/en/latest/advanced/accesstransformers/ for more information. - accessTransformer = file('src/main/resources/META-INF/accesstransformer.cfg') - - // Default run configurations. - // These can be tweaked, removed, or duplicated as needed. + mappings channel: project.mapping_channel, version: project.mapping_version + accessTransformer = 'META-INF/accesstransformer.cfg' runs { - // applies to all the run configs below configureEach { - workingDirectory project.file('run') - - // Recommended logging data for a userdev environment - // The markers can be added/remove as needed separated by commas. - // "SCAN": For mods scan. - // "REGISTRIES": For firing of registry events. - // "REGISTRYDUMP": For getting the contents of all registries. - property 'forge.logging.markers', 'REGISTRIES' - - // Recommended logging level for the console - // You can set various levels here. - // Please read: https://stackoverflow.com/questions/2031163/when-to-use-the-different-log-levels - property 'forge.logging.console.level', 'debug' - - // Comma-separated list of namespaces to load gametests from. Empty = all namespaces. - property 'forge.enabledGameTestNamespaces', mod_id - - mods { - orespawn { - source sourceSets.main - } - } + workingDir.convention layout.projectDirectory.dir('run') + jvmArgs '--add-opens', 'java.base/java.lang.invoke=ALL-UNNAMED' + systemProperty 'forge.logging.markers', 'REGISTRIES' + systemProperty 'forge.logging.console.level', 'debug' + mods { orespawn { source sourceSets.main } } } - - client { - // Comma-separated list of namespaces to load gametests from. Empty = all namespaces. - property 'forge.enabledGameTestNamespaces', mod_id - } - - server { - property 'forge.enabledGameTestNamespaces', mod_id - args '--nogui' - if (providers.gradleProperty('orespawnBenchmarkMode').isPresent()) { - property 'orespawn.worldgenBenchmarkMode', providers.gradleProperty('orespawnBenchmarkMode').get() - property 'orespawn.worldgenBenchmarkRadius', - providers.gradleProperty('orespawnBenchmarkRadius').getOrElse('8') - property 'orespawn.worldgenBenchmarkRepetitions', - providers.gradleProperty('orespawnBenchmarkRepetitions').getOrElse('5') - property 'orespawn.worldgenBenchmarkVanillaOres', - providers.gradleProperty('orespawnBenchmarkVanillaOres').getOrElse('false') - property 'orespawn.worldgenBenchmarkOreAudit', - providers.gradleProperty('orespawnBenchmarkOreAudit').getOrElse('false') - if (providers.gradleProperty('orespawnBenchmarkBlockAudit').isPresent()) { - property 'orespawn.worldgenBenchmarkBlockAudit', - providers.gradleProperty('orespawnBenchmarkBlockAudit').get() - } - property 'orespawn.worldgenBenchmarkCenterX', - providers.gradleProperty('orespawnBenchmarkCenterX').getOrElse('1024') - property 'orespawn.worldgenBenchmarkCenterZ', - providers.gradleProperty('orespawnBenchmarkCenterZ').getOrElse('1024') - property 'orespawn.worldgenBenchmarkCenterStep', - providers.gradleProperty('orespawnBenchmarkCenterStep').getOrElse('64') - property 'orespawn.worldgenBenchmarkDimension', - providers.gradleProperty('orespawnBenchmarkDimension').getOrElse('overworld') - if (providers.gradleProperty('orespawnBenchmarkBiomeType').isPresent()) { - property 'orespawn.worldgenBenchmarkBiomeType', - providers.gradleProperty('orespawnBenchmarkBiomeType').get() - } - property 'orespawn.worldgenBenchmarkStopServer', 'true' - if (providers.gradleProperty('worldgenJfrFile').isPresent()) { - jvmArg "-XX:StartFlightRecording=filename=${providers.gradleProperty('worldgenJfrFile').get()},settings=profile,dumponexit=true" - } - } + register('client') + register('server') { args '--nogui' } + register('data') { + workingDir.convention layout.projectDirectory.dir('run-data') + args '--mod', project.mod_id, '--all', '--output', file('src/generated/resources/'), + '--existing', file('src/main/resources/') } - - // This run config launches GameTestServer and runs all registered gametests, then exits. - // By default, the server will crash when no gametests are provided. - // The gametest system is also enabled by default for other run configs under the /test command. - gameTestServer { - property 'forge.enabledGameTestNamespaces', mod_id - if (providers.gradleProperty('orespawnBenchmarkMode').isPresent()) { - property 'orespawn.worldgenBenchmarkMode', providers.gradleProperty('orespawnBenchmarkMode').get() - property 'orespawn.worldgenBenchmarkRadius', - providers.gradleProperty('orespawnBenchmarkRadius').getOrElse('4') - property 'orespawn.worldgenBenchmarkRepetitions', - providers.gradleProperty('orespawnBenchmarkRepetitions').getOrElse('3') - property 'orespawn.worldgenBenchmarkVanillaOres', - providers.gradleProperty('orespawnBenchmarkVanillaOres').getOrElse('false') - property 'orespawn.worldgenBenchmarkOreAudit', - providers.gradleProperty('orespawnBenchmarkOreAudit').getOrElse('false') - if (providers.gradleProperty('orespawnBenchmarkBlockAudit').isPresent()) { - property 'orespawn.worldgenBenchmarkBlockAudit', - providers.gradleProperty('orespawnBenchmarkBlockAudit').get() - } - property 'orespawn.worldgenBenchmarkCenterX', - providers.gradleProperty('orespawnBenchmarkCenterX').getOrElse('256') - property 'orespawn.worldgenBenchmarkCenterZ', - providers.gradleProperty('orespawnBenchmarkCenterZ').getOrElse('256') - property 'orespawn.worldgenBenchmarkCenterStep', - providers.gradleProperty('orespawnBenchmarkCenterStep').getOrElse('64') - property 'orespawn.worldgenBenchmarkDimension', - providers.gradleProperty('orespawnBenchmarkDimension').getOrElse('overworld') - if (providers.gradleProperty('orespawnBenchmarkBiomeType').isPresent()) { - property 'orespawn.worldgenBenchmarkBiomeType', - providers.gradleProperty('orespawnBenchmarkBiomeType').get() - } - property 'orespawn.worldgenBenchmarkStopServer', 'true' - if (providers.gradleProperty('worldgenJfrFile').isPresent()) { - jvmArg "-XX:StartFlightRecording=filename=${providers.gradleProperty('worldgenJfrFile').get()},settings=profile,dumponexit=true" - } - } + register('surfaceIntegrationFresh') { + mainClass = 'cpw.mods.bootstraplauncher.BootstrapLauncher' + workingDir.convention layout.buildDirectory.dir('surface-integration-run') + jvmArgs '-p', '{modules}', '--add-modules', 'ALL-MODULE-PATH', + '--add-opens', 'java.base/java.lang.invoke=cpw.mods.securejarhandler', + '--add-opens', 'java.base/java.util.jar=cpw.mods.securejarhandler', + '--add-exports', 'java.base/sun.security.util=cpw.mods.securejarhandler', + '--add-exports', 'jdk.naming.dns/com.sun.jndi.dns=java.naming' + args '--launchTarget', 'forgeserveruserdev', '--gameDir', '.', + '--fml.forgeVersion', forge_version, '--fml.mcVersion', minecraft_version, + '--fml.forgeGroup', 'net.minecraftforge', '--fml.mcpVersion', mcp_version, + '--nogui' + systemProperty 'legacyClassPath.file', '{minecraft_classpath_file}' + systemProperty 'ignoreList', + 'bootstraplauncher,securejarhandler,asm-commons,asm-util,asm-analysis,asm-tree,asm,JarJarFileSystems,client-extra,fmlcore,javafmllanguage,lowcodelanguage,mclanguage,forge-' + systemProperty 'mergeModules', + 'jna-5.12.1.jar,jna-platform-5.12.1.jar,java-objc-bridge-1.0.0.jar' + systemProperty 'surfaceprobe.integrationPhase', 'fresh' + environment 'MCP_MAPPINGS', '{mcp_mappings}' } - - data { - // example of overriding the workingDirectory set in configureEach above - workingDirectory project.file('run-data') - - // Specify the modid for data generation, where to output the resulting resource, and where to look for existing resources. - args '--mod', mod_id, '--all', '--output', file('src/generated/resources/'), '--existing', file('src/main/resources/') + register('surfaceIntegrationReload') { + mainClass = 'cpw.mods.bootstraplauncher.BootstrapLauncher' + workingDir.convention layout.buildDirectory.dir('surface-integration-run') + jvmArgs '-p', '{modules}', '--add-modules', 'ALL-MODULE-PATH', + '--add-opens', 'java.base/java.lang.invoke=cpw.mods.securejarhandler', + '--add-opens', 'java.base/java.util.jar=cpw.mods.securejarhandler', + '--add-exports', 'java.base/sun.security.util=cpw.mods.securejarhandler', + '--add-exports', 'jdk.naming.dns/com.sun.jndi.dns=java.naming' + args '--launchTarget', 'forgeserveruserdev', '--gameDir', '.', + '--fml.forgeVersion', forge_version, '--fml.mcVersion', minecraft_version, + '--fml.forgeGroup', 'net.minecraftforge', '--fml.mcpVersion', mcp_version, + '--nogui' + systemProperty 'legacyClassPath.file', '{minecraft_classpath_file}' + systemProperty 'ignoreList', + 'bootstraplauncher,securejarhandler,asm-commons,asm-util,asm-analysis,asm-tree,asm,JarJarFileSystems,client-extra,fmlcore,javafmllanguage,lowcodelanguage,mclanguage,forge-' + systemProperty 'mergeModules', + 'jna-5.12.1.jar,jna-platform-5.12.1.jar,java-objc-bridge-1.0.0.jar' + systemProperty 'surfaceprobe.integrationPhase', 'reload' + environment 'MCP_MAPPINGS', '{mcp_mappings}' } - - ['Fresh', 'Reload'].each { String phase -> - create("surfaceIntegration${phase}") { - parent runs.server - workingDirectory layout.buildDirectory.dir('surface-integration-run').get().asFile - property 'forge.logging.console.level', 'info' - property 'surfaceprobe.integrationPhase', phase.toLowerCase(Locale.ROOT) - args '--nogui' - } - } } } -// Include resources generated by data generators. -sourceSets.main.resources { srcDir 'src/generated/resources' } - -repositories { - // Put repositories for dependencies here - // ForgeGradle automatically adds the Forge maven and Maven Central for you - - // If you have mod jar dependencies in ./libs, you can declare them as a repository like so: - // flatDir { - // dir 'libs' - // } +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) } -dependencies { - // Specify the version of Minecraft to use. - // Any artifact can be supplied so long as it has a "userdev" classifier artifact and is a compatible patcher artifact. - // The "userdev" classifier will be requested and setup by ForgeGradle. - // If the group id is "net.minecraft" and the artifact id is one of ["client", "server", "joined"], - // then special handling is done to allow a setup of a vanilla dependency without the use of an external repository. - minecraft "net.minecraftforge:forge:${minecraft_version}-${forge_version}" - - testImplementation platform('org.junit:junit-bom:5.10.2') - testImplementation 'org.junit.jupiter:junit-jupiter' - - // Real mod deobf dependency examples - these get remapped to your current mappings - // compileOnly fg.deobf("mezz.jei:jei-${mc_version}:${jei_version}:api") // Adds JEI API as a compile dependency - // runtimeOnly fg.deobf("mezz.jei:jei-${mc_version}:${jei_version}") // Adds the full JEI mod as a runtime dependency - // implementation fg.deobf("com.tterrag.registrate:Registrate:MC${mc_version}-${registrate_version}") // Adds registrate as a dependency - - // Example mod dependency using a mod jar from ./libs with a flat dir repository - // This maps to ./libs/coolmod-${mc_version}-${coolmod_version}.jar - // The group id is ignored when searching -- in this case, it is "blank" - // implementation fg.deobf("blank:coolmod-${mc_version}:${coolmod_version}") - - // For more info: - // http://www.gradle.org/docs/current/userguide/artifact_dependencies_tutorial.html - // http://www.gradle.org/docs/current/userguide/dependency_management.html +sourceSets.main.resources { + // Eclipse rebuilds bin/main from declared resource source folders. Keeping + // the generated documentation in the source set prevents a Buildship + // refresh from silently removing the guide copied by processResources. + srcDir 'src/generated/resources' } -// Example for how to get properties into the manifest for reading at runtime. -tasks.named('jar', Jar).configure { - manifest { - attributes([ - 'Specification-Title' : 'OreSpawn', - 'Specification-Vendor' : 'SkyBlade1978', - 'Specification-Version' : '1', // We are version 1 of ourselves - 'Implementation-Title' : project.name, - 'Implementation-Version' : project.jar.archiveVersion, - 'Implementation-Vendor' : 'SkyBlade1978', - 'Implementation-Timestamp': new Date().format("yyyy-MM-dd'T'HH:mm:ssZ"), - 'OreSpawn-API-Version' : '1' - ]) - } +def forgeRunModClassesDirectory = file("${buildDir}/forge-run-mod-classes/main") +def prepareForgeRunModClasses = tasks.register('prepareForgeRunModClasses', Sync) { + dependsOn tasks.named('classes') + from sourceSets.main.output.classesDirs + from sourceSets.main.output.resourcesDir + into forgeRunModClassesDirectory +} - // This is the preferred method to reobfuscate your jar file - finalizedBy 'reobfJar' +def configureForge40Run = { JavaExec runTask, String launchTarget -> + runTask.dependsOn prepareForgeRunModClasses + // Forge 40 resolves the complete exploded mod from one merged output. + runTask.environment 'MOD_CLASSES', "${mod_id}%%${forgeRunModClassesDirectory}" +} +tasks.withType(JavaExec).configureEach { JavaExec runTask -> + Map targets = [ + runClient: 'fmluserdevclient', + runServer: 'fmluserdevserver', + runData: 'fmluserdevdata', + runSurfaceIntegrationFresh: 'fmluserdevserver', + runSurfaceIntegrationReload: 'fmluserdevserver' + ] + String launchTarget = targets.get(runTask.name) + if (launchTarget != null) configureForge40Run(runTask, launchTarget) } -tasks.named('processResources', ProcessResources).configure { - from('docs/AGENTS.md') { - into '' - rename { 'AGENTS.md' } - } - from('docs') { - into 'META-INF/orespawn/docs' +repositories { + minecraft.mavenizer(it) + maven fg.forgeMaven + maven fg.minecraftLibsMaven + exclusiveContent { + forRepository { maven { url = 'https://repo.spongepowered.org/repository/maven-public' } } + filter { includeGroupAndSubgroups('org.spongepowered') } } + mavenCentral() + maven { url = 'https://libraries.minecraft.net/' } } -// However if you are in a multi-project build, dev time needs unobfed jar files, so you can delay the obfuscation until publishing by doing: -// tasks.named('publish').configure { -// dependsOn 'reobfJar' -// } +def fixtureRoot = file("${rootDir}/ci-fixtures") +def mineralogy5OracleJar = new File(fixtureRoot, + 'artifacts/Mineralogy-1.18.2-5.4.0.jar') +def mineralogy5OracleSha256 = + 'CCA84E9270585478B08F54BA091AD56AB2CB390386C650ED78B2673DC57403EB' -publishing { - publications { - register('mavenJava', MavenPublication) { - artifact jar - artifact sourcesJar - artifact javadocJar +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}") } } - repositories { - maven { - url "file://${project.projectDir}/mcmodsrepo" +} + +tasks.register('verifyMavenizerCompatibilityFixture') { + group = 'verification' + description = 'Verifies the sealed Forge 1.18.2 Mavenizer compatibility fixture.' + inputs.file mavenizerCompatibilityJar + doLast { + File fixture = mavenizerCompatibilityJar.asFile + if (!fixture.isFile()) { + throw new GradleException("Missing Mavenizer compatibility fixture: ${fixture}") + } + MessageDigest digest = MessageDigest.getInstance('SHA-256') + fixture.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 != mavenizerCompatibilitySha256) { + throw new GradleException("Mavenizer compatibility fixture checksum mismatch: ${actual}") } } } -tasks.withType(JavaCompile).configureEach { - options.encoding = 'UTF-8' // Use the UTF-8 charset for Java compilation +dependencies { + implementation minecraft.dependency( + "net.minecraftforge:forge:${project.minecraft_version}-${project.forge_version}") + testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.2' + testImplementation 'org.junit.jupiter:junit-jupiter-params:5.10.2' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.10.2' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.10.2' } -tasks.named('javadoc', Javadoc).configure { - options.encoding = 'UTF-8' - options.addStringOption('Xdoclint:none', '-quiet') +tasks.named('compileTestJava', JavaCompile) { dependsOn tasks.named('verifyLegacyFixtures') } +tasks.named('compileJava', JavaCompile) { dependsOn tasks.named('verifyMavenizerCompatibilityFixture') } +tasks.named('test', Test) { + dependsOn tasks.named('verifyLegacyFixtures') + systemProperty 'orespawn.mineralogy5Oracle', mineralogy5OracleJar.absolutePath } -tasks.named('test', Test).configure { - useJUnitPlatform() - // Unit tests inspect target files relative to the checkout, but they do - // not need Forge's rolling runtime files. A console-only test logger keeps - // them from contending with Eclipse/client logs in this working directory. - systemProperty 'log4j.configurationFile', file('src/test/resources/log4j2-test.xml').absolutePath - // Loaded only through an isolated URLClassLoader by the parity test. This - // is deliberately not a Gradle dependency and cannot leak into Eclipse or - // a published OreSpawn jar. - File mineralogy5Oracle = file('../../MinecraftMineralogy 118/MinecraftMineralogy/build/libs/Mineralogy-1.18.2-5.4.0.jar') - if (mineralogy5Oracle.isFile()) { - systemProperty 'orespawn.mineralogy5Oracle', mineralogy5Oracle.absolutePath - } -} - -// Several registry-focused tests initialize the real global config singleton. -// Keep that target-native coverage without creating or changing a developer's -// checkout config as a side effect of `test` or `build`. -def unitTestWorldgenConfig = file('config/orespawn-worldgen.json') -def unitTestWorldgenConfigWasPresent = false -byte[] unitTestWorldgenConfigBytes = null -tasks.named('test', Test).configure { - doFirst { - unitTestWorldgenConfigWasPresent = unitTestWorldgenConfig.isFile() - unitTestWorldgenConfigBytes = unitTestWorldgenConfigWasPresent - ? unitTestWorldgenConfig.bytes : null +tasks.register('verifyLegacyOracleIsolation') { + group = 'verification' + description = 'Keeps the sealed Mineralogy oracle test-visible but production-invisible.' + dependsOn tasks.named('verifyLegacyFixtures') + doLast { + configurations.findAll { it.canBeResolved }.each { configuration -> + if (configuration.files.any { it.canonicalFile == mineralogy5OracleJar.canonicalFile }) { + throw new GradleException("Mineralogy oracle leaked into ${configuration.name}") + } + } } } -def preserveDeveloperWorldgenConfig = tasks.register('preserveDeveloperWorldgenConfig') { + +def java17Launcher = javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(17) + vendor = JvmVendorSpec.ADOPTIUM +} +tasks.register('verifyJava17Toolchain') { + group = 'verification' doLast { - if (unitTestWorldgenConfigWasPresent) { - byte[] after = unitTestWorldgenConfig.isFile() ? unitTestWorldgenConfig.bytes : null - if (after == null || !java.util.Arrays.equals(unitTestWorldgenConfigBytes, after)) { - unitTestWorldgenConfig.parentFile.mkdirs() - unitTestWorldgenConfig.bytes = unitTestWorldgenConfigBytes - throw new GradleException('Unit tests changed config/orespawn-worldgen.json; the original was restored') - } - } else if (unitTestWorldgenConfig.isFile()) { - delete unitTestWorldgenConfig + def metadata = java17Launcher.get().metadata + if (project.java_toolchain_version != '17.0.1+12' + || metadata.vendor.toString() != 'Eclipse Temurin' + || metadata.javaRuntimeVersion != '17.0.1+12') { + throw new GradleException("Expected Temurin ${project.java_toolchain_version}, found " + + "${metadata.vendor} ${metadata.javaRuntimeVersion} at ${metadata.installationPath}") } } } -tasks.named('test') { - finalizedBy preserveDeveloperWorldgenConfig +tasks.named('check') { + dependsOn tasks.named('verifyLegacyOracleIsolation') + dependsOn tasks.named('verifyJava17Toolchain') +} + +tasks.named('processResources', ProcessResources) { + dependsOn prepareBundledDocumentation + filteringCharset = 'UTF-8' + inputs.property('version', project.version) + inputs.property('minecraft_version', project.minecraft_version) + inputs.property('forge_version_range', project.forge_version_range) + inputs.property('loader_version_range', project.loader_version_range) + inputs.property('minecraft_version_range', project.minecraft_version_range) + filesMatching('META-INF/mods.toml') { + expand([ + version: project.version, + minecraft_version: project.minecraft_version, + forge_version_range: project.forge_version_range, + loader_version_range: project.loader_version_range, + minecraft_version_range: project.minecraft_version_range + ]) + } + from('docs/AGENTS.md') { + into '' + rename { 'AGENTS.md' } + } + filesMatching(archiveTextPatterns, normalizeArchiveLineEndings) +} + +def prepareEclipseResources = tasks.register('prepareEclipseResources') { + group = 'ide' + dependsOn tasks.named('processResources') + doLast { + project.copy { + from(layout.buildDirectory.dir('resources/main')) + into(layout.projectDirectory.dir('bin/main')) + } + } } // A Forge process is not green merely because it returns exit code zero. The @@ -336,6 +361,8 @@ def acceptedForge40LogNoise = [ ~/FML appears to be missing any signature data/, ~/Found multiple arguments for option fml\.mcVersion/, ~/Found multiple arguments for option fml\.forgeVersion/, + ~/\/ERROR\] \[net\.minecraft\.client\.Minecraft\/\]: Failed to verify authentication$/, + ~/\/(?:ERROR|FATAL)\] \[net\.minecraftforge\.fml\.network\.simple\.IndexedMessageCodec\/SIMPLENET\]: Received empty payload on channel fml:handshake$/, ~/\/FATAL\] \[net\.minecraftforge\.common\.ForgeConfig\/CORE\]: Forge config just got changed on the file system!$/, ~/\/FATAL\] \[net\.minecraftforge\.fml\.packs\.ModFileResourcePack\/\]: Failed to clean up tempdir / ] @@ -396,6 +423,8 @@ task runtimeLogScannerTest { File logs = new File(probe, 'logs'); logs.mkdirs() new File(logs, 'latest.log').setText( '[main/ERROR] [FML]: FML appears to be missing any signature data\n' + + '[Render thread/ERROR] [net.minecraft.client.Minecraft/]: Failed to verify authentication\n' + + '[Client thread/ERROR] [net.minecraftforge.fml.network.simple.IndexedMessageCodec/SIMPLENET]: Received empty payload on channel fml:handshake\n' + '[Server thread/INFO] [FML]: Done\n', 'UTF-8') assertRuntimeLogsClean(probe, 'scanner-accepted-noise-probe', [] as Set) new File(logs, 'latest.log').setText( @@ -450,339 +479,1100 @@ check.dependsOn verifyMineralogyOracleIsolation } } -def surfaceIntegrationClasses = layout.buildDirectory.dir('surface-integration-fixture/classes') -def compileSurfaceIntegrationTestMod = tasks.register('compileSurfaceIntegrationTestMod', JavaCompile) { - dependsOn tasks.named('classes') - source fileTree('src/biomeIntegrationTest/java') - classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) - destinationDirectory.set(surfaceIntegrationClasses) - javaCompiler.set(javaToolchains.compilerFor { - languageVersion = JavaLanguageVersion.of(17) - }) - options.release = 17 - options.encoding = 'UTF-8' +def clientIntegrationClasses = file("${buildDir}/client-integration-fixture/classes") +tasks.register('compileClientIntegrationTestMod', JavaCompile) { + dependsOn tasks.named('classes') + source fileTree('src/clientIntegrationTest/java') + classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) + destinationDirectory = clientIntegrationClasses + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + options.encoding = 'UTF-8' +} +tasks.register('clientIntegrationTestModJar', Jar) { + dependsOn tasks.named('compileClientIntegrationTestMod') + archiveFileName = 'clientprobe.jar' + destinationDirectory = file("${buildDir}/client-integration-fixture") + from clientIntegrationClasses + from 'src/clientIntegrationTest/resources' +} +def packagedClientProbeJar = renamer.classes(tasks.named('clientIntegrationTestModJar', Jar)) { + map.from minecraft.dependency.toSrgFile + output = layout.buildDirectory.file('client-integration-fixture/clientprobe-reobf.jar') } -def surfaceIntegrationTestModJar = tasks.register('surfaceIntegrationTestModJar', Jar) { - dependsOn compileSurfaceIntegrationTestMod - archiveFileName = 'surfaceprobe.jar' - destinationDirectory = layout.buildDirectory.dir('surface-integration-fixture') - from surfaceIntegrationClasses - from 'src/biomeIntegrationTest/resources' +def surfaceIntegrationClasses = file("${buildDir}/surface-integration-fixture/classes") +task compileSurfaceIntegrationTestMod(type: JavaCompile, dependsOn: classes) { + source fileTree('src/biomeIntegrationTest/java') + classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) + destinationDirectory = surfaceIntegrationClasses + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + options.encoding = 'UTF-8' } -def surfaceIntegrationRunDirectory = layout.buildDirectory.dir('surface-integration-run') -def prepareSurfaceIntegrationTest = tasks.register('prepareSurfaceIntegrationTest') { - dependsOn surfaceIntegrationTestModJar - doLast { - File runDirectory = surfaceIntegrationRunDirectory.get().asFile - delete runDirectory - runDirectory.mkdirs() - copy { - from surfaceIntegrationTestModJar.flatMap { it.archiveFile } - into surfaceIntegrationRunDirectory.map { it.dir('mods') } - } - new File(runDirectory, 'server.properties').setText('''\ +task surfaceIntegrationTestModJar(type: Jar, dependsOn: compileSurfaceIntegrationTestMod) { + archiveFileName = 'surfaceprobe.jar' + destinationDirectory = file("${buildDir}/surface-integration-fixture") + from surfaceIntegrationClasses + from 'src/biomeIntegrationTest/resources' +} + +def surfaceIntegrationRunDirectory = file("${buildDir}/surface-integration-run") +task prepareSurfaceIntegrationTest(dependsOn: surfaceIntegrationTestModJar) { + doLast { + delete surfaceIntegrationRunDirectory + surfaceIntegrationRunDirectory.mkdirs() + copy { + from surfaceIntegrationTestModJar.archiveFile + into new File(surfaceIntegrationRunDirectory, 'mods') + } + new File(surfaceIntegrationRunDirectory, 'server.properties').setText('''\ level-name=surface-integration-world -level-seed=0 -level-type=minecraft:normal +level-seed=zsjpxah +level-type=default online-mode=false +server-port=0 allow-nether=true generate-structures=false spawn-protection=0 max-tick-time=-1 ''', 'UTF-8') - new File(runDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') - } -} - -tasks.configureEach { - if (name == 'runSurfaceIntegrationFresh') { - dependsOn prepareSurfaceIntegrationTest - } else if (name == 'runSurfaceIntegrationReload') { - dependsOn 'runSurfaceIntegrationFresh' - } - if (name == 'runSurfaceIntegrationFresh' || name == 'runSurfaceIntegrationReload') { - doFirst { - ext.oreSpawnCrashSnapshot = runtimeCrashSnapshot(workingDir) - } - doLast { - assertRuntimeLogsClean(workingDir, "Forge 40 ${name}", - ext.oreSpawnCrashSnapshot as Set) - } - } -} -def surfaceIntegrationTest = tasks.register('surfaceIntegrationTest') { - group = 'verification' - description = 'Verifies provider surfaces and dynamic-biome geology across fresh and reloaded normal terrain.' - dependsOn 'runSurfaceIntegrationReload' - doLast { - File marker = surfaceIntegrationRunDirectory.get().file( - 'surface-integration-world/surfaceprobe-integration.properties').asFile - if (!marker.isFile()) { - throw new GradleException("Surface integration completion marker is missing: ${marker}") - } - Properties result = new Properties() - marker.withInputStream { result.load(it) } - if (result.getProperty('reload_verified') != 'true') { - throw new GradleException("Surface integration reload was not verified: ${marker}") + new File(surfaceIntegrationRunDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') + } +} + +def surfaceIntegrationFreshProcess = tasks.register('surfaceIntegrationFreshProcess') { + group = 'verification' + dependsOn 'runSurfaceIntegrationFresh' +} +tasks.matching { it.name == 'runSurfaceIntegrationFresh' }.all { + dependsOn prepareSurfaceIntegrationTest +} +tasks.matching { it.name == 'runSurfaceIntegrationReload' }.all { + mustRunAfter surfaceIntegrationFreshProcess +} +surfaceIntegrationFreshProcess.configure { + doLast { + File marker = new File(surfaceIntegrationRunDirectory, + 'surface-integration-world/surfaceprobe-integration.properties') + if (!marker.isFile()) { + throw new GradleException("Fresh surface integration completion marker is missing: ${marker}") + } + assertRuntimeLogsClean(surfaceIntegrationRunDirectory, + 'surface integration fresh phase', [] as Set) + } +} +def surfaceIntegrationReloadProcess = tasks.register('surfaceIntegrationReloadProcess') { + group = 'verification' + dependsOn surfaceIntegrationFreshProcess + dependsOn 'runSurfaceIntegrationReload' +} +surfaceIntegrationReloadProcess.configure { + doLast { + assertRuntimeLogsClean(surfaceIntegrationRunDirectory, + 'surface integration reload phase', [] as Set) + } +} + +task surfaceIntegrationTest(dependsOn: surfaceIntegrationReloadProcess) { + group = 'verification' + doLast { + File marker = new File(surfaceIntegrationRunDirectory, + 'surface-integration-world/surfaceprobe-integration.properties') + Properties result = new Properties() + marker.withInputStream { result.load(it) } + if (result.getProperty('reload_verified') != 'true') { + throw new GradleException("Surface integration reload was not verified: ${marker}") + } + logger.lifecycle('Provider surfaces and exact-biome geology verified: {} dimensions, {} columns each, fresh + reload', + result.getProperty('dimensions'), result.getProperty('columns_per_dimension')) + } +} + +check.dependsOn surfaceIntegrationTest + +task syncForge40EclipseLaunches(dependsOn: compileSurfaceIntegrationTestMod) { + group = 'ide' + doLast { + String mainOutput = new File(projectDir, 'bin/main').absolutePath + String ordinaryModClasses = "${mod_id}%%${mainOutput}" + String fixtureOutput = surfaceIntegrationClasses.absolutePath + String fixtureResources = new File(projectDir, 'src/biomeIntegrationTest/resources').absolutePath + String fixtureModClasses = "${mod_id}%%${mainOutput}${File.pathSeparator}" + + "surfaceprobe%%${fixtureOutput}${File.pathSeparator}" + + "surfaceprobe%%${fixtureResources}" + ['Client', 'Server', 'Data'].each { String runName -> + File launch = file("run${runName}.launch") + if (!launch.isFile()) { + throw new GradleException("Missing generated Eclipse launch: ${launch}") + } + String text = launch.getText('UTF-8') + if (text.contains('')) { + text = text.replace( + '', + '\r\n' + + " \r\n" + + '') + } + text = text.replaceFirst( + //, + java.util.regex.Matcher.quoteReplacement( + "")) + if (!text.contains('org.eclipse.jdt.launching.ATTR_EXCLUDE_TEST_CODE')) { + text = text.replace('', + ' \r\n' + + '') + } + launch.setText(text, 'UTF-8') } - logger.lifecycle('Provider surfaces and dynamic-biome geology verified: {} dimensions, {} columns each, fresh + reload', - result.getProperty('dimensions'), result.getProperty('columns_per_dimension')) - } + ['Fresh', 'Reload'].each { String phase -> + File launch = file("runSurfaceIntegration${phase}.launch") + File serverLaunch = file('runServer.launch') + if (!serverLaunch.isFile()) { + throw new GradleException("Missing generated Eclipse launch: ${serverLaunch}") + } + String text = serverLaunch.getText('UTF-8') + text = text.replaceFirst( + //, + java.util.regex.Matcher.quoteReplacement( + "")) + text = text.replaceFirst( + //, + { String match, String arguments -> + "" + }) + text = text.replaceFirst( + //, + java.util.regex.Matcher.quoteReplacement( + "")) + launch.setText(text, 'UTF-8') + } + } } -tasks.named('check') { - dependsOn surfaceIntegrationTest +task verifyForge40EclipseLaunchIsolation { + group = 'verification' + doLast { + ['runClient.launch', 'runServer.launch', 'runData.launch'].each { String launchName -> + File launch = file(launchName) + String launchText = launch.getText('UTF-8') + if (launchText.contains('Mineralogy-') || launchText.contains('biomeIntegrationTest') || + !launchText.contains('org.eclipse.jdt.launching.ATTR_EXCLUDE_TEST_CODE')) { + throw new GradleException("Ordinary Eclipse launch is not isolated from test oracles: ${launch}") + } + } + } } -// Keep every Eclipse launch input on Buildship's physical Gradle cache. Command-line -// verification may deliberately use a separate cache, which must not leak into Eclipse. -def syncEclipseRunClasspaths = tasks.register('syncEclipseRunClasspaths') { - group = 'IDE' - description = 'Aligns Eclipse project, Buildship, and Forge run classpaths with Eclipse\'s Gradle home.' +syncForge40EclipseLaunches.finalizedBy verifyForge40EclipseLaunchIsolation +tasks.matching { it.name == 'genEclipseRuns' }.all { + finalizedBy syncForge40EclipseLaunches +} + +def packagedSurfaceProbeJar = renamer.classes(tasks.named('surfaceIntegrationTestModJar', Jar)) { + map.from minecraft.dependency.toSrgFile + output = layout.buildDirectory.file('surface-integration-fixture/surfaceprobe-reobf.jar') +} + +tasks.named('jar', Jar) { + archiveClassifier = 'deobf' + destinationDirectory = layout.buildDirectory.dir('libs-dev') + manifest { + attributes([ + 'Specification-Title' : 'OreSpawn', + 'Specification-Vendor' : 'SkyBlade1978', + 'Specification-Version' : '1', + 'Implementation-Title' : base.archivesName.get(), + 'Implementation-Version' : project.version, + 'Implementation-Vendor' : 'SkyBlade1978', + 'OreSpawn-API-Version' : '1', + 'FMLAT' : 'accesstransformer.cfg', + 'Maven-Artifact' : expectedMavenCoordinate, + 'Built-On-Java' : '17', + 'Built-On' : "${project.minecraft_version}-${project.forge_version}" + ]) + } +} + +def releaseJar = renamer.classes(tasks.named('jar', Jar)) { + map.from minecraft.dependency.toSrgFile + archiveClassifier = null + accessTransformers = true + output = layout.buildDirectory.file( + "libs/${base.archivesName.get()}-${project.version}.jar") +} + +tasks.named('sourcesJar', Jar) { + dependsOn prepareBundledDocumentation + filteringCharset = 'UTF-8' + includeEmptyDirs = false + filesMatching(archiveTextPatterns, normalizeArchiveLineEndings) + manifest { + attributes([ + 'Implementation-Title' : 'OreSpawn-sources', + 'Implementation-Version': project.version + ]) + } +} +tasks.named('javadocJar', Jar) { + filteringCharset = 'UTF-8' + filesMatching(archiveTextPatterns, normalizeArchiveLineEndings) + manifest { + attributes([ + 'Implementation-Title' : 'OreSpawn-javadoc', + 'Implementation-Version': project.version + ]) + } +} +['apiElements', 'runtimeElements'].each { configurationName -> + configurations.named(configurationName) { artifacts.clear() } + artifacts { add(configurationName, releaseJar) } +} +tasks.named('assemble') { + dependsOn releaseJar + dependsOn tasks.named('sourcesJar') + dependsOn tasks.named('javadocJar') +} + +def expectedReleaseFiles = providers.provider { + String prefix = "${base.archivesName.get()}-${project.version}" + ["${prefix}.jar", "${prefix}-sources.jar", "${prefix}-javadoc.jar"] +} +def preparedReleaseDir = providers.gradleProperty('preparedReleaseDir') + +tasks.register('verifyReleaseConfiguration') { + group = 'verification' doLast { - File classpathDir = layout.buildDirectory.dir('classpath').get().asFile - Set classpathFiles = fileTree(classpathDir) { - include '*_minecraftClasspath.txt' - }.files - if (classpathFiles.isEmpty()) { - throw new GradleException('Missing Forge run classpaths. Run genEclipseRuns before launching from Eclipse.') - } - - File buildshipPreferences = file('.settings/org.eclipse.buildship.core.prefs') - Properties buildship = new Properties() - if (buildshipPreferences.isFile()) { - buildshipPreferences.withInputStream { buildship.load(it) } - } - String configuredGradleHome = buildship.getProperty('connection.gradle.user.home', '').trim() - File eclipseProjectClasspath = file('.classpath') - String eclipseGradleHomeOverride = (findProperty('eclipseGradleUserHome') - ?: System.getenv('ECLIPSE_GRADLE_USER_HOME') ?: '').toString().trim() - File eclipseGradleHome = eclipseGradleHomeOverride - ? file(eclipseGradleHomeOverride).canonicalFile - : new File(System.getProperty('user.home'), '.gradle').canonicalFile - if (!configuredGradleHome || file(configuredGradleHome).canonicalFile != eclipseGradleHome) { - buildship.setProperty('eclipse.preferences.version', '1') - buildship.setProperty('connection.gradle.user.home', eclipseGradleHome.absolutePath) - buildshipPreferences.parentFile.mkdirs() - buildshipPreferences.withOutputStream { - buildship.store(it, - 'Generated by syncEclipseRunClasspaths; keep Eclipse launch inputs on the Buildship cache.') - } - } - File eclipseCache = new File(eclipseGradleHome, 'caches').canonicalFile - File eclipseClasspathDir = file('.settings/orespawn-run-classpaths') - eclipseClasspathDir.mkdirs() - - Closure mapCacheReferences = { String value -> - value.replaceAll(/(?i)[A-Z]:[\\\/]\S*?[\\\/]caches(?=[\\\/])/) { String cacheRoot -> - cacheRoot.contains('/') - ? eclipseCache.absolutePath.replace('\\', '/') - : eclipseCache.absolutePath - } - } - - Map stableClasspaths = [:] - classpathFiles.each { File classpathFile -> - List original = classpathFile.readLines('UTF-8') - List synced = original.collect { String entry -> - String mapped = mapCacheReferences(entry) - String portable = mapped.replace('\\', '/') - int marker = portable.indexOf('/caches/') - if (marker < 0) { - return mapped - } + if (project.mod_version != '4.0.10.118021' + || project.mod_group != expectedMavenGroup + || project.minecraft_version != '1.18.2' + || project.forge_version != '40.3.0' + || project.mapping_channel != 'official' + || project.mapping_version != '1.18.2') { + throw new GradleException('Unexpected OreSpawn 1.18.2 release identity') + } + if (project.loader_name != 'forge' || project.loader_code != '1' + || project.java_version != '17' || project.gradle_java_version != '17' + || project.java_toolchain_version != '17.0.1+12') { + throw new GradleException('Unexpected dispatcher or Java target metadata') + } + List expectedPublicArtifacts = [ + 'OreSpawn-4.0.10.118021.jar', + 'OreSpawn-4.0.10.118021-sources.jar', + 'OreSpawn-4.0.10.118021-javadoc.jar' + ] + if (base.archivesName.get() != expectedMavenArtifact + || expectedReleaseFiles.get().collect { it.toString() } != expectedPublicArtifacts) { + throw new GradleException('Public artifacts must use the version-only OreSpawn filename contract') + } + String ciWorkflow = file('.github/workflows/ci.yml').getText('UTF-8') + expectedPublicArtifacts.each { artifactName -> + if (!ciWorkflow.contains("build/libs/${artifactName}")) { + throw new GradleException("CI does not upload expected public artifact ${artifactName}") + } + } + [ + 'src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java', + 'src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java', + 'README.md', 'CHANGELOG.txt' + ].each { path -> + if (!file(path).getText('UTF-8').contains('4.0.10.118021')) { + throw new GradleException("Release identity missing from ${path}") + } + } + if (!file('docs/API.md').getText('UTF-8').contains('versionRange="[4.0.6,5.0.0)"')) { + throw new GradleException('Consumer compatibility floor must remain [4.0.6,5.0.0)') + } + if (!file('src/main/java/zone/moddev/mc/orespawn/api/OreSpawnApi.java') + .getText('UTF-8').contains('API_VERSION = 1')) { + throw new GradleException('OreSpawn API major must remain 1') + } + if (!file('src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeConfig.java') + .getText('UTF-8').contains('SCHEMA_VERSION = 6') + || !file('src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java') + .getText('UTF-8').contains('SCHEMA_VERSION = 5')) { + throw new GradleException('Global/world schemas must remain 6/5') + } + def schema = new JsonSlurper().parse(file('docs/schemas/orespawn-provider.schema.json')) + if (!(schema.properties.schema_version.enum as List).contains(4)) { + throw new GradleException('Provider schema must remain version 4') + } + } +} - File target = file(mapped).canonicalFile - if (!target.isFile()) { - throw new GradleException("Missing Eclipse run dependency in configured Gradle cache: ${target}") +def trackedDocumentationDirectory = file('docs') +def documentationFiles = { + fileTree(trackedDocumentationDirectory).files.findAll { it.isFile() }.collect { + trackedDocumentationDirectory.canonicalFile.toPath().relativize(it.canonicalFile.toPath()) + .toString().replace('\\', '/') }.sort() +} +def assertDocumentationTree = { File root, List expected, String label -> + List actual = root.isDirectory() ? fileTree(root).files.findAll { it.isFile() } + .collect { root.canonicalFile.toPath().relativize(it.canonicalFile.toPath()) + .toString().replace('\\', '/') } + .sort() : [] + if (actual != expected) { + throw new GradleException("${label} documentation set ${actual} does not match tracked ${expected}") + } + expected.each { relative -> + byte[] tracked = new File(trackedDocumentationDirectory, relative).bytes + byte[] candidate = new File(root, relative).bytes + if (!java.util.Arrays.equals(tracked, candidate)) { + throw new GradleException("${label}/${relative} differs from tracked documentation") + } + } +} + +def verifyDocumentationParity = tasks.register('verifyDocumentationParity') { + group = 'verification' + dependsOn prepareBundledDocumentation + dependsOn tasks.named('processResources') + dependsOn prepareEclipseResources + dependsOn releaseJar + doLast { + List expected = documentationFiles() + if (expected.size() != 21 || !expected.contains('VERSIONS.md')) { + throw new GradleException("Expected exactly 21 tracked guide files including VERSIONS.md, found ${expected}") + } + assertDocumentationTree(bundledDocumentationDirectory.asFile, + expected, 'generated resources') + assertDocumentationTree(new File(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().output.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}") } - return target.absolutePath } - - File stableClasspath = new File(eclipseClasspathDir, classpathFile.name) - stableClasspath.setText(synced.join(System.lineSeparator()) + System.lineSeparator(), 'UTF-8') - stableClasspaths.put(classpathFile.canonicalFile, stableClasspath.canonicalFile) } + } +} - int changedProjectClasspath = 0 - int changedResourceExclusions = 0 - if (eclipseProjectClasspath.isFile()) { - String original = eclipseProjectClasspath.getText('UTF-8') - def parsedClasspath = new XmlParser(false, false).parseText(original) - parsedClasspath.classpathentry.each { entry -> - ['path', 'sourcepath'].each { String attribute -> - String value = entry.attribute(attribute) - if (!value) { - return - } - String mapped = mapCacheReferences(value) - if (mapped == value) { - return +tasks.register('verifyReleaseArtifacts') { + group = 'verification' + dependsOn tasks.named('verifyReleaseConfiguration') + dependsOn verifyDocumentationParity + dependsOn tasks.named('assemble') + doLast { + File libs = layout.buildDirectory.dir('libs').get().asFile + List jars = (libs.listFiles() ?: [] as File[]) + .findAll { it.name.endsWith('.jar') }.sort { it.name } + // Provider interpolation yields GString values; normalize them before + // comparing with real filesystem String names. + List expected = expectedReleaseFiles.get() + .collect { it.toString() }.sort() + if (jars.collect { it.name } != expected) { + throw new GradleException("Expected exactly ${expected}, found ${jars*.name}") + } + jars.each { candidate -> + if (candidate.length() == 0L) throw new GradleException("Empty artifact ${candidate}") + new ZipFile(candidate).withCloseable { zip -> + zip.entries().findAll { entry -> + !entry.isDirectory() && (archiveTextSuffixes.any { entry.name.endsWith(it) } + || entry.name.endsWith('/element-list') + || entry.name.endsWith('/package-list')) + }.each { entry -> + boolean cr = zip.getInputStream(entry).withCloseable { + input -> input.bytes.any { value -> value == 13 } } - File mappedTarget = file(mapped).canonicalFile - if (mappedTarget.exists()) { - entry.attributes().put(attribute, mappedTarget.absolutePath) - changedProjectClasspath = 1 - } else { - // Test-only libraries may have been resolved by command-line Gradle - // but not yet by Buildship. They are not part of the Forge launch - // module path, so retaining the existing valid path is safe. - File existingTarget = file(value).canonicalFile - if (!existingTarget.exists()) { - throw new GradleException("Missing Eclipse project dependency in both Gradle caches: ${mappedTarget}") - } + if (cr) throw new GradleException( + "${candidate.name}!/${entry.name} is not LF-normalized") + } + [ + 'src/test/', 'src/biomeIntegrationTest/', 'src/clientIntegrationTest/', + 'agent-notes/', 'surfaceprobe', 'clientprobe', 'ci-fixtures/', + 'org/junit/', 'org/mockito/', 'net/bytebuddy/', + 'Mineralogy-1.18.2-5.4.0.jar' + ].each { forbidden -> + if (zip.entries().any { it.name.contains(forbidden) }) { + throw new GradleException( + "${candidate.name} contains forbidden ${forbidden}") } } } - parsedClasspath.classpathentry.findAll { entry -> - entry.attribute('kind') == 'src' && entry.attribute('path') == 'src/main/resources' - }.each { entry -> - List exclusions = (entry.attribute('excluding') ?: '') - .split(/\|/) - .findAll { !it.isEmpty() } - ['META-INF/mods.toml', 'pack.mcmeta'].each { String metadataFile -> - if (exclusions.remove(metadataFile)) { - changedResourceExclusions = 1 - } + } + + File mainJar = new File(libs, expectedReleaseFiles.get()[0]) + new ZipFile(mainJar).withCloseable { zip -> + List names = zip.entries().collect { it.name } + [ + 'META-INF/mods.toml', + 'META-INF/accesstransformer.cfg', + 'zone/moddev/mc/orespawn/api/OreSpawnApi.class', + 'META-INF/orespawn/docs/VERSIONS.md', + 'META-INF/orespawn/docs/schemas/orespawn-provider.schema.json', + 'AGENTS.md' + ].each { required -> + if (!names.contains(required)) { + throw new GradleException("Release jar is missing ${required}") } - if (exclusions.isEmpty()) { - entry.attributes().remove('excluding') - } else { - entry.attributes().put('excluding', exclusions.join('|')) + } + String metadata = zip.getInputStream(zip.getEntry('META-INF/mods.toml')) + .getText(StandardCharsets.UTF_8.name()) + if (!metadata.contains('modId="orespawn"') + || !metadata.contains("version=\"${project.version}\"") + || !metadata.contains('versionRange="[1.18.2,1.19)"')) { + throw new GradleException('Packaged Forge metadata is incorrect') + } + String transformer = zip.getInputStream( + zip.getEntry('META-INF/accesstransformer.cfg')) + .getText(StandardCharsets.UTF_8.name()) + List actualRules = transformer.readLines() + .collect { it.replaceFirst(/\s*#.*/, '').trim() } + .findAll { !it.isEmpty() } + List expectedRules = [ + 'public-f net.minecraft.world.level.chunk.ChunkGenerator f_62137_', + 'public-f net.minecraft.world.level.chunk.ChunkGenerator f_62138_', + 'public-f net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator f_188607_', + 'public-f net.minecraft.world.level.biome.Biome f_47438_', + 'public net.minecraft.world.level.biome.Biome f_47437_', + 'public-f net.minecraft.world.level.levelgen.feature.configurations.SpringConfiguration f_68128_' + ] + if (actualRules != expectedRules) { + throw new GradleException("Unexpected packaged SRG access transformer: ${actualRules}") + } + def manifestEntry = zip.getEntry('META-INF/MANIFEST.MF') + def manifest = manifestEntry == null ? null : + new Manifest(zip.getInputStream(manifestEntry)).mainAttributes + if (manifest == null + || manifest.getValue('Implementation-Version') != project.mod_version + || manifest.getValue('OreSpawn-API-Version') != '1' + || manifest.getValue('FMLAT') != 'accesstransformer.cfg' + || manifest.getValue('Maven-Artifact') != expectedMavenCoordinate + || manifest.getValue('Implementation-Timestamp') != null) { + throw new GradleException('Release manifest is incorrect or volatile') + } + zip.entries().findAll { it.name.endsWith('.class') }.each { entry -> + byte[] header = new byte[8] + zip.getInputStream(entry).withCloseable { input -> + if (input.read(header) != 8) throw new GradleException("Cannot inspect ${entry.name}") + } + int major = ((header[6] & 0xff) << 8) | (header[7] & 0xff) + if (major != 61) { + throw new GradleException("${entry.name} uses class major ${major}, expected 61") } } - if (changedProjectClasspath || changedResourceExclusions) { - eclipseProjectClasspath.setText(groovy.xml.XmlUtil.serialize(parsedClasspath), 'UTF-8') + } + 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') + } + } + } +} - parsedClasspath.classpathentry.each { entry -> - ['path', 'sourcepath'].each { String attribute -> - String value = entry.attribute(attribute) - if (value && value.replace('\\', '/').contains('/caches/')) { - File target = file(value).canonicalFile - if (!target.exists()) { - throw new GradleException("Missing Eclipse project dependency in configured Gradle cache: ${target}") - } - } +tasks.register('writeReleaseChecksums') { + group = 'verification' + dependsOn tasks.named('verifyReleaseArtifacts') + def outputFile = layout.buildDirectory.file('release/SHA256SUMS') + outputs.file(outputFile) + doLast { + File output = outputFile.get().asFile + output.parentFile.mkdirs() + File libs = layout.buildDirectory.dir('libs').get().asFile + String contents = expectedReleaseFiles.get().sort().collect { name -> + MessageDigest digest = MessageDigest.getInstance('SHA-256') + new File(libs, name).withInputStream { input -> + byte[] buffer = new byte[8192] + for (int read = input.read(buffer); read >= 0; read = input.read(buffer)) { + if (read > 0) digest.update(buffer, 0, read) } } + "${digest.digest().encodeHex().toString().toUpperCase()} ${name}" + }.join('\n') + '\n' + output.setText(contents, 'UTF-8') + } +} + +tasks.register('verifyPreparedReleaseArtifacts') { + group = 'verification' + doLast { + if (!preparedReleaseDir.isPresent()) { + throw new GradleException('preparedReleaseDir is required') + } + File prepared = file(preparedReleaseDir.get()) + // Provider interpolation yields GString values; normalize them before + // comparing with real filesystem String names. + List expected = expectedReleaseFiles.get() + .collect { it.toString() }.sort() + List jars = (prepared.listFiles() ?: [] as File[]) + .findAll { it.name.endsWith('.jar') }.sort { it.name } + List actualNames = jars.collect { it.name.toString() }.sort() + if (actualNames != expected) { + throw new GradleException( + "Prepared release jars ${actualNames} do not match ${expected}") + } + if (jars.any { it.length() == 0L }) { + throw new GradleException('Prepared release contains an empty jar') + } + File checksums = new File(prepared, 'SHA256SUMS') + if (!checksums.isFile() || !new File(prepared, 'CHANGELOG.txt').isFile()) { + throw new GradleException('Prepared release is missing checksums or changelog') + } + List actual = jars.collect { candidate -> + MessageDigest digest = MessageDigest.getInstance('SHA-256') + candidate.withInputStream { input -> + byte[] buffer = new byte[8192] + for (int read = input.read(buffer); read >= 0; read = input.read(buffer)) { + if (read > 0) digest.update(buffer, 0, read) + } + } + "${digest.digest().encodeHex().toString().toUpperCase()} ${candidate.name}" + }.sort() + if (actual != checksums.readLines('UTF-8').findAll { !it.trim().isEmpty() }.sort()) { + throw new GradleException('Prepared release checksums do not match') } + } +} - File eclipseLaunchDir = file('.eclipse/configurations') - int changedPrepareLaunches = 0 - if (eclipseLaunchDir.isDirectory()) { - boolean windows = System.getProperty('os.name', '').toLowerCase().contains('windows') - File gradleWrapper = file(windows ? 'gradlew.bat' : 'gradlew').canonicalFile - String launcher = windows - ? (System.getenv('ComSpec') ?: new File(System.getenv('SystemRoot') ?: 'C:\\Windows', - 'System32\\cmd.exe').absolutePath) - : '/bin/sh' - String arguments = windows - ? "/d /s /c \"\\\"${gradleWrapper.absolutePath}\\\" copyEclipseResources --console plain\"" - : "-c \"'${gradleWrapper.absolutePath.replace("'", "'\\''")}' copyEclipseResources --console plain\"" - - fileTree(eclipseLaunchDir) { - include '*prepareRun*.launch' - }.files.each { File launchFile -> - StringWriter output = new StringWriter() - def xml = new groovy.xml.MarkupBuilder(output) - xml.mkp.xmlDeclaration(version: '1.0', encoding: 'UTF-8', standalone: 'no') - xml.launchConfiguration(type: 'org.eclipse.ui.externaltools.ProgramLaunchConfigurationType') { - booleanAttribute(key: 'org.eclipse.debug.ui.ATTR_LAUNCH_IN_BACKGROUND', value: 'true') - booleanAttribute(key: 'org.eclipse.ui.externaltools.ATTR_CAPTURE_OUTPUT', value: 'true') - stringAttribute(key: 'org.eclipse.ui.externaltools.ATTR_LOCATION', value: launcher) - booleanAttribute(key: 'org.eclipse.ui.externaltools.ATTR_SHOW_CONSOLE', value: 'true') - stringAttribute(key: 'org.eclipse.ui.externaltools.ATTR_TOOL_ARGUMENTS', value: arguments) - stringAttribute(key: 'org.eclipse.ui.externaltools.ATTR_WORKING_DIRECTORY', - value: projectDir.canonicalPath) - mapAttribute(key: 'org.eclipse.debug.core.environmentVariables') { - mapEntry(key: 'GRADLE_USER_HOME', value: eclipseGradleHome.absolutePath) - mapEntry(key: 'JAVA_HOME', value: System.getProperty('java.home')) +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 { + artifact(releaseJar) + artifact(tasks.named('sourcesJar')) + artifact(tasks.named('javadocJar')) + } + 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' } } - String replacement = output.toString() + System.lineSeparator() - if (launchFile.getText('UTF-8') != replacement) { - launchFile.setText(replacement, 'UTF-8') - changedPrepareLaunches++ - } } } + } + repositories { + maven { + name = 'release' + url = uri(mavenUploadUrl.get()) + credentials { + username = mavenUploadUsername.orNull ?: '' + password = mavenUploadPassword.orNull ?: '' + } + } + } +} +tasks.register('verifyMavenCoordinates') { + group = 'verification' + description = 'Verifies the generated POM uses OreSpawn\'s mod-specific Maven namespace.' + dependsOn tasks.named('generatePomFileForMavenJavaPublication') + doLast { + File pomFile = layout.buildDirectory.file( + 'publications/mavenJava/pom-default.xml').get().asFile + if (!pomFile.isFile()) { + throw new GradleException("Generated Maven POM does not exist: ${pomFile}") + } + 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') +} - int changedLaunches = 0 - int changedTestExclusions = 0 - if (eclipseLaunchDir.isDirectory()) { - File eclipseClasses = file('bin/main').canonicalFile - String modClasses = "${mod_id}%%${eclipseClasses.absolutePath}" - fileTree(eclipseLaunchDir) { - include '*Slim.launch' - }.files.each { File launchFile -> - String original = launchFile.getText('UTF-8') - String synced = mapCacheReferences(original) - stableClasspaths.each { File generated, File stable -> - synced = synced - .replace(generated.absolutePath, stable.absolutePath) - .replace(generated.absolutePath.replace('\\', '/'), stable.absolutePath.replace('\\', '/')) - } - synced = synced.replaceFirst( - //) { - "" +eclipse { + classpath { + downloadSources = true + downloadJavadoc = true + file.whenMerged { classpath -> + classpath.entries.removeAll { entry -> + entry instanceof org.gradle.plugins.ide.eclipse.model.SourceFolder + && ['src/main/resources', 'src/generated/resources'].contains(entry.path) + } + if (!classpath.entries.any { entry -> entry.path == 'build/resources/main' }) { + classpath.entries.add(new org.gradle.plugins.ide.eclipse.model.SourceFolder( + 'build/resources/main', 'bin/main')) + } + } + } + synchronizationTasks 'isolateEclipseProductionRuns' +} +idea { + module { + downloadSources = true + downloadJavadoc = true + } +} +tasks.register('configureEclipseBuildship') { + group = 'ide' + doLast { + File preferencesFile = file('.settings/org.eclipse.buildship.core.prefs') + Properties preferences = new Properties() + [ + 'eclipse.preferences.version' : '1', + 'connection.gradle.distribution': 'GRADLE_DISTRIBUTION(WRAPPER)', + 'connection.gradle.user.home' : gradle.gradleUserHomeDir.canonicalPath, + 'connection.project.dir' : '', + 'gradle.user.home' : gradle.gradleUserHomeDir.canonicalPath, + 'override.workspace.settings' : 'true' + ].each { key, value -> preferences.setProperty(key, value) } + preferencesFile.parentFile.mkdirs() + preferencesFile.withOutputStream { + preferences.store(it, 'Generated by OreSpawn Buildship configuration.') + } + } +} +tasks.register('isolateEclipseProductionRuns') { + group = 'ide' + dependsOn tasks.named('genEclipseRuns') + dependsOn syncForge40EclipseLaunches + dependsOn tasks.named('configureEclipseBuildship') + dependsOn prepareEclipseResources + doLast { + [ + 'OreSpawn_Client.launch': 'GradleStart', + 'OreSpawn_Server.launch': 'GradleStartServer' + ].each { String name, String mainClass -> + File launch = file(name) + if (launch.isFile() && launch.getText('UTF-8').contains(mainClass) + && !launch.delete()) { + throw new GradleException("Could not remove obsolete launch ${name}") + } + } + fileTree(project.projectDir) { include 'run*.launch' }.files.each { launch -> + String contents = launch.getText('UTF-8') + contents = contents.replace( + 'key="MC_VERSION" value="${MC_VERSION}"', + "key=\"MC_VERSION\" value=\"${minecraft_version}\"") + launch.setText(contents.replace('\r\n', '\n'), 'UTF-8') + } + } +} +tasks.register('verifyEclipseProductionClasspath') { + group = 'verification' + dependsOn tasks.named('eclipseClasspath') + dependsOn tasks.named('isolateEclipseProductionRuns') + dependsOn tasks.named('verifyLegacyOracleIsolation') + doLast { + File prefs = file('.settings/org.eclipse.buildship.core.prefs') + if (!prefs.isFile()) throw new GradleException('Missing Buildship preferences') + Properties buildshipPreferences = new Properties() + prefs.withInputStream { buildshipPreferences.load(it) } + String expectedGradleHome = gradle.gradleUserHomeDir.canonicalPath + if (buildshipPreferences.getProperty('connection.gradle.user.home') != expectedGradleHome + || buildshipPreferences.getProperty('gradle.user.home') != expectedGradleHome + || buildshipPreferences.getProperty('override.workspace.settings') != 'true') { + throw new GradleException( + "Eclipse Buildship must use the validated Gradle home ${expectedGradleHome}") + } + Set legacyLwjglArtifacts = configurations.compileClasspath.resolvedConfiguration + .resolvedArtifacts + .findAll { it.moduleVersion.id.group == 'org.lwjgl.lwjgl' } + .collect { "${it.moduleVersion.id.group}:${it.name}:${it.moduleVersion.id.version}" } + .toSet() + if (!legacyLwjglArtifacts.isEmpty()) { + throw new GradleException( + "Forge 1.18 Eclipse classpath contains legacy LWJGL 2 artifacts: ${legacyLwjglArtifacts}") + } + File eclipseClasspath = file('.classpath') + if (!eclipseClasspath.isFile()) { + throw new GradleException('Eclipse .classpath was not generated') + } + String eclipseClasspathText = eclipseClasspath.getText('UTF-8') + if (!eclipseClasspathText.contains('path="build/resources/main"') + || eclipseClasspathText.contains('path="src/main/resources"') + || eclipseClasspathText.contains('path="src/generated/resources"')) { + throw new GradleException( + 'Eclipse must consume only Gradle-processed production resources') + } + [ + 'META-INF/mods.toml', + 'META-INF/orespawn/docs/README.md', + 'META-INF/orespawn/docs/VERSIONS.md' + ].each { relative -> + if (!new File('bin/main', relative).isFile()) { + throw new GradleException("Eclipse output is missing ${relative}") + } + } + File processedModMetadata = new File(sourceSets.main.output.resourcesDir, + 'META-INF/mods.toml') + File eclipseModMetadata = file('bin/main/META-INF/mods.toml') + if (!java.util.Arrays.equals(processedModMetadata.bytes, eclipseModMetadata.bytes) + || eclipseModMetadata.getText('UTF-8').contains('${version}') + || !eclipseModMetadata.getText('UTF-8').contains( + "version=\"${project.version}\"")) { + throw new GradleException( + 'Eclipse output contains unexpanded or stale Forge mod metadata') + } + List forbidden = [ + 'src/test', 'bin/test', 'build/classes/java/test', + 'biomeIntegrationTest', 'clientIntegrationTest', + 'surfaceprobe', 'clientprobe', 'junit-', 'opentest4j-', + 'Mineralogy-1.18.2-5.4.0.jar', 'C:\\Users\\John' + ] + String mainOutput = new File(projectDir, 'bin/main').absolutePath + String expectedModClasses = "${mod_id}%%${mainOutput}" + ['runClient.launch', 'runServer.launch', 'runData.launch'].each { name -> + File launch = file(name) + if (!launch.isFile()) throw new GradleException("Missing ${name}") + String contents = launch.getText('UTF-8') + List leaked = forbidden.findAll { contents.contains(it) } + if (!leaked.isEmpty()) { + throw new GradleException("${name} exposes test/local content: ${leaked}") + } + if (!contents.contains('ATTR_EXCLUDE_TEST_CODE') + || !contents.contains('PROJECT_ATTR" value="OreSpawn"')) { + throw new GradleException("${name} is not a production-only OreSpawn launch") + } + if (!contents.contains("MOD_CLASSES\" value=\"${expectedModClasses}\"")) { + throw new GradleException("${name} lacks Forge 40 merged output discovery") + } + } + } +} + +tasks.register('verifyCommandPortability') { + group = 'verification' + description = 'Rejects shell-specific launch wrappers and hard-coded classpath separators.' + doLast { + String gradleSource = file('build.gradle').getText('UTF-8') + List commandSources = [file('build.gradle')] + commandSources.addAll(fileTree('.github/workflows') { include '*.yml', '*.yaml' }.files) + commandSources.each { File source -> + String text = source.getText('UTF-8') + if (text =~ /(?i)(?:commandLine|executable|run:)\s*[^\n]*(?:cmd(?:\.exe)?\s+\/c|powershell(?:\.exe)?\s+-command|(?:bash|sh)\s+-c)/) { + throw new GradleException("Shell-specific command wrapper in ${source}") + } + } + if (!gradleSource.contains('commandLine(([javaExecutable.absolutePath] + arguments) as List)') + || !gradleSource.contains('join(File.pathSeparator)') + || !gradleSource.contains('${File.pathSeparator}')) { + throw new GradleException('Runtime commands and exploded-mod paths must use native argument/path APIs') + } + } +} + +tasks.named('check') { dependsOn tasks.named('verifyCommandPortability') } +tasks.named('check') { dependsOn tasks.named('verifyMavenCoordinates') } + +def packagedForgeServerRuntime = providers.gradleProperty('packagedForgeServerRuntime') +def packagedForgeClientRuntime = providers.gradleProperty('packagedForgeClientRuntime') +def packagedClientJavaExecutable = providers.gradleProperty('packagedClientJavaExecutable') + +def requireRuntimeDirectory = { Provider configuredPath, String propertyName -> + if (!configuredPath.isPresent()) { + throw new GradleException("Pass -P${propertyName}=") + } + File runtime = file(configuredPath.get()) + if (!runtime.isDirectory()) { + throw new GradleException("${propertyName} does not name a directory: ${runtime}") + } + runtime +} + +def packagedSurfaceRunDirectory = file("${buildDir}/packaged-surface-run") +tasks.register('preparePackagedSurfaceIntegration') { + dependsOn releaseJar + dependsOn packagedSurfaceProbeJar + doLast { + delete packagedSurfaceRunDirectory + packagedSurfaceRunDirectory.mkdirs() + copy { + from releaseJar + from packagedSurfaceProbeJar + into new File(packagedSurfaceRunDirectory, 'mods') + } + new File(packagedSurfaceRunDirectory, 'server.properties').setText('''\ +level-name=surface-integration-world +level-seed=zsjpxah +level-type=default +online-mode=false +server-port=0 +allow-nether=true +generate-structures=false +spawn-protection=0 +max-tick-time=-1 +''', 'UTF-8') + new File(packagedSurfaceRunDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') + } +} +def packagedSurfaceFresh = tasks.register('packagedSurfaceFresh', Exec) { + group = 'verification' + dependsOn tasks.named('preparePackagedSurfaceIntegration') + doFirst { + File runtime = requireRuntimeDirectory(packagedForgeServerRuntime, + 'packagedForgeServerRuntime') + File libraries = new File(runtime, 'libraries') + File forgeDirectory = new File(libraries, + 'net/minecraftforge/forge/1.18.2-40.3.0') + File launcher = new File(forgeDirectory, 'forge-1.18.2-40.3.0-server.jar') + File sourceArguments = new File(forgeDirectory, + System.getProperty('os.name').toLowerCase(Locale.ROOT).contains('windows') + ? 'win_args.txt' : 'unix_args.txt') + [launcher, sourceArguments, libraries].each { + if (!it.exists()) throw new GradleException("Incomplete official server runtime: ${it}") + } + File absoluteArguments = new File(packagedSurfaceRunDirectory, 'forge-server-args.txt') + String libraryPath = libraries.canonicalPath.replace('\\', '/') + '/' + absoluteArguments.setText(sourceArguments.getText('UTF-8') + .replace('libraries/', libraryPath) + .replace('-DlibraryDirectory=libraries', + "-DlibraryDirectory=${libraries.canonicalPath}"), 'UTF-8') + workingDir packagedSurfaceRunDirectory + commandLine java17Launcher.get().executablePath.asFile.absolutePath, + '-Xms512m', '-Xmx2g', '-Dsurfaceprobe.integrationPhase=fresh', + "@${absoluteArguments.absolutePath}", 'nogui' + } + doLast { + File marker = new File(packagedSurfaceRunDirectory, + 'surface-integration-world/surfaceprobe-integration.properties') + if (!marker.isFile()) throw new GradleException('Packaged fresh surface marker is missing') + assertRuntimeLogsClean(packagedSurfaceRunDirectory, + 'packaged surface fresh', [] as Set) + } +} +def packagedSurfaceReload = tasks.register('packagedSurfaceReload', Exec) { + group = 'verification' + dependsOn packagedSurfaceFresh + doFirst { + File runtime = requireRuntimeDirectory(packagedForgeServerRuntime, + 'packagedForgeServerRuntime') + File libraries = new File(runtime, 'libraries') + File forgeDirectory = new File(libraries, + 'net/minecraftforge/forge/1.18.2-40.3.0') + File sourceArguments = new File(forgeDirectory, + System.getProperty('os.name').toLowerCase(Locale.ROOT).contains('windows') + ? 'win_args.txt' : 'unix_args.txt') + File absoluteArguments = new File(packagedSurfaceRunDirectory, 'forge-server-args.txt') + String libraryPath = libraries.canonicalPath.replace('\\', '/') + '/' + absoluteArguments.setText(sourceArguments.getText('UTF-8') + .replace('libraries/', libraryPath) + .replace('-DlibraryDirectory=libraries', + "-DlibraryDirectory=${libraries.canonicalPath}"), 'UTF-8') + workingDir packagedSurfaceRunDirectory + commandLine java17Launcher.get().executablePath.asFile.absolutePath, + '-Xms512m', '-Xmx2g', '-Dsurfaceprobe.integrationPhase=reload', + "@${absoluteArguments.absolutePath}", 'nogui' + } + doLast { + assertRuntimeLogsClean(packagedSurfaceRunDirectory, + 'packaged surface reload', [] as Set) + } +} +tasks.register('packagedSurfaceIntegrationTest') { + group = 'verification' + dependsOn packagedSurfaceReload + doLast { + File marker = new File(packagedSurfaceRunDirectory, + 'surface-integration-world/surfaceprobe-integration.properties') + Properties values = new Properties() + marker.withInputStream { values.load(it) } + if (values.getProperty('reload_verified') != 'true') { + throw new GradleException('Packaged surface reload was not verified') + } + } +} + +def packagedClientRunDirectory = file("${buildDir}/packaged-client-run") +tasks.register('preparePackagedClientIntegration') { + dependsOn releaseJar + dependsOn packagedClientProbeJar + doLast { + delete packagedClientRunDirectory + packagedClientRunDirectory.mkdirs() + copy { + from releaseJar + from packagedClientProbeJar + into new File(packagedClientRunDirectory, 'mods') + } + new File(packagedClientRunDirectory, 'options.txt').setText( + 'fullscreen:false\nlang:en_us\n', 'UTF-8') + File configDirectory = new File(packagedClientRunDirectory, 'config') + configDirectory.mkdirs() + new File(configDirectory, 'orespawn-common.toml').setText('''\ +# Bootstrap fallbacks. Detailed settings live in orespawn-worldgen.json. +[worldgen] + # Master switch for configured terrain replacement. + place_terrain = true + # Allowed Values: GEOME, LEGACY + fallback_geology_mode = "GEOME" + # Range: 4 ~ 32767 + cyano_region_size = 256 + # Range: 1.0 ~ 32767.0 + cyano_layer_reach = 32.0 + # Range: 1 ~ 255 + cyano_layer_thickness = 8 +''', 'UTF-8') + } +} +def packagedClientProcess = tasks.register('packagedClientProcess', Exec) { + group = 'verification' + dependsOn tasks.named('preparePackagedClientIntegration') + doFirst { + File runtime = requireRuntimeDirectory(packagedForgeClientRuntime, + 'packagedForgeClientRuntime') + String forgeVersionId = ['1.18.2-forge-40.3.0', 'forge-40.3.0'].find { candidate -> + new File(runtime, "versions/${candidate}/${candidate}.json").isFile() + } + if (forgeVersionId == null) { + throw new GradleException('Official client runtime has no Forge 40.3.0 profile') + } + File forgeJsonFile = new File(runtime, + "versions/${forgeVersionId}/${forgeVersionId}.json") + File baseJsonFile = new File(runtime, 'versions/1.18.2/1.18.2.json') + File baseJar = new File(runtime, 'versions/1.18.2/1.18.2.jar') + File nativesDirectory = new File(runtime, "natives/${forgeVersionId}") + [forgeJsonFile, baseJsonFile, baseJar, new File(runtime, 'libraries'), + new File(runtime, 'assets'), nativesDirectory].each { + if (!it.exists()) throw new GradleException("Incomplete official client runtime: ${it}") + } + + def slurper = new groovy.json.JsonSlurper() + Map forgeJson = (Map) slurper.parse(forgeJsonFile) + Map baseJson = (Map) slurper.parse(baseJsonFile) + Map classpathByModule = new LinkedHashMap<>() + [baseJson, forgeJson].each { Map metadata -> + ((List) metadata.libraries).each { Map library -> + List rules = (List) library.rules + boolean allowed = rules == null || rules.isEmpty() + if (rules != null) { + rules.each { Map rule -> + Map os = (Map) rule.os + boolean matches = os == null + || (os.name == 'windows' + && (os.arch == null || os.arch == System.getProperty('os.arch'))) + if (matches) allowed = rule.action == 'allow' + } } - String excludeTestKey = 'org.eclipse.jdt.launching.ATTR_EXCLUDE_TEST_CODE' - String excludeTestAttribute = - "" - String beforeTestExclusion = synced - if (synced.contains("key=\"${excludeTestKey}\"")) { - synced = synced.replaceFirst( - //, - excludeTestAttribute) - } else { - int launchHeaderEnd = synced.indexOf('\n', synced.indexOf(' coordinates = ((String) library.name).split(':') as List + String module = coordinates.size() >= 2 + ? "${coordinates[0]}:${coordinates[1]}" : (String) library.name + classpathByModule.put(module, artifact) } } + List classpathFiles = new ArrayList<>(classpathByModule.values()) + classpathFiles.add(baseJar) + + String libraryDirectory = new File(runtime, 'libraries').absolutePath + List forgeJvmArguments = ((List) ((Map) forgeJson.arguments).jvm) + .collect { String argument -> + argument.replace('${library_directory}', libraryDirectory) + .replace('${classpath_separator}', File.pathSeparator) + .replace('${version_name}', (String) forgeJson.inheritsFrom) + } - // ForgeGradle's launch group lets Java start even if its Buildship - // preparation step changed caches or failed. Publish the synchronized - // Java launch directly; Eclipse already copies resources into bin/main. - int changedLaunchGroups = 0 - fileTree(projectDir) { - include 'run*.launch' - }.files.each { File launchFile -> - String runName = launchFile.name.substring(0, launchFile.name.length() - '.launch'.length()) - File slimLaunch = new File(eclipseLaunchDir, "${project.name} - ${runName}Slim.launch") - if (!slimLaunch.isFile()) { - return - } - String replacement = slimLaunch.getText('UTF-8') - if (launchFile.getText('UTF-8') != replacement) { - launchFile.setText(replacement, 'UTF-8') - changedLaunchGroups++ + List gameArguments = [] + gameArguments.addAll((List) ((Map) forgeJson.arguments).game) + gameArguments.addAll([ + '--username', 'OreSpawnValidation', + '--version', (String) forgeJson.id, + '--gameDir', packagedClientRunDirectory.absolutePath, + '--assetsDir', new File(runtime, 'assets').absolutePath, + '--assetIndex', (String) ((Map) baseJson.assetIndex).id, + '--uuid', '00000000-0000-0000-0000-000000000001', + '--accessToken', 'validation-token', + '--userType', 'legacy', + '--versionType', 'release', + '--width', '854', '--height', '480' + ]) + workingDir packagedClientRunDirectory + File clientJava = packagedClientJavaExecutable.isPresent() + ? file(packagedClientJavaExecutable.get()) + : java17Launcher.get().executablePath.asFile + if (!clientJava.isFile()) { + throw new GradleException("packagedClientJavaExecutable does not name a Java executable: ${clientJava}") + } + List clientCommand = [ + clientJava.absolutePath, + '-Xms512m', '-Xmx2g', '-Dclientprobe.enabled=true', + "-Djava.library.path=${nativesDirectory.absolutePath}", + '-Dminecraft.launcher.brand=orespawn-validation', + '-Dminecraft.launcher.version=1' + ] + clientCommand.addAll(forgeJvmArguments) + clientCommand.addAll([ + '-cp', classpathFiles.collect { it.absolutePath }.join(File.pathSeparator), + (String) forgeJson.mainClass + ]) + clientCommand.addAll(gameArguments) + commandLine(clientCommand) + } +} +tasks.register('packagedClientIntegrationTest') { + group = 'verification' + dependsOn packagedClientProcess + doLast { + File marker = new File(packagedClientRunDirectory, 'client-smoke-pass.properties') + if (!marker.isFile()) { + throw new GradleException('Packaged client completion marker is missing') + } + Properties values = new Properties() + marker.withInputStream { values.load(it) } + ['world_settings_opened', 'long_editor_roundtrip', + 'first_world_rendered', 'reload_rendered'].each { key -> + if (values.getProperty(key) != 'true') { + throw new GradleException("Packaged client failed ${key}: ${values}") } } - logger.lifecycle("Eclipse runtime paths aligned with ${eclipseCache} (${stableClasspaths.size()} Forge classpaths, ${changedProjectClasspath} project classpath, ${changedResourceExclusions} metadata exclusion, ${changedPrepareLaunches} prepare launches, ${changedLaunches} slim launches, ${changedTestExclusions} test exclusions, ${changedLaunchGroups} direct project launches updated)") + assertDocumentationTree(new File(packagedClientRunDirectory, + 'config/orespawn-guide'), documentationFiles(), 'runtime guide export') + assertRuntimeLogsClean(packagedClientRunDirectory, + 'packaged client', [] as Set) } } -tasks.configureEach { - if (name in ['copyEclipseResources', 'genEclipseRuns', 'eclipse', - 'prepareRunClient', 'prepareRunServer', 'prepareRunData', 'prepareRunGameTestServer']) { - finalizedBy syncEclipseRunClasspaths - } +tasks.register('packagedRuntimeIntegrationTest') { + group = 'verification' + description = 'Runs the exact reobfuscated jars in official Forge 40 server and client runtimes.' + dependsOn tasks.named('packagedSurfaceIntegrationTest') + dependsOn tasks.named('packagedClientIntegrationTest') } diff --git a/ci-fixtures/README.md b/ci-fixtures/README.md new file mode 100644 index 00000000..76177c4c --- /dev/null +++ b/ci-fixtures/README.md @@ -0,0 +1,12 @@ +# OreSpawn 1.18.2 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. + +`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. diff --git a/ci-fixtures/SHA256SUMS b/ci-fixtures/SHA256SUMS new file mode 100644 index 00000000..b7c6864e --- /dev/null +++ b/ci-fixtures/SHA256SUMS @@ -0,0 +1 @@ +CCA84E9270585478B08F54BA091AD56AB2CB390386C650ED78B2673DC57403EB artifacts/Mineralogy-1.18.2-5.4.0.jar diff --git a/ci-fixtures/artifacts/Mineralogy-1.18.2-5.4.0.jar b/ci-fixtures/artifacts/Mineralogy-1.18.2-5.4.0.jar new file mode 100644 index 00000000..3a6dc189 Binary files /dev/null and b/ci-fixtures/artifacts/Mineralogy-1.18.2-5.4.0.jar differ diff --git a/ci-fixtures/tools/LICENSE-MAVENIZER.txt b/ci-fixtures/tools/LICENSE-MAVENIZER.txt new file mode 100644 index 00000000..8000a6fa --- /dev/null +++ b/ci-fixtures/tools/LICENSE-MAVENIZER.txt @@ -0,0 +1,504 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 2.1, February 1999 + + Copyright (C) 1991, 1999 Free Software Foundation, Inc. + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts + as the successor of the GNU Library Public License, version 2, hence + the version number 2.1.] + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +Licenses are intended to guarantee your freedom to share and change +free software--to make sure the software is free for all its users. + + This license, the Lesser General Public License, applies to some +specially designated software packages--typically libraries--of the +Free Software Foundation and other authors who decide to use it. You +can use it too, but we suggest you first think carefully about whether +this license or the ordinary General Public License is the better +strategy to use in any particular case, based on the explanations below. + + When we speak of free software, we are referring to freedom of use, +not price. Our General Public Licenses are designed to make sure that +you have the freedom to distribute copies of free software (and charge +for this service if you wish); that you receive source code or can get +it if you want it; that you can change the software and use pieces of +it in new free programs; and that you are informed that you can do +these things. + + To protect your rights, we need to make restrictions that forbid +distributors to deny you these rights or to ask you to surrender these +rights. These restrictions translate to certain responsibilities for +you if you distribute copies of the library or if you modify it. + + For example, if you distribute copies of the library, whether gratis +or for a fee, you must give the recipients all the rights that we gave +you. You must make sure that they, too, receive or can get the source +code. If you link other code with the library, you must provide +complete object files to the recipients, so that they can relink them +with the library after making changes to the library and recompiling +it. And you must show them these terms so they know their rights. + + We protect your rights with a two-step method: (1) we copyright the +library, and (2) we offer you this license, which gives you legal +permission to copy, distribute and/or modify the library. + + To protect each distributor, we want to make it very clear that +there is no warranty for the free library. Also, if the library is +modified by someone else and passed on, the recipients should know +that what they have is not the original version, so that the original +author's reputation will not be affected by problems that might be +introduced by others. + + Finally, software patents pose a constant threat to the existence of +any free program. We wish to make sure that a company cannot +effectively restrict the users of a free program by obtaining a +restrictive license from a patent holder. Therefore, we insist that +any patent license obtained for a version of the library must be +consistent with the full freedom of use specified in this license. + + Most GNU software, including some libraries, is covered by the +ordinary GNU General Public License. This license, the GNU Lesser +General Public License, applies to certain designated libraries, and +is quite different from the ordinary General Public License. We use +this license for certain libraries in order to permit linking those +libraries into non-free programs. + + When a program is linked with a library, whether statically or using +a shared library, the combination of the two is legally speaking a +combined work, a derivative of the original library. The ordinary +General Public License therefore permits such linking only if the +entire combination fits its criteria of freedom. The Lesser General +Public License permits more lax criteria for linking other code with +the library. + + We call this license the "Lesser" General Public License because it +does Less to protect the user's freedom than the ordinary General +Public License. It also provides other free software developers Less +of an advantage over competing non-free programs. These disadvantages +are the reason we use the ordinary General Public License for many +libraries. However, the Lesser license provides advantages in certain +special circumstances. + + For example, on rare occasions, there may be a special need to +encourage the widest possible use of a certain library, so that it becomes +a de-facto standard. To achieve this, non-free programs must be +allowed to use the library. A more frequent case is that a free +library does the same job as widely used non-free libraries. In this +case, there is little to gain by limiting the free library to free +software only, so we use the Lesser General Public License. + + In other cases, permission to use a particular library in non-free +programs enables a greater number of people to use a large body of +free software. For example, permission to use the GNU C Library in +non-free programs enables many more people to use the whole GNU +operating system, as well as its variant, the GNU/Linux operating +system. + + Although the Lesser General Public License is Less protective of the +users' freedom, it does ensure that the user of a program that is +linked with the Library has the freedom and the wherewithal to run +that program using a modified version of the Library. + + The precise terms and conditions for copying, distribution and +modification follow. Pay close attention to the difference between a +"work based on the library" and a "work that uses the library". The +former contains code derived from the library, whereas the latter must +be combined with the library in order to run. + + GNU LESSER GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License Agreement applies to any software library or other +program which contains a notice placed by the copyright holder or +other authorized party saying it may be distributed under the terms of +this Lesser General Public License (also called "this License"). +Each licensee is addressed as "you". + + A "library" means a collection of software functions and/or data +prepared so as to be conveniently linked with application programs +(which use some of those functions and data) to form executables. + + The "Library", below, refers to any such software library or work +which has been distributed under these terms. A "work based on the +Library" means either the Library or any derivative work under +copyright law: that is to say, a work containing the Library or a +portion of it, either verbatim or with modifications and/or translated +straightforwardly into another language. (Hereinafter, translation is +included without limitation in the term "modification".) + + "Source code" for a work means the preferred form of the work for +making modifications to it. For a library, complete source code means +all the source code for all modules it contains, plus any associated +interface definition files, plus the scripts used to control compilation +and installation of the library. + + Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running a program using the Library is not restricted, and output from +such a program is covered only if its contents constitute a work based +on the Library (independent of the use of the Library in a tool for +writing it). Whether that is true depends on what the Library does +and what the program that uses the Library does. + + 1. You may copy and distribute verbatim copies of the Library's +complete source code as you receive it, in any medium, provided that +you conspicuously and appropriately publish on each copy an +appropriate copyright notice and disclaimer of warranty; keep intact +all the notices that refer to this License and to the absence of any +warranty; and distribute a copy of this License along with the +Library. + + You may charge a fee for the physical act of transferring a copy, +and you may at your option offer warranty protection in exchange for a +fee. + + 2. You may modify your copy or copies of the Library or any portion +of it, thus forming a work based on the Library, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices + stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no + charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a + table of data to be supplied by an application program that uses + the facility, other than as an argument passed when the facility + is invoked, then you must make a good faith effort to ensure that, + in the event an application does not supply such function or + table, the facility still operates, and performs whatever part of + its purpose remains meaningful. + + (For example, a function in a library to compute square roots has + a purpose that is entirely well-defined independent of the + application. Therefore, Subsection 2d requires that any + application-supplied function or table used by this function must + be optional: if the application does not supply it, the square + root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Library, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Library, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote +it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library +with the Library (or with a work based on the Library) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may opt to apply the terms of the ordinary GNU General Public +License instead of this License to a given copy of the Library. To do +this, you must alter all the notices that refer to this License, so +that they refer to the ordinary GNU General Public License, version 2, +instead of to this License. (If a newer version than version 2 of the +ordinary GNU General Public License has appeared, then you can specify +that version instead if you wish.) Do not make any other change in +these notices. + + Once this change is made in a given copy, it is irreversible for +that copy, so the ordinary GNU General Public License applies to all +subsequent copies and derivative works made from that copy. + + This option is useful when you wish to copy part of the code of +the Library into a program that is not a library. + + 4. You may copy and distribute the Library (or a portion or +derivative of it, under Section 2) in object code or executable form +under the terms of Sections 1 and 2 above provided that you accompany +it with the complete corresponding machine-readable source code, which +must be distributed under the terms of Sections 1 and 2 above on a +medium customarily used for software interchange. + + If distribution of object code is made by offering access to copy +from a designated place, then offering equivalent access to copy the +source code from the same place satisfies the requirement to +distribute the source code, even though third parties are not +compelled to copy the source along with the object code. + + 5. A program that contains no derivative of any portion of the +Library, but is designed to work with the Library by being compiled or +linked with it, is called a "work that uses the Library". Such a +work, in isolation, is not a derivative work of the Library, and +therefore falls outside the scope of this License. + + However, linking a "work that uses the Library" with the Library +creates an executable that is a derivative of the Library (because it +contains portions of the Library), rather than a "work that uses the +library". The executable is therefore covered by this License. +Section 6 states terms for distribution of such executables. + + When a "work that uses the Library" uses material from a header file +that is part of the Library, the object code for the work may be a +derivative work of the Library even though the source code is not. +Whether this is true is especially significant if the work can be +linked without the Library, or if the work is itself a library. The +threshold for this to be true is not precisely defined by law. + + If such an object file uses only numerical parameters, data +structure layouts and accessors, and small macros and small inline +functions (ten lines or less in length), then the use of the object +file is unrestricted, regardless of whether it is legally a derivative +work. (Executables containing this object code plus portions of the +Library will still fall under Section 6.) + + Otherwise, if the work is a derivative of the Library, you may +distribute the object code for the work under the terms of Section 6. +Any executables containing that work also fall under Section 6, +whether or not they are linked directly with the Library itself. + + 6. As an exception to the Sections above, you may also combine or +link a "work that uses the Library" with the Library to produce a +work containing portions of the Library, and distribute that work +under terms of your choice, provided that the terms permit +modification of the work for the customer's own use and reverse +engineering for debugging such modifications. + + You must give prominent notice with each copy of the work that the +Library is used in it and that the Library and its use are covered by +this License. You must supply a copy of this License. If the work +during execution displays copyright notices, you must include the +copyright notice for the Library among them, as well as a reference +directing the user to the copy of this License. Also, you must do one +of these things: + + a) Accompany the work with the complete corresponding + machine-readable source code for the Library including whatever + changes were used in the work (which must be distributed under + Sections 1 and 2 above); and, if the work is an executable linked + with the Library, with the complete machine-readable "work that + uses the Library", as object code and/or source code, so that the + user can modify the Library and then relink to produce a modified + executable containing the modified Library. (It is understood + that the user who changes the contents of definitions files in the + Library will not necessarily be able to recompile the application + to use the modified definitions.) + + b) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (1) uses at run time a + copy of the library already present on the user's computer system, + rather than copying library functions into the executable, and (2) + will operate properly with a modified version of the library, if + the user installs one, as long as the modified version is + interface-compatible with the version that the work was made with. + + c) Accompany the work with a written offer, valid for at + least three years, to give the same user the materials + specified in Subsection 6a, above, for a charge no more + than the cost of performing this distribution. + + d) If distribution of the work is made by offering access to copy + from a designated place, offer equivalent access to copy the above + specified materials from the same place. + + e) Verify that the user has already received a copy of these + materials or that you have already sent this user a copy. + + For an executable, the required form of the "work that uses the +Library" must include any data and utility programs needed for +reproducing the executable from it. However, as a special exception, +the materials to be distributed need not include anything that is +normally distributed (in either source or binary form) with the major +components (compiler, kernel, and so on) of the operating system on +which the executable runs, unless that component itself accompanies +the executable. + + It may happen that this requirement contradicts the license +restrictions of other proprietary libraries that do not normally +accompany the operating system. Such a contradiction means you cannot +use both them and the Library together in an executable that you +distribute. + + 7. You may place library facilities that are a work based on the +Library side-by-side in a single library together with other library +facilities not covered by this License, and distribute such a combined +library, provided that the separate distribution of the work based on +the Library and of the other library facilities is otherwise +permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work + based on the Library, uncombined with any other library + facilities. This must be distributed under the terms of the + Sections above. + + b) Give prominent notice with the combined library of the fact + that part of it is a work based on the Library, and explaining + where to find the accompanying uncombined form of the same work. + + 8. You may not copy, modify, sublicense, link with, or distribute +the Library except as expressly provided under this License. Any +attempt otherwise to copy, modify, sublicense, link with, or +distribute the Library is void, and will automatically terminate your +rights under this License. However, parties who have received copies, +or rights, from you under this License will not have their licenses +terminated so long as such parties remain in full compliance. + + 9. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Library or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Library (or any work based on the +Library), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Library or works based on it. + + 10. Each time you redistribute the Library (or any work based on the +Library), the recipient automatically receives a license from the +original licensor to copy, distribute, link with or modify the Library +subject to these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties with +this License. + + 11. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Library at all. For example, if a patent +license would not permit royalty-free redistribution of the Library by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any +particular circumstance, the balance of the section is intended to apply, +and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 12. If the distribution and/or use of the Library is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Library under this License may add +an explicit geographical distribution limitation excluding those countries, +so that distribution is permitted only in or among countries not thus +excluded. In such case, this License incorporates the limitation as if +written in the body of this License. + + 13. The Free Software Foundation may publish revised and/or new +versions of the Lesser General Public License from time to time. +Such new versions will be similar in spirit to the present version, +but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library +specifies a version number of this License which applies to it and +"any later version", you have the option of following the terms and +conditions either of that version or of any later version published by +the Free Software Foundation. If the Library does not specify a +license version number, you may choose any version ever published by +the Free Software Foundation. + + 14. If you wish to incorporate parts of the Library into other free +programs whose distribution conditions are incompatible with these, +write to the author to ask for permission. For software which is +copyrighted by the Free Software Foundation, write to the Free +Software Foundation; we sometimes make exceptions for this. Our +decision will be guided by the two goals of preserving the free status +of all derivatives of our free software and of promoting the sharing +and reuse of software generally. + + NO WARRANTY + + 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO +WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. +EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY +KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE +LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME +THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN +WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY +AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU +FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR +CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE +LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING +RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A +FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF +SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Libraries + + If you develop a new library, and you want it to be of the greatest +possible use to the public, we recommend making it free software that +everyone can redistribute and change. You can do so by permitting +redistribution under these terms (or, alternatively, under the terms of the +ordinary General Public License). + + To apply these terms, attach the following notices to the library. It is +safest to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least the +"copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 + USA + +Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the library, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the + library `Frob' (a library for tweaking knobs) written by James Random + Hacker. + + , 1 April 1990 + Ty Coon, President of Vice + +That's all there is to it! diff --git a/ci-fixtures/tools/README.md b/ci-fixtures/tools/README.md new file mode 100644 index 00000000..c2df35ed --- /dev/null +++ b/ci-fixtures/tools/README.md @@ -0,0 +1,23 @@ +# Forge 1.18.2 Mavenizer compatibility fixture + +`minecraft-mavenizer-0.5.21-orespawn-1.18.jar` is a build-only derivative of +MinecraftForge MinecraftMavenizer `0.5.21`, source commit +`6968241ce7a0a902cdc1c534b976e8373a423091`. + +Forge 40's 1.18.2 decompiler emits a redundant explicit `Holder.Direct#value` +record accessor. The complete patched Minecraft source tree is rejected by +modern `javac`; removing that method is safe because the record generates the +same public accessor automatically. The derivative also honors +`ORESPAWN_MAVENIZER_OFFLINE=true` by adding Mavenizer's native `--offline` +argument. This closes ForgeGradle 7's failure to propagate Gradle offline mode +to the subprocess; it does not alter normal online builds. + +`minecraft-1.18-holder-accessor.patch` documents the complete two-file source +change. + +The fixture is used only while ForgeGradle prepares the development dependency. +It is checksum-verified by the build and excluded from every OreSpawn artifact. +MinecraftMavenizer remains licensed LGPL-2.1-only; see `LICENSE-MAVENIZER.txt`. + +SHA-256: +`28A6697BDF6D9500EC0AF9C1C949B9F3ED7DDFDA649CB5CAC4BEEA2E9567570A` diff --git a/ci-fixtures/tools/minecraft-1.18-holder-accessor.patch b/ci-fixtures/tools/minecraft-1.18-holder-accessor.patch new file mode 100644 index 00000000..57fd2b27 --- /dev/null +++ b/ci-fixtures/tools/minecraft-1.18-holder-accessor.patch @@ -0,0 +1,43 @@ +diff --git a/src/main/java/net/minecraftforge/mcmaven/cli/Main.java b/src/main/java/net/minecraftforge/mcmaven/cli/Main.java +index eeda8a0..4762951 100644 +--- a/src/main/java/net/minecraftforge/mcmaven/cli/Main.java ++++ b/src/main/java/net/minecraftforge/mcmaven/cli/Main.java +@@ -14,0 +15 @@ import java.util.ArrayList; ++import java.util.Arrays; +@@ -39,0 +41,6 @@ public class Main { ++ if ("true".equalsIgnoreCase(System.getenv("ORESPAWN_MAVENIZER_OFFLINE")) ++ && Arrays.stream(args).noneMatch("--offline"::equals)) { ++ var offlineArgs = Arrays.copyOf(args, args.length + 1); ++ offlineArgs[args.length] = "--offline"; ++ args = offlineArgs; ++ } +diff --git a/src/main/java/net/minecraftforge/mcmaven/impl/util/ProcessUtils.java b/src/main/java/net/minecraftforge/mcmaven/impl/util/ProcessUtils.java +index 9fa2888..be7f6be 100644 +--- a/src/main/java/net/minecraftforge/mcmaven/impl/util/ProcessUtils.java ++++ b/src/main/java/net/minecraftforge/mcmaven/impl/util/ProcessUtils.java +@@ -307,0 +308,2 @@ public final class ProcessUtils { ++ patchMinecraft118HolderAccessor(sourcesOutput); ++ +@@ -368,0 +371,22 @@ public final class ProcessUtils { ++ private static void patchMinecraft118HolderAccessor(File sourcesOutput) { ++ var holder = new File(sourcesOutput, "net/minecraft/core/Holder.java"); ++ if (!holder.isFile()) ++ return; ++ ++ try { ++ var source = Files.readString(holder.toPath()); ++ var record = "public static record Direct(T value) implements Holder"; ++ var accessor = "\n public T value() {\n return this.value;\n }\n"; ++ if (!source.contains(record) || !source.contains(accessor)) ++ return; ++ ++ var patched = source.replace(accessor, "\n"); ++ if (patched.contains(accessor)) ++ throw new IllegalStateException("Holder.Direct accessor compatibility patch was ambiguous"); ++ Files.writeString(holder.toPath(), patched); ++ LOGGER.info("Applied Minecraft 1.18.2 Holder.Direct javac compatibility patch"); ++ } catch (IOException e) { ++ throw new RuntimeException("Failed to patch Minecraft 1.18.2 Holder source", e); ++ } ++ } ++ diff --git a/ci-fixtures/tools/minecraft-mavenizer-0.5.21-orespawn-1.18.jar b/ci-fixtures/tools/minecraft-mavenizer-0.5.21-orespawn-1.18.jar new file mode 100644 index 00000000..f88af85f Binary files /dev/null and b/ci-fixtures/tools/minecraft-mavenizer-0.5.21-orespawn-1.18.jar differ diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 04e7b789..a62c49fb 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -12,5 +12,6 @@ Use the focused guides for implementation details: - [BIOMES.md](BIOMES.md) and [DIMENSIONS.md](DIMENSIONS.md) for world integration; - [TEMPLATES.md](TEMPLATES.md) for selectable world styles; - [CONFIGURATION.md](CONFIGURATION.md) for configuration behavior; -- [VERSIONS.md](VERSIONS.md) for the shared four-component target-qualified versioning and branch-release convention; +- [VERSIONS.md](VERSIONS.md) for the shared four-component target-qualified + versioning, skipped functional releases, and branch-release convention; - [README.md](README.md) for schemas, examples, and the complete documentation index. diff --git a/docs/API.md b/docs/API.md index a4138e91..f12d6586 100644 --- a/docs/API.md +++ b/docs/API.md @@ -12,7 +12,7 @@ runtime. In `mods.toml` use a mandatory dependency, for example: [[dependencies.examplemod]] modId="orespawn" mandatory=true -versionRange="[4.0.0,5.0.0)" +versionRange="[4.0.6,5.0.0)" ordering="AFTER" side="BOTH" ``` diff --git a/docs/BIOMES.md b/docs/BIOMES.md index 4a0251ea..0a050dcc 100644 --- a/docs/BIOMES.md +++ b/docs/BIOMES.md @@ -125,6 +125,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/README.md b/docs/README.md index e63cf93c..9fe8ad40 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,6 +17,7 @@ Choose the guide that matches what you are doing: - [Dimensions](DIMENSIONS.md) - [Migration](MIGRATION.md) - [Troubleshooting](TROUBLESHOOTING.md) +- [Versioning and release conventions](VERSIONS.md) - [Compact instructions for coding agents](AGENTS.md) Validated examples are in `examples/`; JSON Schemas are in `schemas/`. diff --git a/docs/VERSIONS.md b/docs/VERSIONS.md index 005f28db..e2c89497 100644 --- a/docs/VERSIONS.md +++ b/docs/VERSIONS.md @@ -49,9 +49,14 @@ version. Examples: -| Minecraft | Loader | Target | Full OreSpawn 4.0.6 version | +| Minecraft | Loader | Target | Example full OreSpawn version | | --- | --- | ---: | --- | | 1.13.2 | Forge | `113021` | `4.0.6.113021` | +| 1.14.4 | Forge | `114041` | `4.0.8.114041` | +| 1.15.2 | Forge | `115021` | `4.0.9.115021` | +| 1.16.5 | Forge | `116051` | `4.0.9.116051` | +| 1.17.1 | Forge | `117011` | `4.0.9.117011` | +| 1.18.2 | Forge | `118021` | `4.0.10.118021` | | 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` | @@ -138,12 +143,16 @@ same `Major.Minor.Bug` may be shared by functionally equivalent ports. If a released branch receives a bug fix that other branches do not require, only the affected branch's Bug number is incremented. For example, Forge -1.13.2 may move from `4.0.6.113021` to `4.0.7.113021` while unaffected branches -remain on their target-qualified 4.0.6 versions. - -If a different branch later receives a separate fix, it uses the next unused -Bug number, such as `4.0.8`, even if the `4.0.7` fix was not applicable to it. -A branch may therefore legitimately skip functional version numbers. +1.12.2 moved to `4.0.7.112021` for its packaged access-transformer repair while +unaffected branches remained on their target-qualified 4.0.6 versions. + +If a different branch later receives a shared fix, it uses the next unused +Bug number, such as Forge 1.14.4's `4.0.8.114041`, even though the 4.0.7 repair +was not applicable there. Forge 1.15.2, 1.16.5, and 1.17.1 then advanced to +their target-qualified 4.0.9 releases for the provider terrain-host ordering +repair. Forge 1.18.2 then advanced to `4.0.10.118021` for the distinct Stable +Layers actual-height eligibility repair. A branch may therefore legitimately +skip functional version numbers. This provides three useful guarantees: diff --git a/gradle.properties b/gradle.properties index 2cb4949b..861b7e4f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,58 +2,31 @@ # This is required to provide enough memory for the Minecraft decompilation process. org.gradle.jvmargs=-Xmx3G org.gradle.daemon=false +org.gradle.configuration-cache=false +org.gradle.caching=true +org.gradle.parallel=false +net.minecraftforge.gradle.merge-source-sets=false - -## Environment Properties - -# The Minecraft version must agree with the Forge version to get a valid artifact minecraft_version=1.18.2 -# The Minecraft version range can use any release version of Minecraft as bounds. -# Snapshots, pre-releases, and release candidates are not guaranteed to sort properly -# as they do not follow standard versioning conventions. minecraft_version_range=[1.18.2,1.19) -# The Forge version must agree with the Minecraft version to get a valid artifact forge_version=40.3.0 -# The Forge version range can use any version of Forge as bounds or match the loader version range forge_version_range=[40,) -# The loader version range can only use the major version of Forge/FML as bounds loader_version_range=[40,) -# The mapping channel to use for mappings. -# The default set of supported mapping channels are ["official", "snapshot", "snapshot_nodoc", "stable", "stable_nodoc"]. -# Additional mapping channels can be registered through the "channelProviders" extension in a Gradle plugin. -# -# | Channel | Version | | -# |-----------|----------------------|--------------------------------------------------------------------------------| -# | official | MCVersion | Official field/method names from Mojang mapping files | -# | parchment | YYYY.MM.DD-MCVersion | Open community-sourced parameter names and javadocs layered on top of official | -# -# You must be aware of the Mojang license when using the 'official' or 'parchment' mappings. -# See more information here: https://github.com/MinecraftForge/MCPConfig/blob/master/Mojang.md -# -# Parchment is an unofficial project maintained by ParchmentMC, separate from Minecraft Forge. -# Additional setup is needed to use their mappings, see https://parchmentmc.org/docs/getting-started mapping_channel=official -# The mapping version to query from the mapping channel. -# This must match the format required by the mapping channel. mapping_version=1.18.2 +mcp_version=20220404.173914 - -## Mod Properties - -# The unique mod identifier for the mod. Must be lowercase in English locale. Must fit the regex [a-z][a-z0-9_]{1,63} -# Must match the String constant located in the main mod class annotated with @Mod. mod_id=orespawn -# The human-readable display name for the mod. 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.118021 -# 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 -mod_group_id=zone.moddev.mc.orespawn -# The authors of the mod. This is a simple text string that is used for display purposes in the mod list. +mod_version=4.0.10.118021 +mod_group=zone.moddev.mc.orespawn mod_authors=SkyBlade1978, dshadowwolf, the MMD Team -# The description of the mod. This is a simple multiline text string that is used for display purposes in the mod list. mod_description=Configurable, provider-driven terrain, ore, and deposit generation. + +loader_name=forge +loader_code=1 +java_version=17 +java_toolchain_version=17.0.1+12 +gradle_java_version=17 +curseforge_project_id=245586 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 249e5832..0d4a9516 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 0d184210..2c68b418 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip +distributionSha256Sum=9c0f7faeeb306cb14e4279a3e084ca6b596894089a0638e68a07c945a32c9e14 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/settings.gradle b/settings.gradle index 0077fa74..e7080f2e 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,13 +1,5 @@ -pluginManagement { - repositories { - gradlePluginPortal() - maven { - name = 'MinecraftForge' - url = 'https://maven.minecraftforge.net/' - } - } -} - plugins { - id 'org.gradle.toolchains.foojay-resolver-convention' version '0.7.0' + id('org.gradle.toolchains.foojay-resolver-convention') version '1.0.0' } + +rootProject.name = 'OreSpawn' diff --git a/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java b/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java index da5aad14..c3377a88 100644 --- a/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java +++ b/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java @@ -97,6 +97,15 @@ public final class SurfaceProbeTestMod { private static final ResourceLocation BIOME_B = new ResourceLocation(MODID + ":surface_b"); private static final ResourceLocation PROBE_GEOME = new ResourceLocation(MODID + ":dynamic_biome_geome"); private static final ResourceLocation DYNAMIC_FLUID = new ResourceLocation(MODID + ":fluid/dynamic_water"); + private static final Block[] NATURAL_SOURCES = { + Blocks.DIRT, Blocks.GRASS_BLOCK, Blocks.COARSE_DIRT, Blocks.PODZOL, + Blocks.ROOTED_DIRT, Blocks.GRAVEL, Blocks.SAND, Blocks.RED_SAND, + Blocks.CLAY, Blocks.TERRACOTTA, Blocks.WHITE_TERRACOTTA, + Blocks.ORANGE_TERRACOTTA, Blocks.RED_TERRACOTTA + }; + private static final Block[] INVALID_TERRAIN_HOSTS = { + Blocks.AIR, Blocks.WATER, Blocks.BEDROCK, Blocks.CHEST + }; private static final ResourceLocation[] BUILT_IN_GEOMES = { new ResourceLocation("orespawn:stable_craton"), new ResourceLocation("orespawn:mountain_belt"), new ResourceLocation("orespawn:volcanic_arc"), new ResourceLocation("orespawn:sedimentary_basin"), @@ -114,9 +123,11 @@ 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"; public SurfaceProbeTestMod() { FMLJavaModLoadingContext context = FMLJavaModLoadingContext.get(); @@ -233,6 +244,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); @@ -348,6 +361,13 @@ 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; BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(); for (int chunkZ = MINIMUM_CHUNK; chunkZ <= MAXIMUM_CHUNK; chunkZ++) { @@ -412,22 +432,94 @@ private static AuditResult auditDimension(ServerLevel level, boolean roofed) { if (previousChunkBiome != null && !previousChunkBiome.equals(centerBiome)) edgeChanges++; previousChunkBiome = centerBiome; 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(); + } } } 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)) + || (!roofed && (rawNaturalSources != EXPECTED_NATURAL_SOURCES + || structureNaturalSources != EXPECTED_NATURAL_SOURCES + || vegetationNaturalSources != EXPECTED_NATURAL_SOURCES + || cavePockets != 54 || underwaterPockets != 63 + || rawBedrock != 9 || rawBlockEntities != 9))) { throw new IllegalStateException("Incomplete surface audit for " + level.dimension().location() + ": top=" + top + ", underwater=" + underwater + ", filler=" + filler + ", biomeA=" + biomeA + ", biomeB=" + biomeB + ", edges=" + edgeChanges + ", sentinels=" + sentinels + ", geology=" + geology - + ", ceiling=" + ceiling + ", roofTop=" + roofTop); + + ", ceiling=" + ceiling + ", roofTop=" + roofTop + + ", rawNatural=" + rawNaturalSources + + ", structureNatural=" + structureNaturalSources + + ", vegetationNatural=" + vegetationNaturalSources + + ", cavePockets=" + cavePockets + + ", underwaterPockets=" + underwaterPockets + + ", rawBedrock=" + rawBedrock + + ", rawBlockEntities=" + rawBlockEntities); } 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); + } + + private static NaturalSourceAudit auditNaturalSources(ServerLevel level, LevelChunk chunk, + BlockPos.MutableBlockPos pos, int minX, int minZ) { + long rawConverted = 0L; + long structurePreserved = 0L; + long vegetationPreserved = 0L; + long cavePreserved = 0L; + long underwaterPreserved = 0L; + long bedrockPreserved = 0L; + long blockEntityPreserved = 0L; + for (int index = 0; index < NATURAL_SOURCES.length; index++) { + int x = naturalX(minX, index); + int z = naturalZ(minZ, index); + int groundY = findMarkedGround(chunk, pos, x, z, + level.getMinBuildHeight(), level.getMaxBuildHeight()); + if (chunk.getBlockState(pos.set(x, groundY - 12, z)).is(Blocks.CALCITE)) rawConverted++; + Block pocket = chunk.getBlockState(pos.set(x, groundY - 11, z)).getBlock(); + if (index < NATURAL_SOURCES.length / 2) { + if (pocket == Blocks.AIR) cavePreserved++; + } else if (pocket == Blocks.WATER) { + underwaterPreserved++; + } + if (chunk.getBlockState(pos.set(x, groundY - 16, z)).is(NATURAL_SOURCES[index])) { + structurePreserved++; + } + if (chunk.getBlockState(pos.set(x, groundY - 20, z)).is(NATURAL_SOURCES[index])) { + vegetationPreserved++; + } + } + int bedrockGroundY = findMarkedGround(chunk, pos, minX + 11, minZ + 12, + level.getMinBuildHeight(), level.getMaxBuildHeight()); + if (chunk.getBlockState(pos.set(minX + 11, bedrockGroundY - 24, minZ + 12)).is(Blocks.BEDROCK)) { + bedrockPreserved++; + } + int chestGroundY = findMarkedGround(chunk, pos, minX + 12, minZ + 12, + level.getMinBuildHeight(), level.getMaxBuildHeight()); + pos.set(minX + 12, chestGroundY - 24, minZ + 12); + if (chunk.getBlockState(pos).is(Blocks.CHEST) + && level.getBlockEntity(pos) instanceof ChestBlockEntity chest + && chest.getItem(0).is(Items.EMERALD) + && RAW_CHEST_ITEM_NAME.equals(chest.getItem(0).getHoverName().getString())) { + blockEntityPreserved++; + } + return new NaturalSourceAudit(rawConverted, structurePreserved, + vegetationPreserved, cavePreserved, underwaterPreserved, + bedrockPreserved, blockEntityPreserved); } private static long auditDynamicFluid(ServerLevel level) { @@ -564,6 +656,13 @@ 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())); } return values; } @@ -658,9 +757,39 @@ private static boolean prepareTerrain(WorldGenLevel world, ChunkAccess chunk) { } } } + if (!roofed) placeRawNaturalSources(world, chunk, pos, minX, minZ); return true; } + private static void placeRawNaturalSources(WorldGenLevel world, ChunkAccess chunk, + BlockPos.MutableBlockPos pos, int minX, int minZ) { + for (int index = 0; index < NATURAL_SOURCES.length; index++) { + int x = naturalX(minX, index); + int z = naturalZ(minZ, index); + int groundY = findMarkedGround(chunk, pos, x, z, + world.getMinBuildHeight(), world.getMaxBuildHeight()); + chunk.setBlockState(pos.set(x, groundY - 12, z), + NATURAL_SOURCES[index].defaultBlockState(), false); + chunk.setBlockState(pos.set(x, groundY - 11, z), + (index < NATURAL_SOURCES.length / 2 ? Blocks.AIR : Blocks.WATER) + .defaultBlockState(), false); + } + int bedrockGroundY = findMarkedGround(chunk, pos, minX + 11, minZ + 12, + world.getMinBuildHeight(), world.getMaxBuildHeight()); + chunk.setBlockState(pos.set(minX + 11, bedrockGroundY - 24, minZ + 12), + Blocks.BEDROCK.defaultBlockState(), false); + int chestGroundY = findMarkedGround(chunk, pos, minX + 12, minZ + 12, + world.getMinBuildHeight(), world.getMaxBuildHeight()); + world.setBlock(pos.set(minX + 12, chestGroundY - 24, minZ + 12), + Blocks.CHEST.defaultBlockState(), 2); + if (world.getBlockEntity(pos) instanceof ChestBlockEntity chest) { + ItemStack sentinel = new ItemStack(Items.EMERALD); + sentinel.setHoverName(new TextComponent(RAW_CHEST_ITEM_NAME)); + chest.setItem(0, sentinel); + chest.setChanged(); + } + } + private static boolean solid(BlockState state) { return !state.isAir() && state.getFluidState().isEmpty(); } @@ -680,6 +809,7 @@ private static boolean placeStructureSentinels(WorldGenLevel world, ChunkAccess chest.setItem(0, sentinel); chest.setChanged(); } + placeAuthoredNaturalSources(world, chunk, pos, minX, minZ, 16); return true; } @@ -695,9 +825,31 @@ 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); return true; } + private static void placeAuthoredNaturalSources(WorldGenLevel world, ChunkAccess chunk, + BlockPos.MutableBlockPos pos, int minX, int minZ, int depth) { + if (!world.getLevel().dimension().equals(OPEN)) return; + for (int index = 0; index < NATURAL_SOURCES.length; index++) { + int x = naturalX(minX, index); + int z = naturalZ(minZ, index); + int groundY = findMarkedGround(chunk, pos, x, z, + world.getMinBuildHeight(), world.getMaxBuildHeight()); + world.setBlock(pos.set(x, groundY - depth, z), + NATURAL_SOURCES[index].defaultBlockState(), 2); + } + } + + private static int naturalX(int minX, int index) { + return minX + 12 + index % 4; + } + + private static int naturalZ(int minZ, int index) { + return minZ + 1 + index / 4; + } + private static int markedGround(ChunkAccess chunk, BlockPos.MutableBlockPos pos, int x, int z, WorldGenLevel world) { return findMarkedGround(chunk, pos, x, z, world.getMinBuildHeight(), world.getMaxBuildHeight()); @@ -706,7 +858,14 @@ 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 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) { } } 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..3c9f694f --- /dev/null +++ b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/clientprobe/ClientProbeTestMod.java @@ -0,0 +1,466 @@ +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.OptionalLong; +import java.util.Properties; +import java.util.Set; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; + +import net.minecraft.ChatFormatting; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.CycleButton; +import net.minecraft.client.gui.components.events.GuiEventListener; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.gui.screens.TitleScreen; +import net.minecraft.client.gui.screens.worldselection.CreateWorldScreen; +import net.minecraft.core.RegistryAccess; +import net.minecraft.world.Difficulty; +import net.minecraft.world.level.DataPackConfig; +import net.minecraft.world.level.GameRules; +import net.minecraft.world.level.GameType; +import net.minecraft.world.level.LevelSettings; +import net.minecraft.world.level.levelgen.WorldGenSettings; +import net.minecraftforge.client.event.RenderLevelLastEvent; +import net.minecraftforge.client.event.ScreenEvent; +import net.minecraftforge.fml.common.Mod; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.event.TickEvent; +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) +@Mod.EventBusSubscriber(modid = ClientProbeTestMod.MODID, value = Dist.CLIENT) +public final class ClientProbeTestMod { + static final String MODID = "clientprobe"; + private static final String WORLD_DIRECTORY = "client-smoke-world"; + private static volatile ClientProbeTestMod instance; + private final Set editorRoutes = new HashSet<>(); + private final Set attemptedButtons = new HashSet<>(); + private AbstractWidget worldSettingsButton; + private int state; + private int stateTicks; + private int firstWorldFrames; + private int reloadWorldFrames; + private int editorFrames; + private boolean worldSettingsOpened; + private boolean longEditorRoundTrip; + private List worldCreationButtons; + + public ClientProbeTestMod() { + instance = this; + } + + @SubscribeEvent + public static void onScreenInitialized(ScreenEvent.InitScreenEvent.Post event) { + ClientProbeTestMod probe = instance; + if (probe == null || !Boolean.getBoolean("clientprobe.enabled")) return; + if (!(event.getScreen() instanceof CreateWorldScreen)) return; + probe.worldCreationButtons = event.getListenersList(); + for (GuiEventListener child : event.getListenersList()) { + if (child instanceof AbstractWidget && isWorldSettingsButton((AbstractWidget) child)) { + probe.worldSettingsButton = (AbstractWidget) child; + } + } + } + + @SubscribeEvent + public static void onScreenDrawn(ScreenEvent.DrawScreenEvent.Post event) { + ClientProbeTestMod probe = instance; + if (probe != null && Boolean.getBoolean("clientprobe.enabled") + && isOreSpawnEditor(event.getScreen())) probe.editorFrames++; + } + + @SubscribeEvent + public static void onWorldRendered(RenderLevelLastEvent 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(TickEvent.ClientTickEvent event) { + ClientProbeTestMod probe = instance; + if (probe == null || event.phase != TickEvent.Phase.END + || !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); + try { + switch (state) { + case 0: + if (minecraft.screen instanceof TitleScreen) { + minecraft.setScreen(CreateWorldScreen.createFresh(minecraft.screen)); + nextState(1); + } + break; + case 1: + if (worldSettingsButton == null && minecraft.screen instanceof CreateWorldScreen) { + for (AbstractWidget candidate : widgets(minecraft.screen)) { + if (isWorldSettingsButton(candidate)) worldSettingsButton = candidate; + } + } + if (worldSettingsButton == null && worldCreationButtons != null) { + for (GuiEventListener child : worldCreationButtons) { + if (child instanceof AbstractWidget + && isWorldSettingsButton((AbstractWidget) child)) { + worldSettingsButton = (AbstractWidget) child; + } + } + } + if (minecraft.screen instanceof CreateWorldScreen && worldSettingsButton != null) { + // Forge 40 invokes the target-native OreSpawn button callback directly. + ((Button) worldSettingsButton).onPress(); + nextState(2); + } + break; + case 2: + if (minecraft.screen instanceof OreSpawnWorldSettingsScreen && editorFrames >= 2) { + worldSettingsOpened = true; + validateCaptions((OreSpawnWorldSettingsScreen) minecraft.screen); + validateLongEditorRoundTrip(minecraft, minecraft.screen); + nextState(3); + } + break; + case 3: + if (minecraft.screen instanceof OreSpawnWorldSettingsScreen) { + OreSpawnWorldSettingsScreen root = (OreSpawnWorldSettingsScreen) minecraft.screen; + Button target = nextNavigationButton(root); + if (target == null) { + if (editorRoutes.size() < 5) fail(minecraft, + "Only exercised " + editorRoutes.size() + " editor routes: " + editorRoutes); + ((Screen) root).onClose(); + nextState(5); + } else { + Screen before = minecraft.screen; + target.onPress(); + if (minecraft.screen != before && isOreSpawnEditor(minecraft.screen)) { + editorRoutes.add(minecraft.screen.getClass().getSimpleName()); + editorFrames = 0; + nextState(4); + } + } + } + break; + case 4: + if (isOreSpawnEditor(minecraft.screen) && editorFrames >= 2) { + validateCaptions(minecraft.screen); + minecraft.screen.onClose(); + nextState(3); + } + break; + case 5: + if (minecraft.screen instanceof CreateWorldScreen) { + createWorld(minecraft); + nextState(6); + } + break; + case 6: + if (minecraft.level != null && minecraft.player != null && firstWorldFrames >= 8 + && stateTicks >= 100) { + stopIntegratedServer(minecraft); + nextState(7); + } + break; + case 7: + if (minecraft.level == null && !minecraft.hasSingleplayerServer() && stateTicks >= 20) { + minecraft.loadLevel(WORLD_DIRECTORY); + nextState(8); + } + break; + case 8: + if (minecraft.level != null && minecraft.player != null && reloadWorldFrames >= 8 + && stateTicks >= 100) { + stopIntegratedServer(minecraft); + nextState(9); + } + break; + case 9: + if (minecraft.level == null && !minecraft.hasSingleplayerServer()) { + writeMarker(); + minecraft.stop(); + nextState(10); + } + break; + default: + break; + } + } catch (RuntimeException | IOException failure) { + fail(minecraft, failure.toString()); + } + } + + private Button nextNavigationButton(OreSpawnWorldSettingsScreen root) { + for (AbstractWidget widget : widgets(root)) { + if (!(widget instanceof Button) || widget instanceof CycleButton) 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.contains("recommended")) continue; + return button; + } + return null; + } + + private static void validateCaptions(Screen screen) { + for (AbstractWidget widget : widgets(screen)) { + String caption = ChatFormatting.stripFormatting(widget.getMessage().getString()); + if (caption == null || caption.trim().isEmpty() + || caption.contains("options.generic_value") + || caption.startsWith("button.orespawn.") + || caption.startsWith("option.orespawn.")) { + throw new IllegalStateException("Invalid client caption: " + widget.getMessage()); + } + } + } + + private void validateLongEditorRoundTrip(Minecraft minecraft, Screen parent) { + JsonObject root = WorldGeologyProfile.recommended(true).rootCopy(); + JsonObject ores = new JsonObject(); + JsonObject ore = new JsonObject(); + ore.addProperty("enabled", true); + ore.addProperty("block", "minecraft:diamond_ore"); + JsonObject oreDimensions = new JsonObject(); + JsonObject oreRule = new JsonObject(); + oreRule.addProperty("enabled", true); + oreRule.addProperty("min_y", 0); + oreRule.addProperty("max_y", 64); + oreRule.addProperty("frequency", 1.0D); + oreRule.addProperty("quantity", 8); + oreRule.addProperty("discard_chance_on_air_exposure", 0.0D); + oreRule.addProperty("pattern", "vein"); + oreRule.addProperty("height_distribution", "uniform"); + oreRule.addProperty("spread", 8); + oreRule.addProperty("vertical_spread", 4); + oreRule.addProperty("node_size", 4); + oreRule.add("host_families", new JsonArray()); + oreRule.add("host_blocks", values( + "example:ore_host_block_identifier_longer_than_thirty_two_characters")); + oreRule.add("host_tags", values( + "forge:ore_host_tag_identifier_longer_than_thirty_two_characters", + "forge:second_ore_host_tag_in_the_same_comma_separated_list")); + oreDimensions.add("minecraft:overworld", oreRule); + ore.add("dimensions", oreDimensions); + ores.add("example:long_editor_ore", ore); + root.add("ores", ores); + + JsonObject deposits = new JsonObject(); + JsonObject deposit = new JsonObject(); + deposit.addProperty("enabled", true); + deposit.addProperty("block", "minecraft:water"); + JsonObject fluidDimensions = new JsonObject(); + JsonObject fluidRule = new JsonObject(); + fluidRule.addProperty("enabled", true); + fluidRule.addProperty("min_y", 0); + fluidRule.addProperty("max_y", 48); + fluidRule.addProperty("frequency", 0.08D); + fluidRule.addProperty("min_radius", 5); + fluidRule.addProperty("max_radius", 12); + fluidRule.addProperty("min_vertical_radius", 2); + fluidRule.addProperty("max_vertical_radius", 5); + fluidRule.addProperty("max_lobes", 4); + fluidRule.addProperty("min_solid_cover", 2); + fluidRule.addProperty("min_solid_shell", 1); + fluidRule.add("host_families", new JsonArray()); + fluidRule.add("host_blocks", values( + "example:fluid_host_block_identifier_longer_than_thirty_two_characters")); + fluidRule.add("host_tags", values( + "forge:fluid_host_tag_identifier_longer_than_thirty_two_characters", + "forge:second_fluid_host_tag_in_the_same_comma_separated_list")); + fluidRule.add("biome_ids", values( + "example:included_biome_identifier_longer_than_thirty_two_characters")); + fluidRule.add("excluded_biome_ids", values( + "example:excluded_biome_identifier_longer_than_thirty_two_characters")); + fluidRule.add("biome_dictionary", values( + "INCLUDED_DICTIONARY_VALUE_LONGER_THAN_THIRTY_TWO_CHARACTERS", + "SECOND_INCLUDED_DICTIONARY_VALUE_IN_THE_COMMA_LIST")); + fluidRule.add("excluded_biome_dictionary", values( + "EXCLUDED_DICTIONARY_VALUE_LONGER_THAN_THIRTY_TWO_CHARACTERS")); + fluidRule.add("geomes", new JsonObject()); + fluidDimensions.add("minecraft:overworld", fluidRule); + deposit.add("dimensions", fluidDimensions); + deposits.add("example:long_editor_deposit", deposit); + root.add("fluid_deposits", deposits); + // Keep the synthetic profile in the editor's canonical shape so this + // assertion is about preservation of the eight long text fields rather + // than the session adding an unrelated optional empty section. + root.add("geomes", new JsonObject()); + + Object session = newEditorSession(root); + String before = editorSessionRoot(session); + + Screen oreScreen = newDimensionScreen("OreDimensionScreen", parent, session, + "example:long_editor_ore", "minecraft:overworld"); + initializeScreen(oreScreen, minecraft); + pressDone(oreScreen); + + Screen fluidScreen = newDimensionScreen("FluidDepositDimensionScreen", parent, session, + "example:long_editor_deposit", "minecraft:overworld"); + initializeScreen(fluidScreen, minecraft); + pressDone(fluidScreen); + + String after = editorSessionRoot(session); + if (!before.equals(after)) { + throw new IllegalStateException("Opening and saving long editor values changed profile JSON\nBefore: " + + before + "\nAfter: " + after); + } + longEditorRoundTrip = true; + } + + private static Object newEditorSession(JsonObject root) { + try { + Class sessionClass = Class.forName( + "zone.moddev.mc.orespawn.client.GeologyEditorSession"); + java.lang.reflect.Constructor constructor = sessionClass.getDeclaredConstructor( + WorldGeologyProfile.class); + constructor.setAccessible(true); + return constructor.newInstance(WorldGeologyProfile.recommended(true).withRoot(root)); + } catch (ReflectiveOperationException failure) { + throw new IllegalStateException("Could not create the target-native editor session", failure); + } + } + + private static Screen newDimensionScreen(String simpleName, Screen parent, Object session, + String ruleId, String dimensionId) { + try { + Class sessionClass = session.getClass(); + Class screenClass = Class.forName( + "zone.moddev.mc.orespawn.client." + simpleName); + java.lang.reflect.Constructor constructor = screenClass.getDeclaredConstructor( + Screen.class, sessionClass, String.class, String.class); + constructor.setAccessible(true); + return (Screen) constructor.newInstance(parent, session, ruleId, dimensionId); + } catch (ReflectiveOperationException failure) { + throw new IllegalStateException("Could not create target-native editor " + simpleName, failure); + } + } + + private static String editorSessionRoot(Object session) { + try { + Method root = session.getClass().getDeclaredMethod("root"); + root.setAccessible(true); + return root.invoke(session).toString(); + } catch (ReflectiveOperationException failure) { + throw new IllegalStateException("Could not read the target-native editor session", failure); + } + } + + private static JsonArray values(String... entries) { + JsonArray result = new JsonArray(); + for (String entry : entries) result.add(new JsonPrimitive(entry)); + return result; + } + + private static void initializeScreen(Screen screen, Minecraft minecraft) { + for (Method method : Screen.class.getDeclaredMethods()) { + Class[] parameters = method.getParameterTypes(); + if (parameters.length != 3 || parameters[0] != Minecraft.class + || parameters[1] != int.class || parameters[2] != int.class + || method.getReturnType() != void.class) continue; + try { + method.setAccessible(true); + method.invoke(screen, minecraft, 640, 480); + return; + } catch (ReflectiveOperationException failure) { + throw new IllegalStateException("Could not initialize target-native editor", failure); + } + } + throw new IllegalStateException("Could not locate Forge 40 Screen initialization method"); + } + + private static void pressDone(Screen screen) { + for (AbstractWidget widget : widgets(screen)) { + if (!(widget instanceof Button)) continue; + String caption = ChatFormatting.stripFormatting(((Button) widget).getMessage().getString()); + if ("done".equalsIgnoreCase(caption)) { + ((Button) widget).onPress(); + return; + } + } + throw new IllegalStateException("Editor did not expose its Done action: " + + screen.getClass().getSimpleName()); + } + + private static boolean isOreSpawnEditor(Screen screen) { + return screen != null && screen.getClass().getName().startsWith( + "zone.moddev.mc.orespawn.client."); + } + + private static boolean isWorldSettingsButton(AbstractWidget widget) { + return widget instanceof Button + && ChatFormatting.stripFormatting(widget.getMessage().getString()) + .toLowerCase(java.util.Locale.ROOT).contains("orespawn"); + } + + private static void createWorld(Minecraft minecraft) { + RegistryAccess.Frozen registries = RegistryAccess.BUILTIN.get(); + WorldGenSettings generator = WorldGenSettings.makeDefault(registries) + .withSeed(false, OptionalLong.of(0L)); + LevelSettings settings = new LevelSettings("OreSpawn Client Smoke", GameType.CREATIVE, + false, Difficulty.NORMAL, true, new GameRules(), DataPackConfig.DEFAULT); + minecraft.createLevel(WORLD_DIRECTORY, settings, registries, generator); + } + + 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); + } + return result; + } + + private static void stopIntegratedServer(Minecraft minecraft) { + // Match Forge 40's target-native disconnect path. clearLevel(Screen) clears + // the integrated-server state as well as the client world; loadWorld(null) only + // swaps the client world on this target and would leave reload stuck. + if (minecraft.level != null) minecraft.level.disconnect(); + minecraft.clearLevel(new TitleScreen()); + } + + private void writeMarker() throws IOException { + Properties values = new Properties(); + values.setProperty("world_settings_opened", Boolean.toString(worldSettingsOpened)); + values.setProperty("long_editor_roundtrip", Boolean.toString(longEditorRoundTrip)); + values.setProperty("editor_routes", Integer.toString(editorRoutes.size())); + values.setProperty("editor_classes", editorRoutes.toString()); + values.setProperty("first_world_rendered", Boolean.toString(firstWorldFrames >= 8)); + values.setProperty("reload_rendered", Boolean.toString(reloadWorldFrames >= 8)); + values.setProperty("world_directory", WORLD_DIRECTORY); + try (FileOutputStream output = new FileOutputStream(new File("client-smoke-pass.properties"))) { + values.store(output, "OreSpawn Forge 1.18.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/mods.toml b/src/clientIntegrationTest/resources/META-INF/mods.toml new file mode 100644 index 00000000..6dfdfba7 --- /dev/null +++ b/src/clientIntegrationTest/resources/META-INF/mods.toml @@ -0,0 +1,30 @@ +modLoader="javafml" +loaderVersion="[37,)" +license="LGPL-2.1" + +[[mods]] +modId="clientprobe" +version="1" +displayName="OreSpawn Client Probe" +description='''Build-only OreSpawn client editor and world reload fixture.''' + +[[dependencies.clientprobe]] +modId="forge" +mandatory=true +versionRange="[37,)" +ordering="NONE" +side="CLIENT" + +[[dependencies.clientprobe]] +modId="orespawn" +mandatory=true +versionRange="[4.0.6,5.0.0)" +ordering="AFTER" +side="CLIENT" + +[[dependencies.clientprobe]] +modId="minecraft" +mandatory=true +versionRange="[1.18.2,1.19)" +ordering="NONE" +side="CLIENT" diff --git a/src/clientIntegrationTest/resources/pack.mcmeta b/src/clientIntegrationTest/resources/pack.mcmeta new file mode 100644 index 00000000..152b6022 --- /dev/null +++ b/src/clientIntegrationTest/resources/pack.mcmeta @@ -0,0 +1,8 @@ +{ + "pack": { + "description": "OreSpawn client qualification fixture", + "forge:resource_pack_format": 8, + "forge:data_pack_format": 9, + "pack_format": 9 + } +} 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 8adddfaf..139a3360 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/FluidDepositDimensionScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/FluidDepositDimensionScreen.java @@ -161,7 +161,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, new TextComponent(key)); - box.setValue(value); box.setMaxLength(32); + box.setMaxLength(32); box.setValue(value); placementWidgets.add(OreSpawnScreenLayout.explain(this, addRenderableWidget(box), placementHelp(key))); return box; @@ -170,7 +170,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, new TextComponent(key)); - box.setValue(value); box.setMaxLength(1024); + box.setMaxLength(1024); box.setValue(value); hostWidgets.add(OreSpawnScreenLayout.explain(this, addRenderableWidget(box), "tooltip.orespawn." + key)); return box; @@ -180,7 +180,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, new TextComponent(key)); - box.setValue(value); box.setMaxLength(1024); + box.setMaxLength(1024); box.setValue(value); biomeWidgets.add(OreSpawnScreenLayout.explain(this, addRenderableWidget(box), "tooltip.orespawn.fluid." + key)); return box; 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 fbfda917..dec1b6a3 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/OreDimensionScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreDimensionScreen.java @@ -240,8 +240,8 @@ protected void init() { private EditBox addPlacementField(int x, int y, String key, String value) { EditBox box = new EditBox(font, x, y, columnWidth, 20, new TextComponent(key)); - box.setValue(value); box.setMaxLength(32); + box.setValue(value); OreSpawnScreenLayout.explain(this, box, placementHelp(key)); placementWidgets.add(addRenderableWidget(box)); return box; @@ -253,8 +253,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, new TextComponent(key)); - box.setValue(value); box.setMaxLength(1024); + box.setValue(value); OreSpawnScreenLayout.explain(this, box, "tooltip.orespawn." + key); hostWidgets.add(addRenderableWidget(box)); return box; @@ -262,8 +262,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, new TextComponent(key)); - box.setValue(value); box.setMaxLength(32); + box.setValue(value); OreSpawnScreenLayout.explain(this, box, "tooltip.orespawn.ore." + key); patternWidgets.add(addRenderableWidget(box)); return box; diff --git a/src/main/java/zone/moddev/mc/orespawn/documentation/DocumentationExporter.java b/src/main/java/zone/moddev/mc/orespawn/documentation/DocumentationExporter.java index b8192939..54a9de16 100644 --- a/src/main/java/zone/moddev/mc/orespawn/documentation/DocumentationExporter.java +++ b/src/main/java/zone/moddev/mc/orespawn/documentation/DocumentationExporter.java @@ -30,6 +30,7 @@ public final class DocumentationExporter { "MIGRATION.md", "TROUBLESHOOTING.md", "AGENTS.md", + "VERSIONS.md", "examples/examplemod-orespawn.json", "examples/orespawn-global.json", "examples/orespawn-world.json", diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/BakedGeomeConfig.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/BakedGeomeConfig.java index 2ab8ec77..a8790288 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/BakedGeomeConfig.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/BakedGeomeConfig.java @@ -35,6 +35,7 @@ public final class BakedGeomeConfig { private final Map biomeWeights; private final Map biomeWeightsById; private final double[] fallbackWeights; + private final RockEntry[] rocks; private final BlockState[] rockStates; private final Set sedimentaryBlocks; private final Set oreReplaceableBlocks; @@ -45,6 +46,9 @@ public final class BakedGeomeConfig { private WeightedBlockPicker[][][] legacyRockPickers; private byte[] stableFamilyChoices; private int[] stableRockChoices; + private int[][] familyRockIndexes; + private double[][][] stableRockLogWeights; + private double[][][] stableRockPriorities; BakedGeomeConfig(GeomeDefinition[] geomes, double geomeScale, double biomeInfluence, double regionalNoiseInfluence, double boundaryNoiseInfluence, Map biomeWeights, @@ -72,6 +76,7 @@ public final class BakedGeomeConfig { noiseOffsetZ[i] = -((i + 1) * 6151); } + this.rocks = rocks.clone(); rockStates = new BlockState[rocks.length]; for (int i = 0; i < rocks.length; i++) { rockStates[i] = rocks[i].state; @@ -152,15 +157,69 @@ RockFamily pickFamily(int geomeIndex, int y, int formationValue, int diversitySl return RockFamily.SEDIMENTARY; } + RockFamily pickStableFamilyAtWorldY(int geomeIndex, int worldY, int formationY, + int formationValue, int diversitySlot) { + RockFamily preferred = pickFamily(geomeIndex, formationY, formationValue, diversitySlot); + if (hasEligibleStableRock(geomeIndex, preferred, worldY, formationY)) { + return preferred; + } + + int bucket = formationValue & 0xFF; + int boundedFormationY = clampStableValue(formationY); + double bestScore = Double.NEGATIVE_INFINITY; + RockFamily bestFamily = preferred; + for (RockFamily family : RockFamily.values()) { + if (!hasEligibleStableRock(geomeIndex, family, worldY, formationY)) { + continue; + } + double weight = Math.pow(geomes[geomeIndex].familyWeights[family.ordinal()], 2.5D) + * familyDepthWeight(family, boundedFormationY); + if (weight <= 0.0D) { + continue; + } + double score = Math.log(weight) + gumbelPriority(bucket, geomeIndex, family.ordinal(), + isStableBucket(bucket, -1), 0x6A09E667F3BCC909L); + if (score > bestScore) { + bestScore = score; + bestFamily = family; + } + } + return bestFamily; + } + public BlockState pickRock(int geomeIndex, RockFamily family, int y, int formationValue) { if (formations.usesStableLayers()) { - int index = stableRockIndex(geomeIndex, family.ordinal(), clampStableY(y), formationValue & 0xFF); - int rockIndex = stableRockChoices[index]; - return rockIndex < 0 ? FALLBACK : rockStates[rockIndex]; + return pickStableRockAtWorldY(geomeIndex, family, y, y, formationValue); } return legacyRockPickers[geomeIndex][family.ordinal()][clampLegacyY(y)].pick(formationValue); } + BlockState pickStableRockAtWorldY(int geomeIndex, RockFamily family, int worldY, + int formationY, int formationValue) { + int yIndex = clampStableY(formationY); + int bucket = formationValue & 0xFF; + int choiceIndex = stableRockIndex(geomeIndex, family.ordinal(), yIndex, bucket); + int selectedRock = stableRockChoices[choiceIndex]; + if (isEligibleStableRock(geomeIndex, selectedRock, worldY, yIndex)) { + return rockStates[selectedRock]; + } + + double bestScore = Double.NEGATIVE_INFINITY; + int bestRock = -1; + for (int rockIndex : familyRockIndexes[family.ordinal()]) { + if (!isEligibleStableRock(geomeIndex, rockIndex, worldY, yIndex)) { + continue; + } + double score = stableRockLogWeights[geomeIndex][rockIndex][yIndex] + + stableRockPriorities[geomeIndex][rockIndex][bucket]; + if (score > bestScore) { + bestScore = score; + bestRock = rockIndex; + } + } + return bestRock < 0 ? FALLBACK : rockStates[bestRock]; + } + public String geomeName(int geomeIndex) { return geomes[geomeIndex].name; } @@ -283,9 +342,9 @@ private void buildStablePickers(RockEntry[] rocks) { stableRockChoices = new int[geomes.length * RockFamily.values().length * HEIGHT * FORMATION_BUCKETS]; Arrays.fill(stableRockChoices, -1); int familyCount = RockFamily.values().length; - int[][] familyRockIndexes = groupRockIndexes(rocks); - double[][][] rockLogWeights = new double[geomes.length][rocks.length][HEIGHT]; - double[][][] rockPriorities = new double[geomes.length][rocks.length][FORMATION_BUCKETS]; + familyRockIndexes = groupRockIndexes(rocks); + stableRockLogWeights = new double[geomes.length][rocks.length][HEIGHT]; + stableRockPriorities = new double[geomes.length][rocks.length][FORMATION_BUCKETS]; double[][][] familyLogWeights = new double[geomes.length][familyCount][HEIGHT]; double[][][] familyWeights = new double[geomes.length][familyCount][HEIGHT]; double[][][] familyPriorities = new double[geomes.length][familyCount][FORMATION_BUCKETS]; @@ -294,14 +353,13 @@ private void buildStablePickers(RockEntry[] rocks) { for (int rockIndex = 0; rockIndex < rocks.length; rockIndex++) { RockEntry rock = rocks[rockIndex]; for (int y = MIN_Y; y <= MAX_Y; y++) { - double rawWeight = y < rock.minY || y > rock.maxY ? 0.0D - : rock.weight * rock.geomeWeights[geome] - * depthWeight(y, rock.depthPeak, rock.depthSpread); - rockLogWeights[geome][rockIndex][y - MIN_Y] = rawWeight > 0.0D + double rawWeight = rock.weight * rock.geomeWeights[geome] + * depthWeight(y, rock.depthPeak, rock.depthSpread); + stableRockLogWeights[geome][rockIndex][y - MIN_Y] = rawWeight > 0.0D ? Math.log(rawWeight) : Double.NEGATIVE_INFINITY; } for (int bucket = 0; bucket < FORMATION_BUCKETS; bucket++) { - rockPriorities[geome][rockIndex][bucket] = gumbelPriority(bucket, geome, rockIndex, + stableRockPriorities[geome][rockIndex][bucket] = gumbelPriority(bucket, geome, rockIndex, isStableBucket(bucket, rock.family.ordinal()), 0xBB67AE8584CAA73BL ^ ((long) rock.family.ordinal() << 32)); } @@ -313,7 +371,9 @@ private void buildStablePickers(RockEntry[] rocks) { int yIndex = y - MIN_Y; boolean available = false; for (int rockIndex : familyRockIndexes[familyIndex]) { - if (rockLogWeights[geome][rockIndex][yIndex] != Double.NEGATIVE_INFINITY) { + RockEntry rock = rocks[rockIndex]; + if (y >= rock.minY && y <= rock.maxY + && stableRockLogWeights[geome][rockIndex][yIndex] != Double.NEGATIVE_INFINITY) { available = true; break; } @@ -363,8 +423,12 @@ private void buildStablePickers(RockEntry[] rocks) { double bestRockScore = Double.NEGATIVE_INFINITY; int bestRock = -1; for (int rockIndex : familyRockIndexes[familyIndex]) { - double rockScore = rockLogWeights[geome][rockIndex][yIndex] - + rockPriorities[geome][rockIndex][bucket]; + RockEntry rock = rocks[rockIndex]; + if (y < rock.minY || y > rock.maxY) { + continue; + } + double rockScore = stableRockLogWeights[geome][rockIndex][yIndex] + + stableRockPriorities[geome][rockIndex][bucket]; if (rockScore > bestRockScore) { bestRockScore = rockScore; bestRock = rockIndex; @@ -377,6 +441,25 @@ private void buildStablePickers(RockEntry[] rocks) { } } + private boolean hasEligibleStableRock(int geomeIndex, RockFamily family, int worldY, int formationY) { + int yIndex = clampStableY(formationY); + for (int rockIndex : familyRockIndexes[family.ordinal()]) { + if (isEligibleStableRock(geomeIndex, rockIndex, worldY, yIndex)) { + return true; + } + } + return false; + } + + private boolean isEligibleStableRock(int geomeIndex, int rockIndex, int worldY, int formationYIndex) { + if (rockIndex < 0) { + return false; + } + RockEntry rock = rocks[rockIndex]; + return worldY >= rock.minY && worldY <= rock.maxY + && stableRockLogWeights[geomeIndex][rockIndex][formationYIndex] != Double.NEGATIVE_INFINITY; + } + private void fillBalancedFamilyCycle(int geome, int yIndex, int bucket, double[][][] familyWeights, double[][][] familyPriorities, int[] quotas, int[] remaining, double[] remainders, boolean[] bonusAwarded) { @@ -631,6 +714,10 @@ private static int clampStableY(int y) { return Math.max(MIN_Y, Math.min(MAX_Y, y)) - MIN_Y; } + private static int clampStableValue(int y) { + return Math.max(MIN_Y, Math.min(MAX_Y, y)); + } + private static int clampLegacyY(int y) { return Math.max(0, Math.min(LEGACY_MAX_Y, y)); } diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/BakedTerrainDimension.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/BakedTerrainDimension.java index eabe8b11..4d7e054b 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,7 @@ import net.minecraft.resources.ResourceLocation; import net.minecraft.world.level.Level; import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.state.BlockState; /** Immutable setup-time resolution of one terrain replacement dimension. */ @@ -38,6 +39,10 @@ boolean hasBiomeFilter() { } boolean isReplaceable(BlockState state) { + if (state.isAir() || !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/BiomeFeatureInstaller.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeFeatureInstaller.java index c5e5cc6d..4a0adaf5 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeFeatureInstaller.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeFeatureInstaller.java @@ -57,10 +57,10 @@ private static void install(Biome biome, ResourceKey dimension) { List> underground = step(features, GenerationStep.Decoration.UNDERGROUND_ORES); changed |= VanillaOreFeatureGate.wrapFeatureList(underground); - if (GeomeConfig.hasTerrainReplacement(dimension)) { + boolean terrain = GeomeConfig.hasTerrainReplacement(dimension); + if (terrain) { changed |= StoneReplacer.removeVanillaMatchingStoneFeatures(underground); } - changed |= addUnique(underground, StoneReplacer.placedFeature()); changed |= addUnique(underground, OreSpawnOreGeneration.placedFeature()); changed |= addUnique(underground, FluidDepositFeature.placedFeature()); @@ -68,19 +68,29 @@ private static void install(Biome biome, ResourceKey dimension) { step(features, GenerationStep.Decoration.UNDERGROUND_DECORATION); changed |= VanillaOreFeatureGate.wrapFeatureList(undergroundDecoration); - changed |= installSurfaceStages(features); + changed |= installSurfaceStages(features, terrain, true); if (!changed) return; ORIGINALS.putIfAbsent(biome, original); biome.generationSettings = rebuild(original, features); } - static boolean installSurfaceStages(List>> features) { + static boolean installSurfaceStages(List>> features, + boolean terrain, boolean surfaces) { List> local = step(features, GenerationStep.Decoration.LOCAL_MODIFICATIONS); List> top = step(features, GenerationStep.Decoration.TOP_LAYER_MODIFICATION); - boolean changed = addUnique(local, BiomeSurfaceFeature.placedFeature()); + boolean changed = false; + if (terrain) { + changed |= StoneReplacer.placeUniqueAt(local, StoneReplacer.placedFeature(), 0); + if (surfaces) { + changed |= StoneReplacer.placeUniqueAt(local, + BiomeSurfaceFeature.placedFeature(), 1); + } + } else if (surfaces) { + changed |= addUnique(local, BiomeSurfaceFeature.placedFeature()); + } changed |= addUnique(top, FlatBedrockFeature.placedFeature()); return changed; } diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeSurfaceFeature.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeSurfaceFeature.java index f3a55209..2b4c2e5e 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeSurfaceFeature.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeSurfaceFeature.java @@ -50,11 +50,16 @@ static boolean install(BiomeGenerationSettingsBuilder generation) { if (placedFeature == null) return false; java.util.List> features = generation.getFeatures( GenerationStep.Decoration.LOCAL_MODIFICATIONS); - if (features.stream().anyMatch(existing -> existing.value() == placedFeature.value())) { - return false; + Holder stone = StoneReplacer.placedFeature(); + int stoneIndex = -1; + for (int index = 0; stone != null && index < features.size(); index++) { + if (features.get(index).value() == stone.value()) { + stoneIndex = index; + break; + } } - features.add(placedFeature); - return true; + return StoneReplacer.placeUniqueAt(features, placedFeature, + stoneIndex >= 0 ? stoneIndex + 1 : features.size()); } static Holder placedFeature() { 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 20bba705..51c8cd86 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/Geology.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/Geology.java @@ -112,8 +112,9 @@ public void replaceStoneInChunk(LevelAccessor world, ChunkAccess chunk, BakedTer for (; y >= chunk.getMinBuildHeight(); y--) { cursor.set(x, y, z); BlockState current = chunk.getBlockState(cursor); - if (terrain.isReplaceable(current) - || (realisticCoalLayers && current.getBlock() == Blocks.COAL_ORE)) { + if ((terrain.isReplaceable(current) + || (realisticCoalLayers && current.getBlock() == Blocks.COAL_ORE)) + && chunk.getBlockEntity(cursor) == null) { BlockState replacement = pickReplacement(baseRockVal, geomeBase, y); if (current.equals(replacement)) continue; chunk.setBlockState(cursor, replacement, false); diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeGeology.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeGeology.java index 9f6ad959..352fcd85 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeGeology.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeGeology.java @@ -119,7 +119,8 @@ public void replaceStoneInChunk(LevelAccessor world, ChunkAccess chunk, BakedTer } else { for (int y = surfaceY; y >= chunk.getMinBuildHeight(); y--) { cursor.set(x, y, z); - if (terrain.isReplaceable(chunk.getBlockState(cursor))) { + if (terrain.isReplaceable(chunk.getBlockState(cursor)) + && chunk.getBlockEntity(cursor) == null) { chunk.setBlockState(cursor, pickReplacement(geomeIndex, baseRockValue, formationRegion, x, y, z), false); changed = true; @@ -142,7 +143,6 @@ private boolean replaceStableColumn(ChunkAccess chunk, BlockPos.MutableBlockPos int layerStart = layerIndex * layerThickness; int layerGeome = pickStableLayerGeome(geomeScores, geomeIndex, secondGeome, layerIndex, geomeTransitionPhase); - BlockState replacement = pickStableReplacement(layerGeome, formationRegion, layerIndex); boolean changed = false; cursor.set(x, surfaceY, z); @@ -153,11 +153,12 @@ private boolean replaceStableColumn(ChunkAccess chunk, BlockPos.MutableBlockPos layerStart -= layerThickness; layerGeome = pickStableLayerGeome(geomeScores, geomeIndex, secondGeome, layerIndex, geomeTransitionPhase); - replacement = pickStableReplacement(layerGeome, formationRegion, layerIndex); } cursor.setY(y); - if (terrain.isReplaceable(chunk.getBlockState(cursor))) { - chunk.setBlockState(cursor, replacement, false); + if (terrain.isReplaceable(chunk.getBlockState(cursor)) + && chunk.getBlockEntity(cursor) == null) { + chunk.setBlockState(cursor, + pickStableReplacement(layerGeome, formationRegion, layerIndex, y), false); changed = true; } } @@ -250,7 +251,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 +261,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 +287,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 9a2bc4db..a40aeca6 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java @@ -212,7 +212,7 @@ private static JsonObject convertSpawn(String name, JsonObject source, List 0) ore.add("dimensions", dimensions); @@ -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.118021 Upgrade Report"); + lines.add("OreSpawn 4.0.10.118021 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 125cf08b..56278d73 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java @@ -204,7 +204,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.118021 Upgrade Report"); + lines.add("OreSpawn 4.0.10.118021 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/StoneReplacer.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/StoneReplacer.java index ac90031a..ae6fd829 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/StoneReplacer.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/StoneReplacer.java @@ -66,16 +66,35 @@ public static void onBiomeLoading(BiomeLoadingEvent event) { OreSpawnConfig.placeOreSpawnRock(), GeomeConfig.hasTerrainReplacement(Level.OVERWORLD))) { removeVanillaMatchingStoneFeatures(event); } - if (placedFeature != null) { - event.getGeneration().getFeatures(GenerationStep.Decoration.UNDERGROUND_ORES) - .add(placedFeature); - } + install(event.getGeneration()); + } + + static boolean install(net.minecraftforge.common.world.BiomeGenerationSettingsBuilder generation) { + return placeUniqueAt(generation.getFeatures( + GenerationStep.Decoration.LOCAL_MODIFICATIONS), placedFeature, 0); } 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/resources/META-INF/accesstransformer.cfg b/src/main/resources/META-INF/accesstransformer.cfg index d450409f..5f08b50f 100644 --- a/src/main/resources/META-INF/accesstransformer.cfg +++ b/src/main/resources/META-INF/accesstransformer.cfg @@ -1,6 +1,6 @@ -public-f net.minecraft.world.level.chunk.ChunkGenerator f_62137_ # biomeSource -public-f net.minecraft.world.level.chunk.ChunkGenerator f_62138_ # runtimeBiomeSource -public-f net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator f_188607_ # globalFluidPicker -public-f net.minecraft.world.level.biome.Biome f_47438_ # generationSettings -public net.minecraft.world.level.biome.Biome f_47437_ # climateSettings -public-f net.minecraft.world.level.levelgen.feature.configurations.SpringConfiguration f_68128_ # validBlocks +public-f net.minecraft.world.level.chunk.ChunkGenerator biomeSource +public-f net.minecraft.world.level.chunk.ChunkGenerator runtimeBiomeSource +public-f net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator globalFluidPicker +public-f net.minecraft.world.level.biome.Biome generationSettings +public net.minecraft.world.level.biome.Biome climateSettings +public-f net.minecraft.world.level.levelgen.feature.configurations.SpringConfiguration validBlocks diff --git a/src/main/resources/META-INF/mods.toml b/src/main/resources/META-INF/mods.toml index 3c79e06f..d00a284d 100644 --- a/src/main/resources/META-INF/mods.toml +++ b/src/main/resources/META-INF/mods.toml @@ -6,7 +6,7 @@ # The name of the mod loader type to load - for regular FML @Mod mods it should be javafml modLoader="javafml" #mandatory # A version range to match for said mod loader - for regular FML @Mod it will be the forge version -loaderVersion="[40,)" #mandatory This is typically bumped every Minecraft version by Forge. See our download page for lists of versions. +loaderVersion="${loader_version_range}" #mandatory This is typically bumped every Minecraft version by Forge. See our download page for lists of versions. # The license for you mod. This is mandatory metadata and allows for easier comprehension of your redistributive properties. # Review your options at https://choosealicense.com/. All rights reserved is the default copyright stance, and is thus the default here. license="LGPL-2.1" @@ -17,7 +17,7 @@ issueTrackerURL="https://github.com/SkyBlade1978/OreSpawn/issues" #optional # The modid of the mod modId="orespawn" #mandatory # The version number of the mod -version="${file.jarVersion}" #mandatory +version="${version}" #mandatory # A display name for the mod displayName="MMD OreSpawn" #mandatory # A URL to query for updates for this mod. See the JSON update specification https://docs.minecraftforge.net/en/latest/misc/updatechecker/ @@ -47,7 +47,7 @@ modId="forge" #mandatory # Does this dependency have to exist - if not, ordering below must be specified mandatory=true #mandatory # The version range of the dependency -versionRange="[40,)" #mandatory +versionRange="${forge_version_range}" #mandatory # An ordering relationship for the dependency - BEFORE or AFTER required if the dependency is not mandatory # BEFORE - This mod is loaded BEFORE the dependency # AFTER - This mod is loaded AFTER the dependency @@ -59,6 +59,6 @@ side="BOTH" modId="minecraft" mandatory=true # This version range declares a minimum of the current minecraft version up to but not including the next major version -versionRange="[1.18.2,1.19)" +versionRange="${minecraft_version_range}" ordering="NONE" side="BOTH" 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..9b453a4a --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/ReleaseWorkflowContractTest.java @@ -0,0 +1,61 @@ +package zone.moddev.mc.orespawn; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Properties; + +import org.junit.jupiter.api.Test; + +class ReleaseWorkflowContractTest { + @Test + void usesOreSpawnSpecificMavenNamespace() throws Exception { + Properties properties = new Properties(); + try (InputStream input = Files.newInputStream(Paths.get("gradle.properties"))) { + properties.load(input); + } + assertEquals("zone.moddev.mc.orespawn", properties.getProperty("mod_group")); + } + + @Test + void verifiesGeneratedMavenCoordinatesBeforeCheckAndPublication() throws Exception { + Path buildFile = Paths.get("build.gradle"); + String build = new String(Files.readAllBytes(buildFile), StandardCharsets.UTF_8); + assertTrue(build.contains("tasks.register('verifyMavenCoordinates')")); + assertTrue(build.contains("generatePomFileForMavenJavaPublication")); + assertTrue(build.contains("dependsOn tasks.named('verifyMavenCoordinates')")); + assertTrue(build.contains("expectedMavenCoordinate")); + } + + @Test + void hostedWorkflowsUseThePinnedTemurinJdkForGradleAndCompilation() throws Exception { + for (String workflow : new String[] { "ci.yml", "codeql-analysis.yml" }) { + String text = new String(Files.readAllBytes( + Paths.get(".github", "workflows", workflow)), StandardCharsets.UTF_8); + assertEquals(1, occurrences(text, "actions/setup-java@"), + workflow + " must not replace the pinned toolchain with a second JDK"); + assertTrue(text.contains("distribution: temurin"), + workflow + " must use Temurin"); + assertTrue(text.contains("java-version: '17.0.1+12'"), + workflow + " must install the exact qualified Java runtime"); + assertFalse(text.contains("distribution: microsoft"), + workflow + " must not replace the exact Temurin Gradle runtime"); + } + } + + 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/WorldgenProviderTest.java b/src/test/java/zone/moddev/mc/orespawn/api/WorldgenProviderTest.java index b690f7b1..96fbf8be 100644 --- a/src/test/java/zone/moddev/mc/orespawn/api/WorldgenProviderTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/api/WorldgenProviderTest.java @@ -13,6 +13,33 @@ import net.minecraft.resources.ResourceLocation; class WorldgenProviderTest { + @Test + void terrainHostContractRetainsNaturalSourceOrder() { + ResourceLocation dimension = id("surfaceprobe:the_end"); + WorldgenProvider provider = WorldgenProvider.builder("surfaceprobe", 1) + .terrainDimension(dimension, terrain -> terrain + .hostBlock(id("minecraft:dirt")) + .hostBlock(id("minecraft:grass_block")) + .hostBlock(id("minecraft:coarse_dirt")) + .hostBlock(id("minecraft:podzol")) + .hostBlock(id("minecraft:rooted_dirt")) + .hostBlock(id("minecraft:gravel")) + .hostBlock(id("minecraft:sand")) + .hostBlock(id("minecraft:red_sand")) + .hostBlock(id("minecraft:clay")) + .hostBlock(id("minecraft:terracotta"))) + .build(); + + assertEquals("[\"minecraft:dirt\",\"minecraft:grass_block\"," + + "\"minecraft:coarse_dirt\",\"minecraft:podzol\"," + + "\"minecraft:rooted_dirt\",\"minecraft:gravel\"," + + "\"minecraft:sand\",\"minecraft:red_sand\"," + + "\"minecraft:clay\",\"minecraft:terracotta\"]", + provider.toJson().getAsJsonObject("terrain_dimensions") + .getAsJsonObject(dimension.toString()) + .getAsJsonArray("host_blocks").toString()); + } + @Test void serializesTypedSchemaFourProvider() { ResourceLocation overworld = id("minecraft:overworld"); 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..fe481e45 --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/client/ClientTextFieldPersistenceTest.java @@ -0,0 +1,55 @@ +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.TextComponent; + +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, + new TextComponent("host_blocks")); + + field.setMaxLength(1024); + field.setValue(value); + + assertEquals(value, field.getValue()); + } +} 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 abcf5faf..94f0625a 100644 --- a/src/test/java/zone/moddev/mc/orespawn/worldgen/BiomeSurfaceFeatureOrderTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/BiomeSurfaceFeatureOrderTest.java @@ -24,27 +24,33 @@ static void bootstrapMinecraftRegistries() { @Test void eventAndRuntimeInstallersKeepSurfacesEarlyAndBedrockLast() { + StoneReplacer.registerConfiguredFeature(); BiomeSurfaceFeature.registerConfiguredFeature(); FlatBedrockFeature.registerConfiguredFeature(); BiomeGenerationSettingsBuilder generation = new BiomeGenerationSettingsBuilder( net.minecraft.world.level.biome.BiomeGenerationSettings.EMPTY); + assertTrue(StoneReplacer.install(generation)); assertTrue(BiomeSurfaceFeature.install(generation)); Holder surfaces = BiomeSurfaceFeature.placedFeature(); 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(top.stream().anyMatch(feature -> feature.value() == surfaces.value())); List>> runtime = new ArrayList<>(); - assertTrue(BiomeFeatureInstaller.installSurfaceStages(runtime)); + assertTrue(BiomeFeatureInstaller.installSurfaceStages(runtime, true, true)); List> runtimeLocal = step(runtime, GenerationStep.Decoration.LOCAL_MODIFICATIONS); List> runtimeTop = step(runtime, GenerationStep.Decoration.TOP_LAYER_MODIFICATION); - assertTrue(runtimeLocal.stream().anyMatch(feature -> feature.value() == surfaces.value())); + assertTrue(runtimeLocal.size() >= 2); + assertTrue(runtimeLocal.get(0).value() == StoneReplacer.placedFeature().value()); + assertTrue(runtimeLocal.get(1).value() == surfaces.value()); assertFalse(runtimeLocal.stream().anyMatch(feature -> feature.value() == bedrock.value())); assertTrue(runtimeTop.stream().anyMatch(feature -> feature.value() == bedrock.value())); assertFalse(runtimeTop.stream().anyMatch(feature -> feature.value() == surfaces.value())); diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyGeologyParityTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyGeologyParityTest.java index 9bdef311..eac3160a 100644 --- a/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyGeologyParityTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyGeologyParityTest.java @@ -37,7 +37,7 @@ static void bootstrapMinecraftRegistries() { } @Test - void cyanoSamplerMatchesPublishedMineralogy540AndSealedVectors() throws Exception { + void cyanoSamplerMatchesPublishedMineralogy530AndSealedVectors() throws Exception { Block[] igneous = { Blocks.STONE, Blocks.OBSIDIAN, Blocks.NETHERRACK }; Block[] metamorphic = { Blocks.COBBLESTONE, Blocks.MOSSY_COBBLESTONE }; Block[] sedimentary = { Blocks.SANDSTONE, Blocks.GRAVEL, Blocks.COAL_ORE, @@ -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/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 85f9e9c6..590ace0c 100644 --- a/src/test/java/zone/moddev/mc/orespawn/worldgen/StoneReplacerTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/StoneReplacerTest.java @@ -3,8 +3,17 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.Collections; +import java.util.LinkedHashSet; + import org.junit.jupiter.api.Test; +import net.minecraft.core.Registry; +import net.minecraft.resources.ResourceKey; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.biome.Biome.BiomeCategory; class StoneReplacerTest { @@ -27,4 +36,22 @@ void nonOverworldBiomesAreNeverChanged() { assertFalse(TerrainFeaturePolicy.shouldRemoveVanillaMatchingStoneFeatures( BiomeCategory.THEEND, 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(Registry.DIMENSION_REGISTRY, + new ResourceLocation("surfaceprobe", "the_end")), + Collections.emptySet(), Collections.emptySet(), hosts); + + assertFalse(terrain.isReplaceable(Blocks.AIR.defaultBlockState())); + assertFalse(terrain.isReplaceable(Blocks.WATER.defaultBlockState())); + assertFalse(terrain.isReplaceable(Blocks.BEDROCK.defaultBlockState())); + assertTrue(terrain.isReplaceable(Blocks.DIRT.defaultBlockState())); + } }