diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7fc461d3..772269cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,24 +1,77 @@ -name: CI +name: OreSpawn 1.16.5 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.16.5 + - 'feature/**' + pull_request: + branches: + - master-1.16.5 + +permissions: + contents: read + +concurrency: + group: orespawn-1.16-${{ 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 Java 8 toolchain + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '8.0.502+7' + + - name: Install Java 17 for Gradle + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: microsoft + java-version: '17' + + - 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.16.5-${{ github.sha }} + if-no-files-found: error + retention-days: 30 + path: | + build/libs/OreSpawn-4.0.9.116051.jar + build/libs/OreSpawn-4.0.9.116051-sources.jar + build/libs/OreSpawn-4.0.9.116051-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.16.5-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..23d19749 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -1,73 +1,55 @@ -# 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.16.5 + - 'feature/**' + pull_request: + branches: + - master-1.16.5 + 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 Java 8 toolchain + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '8.0.502+7' + + - name: Install Java 17 for Gradle + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: microsoft + java-version: '17' + + - 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..fc66575a 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.16.5 + - 'feature/**' + pull_request: + branches: + - master-1.16.5 + +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..f0d739f4 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 diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 31da5395..9446c76c 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,23 @@ +Version 4.0.9.116051 + +* 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.116051 + +* 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.16.5. + Version 4.0.6.116051 * Adopt target-qualified four-component versions so Minecraft and loader compatibility can be identified from the mod version. diff --git a/Jenkinsfile b/Jenkinsfile deleted file mode 100644 index a3081bfe..00000000 --- a/Jenkinsfile +++ /dev/null @@ -1,128 +0,0 @@ -pipeline { - agent any - environment { - GRADLE_OPTS = '-Dorg.gradle.caching=true -Dorg.gradle.configureondemand=true -Dorg.gradle.warning.mode=all' -// JAVA_OPTS = '' - } - options { - ansiColor('xterm') - } - tools { -// git 'Git' - gradle 'Gradle 4.9' - jdk 'oraclejdk8' - } - stages { - stage('prebuild') { - steps { - sh 'rm -rf build/libs' - sh 'chmod +x gradlew' - sh 'java -version' - sh 'gradle -version' - sh './gradlew -version' - sh 'export' - } - } - stage('CIWorkspace') { - steps { - withGradle { - sh './gradlew clean setupCiWorkspace -S' - } - } - } - stage('build') { - steps { - withGradle { - sh './gradlew build -S' - } - } - } - stage('test') { - steps { - withGradle { - sh './gradlew test -S' - } - } - } - stage('publish') { - steps { - withCredentials([file(credentialsId: 'secret.json', variable: 'SECRET_FILE')]) { - withGradle { - sh './gradlew publish -S' - } - } - } - } - stage('CurseForge') { - steps { - withCredentials([file(credentialsId: 'secret.json', variable: 'SECRET_FILE')]) { - withGradle { - sh './gradlew -x publish curseforge -S' - } - } - } - } - stage('SonarQube') { - tools { - jdk "oraclejdk11" - } - environment { - scannerHome = tool 'SonarQube' - } - steps { -// withCredentials([file(credentialsId: 'secret.json', variable: 'SECRET_FILE')]) { -// withGradle { -// sh './gradlew sonarqube -S' -// } -// } - withSonarQubeEnv(installationName: 'SonarCloud', , envOnly: false) { - sh "${scannerHome}/bin/sonar-scanner -Dsonar.java.jdkHome=${JAVA_HOME}" - } - } - } - stage('postbuild') { - steps { - archiveArtifacts artifacts: 'build/libs/*.jar', followSymlinks: false - javadoc javadocDir: 'build/docs/javadoc', keepAll: false - fingerprint 'build/libs/*.zip' - junit allowEmptyResults: true, testResults: '**/build/test-results/junit-platform/*.xml' - jacoco classPattern: '**/build/classes/java', execPattern: '**/build/jacoco/**.exec', sourceInclusionPattern: '**/*.java', sourcePattern: '**/src/main/java' - findBuildScans() - recordIssues(tools: [java()]) - recordIssues(tools: [javaDoc()]) -// if (fileExists('')) { -// recordIssues(tools: [errorProne(pattern: 'ReportFilePattern', reportEncoding: 'UTF-8')]) -// } else { -// echo 'No ErrorProne report available' -// } - if (fileExists('**/build/reports/checkstyle/*.xml')) { - recordIssues(tools: [checkStyle(pattern: '**/build/reports/checkstyle/*.xml')]) - } else { - echo 'No CheckStyle report available' - } - if (fileExists('**/build/reports/pmd/*.xml')) { - recordIssues(tools: [pmdParser(pattern: '**/build/reports/pmd/*.xml')]) - } else { - echo 'No PMD report available' - } - if (fileExists('*/build/reports/findbugs/*.xml')) { - recordIssues(tools: [findBugs(pattern: '*/build/reports/findbugs/*.xml', useRankAsPriority: true)]) - } else { - echo 'No FindBugs report available' - } - } - when { expression { fileExists('**/build/reports/spotbugs/*.xml') } } - steps { - recordIssues(tools: [spotBugs(pattern: '**/build/reports/spotbugs/*.xml', useRankAsPriority: true)]) - } - when { expression { fileExists('**/build/test-results/junit-platform/*.xml') } } - steps { - recordIssues(tools: [junitParser(pattern: '**/build/test-results/junit-platform/*.xml')]) - } - when { expression { fileExists('**/sonar-report.json') } } - steps { - recordIssues(tools: [sonarQube(pattern: '**/sonar-report.json')]) - } - } - } -} diff --git a/README.md b/README.md index 0fc25308..468d3214 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.16.5)](https://github.com/MinecraftModDevelopmentMods/OreSpawn/actions/workflows/ci.yml?query=branch%3Amaster-1.16.5) + # MMD OreSpawn OreSpawn 4 is a provider-driven world-generation engine for Minecraft 1.16.5. @@ -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.9.116051`: the OreSpawn 4.0.9 +feature set for Minecraft 1.16.5 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 8 from the repository root (the local validation JDK is 1.8.0_221): +Run Gradle with Java 17 from the repository root. Install the exact Temurin +`8.0.502+7` toolchain used to compile production code and test fixtures for +Minecraft 1.16.5; the build rejects a different Java 8 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 5 project in Eclipse. This branch uses the Gradle 7.3.3 wrapper. +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 36.2.34, official Minecraft +1.16.5 mappings, and pack format 6. Ordinary Eclipse launches exclude tests +and fixtures. Published jars are deterministic, SRG-reobfuscated for the Forge +36 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 365ce4db..425a28c1 100644 --- a/build.gradle +++ b/build.gradle @@ -1,346 +1,292 @@ -buildscript { - repositories { - maven { url = 'https://maven.minecraftforge.net' } - mavenCentral() - } - dependencies { - classpath group: 'net.minecraftforge.gradle', name: 'ForgeGradle', version: '5.1.+', changing: true - } +import groovy.json.JsonSlurper +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.renamer' version '1.1.5' + id 'net.minecraftforge.accesstransformers' version '2.0.0' + id 'net.minecraftforge.gradle' version '7.0.34' } -apply plugin: 'net.minecraftforge.gradle' -apply plugin: 'eclipse' -apply plugin: 'maven-publish' +group = project.mod_group +version = project.mod_version +base.archivesName = 'OreSpawn' -version = mod_version -group = mod_group_id - -archivesBaseName = "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 -// Minecraft 1.16.5 and Forge 36 target Java 8. java { - toolchain.languageVersion = JavaLanguageVersion.of(8) + toolchain { + languageVersion = JavaLanguageVersion.of(8) + 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(8) + vendor = JvmVendorSpec.ADOPTIUM + } + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + 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 - - // 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 - } - } + mainClass = 'net.minecraftforge.userdev.LaunchTesting' + workingDir.convention layout.projectDirectory.dir('run') + 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 + 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/') } - - server { - property 'forge.enabledGameTestNamespaces', mod_id + register('surfaceIntegrationFresh') { + workingDir.convention layout.buildDirectory.dir('surface-integration-run') + systemProperty 'surfaceprobe.integrationPhase', 'fresh' 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" - } - } - } - - // 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" - } - } } - - 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') { + workingDir.convention layout.buildDirectory.dir('surface-integration-run') + systemProperty 'surfaceprobe.integrationPhase', 'reload' + args '--nogui' } - - ['Fresh', 'Reload'].each { String phase -> - create("surfaceIntegration${phase}") { - parent runs.server - workingDirectory layout.buildDirectory.dir('surface-integration-run').get().asFile - jvmArgs '-Xmx1536m' - 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 configureForge36Run = { JavaExec runTask, String launchTarget -> + runTask.dependsOn prepareForgeRunModClasses + runTask.mainClass.set('net.minecraftforge.userdev.LaunchTesting') + runTask.environment 'target', launchTarget + runTask.environment 'MCP_MAPPINGS', "${mapping_channel}_${mapping_version}" + runTask.environment 'MCP_VERSION', mcp_version + runTask.environment 'FORGE_VERSION', forge_version + runTask.environment 'FORGE_GROUP', 'net.minecraftforge' + runTask.environment 'MC_VERSION', minecraft_version + // Forge 36's exploded-directory locator resolves one physical output per + // mod entry. Give it a merged, build-owned classes/resources directory, + // repeated for the target's legacy duplicate-entry discovery contract. + runTask.environment 'MOD_CLASSES', "${mod_id}%%${forgeRunModClassesDirectory}${File.pathSeparator}" + + "${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) configureForge36Run(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.16.5-5.2.0.jar') +def mineralogy5OracleSha256 = + 'C24203651711BC26436C25F081EE7F2DA2F9239DC19092AA18EA8A9A11A86501' -publishing { - publications { - register('mavenJava', MavenPublication) { - artifact jar - artifact sourcesJar - artifact javadocJar +tasks.register('verifyLegacyFixtures') { + group = 'verification' + description = 'Verifies the sealed Mineralogy 1.16.5 5.2.0 oracle used only by isolated tests.' + inputs.file mineralogy5OracleJar + doLast { + if (!mineralogy5OracleJar.isFile()) { + throw new GradleException("Missing mandatory Mineralogy oracle: ${mineralogy5OracleJar}") } - } - repositories { - maven { - url "file://${project.projectDir}/mcmodsrepo" + MessageDigest digest = MessageDigest.getInstance('SHA-256') + mineralogy5OracleJar.withInputStream { input -> + byte[] buffer = new byte[8192] + for (int read = input.read(buffer); read >= 0; read = input.read(buffer)) { + if (read > 0) digest.update(buffer, 0, read) + } + } + String actual = digest.digest().encodeHex().toString().toUpperCase() + if (actual != mineralogy5OracleSha256) { + throw new GradleException("Mineralogy oracle checksum mismatch: ${actual}") } } } -tasks.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' } -// ForgeGradle 5 resolves the access-transformed mapped Forge dependency into -// build/fg_cache during configuration. Deleting that cache afterward makes a -// single-invocation `clean build` lose its compile classpath, because the -// dependency has already been considered resolved. It is a dependency cache, -// not a produced mod artifact, so retain it while cleaning every other build -// output. -tasks.named('clean', Delete).configure { - setDelete(fileTree(layout.buildDirectory) { - exclude 'fg_cache/**' - }) +tasks.named('compileTestJava', JavaCompile) { dependsOn tasks.named('verifyLegacyFixtures') } +tasks.named('test', Test) { + dependsOn tasks.named('verifyLegacyFixtures') + systemProperty 'orespawn.mineralogy5Oracle', mineralogy5OracleJar.absolutePath } -tasks.named('javadoc', Javadoc).configure { - options.encoding = 'UTF-8' - options.addStringOption('Xdoclint:none', '-quiet') +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}") + } + } + } } -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 116/MinecraftMineralogy/build/libs/Mineralogy-1.16.5-5.2.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 - } +def java8Launcher = javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(8) + vendor = JvmVendorSpec.ADOPTIUM } -def preserveDeveloperWorldgenConfig = tasks.register('preserveDeveloperWorldgenConfig') { +tasks.register('verifyJava8Toolchain') { + 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 = java8Launcher.get().metadata + if (project.java_toolchain_version != '8.0.502+7' + || metadata.vendor.toString() != 'Eclipse Temurin' + || metadata.javaRuntimeVersion != '1.8.0_502-b07') { + 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('verifyJava8Toolchain') +} + +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 @@ -349,6 +295,7 @@ def acceptedForge36LogNoise = [ ~/FML appears to be missing any signature data/, ~/Found multiple arguments for option fml\.mcVersion/, ~/Found multiple arguments for option fml\.forgeVersion/, + ~/\/(?: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 / ] @@ -409,6 +356,7 @@ 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' + + '[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( @@ -463,459 +411,1051 @@ 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(8) - }) - 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_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + 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 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 = '1.8' + targetCompatibility = '1.8' + options.encoding = 'UTF-8' } -def surfaceIntegrationTestModJar = tasks.register('surfaceIntegrationTestModJar', Jar) { - dependsOn compileSurfaceIntegrationTestMod - archiveFileName = 'surfaceprobe.jar' - destinationDirectory = layout.buildDirectory.dir('surface-integration-fixture') - from surfaceIntegrationClasses - from 'src/biomeIntegrationTest/resources' +task surfaceIntegrationTestModJar(type: Jar, dependsOn: compileSurfaceIntegrationTestMod) { + archiveFileName = 'surfaceprobe.jar' + destinationDirectory = file("${buildDir}/surface-integration-fixture") + from surfaceIntegrationClasses + from 'src/biomeIntegrationTest/resources' } -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('''\ +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 -# Minecraft 1.16.5 treats numeric zero as "choose a random seed". This -# non-numeric token has Java String.hashCode() == 0, so the actual seed is 0. level-seed=zsjpxah -level-type=minecraft:normal +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 - } -} - -def surfaceIntegrationProcess = { String phase, dependency -> - tasks.register("surfaceIntegration${phase}Process", Exec) { - group = 'verification' - description = "Runs the ${phase.toLowerCase(Locale.ROOT)} surface-probe server behind a process boundary." - dependsOn dependency, "prepareRunSurfaceIntegration${phase}" - doFirst { - JavaExec runTask = tasks.getByName("runSurfaceIntegration${phase}") as JavaExec - File classpathJar = layout.buildDirectory.file( - "surface-integration-fixture/${phase.toLowerCase(Locale.ROOT)}-classpath.jar").get().asFile - classpathJar.parentFile.mkdirs() - java.util.jar.Manifest manifest = new java.util.jar.Manifest() - manifest.mainAttributes.put(java.util.jar.Attributes.Name.MANIFEST_VERSION, '1.0') - manifest.mainAttributes.put(java.util.jar.Attributes.Name.CLASS_PATH, - runTask.classpath.files.collect { File entry -> entry.toURI().toASCIIString() }.join(' ')) - classpathJar.withOutputStream { output -> - new java.util.jar.JarOutputStream(output, manifest).close() + new File(surfaceIntegrationRunDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') + } +} + +tasks.matching { it.name == 'runSurfaceIntegrationFresh' }.all { + dependsOn prepareSurfaceIntegrationTest +} + +def createSurfaceProcess = { String phase, Object dependency -> + task("surfaceIntegration${phase}Process", type: Exec, dependsOn: dependency) { + group = 'verification' + dependsOn { + JavaExec runTask = tasks.getByName("runSurfaceIntegration${phase}") as JavaExec + runTask.taskDependencies.getDependencies(runTask) + } + doFirst { + JavaExec runTask = tasks.getByName("runSurfaceIntegration${phase}") as JavaExec + File classpathJar = file("${buildDir}/surface-integration-fixture/${phase.toLowerCase()}-classpath.jar") + classpathJar.parentFile.mkdirs() + java.util.jar.Manifest manifest = new java.util.jar.Manifest() + manifest.mainAttributes.put(java.util.jar.Attributes.Name.MANIFEST_VERSION, '1.0') + manifest.mainAttributes.put(java.util.jar.Attributes.Name.CLASS_PATH, + runTask.classpath.files.collect { it.toURI().toASCIIString() }.join(' ')) + classpathJar.withOutputStream { output -> + new java.util.jar.JarOutputStream(output, manifest).close() + } + List arguments = [] + arguments.addAll(runTask.allJvmArgs) + int classpathFlag = arguments.lastIndexOf('-cp') + if (classpathFlag < 0) { + classpathFlag = arguments.lastIndexOf('-classpath') + } + if (classpathFlag >= 0 && classpathFlag + 1 < arguments.size()) { + arguments.remove(classpathFlag + 1) + arguments.remove(classpathFlag) + } + arguments.add("-Dsurfaceprobe.integrationPhase=${phase.toLowerCase()}") + arguments.add('-cp') + arguments.add(classpathJar.absolutePath) + arguments.add(runTask.mainClass.orNull ?: 'net.minecraftforge.userdev.LaunchTesting') + arguments.addAll(runTask.args) + // The generated ForgeGradle task keeps the project-directory default + // after a clean configuration. The integration world must stay in + // its disposable build-owned directory instead. + workingDir surfaceIntegrationRunDirectory + environment runTask.environment + // Forge 36's launcher (and grossjava9hacks) must run on Java 8 even + // though Gradle and ForgeGradle 7 themselves run on Java 17. + File javaExecutable = java8Launcher.get().executablePath.asFile + // Let Gradle pass the argument vector directly. Wrapping this in + // cmd.exe made the otherwise portable integration gate fail on + // the Linux GitHub Actions runner before Minecraft could start. + commandLine(([javaExecutable.absolutePath] + arguments) as List) + } + } +} + +def surfaceIntegrationFreshProcess = createSurfaceProcess('Fresh', prepareSurfaceIntegrationTest) +surfaceIntegrationFreshProcess.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 = createSurfaceProcess('Reload', surfaceIntegrationFreshProcess) +surfaceIntegrationReloadProcess.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 syncForge36EclipseLaunches(dependsOn: compileSurfaceIntegrationTestMod) { + group = 'ide' + doLast { + String mainClass = 'net.minecraftforge.userdev.LaunchTesting' + String mainOutput = new File(projectDir, 'bin/main').absolutePath + String ordinaryModClasses = "${mod_id}%%${mainOutput}${File.pathSeparator}" + + "${mod_id}%%${mainOutput}" + String fixtureOutput = surfaceIntegrationClasses.absolutePath + String fixtureResources = new File(projectDir, 'src/biomeIntegrationTest/resources').absolutePath + String fixtureModClasses = "${mod_id}%%${mainOutput}${File.pathSeparator}" + + "${mod_id}%%${mainOutput}${File.pathSeparator}" + + "surfaceprobe%%${fixtureOutput}${File.pathSeparator}" + + "surfaceprobe%%${fixtureResources}" + def environmentEntries = { String launchTarget -> + " \r\n" + + " \r\n" + + " \r\n" + + " \r\n" + + " \r\n" + + " \r\n" + } + ['Client', 'Server', 'Data'].each { String runName -> + File launch = file("run${runName}.launch") + if (!launch.isFile()) { + throw new GradleException("Missing generated Eclipse launch: ${launch}") } - List arguments = [] - arguments.addAll(runTask.allJvmArgs) - arguments.add('-cp') - arguments.add(classpathJar.absolutePath) - arguments.add(runTask.main) - arguments.addAll(runTask.args) - - workingDir runTask.workingDir - environment runTask.environment - File javaExecutable = runTask.javaLauncher.get().executablePath.asFile - if (System.getProperty('os.name').toLowerCase(java.util.Locale.ROOT).contains('windows')) { - // Forge 36's direct JavaExec server shutdown can also terminate Gradle's - // single-use daemon. cmd/call keeps the server as a grandchild so Gradle - // receives the real exit code and can continue to the reload assertion. - String command = 'call "' + javaExecutable.absolutePath + '" ' + - arguments.collect { String value -> '"' + value.replace('"', '""') + '"' }.join(' ') - commandLine 'cmd.exe', '/d', '/s', '/c', command - } else { - commandLine javaExecutable.absolutePath, arguments + String text = launch.getText('UTF-8') + if (text.contains('')) { + String launchTarget = [Client: 'fmluserdevclient', Server: 'fmluserdevserver', + Data: 'fmluserdevdata'][runName] + text = text.replace( + '', + '\r\n' + + environmentEntries(launchTarget) + + " \r\n" + + '') } + text = text.replace( + '', + "") + text = text.replaceFirst( + //, + java.util.regex.Matcher.quoteReplacement( + "")) + if (!text.contains('key="target"')) { + String launchTarget = [Client: 'fmluserdevclient', Server: 'fmluserdevserver', + Data: 'fmluserdevdata'][runName] + text = text.replace(' ', + ' \r\n' + + '') + } + launch.setText(text, 'UTF-8') } - } + ['Fresh', 'Reload'].each { String phase -> + File launch = file("runSurfaceIntegration${phase}.launch") + if (!launch.isFile()) { + throw new GradleException("Missing generated Eclipse launch: ${launch}") + } + String text = launch.getText('UTF-8') + text = text.replace( + '', + "") + text = text.replaceFirst( + //, + java.util.regex.Matcher.quoteReplacement( + "")) + if (!text.contains('key="target"')) { + text = text.replace(' + 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}") + } + } + } } -tasks.named('check') { - dependsOn surfaceIntegrationTest +syncForge36EclipseLaunches.finalizedBy verifyForge36EclipseLaunchIsolation + +tasks.matching { it.name == 'genEclipseRuns' }.all { + finalizedBy syncForge36EclipseLaunches } -// 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.' +def packagedSurfaceProbeJar = renamer.classes(tasks.named('surfaceIntegrationTestModJar', Jar)) { + map.from minecraft.dependency.toSrgFile + output = layout.buildDirectory.file('surface-integration-fixture/surfaceprobe-reobf.jar') +} - doLast { - File classpathDir = layout.buildDirectory.dir('classpath').get().asFile - Set classpathFiles = fileTree(classpathDir) { - include '*_minecraftClasspath.txt' - }.files - boolean hasForgeGradle5Launches = ['runClient.launch', 'runServer.launch', - 'runData.launch'].every { file(it).isFile() } - if (classpathFiles.isEmpty() && !hasForgeGradle5Launches) { - throw new GradleException('Missing Forge run metadata. 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 - } +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' : "${project.group}:${base.archivesName.get()}:${project.version}", + 'Built-On-Java' : '8', + 'Built-On' : "${project.minecraft_version}-${project.forge_version}" + ]) + } +} - File target = file(mapped).canonicalFile - if (!target.isFile()) { - throw new GradleException("Missing Eclipse run dependency in configured Gradle cache: ${target}") - } - return target.absolutePath +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 { + if (project.mod_version != '4.0.9.116051' + || project.minecraft_version != '1.16.5' + || project.forge_version != '36.2.34' + || project.mapping_channel != 'official' + || project.mapping_version != '1.16.5') { + throw new GradleException('Unexpected OreSpawn 1.16.5 release identity') + } + if (project.loader_name != 'forge' || project.loader_code != '1' + || project.java_version != '8' || project.gradle_java_version != '17' + || project.java_toolchain_version != '8.0.502+7') { + throw new GradleException('Unexpected dispatcher or Java target metadata') + } + List expectedPublicArtifacts = [ + 'OreSpawn-4.0.9.116051.jar', + 'OreSpawn-4.0.9.116051-sources.jar', + 'OreSpawn-4.0.9.116051-javadoc.jar' + ] + if (base.archivesName.get() != 'OreSpawn' + || 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.9.116051')) { + 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 stableClasspath = new File(eclipseClasspathDir, classpathFile.name) - stableClasspath.setText(synced.join(System.lineSeparator()) + System.lineSeparator(), 'UTF-8') - stableClasspaths.put(classpathFile.canonicalFile, stableClasspath.canonicalFile) +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") } + } +} - 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 - } - 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}") - } - } +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}") } } - 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 + } + } +} + +tasks.register('verifyReleaseArtifacts') { + group = 'verification' + dependsOn tasks.named('verifyReleaseConfiguration') + dependsOn verifyDocumentationParity + dependsOn tasks.named('assemble') + doLast { + File libs = layout.buildDirectory.dir('libs').get().asFile + List jars = (libs.listFiles() ?: [] as File[]) + .findAll { it.name.endsWith('.jar') }.sort { it.name } + // Provider interpolation yields GString values; normalize them before + // comparing with real filesystem String names. + List expected = expectedReleaseFiles.get() + .collect { it.toString() }.sort() + if (jars.collect { it.name } != expected) { + throw new GradleException("Expected exactly ${expected}, found ${jars*.name}") + } + jars.each { candidate -> + if (candidate.length() == 0L) throw new GradleException("Empty artifact ${candidate}") + new ZipFile(candidate).withCloseable { zip -> + zip.entries().findAll { entry -> + !entry.isDirectory() && (archiveTextSuffixes.any { entry.name.endsWith(it) } + || entry.name.endsWith('/element-list') + || entry.name.endsWith('/package-list')) + }.each { entry -> + boolean cr = zip.getInputStream(entry).withCloseable { + input -> input.bytes.any { value -> value == 13 } } + if (cr) throw new GradleException( + "${candidate.name}!/${entry.name} is not LF-normalized") } - if (exclusions.isEmpty()) { - entry.attributes().remove('excluding') - } else { - entry.attributes().put('excluding', exclusions.join('|')) - } - } - if (changedProjectClasspath || changedResourceExclusions) { - eclipseProjectClasspath.setText(groovy.xml.XmlUtil.serialize(parsedClasspath), 'UTF-8') - } - - 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}") - } + [ + 'src/test/', 'src/biomeIntegrationTest/', 'src/clientIntegrationTest/', + 'agent-notes/', 'surfaceprobe', 'clientprobe', 'ci-fixtures/', + 'org/junit/', 'org/mockito/', 'net/bytebuddy/', + 'Mineralogy-1.16.5-5.2.0.jar' + ].each { forbidden -> + if (zip.entries().any { it.name.contains(forbidden) }) { + throw new GradleException( + "${candidate.name} contains forbidden ${forbidden}") } } } } - 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')) - } + File mainJar = new File(libs, expectedReleaseFiles.get()[0]) + new ZipFile(mainJar).withCloseable { zip -> + List names = zip.entries().collect { it.name } + [ + 'META-INF/mods.toml', + 'META-INF/accesstransformer.cfg', + 'zone/moddev/mc/orespawn/api/OreSpawnApi.class', + 'META-INF/orespawn/docs/VERSIONS.md', + 'META-INF/orespawn/docs/schemas/orespawn-provider.schema.json', + 'AGENTS.md' + ].each { required -> + if (!names.contains(required)) { + throw new GradleException("Release jar is missing ${required}") } - String replacement = output.toString() + System.lineSeparator() - if (launchFile.getText('UTF-8') != replacement) { - launchFile.setText(replacement, 'UTF-8') - changedPrepareLaunches++ + } + 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.16.5,1.17)"')) { + 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.gen.ChunkGenerator field_222542_c', + 'public-f net.minecraft.world.gen.ChunkGenerator field_235949_c_', + 'public-f net.minecraft.world.gen.NoiseChunkGenerator field_222560_g', + 'public-f net.minecraft.world.biome.Biome field_242424_k', + 'public net.minecraft.world.biome.Biome field_242423_j', + 'public-f net.minecraft.world.gen.feature.LiquidsConfig field_227366_f_' + ] + 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('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 != 52) { + throw new GradleException("${entry.name} uses class major ${major}, expected 52") } } } + 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') + } + } + } +} - int changedLaunches = 0 - int changedTestExclusions = 0 - File eclipseClasses = file('bin/main').canonicalFile - String eclipseModEntry = "${mod_id}%%${eclipseClasses.absolutePath}" - // Forge 36's exploded-directory locator treats the first entry as the - // resource root and scans only the remaining entries for @Mod classes. - // Eclipse merges classes and resources in bin/main, so repeat the path - // deliberately instead of collapsing it to a single entry. - String forge36ModClasses = "${eclipseModEntry};${eclipseModEntry}" - if (eclipseLaunchDir.isDirectory()) { - 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( - //) { - "" +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) } - 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(' 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') } + } +} - // 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++ - } - } - - // ForgeGradle 5 writes its Java launches directly at the project root, - // rather than producing the later *Slim.launch files above. Keep the - // ordinary player/developer launches on main output only; the isolated - // surface-probe launches remain available as their own explicit runs. - int changedDirectLaunches = 0 - ['runClient.launch', 'runServer.launch', 'runGameTestServer.launch', - 'runData.launch'].each { String launchName -> - File launchFile = file(launchName) - if (!launchFile.isFile()) { - return - } - 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( - //) { - "" - } - String excludeTestKey = 'org.eclipse.jdt.launching.ATTR_EXCLUDE_TEST_CODE' - String excludeTestAttribute = - "" - if (synced.contains("key=\"${excludeTestKey}\"")) { - synced = synced.replaceFirst( - //, - excludeTestAttribute) +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 = project.group.toString() + artifactId = base.archivesName.get() + version = project.version.toString() + if (preparedReleaseDir.isPresent()) { + File prepared = file(preparedReleaseDir.get()) + artifact(new File(prepared, expectedReleaseFiles.get()[0])) + artifact(new File(prepared, expectedReleaseFiles.get()[1])) { classifier = 'sources' } + artifact(new File(prepared, expectedReleaseFiles.get()[2])) { classifier = 'javadoc' } } else { - int launchHeaderEnd = synced.indexOf('\n', synced.indexOf(' - File launchFile = file(launchName) - if (!launchFile.isFile()) { - throw new GradleException("Missing ForgeGradle 5 Eclipse launch: ${launchFile}") +eclipse { + classpath { + downloadSources = true + downloadJavadoc = true + } + 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 syncForge36EclipseLaunches + 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.16 Eclipse classpath contains legacy LWJGL 2 artifacts: ${legacyLwjglArtifacts}") + } + File eclipseClasspath = file('.classpath') + if (!eclipseClasspath.isFile() + || !eclipseClasspath.getText('UTF-8').contains( + 'path="src/generated/resources"')) { + throw new GradleException( + 'Eclipse does not expose the generated production-resource source folder') + } + [ + '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}") + } + } + List forbidden = [ + 'src/test', 'bin/test', 'build/classes/java/test', + 'biomeIntegrationTest', 'clientIntegrationTest', + 'surfaceprobe', 'clientprobe', 'junit-', 'opentest4j-', + 'Mineralogy-1.16.5-5.2.0.jar', 'C:\\Users\\John' + ] + String mainOutput = new File(projectDir, 'bin/main').absolutePath + String expectedModClasses = "${mod_id}%%${mainOutput}${File.pathSeparator}" + + "${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}") } - String launchText = launchFile.getText('UTF-8') - String expectedEntry = - "" - if (!launchText.contains(expectedEntry)) { - throw new GradleException( - "Forge 36 Eclipse launch must repeat bin/main for resource and @Mod class scanning: ${launchFile}") + 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 36 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}") } } - 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} slim test exclusions, ${changedLaunchGroups} launch groups, ${changedDirectLaunches} ForgeGradle 5 launches updated)") + 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') } + +def packagedForgeServerRuntime = providers.gradleProperty('packagedForgeServerRuntime') +def packagedForgeClientRuntime = providers.gradleProperty('packagedForgeClientRuntime') + +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 launcher = new File(runtime, 'forge-1.16.5-36.2.34.jar') + File vanillaServer = new File(runtime, 'minecraft_server.1.16.5.jar') + File libraries = new File(runtime, 'libraries') + [launcher, vanillaServer, libraries].each { + if (!it.exists()) throw new GradleException("Incomplete official server runtime: ${it}") + } + workingDir packagedSurfaceRunDirectory + commandLine java8Launcher.get().executablePath.asFile.absolutePath, + '-Xms512m', '-Xmx2g', '-Dsurfaceprobe.integrationPhase=fresh', + '-jar', launcher.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 launcher = new File(runtime, 'forge-1.16.5-36.2.34.jar') + workingDir packagedSurfaceRunDirectory + commandLine java8Launcher.get().executablePath.asFile.absolutePath, + '-Xms512m', '-Xmx2g', '-Dsurfaceprobe.integrationPhase=reload', + '-jar', launcher.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') + } } } -tasks.configureEach { - if (name in ['copyEclipseResources', 'genEclipseRuns', 'eclipse', - 'prepareRunClient', 'prepareRunServer', 'prepareRunData', 'prepareRunGameTestServer']) { - finalizedBy syncEclipseRunClasspaths +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') } } +def packagedClientProcess = tasks.register('packagedClientProcess', Exec) { + group = 'verification' + dependsOn tasks.named('preparePackagedClientIntegration') + doFirst { + File runtime = requireRuntimeDirectory(packagedForgeClientRuntime, + 'packagedForgeClientRuntime') + String forgeVersionId = ['1.16.5-forge-36.2.34', 'forge-36.2.34'].find { candidate -> + new File(runtime, "versions/${candidate}/${candidate}.json").isFile() + } + if (forgeVersionId == null) { + throw new GradleException('Official client runtime has no Forge 36.2.34 profile') + } + File forgeJsonFile = new File(runtime, + "versions/${forgeVersionId}/${forgeVersionId}.json") + File baseJsonFile = new File(runtime, 'versions/1.16.5/1.16.5.json') + File baseJar = new File(runtime, 'versions/1.16.5/1.16.5.jar') + File nativesDirectory = new File(runtime, "natives/${forgeVersionId}") + [forgeJsonFile, baseJsonFile, baseJar, new File(runtime, 'libraries'), + new File(runtime, 'assets'), nativesDirectory].each { + if (!it.exists()) throw new GradleException("Incomplete official client runtime: ${it}") + } + + def slurper = new groovy.json.JsonSlurper() + Map forgeJson = (Map) slurper.parse(forgeJsonFile) + Map baseJson = (Map) slurper.parse(baseJsonFile) + Map classpathByModule = new LinkedHashMap<>() + [baseJson, forgeJson].each { Map metadata -> + ((List) metadata.libraries).each { Map library -> + List rules = (List) library.rules + boolean allowed = rules == null || rules.isEmpty() + if (rules != null) { + rules.each { Map rule -> + Map os = (Map) rule.os + boolean matches = os == null + || (os.name == 'windows' + && (os.arch == null || os.arch == System.getProperty('os.arch'))) + if (matches) allowed = rule.action == 'allow' + } + } + if (!allowed) return + String relative = (String) ((Map) ((Map) library.downloads).artifact).path + File artifact = new File(runtime, "libraries/${relative}") + if (!artifact.isFile()) { + throw new GradleException("Missing official client library: ${artifact}") + } + List coordinates = ((String) library.name).split(':') as List + String module = coordinates.size() >= 2 + ? "${coordinates[0]}:${coordinates[1]}" : (String) library.name + classpathByModule.put(module, artifact) + } + } + List classpathFiles = new ArrayList<>(classpathByModule.values()) + classpathFiles.add(baseJar) + + 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 + commandLine java8Launcher.get().executablePath.asFile.absolutePath, + '-Xms512m', '-Xmx2g', '-Dclientprobe.enabled=true', + "-Djava.library.path=${nativesDirectory.absolutePath}", + '-Dminecraft.launcher.brand=orespawn-validation', + '-Dminecraft.launcher.version=1', + '-cp', classpathFiles.collect { it.absolutePath }.join(File.pathSeparator), + (String) forgeJson.mainClass + args gameArguments + } +} +tasks.register('packagedClientIntegrationTest') { + group = 'verification' + dependsOn packagedClientProcess + doLast { + File marker = new File(packagedClientRunDirectory, 'client-smoke-pass.properties') + if (!marker.isFile()) { + throw new GradleException('Packaged client completion marker is missing') + } + Properties values = new Properties() + marker.withInputStream { values.load(it) } + ['world_settings_opened', 'long_editor_roundtrip', + 'first_world_rendered', 'reload_rendered'].each { key -> + if (values.getProperty(key) != 'true') { + throw new GradleException("Packaged client failed ${key}: ${values}") + } + } + assertDocumentationTree(new File(packagedClientRunDirectory, + 'config/orespawn-guide'), documentationFiles(), 'runtime guide export') + assertRuntimeLogsClean(packagedClientRunDirectory, + 'packaged client', [] as Set) + } +} + +tasks.register('packagedRuntimeIntegrationTest') { + group = 'verification' + description = 'Runs the exact reobfuscated jars in official Forge 36 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..e538dd31 --- /dev/null +++ b/ci-fixtures/README.md @@ -0,0 +1,12 @@ +# OreSpawn 1.16.5 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.16.5-5.2.0.jar` was reproduced from the exact historical +MinecraftMineralogy source commit +`15508b27ee16e9005f21fd8c661a4350eee391d5` using Java 8 and the original +ForgeGradle 5 / Gradle 7.3.3 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..0b20a4be --- /dev/null +++ b/ci-fixtures/SHA256SUMS @@ -0,0 +1 @@ +C24203651711BC26436C25F081EE7F2DA2F9239DC19092AA18EA8A9A11A86501 artifacts/Mineralogy-1.16.5-5.2.0.jar diff --git a/ci-fixtures/artifacts/Mineralogy-1.16.5-5.2.0.jar b/ci-fixtures/artifacts/Mineralogy-1.16.5-5.2.0.jar new file mode 100644 index 00000000..01b71ffb Binary files /dev/null and b/ci-fixtures/artifacts/Mineralogy-1.16.5-5.2.0.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 a38d1285..8e2325e9 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 42e6cd9e..02e97a36 100644 --- a/docs/BIOMES.md +++ b/docs/BIOMES.md @@ -123,6 +123,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..04099b8e 100644 --- a/docs/VERSIONS.md +++ b/docs/VERSIONS.md @@ -49,9 +49,12 @@ 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.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,11 +141,13 @@ same `Major.Minor.Bug` may be shared by functionally equivalent ports. If a released branch receives a bug fix that other branches do not require, only the affected branch's Bug number is incremented. For example, Forge -1.13.2 may move from `4.0.6.113021` to `4.0.7.113021` while unaffected branches -remain on their target-qualified 4.0.6 versions. +1.12.2 moved to `4.0.7.112021` for its packaged access-transformer repair while +unaffected branches remained on their target-qualified 4.0.6 versions. -If a different branch later receives a separate fix, it uses the next unused -Bug number, such as `4.0.8`, even if the `4.0.7` fix was not applicable to it. +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 and 1.16.5 then advanced to their +target-qualified 4.0.9 releases for the provider terrain-host ordering repair. A branch may therefore legitimately skip functional version numbers. This provides three useful guarantees: diff --git a/gradle.properties b/gradle.properties index 41799370..15f53e9a 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.16.5 -# 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.16.5,1.17) -# The Forge version must agree with the Minecraft version to get a valid artifact forge_version=36.2.34 -# The Forge version range can use any version of Forge as bounds or match the loader version range forge_version_range=[36,) -# The loader version range can only use the major version of Forge/FML as bounds loader_version_range=[36,) -# 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.16.5 +mcp_version=20210115.111550 - -## 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.116051 -# 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.9.116051 +mod_group=zone.moddev.mc 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=8 +java_toolchain_version=8.0.502+7 +gradle_java_version=17 +curseforge_project_id=245586 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index e708b1c0..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 2e6e5897..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-7.3.3-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 new file mode 100644 index 00000000..e7080f2e --- /dev/null +++ b/settings.gradle @@ -0,0 +1,5 @@ +plugins { + id('org.gradle.toolchains.foojay-resolver-convention') version '1.0.0' +} + +rootProject.name = 'OreSpawn' diff --git a/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java b/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java index a9d77053..390c667d 100644 --- a/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java +++ b/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java @@ -103,6 +103,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.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"), @@ -123,9 +132,11 @@ public final class SurfaceProbeTestMod { private static final int FLUID_PROBE_MAX_CHUNK_X = 62; 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(); @@ -240,6 +251,8 @@ private void enableGeologyProbe(FMLServerAboutToStartEvent 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); @@ -359,6 +372,13 @@ private static AuditResult auditDimension(ServerWorld 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.Mutable pos = new BlockPos.Mutable(); for (int chunkZ = MINIMUM_CHUNK; chunkZ <= MAXIMUM_CHUNK; chunkZ++) { @@ -427,22 +447,97 @@ private static AuditResult auditDimension(ServerWorld level, boolean roofed) { } } 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 != EXPECTED_NATURAL_SOURCES / 2 + || underwaterPockets != EXPECTED_NATURAL_SOURCES / 2 + || 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(ServerWorld level, IChunk chunk, + BlockPos.Mutable 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, 0, 256); + if (chunk.getBlockState(pos.set(x, groundY - 12, z)).is(Blocks.DIORITE)) { + 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, 0, 256); + if (chunk.getBlockState(pos.set(minX + 11, bedrockGroundY - 24, minZ + 12)) + .is(Blocks.BEDROCK)) { + bedrockPreserved++; + } + int chestGroundY = findMarkedGround(chunk, pos, minX + 12, minZ + 12, 0, 256); + pos.set(minX + 12, chestGroundY - 24, minZ + 12); + if (chunk.getBlockState(pos).is(Blocks.CHEST) + && level.getBlockEntity(pos) instanceof ChestTileEntity) { + ChestTileEntity chest = (ChestTileEntity) level.getBlockEntity(pos); + if (chest != null && chest.getItem(0).getItem() == 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(ServerWorld level) { @@ -581,6 +676,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; } @@ -676,9 +778,37 @@ private static boolean prepareTerrain(ISeedReader world, IChunk chunk) { } } } + if (!roofed) placeRawNaturalSources(world, chunk, pos, minX, minZ); return true; } + private static void placeRawNaturalSources(ISeedReader world, IChunk chunk, + BlockPos.Mutable 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, 0, 256); + 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, 0, 256); + chunk.setBlockState(pos.set(minX + 11, bedrockGroundY - 24, minZ + 12), + Blocks.BEDROCK.defaultBlockState(), false); + int chestGroundY = findMarkedGround(chunk, pos, minX + 12, minZ + 12, 0, 256); + world.setBlock(pos.set(minX + 12, chestGroundY - 24, minZ + 12), + Blocks.CHEST.defaultBlockState(), 2); + if (world.getBlockEntity(pos) instanceof ChestTileEntity) { + ChestTileEntity chest = (ChestTileEntity) world.getBlockEntity(pos); + ItemStack sentinel = new ItemStack(Items.EMERALD); + sentinel.setHoverName(new StringTextComponent(RAW_CHEST_ITEM_NAME)); + chest.setItem(0, sentinel); + chest.setChanged(); + } + } + private static boolean solid(BlockState state) { return !state.isAir() && state.getFluidState().isEmpty(); } @@ -700,6 +830,7 @@ private static boolean placeStructureSentinels(ISeedReader world, IChunk chunk) chest.setItem(0, sentinel); chest.setChanged(); } + placeAuthoredNaturalSources(world, chunk, pos, minX, minZ, 16); return true; } @@ -716,9 +847,30 @@ private static boolean placeVegetationSentinels(ISeedReader world, IChunk chunk) 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(ISeedReader world, IChunk chunk, + BlockPos.Mutable 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, 0, 256); + 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(IChunk chunk, BlockPos.Mutable pos, int x, int z, ISeedReader world) { return findMarkedGround(chunk, pos, x, z, 0, 256); @@ -751,6 +903,28 @@ private static final class Material { BlockState ceiling() { return ceiling; } } + private static final class NaturalSourceAudit { + final long rawConverted; + final long structurePreserved; + final long vegetationPreserved; + final long cavePreserved; + final long underwaterPreserved; + final long bedrockPreserved; + final long blockEntityPreserved; + + NaturalSourceAudit(long rawConverted, long structurePreserved, + long vegetationPreserved, long cavePreserved, long underwaterPreserved, + long bedrockPreserved, long blockEntityPreserved) { + this.rawConverted = rawConverted; + this.structurePreserved = structurePreserved; + this.vegetationPreserved = vegetationPreserved; + this.cavePreserved = cavePreserved; + this.underwaterPreserved = underwaterPreserved; + this.bedrockPreserved = bedrockPreserved; + this.blockEntityPreserved = blockEntityPreserved; + } + } + private static final class AuditResult { private final long top; private final long underwater; @@ -763,10 +937,19 @@ private static final class AuditResult { private final int edgeChanges; private final int sentinels; private final long aquiferFluid; + private final long rawNaturalSources; + private final long structureNaturalSources; + private final long vegetationNaturalSources; + private final long cavePockets; + private final long underwaterPockets; + private final long rawBedrock; + private final long rawBlockEntities; AuditResult(long top, long underwater, long filler, long geology, long ceiling, long roofTop, int biomeA, int biomeB, int edgeChanges, int sentinels, - long aquiferFluid) { + long aquiferFluid, long rawNaturalSources, long structureNaturalSources, + long vegetationNaturalSources, long cavePockets, long underwaterPockets, + long rawBedrock, long rawBlockEntities) { this.top = top; this.underwater = underwater; this.filler = filler; @@ -778,6 +961,13 @@ private static final class AuditResult { this.edgeChanges = edgeChanges; this.sentinels = sentinels; this.aquiferFluid = aquiferFluid; + this.rawNaturalSources = rawNaturalSources; + this.structureNaturalSources = structureNaturalSources; + this.vegetationNaturalSources = vegetationNaturalSources; + this.cavePockets = cavePockets; + this.underwaterPockets = underwaterPockets; + this.rawBedrock = rawBedrock; + this.rawBlockEntities = rawBlockEntities; } long top() { return top; } @@ -791,5 +981,12 @@ private static final class AuditResult { int edgeChanges() { return edgeChanges; } int sentinels() { return sentinels; } long aquiferFluid() { return aquiferFluid; } + long rawNaturalSources() { return rawNaturalSources; } + long structureNaturalSources() { return structureNaturalSources; } + long vegetationNaturalSources() { return vegetationNaturalSources; } + long cavePockets() { return cavePockets; } + long underwaterPockets() { return underwaterPockets; } + long rawBedrock() { return rawBedrock; } + long rawBlockEntities() { return rawBlockEntities; } } } diff --git a/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java new file mode 100644 index 00000000..ba121953 --- /dev/null +++ b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java @@ -0,0 +1,425 @@ +package zone.moddev.mc.orespawn.client; + +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.client.Minecraft; +import net.minecraft.client.gui.screen.CreateWorldScreen; +import net.minecraft.client.gui.screen.MainMenuScreen; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.gui.widget.Widget; +import net.minecraft.client.gui.widget.button.Button; +import net.minecraft.util.datafix.codec.DatapackCodec; +import net.minecraft.util.registry.DynamicRegistries; +import net.minecraft.util.registry.Registry; +import net.minecraft.util.text.TextFormatting; +import net.minecraft.world.Difficulty; +import net.minecraft.world.GameRules; +import net.minecraft.world.GameType; +import net.minecraft.world.WorldSettings; +import net.minecraft.world.gen.settings.DimensionGeneratorSettings; +import net.minecraftforge.client.event.GuiScreenEvent; +import net.minecraftforge.client.event.RenderWorldLastEvent; +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.worldgen.WorldGeologyProfile; + +/** Build-only 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 Widget 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(GuiScreenEvent.InitGuiEvent.Post event) { + ClientProbeTestMod probe = instance; + if (probe == null || !Boolean.getBoolean("clientprobe.enabled")) return; + if (!(event.getGui() instanceof CreateWorldScreen)) return; + probe.worldCreationButtons = event.getWidgetList(); + for (Widget button : event.getWidgetList()) { + if (isWorldSettingsButton(button)) probe.worldSettingsButton = button; + } + } + + @SubscribeEvent + public static void onScreenDrawn(GuiScreenEvent.DrawScreenEvent.Post event) { + ClientProbeTestMod probe = instance; + if (probe != null && Boolean.getBoolean("clientprobe.enabled") + && isOreSpawnEditor(event.getGui())) probe.editorFrames++; + } + + @SubscribeEvent + public static void onWorldRendered(RenderWorldLastEvent 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 MainMenuScreen) { + minecraft.setScreen(CreateWorldScreen.create(minecraft.screen)); + nextState(1); + } + break; + case 1: + if (worldSettingsButton == null && minecraft.screen instanceof CreateWorldScreen) { + for (Widget candidate : widgets(minecraft.screen)) { + if (isWorldSettingsButton(candidate)) worldSettingsButton = candidate; + } + } + if (worldSettingsButton == null && worldCreationButtons != null) { + for (Widget candidate : worldCreationButtons) { + if (isWorldSettingsButton(candidate)) worldSettingsButton = candidate; + } + } + if (minecraft.screen instanceof CreateWorldScreen && worldSettingsButton != null) { + // Forge 36 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 (Widget widget : widgets(root)) { + if (!(widget instanceof Button) || widget instanceof CycleButton) continue; + Button button = (Button) widget; + String caption = TextFormatting.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 (Widget widget : widgets(screen)) { + String caption = TextFormatting.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()); + + GeologyEditorSession session = new GeologyEditorSession( + WorldGeologyProfile.recommended(true).withRoot(root)); + String before = session.root().toString(); + + OreDimensionScreen oreScreen = new OreDimensionScreen(parent, session, + "example:long_editor_ore", "minecraft:overworld"); + initializeScreen(oreScreen, minecraft); + pressDone(oreScreen); + + FluidDepositDimensionScreen fluidScreen = new FluidDepositDimensionScreen(parent, session, + "example:long_editor_deposit", "minecraft:overworld"); + initializeScreen(fluidScreen, minecraft); + pressDone(fluidScreen); + + String after = session.root().toString(); + if (!before.equals(after)) { + throw new IllegalStateException("Opening and saving long editor values changed profile JSON\nBefore: " + + before + "\nAfter: " + after); + } + longEditorRoundTrip = true; + } + + 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 36 Screen initialization method"); + } + + private static void pressDone(Screen screen) { + for (Widget widget : widgets(screen)) { + if (!(widget instanceof Button)) continue; + String caption = TextFormatting.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(Widget widget) { + return widget instanceof Button + && TextFormatting.stripFormatting(widget.getMessage().getString()) + .toLowerCase(java.util.Locale.ROOT).contains("orespawn"); + } + + private static void createWorld(Minecraft minecraft) { + DynamicRegistries.Impl registries = DynamicRegistries.builtin(); + DimensionGeneratorSettings generator = DimensionGeneratorSettings.makeDefault( + registries.registryOrThrow(Registry.DIMENSION_TYPE_REGISTRY), + registries.registryOrThrow(Registry.BIOME_REGISTRY), + registries.registryOrThrow(Registry.NOISE_GENERATOR_SETTINGS_REGISTRY)) + .withSeed(false, OptionalLong.of(0L)); + WorldSettings settings = new WorldSettings("OreSpawn Client Smoke", GameType.CREATIVE, + false, Difficulty.NORMAL, true, new GameRules(), DatapackCodec.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 (net.minecraft.client.gui.IGuiEventListener child : screen.children()) { + if (child instanceof Widget) result.add((Widget) child); + } + return result; + } + + private static void stopIntegratedServer(Minecraft minecraft) { + // Match Forge 36's target-native disconnect path. unloadWorld(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 MainMenuScreen()); + } + + 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.16.5 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..4cc461b2 --- /dev/null +++ b/src/clientIntegrationTest/resources/META-INF/mods.toml @@ -0,0 +1,30 @@ +modLoader="javafml" +loaderVersion="[36,)" +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="[36,)" +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.16.5,1.17)" +ordering="NONE" +side="CLIENT" diff --git a/src/clientIntegrationTest/resources/pack.mcmeta b/src/clientIntegrationTest/resources/pack.mcmeta new file mode 100644 index 00000000..9a0d8124 --- /dev/null +++ b/src/clientIntegrationTest/resources/pack.mcmeta @@ -0,0 +1,8 @@ +{ + "pack": { + "description": "OreSpawn client qualification fixture", + "forge:resource_pack_format": 6, + "forge:data_pack_format": 6, + "pack_format": 6 + } +} 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 5477abde..8c398bc3 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/FluidDepositDimensionScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/FluidDepositDimensionScreen.java @@ -160,7 +160,7 @@ private TextFieldWidget placementField(int index, String key, String value) { int fieldWidth = Math.min(72, Math.max(58, columnWidth / 3)); TextFieldWidget box = new TextFieldWidget(font, groupX + columnWidth - fieldWidth, 90 + (row * 24), fieldWidth, 20, new StringTextComponent(key)); - box.setValue(value); box.setMaxLength(32); + box.setMaxLength(32); box.setValue(value); placementWidgets.add(OreSpawnScreenLayout.explain(this, addButton(box), placementHelp(key))); return box; @@ -169,7 +169,7 @@ private TextFieldWidget placementField(int index, String key, String value) { private TextFieldWidget hostField(int index, String key, String value) { int x = index == 0 ? left : left + columnWidth + 5; TextFieldWidget box = new TextFieldWidget(font, x, 106, columnWidth, 20, new StringTextComponent(key)); - box.setValue(value); box.setMaxLength(1024); + box.setMaxLength(1024); box.setValue(value); hostWidgets.add(OreSpawnScreenLayout.explain(this, addButton(box), "tooltip.orespawn." + key)); return box; @@ -179,7 +179,7 @@ private TextFieldWidget biomeField(int index, String key, String value) { int x = (index & 1) == 0 ? left : left + columnWidth + 5; int y = 106 + ((index / 2) * 44); TextFieldWidget box = new TextFieldWidget(font, x, y, columnWidth, 20, new StringTextComponent(key)); - box.setValue(value); box.setMaxLength(1024); + box.setMaxLength(1024); box.setValue(value); biomeWidgets.add(OreSpawnScreenLayout.explain(this, addButton(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 14c0a847..19747f4c 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/OreDimensionScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreDimensionScreen.java @@ -239,8 +239,8 @@ protected void init() { private TextFieldWidget addPlacementField(int x, int y, String key, String value) { TextFieldWidget box = new TextFieldWidget(font, x, y, columnWidth, 20, new StringTextComponent(key)); - box.setValue(value); box.setMaxLength(32); + box.setValue(value); OreSpawnScreenLayout.explain(this, box, placementHelp(key)); placementWidgets.add(addButton(box)); return box; @@ -252,8 +252,8 @@ private int compactPlacementFieldY(int row) { private TextFieldWidget addHostField(int x, int y, String key, String value) { TextFieldWidget box = new TextFieldWidget(font, x, y, contentWidth, 20, new StringTextComponent(key)); - box.setValue(value); box.setMaxLength(1024); + box.setValue(value); OreSpawnScreenLayout.explain(this, box, "tooltip.orespawn." + key); hostWidgets.add(addButton(box)); return box; @@ -261,8 +261,8 @@ private TextFieldWidget addHostField(int x, int y, String key, String value) { private TextFieldWidget addPatternField(int x, int y, String key, String value) { TextFieldWidget box = new TextFieldWidget(font, x, y, columnWidth, 20, new StringTextComponent(key)); - box.setValue(value); box.setMaxLength(32); + box.setValue(value); OreSpawnScreenLayout.explain(this, box, "tooltip.orespawn.ore." + key); patternWidgets.add(addButton(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/BakedTerrainDimension.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/BakedTerrainDimension.java index 9e4c23f5..e641cc0d 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/BakedTerrainDimension.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/BakedTerrainDimension.java @@ -9,6 +9,7 @@ import net.minecraft.world.World; import net.minecraft.block.Block; import net.minecraft.block.BlockState; +import net.minecraft.block.Blocks; /** Immutable setup-time resolution of one terrain replacement dimension. */ final class BakedTerrainDimension { @@ -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 68475b83..8c91273a 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, RegistryKey dimension) { List>> underground = step(features, GenerationStage.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.configuredFeature()); changed |= addUnique(underground, OreSpawnOreGeneration.configuredFeature()); changed |= addUnique(underground, FluidDepositFeature.configuredFeature()); @@ -68,19 +68,29 @@ private static void install(Biome biome, RegistryKey dimension) { step(features, GenerationStage.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, GenerationStage.Decoration.LOCAL_MODIFICATIONS); List>> top = step(features, GenerationStage.Decoration.TOP_LAYER_MODIFICATION); - boolean changed = addUnique(local, BiomeSurfaceFeature.configuredFeature()); + boolean changed = false; + if (terrain) { + changed |= StoneReplacer.placeUniqueAt(local, StoneReplacer.configuredFeature(), 0); + if (surfaces) { + changed |= StoneReplacer.placeUniqueAt(local, + BiomeSurfaceFeature.configuredFeature(), 1); + } + } else if (surfaces) { + changed |= addUnique(local, BiomeSurfaceFeature.configuredFeature()); + } changed |= addUnique(top, FlatBedrockFeature.configuredFeature()); 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 f0618dd4..b28d172e 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeSurfaceFeature.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeSurfaceFeature.java @@ -47,11 +47,15 @@ static boolean install(BiomeGenerationSettingsBuilder generation) { if (configuredFeature == null) return false; java.util.List>> features = generation.getFeatures( GenerationStage.Decoration.LOCAL_MODIFICATIONS); - if (features.stream().anyMatch(existing -> existing.get() == configuredFeature)) { - return false; + int stoneIndex = -1; + for (int index = 0; index < features.size(); index++) { + if (features.get(index).get() == StoneReplacer.configuredFeature()) { + stoneIndex = index; + break; + } } - features.add(() -> configuredFeature); - return true; + return StoneReplacer.placeUniqueAt(features, configuredFeature, + stoneIndex >= 0 ? stoneIndex + 1 : features.size()); } static ConfiguredFeature configuredFeature() { 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 6cdd4d42..97507057 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/Geology.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/Geology.java @@ -113,8 +113,9 @@ public void replaceStoneInChunk(IWorld world, IChunk chunk, BakedTerrainDimensio for (; y >= 0; 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 5f4e7bed..972cd26a 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeGeology.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeGeology.java @@ -116,7 +116,8 @@ public void replaceStoneInChunk(IWorld world, IChunk chunk, BakedTerrainDimensio } else { for (int y = surfaceY; y >= 0; 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; @@ -153,7 +154,8 @@ private boolean replaceStableColumn(IChunk chunk, BlockPos.Mutable cursor, int g replacement = pickStableReplacement(layerGeome, formationRegion, layerIndex); } cursor.setY(y); - if (terrain.isReplaceable(chunk.getBlockState(cursor))) { + if (terrain.isReplaceable(chunk.getBlockState(cursor)) + && chunk.getBlockEntity(cursor) == null) { chunk.setBlockState(cursor, replacement, false); changed = true; } 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 bed13e67..552920fe 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java @@ -359,7 +359,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.116051 Upgrade Report"); + lines.add("OreSpawn 4.0.9.116051 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 e4168abf..9d7fa570 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java @@ -199,7 +199,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.116051 Upgrade Report"); + lines.add("OreSpawn 4.0.9.116051 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 30b0c12a..45ef9442 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/StoneReplacer.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/StoneReplacer.java @@ -61,10 +61,12 @@ public static void onBiomeLoading(BiomeLoadingEvent event) { OreSpawnConfig.placeOreSpawnRock(), GeomeConfig.hasTerrainReplacement(World.OVERWORLD))) { removeVanillaMatchingStoneFeatures(event); } - if (configuredFeature != null) { - event.getGeneration().getFeatures(GenerationStage.Decoration.UNDERGROUND_ORES) - .add(() -> configuredFeature); - } + install(event.getGeneration()); + } + + static boolean install(net.minecraftforge.common.world.BiomeGenerationSettingsBuilder generation) { + return placeUniqueAt(generation.getFeatures( + GenerationStage.Decoration.LOCAL_MODIFICATIONS), configuredFeature, 0); } static ConfiguredFeature configuredFeature() { @@ -75,6 +77,23 @@ static boolean removeVanillaMatchingStoneFeatures(List>> features, + ConfiguredFeature feature, int index) { + if (feature == null) return false; + int current = -1; + for (int candidate = 0; candidate < features.size(); candidate++) { + if (features.get(candidate).get() == feature) { + 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; + } + @Override boolean place(FeaturePlaceContext context) { ISeedReader world = context.level(); diff --git a/src/main/resources/META-INF/accesstransformer.cfg b/src/main/resources/META-INF/accesstransformer.cfg index d1c141c5..375e98e6 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.gen.ChunkGenerator field_222542_c # biomeSource -public-f net.minecraft.world.gen.ChunkGenerator field_235949_c_ # runtimeBiomeSource -public-f net.minecraft.world.gen.NoiseChunkGenerator field_222560_g # defaultFluid -public-f net.minecraft.world.biome.Biome field_242424_k # generationSettings -public net.minecraft.world.biome.Biome field_242423_j # climateSettings -public-f net.minecraft.world.gen.feature.LiquidsConfig field_227366_f_ # validBlocks +public-f net.minecraft.world.gen.ChunkGenerator biomeSource +public-f net.minecraft.world.gen.ChunkGenerator runtimeBiomeSource +public-f net.minecraft.world.gen.NoiseChunkGenerator defaultFluid +public-f net.minecraft.world.biome.Biome generationSettings +public net.minecraft.world.biome.Biome climateSettings +public-f net.minecraft.world.gen.feature.LiquidsConfig validBlocks diff --git a/src/main/resources/META-INF/mods.toml b/src/main/resources/META-INF/mods.toml index 91296079..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="[36,)" #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="[36,)" #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.16.5,1.17)" +versionRange="${minecraft_version_range}" ordering="NONE" side="BOTH" 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 41fb3180..d32b0ca3 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,32 @@ import net.minecraft.util.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: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: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..e51a50a2 --- /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.widget.TextFieldWidget; +import net.minecraft.util.text.StringTextComponent; + +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"; + TextFieldWidget field = new TextFieldWidget(null, 0, 0, 200, 20, + new StringTextComponent("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 6fd544cd..8c1e479f 100644 --- a/src/test/java/zone/moddev/mc/orespawn/worldgen/BiomeSurfaceFeatureOrderTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/BiomeSurfaceFeatureOrderTest.java @@ -22,11 +22,13 @@ static void bootstrapMinecraftRegistries() { @Test void eventAndRuntimeInstallersKeepSurfacesEarlyAndBedrockLast() { + StoneReplacer.registerConfiguredFeature(); BiomeSurfaceFeature.registerConfiguredFeature(); FlatBedrockFeature.registerConfiguredFeature(); BiomeGenerationSettingsBuilder generation = new BiomeGenerationSettingsBuilder( net.minecraft.world.biome.BiomeGenerationSettings.EMPTY); + assertTrue(StoneReplacer.install(generation)); assertTrue(BiomeSurfaceFeature.install(generation)); ConfiguredFeature surfaces = BiomeSurfaceFeature.configuredFeature(); @@ -35,16 +37,20 @@ void eventAndRuntimeInstallersKeepSurfacesEarlyAndBedrockLast() { generation.getFeatures(GenerationStage.Decoration.LOCAL_MODIFICATIONS); List>> top = generation.getFeatures(GenerationStage.Decoration.TOP_LAYER_MODIFICATION); - assertTrue(local.stream().anyMatch(feature -> feature.get() == surfaces)); + assertTrue(local.size() >= 2); + assertTrue(local.get(0).get() == StoneReplacer.configuredFeature()); + assertTrue(local.get(1).get() == surfaces); assertFalse(top.stream().anyMatch(feature -> feature.get() == surfaces)); List>>> runtime = new ArrayList<>(); - assertTrue(BiomeFeatureInstaller.installSurfaceStages(runtime)); + assertTrue(BiomeFeatureInstaller.installSurfaceStages(runtime, true, true)); List>> runtimeLocal = step(runtime, GenerationStage.Decoration.LOCAL_MODIFICATIONS); List>> runtimeTop = step(runtime, GenerationStage.Decoration.TOP_LAYER_MODIFICATION); - assertTrue(runtimeLocal.stream().anyMatch(feature -> feature.get() == surfaces)); + assertTrue(runtimeLocal.size() >= 2); + assertTrue(runtimeLocal.get(0).get() == StoneReplacer.configuredFeature()); + assertTrue(runtimeLocal.get(1).get() == surfaces); assertFalse(runtimeLocal.stream().anyMatch(feature -> feature.get() == bedrock)); assertTrue(runtimeTop.stream().anyMatch(feature -> feature.get() == bedrock)); assertFalse(runtimeTop.stream().anyMatch(feature -> feature.get() == surfaces)); 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 10114422..4279f5c0 100644 --- a/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyGeologyParityTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyGeologyParityTest.java @@ -43,38 +43,35 @@ void cyanoSamplerMatchesPublishedMineralogy520AndSealedVectors() 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.2.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.2.0 mismatch at " - + seed + ":" + x + ":" + y + ":" + z); - } + assertEquals(sampler.getStoneAt(x, y, z), actual, + "Published Mineralogy 5.2.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.2.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/StoneReplacerTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/StoneReplacerTest.java index dc04861f..fa302f79 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.block.Block; +import net.minecraft.block.Blocks; +import net.minecraft.util.RegistryKey; +import net.minecraft.util.ResourceLocation; +import net.minecraft.util.registry.Registry; +import net.minecraft.world.World; import net.minecraft.world.biome.Biome.Category; class StoneReplacerTest { @@ -27,4 +36,22 @@ void nonOverworldBiomesAreNeverChanged() { assertFalse(TerrainFeaturePolicy.shouldRemoveVanillaMatchingStoneFeatures( Category.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( + RegistryKey.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())); + } }