diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7fc461d3..f0028835 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,24 +1,77 @@ -name: CI +name: OreSpawn 1.15.2 CI -on: [push, pull_request] -#on: -# push: -# branches: [ master-1.12 ] -# pull_request: -# # The branches below must be a subset of the branches above -# branches: [ master-1.12 ] -# types: [opened, synchronize, reopened] +on: + push: + branches: + - master-1.15.2 + - 'feature/**' + pull_request: + branches: + - master-1.15.2 + +permissions: + contents: read + +concurrency: + group: orespawn-1.15-${{ 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.15.2-${{ github.sha }} + if-no-files-found: error + retention-days: 30 + path: | + build/libs/OreSpawn-4.0.9.115021.jar + build/libs/OreSpawn-4.0.9.115021-sources.jar + build/libs/OreSpawn-4.0.9.115021-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.15.2-diagnostics-${{ github.sha }} + if-no-files-found: ignore + retention-days: 14 + path: | + build/test-results/** + build/reports/** + build/*-run/logs/** + build/surface-integration-run/**/*.properties + build/problems/** diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index d5a02752..1663894f 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.15.2 + - 'feature/**' + pull_request: + branches: + - master-1.15.2 + 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..7d474847 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.15.2 + - 'feature/**' + pull_request: + branches: + - master-1.15.2 + +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..5951c987 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ run classes logs /mcmodsrepo/ +/src/generated/resources/META-INF/orespawn/docs/ # machine-specific agent context (public integration notes live under /docs) /AGENTS.md diff --git a/CHANGELOG.txt b/CHANGELOG.txt index dac9de54..22c8d80a 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,23 @@ +Version 4.0.9.115021 + +* 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.115021 + +* 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.15.2. + Version 4.0.6.115021 * 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 5d57ab6b..b8b433d5 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.15.2)](https://github.com/MinecraftModDevelopmentMods/OreSpawn/actions/workflows/ci.yml?query=branch%3Amaster-1.15.2) + # MMD OreSpawn OreSpawn 4 is a provider-driven world-generation engine for Minecraft 1.15.2. @@ -12,6 +17,10 @@ End" policy used by mods such as Base Metals. This is not the unrelated mod that adds mobs and dimensions under the same name. +This branch builds target-qualified version `4.0.9.115021`: the OreSpawn 4.0.9 +feature set for Minecraft 1.15.2 and Forge. See the +[versioning policy](docs/VERSIONS.md) for the encoding and release convention. + ## What Happens When It Is Installed? OreSpawn is deliberately passive on its own. It does not replace stone, remove @@ -86,11 +95,13 @@ exported to `config/orespawn-guide/` without overwriting existing files. ## Building -Use Java 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.15.2; 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,9 +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 3 project in Eclipse. This branch uses the Gradle 4.10.3 wrapper, -Forge 31.2.57, and the `snapshot_20200514-1.15.1` MCP mappings. +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 31.2.57, the +`snapshot_20200514-1.15.1` MCP mappings, and pack format 5. Ordinary Eclipse +launches exclude tests and fixtures. Published jars are deterministic, +SRG-reobfuscated for the Forge 31 runtime, audited for their access transformer +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 414fafd9..5bfa9456 100644 --- a/build.gradle +++ b/build.gradle @@ -1,96 +1,159 @@ -buildscript { - repositories { - maven { url = 'https://maven.minecraftforge.net/' } - mavenCentral() - } - dependencies { - classpath group: 'net.minecraftforge.gradle', name: 'ForgeGradle', version: '3.+', 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' + +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 -version = mod_version -group = mod_group_id -archivesBaseName = "OreSpawn-${minecraft_version}" +java { + toolchain { + languageVersion = JavaLanguageVersion.of(8) + vendor = JvmVendorSpec.ADOPTIUM + } + withSourcesJar() + withJavadocJar() +} -sourceCompatibility = targetCompatibility = compileJava.sourceCompatibility = compileJava.targetCompatibility = '1.8' +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 +} -println "Java: ${System.getProperty 'java.version'}, JVM: ${System.getProperty 'java.vm.version'} (${System.getProperty 'java.vendor'}), Arch: ${System.getProperty 'os.arch'}" +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 { mappings channel: project.mapping_channel, version: project.mapping_version - accessTransformer = file('src/main/resources/META-INF/accesstransformer.cfg') - + accessTransformer = 'META-INF/accesstransformer.cfg' runs { - client { - workingDirectory project.file('run') - property 'forge.logging.markers', 'REGISTRIES' - property 'forge.logging.console.level', 'debug' - mods { - orespawn { source sourceSets.main } - } - } - - server { - workingDirectory project.file('run') - property 'forge.logging.markers', 'REGISTRIES' - property 'forge.logging.console.level', 'debug' - args '--nogui' - mods { - orespawn { source sourceSets.main } - } + configureEach { + 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 } } } - - data { - workingDirectory project.file('run-data') - property 'forge.logging.markers', 'REGISTRIES' - property 'forge.logging.console.level', 'debug' - args '--mod', project.mod_id, '--all', '--output', file('src/generated/resources/'), '--existing', file('src/main/resources/') - mods { - orespawn { source sourceSets.main } - } + 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/') } - - surfaceIntegrationFresh { - workingDirectory project.file("${buildDir}/surface-integration-run") - property 'forge.logging.console.level', 'info' - property 'surfaceprobe.integrationPhase', 'fresh' + register('surfaceIntegrationFresh') { + workingDir.convention layout.buildDirectory.dir('surface-integration-run') + systemProperty 'surfaceprobe.integrationPhase', 'fresh' args '--nogui' - mods { - orespawn { source sourceSets.main } - } } - - surfaceIntegrationReload { - workingDirectory project.file("${buildDir}/surface-integration-run") - property 'forge.logging.console.level', 'info' - property 'surfaceprobe.integrationPhase', 'reload' + register('surfaceIntegrationReload') { + workingDir.convention layout.buildDirectory.dir('surface-integration-run') + systemProperty 'surfaceprobe.integrationPhase', 'reload' args '--nogui' - mods { - orespawn { source sourceSets.main } - } } } } -sourceSets.main.resources { srcDir 'src/generated/resources' } +def bundledDocumentationDirectory = layout.projectDirectory.dir( + 'src/generated/resources/META-INF/orespawn/docs') +def prepareBundledDocumentation = tasks.register('prepareBundledDocumentation', Sync) { + group = 'build' + description = 'Stages public documentation as a generated production resource tree.' + from('docs') + into(bundledDocumentationDirectory) +} + +sourceSets.main.resources { + // Eclipse rebuilds bin/main from declared resource source folders. Keeping + // the generated documentation in the source set prevents a Buildship + // refresh from silently removing the guide copied by processResources. + srcDir 'src/generated/resources' +} + +def 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 +} def configureForge31Run = { JavaExec runTask, String launchTarget -> - runTask.main = 'net.minecraftforge.userdev.LaunchTesting' + 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 - runTask.environment 'MOD_CLASSES', "${mod_id}%%${sourceSets.main.output.classesDirs.singleFile};" + - "${mod_id}%%${sourceSets.main.output.resourcesDir}" + // Forge 31'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).all { JavaExec runTask -> +tasks.withType(JavaExec).configureEach { JavaExec runTask -> Map targets = [ runClient: 'fmluserdevclient', runServer: 'fmluserdevserver', @@ -99,118 +162,131 @@ tasks.withType(JavaExec).all { JavaExec runTask -> runSurfaceIntegrationReload: 'fmluserdevserver' ] String launchTarget = targets.get(runTask.name) - if (launchTarget != null) { - configureForge31Run(runTask, launchTarget) - } + if (launchTarget != null) configureForge31Run(runTask, launchTarget) } repositories { + minecraft.mavenizer(it) + maven fg.forgeMaven + maven fg.minecraftLibsMaven + exclusiveContent { + forRepository { maven { url = 'https://repo.spongepowered.org/repository/maven-public' } } + filter { includeGroupAndSubgroups('org.spongepowered') } + } mavenCentral() + maven { url = 'https://libraries.minecraft.net/' } } -dependencies { - minecraft "net.minecraftforge:forge:${minecraft_version}-${forge_version}" +def fixtureRoot = file("${rootDir}/ci-fixtures") +def mineralogy5OracleJar = new File(fixtureRoot, + 'artifacts/Mineralogy-1.15.2-5.1.1.jar') +def mineralogy5OracleSha256 = + 'C22060D02578044BF1B9D571881EA76D3235543358C508696B67C88552BF0FA7' - testCompile 'org.junit.jupiter:junit-jupiter-api:5.10.2' - testCompile 'org.junit.jupiter:junit-jupiter-params:5.10.2' - testRuntime 'org.junit.jupiter:junit-jupiter-engine:5.10.2' - testRuntime 'org.junit.platform:junit-platform-launcher:1.10.2' +tasks.register('verifyLegacyFixtures') { + group = 'verification' + description = 'Verifies the sealed Mineralogy 1.15.2 5.1.1 oracle used only by isolated tests.' + inputs.file mineralogy5OracleJar + doLast { + if (!mineralogy5OracleJar.isFile()) { + throw new GradleException("Missing mandatory Mineralogy oracle: ${mineralogy5OracleJar}") + } + MessageDigest digest = MessageDigest.getInstance('SHA-256') + mineralogy5OracleJar.withInputStream { input -> + byte[] buffer = new byte[8192] + for (int read = input.read(buffer); read >= 0; read = input.read(buffer)) { + if (read > 0) digest.update(buffer, 0, read) + } + } + String actual = digest.digest().encodeHex().toString().toUpperCase() + if (actual != mineralogy5OracleSha256) { + throw new GradleException("Mineralogy oracle checksum mismatch: ${actual}") + } + } } -task sourcesJar(type: Jar, dependsOn: classes) { - classifier = 'sources' - from sourceSets.main.allSource +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' } -task javadocJar(type: Jar, dependsOn: javadoc) { - classifier = 'javadoc' - from javadoc.destinationDir +tasks.named('compileTestJava', JavaCompile) { dependsOn tasks.named('verifyLegacyFixtures') } +tasks.named('test', Test) { + dependsOn tasks.named('verifyLegacyFixtures') + systemProperty 'orespawn.mineralogy5Oracle', mineralogy5OracleJar.absolutePath } -artifacts { - archives sourcesJar - archives javadocJar +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}") + } + } + } } -jar { - manifest { - attributes([ - 'Specification-Title' : 'OreSpawn', - 'Specification-Vendor' : 'SkyBlade1978', - 'Specification-Version' : '1', - 'Implementation-Title' : project.name, - 'Implementation-Version' : version, - 'Implementation-Vendor' : 'SkyBlade1978', - 'Implementation-Timestamp' : new Date().format("yyyy-MM-dd'T'HH:mm:ssZ"), - 'OreSpawn-API-Version' : '1' - ]) +def java8Launcher = javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(8) + vendor = JvmVendorSpec.ADOPTIUM +} +tasks.register('verifyJava8Toolchain') { + group = 'verification' + doLast { + 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}") + } } - finalizedBy 'reobfJar' +} +tasks.named('check') { + dependsOn tasks.named('verifyLegacyOracleIsolation') + dependsOn tasks.named('verifyJava8Toolchain') } -processResources { - inputs.property 'version', project.version - inputs.property 'minecraft_version', minecraft_version - inputs.property 'forge_version_range', forge_version_range - inputs.property 'loader_version_range', loader_version_range - inputs.property 'minecraft_version_range', minecraft_version_range - +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: minecraft_version, - forge_version_range: forge_version_range, - loader_version_range: loader_version_range, - minecraft_version_range: minecraft_version_range + 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' } } - from('docs') { - into 'META-INF/orespawn/docs' - } + filesMatching(archiveTextPatterns, normalizeArchiveLineEndings) } -publishing { - publications { - mavenJava(MavenPublication) { - artifact jar - artifact sourcesJar - artifact javadocJar +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')) } } - repositories { - maven { url "file:///${project.projectDir}/mcmodsrepo" } - } -} - -tasks.withType(JavaCompile) { - sourceCompatibility = '1.8' - targetCompatibility = '1.8' - options.encoding = 'UTF-8' - options.compilerArgs += ['-Xmaxerrs', '1000'] -} - -javadoc { - options.encoding = 'UTF-8' - options.addStringOption('Xdoclint:none', '-quiet') -} - -test { - 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 115/MinecraftMineralogy/build/libs/Mineralogy-1.15.2-5.1.1.jar') - if (mineralogy5Oracle.isFile()) { - systemProperty 'orespawn.mineralogy5Oracle', mineralogy5Oracle.absolutePath - } } // A Forge process is not green merely because it returns exit code zero. The @@ -219,6 +295,7 @@ def acceptedForge31LogNoise = [ ~/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 / ] @@ -279,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( @@ -333,19 +411,41 @@ check.dependsOn verifyMineralogyOracleIsolation } } +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) - destinationDir = surfaceIntegrationClasses + destinationDirectory = surfaceIntegrationClasses sourceCompatibility = '1.8' targetCompatibility = '1.8' options.encoding = 'UTF-8' } task surfaceIntegrationTestModJar(type: Jar, dependsOn: compileSurfaceIntegrationTestMod) { - archiveName = 'surfaceprobe.jar' - destinationDir = file("${buildDir}/surface-integration-fixture") + archiveFileName = 'surfaceprobe.jar' + destinationDirectory = file("${buildDir}/surface-integration-fixture") from surfaceIntegrationClasses from 'src/biomeIntegrationTest/resources' } @@ -356,7 +456,7 @@ task prepareSurfaceIntegrationTest(dependsOn: surfaceIntegrationTestModJar) { delete surfaceIntegrationRunDirectory surfaceIntegrationRunDirectory.mkdirs() copy { - from surfaceIntegrationTestModJar.archivePath + from surfaceIntegrationTestModJar.archiveFile into new File(surfaceIntegrationRunDirectory, 'mods') } new File(surfaceIntegrationRunDirectory, 'server.properties').setText('''\ @@ -378,8 +478,12 @@ tasks.matching { it.name == 'runSurfaceIntegrationFresh' }.all { } def createSurfaceProcess = { String phase, Object dependency -> - task("surfaceIntegration${phase}Process", type: Exec, dependsOn: [dependency, "prepareRunSurfaceIntegration${phase}"]) { + 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") @@ -401,16 +505,23 @@ def createSurfaceProcess = { String phase, Object dependency -> arguments.remove(classpathFlag + 1) arguments.remove(classpathFlag) } + arguments.add("-Dsurfaceprobe.integrationPhase=${phase.toLowerCase()}") arguments.add('-cp') arguments.add(classpathJar.absolutePath) - arguments.add(runTask.main ?: 'net.minecraftforge.userdev.LaunchTesting') + arguments.add(runTask.mainClass.orNull ?: 'net.minecraftforge.userdev.LaunchTesting') arguments.addAll(runTask.args) - workingDir runTask.workingDir + // 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 - File javaExecutable = new File(System.getProperty('java.home'), 'bin/java.exe') - String command = 'call "' + javaExecutable.absolutePath + '" ' + - arguments.collect { '"' + it.toString().replace('"', '""') + '"' }.join(' ') - commandLine 'cmd.exe', '/d', '/s', '/c', command + // Forge 31'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) } } } @@ -453,30 +564,58 @@ task syncForge31EclipseLaunches(dependsOn: compileSurfaceIntegrationTestMod) { 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};${mod_id}%%${mainOutput};" + - "surfaceprobe%%${fixtureOutput};surfaceprobe%%${fixtureResources}" - String environmentEntries = + 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" + " \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}") - } - String text = launch.getText('UTF-8') - if (!text.contains('org.eclipse.jdt.launching.ATTR_EXCLUDE_TEST_CODE')) { - text = text.replace('', - ' \r\n' + - '') - } - launch.setText(text, 'UTF-8') - } + } + ['Client', 'Server', 'Data'].each { String runName -> + File launch = file("run${runName}.launch") + if (!launch.isFile()) { + throw new GradleException("Missing generated Eclipse launch: ${launch}") + } + String text = launch.getText('UTF-8') + if (text.contains('')) { + 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()) { @@ -492,7 +631,7 @@ task syncForge31EclipseLaunches(dependsOn: compileSurfaceIntegrationTestMod) { "")) if (!text.contains('key="target"')) { text = text.replace(' + 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.115021' + || project.minecraft_version != '1.15.2' + || project.forge_version != '31.2.57' + || project.mapping_channel != 'snapshot' + || project.mapping_version != '20200514-1.15.1') { + throw new GradleException('Unexpected OreSpawn 1.15.2 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.115021.jar', + 'OreSpawn-4.0.9.115021-sources.jar', + 'OreSpawn-4.0.9.115021-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.115021')) { + 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') + } + } +} + +def trackedDocumentationDirectory = file('docs') +def documentationFiles = { + fileTree(trackedDocumentationDirectory).files.findAll { it.isFile() }.collect { + trackedDocumentationDirectory.canonicalFile.toPath().relativize(it.canonicalFile.toPath()) + .toString().replace('\\', '/') }.sort() +} +def assertDocumentationTree = { File root, List expected, String label -> + List actual = root.isDirectory() ? fileTree(root).files.findAll { it.isFile() } + .collect { root.canonicalFile.toPath().relativize(it.canonicalFile.toPath()) + .toString().replace('\\', '/') } + .sort() : [] + if (actual != expected) { + throw new GradleException("${label} documentation set ${actual} does not match tracked ${expected}") + } + expected.each { relative -> + byte[] tracked = new File(trackedDocumentationDirectory, relative).bytes + byte[] candidate = new File(root, relative).bytes + if (!java.util.Arrays.equals(tracked, candidate)) { + throw new GradleException("${label}/${relative} differs from tracked documentation") + } + } +} + +def verifyDocumentationParity = tasks.register('verifyDocumentationParity') { + group = 'verification' + dependsOn prepareBundledDocumentation + dependsOn tasks.named('processResources') + dependsOn prepareEclipseResources + dependsOn releaseJar + doLast { + List expected = documentationFiles() + if (expected.size() != 21 || !expected.contains('VERSIONS.md')) { + throw new GradleException("Expected exactly 21 tracked guide files including VERSIONS.md, found ${expected}") + } + assertDocumentationTree(bundledDocumentationDirectory.asFile, + expected, 'generated resources') + assertDocumentationTree(new File(layout.buildDirectory.dir('resources/main').get().asFile, + 'META-INF/orespawn/docs'), expected, 'processed resources') + assertDocumentationTree(file('bin/main/META-INF/orespawn/docs'), + expected, 'Eclipse bin/main') + new ZipFile(releaseJar.get().output.get().asFile).withCloseable { zip -> + expected.each { relative -> + def entry = zip.getEntry("META-INF/orespawn/docs/${relative}") + if (entry == null || !java.util.Arrays.equals( + new File(trackedDocumentationDirectory, relative).bytes, + zip.getInputStream(entry).withCloseable { it.bytes })) { + throw new GradleException("Release jar documentation differs at ${relative}") + } + } + } + } +} + +tasks.register('verifyReleaseArtifacts') { + group = 'verification' + dependsOn tasks.named('verifyReleaseConfiguration') + dependsOn verifyDocumentationParity + dependsOn tasks.named('assemble') + doLast { + File libs = layout.buildDirectory.dir('libs').get().asFile + List jars = (libs.listFiles() ?: [] as File[]) + .findAll { it.name.endsWith('.jar') }.sort { it.name } + // Provider interpolation yields GString values; normalize them before + // comparing with real filesystem String names. + List expected = expectedReleaseFiles.get() + .collect { it.toString() }.sort() + if (jars.collect { it.name } != expected) { + throw new GradleException("Expected exactly ${expected}, found ${jars*.name}") + } + jars.each { candidate -> + if (candidate.length() == 0L) throw new GradleException("Empty artifact ${candidate}") + new ZipFile(candidate).withCloseable { zip -> + zip.entries().findAll { entry -> + !entry.isDirectory() && (archiveTextSuffixes.any { entry.name.endsWith(it) } + || entry.name.endsWith('/element-list') + || entry.name.endsWith('/package-list')) + }.each { entry -> + boolean cr = zip.getInputStream(entry).withCloseable { + input -> input.bytes.any { value -> value == 13 } + } + if (cr) throw new GradleException( + "${candidate.name}!/${entry.name} is not LF-normalized") + } + [ + 'src/test/', 'src/biomeIntegrationTest/', 'src/clientIntegrationTest/', + 'agent-notes/', 'surfaceprobe', 'clientprobe', 'ci-fixtures/', + 'org/junit/', 'org/mockito/', 'net/bytebuddy/', + 'Mineralogy-1.15.2-5.1.1.jar' + ].each { forbidden -> + if (zip.entries().any { it.name.contains(forbidden) }) { + throw new GradleException( + "${candidate.name} contains forbidden ${forbidden}") + } + } + } + } + + File mainJar = new File(libs, expectedReleaseFiles.get()[0]) + new ZipFile(mainJar).withCloseable { zip -> + List names = zip.entries().collect { it.name } + [ + 'META-INF/mods.toml', + 'META-INF/accesstransformer.cfg', + 'zone/moddev/mc/orespawn/api/OreSpawnApi.class', + 'META-INF/orespawn/docs/VERSIONS.md', + 'META-INF/orespawn/docs/schemas/orespawn-provider.schema.json', + 'AGENTS.md' + ].each { required -> + if (!names.contains(required)) { + throw new GradleException("Release jar is missing ${required}") + } + } + String metadata = zip.getInputStream(zip.getEntry('META-INF/mods.toml')) + .getText(StandardCharsets.UTF_8.name()) + if (!metadata.contains('modId="orespawn"') + || !metadata.contains("version=\"${project.version}\"") + || !metadata.contains('versionRange="[1.15.2]"')) { + 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 net.minecraft.world.biome.provider.BiomeProvider field_226837_c_', + 'public-f net.minecraft.world.gen.NoiseChunkGenerator field_222560_g', + 'public-f net.minecraft.world.gen.feature.LiquidsConfig field_227366_f_', + 'public net.minecraft.world.biome.Biome field_201874_aj' + ] + 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') + } + } + } +} + +tasks.register('writeReleaseChecksums') { + group = 'verification' + dependsOn tasks.named('verifyReleaseArtifacts') + def outputFile = layout.buildDirectory.file('release/SHA256SUMS') + outputs.file(outputFile) + doLast { + File output = outputFile.get().asFile + output.parentFile.mkdirs() + File libs = layout.buildDirectory.dir('libs').get().asFile + String contents = expectedReleaseFiles.get().sort().collect { name -> + MessageDigest digest = MessageDigest.getInstance('SHA-256') + new File(libs, name).withInputStream { input -> + byte[] buffer = new byte[8192] + for (int read = input.read(buffer); read >= 0; read = input.read(buffer)) { + if (read > 0) digest.update(buffer, 0, read) + } + } + "${digest.digest().encodeHex().toString().toUpperCase()} ${name}" + }.join('\n') + '\n' + output.setText(contents, 'UTF-8') + } +} + +tasks.register('verifyPreparedReleaseArtifacts') { + group = 'verification' + doLast { + if (!preparedReleaseDir.isPresent()) { + throw new GradleException('preparedReleaseDir is required') + } + File prepared = file(preparedReleaseDir.get()) + // Provider interpolation yields GString values; normalize them before + // comparing with real filesystem String names. + List expected = expectedReleaseFiles.get() + .collect { it.toString() }.sort() + List jars = (prepared.listFiles() ?: [] as File[]) + .findAll { it.name.endsWith('.jar') }.sort { it.name } + List actualNames = jars.collect { it.name.toString() }.sort() + if (actualNames != expected) { + throw new GradleException( + "Prepared release jars ${actualNames} do not match ${expected}") + } + if (jars.any { it.length() == 0L }) { + throw new GradleException('Prepared release contains an empty jar') + } + File checksums = new File(prepared, 'SHA256SUMS') + if (!checksums.isFile() || !new File(prepared, 'CHANGELOG.txt').isFile()) { + throw new GradleException('Prepared release is missing checksums or changelog') + } + List actual = jars.collect { candidate -> + MessageDigest digest = MessageDigest.getInstance('SHA-256') + candidate.withInputStream { input -> + byte[] buffer = new byte[8192] + for (int read = input.read(buffer); read >= 0; read = input.read(buffer)) { + if (read > 0) digest.update(buffer, 0, read) + } + } + "${digest.digest().encodeHex().toString().toUpperCase()} ${candidate.name}" + }.sort() + if (actual != checksums.readLines('UTF-8').findAll { !it.trim().isEmpty() }.sort()) { + throw new GradleException('Prepared release checksums do not match') + } + } +} + +def mavenUploadUrl = providers.environmentVariable('MAVEN_UPLOAD_URL') + .orElse('https://invalid.invalid/missing-maven-upload-url') +def mavenUploadUsername = providers.environmentVariable('MAVEN_UPLOAD_USERNAME') +def mavenUploadPassword = providers.environmentVariable('MAVEN_UPLOAD_PASSWORD') +publishing { + publications { + mavenJava(MavenPublication) { + groupId = 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 { + artifact(releaseJar) + artifact(tasks.named('sourcesJar')) + artifact(tasks.named('javadocJar')) + } + pom { + name = 'MMD OreSpawn' + description = project.mod_description + url = 'https://github.com/MinecraftModDevelopmentMods/OreSpawn' + licenses { + license { + name = 'GNU Lesser General Public License, Version 2.1' + url = 'https://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt' + } + } + } + } + } + repositories { + maven { + name = 'release' + url = uri(mavenUploadUrl.get()) + credentials { + username = mavenUploadUsername.orNull ?: '' + password = mavenUploadPassword.orNull ?: '' + } + } + } +} +tasks.register('validateMavenReleaseCredentials') { + group = 'publishing' + doLast { + if (!providers.environmentVariable('MAVEN_UPLOAD_URL').isPresent() + || !mavenUploadUsername.isPresent() || !mavenUploadPassword.isPresent()) { + throw new GradleException( + 'MAVEN_UPLOAD_URL, MAVEN_UPLOAD_USERNAME, and MAVEN_UPLOAD_PASSWORD are required') + } + if (providers.environmentVariable('MAVEN_UPLOAD_URL').get().startsWith('file:')) { + throw new GradleException('Release publication must use a remote repository') + } + } +} +tasks.withType(PublishToMavenRepository).configureEach { + dependsOn tasks.named('validateMavenReleaseCredentials') + dependsOn preparedReleaseDir.isPresent() + ? tasks.named('verifyPreparedReleaseArtifacts') + : tasks.named('verifyReleaseArtifacts') +} + +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 syncForge31EclipseLaunches + 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.15 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.15.2-5.1.1.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}") + } + 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 31 merged output discovery") + } + } + } +} + +tasks.register('verifyCommandPortability') { + group = 'verification' + description = 'Rejects shell-specific launch wrappers and hard-coded classpath separators.' + doLast { + String gradleSource = file('build.gradle').getText('UTF-8') + List commandSources = [file('build.gradle')] + commandSources.addAll(fileTree('.github/workflows') { include '*.yml', '*.yaml' }.files) + commandSources.each { File source -> + String text = source.getText('UTF-8') + if (text =~ /(?i)(?:commandLine|executable|run:)\s*[^\n]*(?:cmd(?:\.exe)?\s+\/c|powershell(?:\.exe)?\s+-command|(?:bash|sh)\s+-c)/) { + throw new GradleException("Shell-specific command wrapper in ${source}") + } + } + if (!gradleSource.contains('commandLine(([javaExecutable.absolutePath] + arguments) as List)') + || !gradleSource.contains('join(File.pathSeparator)') + || !gradleSource.contains('${File.pathSeparator}')) { + throw new GradleException('Runtime commands and exploded-mod paths must use native argument/path APIs') + } + } +} + +tasks.named('check') { dependsOn tasks.named('verifyCommandPortability') } + +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 +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.15.2-31.2.57.jar') + File vanillaServer = new File(runtime, 'minecraft_server.1.15.2.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.15.2-31.2.57.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') + } + } +} + +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.15.2-forge-31.2.57' + File forgeJsonFile = new File(runtime, + "versions/${forgeVersionId}/${forgeVersionId}.json") + File baseJsonFile = new File(runtime, 'versions/1.15.2/1.15.2.json') + File baseJar = new File(runtime, 'versions/1.15.2/1.15.2.jar') + File nativesDirectory = new File(runtime, "natives/${forgeVersionId}") + [forgeJsonFile, baseJsonFile, baseJar, new File(runtime, 'libraries'), + new File(runtime, 'assets'), nativesDirectory].each { + if (!it.exists()) throw new GradleException("Incomplete official client runtime: ${it}") + } + + def slurper = new groovy.json.JsonSlurper() + Map forgeJson = (Map) slurper.parse(forgeJsonFile) + Map baseJson = (Map) slurper.parse(baseJsonFile) + Map classpathByModule = new LinkedHashMap<>() + [baseJson, forgeJson].each { Map metadata -> + ((List) metadata.libraries).each { Map library -> + List rules = (List) library.rules + boolean allowed = rules == null || rules.isEmpty() + if (rules != null) { + rules.each { Map rule -> + Map os = (Map) rule.os + boolean matches = os == null + || (os.name == 'windows' + && (os.arch == null || os.arch == System.getProperty('os.arch'))) + if (matches) allowed = rule.action == 'allow' + } + } + 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 31 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..ffd883d0 --- /dev/null +++ b/ci-fixtures/README.md @@ -0,0 +1,12 @@ +# OreSpawn 1.15.2 CI fixtures + +These immutable inputs make the legacy-Mineralogy compatibility gate +self-contained. They are test oracles only and must never enter a Gradle +dependency configuration, Eclipse launch, or published OreSpawn artifact. + +`Mineralogy-1.15.2-5.1.1.jar` was reproduced from the exact historical +MinecraftMineralogy source commit +`e1d324fd77ce33bc040cb870864238b37e34d27f` using Java 8 and the original +ForgeGradle 3 / Gradle 4.10.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..c280acbb --- /dev/null +++ b/ci-fixtures/SHA256SUMS @@ -0,0 +1 @@ +C22060D02578044BF1B9D571881EA76D3235543358C508696B67C88552BF0FA7 artifacts/Mineralogy-1.15.2-5.1.1.jar diff --git a/ci-fixtures/artifacts/Mineralogy-1.15.2-5.1.1.jar b/ci-fixtures/artifacts/Mineralogy-1.15.2-5.1.1.jar new file mode 100644 index 00000000..3d07159e Binary files /dev/null and b/ci-fixtures/artifacts/Mineralogy-1.15.2-5.1.1.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 4f08d42e..eb5050b2 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..ccdb2597 100644 --- a/docs/VERSIONS.md +++ b/docs/VERSIONS.md @@ -49,9 +49,11 @@ 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.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 +140,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 to it. Forge 1.15.2 then advanced to `4.0.9.115021` for a +target-qualified 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 b0e65b77..2e0c14c0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,6 +2,10 @@ # 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 minecraft_version=1.15.2 minecraft_version_range=[1.15.2] @@ -15,7 +19,14 @@ mcp_version=20200515.085601 mod_id=orespawn mod_name=MMD OreSpawn mod_license=LGPL-2.1 -mod_version=4.0.6.115021 -mod_group_id=zone.moddev.mc.orespawn +mod_version=4.0.9.115021 +mod_group=zone.moddev.mc mod_authors=SkyBlade1978, dshadowwolf, the MMD Team mod_description=Configurable, provider-driven terrain, ore, and deposit generation. + +loader_name=forge +loader_code=1 +java_version=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 7a3265ee..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 1d5b29fb..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-9.6.1-bin.zip +distributionSha256Sum=9c0f7faeeb306cb14e4279a3e084ca6b596894089a0638e68a07c945a32c9e14 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.3-bin.zip 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 7db3412b..ed347bc3 100644 --- a/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java +++ b/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java @@ -108,6 +108,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"), @@ -128,9 +137,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(); @@ -243,6 +254,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); @@ -368,6 +381,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++) { @@ -436,22 +456,99 @@ 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 " + DimensionType.getKey(level.dimension.getType()) + ": 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.setPos(x, groundY - 12, z)).getBlock() == Blocks.DIORITE) { + rawConverted++; + } + Block pocket = chunk.getBlockState(pos.setPos(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.setPos(x, groundY - 16, z)).getBlock() + == NATURAL_SOURCES[index]) { + structurePreserved++; + } + if (chunk.getBlockState(pos.setPos(x, groundY - 20, z)).getBlock() + == NATURAL_SOURCES[index]) { + vegetationPreserved++; + } + } + int bedrockGroundY = findMarkedGround(chunk, pos, minX + 11, minZ + 12, 0, 256); + if (chunk.getBlockState(pos.setPos(minX + 11, bedrockGroundY - 24, minZ + 12)).getBlock() + == Blocks.BEDROCK) { + bedrockPreserved++; + } + int chestGroundY = findMarkedGround(chunk, pos, minX + 12, minZ + 12, 0, 256); + pos.setPos(minX + 12, chestGroundY - 24, minZ + 12); + if (chunk.getBlockState(pos).getBlock() == Blocks.CHEST + && level.getTileEntity(pos) instanceof ChestTileEntity) { + ChestTileEntity chest = (ChestTileEntity) level.getTileEntity(pos); + if (chest != null && chest.getStackInSlot(0).getItem() == Items.EMERALD + && RAW_CHEST_ITEM_NAME.equals( + chest.getStackInSlot(0).getDisplayName().getString())) { + blockEntityPreserved++; + } + } + return new NaturalSourceAudit(rawConverted, structurePreserved, + vegetationPreserved, cavePreserved, underwaterPreserved, + bedrockPreserved, blockEntityPreserved); } private static long auditDynamicFluid(ServerWorld level) { @@ -589,6 +686,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; } @@ -683,9 +787,37 @@ private static boolean prepareTerrain(IWorld world, IChunk chunk) { } } } + if (!roofed) placeRawNaturalSources(world, chunk, pos, minX, minZ); return true; } + private static void placeRawNaturalSources(IWorld 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.setPos(x, groundY - 12, z), + NATURAL_SOURCES[index].getDefaultState(), false); + chunk.setBlockState(pos.setPos(x, groundY - 11, z), + (index < NATURAL_SOURCES.length / 2 ? Blocks.AIR : Blocks.WATER) + .getDefaultState(), false); + } + int bedrockGroundY = findMarkedGround(chunk, pos, minX + 11, minZ + 12, 0, 256); + chunk.setBlockState(pos.setPos(minX + 11, bedrockGroundY - 24, minZ + 12), + Blocks.BEDROCK.getDefaultState(), false); + int chestGroundY = findMarkedGround(chunk, pos, minX + 12, minZ + 12, 0, 256); + world.setBlockState(pos.setPos(minX + 12, chestGroundY - 24, minZ + 12), + Blocks.CHEST.getDefaultState(), 2); + if (world.getTileEntity(pos) instanceof ChestTileEntity) { + ChestTileEntity chest = (ChestTileEntity) world.getTileEntity(pos); + ItemStack sentinel = new ItemStack(Items.EMERALD); + sentinel.setDisplayName(new StringTextComponent(RAW_CHEST_ITEM_NAME)); + chest.setInventorySlotContents(0, sentinel); + chest.markDirty(); + } + } + private static boolean solid(BlockState state) { return !state.isAir() && state.getFluidState().isEmpty(); } @@ -707,6 +839,7 @@ private static boolean placeStructureSentinels(IWorld world, IChunk chunk) { chest.setInventorySlotContents(0, sentinel); chest.markDirty(); } + placeAuthoredNaturalSources(world, chunk, pos, minX, minZ, 16); return true; } @@ -723,9 +856,30 @@ private static boolean placeVegetationSentinels(IWorld world, IChunk chunk) { int vegetationY = markedGround(chunk, pos, minX + 6, minZ + 6, world); world.setBlockState(pos.setPos(minX + 6, vegetationY + 1, minZ + 6), Blocks.DIRT.getDefaultState(), 2); world.setBlockState(pos.setPos(minX + 6, vegetationY + 2, minZ + 6), Blocks.OAK_SAPLING.getDefaultState(), 2); + placeAuthoredNaturalSources(world, chunk, pos, minX, minZ, 20); return true; } + private static void placeAuthoredNaturalSources(IWorld world, IChunk chunk, + BlockPos.Mutable pos, int minX, int minZ, int depth) { + if (!OPEN_ID.equals(DimensionType.getKey(world.getWorld().dimension.getType()))) 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.setBlockState(pos.setPos(x, groundY - depth, z), + NATURAL_SOURCES[index].getDefaultState(), 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, IWorld world) { return findMarkedGround(chunk, pos, x, z, 0, 256); @@ -758,6 +912,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; @@ -770,10 +946,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; @@ -785,6 +970,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; } @@ -798,5 +990,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..d598d111 --- /dev/null +++ b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java @@ -0,0 +1,366 @@ +package zone.moddev.mc.orespawn.client; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.HashSet; +import java.util.List; +import java.util.Properties; +import java.util.Set; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; + +import net.minecraft.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.util.text.TextFormatting; +import net.minecraft.world.GameType; +import net.minecraft.world.WorldSettings; +import net.minecraft.world.WorldType; +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 (button instanceof Button) probe.worldSettingsButton = button; + } + } + + @SubscribeEvent + public static void onScreenDrawn(GuiScreenEvent.DrawScreenEvent.Post event) { + ClientProbeTestMod probe = instance; + if (probe != null && Boolean.getBoolean("clientprobe.enabled") + && event.getGui() instanceof OreSpawnScreen) 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.currentScreen instanceof MainMenuScreen) { + minecraft.displayGuiScreen(new CreateWorldScreen(minecraft.currentScreen)); + nextState(1); + } + break; + case 1: + if (worldSettingsButton == null && worldCreationButtons != null) { + for (Widget candidate : worldCreationButtons) { + if (candidate instanceof Button) worldSettingsButton = candidate; + } + } + if (minecraft.currentScreen instanceof CreateWorldScreen && worldSettingsButton != null) { + // Forge 31 invokes the target-native OreSpawn button callback directly. + ((Button) worldSettingsButton).onPress(); + nextState(2); + } + break; + case 2: + if (minecraft.currentScreen instanceof OreSpawnWorldSettingsScreen && editorFrames >= 2) { + worldSettingsOpened = true; + validateCaptions((OreSpawnWorldSettingsScreen) minecraft.currentScreen); + validateLongEditorRoundTrip(minecraft, minecraft.currentScreen); + nextState(3); + } + break; + case 3: + if (minecraft.currentScreen instanceof OreSpawnWorldSettingsScreen) { + OreSpawnWorldSettingsScreen root = (OreSpawnWorldSettingsScreen) minecraft.currentScreen; + Button target = nextNavigationButton(root); + if (target == null) { + if (editorRoutes.size() < 5) fail(minecraft, + "Only exercised " + editorRoutes.size() + " editor routes: " + editorRoutes); + root.onClose(); + nextState(5); + } else { + Screen before = minecraft.currentScreen; + target.onPress(); + if (minecraft.currentScreen != before && minecraft.currentScreen instanceof OreSpawnScreen) { + editorRoutes.add(minecraft.currentScreen.getClass().getSimpleName()); + editorFrames = 0; + nextState(4); + } + } + } + break; + case 4: + if (minecraft.currentScreen instanceof OreSpawnScreen && editorFrames >= 2) { + validateCaptions((OreSpawnScreen) minecraft.currentScreen); + ((OreSpawnScreen) minecraft.currentScreen).onClose(); + nextState(3); + } + break; + case 5: + if (minecraft.currentScreen instanceof CreateWorldScreen) { + minecraft.launchIntegratedServer(WORLD_DIRECTORY, "OreSpawn Client Smoke", + new WorldSettings(0L, GameType.CREATIVE, false, false, WorldType.DEFAULT)); + nextState(6); + } + break; + case 6: + if (minecraft.world != null && minecraft.player != null && firstWorldFrames >= 8 + && stateTicks >= 100) { + stopIntegratedServer(minecraft); + nextState(7); + } + break; + case 7: + if (minecraft.world == null && !minecraft.isIntegratedServerRunning() && stateTicks >= 20) { + minecraft.launchIntegratedServer(WORLD_DIRECTORY, "OreSpawn Client Smoke", + new WorldSettings(0L, GameType.CREATIVE, false, false, WorldType.DEFAULT)); + nextState(8); + } + break; + case 8: + if (minecraft.world != null && minecraft.player != null && reloadWorldFrames >= 8 + && stateTicks >= 100) { + stopIntegratedServer(minecraft); + nextState(9); + } + break; + case 9: + if (minecraft.world == null && !minecraft.isIntegratedServerRunning()) { + writeMarker(); + minecraft.shutdown(); + nextState(10); + } + break; + default: + break; + } + } catch (RuntimeException | IOException failure) { + fail(minecraft, failure.toString()); + } + } + + private Button nextNavigationButton(OreSpawnWorldSettingsScreen root) { + for (Widget widget : root.qualificationButtons()) { + if (!(widget instanceof Button) || widget instanceof CycleButton) continue; + Button button = (Button) widget; + String caption = TextFormatting.getTextWithoutFormattingCodes(button.getMessage()); + 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(OreSpawnScreen screen) { + for (Widget widget : screen.qualificationButtons()) { + String caption = TextFormatting.getTextWithoutFormattingCodes(widget.getMessage()); + 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"); + oreScreen.init(minecraft, 640, 480); + pressDone(oreScreen); + + FluidDepositDimensionScreen fluidScreen = new FluidDepositDimensionScreen(parent, session, + "example:long_editor_deposit", "minecraft:overworld"); + fluidScreen.init(minecraft, 640, 480); + 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 pressDone(OreSpawnScreen screen) { + for (Widget widget : screen.qualificationButtons()) { + if (!(widget instanceof Button)) continue; + String caption = TextFormatting.getTextWithoutFormattingCodes(((Button) widget).getMessage()); + if ("done".equalsIgnoreCase(caption)) { + ((Button) widget).onPress(); + return; + } + } + throw new IllegalStateException("Editor did not expose its Done action: " + + screen.getClass().getSimpleName()); + } + + private static void stopIntegratedServer(Minecraft minecraft) { + // Match Forge 31'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.world != null) minecraft.world.sendQuittingDisconnectingPacket(); + minecraft.unloadWorld(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.15.2 client integration gate"); + } + } + + private void nextState(int next) { + state = next; + stateTicks = 0; + } + + private static void fail(Minecraft minecraft, String message) { + try { + Properties values = new Properties(); values.setProperty("failure", message); + try (FileOutputStream output = new FileOutputStream(new File("client-smoke-failure.properties"))) { + values.store(output, "OreSpawn client probe failure"); + } + } catch (IOException ignored) { + } + minecraft.shutdown(); + 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..053ebc1b --- /dev/null +++ b/src/clientIntegrationTest/resources/META-INF/mods.toml @@ -0,0 +1,30 @@ +modLoader="javafml" +loaderVersion="[31,)" +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="[31,)" +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.15.2]" +ordering="NONE" +side="CLIENT" diff --git a/src/clientIntegrationTest/resources/pack.mcmeta b/src/clientIntegrationTest/resources/pack.mcmeta new file mode 100644 index 00000000..ebb6f031 --- /dev/null +++ b/src/clientIntegrationTest/resources/pack.mcmeta @@ -0,0 +1,8 @@ +{ + "pack": { + "description": "OreSpawn client qualification fixture", + "forge:resource_pack_format": 5, + "forge:data_pack_format": 5, + "pack_format": 5 + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/api/WorldgenProvider.java b/src/main/java/zone/moddev/mc/orespawn/api/WorldgenProvider.java index 25e9a0ad..bf8d421e 100644 --- a/src/main/java/zone/moddev/mc/orespawn/api/WorldgenProvider.java +++ b/src/main/java/zone/moddev/mc/orespawn/api/WorldgenProvider.java @@ -1272,7 +1272,7 @@ public Builder fluidDeposit(FluidDepositDefinition value) { profile.addProperty("place_fluid_deposits", true); return this; } - /** @deprecated Use {@link #fluidDeposit(FluidDepositDefinition)}. */ + /** @deprecated Use {@code fluidDeposit(FluidDepositDefinition)}. */ @Deprecated public Builder oil(OilDefinition value) { profile.add("oil", value.toJson()); 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 76f5247d..d68d6327 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/FluidDepositDimensionScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/FluidDepositDimensionScreen.java @@ -155,7 +155,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; @@ -164,7 +164,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; @@ -174,7 +174,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 6470e31c..15cf7518 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/OreDimensionScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreDimensionScreen.java @@ -234,8 +234,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; @@ -247,8 +247,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; @@ -256,8 +256,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/client/OreSpawnScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnScreen.java index 96809684..76f646c0 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnScreen.java @@ -5,6 +5,7 @@ import net.minecraft.client.gui.FontRenderer; import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.gui.widget.Widget; import net.minecraft.util.text.ITextComponent; /** @@ -32,4 +33,9 @@ protected final void renderComponentTooltip(List lines for (ITextComponent line : lines) text.add(line.getFormattedText()); renderTooltip(text, mouseX, mouseY); } + + /** Package-private view used by the separately packaged client qualification fixture. */ + final List qualificationButtons() { + return buttons; + } } 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 c6400d0d..e69a401a 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/BakedTerrainDimension.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/BakedTerrainDimension.java @@ -7,6 +7,7 @@ import net.minecraft.util.ResourceLocation; 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 { @@ -36,6 +37,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 b78a1127..dc47c439 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeFeatureInstaller.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeFeatureInstaller.java @@ -62,7 +62,6 @@ static void installFeatures(Biome biome, boolean terrain, if (vanillaOreGate) VanillaOreFeatureGate.wrapFeatureList(underground); if (terrain) { StoneReplacer.removeVanillaMatchingStoneFeatures(underground); - addUnique(underground, StoneReplacer.configuredFeature()); } if (managedOres) addUnique(underground, OreSpawnOreGeneration.configuredFeature()); if (fluidDeposits) addUnique(underground, FluidDepositFeature.configuredFeature()); @@ -71,13 +70,22 @@ static void installFeatures(Biome biome, boolean terrain, VanillaOreFeatureGate.wrapFeatureList( biome.getFeatures(GenerationStage.Decoration.UNDERGROUND_DECORATION)); } - installSurfaceStages(biome, surfaces, flatBedrock); + installSurfaceStages(biome, terrain, surfaces, flatBedrock); } - static boolean installSurfaceStages(Biome biome, boolean surfaces, boolean flatBedrock) { - boolean changed = surfaces && addUnique(biome.getFeatures( - GenerationStage.Decoration.LOCAL_MODIFICATIONS), - BiomeSurfaceFeature.configuredFeature()); + static boolean installSurfaceStages(Biome biome, boolean terrain, + boolean surfaces, boolean flatBedrock) { + List> local = + biome.getFeatures(GenerationStage.Decoration.LOCAL_MODIFICATIONS); + boolean changed = false; + if (terrain) { + changed |= placeUniqueAt(local, StoneReplacer.configuredFeature(), 0); + if (surfaces) { + changed |= placeUniqueAt(local, BiomeSurfaceFeature.configuredFeature(), 1); + } + } else if (surfaces) { + changed |= addUnique(local, BiomeSurfaceFeature.configuredFeature()); + } changed |= flatBedrock && addUnique(biome.getFeatures( GenerationStage.Decoration.TOP_LAYER_MODIFICATION), FlatBedrockFeature.configuredFeature()); @@ -98,4 +106,15 @@ private static boolean addUnique(List> features, features.add(feature); return true; } + + private static boolean placeUniqueAt(List> features, + ConfiguredFeature feature, int index) { + if (feature == null) return false; + int current = features.indexOf(feature); + 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; + } } 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 efb17604..63fdb557 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/Geology.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/Geology.java @@ -111,8 +111,9 @@ public void replaceStoneInChunk(IWorld world, IChunk chunk, BakedTerrainDimensio for (; y >= 0; y--) { cursor.setPos(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.getTileEntity(cursor) == null) { BlockState replacement = pickReplacement(baseRockVal, geomeBase, y); if (!GeomeGeology.changes(current, 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 6d78292a..bf1eba5d 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeGeology.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeGeology.java @@ -115,7 +115,7 @@ public void replaceStoneInChunk(IWorld world, IChunk chunk, BakedTerrainDimensio for (int y = surfaceY; y >= 0; y--) { cursor.setPos(x, y, z); BlockState current = chunk.getBlockState(cursor); - if (terrain.isReplaceable(current)) { + if (terrain.isReplaceable(current) && chunk.getTileEntity(cursor) == null) { BlockState replacement = pickReplacement( geomeIndex, baseRockValue, formationRegion, x, y, z); if (!changes(current, replacement)) continue; @@ -155,7 +155,8 @@ private boolean replaceStableColumn(IChunk chunk, BlockPos.Mutable cursor, int g } cursor.setY(y); BlockState current = chunk.getBlockState(cursor); - if (terrain.isReplaceable(current) && changes(current, replacement)) { + if (terrain.isReplaceable(current) && chunk.getTileEntity(cursor) == null + && changes(current, replacement)) { 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 5dbebd68..417e7e71 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.115021 Upgrade Report"); + lines.add("OreSpawn 4.0.9.115021 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 71fbe8d7..02cb8672 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.115021 Upgrade Report"); + lines.add("OreSpawn 4.0.9.115021 Upgrade Report"); lines.add("================================"); lines.add(""); lines.add("RESULT: Existing Mineralogy " + identity.version + " world detected."); diff --git a/src/main/resources/META-INF/accesstransformer.cfg b/src/main/resources/META-INF/accesstransformer.cfg index 7abf2975..8a4c85c9 100644 --- a/src/main/resources/META-INF/accesstransformer.cfg +++ b/src/main/resources/META-INF/accesstransformer.cfg @@ -1,5 +1,5 @@ -public-f net.minecraft.world.gen.ChunkGenerator field_222542_c # biomeProvider -public net.minecraft.world.biome.provider.BiomeProvider field_226837_c_ # biomes -public-f net.minecraft.world.gen.NoiseChunkGenerator field_222560_g # defaultFluid -public-f net.minecraft.world.gen.feature.LiquidsConfig field_227366_f_ # acceptedBlocks -public net.minecraft.world.biome.Biome field_201874_aj # structures +public-f net.minecraft.world.gen.ChunkGenerator biomeProvider +public net.minecraft.world.biome.provider.BiomeProvider biomes +public-f net.minecraft.world.gen.NoiseChunkGenerator defaultFluid +public-f net.minecraft.world.gen.feature.LiquidsConfig acceptedBlocks +public net.minecraft.world.biome.Biome structures 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/ClientButtonTextTest.java b/src/test/java/zone/moddev/mc/orespawn/client/ClientButtonTextTest.java index 1b812010..d3c45f15 100644 --- a/src/test/java/zone/moddev/mc/orespawn/client/ClientButtonTextTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/client/ClientButtonTextTest.java @@ -65,4 +65,5 @@ void everyLiteralClientTranslationKeyExistsOnTheTarget() throws Exception { assertTrue(missing.isEmpty(), "Client labels must exist in OreSpawn or Minecraft 1.15: " + missing); } + } 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..429ad8de --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/client/ClientTextFieldPersistenceTest.java @@ -0,0 +1,54 @@ +package zone.moddev.mc.orespawn.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; + +import net.minecraft.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 ad74796f..59417516 100644 --- a/src/test/java/zone/moddev/mc/orespawn/worldgen/BiomeSurfaceFeatureOrderTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/BiomeSurfaceFeatureOrderTest.java @@ -3,6 +3,8 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.List; + import net.minecraft.util.registry.Bootstrap; import net.minecraft.world.biome.Biome; import net.minecraft.world.gen.GenerationStage; @@ -35,7 +37,7 @@ void staticInstallerKeepsSurfacesEarlyAndBedrockLast() throws ReflectiveOperatio .surfaceBuilder(SurfaceBuilder.DEFAULT, SurfaceBuilder.GRASS_DIRT_GRAVEL_CONFIG)); BiomeFeatureInstaller.installFeatures(biome, true, true, true, true, true, true); - assertTrue(biome.getFeatures(GenerationStage.Decoration.UNDERGROUND_ORES) + assertFalse(biome.getFeatures(GenerationStage.Decoration.UNDERGROUND_ORES) .contains(StoneReplacer.configuredFeature())); assertTrue(biome.getFeatures(GenerationStage.Decoration.UNDERGROUND_ORES) .contains(OreSpawnOreGeneration.configuredFeature())); @@ -43,7 +45,11 @@ void staticInstallerKeepsSurfacesEarlyAndBedrockLast() throws ReflectiveOperatio .contains(FluidDepositFeature.configuredFeature())); ConfiguredFeature surfaces = BiomeSurfaceFeature.configuredFeature(); ConfiguredFeature bedrock = FlatBedrockFeature.configuredFeature(); - assertTrue(biome.getFeatures(GenerationStage.Decoration.LOCAL_MODIFICATIONS).contains(surfaces)); + List> local = + biome.getFeatures(GenerationStage.Decoration.LOCAL_MODIFICATIONS); + assertTrue(local.size() >= 2); + assertTrue(local.get(0) == StoneReplacer.configuredFeature()); + assertTrue(local.get(1) == surfaces); assertFalse(biome.getFeatures(GenerationStage.Decoration.TOP_LAYER_MODIFICATION).contains(surfaces)); assertTrue(biome.getFeatures(GenerationStage.Decoration.TOP_LAYER_MODIFICATION).contains(bedrock)); assertFalse(biome.getFeatures(GenerationStage.Decoration.LOCAL_MODIFICATIONS).contains(bedrock)); 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 e5fa99b4..a2370337 100644 --- a/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyGeologyParityTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyGeologyParityTest.java @@ -35,7 +35,7 @@ static void bootstrapMinecraftRegistries() { } @Test - void cyanoSamplerMatchesPublishedMineralogy501AndSealedVectors() throws Exception { + void cyanoSamplerMatchesPublishedMineralogy511AndSealedVectors() throws Exception { Block[] igneous = { Blocks.STONE, Blocks.OBSIDIAN, Blocks.NETHERRACK }; Block[] metamorphic = { Blocks.COBBLESTONE, Blocks.MOSSY_COBBLESTONE }; Block[] sedimentary = { Blocks.SANDSTONE, Blocks.GRAVEL, Blocks.COAL_ORE, @@ -43,38 +43,35 @@ void cyanoSamplerMatchesPublishedMineralogy501AndSealedVectors() 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.1.1 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.0.1 mismatch at " - + seed + ":" + x + ":" + y + ":" + z); - } + assertEquals(sampler.getStoneAt(x, y, z), actual, + "Published Mineralogy 5.1.1 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.0.1 sampler"); - if (oracle != null) { - assertTrue(Files.isRegularFile(oracle), "Configured Mineralogy oracle is missing: " + oracle); - } + "The sealed vector digest is generated from the exact published Mineralogy 5.1.1 sampler"); } 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 12dd0690..b1658102 100644 --- a/src/test/java/zone/moddev/mc/orespawn/worldgen/StoneReplacerTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/StoneReplacerTest.java @@ -3,6 +3,9 @@ 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.BeforeAll; import org.junit.jupiter.api.Test; @@ -39,4 +42,21 @@ 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( + new net.minecraft.util.ResourceLocation("surfaceprobe:the_end"), + Collections.emptySet(), Collections.emptySet(), hosts); + + assertFalse(terrain.isReplaceable(Blocks.AIR.getDefaultState())); + assertFalse(terrain.isReplaceable(Blocks.WATER.getDefaultState())); + assertFalse(terrain.isReplaceable(Blocks.BEDROCK.getDefaultState())); + assertTrue(terrain.isReplaceable(Blocks.DIRT.getDefaultState())); + } }