From 92251f643e07189cf31802df429d9cc654a53e3f Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Wed, 21 Oct 2026 14:10:20 +0100 Subject: [PATCH 1/5] Fix corpse recovery and update the Minecraft 1.10.2 build --- .gitattributes | 15 + .github/workflows/ci.yml | 238 +++++++ .github/workflows/codeql-analysis.yml | 74 +++ .github/workflows/deploy-release.yml | 335 ++++++++++ .github/workflows/release-on-tag.yml | 60 ++ .github/workflows/validate-gradle-build.yml | 25 + .gitignore | 14 + .travis.yml | 12 - CHANGELOG.txt | 21 + LICENSE | 617 +++++++++++++---- LICENSE-CC0 | 116 ++++ NOTICE | 11 + README.md | 40 +- build.gradle | 279 +++++--- docs/RELEASING.md | 19 + docs/VERSIONS.md | 31 + gradle.properties | 34 + gradle/audit.gradle | 106 +++ gradle/ide.gradle | 100 +++ gradle/log4j2.xml | 8 + gradle/release.gradle | 181 +++++ gradle/runtime.gradle | 200 ++++++ gradle/wrapper/gradle-wrapper.jar | Bin 54413 -> 48462 bytes gradle/wrapper/gradle-wrapper.properties | 7 +- gradlew | 302 +++++---- gradlew.bat | 166 +++-- mcmod.info | 16 - resources/mcmod.info | 1 - settings.gradle | 4 + .../java/buildcontrol/ForgeControl.java | 28 + .../lootable/buildtest/ClientChecks.java | 125 ++++ .../buildtest/ConfigClientChecks.java | 201 ++++++ .../buildtest/ConnectedClientChecks.java | 205 ++++++ .../buildtest/ContainerInteractionChecks.java | 403 +++++++++++ .../lootable/buildtest/DropClientChecks.java | 118 ++++ .../lootable/buildtest/EffectHurtChecks.java | 95 +++ .../buildtest/HealthAttributeChecks.java | 130 ++++ .../buildtest/InventorySafetyChecks.java | 367 ++++++++++ .../lootable/buildtest/MultiplayerChecks.java | 120 ++++ .../buildtest/MultiplayerClientChecks.java | 79 +++ .../buildtest/NameLifetimeChecks.java | 117 ++++ .../lootable/buildtest/NbtClientChecks.java | 94 +++ .../lootable/buildtest/NbtRecoveryChecks.java | 120 ++++ .../lootable/buildtest/NbtWorldChecks.java | 115 ++++ .../buildtest/PositionMotionChecks.java | 82 +++ .../buildtest/ReportedIssueChecks.java | 253 +++++++ .../buildtest/ReportedIssueClientChecks.java | 157 +++++ .../buildtest/RespawnPacketChecks.java | 125 ++++ .../buildtest/RespawnPacketClientChecks.java | 128 ++++ .../buildtest/RespawnPacketTrace.java | 66 ++ .../buildtest/RotationFallChecks.java | 80 +++ .../lootable/buildtest/RuntimeChecks.java | 247 +++++++ .../lootable/buildtest/VisibilityChecks.java | 154 +++++ .../buildtest/VisibilityClientChecks.java | 104 +++ .../buildtest/VisibilitySkinChecks.java | 81 +++ .../buildtest/VisibilitySkinClientChecks.java | 53 ++ src/integrationTest/resources/log4j2.xml | 8 + src/integrationTest/resources/mcmod.info | 1 + src/main/java/cyano/lootable/ClientProxy.java | 1 + .../java/cyano/lootable/LootableBodies.java | 46 +- .../lootable/entities/EntityLootableBody.java | 625 ++++++++++++++++-- .../events/PlayerDeathEventHandler.java | 57 +- .../graphics/ClientConfigChanges.java | 24 + .../lootable/graphics/CorpseConfigGui.java | 78 +++ .../graphics/CorpseConfigGuiFactory.java | 18 + .../lootable/graphics/CorpseContainer.java | 190 +++++- .../lootable/graphics/CorpseRenderer.java | 10 +- .../assets/lootablebodies/lang/en_US.lang | 28 + src/main/resources/mcmod.info | 9 +- .../lootable/contracts/BuildContractTest.java | 65 ++ .../lootable/contracts/ConfigScreenTest.java | 119 ++++ .../contracts/ReleaseContractTest.java | 86 +++ tools/audit_runtime_logs.py | 35 + tools/release_identity.py | 9 + tools/verify_multiplayer.py | 109 +++ tools/verify_nbt_recovery.py | 25 + tools/verify_packaged.py | 129 ++++ tools/verify_packaged_client.py | 111 ++++ tools/verify_release_setup.py | 130 ++++ 79 files changed, 8265 insertions(+), 527 deletions(-) create mode 100644 .gitattributes create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/codeql-analysis.yml create mode 100644 .github/workflows/deploy-release.yml create mode 100644 .github/workflows/release-on-tag.yml create mode 100644 .github/workflows/validate-gradle-build.yml delete mode 100644 .travis.yml create mode 100644 CHANGELOG.txt create mode 100644 LICENSE-CC0 create mode 100644 NOTICE create mode 100644 docs/RELEASING.md create mode 100644 docs/VERSIONS.md create mode 100644 gradle.properties create mode 100644 gradle/audit.gradle create mode 100644 gradle/ide.gradle create mode 100644 gradle/log4j2.xml create mode 100644 gradle/release.gradle create mode 100644 gradle/runtime.gradle delete mode 100644 mcmod.info delete mode 120000 resources/mcmod.info create mode 100644 settings.gradle create mode 100644 src/controlTest/java/buildcontrol/ForgeControl.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/ClientChecks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/ConfigClientChecks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/ConnectedClientChecks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/ContainerInteractionChecks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/DropClientChecks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/EffectHurtChecks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/HealthAttributeChecks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/InventorySafetyChecks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/MultiplayerChecks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/MultiplayerClientChecks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/NameLifetimeChecks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/NbtClientChecks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/NbtRecoveryChecks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/NbtWorldChecks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/PositionMotionChecks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/ReportedIssueChecks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/ReportedIssueClientChecks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/RespawnPacketChecks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/RespawnPacketClientChecks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/RespawnPacketTrace.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/RotationFallChecks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/RuntimeChecks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/VisibilityChecks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/VisibilityClientChecks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/VisibilitySkinChecks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/VisibilitySkinClientChecks.java create mode 100644 src/integrationTest/resources/log4j2.xml create mode 100644 src/integrationTest/resources/mcmod.info create mode 100644 src/main/java/cyano/lootable/graphics/ClientConfigChanges.java create mode 100644 src/main/java/cyano/lootable/graphics/CorpseConfigGui.java create mode 100644 src/main/java/cyano/lootable/graphics/CorpseConfigGuiFactory.java create mode 100644 src/test/java/cyano/lootable/contracts/BuildContractTest.java create mode 100644 src/test/java/cyano/lootable/contracts/ConfigScreenTest.java create mode 100644 src/test/java/cyano/lootable/contracts/ReleaseContractTest.java create mode 100644 tools/audit_runtime_logs.py create mode 100644 tools/release_identity.py create mode 100644 tools/verify_multiplayer.py create mode 100644 tools/verify_nbt_recovery.py create mode 100644 tools/verify_packaged.py create mode 100644 tools/verify_packaged_client.py create mode 100644 tools/verify_release_setup.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1e5f4c3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,15 @@ +* text=auto +*.java text eol=lf +*.gradle text eol=lf +*.properties text eol=lf +*.yml text eol=lf +*.py text eol=lf +*.md text eol=lf +*.txt text eol=lf +LICENSE* text eol=lf +NOTICE text eol=lf +*.lang text eol=lf +gradlew text eol=lf +gradlew.bat text eol=crlf +*.jar binary +*.png binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..65f9f93 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,238 @@ +name: LootableBodies 1.10 CI + +on: + push: + branches: + - master-1.10.2 + - 1.10.2 + - 'feature/**' + pull_request: + branches: + - master-1.10.2 + - 1.10.2 + +permissions: + contents: read + +concurrency: + group: lootablebodies-1.10-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + cold-forge-bootstrap: + name: Cold Forge bootstrap + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Install pinned Java 25 ForgeGradle Mavenizer runtime + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '25.0.3+9.0.LTS' + + - name: Install pinned Java 8 compilation toolchain + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '8.0.502+7' + + - name: Install pinned Java 17 Gradle runtime + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '17.0.1+12' + + - name: Bootstrap Forge from an empty cache + env: + GRADLE_USER_HOME: ${{ runner.temp }}/lootablebodies-cold-gradle + LOOTABLEBODIES_JAVA8_HOME: ${{ env.JAVA_HOME_8_X64 }} + run: | + set -euo pipefail + test ! -e .gradle + test ! -e "$GRADLE_USER_HOME" + mkdir -p "$GRADLE_USER_HOME" + chmod +x ./gradlew + ./gradlew classes --no-daemon --no-build-cache --stacktrace \ + -Dorg.gradle.java.installations.paths="$JAVA_HOME,$JAVA_HOME_8_X64,$JAVA_HOME_25_X64" \ + -Dorg.gradle.java.installations.auto-detect=false \ + -Dorg.gradle.java.installations.auto-download=false + + - name: Verify the same cache offline + env: + GRADLE_USER_HOME: ${{ runner.temp }}/lootablebodies-cold-gradle + LOOTABLEBODIES_JAVA8_HOME: ${{ env.JAVA_HOME_8_X64 }} + run: | + ./gradlew classes --offline --rerun-tasks --no-daemon --no-build-cache --stacktrace \ + -Dorg.gradle.java.installations.paths="$JAVA_HOME,$JAVA_HOME_8_X64,$JAVA_HOME_25_X64" \ + -Dorg.gradle.java.installations.auto-detect=false \ + -Dorg.gradle.java.installations.auto-download=false + + build: + name: Build, test, and audit + runs-on: ubuntu-latest + timeout-minutes: 60 + + steps: + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Install pinned Java 25 ForgeGradle Mavenizer runtime + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '25.0.3+9.0.LTS' + + - name: Install pinned Java 8 compilation toolchain + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '8.0.502+7' + + - name: Install pinned Java 17 Gradle runtime + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '17.0.1+12' + + - name: Pin production JDK + run: echo "LOOTABLEBODIES_JAVA8_HOME=$JAVA_HOME_8_X64" >> "$GITHUB_ENV" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6 + with: + cache-disabled: true + + - name: Make the wrapper executable + run: chmod +x ./gradlew + + - name: Build, test, and audit release artifacts + run: >- + ./gradlew clean check build javadoc forgeControlServer verifyReleaseArtifacts writeReleaseChecksums + inventoryRegressionTest containerInteractionTest dropCreativeInteractionTest nbtRecoveryTest + nameLifetimeTest healthAttributeTest effectHurtTest positionMotionTest rotationFallTest + verifyEclipseProductionClasspath --no-daemon --stacktrace + -Dorg.gradle.java.installations.paths="$JAVA_HOME,$JAVA_HOME_8_X64,$JAVA_HOME_25_X64" + -Dorg.gradle.java.installations.auto-detect=false + -Dorg.gradle.java.installations.auto-download=false + + - name: Verify real graphical client and corpse GUI + env: + LIBGL_ALWAYS_SOFTWARE: 'true' + run: xvfb-run -a ./gradlew clientIntegrationTest preparePackagedIntegrationProbe exportRuntimeClasspath --no-daemon + + - name: Verify exact packaged dedicated servers + run: >- + python3 tools/verify_packaged.py --java "$JAVA_HOME_8_X64/bin/java" + --output build/packaged-runtime + + - name: Verify malformed corpse NBT and returning items across JVMs + run: >- + python3 tools/verify_nbt_recovery.py --java "$JAVA_HOME_8_X64/bin/java" + --forge-home build/packaged-runtime/forge-distribution --output build/packaged-nbt + + - name: Audit complete development logs + run: >- + python3 tools/audit_runtime_logs.py --development + build/integration-run/fresh.log build/integration-run/reload.log build/integration-run/deferred.log + build/client-integration-run/logs/latest.log build/forge-control-run/logs/latest.log + + - name: Verify exact packaged client + env: + LIBGL_ALWAYS_SOFTWARE: 'true' + run: >- + xvfb-run -a python3 tools/verify_packaged_client.py --java "$JAVA_HOME_8_X64/bin/java" + --forge-jar build/packaged-runtime/forge-distribution/forge-1.10.2-12.18.3.2511-universal.jar + --output build/packaged-client --gradle-home "$HOME/.gradle" + + - name: Verify two real loopback clients and persisted shared inventory + env: + LIBGL_ALWAYS_SOFTWARE: 'true' + run: >- + xvfb-run -a python3 tools/verify_multiplayer.py --java "$JAVA_HOME_8_X64/bin/java" + --forge-home build/packaged-runtime/forge-distribution --gradle-home "$HOME/.gradle" + --output build/packaged-multiplayer + + - name: Compare a second forced build + run: | + mkdir -p "$RUNNER_TEMP/first-build" + cp build/libs/LootableBodies-2.4.1.110021{,-sources,-javadoc}.jar build/release/SHA256SUMS build/publications/mavenJava/pom-default.xml "$RUNNER_TEMP/first-build/" + ./gradlew assemble writeReleaseChecksums verifyMavenCoordinates --rerun-tasks --offline --no-daemon --no-build-cache + for name in LootableBodies-2.4.1.110021{,-sources,-javadoc}.jar; do + cmp "$RUNNER_TEMP/first-build/$name" "build/libs/$name" + done + cmp "$RUNNER_TEMP/first-build/SHA256SUMS" build/release/SHA256SUMS + cmp "$RUNNER_TEMP/first-build/pom-default.xml" build/publications/mavenJava/pom-default.xml + + - name: Prepare and test the local release contract + run: | + ./gradlew prepareReleaseBundle --offline --no-daemon + python3 tools/verify_release_setup.py --output build/release-contracts + + - name: Upload audited release candidate + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: LootableBodies-1.10.2-${{ github.sha }} + if-no-files-found: error + retention-days: 30 + path: | + build/libs/LootableBodies-2.4.1.110021.jar + build/libs/LootableBodies-2.4.1.110021-sources.jar + build/libs/LootableBodies-2.4.1.110021-javadoc.jar + build/release/SHA256SUMS + build/publications/mavenJava/pom-default.xml + CHANGELOG.txt + + - name: Upload diagnostics on failure + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: LootableBodies-1.10.2-diagnostics-${{ github.sha }} + if-no-files-found: error + retention-days: 14 + path: | + build/test-results/** + build/reports/** + build/release-contracts/** + build/*-run/logs/** + build/*-run/crash-reports/** + build/integration-run/world/inventory-checks.txt + build/integration-run/world/container-checks.txt + build/integration-run/world/drop-creative-checks.txt + build/integration-run/world/nbt-checks.txt + build/integration-run/world/name-lifetime-checks.txt + build/integration-run/world/health-attribute-checks.txt + build/integration-run/world/effect-hurt-checks.txt + build/integration-run/world/position-motion-checks.txt + build/integration-run/world/rotation-fall-checks.txt + build/packaged-runtime/**/*.log + build/packaged-runtime/**/crash-reports/** + build/packaged-runtime/candidate-fresh/world/inventory-checks.txt + build/packaged-runtime/candidate-fresh/world/container-checks.txt + build/packaged-runtime/candidate-fresh/world/drop-creative-checks.txt + build/packaged-runtime/candidate-fresh/world/nbt-checks.txt + build/packaged-runtime/candidate-fresh/world/name-lifetime-checks.txt + build/packaged-runtime/candidate-fresh/world/health-attribute-checks.txt + build/packaged-runtime/candidate-fresh/world/effect-hurt-checks.txt + build/packaged-runtime/candidate-fresh/world/position-motion-checks.txt + build/packaged-runtime/candidate-fresh/world/rotation-fall-checks.txt + build/packaged-nbt/**/*.log + build/packaged-nbt/**/crash-reports/** + build/packaged-nbt/candidate/world/nbt-world-checks.txt + build/client-integration-run/screenshots/*.png + build/client-integration-run/connected-checks.txt + build/client-integration-run/drop-client-checks.txt + build/client-integration-run/nbt-client-checks.txt + build/packaged-client/client.log + build/packaged-client/drop-client-checks.txt + build/packaged-client/nbt-client-checks.txt + build/packaged-client/connected-checks.txt + build/packaged-client/screenshots/*.png + build/packaged-multiplayer/**/*.log + build/packaged-multiplayer/**/crash-reports/** + build/packaged-multiplayer/clientA/screenshots/*.png + build/packaged-multiplayer/clientB/screenshots/*.png + build/packaged-multiplayer/sync/checks.txt + build/problems/** diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 0000000..8e95ee3 --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,74 @@ +name: CodeQL + +on: + push: + branches: + - master-1.10.2 + - 1.10.2 + - 'feature/**' + pull_request: + branches: + - master-1.10.2 + - 1.10.2 + schedule: + - cron: '43 7 * * 4' + +permissions: + actions: read + contents: read + security-events: write + +jobs: + analyze: + name: Analyze Java + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Install pinned Java 25 ForgeGradle Mavenizer runtime + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '25.0.3+9.0.LTS' + + - name: Install pinned Java 8 compilation toolchain + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '8.0.502+7' + + - name: Install pinned Java 17 Gradle runtime + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '17.0.1+12' + + - name: Pin production JDK + run: echo "LOOTABLEBODIES_JAVA8_HOME=$JAVA_HOME_8_X64" >> "$GITHUB_ENV" + + - 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 + build-mode: manual + + - name: Compile production code + run: | + chmod +x ./gradlew + gradle_args=( + clean classes --no-build-cache --rerun-tasks + --no-daemon --stacktrace --max-workers=2 + -Dorg.gradle.java.installations.paths="$JAVA_HOME,$JAVA_HOME_8_X64,$JAVA_HOME_25_X64" + -Dorg.gradle.java.installations.auto-detect=false + -Dorg.gradle.java.installations.auto-download=false + ) + ./gradlew "${gradle_args[@]}" + + - name: Analyze + uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4 diff --git a/.github/workflows/deploy-release.yml b/.github/workflows/deploy-release.yml new file mode 100644 index 0000000..1d7e23c --- /dev/null +++ b/.github/workflows/deploy-release.yml @@ -0,0 +1,335 @@ +name: Release Lootable Bodies +run-name: Release Lootable Bodies ${{ inputs.release_version }} (${{ inputs.curseforge_release_level }}) + +on: + workflow_dispatch: + inputs: + release_version: + description: Complete Minecraft 1.10.2 version from gradle.properties, ending in 110021. + required: true + type: string + curseforge_release_level: + description: CurseForge release level. + required: true + default: release + type: choice + options: [release, beta, alpha] + confirm_live_publication: + description: Publish the audited files to Maven, CurseForge and GitHub after all checks pass. + required: true + default: false + type: boolean + +permissions: + actions: read + checks: read + contents: read + +concurrency: + group: lootablebodies-release-${{ inputs.release_version }} + cancel-in-progress: false + +jobs: + preflight: + name: Validate release request + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + release_sha: ${{ steps.validate.outputs.release_sha }} + release_version: ${{ steps.validate.outputs.release_version }} + steps: + - name: Require the MMD dispatcher and a supported target + env: + RELEASE_VERSION: ${{ inputs.release_version }} + run: | + set -euo pipefail + [[ "$GITHUB_REPOSITORY" == 'MinecraftModDevelopmentMods/LootableBodies' ]] + [[ "$GITHUB_REF" == 'refs/heads/master-1.12' ]] + [[ "$RELEASE_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.110021$ ]] + - name: Check out the maintained Minecraft 1.10.2 branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: '1.10.2' + fetch-depth: 0 + - name: Validate identity, exact commit and successful checks + id: validate + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ inputs.release_version }} + run: | + set -euo pipefail + python3 - <<'PY' + import json, os, re, subprocess + from pathlib import Path + values = dict(line.split('=', 1) for line in Path('gradle.properties').read_text().splitlines() + if line.strip() and not line.lstrip().startswith('#') and '=' in line) + expected = {'minecraft_version': '1.10.2', 'forge_version': '12.18.3.2511', + 'loader_name': 'forge', 'loader_code': '1', 'java_version': '8', + 'java_toolchain_version': '8.0.502+7', 'gradle_java_version': '17', + 'mod_id': 'lootablebodies', 'mod_artifact': 'LootableBodies', + 'mod_group': 'zone.moddev.mc.lootablebodies', 'mod_license': 'LGPL-2.1-only', + 'curseforge_project_id': '240968'} + if any(values.get(key) != value for key, value in expected.items()): + raise SystemExit('Release identity differs from the maintained Minecraft 1.10.2 build') + version = os.environ['RELEASE_VERSION'] + if version != values['mod_version'] or not re.fullmatch(r'(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.110021', version): + raise SystemExit('Requested version does not match the selected branch') + sha = subprocess.check_output(['git', 'rev-parse', 'HEAD'], text=True).strip() + repo = os.environ['GITHUB_REPOSITORY'] + checks = json.loads(subprocess.check_output(['gh', 'api', f'repos/{repo}/commits/{sha}/check-runs?per_page=100'], text=True))['check_runs'] + required = {'Cold Forge bootstrap', 'Build, test, and audit', 'Analyze Java', 'Validation'} + for name in required: + matching = [check for check in checks if check['name'] == name] + if not matching or max(matching, key=lambda c: c['id'])['conclusion'] != 'success': + raise SystemExit(f'The selected commit has no successful {name} check') + release = subprocess.run(['gh', 'api', f'repos/{repo}/releases/tags/{version}'], capture_output=True, text=True) + if release.returncode == 0: + raise SystemExit('This GitHub release already exists; inspect the completed publication before retrying') + if '404' not in release.stderr: + raise SystemExit('Could not establish whether this release already exists') + tag = subprocess.run(['gh', 'api', f'repos/{repo}/git/ref/tags/{version}'], capture_output=True, text=True) + if tag.returncode == 0 and json.loads(tag.stdout)['object']['sha'] != sha: + raise SystemExit('Existing release tag points to another commit') + if tag.returncode != 0 and '404' not in tag.stderr: + raise SystemExit('Could not verify the release tag') + with open(os.environ['GITHUB_OUTPUT'], 'a') as out: + out.write(f'release_sha={sha}\nrelease_version={version}\n') + PY + + build: + name: Build immutable release artifact + needs: preflight + runs-on: ubuntu-latest + timeout-minutes: 60 + outputs: + artifact_name: ${{ steps.bundle.outputs.artifact_name }} + steps: + - name: Check out the verified commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ needs.preflight.outputs.release_sha }} + - name: Install Mavenizer Java + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '25.0.3+9.0.LTS' + - name: Install production Java + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '8.0.502+7' + - name: Install Gradle Java + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '17.0.1+12' + - name: Pin the production toolchain + run: echo "LOOTABLEBODIES_JAVA8_HOME=$JAVA_HOME_8_X64" >> "$GITHUB_ENV" + - name: Set up Gradle + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6 + - name: Build, test and audit the release + run: | + chmod +x ./gradlew + ./gradlew clean build javadoc verifyEclipseProductionClasspath prepareReleaseBundle \ + --no-daemon --stacktrace \ + -Dorg.gradle.java.installations.paths="$JAVA_HOME,$JAVA_HOME_8_X64,$JAVA_HOME_25_X64" \ + -Dorg.gradle.java.installations.auto-detect=false \ + -Dorg.gradle.java.installations.auto-download=false + - name: Stage the exact audited bundle + id: bundle + env: + RELEASE_VERSION: ${{ needs.preflight.outputs.release_version }} + RELEASE_SHA: ${{ needs.preflight.outputs.release_sha }} + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/release-bundle" + cp build/release/bundle/* "$RUNNER_TEMP/release-bundle/" + python3 - <<'PY' + import os + from pathlib import Path + prefix = 'LootableBodies-' + os.environ['RELEASE_VERSION'] + expected = {prefix + suffix for suffix in ['.jar', '-sources.jar', '-javadoc.jar', '.pom']} + expected.update({'SHA256SUMS', 'CHANGELOG.txt'}) + actual = {p.name for p in (Path(os.environ['RUNNER_TEMP']) / 'release-bundle').iterdir()} + if actual != expected: + raise SystemExit('Release bundle contains missing or unexpected files') + PY + (cd "$RUNNER_TEMP/release-bundle" && sha256sum --check SHA256SUMS) + echo "artifact_name=LootableBodies-$RELEASE_VERSION-$RELEASE_SHA-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" >> "$GITHUB_OUTPUT" + { + echo "Prepared Lootable Bodies $RELEASE_VERSION from commit $RELEASE_SHA." + echo + cat build/release/SHA256SUMS + } >> "$GITHUB_STEP_SUMMARY" + - name: Upload the immutable release bundle + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: ${{ steps.bundle.outputs.artifact_name }} + if-no-files-found: error + retention-days: 90 + path: ${{ runner.temp }}/release-bundle/* + + release_confirmation: + name: Confirm live publication + needs: [preflight, build] + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Require confirmation and release-secret access + env: + CONFIRM_LIVE_PUBLICATION: ${{ inputs.confirm_live_publication }} + CURSEFORGE_TOKEN: ${{ secrets.CURSEFORGE_TOKEN }} + MAVEN_UPLOAD_URL: ${{ secrets.MAVEN_UPLOAD_URL }} + MAVEN_UPLOAD_USERNAME: ${{ secrets.MAVEN_UPLOAD_USERNAME }} + MAVEN_UPLOAD_PASSWORD: ${{ secrets.MAVEN_UPLOAD_PASSWORD }} + run: | + set -euo pipefail + [[ "$CONFIRM_LIVE_PUBLICATION" == true ]] || { echo 'Live publication was not confirmed'; exit 1; } + for name in CURSEFORGE_TOKEN MAVEN_UPLOAD_URL MAVEN_UPLOAD_USERNAME MAVEN_UPLOAD_PASSWORD; do + [[ -n "${!name:-}" ]] || { echo "Required release secret is unavailable: $name"; exit 1; } + done + echo 'Publication confirmed; all required release secrets are available.' + + create_release_tag: + name: Create validated release tag + needs: [preflight, build, release_confirmation] + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + steps: + - name: Create or verify the immutable version tag + env: + GH_TOKEN: ${{ github.token }} + RELEASE_SHA: ${{ needs.preflight.outputs.release_sha }} + RELEASE_VERSION: ${{ needs.preflight.outputs.release_version }} + run: | + set -euo pipefail + if tag="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_VERSION" 2>/dev/null)"; then + [[ "$(jq -r '.object.sha' <<<"$tag")" == "$RELEASE_SHA" ]] + else + gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \ + -f ref="refs/tags/$RELEASE_VERSION" -f sha="$RELEASE_SHA" >/dev/null + fi + [[ "$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_VERSION" --jq '.object.sha')" == "$RELEASE_SHA" ]] + + publish_maven: + name: Publish Maven + needs: [preflight, build, create_release_tag] + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Check out the verified publication definition + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ needs.preflight.outputs.release_sha }} + - name: Install Mavenizer Java + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '25.0.3+9.0.LTS' + - name: Install production Java + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '8.0.502+7' + - name: Install Gradle Java + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '17.0.1+12' + - name: Pin the production toolchain + run: echo "LOOTABLEBODIES_JAVA8_HOME=$JAVA_HOME_8_X64" >> "$GITHUB_ENV" + - name: Set up Gradle + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6 + - name: Download the immutable release bundle + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: ${{ needs.build.outputs.artifact_name }} + path: ${{ runner.temp }}/release-input + - name: Verify release checksums + run: (cd "$RUNNER_TEMP/release-input" && sha256sum --check SHA256SUMS) + - name: Publish the prepared Maven artifacts + env: + MAVEN_UPLOAD_URL: ${{ secrets.MAVEN_UPLOAD_URL }} + MAVEN_UPLOAD_USERNAME: ${{ secrets.MAVEN_UPLOAD_USERNAME }} + MAVEN_UPLOAD_PASSWORD: ${{ secrets.MAVEN_UPLOAD_PASSWORD }} + LOOTABLEBODIES_RELEASE_SHA: ${{ needs.preflight.outputs.release_sha }} + LOOTABLEBODIES_RELEASE_VERSION: ${{ needs.preflight.outputs.release_version }} + run: | + chmod +x ./gradlew + ./gradlew publishMavenJavaPublicationToReleaseRepository \ + -PpreparedReleaseDir="$RUNNER_TEMP/release-input" --no-daemon --stacktrace \ + -Dorg.gradle.java.installations.paths="$JAVA_HOME,$JAVA_HOME_8_X64,$JAVA_HOME_25_X64" \ + -Dorg.gradle.java.installations.auto-detect=false \ + -Dorg.gradle.java.installations.auto-download=false + + publish_curseforge: + name: Publish CurseForge + needs: [preflight, build, publish_maven] + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Download the immutable release bundle + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: ${{ needs.build.outputs.artifact_name }} + path: ${{ runner.temp }}/release-input + - name: Verify release checksums + run: (cd "$RUNNER_TEMP/release-input" && sha256sum --check SHA256SUMS) + - name: Publish Lootable Bodies to CurseForge + uses: Kira-NT/mc-publish@52307b03863581dec6b652b83e597aec02ebb075 # v3.3.1 + with: + curseforge-token: ${{ secrets.CURSEFORGE_TOKEN }} + curseforge-id: '240968' + name: Lootable Bodies ${{ needs.preflight.outputs.release_version }} for Minecraft 1.10.2 (Forge) + version: ${{ needs.preflight.outputs.release_version }} + version-type: ${{ inputs.curseforge_release_level }} + changelog-file: ${{ runner.temp }}/release-input/CHANGELOG.txt + loaders: forge + game-versions: '1.10.2' + game-version-filter: none + environment: client | server + java: Java 8 + fail-mode: fail + files: | + ${{ runner.temp }}/release-input/LootableBodies-${{ needs.preflight.outputs.release_version }}.jar + ${{ runner.temp }}/release-input/LootableBodies-${{ needs.preflight.outputs.release_version }}-sources.jar + ${{ runner.temp }}/release-input/LootableBodies-${{ needs.preflight.outputs.release_version }}-javadoc.jar + + publish_github: + name: Publish GitHub Release + needs: [preflight, build, publish_curseforge] + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: write + actions: read + steps: + - name: Download the immutable release bundle + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: ${{ needs.build.outputs.artifact_name }} + path: ${{ runner.temp }}/release-input + - name: Verify release checksums + run: (cd "$RUNNER_TEMP/release-input" && sha256sum --check SHA256SUMS) + - name: Publish the GitHub release last + uses: Kira-NT/mc-publish@52307b03863581dec6b652b83e597aec02ebb075 # v3.3.1 + with: + github-token: ${{ github.token }} + github-tag: ${{ needs.preflight.outputs.release_version }} + github-commitish: ${{ needs.preflight.outputs.release_sha }} + github-draft: false + github-prerelease: false + name: Lootable Bodies ${{ needs.preflight.outputs.release_version }} for Minecraft 1.10.2 (Forge) + version: ${{ needs.preflight.outputs.release_version }} + version-type: release + changelog-file: ${{ runner.temp }}/release-input/CHANGELOG.txt + fail-mode: fail + files: | + ${{ runner.temp }}/release-input/LootableBodies-${{ needs.preflight.outputs.release_version }}.jar + ${{ runner.temp }}/release-input/LootableBodies-${{ needs.preflight.outputs.release_version }}-sources.jar + ${{ runner.temp }}/release-input/LootableBodies-${{ needs.preflight.outputs.release_version }}-javadoc.jar + ${{ runner.temp }}/release-input/LootableBodies-${{ needs.preflight.outputs.release_version }}.pom + ${{ runner.temp }}/release-input/SHA256SUMS + ${{ runner.temp }}/release-input/CHANGELOG.txt diff --git a/.github/workflows/release-on-tag.yml b/.github/workflows/release-on-tag.yml new file mode 100644 index 0000000..25b380d --- /dev/null +++ b/.github/workflows/release-on-tag.yml @@ -0,0 +1,60 @@ +name: Validate Lootable Bodies release tag + +on: + push: + tags: + - '*.*.*.*' + +permissions: + actions: read + contents: read + checks: read + +concurrency: + group: lootablebodies-release-tag-${{ github.ref_name }} + cancel-in-progress: false + +jobs: + validate-release-tag: + name: Validate release identity and prior CI + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out tagged source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Validate target-qualified tag + run: | + python3 - <<'PY' + import os + from pathlib import Path + import re + values = dict(line.split('=', 1) for line in Path('gradle.properties').read_text().splitlines() + if line.strip() and not line.lstrip().startswith('#') and '=' in line) + expected = {'minecraft_version': '1.10.2', 'loader_name': 'forge', 'loader_code': '1', + 'mod_id': 'lootablebodies', 'mod_artifact': 'LootableBodies', + 'mod_group': 'zone.moddev.mc.lootablebodies', 'curseforge_project_id': '240968', + 'mod_license': 'LGPL-2.1-only'} + if any(values.get(key) != value for key, value in expected.items()): + raise SystemExit('Wrong Lootable Bodies release identity') + version = values['mod_version'] + if not re.fullmatch(r'(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.110021', version): + raise SystemExit('Release version must use Major.Minor.Bug.110021') + if os.environ['GITHUB_REF_NAME'] != version: + raise SystemExit('Release tag must exactly match mod_version') + PY + + - name: Require successful build checks on this commit + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + count="$(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 [[ "$count" -lt 1 ]]; then + echo 'The tagged commit has no successful Build, test, and audit check' >&2 + exit 1 + fi + + - name: Record the release workflow link + run: echo 'Tag validated. Publish only through the confirmed Release Lootable Bodies workflow on the default branch.' >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/validate-gradle-build.yml b/.github/workflows/validate-gradle-build.yml new file mode 100644 index 0000000..5189337 --- /dev/null +++ b/.github/workflows/validate-gradle-build.yml @@ -0,0 +1,25 @@ +name: Validate Gradle Wrapper + +on: + push: + branches: + - master-1.10.2 + - 1.10.2 + - 'feature/**' + pull_request: + branches: + - master-1.10.2 + - 1.10.2 + +permissions: + contents: read + +jobs: + validation: + name: Validation + runs-on: ubuntu-latest + steps: + - 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 372b4f6..66da4f4 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,18 @@ /.idea/* temp/* /run/* +/.project +/.settings/ +/*.launch +/.eclipse/ +/build/ +/bin/ +__pycache__/ +*.pyc + +/gradle-local.properties +/logs/ +/crash-reports/ +/runs/ +/out/ diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index a430138..0000000 --- a/.travis.yml +++ /dev/null @@ -1,12 +0,0 @@ -language: java -cache: - directories: - - $HOME/.gradle -notifications: - email: false -jdk: - - oraclejdk8 -install: ./gradlew setupCIWorkspace -S -script: ./gradlew build -S -before_install: - - chmod a+x gradlew diff --git a/CHANGELOG.txt b/CHANGELOG.txt new file mode 100644 index 0000000..a8adb57 --- /dev/null +++ b/CHANGELOG.txt @@ -0,0 +1,21 @@ +2.4.1.110021 - Minecraft 1.10.2, Forge + +Updated the Minecraft 1.10.2 build and release setup. + +- Fixed item loss and duplication during corpse creation, inventory transfers, + hotbar swaps, dragging and cancelled drops. +- Improved recovery of damaged corpse records while keeping recoverable items. +- Corpses wait for nearby unloaded terrain instead of falling through it. +- Failed player skin lookups use a default skin without blocking the game. +- Added a Config button on the Mods list. Appearance changes apply locally; + world and server rules take effect after restarting Minecraft. + +Unreadable item and damaged field records stay with the corpse for recovery. +Keep backups and keep using this maintained reader for affected saves; older +builds discard its additional recovery records when saving. Missing mods must +be restored before their items can be recovered. This release does not qualify +arbitrary modpacks or upgrades to a different Minecraft version. + +Existing corpse inventories, saved identities and configuration defaults are +preserved. Created by Cyanobacterium; maintained by SkyBlade1978 and Minecraft +Mod Development. This release uses LGPLv2.1 and retains the original CC0 notice. diff --git a/LICENSE b/LICENSE index 670154e..f6683e7 100644 --- a/LICENSE +++ b/LICENSE @@ -1,116 +1,501 @@ -CC0 1.0 Universal - -Statement of Purpose - -The laws of most jurisdictions throughout the world automatically confer -exclusive Copyright and Related Rights (defined below) upon the creator and -subsequent owner(s) (each and all, an "owner") of an original work of -authorship and/or a database (each, a "Work"). - -Certain owners wish to permanently relinquish those rights to a Work for the -purpose of contributing to a commons of creative, cultural and scientific -works ("Commons") that the public can reliably and without fear of later -claims of infringement build upon, modify, incorporate in other works, reuse -and redistribute as freely as possible in any form whatsoever and for any -purposes, including without limitation commercial purposes. These owners may -contribute to the Commons to promote the ideal of a free culture and the -further production of creative, cultural and scientific works, or to gain -reputation or greater distribution for their Work in part through the use and -efforts of others. - -For these and/or other purposes and motivations, and without any expectation -of additional consideration or compensation, the person associating CC0 with a -Work (the "Affirmer"), to the extent that he or she is an owner of Copyright -and Related Rights in the Work, voluntarily elects to apply CC0 to the Work -and publicly distribute the Work under its terms, with knowledge of his or her -Copyright and Related Rights in the Work and the meaning and intended legal -effect of CC0 on those rights. - -1. Copyright and Related Rights. A Work made available under CC0 may be -protected by copyright and related or neighboring rights ("Copyright and -Related Rights"). Copyright and Related Rights include, but are not limited -to, the following: - - i. the right to reproduce, adapt, distribute, perform, display, communicate, - and translate a Work; - - ii. moral rights retained by the original author(s) and/or performer(s); - - iii. publicity and privacy rights pertaining to a person's image or likeness - depicted in a Work; - - iv. rights protecting against unfair competition in regards to a Work, - subject to the limitations in paragraph 4(a), below; - - v. rights protecting the extraction, dissemination, use and reuse of data in - a Work; - - vi. database rights (such as those arising under Directive 96/9/EC of the - European Parliament and of the Council of 11 March 1996 on the legal - protection of databases, and under any national implementation thereof, - including any amended or successor version of such directive); and - - vii. other similar, equivalent or corresponding rights throughout the world - based on applicable law or treaty, and any national implementations thereof. - -2. Waiver. To the greatest extent permitted by, but not in contravention of, -applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and -unconditionally waives, abandons, and surrenders all of Affirmer's Copyright -and Related Rights and associated claims and causes of action, whether now -known or unknown (including existing as well as future claims and causes of -action), in the Work (i) in all territories worldwide, (ii) for the maximum -duration provided by applicable law or treaty (including future time -extensions), (iii) in any current or future medium and for any number of -copies, and (iv) for any purpose whatsoever, including without limitation -commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes -the Waiver for the benefit of each member of the public at large and to the -detriment of Affirmer's heirs and successors, fully intending that such Waiver -shall not be subject to revocation, rescission, cancellation, termination, or -any other legal or equitable action to disrupt the quiet enjoyment of the Work -by the public as contemplated by Affirmer's express Statement of Purpose. - -3. Public License Fallback. Should any part of the Waiver for any reason be -judged legally invalid or ineffective under applicable law, then the Waiver -shall be preserved to the maximum extent permitted taking into account -Affirmer's express Statement of Purpose. In addition, to the extent the Waiver -is so judged Affirmer hereby grants to each affected person a royalty-free, -non transferable, non sublicensable, non exclusive, irrevocable and -unconditional license to exercise Affirmer's Copyright and Related Rights in -the Work (i) in all territories worldwide, (ii) for the maximum duration -provided by applicable law or treaty (including future time extensions), (iii) -in any current or future medium and for any number of copies, and (iv) for any -purpose whatsoever, including without limitation commercial, advertising or -promotional purposes (the "License"). The License shall be deemed effective as -of the date CC0 was applied by Affirmer to the Work. Should any part of the -License for any reason be judged legally invalid or ineffective under -applicable law, such partial invalidity or ineffectiveness shall not -invalidate the remainder of the License, and in such case Affirmer hereby -affirms that he or she will not (i) exercise any of his or her remaining -Copyright and Related Rights in the Work or (ii) assert any associated claims -and causes of action with respect to the Work, in either case contrary to -Affirmer's express Statement of Purpose. - -4. Limitations and Disclaimers. - - a. No trademark or patent rights held by Affirmer are waived, abandoned, - surrendered, licensed or otherwise affected by this document. - - b. Affirmer offers the Work as-is and makes no representations or warranties - of any kind concerning the Work, express, implied, statutory or otherwise, - including without limitation warranties of title, merchantability, fitness - for a particular purpose, non infringement, or the absence of latent or - other defects, accuracy, or the present or absence of errors, whether or not - discoverable, all to the greatest extent permissible under applicable law. - - c. Affirmer disclaims responsibility for clearing rights of other persons - that may apply to the Work or any use thereof, including without limitation - any person's Copyright and Related Rights in the Work. Further, Affirmer - disclaims responsibility for obtaining any necessary consents, permissions - or other rights required for any use of the Work. - - d. Affirmer understands and acknowledges that Creative Commons is not a - party to this document and has no duty or obligation with respect to this - CC0 or use of the Work. - -For more information, please see - + GNU LESSER GENERAL PUBLIC LICENSE + Version 2.1, February 1999 + + Copyright (C) 1991, 1999 Free Software Foundation, Inc. + + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts + as the successor of the GNU Library Public License, version 2, hence + the version number 2.1.] + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +Licenses are intended to guarantee your freedom to share and change +free software--to make sure the software is free for all its users. + + This license, the Lesser General Public License, applies to some +specially designated software packages--typically libraries--of the +Free Software Foundation and other authors who decide to use it. You +can use it too, but we suggest you first think carefully about whether +this license or the ordinary General Public License is the better +strategy to use in any particular case, based on the explanations below. + + When we speak of free software, we are referring to freedom of use, +not price. Our General Public Licenses are designed to make sure that +you have the freedom to distribute copies of free software (and charge +for this service if you wish); that you receive source code or can get +it if you want it; that you can change the software and use pieces of +it in new free programs; and that you are informed that you can do +these things. + + To protect your rights, we need to make restrictions that forbid +distributors to deny you these rights or to ask you to surrender these +rights. These restrictions translate to certain responsibilities for +you if you distribute copies of the library or if you modify it. + + For example, if you distribute copies of the library, whether gratis +or for a fee, you must give the recipients all the rights that we gave +you. You must make sure that they, too, receive or can get the source +code. If you link other code with the library, you must provide +complete object files to the recipients, so that they can relink them +with the library after making changes to the library and recompiling +it. And you must show them these terms so they know their rights. + + We protect your rights with a two-step method: (1) we copyright the +library, and (2) we offer you this license, which gives you legal +permission to copy, distribute and/or modify the library. + + To protect each distributor, we want to make it very clear that +there is no warranty for the free library. Also, if the library is +modified by someone else and passed on, the recipients should know +that what they have is not the original version, so that the original +author's reputation will not be affected by problems that might be +introduced by others. + + Finally, software patents pose a constant threat to the existence of +any free program. We wish to make sure that a company cannot +effectively restrict the users of a free program by obtaining a +restrictive license from a patent holder. Therefore, we insist that +any patent license obtained for a version of the library must be +consistent with the full freedom of use specified in this license. + + Most GNU software, including some libraries, is covered by the +ordinary GNU General Public License. This license, the GNU Lesser +General Public License, applies to certain designated libraries, and +is quite different from the ordinary General Public License. We use +this license for certain libraries in order to permit linking those +libraries into non-free programs. + + When a program is linked with a library, whether statically or using +a shared library, the combination of the two is legally speaking a +combined work, a derivative of the original library. The ordinary +General Public License therefore permits such linking only if the +entire combination fits its criteria of freedom. The Lesser General +Public License permits more lax criteria for linking other code with +the library. + + We call this license the "Lesser" General Public License because it +does Less to protect the user's freedom than the ordinary General +Public License. It also provides other free software developers Less +of an advantage over competing non-free programs. These disadvantages +are the reason we use the ordinary General Public License for many +libraries. However, the Lesser license provides advantages in certain +special circumstances. + + For example, on rare occasions, there may be a special need to +encourage the widest possible use of a certain library, so that it becomes +a de-facto standard. To achieve this, non-free programs must be +allowed to use the library. A more frequent case is that a free +library does the same job as widely used non-free libraries. In this +case, there is little to gain by limiting the free library to free +software only, so we use the Lesser General Public License. + + In other cases, permission to use a particular library in non-free +programs enables a greater number of people to use a large body of +free software. For example, permission to use the GNU C Library in +non-free programs enables many more people to use the whole GNU +operating system, as well as its variant, the GNU/Linux operating +system. + + Although the Lesser General Public License is Less protective of the +users' freedom, it does ensure that the user of a program that is +linked with the Library has the freedom and the wherewithal to run +that program using a modified version of the Library. + + The precise terms and conditions for copying, distribution and +modification follow. Pay close attention to the difference between a +"work based on the library" and a "work that uses the library". The +former contains code derived from the library, whereas the latter must +be combined with the library in order to run. + + GNU LESSER GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License Agreement applies to any software library or other +program which contains a notice placed by the copyright holder or +other authorized party saying it may be distributed under the terms of +this Lesser General Public License (also called "this License"). +Each licensee is addressed as "you". + + A "library" means a collection of software functions and/or data +prepared so as to be conveniently linked with application programs +(which use some of those functions and data) to form executables. + + The "Library", below, refers to any such software library or work +which has been distributed under these terms. A "work based on the +Library" means either the Library or any derivative work under +copyright law: that is to say, a work containing the Library or a +portion of it, either verbatim or with modifications and/or translated +straightforwardly into another language. (Hereinafter, translation is +included without limitation in the term "modification".) + + "Source code" for a work means the preferred form of the work for +making modifications to it. For a library, complete source code means +all the source code for all modules it contains, plus any associated +interface definition files, plus the scripts used to control compilation +and installation of the library. + + Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running a program using the Library is not restricted, and output from +such a program is covered only if its contents constitute a work based +on the Library (independent of the use of the Library in a tool for +writing it). Whether that is true depends on what the Library does +and what the program that uses the Library does. + + 1. You may copy and distribute verbatim copies of the Library's +complete source code as you receive it, in any medium, provided that +you conspicuously and appropriately publish on each copy an +appropriate copyright notice and disclaimer of warranty; keep intact +all the notices that refer to this License and to the absence of any +warranty; and distribute a copy of this License along with the +Library. + + You may charge a fee for the physical act of transferring a copy, +and you may at your option offer warranty protection in exchange for a +fee. + + 2. You may modify your copy or copies of the Library or any portion +of it, thus forming a work based on the Library, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices + stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no + charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a + table of data to be supplied by an application program that uses + the facility, other than as an argument passed when the facility + is invoked, then you must make a good faith effort to ensure that, + in the event an application does not supply such function or + table, the facility still operates, and performs whatever part of + its purpose remains meaningful. + + (For example, a function in a library to compute square roots has + a purpose that is entirely well-defined independent of the + application. Therefore, Subsection 2d requires that any + application-supplied function or table used by this function must + be optional: if the application does not supply it, the square + root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Library, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Library, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote +it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library +with the Library (or with a work based on the Library) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may opt to apply the terms of the ordinary GNU General Public +License instead of this License to a given copy of the Library. To do +this, you must alter all the notices that refer to this License, so +that they refer to the ordinary GNU General Public License, version 2, +instead of to this License. (If a newer version than version 2 of the +ordinary GNU General Public License has appeared, then you can specify +that version instead if you wish.) Do not make any other change in +these notices. + + Once this change is made in a given copy, it is irreversible for +that copy, so the ordinary GNU General Public License applies to all +subsequent copies and derivative works made from that copy. + + This option is useful when you wish to copy part of the code of +the Library into a program that is not a library. + + 4. You may copy and distribute the Library (or a portion or +derivative of it, under Section 2) in object code or executable form +under the terms of Sections 1 and 2 above provided that you accompany +it with the complete corresponding machine-readable source code, which +must be distributed under the terms of Sections 1 and 2 above on a +medium customarily used for software interchange. + + If distribution of object code is made by offering access to copy +from a designated place, then offering equivalent access to copy the +source code from the same place satisfies the requirement to +distribute the source code, even though third parties are not +compelled to copy the source along with the object code. + + 5. A program that contains no derivative of any portion of the +Library, but is designed to work with the Library by being compiled or +linked with it, is called a "work that uses the Library". Such a +work, in isolation, is not a derivative work of the Library, and +therefore falls outside the scope of this License. + + However, linking a "work that uses the Library" with the Library +creates an executable that is a derivative of the Library (because it +contains portions of the Library), rather than a "work that uses the +library". The executable is therefore covered by this License. +Section 6 states terms for distribution of such executables. + + When a "work that uses the Library" uses material from a header file +that is part of the Library, the object code for the work may be a +derivative work of the Library even though the source code is not. +Whether this is true is especially significant if the work can be +linked without the Library, or if the work is itself a library. The +threshold for this to be true is not precisely defined by law. + + If such an object file uses only numerical parameters, data +structure layouts and accessors, and small macros and small inline +functions (ten lines or less in length), then the use of the object +file is unrestricted, regardless of whether it is legally a derivative +work. (Executables containing this object code plus portions of the +Library will still fall under Section 6.) + + Otherwise, if the work is a derivative of the Library, you may +distribute the object code for the work under the terms of Section 6. +Any executables containing that work also fall under Section 6, +whether or not they are linked directly with the Library itself. + + 6. As an exception to the Sections above, you may also combine or +link a "work that uses the Library" with the Library to produce a +work containing portions of the Library, and distribute that work +under terms of your choice, provided that the terms permit +modification of the work for the customer's own use and reverse +engineering for debugging such modifications. + + You must give prominent notice with each copy of the work that the +Library is used in it and that the Library and its use are covered by +this License. You must supply a copy of this License. If the work +during execution displays copyright notices, you must include the +copyright notice for the Library among them, as well as a reference +directing the user to the copy of this License. Also, you must do one +of these things: + + a) Accompany the work with the complete corresponding + machine-readable source code for the Library including whatever + changes were used in the work (which must be distributed under + Sections 1 and 2 above); and, if the work is an executable linked + with the Library, with the complete machine-readable "work that + uses the Library", as object code and/or source code, so that the + user can modify the Library and then relink to produce a modified + executable containing the modified Library. (It is understood + that the user who changes the contents of definitions files in the + Library will not necessarily be able to recompile the application + to use the modified definitions.) + + b) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (1) uses at run time a + copy of the library already present on the user's computer system, + rather than copying library functions into the executable, and (2) + will operate properly with a modified version of the library, if + the user installs one, as long as the modified version is + interface-compatible with the version that the work was made with. + + c) Accompany the work with a written offer, valid for at + least three years, to give the same user the materials + specified in Subsection 6a, above, for a charge no more + than the cost of performing this distribution. + + d) If distribution of the work is made by offering access to copy + from a designated place, offer equivalent access to copy the above + specified materials from the same place. + + e) Verify that the user has already received a copy of these + materials or that you have already sent this user a copy. + + For an executable, the required form of the "work that uses the +Library" must include any data and utility programs needed for +reproducing the executable from it. However, as a special exception, +the materials to be distributed need not include anything that is +normally distributed (in either source or binary form) with the major +components (compiler, kernel, and so on) of the operating system on +which the executable runs, unless that component itself accompanies +the executable. + + It may happen that this requirement contradicts the license +restrictions of other proprietary libraries that do not normally +accompany the operating system. Such a contradiction means you cannot +use both them and the Library together in an executable that you +distribute. + + 7. You may place library facilities that are a work based on the +Library side-by-side in a single library together with other library +facilities not covered by this License, and distribute such a combined +library, provided that the separate distribution of the work based on +the Library and of the other library facilities is otherwise +permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work + based on the Library, uncombined with any other library + facilities. This must be distributed under the terms of the + Sections above. + + b) Give prominent notice with the combined library of the fact + that part of it is a work based on the Library, and explaining + where to find the accompanying uncombined form of the same work. + + 8. You may not copy, modify, sublicense, link with, or distribute +the Library except as expressly provided under this License. Any +attempt otherwise to copy, modify, sublicense, link with, or +distribute the Library is void, and will automatically terminate your +rights under this License. However, parties who have received copies, +or rights, from you under this License will not have their licenses +terminated so long as such parties remain in full compliance. + + 9. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Library or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Library (or any work based on the +Library), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Library or works based on it. + + 10. Each time you redistribute the Library (or any work based on the +Library), the recipient automatically receives a license from the +original licensor to copy, distribute, link with or modify the Library +subject to these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties with +this License. + + 11. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Library at all. For example, if a patent +license would not permit royalty-free redistribution of the Library by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any +particular circumstance, the balance of the section is intended to apply, +and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 12. If the distribution and/or use of the Library is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Library under this License may add +an explicit geographical distribution limitation excluding those countries, +so that distribution is permitted only in or among countries not thus +excluded. In such case, this License incorporates the limitation as if +written in the body of this License. + + 13. The Free Software Foundation may publish revised and/or new +versions of the Lesser General Public License from time to time. +Such new versions will be similar in spirit to the present version, +but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library +specifies a version number of this License which applies to it and +"any later version", you have the option of following the terms and +conditions either of that version or of any later version published by +the Free Software Foundation. If the Library does not specify a +license version number, you may choose any version ever published by +the Free Software Foundation. + + 14. If you wish to incorporate parts of the Library into other free +programs whose distribution conditions are incompatible with these, +write to the author to ask for permission. For software which is +copyrighted by the Free Software Foundation, write to the Free +Software Foundation; we sometimes make exceptions for this. Our +decision will be guided by the two goals of preserving the free status +of all derivatives of our free software and of promoting the sharing +and reuse of software generally. + + NO WARRANTY + + 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO +WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. +EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY +KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE +LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME +THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN +WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY +AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU +FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR +CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE +LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING +RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A +FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF +SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Libraries + + If you develop a new library, and you want it to be of the greatest +possible use to the public, we recommend making it free software that +everyone can redistribute and change. You can do so by permitting +redistribution under these terms (or, alternatively, under the terms of the +ordinary General Public License). + + To apply these terms, attach the following notices to the library. It is +safest to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least the +"copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, see . + +Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the library, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the + library `Frob' (a library for tweaking knobs) written by James Random Hacker. + + , 1 April 1990 + Moe Ghoul, President of Vice + +That's all there is to it! diff --git a/LICENSE-CC0 b/LICENSE-CC0 new file mode 100644 index 0000000..670154e --- /dev/null +++ b/LICENSE-CC0 @@ -0,0 +1,116 @@ +CC0 1.0 Universal + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator and +subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for the +purpose of contributing to a commons of creative, cultural and scientific +works ("Commons") that the public can reliably and without fear of later +claims of infringement build upon, modify, incorporate in other works, reuse +and redistribute as freely as possible in any form whatsoever and for any +purposes, including without limitation commercial purposes. These owners may +contribute to the Commons to promote the ideal of a free culture and the +further production of creative, cultural and scientific works, or to gain +reputation or greater distribution for their Work in part through the use and +efforts of others. + +For these and/or other purposes and motivations, and without any expectation +of additional consideration or compensation, the person associating CC0 with a +Work (the "Affirmer"), to the extent that he or she is an owner of Copyright +and Related Rights in the Work, voluntarily elects to apply CC0 to the Work +and publicly distribute the Work under its terms, with knowledge of his or her +Copyright and Related Rights in the Work and the meaning and intended legal +effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not limited +to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, communicate, + and translate a Work; + + ii. moral rights retained by the original author(s) and/or performer(s); + + iii. publicity and privacy rights pertaining to a person's image or likeness + depicted in a Work; + + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + + v. rights protecting the extraction, dissemination, use and reuse of data in + a Work; + + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation thereof, + including any amended or successor version of such directive); and + + vii. other similar, equivalent or corresponding rights throughout the world + based on applicable law or treaty, and any national implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention of, +applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and +unconditionally waives, abandons, and surrenders all of Affirmer's Copyright +and Related Rights and associated claims and causes of action, whether now +known or unknown (including existing as well as future claims and causes of +action), in the Work (i) in all territories worldwide, (ii) for the maximum +duration provided by applicable law or treaty (including future time +extensions), (iii) in any current or future medium and for any number of +copies, and (iv) for any purpose whatsoever, including without limitation +commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes +the Waiver for the benefit of each member of the public at large and to the +detriment of Affirmer's heirs and successors, fully intending that such Waiver +shall not be subject to revocation, rescission, cancellation, termination, or +any other legal or equitable action to disrupt the quiet enjoyment of the Work +by the public as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason be +judged legally invalid or ineffective under applicable law, then the Waiver +shall be preserved to the maximum extent permitted taking into account +Affirmer's express Statement of Purpose. In addition, to the extent the Waiver +is so judged Affirmer hereby grants to each affected person a royalty-free, +non transferable, non sublicensable, non exclusive, irrevocable and +unconditional license to exercise Affirmer's Copyright and Related Rights in +the Work (i) in all territories worldwide, (ii) for the maximum duration +provided by applicable law or treaty (including future time extensions), (iii) +in any current or future medium and for any number of copies, and (iv) for any +purpose whatsoever, including without limitation commercial, advertising or +promotional purposes (the "License"). The License shall be deemed effective as +of the date CC0 was applied by Affirmer to the Work. Should any part of the +License for any reason be judged legally invalid or ineffective under +applicable law, such partial invalidity or ineffectiveness shall not +invalidate the remainder of the License, and in such case Affirmer hereby +affirms that he or she will not (i) exercise any of his or her remaining +Copyright and Related Rights in the Work or (ii) assert any associated claims +and causes of action with respect to the Work, in either case contrary to +Affirmer's express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + + b. Affirmer offers the Work as-is and makes no representations or warranties + of any kind concerning the Work, express, implied, statutory or otherwise, + including without limitation warranties of title, merchantability, fitness + for a particular purpose, non infringement, or the absence of latent or + other defects, accuracy, or the present or absence of errors, whether or not + discoverable, all to the greatest extent permissible under applicable law. + + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without limitation + any person's Copyright and Related Rights in the Work. Further, Affirmer + disclaims responsibility for obtaining any necessary consents, permissions + or other rights required for any use of the Work. + + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to this + CC0 or use of the Work. + +For more information, please see + diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..560135e --- /dev/null +++ b/NOTICE @@ -0,0 +1,11 @@ +DrCyano's Lootable Bodies + +Created by Cyanobacterium (DrCyano, also known as Synechocystis). +Maintained by SkyBlade1978 and the Minecraft Mod Development community. + +This maintained release is distributed under the GNU Lesser General Public +License, version 2.1. See LICENSE for the complete text. + +The original source was supplied with a CC0 1.0 Universal dedication. +That original notice is preserved in LICENSE-CC0. The maintained release's +LGPL notice does not remove the original dedication from the original work. diff --git a/README.md b/README.md index 8c805c9..067fd91 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,44 @@ A death-chest mod for Minecraft ## Requirements -This mod requires that you install Minecraft Forge version 1.9-12.16.0.1865 or later. +This version is for Minecraft 1.10.2, Forge 12.18.3.2511 and Java 8. ## Installing -After you have successfully installed Forge, simply place the file *lootable-bodies-#.#.jar* in your *mods* folder. You can get the lootable-bodies-#.#.jar file from the Releases tab of this repository page. +Install Forge, then place `LootableBodies-2.4.1.110021.jar` in the +`mods` folder on the client and server. Keep a backup before changing an +existing modpack or world. + +When a player dies, a corpse holds their dropped items. Right-click the corpse +to recover them. The configuration file controls corpse damage, decay, skins +and the extra bones and rotten flesh. + +Open **Mods**, select **DrCyano's Lootable Bodies**, then choose **Config**. +Appearance settings apply locally when you save with Done. World/server rules +require a full Minecraft restart and cannot be edited while a world is open. +They change this installation's file, not a remote server's rules. Dedicated +server owners should edit their server's configuration file and restart it. + +If a corpse cannot spawn, the player's ordinary item drops remain available. +If a corpse cannot release an item, it keeps that item for recovery or a later +retry. Inventory changes are saved with the corpse's chunk. +Corpses stay in place while nearby terrain is unloaded. +If a player skin cannot be loaded, the corpse uses a default skin. + +Unreadable item records stay with the corpse. Restore missing mods before +reloading to retry recovery. An apparently empty corpse can still hold saved +item data. Keep using this build when saving those corpses; older builds do not +retain its recovery records. + +Damaged corpse records keep recoverable items and the original data for repair. +Existing corpse slots and configuration defaults are preserved. + +## Credits and licence + +Created by Cyanobacterium (DrCyano, also known as Synechocystis). +Maintained by [SkyBlade1978](https://github.com/SkyBlade1978) and Minecraft Mod Development. +This maintained release uses [LGPLv2.1](LICENSE). The original +[CC0 dedication](LICENSE-CC0) and [attribution](NOTICE) are retained. + +Version `2.4.1.110021` is the maintained Minecraft 1.10.2 Forge release. +See [changes](CHANGELOG.txt) and the [versioning policy](docs/VERSIONS.md). diff --git a/build.gradle b/build.gradle index f1e5e9f..9e6bb3b 100644 --- a/build.gradle +++ b/build.gradle @@ -1,122 +1,205 @@ -buildscript { - repositories { - jcenter() - maven { - name = "forge" - url = "http://files.minecraftforge.net/maven" - } - maven { - name = 'gradle' - url 'https://plugins.gradle.org/m2/' - } - maven { - name = "sonatype" - url = 'https://oss.sonatype.org/content/groups/public' - } - } - dependencies { - classpath "net.minecraftforge.gradle:ForgeGradle:2.2-SNAPSHOT" - classpath "gradle.plugin.com.matthewprenger:CurseGradle:1.0.11" - } -} - -apply plugin: "net.minecraftforge.gradle.forge" -apply plugin: "com.matthewprenger.cursegradle" - -def mod_file = getModFile() -version = getVersion("VERSION", mod_file) -group= "cyano.lootable" -archivesBaseName = "DrCyanosLootableBodies_1.10.2" -sourceCompatibility = targetCompatibility = "1.8" - -minecraft { - version = "1.10.2-12.18.3.2185" - runDir = "run" - mappings = "stable_29" -} +import org.apache.tools.ant.filters.FixCrLfFilter -dependencies { - // none +plugins { + id 'java' + id 'eclipse' + id 'idea' + id 'maven-publish' + id 'net.minecraftforge.renamer' version '1.1.5' + id 'net.minecraftforge.gradle' version '7.0.34' } -processResources { - inputs.property "version", project.version - inputs.property "mcversion", project.minecraft.version +group = project.mod_group +version = project.mod_version +base.archivesName = project.mod_artifact - from(sourceSets.main.resources.srcDirs) { - include 'mcmod.info' - expand 'version': project.version, 'mcversion': project.minecraft.version +// Gradle uses Java 17; every compiler, test and game process uses this JDK 8. +// Eclipse does not inherit the command-line helper's environment. Keep its +// machine-specific toolchain selection in an ignored local properties file. +Properties localJavaSetup = new Properties() +File localJavaSetupFile = file('gradle-local.properties') +if (localJavaSetupFile.isFile()) localJavaSetupFile.withInputStream { localJavaSetup.load(it) } +def pinnedJava8 = providers.environmentVariable('LOOTABLEBODIES_JAVA8_HOME') + .orElse(providers.gradleProperty('lootablebodies.java8.home')) + .orElse(providers.provider { localJavaSetup.getProperty('lootablebodies.java8.home') }) +def exactJava8Spec = pinnedJava8.isPresent() ? + org.gradle.jvm.toolchain.internal.SpecificInstallationToolchainSpec.fromJavaHome( + project.services.get(org.gradle.api.internal.provider.PropertyFactory), file(pinnedJava8.get())) : null +java { + toolchain { + languageVersion = JavaLanguageVersion.of(8) + vendor = JvmVendorSpec.ADOPTIUM } + withSourcesJar() + withJavadocJar() +} +ext.productionLauncher = exactJava8Spec != null ? javaToolchains.launcherFor(exactJava8Spec) : + javaToolchains.launcherFor(java.toolchain) +def productionCompiler = exactJava8Spec != null ? javaToolchains.compilerFor(exactJava8Spec) : + javaToolchains.compilerFor(java.toolchain) +def productionJavadoc = exactJava8Spec != null ? javaToolchains.javadocToolFor(exactJava8Spec) : + javaToolchains.javadocToolFor(java.toolchain) - from(sourceSets.main.resources.srcDirs) { - exclude 'mcmod.info' +tasks.register('verifyJavaSetup') { + group = 'verification' + doLast { + if (JavaVersion.current() != JavaVersion.VERSION_17) throw new GradleException('Run Gradle with Java 17') + def metadata = productionLauncher.get().metadata + Properties release = new Properties() + metadata.installationPath.file('release').asFile.withInputStream { release.load(it) } + if (release.getProperty('SEMANTIC_VERSION') != '"8.0.502+7"' || + release.getProperty('IMPLEMENTOR') != '"Eclipse Adoptium"') { + throw new GradleException("Production requires Temurin 8.0.502+7, found ${metadata.javaRuntimeVersion} ${metadata.vendor}") + } + logger.lifecycle("Gradle Java: ${System.getProperty('java.runtime.version')}; production: ${metadata}") } } - -task deobfJar(type: Jar) { - classifier = "deobf" - from sourceSets.main.output +tasks.withType(JavaCompile).configureEach { + dependsOn 'verifyJavaSetup' + javaCompiler = productionCompiler + sourceCompatibility = '1.8' + targetCompatibility = '1.8' + options.encoding = 'UTF-8' } - -task javadocJar(type: Jar, dependsOn: javadoc) { - classifier = "javadoc" - from javadoc.getDestinationDir() +tasks.named('compileTestJava', JavaCompile) { options.compilerArgs.add('-proc:none') } +tasks.withType(Test).configureEach { + useJUnitPlatform() + javaLauncher = productionLauncher + workingDir = projectDir } - -artifacts { - archives deobfJar - archives sourceJar - archives javadocJar +tasks.withType(JavaExec).configureEach { javaLauncher = productionLauncher } +tasks.withType(Javadoc).configureEach { + javadocTool = productionJavadoc + failOnError = true + options.encoding = 'UTF-8' + options.addStringOption('Xdoclint:none', '-quiet') + options.addBooleanOption('notimestamp', true) +} +tasks.withType(AbstractArchiveTask).configureEach { + preserveFileTimestamps = false + reproducibleFileOrder = true +} +ext.archiveTextPatterns = ['**/*.info', '**/*.java', '**/*.lang', '**/*.mcmeta', '**/*.html', + '**/*.css', '**/*.js', '**/*.md', '**/*.txt', '**/package-list', '**/LICENSE', '**/LICENSE-CC0', '**/NOTICE'] +ext.normalizeArchiveText = { details -> + details.filter(FixCrLfFilter, eol: FixCrLfFilter.CrLf.newInstance('lf'), + eof: FixCrLfFilter.AddAsisRemove.newInstance('asis')) } -curseforge { - apiKey = "" - project { - id = "240968" - changelog = file("CHANGELOG.txt") - releaseType = "release" - mainArtifact(jar) { - displayName = "Dr Cyano's Lootable Bodies $project.version" +minecraft { + mappings channel: project.mapping_channel, version: project.mapping_version + runs { + configureEach { + workingDir.convention layout.projectDirectory.dir( + providers.gradleProperty('gameRunDirectory').getOrElse('run')) + systemProperty 'log4j.configurationFile', file('gradle/log4j2.xml').toURI().toString() } - addArtifact sourceJar - addArtifact deobfJar - addArtifact javadocJar + register('client') + register('server') { args 'nogui' } } } +tasks.matching { it.name == 'runServer' }.configureEach { standardInput = System.in } -String getModFile() { - String path = ""; - FileTree tree = fileTree(dir: "src/main/java") - tree.include '**/*.java' - tree.visit { element -> - if (element.file.isFile()) { - element.file.eachLine { String s -> - s = s.trim(); - if (s.startsWith("@Mod")) { - path = "src/main/java/$element.relativePath" - } +// Legacy FML discovers classes and processed resources from a single root. +ext.developmentOutput = layout.buildDirectory.dir('forge10-development/main') +tasks.register('syncDevelopmentOutput', Sync) { + dependsOn 'classes' + from tasks.named('compileJava', JavaCompile).flatMap { it.destinationDirectory } + from sourceSets.main.output.resourcesDir + into developmentOutput +} +tasks.configureEach { + if (name in ['runClient', 'runServer']) { + dependsOn 'syncDevelopmentOutput' + doFirst { + Set split = sourceSets.main.output.files + classpath = files(developmentOutput, classpath.filter { !split.contains(it) }) + def forbidden = classpath.files.findAll { + it.path.replace('\\', '/') =~ /(?i)(\/test(?:\/|$)|junit|opentest4j|mockito|byte-buddy|integration-test)/ } + if (forbidden) throw new GradleException("Ordinary launch contains testing code: ${forbidden}") } } - return path; } -String getVersion(String type, String mod_file) { - String major = "0"; - String revision = "0"; - String patch = "0"; - String prefix = "public static final String $type = \""; - File file = file(mod_file) - file.eachLine { String s -> - s = s.trim(); - if (s.startsWith(prefix)) { - s = s.substring(prefix.length(), s.length() - 2); - String[] pts = s.split("\\."); +repositories { + minecraft.mavenizer(it) + maven fg.forgeMaven + maven fg.minecraftLibsMaven + exclusiveContent { + forRepository { maven { name = 'Sponge'; url = 'https://repo.spongepowered.org/repository/maven-public' } } + filter { includeGroupAndSubgroups('org.spongepowered') } + } + mavenCentral() + maven { url = 'https://libraries.minecraft.net/' } +} +dependencies { + implementation minecraft.dependency("net.minecraftforge:forge:${minecraft_version}-${forge_version}") + compileOnly 'org.lwjgl.lwjgl:lwjgl_util:2.9.4-nightly-20150209' + testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.2' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.10.2' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.10.2' +} - major = pts[0]; - revision = pts[1]; - patch = pts[2]; - } +tasks.named('processResources', ProcessResources) { + filteringCharset = 'UTF-8' + inputs.properties(version: project.version, mcversion: project.minecraft_version, + mod_url: project.mod_url, mod_license: project.mod_license) + filesMatching('mcmod.info') { + expand version: project.version, mcversion: project.minecraft_version, + mod_url: project.mod_url, mod_license: project.mod_license + } + filesMatching(archiveTextPatterns, normalizeArchiveText) +} +tasks.named('jar', Jar) { + archiveClassifier = 'deobf' + destinationDirectory = layout.buildDirectory.dir('libs-dev') + from(['LICENSE', 'LICENSE-CC0', 'NOTICE']) { into 'META-INF' } + manifest.attributes('Implementation-Title': project.mod_name, + 'Implementation-Version': project.version, 'Implementation-Vendor': project.mod_vendor, + 'Maven-Artifact': "${project.group}:${base.archivesName.get()}:${project.version}", + 'Implementation-License': project.mod_license, + 'Built-On-Java': '8', 'Built-On': "${minecraft_version}-${forge_version}") + filesMatching(archiveTextPatterns, normalizeArchiveText) +} +ext.releaseJar = renamer.classes(tasks.named('jar', Jar)) { + map.from minecraft.dependency.toSrgFile + libraries.from(sourceSets.main.compileClasspath) + archiveClassifier = null + output = layout.buildDirectory.file("libs/${base.archivesName.get()}-${project.version}.jar") +} +['sourcesJar', 'javadocJar'].each { name -> + tasks.named(name, Jar) { + from(['LICENSE', 'LICENSE-CC0', 'NOTICE']) { into 'META-INF' } + manifest.attributes('Implementation-Title': project.mod_name, + 'Implementation-Version': project.version, 'Implementation-Vendor': project.mod_vendor, + 'Implementation-License': project.mod_license, + 'Maven-Artifact': "${project.group}:${base.archivesName.get()}:${project.version}:${name == 'sourcesJar' ? 'sources' : 'javadoc'}") + filesMatching(archiveTextPatterns, normalizeArchiveText) } - return "$major.$revision.$patch"; } +['apiElements', 'runtimeElements'].each { name -> + configurations.named(name) { artifacts.clear() } + artifacts { add(name, releaseJar) } +} +tasks.named('assemble') { dependsOn releaseJar, 'sourcesJar', 'javadocJar' } + +apply from: 'gradle/ide.gradle' +apply from: 'gradle/audit.gradle' +apply from: 'gradle/release.gradle' +apply from: 'gradle/runtime.gradle' + +tasks.register('verifyProductionRuntimeClasspath') { + group = 'verification' + dependsOn 'syncDevelopmentOutput' + doLast { + if (configurations.runtimeClasspath.files.any { + it.path.replace('\\','/') =~ /(?i)(\/test(?:\/|$)|junit|opentest4j|mockito|byte-buddy|integration-test|buildtest)/ + }) throw new GradleException('Testing code leaked into the production runtime') + if (!developmentOutput.get().file('mcmod.info').asFile.text.contains("\"version\": \"${project.version}\"")) + throw new GradleException('Development resources are not processed') + } +} +tasks.named('check') { dependsOn 'verifyProductionRuntimeClasspath' } + +// Publication is restricted to the confirmed MMD release workflow. diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..39ee53d --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,19 @@ +# Releasing Lootable Bodies + +Changes go to the maintainer's fork first, then to the matching MMD branch by +pull request. Merge after the build, security and wrapper checks pass. + +For Minecraft 1.10.2, open **Release Lootable Bodies** in MMD's Actions tab. +Run it from the default branch, enter the complete version from +`gradle.properties`, choose the CurseForge release level and confirm publication. +The workflow selects the `1.10.2` branch for versions ending in `110021`. + +The workflow requires green checks on the selected commit, builds and audits +the release bundle, then creates the exact version tag. It publishes the same +files to Maven, CurseForge and GitHub, in that order. Missing files or release +credentials stop the release. Ordinary local builds cannot publish. + +Keep the run ID, commit and checksums when investigating a failed release. +Inspect completed upload steps before retrying. Do not start another upload +while a CurseForge file is pending review. An accepted upload does not prove +that the public download is available yet. diff --git a/docs/VERSIONS.md b/docs/VERSIONS.md new file mode 100644 index 0000000..b482567 --- /dev/null +++ b/docs/VERSIONS.md @@ -0,0 +1,31 @@ +# Versions + +Maintained releases use `Major.Minor.Bug.Target`, matching the MMD convention. +The first three numbers describe the functional release. Increase Major for a +breaking new generation, Minor for a substantial feature and Bug for a fix or +small improvement. Reset the numbers to the right when Major or Minor changes. + +Allocate functional numbers across Lootable Bodies as a whole. Equivalent ports +may share a functional version; unrelated fixes must use a new number. A higher +version on another Minecraft branch does not imply that all fixes apply there. +Release notes identify the changes actually included. Historical versions retain +their original meaning. + +Target encodes the Minecraft major number, two-digit minor number, two-digit +patch number and a loader digit: `1` for Forge, `2` for NeoForge. A missing +Minecraft patch is zero. Minecraft 1.10.2 Forge therefore has target `110021`. +This is a four-component MMD version, rather than strict three-component SemVer. + +This branch uses `2.4.1.110021`. It follows the historical 2.4.0 allocation and +contains the maintained 1.10.2 inventory and saved-data fixes described in the +[changelog](../CHANGELOG.txt). Other Minecraft targets remain separate. + +The stable artifact name is `LootableBodies`; main, sources and Javadoc jars +share the complete version. Maven coordinates are +`zone.moddev.mc.lootablebodies:LootableBodies:2.4.1.110021`. +These coordinates do not change the Java package `cyano.lootable`, mod ID +`lootablebodies`, resource namespace or saved corpse identity. + +Ordinary builds prepare and validate the jars and Maven metadata without +uploading files. Releases use the confirmed MMD workflow described in +[releasing](RELEASING.md). diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..a0cc8ab --- /dev/null +++ b/gradle.properties @@ -0,0 +1,34 @@ +org.gradle.jvmargs=-Xmx3G +org.gradle.daemon=false +org.gradle.configuration-cache=false +org.gradle.caching=true +org.gradle.parallel=false +org.gradle.workers.max=2 +net.minecraftforge.gradle.merge-source-sets=false +minecraft_version=1.10.2 +forge_version=12.18.3.2511 +mapping_channel=stable +mapping_version=29-1.10.2 + +# Release identity and dispatcher target metadata. +loader_name=forge +loader_code=1 +java_version=8 +java_toolchain_version=8.0.502+7 +gradle_java_version=17 +curseforge_project_id=240968 + +mod_id=lootablebodies +mod_name=DrCyano's Lootable Bodies +mod_artifact=LootableBodies +mod_vendor=Cyanobacterium +mod_license=LGPL-2.1-only +mod_license_name=GNU Lesser General Public License, Version 2.1 +mod_license_url=https://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt +mod_version=2.4.1.110021 +mod_group=zone.moddev.mc.lootablebodies +mod_authors=Cyanobacterium,SkyBlade1978 +mod_description=Saves your items on a lootable corpse when you die. +mod_url=https://github.com/MinecraftModDevelopmentMods/LootableBodies +mod_scm_connection=scm:git:https://github.com/MinecraftModDevelopmentMods/LootableBodies.git +mod_scm_developer_connection=scm:git:ssh://git@github.com/MinecraftModDevelopmentMods/LootableBodies.git diff --git a/gradle/audit.gradle b/gradle/audit.gradle new file mode 100644 index 0000000..c04b5b9 --- /dev/null +++ b/gradle/audit.gradle @@ -0,0 +1,106 @@ +import groovy.json.JsonSlurper +import java.security.MessageDigest +import java.util.jar.Manifest +import java.util.zip.ZipFile + +ext.releaseNames = ['', '-sources', '-javadoc'].collect { "${base.archivesName.get()}-${version}${it}.jar".toString() } +ext.releasePomName = "${base.archivesName.get()}-${version}.pom".toString() +ext.releaseDigest = { File candidate -> + MessageDigest.getInstance('SHA-256').digest(candidate.bytes).encodeHex().toString().toUpperCase() +} + +// Apply identical archive checks to newly built jars and immutable input bundles. +ext.auditReleaseArchives = { File directory -> + Set actual = (directory.listFiles() ?: [] as File[]).findAll { it.name.endsWith('.jar') }*.name as Set + if (actual != releaseNames as Set) throw new GradleException("Unexpected/missing artifacts: ${actual}") + releaseNames.eachWithIndex { name, index -> + File candidate = new File(directory, name) + if (!candidate.isFile() || candidate.length() == 0L) throw new GradleException("Empty artifact ${name}") + new ZipFile(candidate).withCloseable { zip -> + Set names = zip.entries().collect { it.name } as Set + if (names.size() != zip.size()) throw new GradleException("Duplicate zip entries in ${name}") + ['LICENSE', 'LICENSE-CC0', 'NOTICE'].each { noticeName -> + def entry = zip.getEntry("META-INF/${noticeName}") + if (!entry || zip.getInputStream(entry).getText('UTF-8').replace('\r\n','\n') != + file(noticeName).getText('UTF-8').replace('\r\n','\n')) + throw new GradleException("Missing/changed licence or attribution ${noticeName} in ${name}") + } + zip.entries().each { entry -> + String path = entry.name + if (path =~ /(?i)(agent|\.codex|\.git\/|test|probe|junit|mockito|bytebuddy|credentials|\.launch$)/) + throw new GradleException("Forbidden archive entry ${path}") + if (path.endsWith('.class')) { + if (!path.startsWith('cyano/lootable/')) throw new GradleException("Foreign production class ${path}") + byte[] bytes = zip.getInputStream(entry).bytes + int major = ((bytes[6] & 255) << 8) | (bytes[7] & 255) + if (major != 52) throw new GradleException("${path} is class major ${major}") + } + String content = new String(zip.getInputStream(entry).bytes, 'ISO-8859-1') + if (content =~ /(?i)([a-z]:[\\\/]users[\\\/]|[a-z]:[\\\/]LootableBodies|\/home\/runner|\/Users\/|gh[pousr]_[A-Za-z0-9]{20}|-----BEGIN .*PRIVATE KEY-----)/) + throw new GradleException("Private path/credential in ${name}: ${path}") + } + def manifestEntry = zip.getEntry('META-INF/MANIFEST.MF') + def manifest = manifestEntry ? new Manifest(zip.getInputStream(manifestEntry)).mainAttributes : null + String classifier = index == 0 ? '' : index == 1 ? ':sources' : ':javadoc' + if (manifest == null || manifest.getValue('Implementation-Version') != project.version.toString() || + manifest.getValue('Implementation-Title') != project.mod_name || + manifest.getValue('Implementation-Vendor') != project.mod_vendor || + manifest.getValue('Implementation-License') != project.mod_license || + manifest.getValue('Maven-Artifact') != "${project.group}:${base.archivesName.get()}:${project.version}${classifier}" || + manifest.getValue('Implementation-Timestamp') != null || manifest.getValue('Timestamp') != null) + throw new GradleException("Incorrect release manifest in ${name}") + if (index == 0) { + ['mcmod.info', 'pack.mcmeta', 'cyano/lootable/LootableBodies.class', + 'assets/lootablebodies/textures/gui/corpse.png', + 'assets/lootablebodies/textures/entity/corpse/corpse.png'].each { + if (!names.contains(it)) throw new GradleException("Missing production entry ${it}") + } + def info = new JsonSlurper().parseText(zip.getInputStream(zip.getEntry('mcmod.info')).getText('UTF-8'))[0] + if (info.modid != project.mod_id || info.version != project.version.toString() || + info.mcversion != project.minecraft_version || info.name != project.mod_name || + info.url != project.mod_url || info.license != project.mod_license || + info.authorList != ['Cyanobacterium', 'SkyBlade1978'] || info.dependencies != []) + throw new GradleException('Incorrect production metadata/attribution') + if (names.any { it.endsWith('.java') || it.startsWith('net/') || it.startsWith('org/') }) + throw new GradleException('Bundled sources or dependencies in main jar') + String entity = new String(zip.getInputStream(zip.getEntry( + 'cyano/lootable/entities/EntityLootableBody.class')).bytes, 'ISO-8859-1') + if (!entity.contains('func_70037_a') || !entity.contains('func_70014_b')) + throw new GradleException('Production jar does not have runtime SRG mappings') + } else if (index == 1 && !names.contains('cyano/lootable/LootableBodies.java')) { + throw new GradleException('Sources jar does not contain production sources') + } else if (index == 2 && !names.contains('index.html')) { + throw new GradleException('Javadoc jar has no index') + } + } + } +} + +tasks.register('verifyReleaseArtifacts') { + group = 'verification' + dependsOn 'assemble', 'verifyJavaSetup', 'verifyReleaseConfiguration' + doLast { + File libs = layout.buildDirectory.dir('libs').get().asFile + Set actual = (libs.listFiles() ?: [] as File[])*.name as Set + if (actual != releaseNames as Set) throw new GradleException('Release output must contain exactly the three declared jars') + auditReleaseArchives(libs) + new ZipFile(new File(libs, releaseNames[0])).withCloseable { zip -> + new ZipFile(tasks.jar.archiveFile.get().asFile).withCloseable { dev -> + String cls = 'cyano/lootable/entities/EntityLootableBody.class' + if (Arrays.equals(zip.getInputStream(zip.getEntry(cls)).bytes, dev.getInputStream(dev.getEntry(cls)).bytes)) + throw new GradleException('Production jar was not reobfuscated') + } + } + } +} +tasks.register('writeReleaseChecksums') { + group = 'verification' + dependsOn 'verifyReleaseArtifacts' + doLast { + File output = layout.buildDirectory.file('release/SHA256SUMS').get().asFile + output.parentFile.mkdirs() + output.setText(releaseNames.sort(false).collect { name -> + "${releaseDigest(layout.buildDirectory.file("libs/${name}").get().asFile)} ${name}" + }.join('\n') + '\n', 'UTF-8') + } +} diff --git a/gradle/ide.gradle b/gradle/ide.gradle new file mode 100644 index 0000000..0d8c227 --- /dev/null +++ b/gradle/ide.gradle @@ -0,0 +1,100 @@ +eclipse { + jdt.javaRuntimeName = 'JavaSE-1.8' + classpath { + downloadSources = true + downloadJavadoc = true + file.whenMerged { model -> + model.entries.findAll { it.kind == 'src' && it.path == 'src/main/resources' }.each { + // Eclipse must not overwrite expanded metadata with raw templates. + it.path = 'build/resources/main' + } + Set sourcePaths = new HashSet<>() + model.entries.removeAll { it.kind == 'src' && !sourcePaths.add(it.path) } + } + } + synchronizationTasks 'genEclipseRuns' +} +tasks.register('prepareEclipseResources') { + group = 'ide' + dependsOn 'processResources' + doLast { copy { from layout.buildDirectory.dir('resources/main'); into 'bin/main' } } +} +tasks.register('configureEclipseBuildship') { + group = 'ide' + dependsOn 'verifyJavaSetup' + doLast { + File target = file('.settings/org.eclipse.buildship.core.prefs') + Properties prefs = new Properties() + if (target.isFile()) target.withInputStream { prefs.load(it) } + ['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', 'java.home': System.getProperty('java.home') + ].each { k, v -> prefs.setProperty(k, v) } + target.parentFile.mkdirs() + target.withOutputStream { prefs.store(it, 'Local Buildship settings') } + File localJavaSetup = file('gradle-local.properties') + Properties toolchains = new Properties() + if (localJavaSetup.isFile()) localJavaSetup.withInputStream { toolchains.load(it) } + toolchains.setProperty('lootablebodies.java8.home', + productionLauncher.get().metadata.installationPath.asFile.canonicalPath) + localJavaSetup.withOutputStream { toolchains.store(it, 'Local production Java toolchain') } + } +} +tasks.register('isolateEclipseProductionRuns') { + group = 'ide' + dependsOn 'prepareEclipseResources', 'configureEclipseBuildship' + doLast { + ['runClient.launch', 'runServer.launch'].each { name -> + File launch = file(name) + if (!launch.isFile()) throw new GradleException("Generate ${name} with genEclipseRuns first") + String contents = launch.getText('UTF-8') + contents = contents.replace('/JavaSE-8', '/JavaSE-1.8') + contents = contents.replaceAll(//, '') + contents = contents.replace('', + ' \n') + ['--cache', '--metadata', '--to-srg', '--to-obf'].each { option -> + int start = contents.indexOf(option + ' ') + if (start >= 0) { + start += option.length() + 1 + int end = contents.indexOf(' --', start) + if (end > start) { + String value = contents.substring(start, end) + if (value.find(/\s/) && !value.startsWith('"')) + contents = contents.substring(0, start) + '"' + value + '"' + contents.substring(end) + } + } + } + launch.setText(contents.replace('\r\n', '\n'), 'UTF-8') + } + } +} +tasks.named('genEclipseRuns') { finalizedBy 'isolateEclipseProductionRuns' } +tasks.register('verifyEclipseProductionClasspath') { + group = 'verification' + dependsOn 'genEclipseRuns', 'eclipse' + doLast { + String classpathXml = file('.classpath').text + if (!classpathXml.contains('path="build/resources/main"') || classpathXml.contains('path="src/main/resources"')) + throw new GradleException('Eclipse must use Gradle-processed resources') + def classpathModel = new groovy.xml.XmlSlurper().parseText(classpathXml) + if (classpathModel.classpathentry.findAll { it.@path.text() == 'build/resources/main' }.size() != 1) + throw new GradleException('Duplicate Eclipse production resource roots') + classpathModel.classpathentry.findAll { it.@path.text() =~ /(?i)(junit|opentest4j|apiguardian)/ }.each { entry -> + if (!entry.attributes.attribute.any { it.@name.text() == 'test' && it.@value.text() == 'true' }) + throw new GradleException('Eclipse testing library is not marked test-only') + } + def metadata = new groovy.json.JsonSlurper().parse(file('bin/main/mcmod.info'))[0] + if (metadata.modid != project.mod_id || metadata.version != project.version.toString() || metadata.mcversion != project.minecraft_version) + throw new GradleException('Eclipse resources are not processed production resources') + ['runClient.launch', 'runServer.launch'].each { name -> + String xml = file(name).text.replace('\\', '/') + if (!xml.contains('ATTR_EXCLUDE_TEST_CODE" value="true"') || + xml =~ /(?i)(src\/test|bin\/test|junit-|integration-test|mockito|byte-buddy)/ || + !xml.contains('PROJECT_ATTR" value="LootableBodies"')) + throw new GradleException("${name} has the wrong project or exposes tests") + if (name == 'runServer.launch' && (!xml.contains('nogui') || xml.contains('--nogui'))) + throw new GradleException('Legacy dedicated server needs bare nogui') + } + } +} diff --git a/gradle/log4j2.xml b/gradle/log4j2.xml new file mode 100644 index 0000000..d15f663 --- /dev/null +++ b/gradle/log4j2.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/gradle/release.gradle b/gradle/release.gradle new file mode 100644 index 0000000..6b44f51 --- /dev/null +++ b/gradle/release.gradle @@ -0,0 +1,181 @@ +import groovy.xml.XmlSlurper +import org.gradle.api.publish.maven.tasks.AbstractPublishToMaven + +def expectedIdentity = [ + mod_id: 'lootablebodies', mod_artifact: 'LootableBodies', + mod_group: 'zone.moddev.mc.lootablebodies', mod_vendor: 'Cyanobacterium', + mod_name: "DrCyano's Lootable Bodies", mod_authors: 'Cyanobacterium,SkyBlade1978', + minecraft_version: '1.10.2', forge_version: '12.18.3.2511', + mapping_channel: 'stable', mapping_version: '29-1.10.2', + loader_name: 'forge', loader_code: '1', curseforge_project_id: '240968', + java_version: '8', java_toolchain_version: '8.0.502+7', gradle_java_version: '17', + mod_license: 'LGPL-2.1-only', mod_license_name: 'GNU Lesser General Public License, Version 2.1', + mod_license_url: 'https://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt', + mod_url: 'https://github.com/MinecraftModDevelopmentMods/LootableBodies', + mod_scm_connection: 'scm:git:https://github.com/MinecraftModDevelopmentMods/LootableBodies.git', + mod_scm_developer_connection: 'scm:git:ssh://git@github.com/MinecraftModDevelopmentMods/LootableBodies.git' +] +expectedIdentity.each { key, value -> + if (project.findProperty(key)?.toString() != value) + throw new GradleException("Release identity mismatch: ${key} must be ${value}") +} +def versionParts = project.version.toString().split('\\.', -1) +def minecraftParts = project.minecraft_version.toString().split('\\.') +String target = minecraftParts[0] + minecraftParts[1].padLeft(2, '0') + + (minecraftParts.length == 3 ? minecraftParts[2] : '0').padLeft(2, '0') + project.loader_code +if (versionParts.length != 4 || !versionParts.every { it ==~ /(?:0|[1-9][0-9]*)/ } || versionParts[3] != target) + throw new GradleException("mod_version must use Major.Minor.Bug.${target} for Minecraft ${minecraft_version} Forge") +ext.functional_version = versionParts[0..2].join('.') +ext.release_tag = project.version.toString() +ext.display_version = project.version.toString() + +def preparedReleaseDir = providers.gradleProperty('preparedReleaseDir') +def generatedPom = layout.buildDirectory.file('publications/mavenJava/pom-default.xml') + +tasks.register('verifyReleaseConfiguration') { + group = 'verification' + doLast { + String source = file('src/main/java/cyano/lootable/LootableBodies.java').getText('UTF-8') + if (!source.contains("VERSION = \"${project.version}\"") || !source.contains('MODID = "lootablebodies"') || + !source.contains('acceptedMinecraftVersions = "[1.10.2]"') || !source.contains('String idName = "Corpse"')) + throw new GradleException('Java release version or persistent identity differs from release metadata') + if (!file('README.md').getText('UTF-8').contains(releaseNames[0]) || + !file('CHANGELOG.txt').getText('UTF-8').contains(project.version.toString())) + throw new GradleException('Release documentation does not match the declared artifact/version') + if (!file('LICENSE').getText('UTF-8').contains('Version 2.1, February 1999') || + !file('LICENSE-CC0').getText('UTF-8').startsWith('CC0 1.0 Universal')) + throw new GradleException('Maintained LGPL licence or original CC0 notice is missing') + } +} +tasks.withType(JavaCompile).configureEach { dependsOn 'verifyReleaseConfiguration' } +tasks.named('processResources') { dependsOn 'verifyReleaseConfiguration' } + +publishing { + publications { + mavenJava(MavenPublication) { + groupId = project.mod_group + artifactId = project.mod_artifact + version = project.version.toString() + if (preparedReleaseDir.isPresent()) { + artifact(new File(file(preparedReleaseDir.get()), releaseNames[0])) + artifact(new File(file(preparedReleaseDir.get()), releaseNames[1])) { classifier = 'sources' } + artifact(new File(file(preparedReleaseDir.get()), releaseNames[2])) { classifier = 'javadoc' } + } else { + artifact(releaseJar) + artifact(tasks.named('sourcesJar')) + artifact(tasks.named('javadocJar')) + } + pom { + name = project.mod_name + description = project.mod_description + url = project.mod_url + licenses { license { name = project.mod_license_name; url = project.mod_license_url; distribution = 'repo' } } + developers { + developer { id = 'cyanobacterium'; name = 'Cyanobacterium (DrCyano / Synechocystis)' } + developer { id = 'SkyBlade1978'; name = 'SkyBlade1978'; url = 'https://github.com/SkyBlade1978' } + } + scm { + connection = project.mod_scm_connection + developerConnection = project.mod_scm_developer_connection + url = project.mod_url + } + } + } + } + repositories { + maven { + name = 'release' + url = uri(providers.environmentVariable('MAVEN_UPLOAD_URL').getOrElse('https://invalid.invalid/publication-disabled')) + credentials { + username = providers.environmentVariable('MAVEN_UPLOAD_USERNAME').getOrElse('') + password = providers.environmentVariable('MAVEN_UPLOAD_PASSWORD').getOrElse('') + } + } + } +} + +tasks.register('verifyMavenCoordinates') { + group = 'verification' + dependsOn 'generatePomFileForMavenJavaPublication', 'verifyReleaseConfiguration' + doLast { + def pom = new XmlSlurper(false, false).parse(generatedPom.get().asFile) + if ([pom.groupId.text(), pom.artifactId.text(), pom.version.text()] != + [project.mod_group, project.mod_artifact, project.version.toString()] || + pom.name.text() != project.mod_name || pom.url.text() != project.mod_url || + pom.licenses.license.size() != 1 || pom.licenses.license.name.text() != project.mod_license_name || + pom.licenses.license.url.text() != project.mod_license_url || + pom.scm.connection.text() != project.mod_scm_connection || + pom.scm.developerConnection.text() != project.mod_scm_developer_connection || + pom.scm.url.text() != project.mod_url || pom.dependencies.dependency.size() != 0 || + !pom.developers.developer.name.text().contains('Cyanobacterium')) + throw new GradleException('Generated Maven POM does not match the release identity/licence') + def declared = publishing.publications.mavenJava.artifacts.collect { + [it.extension, it.classifier ?: '', it.file.name] + }.sort { it[1] } + def expected = [['jar', '', releaseNames[0]], ['jar', 'sources', releaseNames[1]], + ['jar', 'javadoc', releaseNames[2]]].sort { it[1] } + if (declared != expected) throw new GradleException('Maven must contain exactly runtime, sources and Javadocs') + } +} +tasks.withType(Test).configureEach { dependsOn 'verifyMavenCoordinates' } +tasks.named('check') { dependsOn 'verifyMavenCoordinates', 'verifyReleaseConfiguration' } + +tasks.register('prepareReleaseBundle', Sync) { + group = 'build' + description = 'Prepares the audited local jars, POM, checksums and changelog without publishing.' + dependsOn 'writeReleaseChecksums', 'verifyMavenCoordinates' + from(layout.buildDirectory.dir('libs')) { include releaseNames } + from(generatedPom) { rename { releasePomName } } + from(layout.buildDirectory.file('release/SHA256SUMS')) + from('CHANGELOG.txt') + into layout.buildDirectory.dir('release/bundle') +} +tasks.register('verifyPreparedReleaseArtifacts') { + group = 'verification' + description = 'Checks an immutable prepared bundle without recompiling its jars.' + dependsOn 'verifyMavenCoordinates' + doLast { + if (!preparedReleaseDir.isPresent()) throw new GradleException('preparedReleaseDir is required') + File prepared = file(preparedReleaseDir.get()) + Set expected = (releaseNames + [releasePomName, 'SHA256SUMS', 'CHANGELOG.txt']) as Set + Set actual = (prepared.listFiles() ?: [] as File[])*.name as Set + if (!prepared.isDirectory() || actual != expected) + throw new GradleException('Prepared release must contain exactly the declared jars, POM, checksums and changelog') + auditReleaseArchives(prepared) + List sums = releaseNames.collect { "${releaseDigest(new File(prepared, it))} ${it}".toString() }.sort() + if (new File(prepared, 'SHA256SUMS').readLines('UTF-8').sort() != sums) + throw new GradleException('Prepared release checksums do not match the immutable jars') + if (!Arrays.equals(new File(prepared, releasePomName).bytes, generatedPom.get().asFile.bytes)) + throw new GradleException('Prepared release POM differs from the configured release metadata') + if (!Arrays.equals(new File(prepared, 'CHANGELOG.txt').bytes, file('CHANGELOG.txt').bytes)) + throw new GradleException('Prepared release changelog differs from the reviewed source') + } +} + +// Only the confirmed MMD release workflow may upload an audited prepared bundle. +gradle.taskGraph.whenReady { graph -> + if (graph.allTasks.any { it instanceof AbstractPublishToMaven || it.name in ['publish', 'publishToMavenLocal'] }) { + if (graph.allTasks.any { it.name == 'publishToMavenLocal' || it.class.name.contains('PublishToMavenLocal') } || + System.getenv('GITHUB_ACTIONS') != 'true' || + System.getenv('GITHUB_REPOSITORY') != 'MinecraftModDevelopmentMods/LootableBodies' || + System.getenv('LOOTABLEBODIES_RELEASE_VERSION') != project.version.toString() || + !(System.getenv('GITHUB_RUN_ID') ==~ /[0-9]+/) || !preparedReleaseDir.isPresent()) + throw new GradleException('Publication is disabled outside the confirmed MMD release workflow') + } +} +tasks.register('validateMavenRelease') { + doLast { + def expectedSha = System.getenv('LOOTABLEBODIES_RELEASE_SHA') ?: '' + def gitHead = providers.exec { commandLine('git', 'rev-parse', 'HEAD') } + def actualSha = gitHead.standardOutput.asText.get().trim() + if (!expectedSha.matches('[a-f0-9]{40}') || actualSha != expectedSha) + throw new GradleException('The publication checkout does not match the confirmed release commit') + def uploadUrl = System.getenv('MAVEN_UPLOAD_URL') ?: '' + if (!uploadUrl.matches('https://[^/ ]+(/.*)?') || uploadUrl.startsWith('https://invalid.invalid') || + !System.getenv('MAVEN_UPLOAD_USERNAME') || !System.getenv('MAVEN_UPLOAD_PASSWORD')) + throw new GradleException('A remote HTTPS Maven repository and release credentials are required') + } +} +tasks.withType(AbstractPublishToMaven).configureEach { + dependsOn 'validateMavenRelease', 'verifyPreparedReleaseArtifacts' +} diff --git a/gradle/runtime.gradle b/gradle/runtime.gradle new file mode 100644 index 0000000..2921812 --- /dev/null +++ b/gradle/runtime.gradle @@ -0,0 +1,200 @@ +// Build-only legacy runtime verification, separate from production classpaths. +def probeClasses = layout.buildDirectory.dir('integration-test-mod/classes') +tasks.register('compileIntegrationTestMod', JavaCompile) { + group = 'verification' + dependsOn 'classes' + source fileTree('src/integrationTest/java') + classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) + destinationDirectory = probeClasses + options.compilerArgs.add('-proc:none') +} +def probeJar = tasks.register('integrationTestModJar', Jar) { + group = 'verification' + dependsOn 'compileIntegrationTestMod' + archiveFileName = 'lootablebodies-build-tests.jar' + destinationDirectory = layout.buildDirectory.dir('integration-test-mod') + from probeClasses + from 'src/integrationTest/resources' +} +def packagedProbe = renamer.classes(probeJar) { + map.from minecraft.dependency.toSrgFile + libraries.from(tasks.named('jar'), sourceSets.main.compileClasspath) + output = layout.buildDirectory.file('integration-test-mod/lootablebodies-build-tests-reobf.jar') +} +tasks.register('preparePackagedIntegrationProbe') { dependsOn packagedProbe, releaseJar } + +def runDirectory = layout.buildDirectory.dir('integration-run') +tasks.register('prepareIntegrationTest') { + dependsOn probeJar + doLast { + File root = runDirectory.get().asFile + if (root.exists()) { + File history = layout.buildDirectory.dir('runtime-history/' + System.currentTimeMillis()).get().asFile + history.parentFile.mkdirs() + if (!root.renameTo(history)) throw new GradleException('Cannot preserve previous runtime evidence') + } + new File(root,'mods').mkdirs() + copy { from probeJar; into new File(root,'mods') } + new File(root,'eula.txt').text = 'eula=true\n' + ['ops','whitelist','banned-players','banned-ips'].each { new File(root,"${it}.json").text='[]\n' } + new File(root,'server.properties').text = '''level-name=world +level-seed=4267321 +level-type=FLAT +online-mode=false +server-ip=127.0.0.1 +server-port=0 +generate-structures=false +spawn-animals=false +spawn-monsters=false +spawn-protection=0 +max-tick-time=120000 +''' + } +} + +// Reuse FG7's actual bootstrap, mappings, natives and pinned Java launcher. +def phaseTask = { String phase, Object prerequisite -> + tasks.register("integration${phase.capitalize()}Process", JavaExec) { + group = 'verification' + dependsOn prerequisite + dependsOn { def run = tasks.getByName('runServer'); run.taskDependencies.getDependencies(run) } + actions.clear() + doLast { + JavaExec run = tasks.getByName('runServer') + def options = minecraft.runs.getByName('server') + File previousDir = options.workingDir.get().asFile + def previousProperties = new LinkedHashMap(run.systemProperties) + def previousClasspath = files(run.classpath) + try { + options.workingDir.set(runDirectory) + run.workingDir(runDirectory) + run.systemProperty('lootablebodies.phase', phase) + run.systemProperty('log4j.configurationFile', file('src/integrationTest/resources/log4j2.xml').toURI()) + run.executable = productionLauncher.get().executablePath.asFile.path + run.setClasspath(files(developmentOutput, run.classpath.filter { !sourceSets.main.output.files.contains(it) })) + run.exec() + } finally { + options.workingDir.set(previousDir) + run.workingDir(previousDir) + run.setSystemProperties(previousProperties) + run.setClasspath(previousClasspath) + } + File marker = new File(runDirectory.get().asFile,"world/${phase}.pass") + if (!marker.isFile()) throw new GradleException("Missing ${phase} corpse preservation marker") + File logs = new File(runDirectory.get().asFile,'logs/latest.log') + if (!logs.isFile() || logs.text =~ /(?i)(AssertionError|Encountered an unexpected exception|Exception stopping server)/) + throw new GradleException("Runtime failure in ${logs}") + copy { from logs; into runDirectory; rename { "${phase}.log" } } + if (!fileTree(new File(runDirectory.get().asFile,'crash-reports')).isEmpty()) + throw new GradleException('Runtime crash reports found') + } + } +} +def fresh = phaseTask('fresh','prepareIntegrationTest') +def inventory = phaseTask('inventory','prepareIntegrationTest') +tasks.register('inventoryRegressionTest') { group='verification'; dependsOn inventory } + +def nbt = phaseTask('nbt','prepareIntegrationTest') +tasks.register('nbtRecoveryTest') { group='verification'; dependsOn nbt } + +def scalars = phaseTask('scalars','prepareIntegrationTest') +tasks.register('nameLifetimeTest') { group='verification'; dependsOn scalars } + +def health = phaseTask('health','prepareIntegrationTest') +tasks.register('healthAttributeTest') { group='verification'; dependsOn health } + +def effects = phaseTask('effects','prepareIntegrationTest') + +tasks.register('effectHurtTest') { group='verification'; dependsOn effects } + +def geometry = phaseTask('geometry','prepareIntegrationTest') + +tasks.register('positionMotionTest') { group='verification'; dependsOn geometry } + +def rotation = phaseTask('rotation','prepareIntegrationTest') + +tasks.register('rotationFallTest') { group='verification'; dependsOn rotation } + +def drops = phaseTask('drops','prepareIntegrationTest') +tasks.register('dropCreativeInteractionTest') { group='verification'; dependsOn drops } + +def interactions = phaseTask('interactions','prepareIntegrationTest') +tasks.register('containerInteractionTest') { group='verification'; dependsOn interactions } +def reload = phaseTask('reload',fresh) +def deferred = phaseTask('deferred',reload) +tasks.register('integrationTest') { group = 'verification'; dependsOn deferred } +tasks.named('check') { dependsOn 'integrationTest' } + +tasks.register('clientIntegrationTest') { + group = 'verification' + dependsOn probeJar + dependsOn { def run = tasks.getByName('runClient'); run.taskDependencies.getDependencies(run) } + doLast { + File root = layout.buildDirectory.dir('client-integration-run').get().asFile + if(root.exists()) throw new GradleException('Preserve the previous client evidence before rerunning') + new File(root,'mods').mkdirs() + new File(root,'config').mkdirs() + copy { from probeJar; into new File(root,'mods') } + new File(root,'config/lootablebodies.cfg').text = 'options {\n B:local_texture=true\n}\n' + JavaExec run = tasks.getByName('runClient') + def options = minecraft.runs.getByName('client') + options.workingDir.set(root) + run.workingDir(root) + run.systemProperty('lootablebodies.clientCheck','true') + run.systemProperty('log4j.configurationFile',file('src/integrationTest/resources/log4j2.xml').toURI()) + run.executable = productionLauncher.get().executablePath.asFile.path + run.setClasspath(files(developmentOutput,run.classpath.filter { !sourceSets.main.output.files.contains(it) })) + run.exec() + if(!new File(root,'client.pass').isFile()) throw new GradleException('Client did not preserve/reopen/render the corpse GUI') + if(!fileTree(new File(root,'crash-reports')).isEmpty()) throw new GradleException('Client crash report') + } +} +tasks.register('exportRuntimeClasspath') { + group='verification' + doLast { + File output=layout.buildDirectory.file('runtime-classpath.json').get().asFile + output.parentFile.mkdirs() + output.text=groovy.json.JsonOutput.toJson(configurations.runtimeClasspath.files*.absolutePath) + } +} + +def controlClasses = layout.buildDirectory.dir('control-test-mod/classes') +tasks.register('compileForgeControl',JavaCompile) { + source fileTree('src/controlTest/java') + classpath=sourceSets.main.compileClasspath + destinationDirectory=controlClasses + options.compilerArgs.add('-proc:none') +} +def controlJar=tasks.register('forgeControlJar',Jar) { + dependsOn 'compileForgeControl' + from controlClasses + archiveFileName='forge-platform-control.jar' + destinationDirectory=layout.buildDirectory.dir('control-test-mod') +} +def packagedControl=renamer.classes(controlJar) { + map.from minecraft.dependency.toSrgFile + libraries.from(sourceSets.main.compileClasspath) + output=layout.buildDirectory.file('control-test-mod/forge-platform-control-reobf.jar') +} +tasks.register('preparePackagedForgeControl') { dependsOn packagedControl } +tasks.register('forgeControlServer') { + group='verification' + dependsOn controlJar + dependsOn { def run=tasks.getByName('runServer'); run.taskDependencies.getDependencies(run) } + doLast { + File root=layout.buildDirectory.dir('forge-control-run').get().asFile + new File(root,'mods').mkdirs() + copy { from controlJar; into new File(root,'mods') } + new File(root,'eula.txt').text='eula=true\n' + new File(root,'server.properties').text='level-name=world\nlevel-type=FLAT\nserver-ip=127.0.0.1\nserver-port=0\nonline-mode=false\n' + ['ops','whitelist','banned-players','banned-ips'].each { new File(root,"${it}.json").text='[]\n' } + JavaExec run=tasks.getByName('runServer') + minecraft.runs.getByName('server').workingDir.set(root) + run.workingDir(root) + run.systemProperty('log4j.configurationFile',file('src/integrationTest/resources/log4j2.xml').toURI()) + run.setClasspath(run.classpath.filter { !sourceSets.main.output.files.contains(it) && it != developmentOutput.get().asFile }) + run.exec() + String text=new File(root,'logs/latest.log').text + if(!text.contains('FORGE_PLATFORM_CONTROL_PASS') || text.contains('lootablebodies@')) throw new GradleException('Invalid independent platform control') + } +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 0d4a9516871afd710a9d84d89e31ba77745607bd..b1b8ef56b44f16b14dc800fa8103a6d89abb526f 100644 GIT binary patch literal 48462 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 R2np~f1o-$>Q}y+){|A@R9n$~+ literal 54413 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 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index a95009c..dbe66e1 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,10 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-4.9-bin.zip +distributionSha256Sum=9c0f7faeeb306cb14e4279a3e084ca6b596894089a0638e68a07c945a32c9e14 +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index cccdd3d..249efbb 100644 --- a/gradlew +++ b/gradlew @@ -1,78 +1,128 @@ -#!/usr/bin/env sh +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## -## -## Gradle start up script for UN*X -## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # Attempt to set APP_HOME + # Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null - -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS="" +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -81,92 +131,118 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac fi -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) -# For Cygwin, switch paths to Windows format before running java -if $cygwin ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) fi - i=$((i+1)) + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg done - case $i in - (0) set -- ;; - (1) set -- "$args0" ;; - (2) set -- "$args0" "$args1" ;; - (3) set -- "$args0" "$args1" "$args2" ;; - (4) set -- "$args0" "$args1" "$args2" "$args3" ;; - (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=$(save "$@") - -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" -# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong -if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then - cd "$(dirname "$0")" +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" fi +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index e95643d..a51ec4f 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -1,84 +1,82 @@ -@if "%DEBUG%" == "" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS= - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto init - -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto init - -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:init -@rem Get command-line arguments, handling Windows variants - -if not "%OS%" == "Windows_NT" goto win9xME_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% - -:end -@rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/mcmod.info b/mcmod.info deleted file mode 100644 index 747c047..0000000 --- a/mcmod.info +++ /dev/null @@ -1,16 +0,0 @@ -[ -{ - "modid": "lootable", - "name": "DrCyano's Lootable Bodies", - "description": "Saves your items when you die in a container so that you can get your items back when you return.", - "version": "${version}", - "mcversion": "${mcversion}", - "url": "https://github.com/cyanobacterium/DrCyanos_Lootable-Bodies", - "updateUrl": "", - "authorList": ["Cyanobacterium"], - "credits": "The Forge and FML guys, for making the Forge modding API (you guys are awesome), plant-eating zombies, and the mod author Cyanobacterium (aka Synechocystis)", - "logoFile": "", - "screenshots": [], - "dependencies": [] -} -] diff --git a/resources/mcmod.info b/resources/mcmod.info deleted file mode 120000 index 8b9091c..0000000 --- a/resources/mcmod.info +++ /dev/null @@ -1 +0,0 @@ -src/main/resources/mcmod.info \ No newline at end of file diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..d6dc9f9 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,4 @@ +plugins { + id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0' +} +rootProject.name = 'LootableBodies' diff --git a/src/controlTest/java/buildcontrol/ForgeControl.java b/src/controlTest/java/buildcontrol/ForgeControl.java new file mode 100644 index 0000000..9532c46 --- /dev/null +++ b/src/controlTest/java/buildcontrol/ForgeControl.java @@ -0,0 +1,28 @@ +package buildcontrol; +import net.minecraftforge.fml.common.*; +import net.minecraftforge.fml.common.event.*; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; +import net.minecraftforge.fml.common.gameevent.TickEvent; + +/** Independent Forge/platform control; never loads Lootable Bodies. */ +@Mod(modid="forgeplatformcontrol",name="Forge platform control",version="1") +public final class ForgeControl { + @Mod.EventHandler public void started(FMLServerStartedEvent event) { + if(FMLCommonHandler.instance().getSide().isServer()) { + System.out.println("FORGE_PLATFORM_CONTROL_PASS"); + FMLCommonHandler.instance().getMinecraftServerInstance().initiateShutdown(); + } + } + @Mod.EventHandler public void init(FMLInitializationEvent event) { + if(FMLCommonHandler.instance().getSide().isClient()) new ClientControl().register(); + } + public static final class ClientControl { + void register() { FMLCommonHandler.instance().bus().register(this); } + @SubscribeEvent public void tick(TickEvent.ClientTickEvent event) { + if(event.phase==TickEvent.Phase.END && net.minecraft.client.Minecraft.getMinecraft().currentScreen instanceof net.minecraft.client.gui.GuiMainMenu) { + System.out.println("FORGE_PLATFORM_CONTROL_PASS"); + net.minecraft.client.Minecraft.getMinecraft().shutdown(); + } + } + } +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/ClientChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/ClientChecks.java new file mode 100644 index 0000000..7ac00db --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/ClientChecks.java @@ -0,0 +1,125 @@ +package cyano.lootable.buildtest; + +import cyano.lootable.entities.EntityLootableBody; +import cyano.lootable.graphics.CorpseGUIContainer; +import java.io.*; +import java.util.concurrent.Future; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiMainMenu; +import net.minecraft.entity.Entity; +import net.minecraft.util.EnumHand; +import net.minecraft.util.ScreenShotHelper; +import net.minecraft.world.*; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.fml.common.FMLCommonHandler; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; +import net.minecraftforge.fml.common.gameevent.TickEvent; + +/** Automated real-client world, packet interaction, GUI render and reload check. */ +public final class ClientChecks { + private int stage, ticks, frames; + private int reportedStage=-1; + private Future pending; + private final ConnectedClientChecks connected = new ConnectedClientChecks(); + private final DropClientChecks drops = new DropClientChecks(); + private final NbtClientChecks nbt = new NbtClientChecks(); + private final ReportedIssueClientChecks reportedIssues = new ReportedIssueClientChecks(); + private final VisibilitySkinClientChecks visibilitySkins = new VisibilitySkinClientChecks(); + private final ConfigClientChecks configScreen = new ConfigClientChecks(); + @SubscribeEvent public void mouse(net.minecraftforge.client.event.GuiScreenEvent.MouseInputEvent.Pre event) { + event.setCanceled(true); + } + @SubscribeEvent public void keyboard(net.minecraftforge.client.event.GuiScreenEvent.KeyboardInputEvent.Pre event) { + event.setCanceled(true); + } + public void register() { + if (Boolean.getBoolean("lootablebodies.clientCheck")) { + FMLCommonHandler.instance().bus().register(this); + MinecraftForge.EVENT_BUS.register(this); + } + } + @SubscribeEvent public void tick(TickEvent.ClientTickEvent event) { + if(event.phase != TickEvent.Phase.END) return; + Minecraft mc = Minecraft.getMinecraft(); + // The verifier never needs to capture the user's mouse or keyboard. + if(mc.inGameHasFocus) mc.setIngameNotInFocus(); + net.minecraft.client.settings.KeyBinding.unPressAllKeys(); + if(stage!=reportedStage || ticks%60==0) { + System.out.println("GRAPHICAL_STAGE "+stage+" screen="+(mc.currentScreen==null?"world":mc.currentScreen.getClass().getSimpleName())+ + " position="+(mc.player==null?"none":mc.player.getPosition())+" focus="+mc.inGameHasFocus); + reportedStage=stage; + } + if(ticks>0 && ticks%150==0) ScreenShotHelper.saveScreenshot(mc.mcDataDir,"diagnostic-stage-"+stage+"-tick-"+ticks+".png",mc.displayWidth,mc.displayHeight,mc.getFramebuffer()); + if(++ticks > 12000) fail(new AssertionError("client timeout stage="+stage)); + try { + RuntimeChecks.require(!new File(mc.mcDataDir,"abort").exists(),"Disposable graphical check aborted"); + if(Boolean.getBoolean("lootablebodies.configUiCheck")) {configScreen.tick(mc);return;} + if(Boolean.getBoolean("lootablebodies.visibilitySkinCheck")) {visibilitySkins.tick(mc);return;} + if(Boolean.getBoolean("lootablebodies.issueClientCheck")) {reportedIssues.tick(mc);return;} + if(stage==0 && mc.currentScreen instanceof GuiMainMenu) { + mc.gameSettings.pauseOnLostFocus=false; + mc.gameSettings.limitFramerate=30; + mc.gameSettings.renderDistanceChunks=4; + stage=1; + mc.launchIntegratedServer("build-fixture", "Build fixture",new WorldSettings(4267321,GameType.CREATIVE,false,false,WorldType.FLAT)); + } else if(stage==1 && mc.world!=null && mc.player!=null && mc.getIntegratedServer()!=null) { + stage=2; + pending=mc.getIntegratedServer().addScheduledTask(() -> { + try { RuntimeChecks.run(mc.getIntegratedServer(),"fresh"); } + catch(Exception error) { throw new RuntimeException(error); } + net.minecraft.entity.player.EntityPlayerMP player=mc.getIntegratedServer().getPlayerList().getPlayerByUUID(mc.player.getUniqueID()); + player.capabilities.isFlying=true;player.sendPlayerAbilities(); + player.connection.setPlayerLocation(8.5,80,7.5,0,0); + }); + } else if(stage==2 && pending.isDone()) { + pending.get(); + for(Entity entity : mc.world.loadedEntityList) if(entity instanceof EntityLootableBody && entity.getUniqueID().equals(RuntimeChecks.NEAR)) { + mc.playerController.interactWithEntity(mc.player,entity,null,EnumHand.MAIN_HAND); + stage=3; break; + } + } else if(stage==3 && mc.currentScreen instanceof CorpseGUIContainer && ++frames>20) { + RuntimeChecks.require(((CorpseGUIContainer)mc.currentScreen).inventorySlots.inventorySlots.size()==84,"rendered GUI slots"); + ScreenShotHelper.saveScreenshot(mc.mcDataDir,"corpse-gui-fresh.png",mc.displayWidth,mc.displayHeight,mc.getFramebuffer()); + mc.player.closeScreen(); stage=4; + mc.getIntegratedServer().initiateShutdown(); + } else if(stage==4 && mc.world==null && !mc.isIntegratedServerRunning()) { + stage=5; mc.launchIntegratedServer("build-fixture","Build fixture",null); + } else if(stage==5 && mc.player!=null && mc.world!=null && mc.getIntegratedServer()!=null) { + stage=6; + pending=mc.getIntegratedServer().addScheduledTask(() -> { + try { RuntimeChecks.run(mc.getIntegratedServer(),"deferred"); } + catch(Exception error) { throw new RuntimeException(error); } + net.minecraft.entity.player.EntityPlayerMP player=mc.getIntegratedServer().getPlayerList().getPlayerByUUID(mc.player.getUniqueID()); + player.capabilities.isFlying=true;player.sendPlayerAbilities(); + player.connection.setPlayerLocation(8.5,80,7.5,0,0); + }); + } else if(stage==6 && pending.isDone()) { + pending.get(); + for(Entity entity:mc.world.loadedEntityList) if(entity instanceof EntityLootableBody && entity.getUniqueID().equals(RuntimeChecks.NEAR)) { + mc.playerController.interactWithEntity(mc.player,entity,null,EnumHand.MAIN_HAND); + stage=7; frames=0; break; + } + } else if(stage==7 && mc.currentScreen instanceof CorpseGUIContainer && ++frames>20) { + ScreenShotHelper.saveScreenshot(mc.mcDataDir,"corpse-gui-reload.png",mc.displayWidth,mc.displayHeight,mc.getFramebuffer()); + mc.player.closeScreen(); stage=20; connected.start(mc); + } else if(stage==20 && connected.tick(mc)) { + mc.player.closeScreen();drops.start(mc);stage=21; + } else if(stage==21 && drops.tick(mc)) { + mc.player.closeScreen();nbt.start(mc);stage=22; + } else if(stage==22 && nbt.tick(mc)) { + try(FileWriter out=new FileWriter(new File(mc.mcDataDir,"client.pass"))) { + out.write("fresh/reload GUI; connected death/respawn; network shift/normal recovery; two save/reloads verified\n"); + } + System.out.println("LOOTABLE_BODIES_CLIENT_PASS"); + stage=8; mc.player.closeScreen(); mc.getIntegratedServer().initiateShutdown(); + } else if(stage==8 && mc.world==null && !mc.isIntegratedServerRunning()) { + stage=9; mc.shutdown(); + } + } catch(Throwable error) { fail(error); } + } + private void fail(Throwable error) { + error.printStackTrace(); + Minecraft.getMinecraft().shutdown(); + stage=99; + } +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/ConfigClientChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/ConfigClientChecks.java new file mode 100644 index 0000000..50be35c --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/ConfigClientChecks.java @@ -0,0 +1,201 @@ +package cyano.lootable.buildtest; + +import cyano.lootable.LootableBodies; +import cyano.lootable.entities.EntityLootableBody; +import java.io.*; +import java.lang.reflect.*; +import java.util.*; +import java.util.concurrent.Future; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.*; +import net.minecraft.entity.Entity; +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.util.ScreenShotHelper; +import net.minecraft.world.*; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.fml.client.*; +import net.minecraftforge.fml.client.config.*; +import net.minecraftforge.fml.client.config.GuiConfigEntries.*; +import net.minecraftforge.fml.client.event.ConfigChangedEvent; +import net.minecraftforge.fml.common.*; + +/** Exercises the actual Forge Config button and widgets without OS input control. */ +final class ConfigClientChecks { + private int stage, frames; + private GuiModList mods; + private GuiConfig root, appearance, rules; + private Future pending; + private final UUID bodyId = UUID.fromString("00000000-0000-4000-8000-000000000941"); + private File report; + + void tick(Minecraft mc) throws Exception { + if (++frames < 20) return; + frames = 0; + if (report == null) report = new File(mc.mcDataDir, "config-checks.txt"); + if (stage == 0 && mc.currentScreen instanceof GuiMainMenu) { + mc.gameSettings.pauseOnLostFocus = false; mc.gameSettings.limitFramerate = 30; + mc.gameSettings.renderDistanceChunks = 4; + if (Boolean.getBoolean("lootablebodies.configUiReload")) { + check(LootableBodies.corpseHP == 31 && LootableBodies.corpseDecayTime == 200 + && LootableBodies.ticksPerItemDecay == 0, "pending_rules_apply_after_process_restart"); + check(!LootableBodies.useLocalSkin && LootableBodies.displayNameTag, "appearance_survives_restart"); + finish(mc); return; + } + check(LootableBodies.corpseHP == 20 && LootableBodies.ticksPerItemDecay == 6000, "startup_defaults"); + openMods(mc); stage = 1; + } else if (stage == 1) { + screenshot(mc, "01-mods-config-button.png"); + openConfig(mc); stage = 2; + } else if (stage == 2) { + check(root.configElements.size() == 2, "appearance_and_rules_pages"); + screenshot(mc, "02-config-root.png"); + appearance = category(root, 0); mc.displayGuiScreen(appearance); stage = 3; + } else if (stage == 3) { + screenshot(mc, "03-appearance.png"); + toggle(appearance, 0); toggle(appearance, 1); + click(appearance, 2000); // child Done stages edits; root Escape discards them. + mc.displayGuiScreen(mods); + check(!LootableBodies.useLocalSkin && LootableBodies.displayNameTag, "leaving_without_done_discards_changes"); + openConfig(mc); appearance = category(root, 0); mc.displayGuiScreen(appearance); + toggle(appearance, 0); toggle(appearance, 1); click(appearance, 2000); click(root, 2000); + check(LootableBodies.useLocalSkin && !LootableBodies.displayNameTag, "appearance_done_applies_immediately"); + openConfig(mc); rules = category(root, 1); mc.displayGuiScreen(rules); stage = 4; + } else if (stage == 4) { + screenshot(mc, "04-world-rules.png"); + for (IConfigEntry entry : rules.entryList.listEntries) + check(entry.enabled() && entry.getConfigElement().requiresMcRestart(), "rule_restart_policy_" + entry.getConfigElement().getName()); + IConfigEntry health = rules.entryList.listEntries.get(0); + text(health, "31"); health.undoChanges(); + check(health.getCurrentValue().toString().equals("20.0"), "undo_restores_health"); + text(health, "31"); health.setToDefault(); + check(health.getCurrentValue().toString().equals("20.0"), "defaults_restore_health"); + text(health, "31"); text(rules.entryList.listEntries.get(2), "0"); + IConfigEntry lifetime = rules.entryList.listEntries.get(8); + for (String invalid : Arrays.asList("bad", "1:-2", "29826:9:43", "1:2:3:4")) { + text(lifetime, invalid); lifetime.saveConfigElement(); + check(lifetime.getConfigElement().get().equals("1:00:00"), "invalid_time_not_saved_" + invalid); + } + text(lifetime, "0:00:10"); click(rules, 2000); click(root, 2000); + check(mc.currentScreen instanceof GuiMessageDialog, "restart_dialog_shown"); + check(LootableBodies.corpseHP == 20 && LootableBodies.corpseDecayTime == 72000 + && LootableBodies.ticksPerItemDecay == 6000, "gameplay_unchanged_until_restart"); + stage = 5; + } else if (stage == 5) { + screenshot(mc, "05-restart-notice.png"); + mc.displayGuiScreen(null); + mc.launchIntegratedServer("config-fixture", "Config fixture", + new WorldSettings(4267321, GameType.CREATIVE, false, false, WorldType.FLAT)); stage = 6; + } else if (stage == 6 && mc.world != null && mc.player != null) { + pending = mc.getIntegratedServer().addScheduledTask(() -> { + try { + // Freeze only the fixture lifetime while comparing exact saved timer fields. + LootableBodies.allowCorpseDecay = false; + EntityLootableBody body = RuntimeChecks.fixture(mc.getIntegratedServer().worldServerForDimension(0), bodyId, 8, true); + RuntimeChecks.require(body.getAlwaysRenderNameTag(), "Local appearance preference leaked into saved corpse metadata"); + // Simulate an old corpse whose saved visibility flag was false. + body.setAlwaysRenderNameTag(false); + RuntimeChecks.saveExpected(mc.getIntegratedServer().worldServerForDimension(0).getSaveHandler().getWorldDirectory(), "config-body", body); + net.minecraft.entity.player.EntityPlayerMP player = mc.getIntegratedServer().getPlayerList().getPlayerByUUID(mc.player.getUniqueID()); + player.capabilities.isFlying = true; player.sendPlayerAbilities(); + player.connection.setPlayerLocation(8.5, 80, 7.5, 0, 0); + } catch (Exception error) { throw new RuntimeException(error); } + }); stage = 7; + } else if (stage == 7 && pending.isDone()) { + pending.get(); openMods(mc); openConfig(mc); + rules = category(root, 1); mc.displayGuiScreen(rules); stage = 8; + } else if (stage == 8) { + screenshot(mc, "06-rules-disabled-in-world.png"); + for (IConfigEntry entry : rules.entryList.listEntries) check(!entry.enabled(), "rule_disabled_in_world_" + entry.getConfigElement().getName()); + click(rules, 2000); appearance = category(root, 0); mc.displayGuiScreen(appearance); + for (IConfigEntry entry : appearance.entryList.listEntries) check(entry.enabled(), "appearance_enabled_in_world_" + entry.getConfigElement().getName()); + // Cache a synthetic profile: switching skins needs no external account service. + net.minecraft.tileentity.TileEntitySkull.setProfileCache(new net.minecraft.server.management.PlayerProfileCache(null, + new File(mc.mcDataDir, "synthetic-cache.json")) { + @Override public com.mojang.authlib.GameProfile getGameProfileForUsername(String name) { + com.mojang.authlib.GameProfile profile = new com.mojang.authlib.GameProfile(bodyId, name); + profile.getProperties().put("textures", new com.mojang.authlib.properties.Property("textures", "eyJ0ZXh0dXJlcyI6e319")); return profile; + } + }); + toggle(appearance, 0); toggle(appearance, 1); click(appearance, 2000); click(root, 2000); + check(!LootableBodies.useLocalSkin && LootableBodies.displayNameTag, "existing_body_appearance_changes_live"); + // Another mod's event cannot change our settings. + LootableBodies.getConfiguration().getCategory("options").get("local_texture").set(true); + MinecraftForge.EVENT_BUS.post(new ConfigChangedEvent.OnConfigChangedEvent("anothermod", null, true, false)); + check(!LootableBodies.useLocalSkin, "other_mod_config_event_ignored"); + LootableBodies.getConfiguration().getCategory("options").get("local_texture").set(false); + pending = mc.getIntegratedServer().addScheduledTask(() -> { + try { + WorldServer world = mc.getIntegratedServer().worldServerForDimension(0); + EntityLootableBody body = RuntimeChecks.find(world, bodyId); + RuntimeChecks.verifyExpected(world.getSaveHandler().getWorldDirectory(), "config-body", body); + RuntimeChecks.require(LootableBodies.corpseHP == 20 && LootableBodies.ticksPerItemDecay == 6000, "GUI changed server rules"); + world.saveAllChunks(true, null); + } catch (Exception error) { throw new RuntimeException(error); } + }); stage = 9; + } else if (stage == 9 && pending.isDone()) { + pending.get(); + EntityLootableBody body = null; + for (Entity entity : mc.world.loadedEntityList) if (entity.getUniqueID().equals(bodyId)) body = (EntityLootableBody) entity; + check(body != null && body.getGameProfile() != null && body.getGameProfile().getId() != null, "existing_body_skin_refreshes_without_rename"); + check(!body.getAlwaysRenderNameTag(), "appearance_did_not_rewrite_synced_name_flag"); + mc.player.closeScreen(); mc.world.sendQuittingDisconnectingPacket(); mc.loadWorld(null); + mc.displayGuiScreen(new GuiMainMenu()); stage = 10; + } else if (stage == 10 && mc.world == null && !mc.isIntegratedServerRunning()) { + mc.launchIntegratedServer("config-fixture", "Config fixture", null); stage = 11; + } else if (stage == 11 && mc.world != null && mc.player != null) { + pending = mc.getIntegratedServer().addScheduledTask(() -> { + try { + WorldServer world = mc.getIntegratedServer().worldServerForDimension(0); world.getChunkFromChunkCoords(0, 0); + EntityLootableBody body = RuntimeChecks.find(world, bodyId); + RuntimeChecks.verifyExpected(world.getSaveHandler().getWorldDirectory(), "config-body", body); + RuntimeChecks.require(!body.getAlwaysRenderNameTag(), "Saved legacy visibility flag changed on reload"); + RuntimeChecks.require(body.getMaxHealth() == 20, "Pending GUI health rewrote existing corpse attributes"); + } catch (Exception error) { throw new RuntimeException(error); } + }); stage = 12; + } else if (stage == 12 && pending.isDone()) { + pending.get(); check(true, "corpse_inventory_and_nbt_survive_save_reload"); finish(mc); + } + } + + private void openMods(Minecraft mc) throws Exception { + mods = new GuiModList(new GuiMainMenu()); mc.displayGuiScreen(mods); + Field list = GuiModList.class.getDeclaredField("mods"); list.setAccessible(true); + List containers = (List) list.get(mods); + for (int i = 0; i < containers.size(); i++) if (((ModContainer) containers.get(i)).getModId().equals(LootableBodies.MODID)) mods.selectModIndex(i); + } + private void openConfig(Minecraft mc) throws Exception { + Field field = GuiModList.class.getDeclaredField("configModButton"); field.setAccessible(true); + GuiButton button = (GuiButton) field.get(mods); + check(button.enabled && button.visible, "mods_list_config_button_enabled"); + invokeButton(mods, button); root = (GuiConfig) mc.currentScreen; + check(root.getClass().getName().equals("cyano.lootable.graphics.CorpseConfigGui"), "mods_button_opens_our_screen"); + } + private GuiConfig category(GuiConfig screen, int index) throws Exception { + Field field = CategoryEntry.class.getDeclaredField("childScreen"); field.setAccessible(true); + return (GuiConfig) field.get(screen.entryList.listEntries.get(index)); + } + private void toggle(GuiConfig screen, int index) { ((BooleanEntry) screen.entryList.listEntries.get(index)).valueButtonPressed(index); } + private void text(IConfigEntry entry, String value) throws Exception { + Field field = StringEntry.class.getDeclaredField("textFieldValue"); field.setAccessible(true); + ((GuiTextField) field.get(entry)).setText(value); entry.keyTyped((char) 0, org.lwjgl.input.Keyboard.KEY_HOME); + } + private void click(GuiConfig screen, int id) throws Exception { invokeButton(screen, new GuiButton(id, 0, 0, "fixture")); } + private void invokeButton(GuiScreen screen, GuiButton button) throws Exception { + Class type = screen.getClass(); + while (type != null) { + for (Method method : type.getDeclaredMethods()) if (Arrays.equals(method.getParameterTypes(), new Class[] {GuiButton.class})) { + method.setAccessible(true); method.invoke(screen, button); return; + } + type = type.getSuperclass(); + } + throw new AssertionError("No button action"); + } + private void screenshot(Minecraft mc, String name) { ScreenShotHelper.saveScreenshot(mc.mcDataDir, name, mc.displayWidth, mc.displayHeight, mc.getFramebuffer()); } + private void check(boolean value, String name) throws Exception { + RuntimeChecks.require(value, name); try (FileWriter writer = new FileWriter(report, true)) { writer.write("PASS " + name + "\n"); } + } + private void finish(Minecraft mc) throws Exception { + new File(mc.mcDataDir, Boolean.getBoolean("lootablebodies.configUiReload") ? "config-restart.pass" : "client.pass").createNewFile(); + System.out.println("LOOTABLE_BODIES_CONFIG_UI_PASS restart=" + Boolean.getBoolean("lootablebodies.configUiReload")); mc.shutdown(); stage = 99; + } +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/ConnectedClientChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/ConnectedClientChecks.java new file mode 100644 index 0000000..d353131 --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/ConnectedClientChecks.java @@ -0,0 +1,205 @@ +package cyano.lootable.buildtest; + +import cyano.lootable.entities.EntityLootableBody; +import cyano.lootable.graphics.CorpseGUIContainer; +import java.io.*; +import java.util.*; +import java.util.concurrent.Future; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGameOver; +import net.minecraft.client.gui.GuiMainMenu; +import net.minecraft.entity.Entity; +import net.minecraft.entity.player.EntityPlayerMP; +import net.minecraft.init.Blocks; +import net.minecraft.inventory.ClickType; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.*; +import net.minecraft.server.MinecraftServer; +import net.minecraft.util.EnumHand; +import net.minecraft.util.ScreenShotHelper; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.GameType; + +/** Real connected-player death, container packets and persisted recovery. */ +final class ConnectedClientChecks { + private interface Action { void run() throws Exception; } + private int stage, frames; + private Future pending; + private UUID playerId, corpseId; + private NBTTagCompound visibleExpected; + private File report; + + void start(Minecraft mc) { + playerId=mc.player.getUniqueID(); + report=new File(mc.mcDataDir,"connected-checks.txt"); + RuntimeChecks.require(!report.exists(),"Refusing stale connected-client report"); + } + boolean tick(Minecraft mc) throws Exception { + frames++; + if(stage==0) { + schedule(mc,() -> prepareDeath(mc)); next(1); + } else if(stage==1 && done() && mc.currentScreen instanceof GuiGameOver && frames>20) { + screenshot(mc,"connected-player-death.png"); + mc.player.respawnPlayer(); next(2); + } else if(stage==2 && mc.player!=null && mc.player.isEntityAlive() && !(mc.currentScreen instanceof GuiGameOver)) { + schedule(mc,() -> { + EntityPlayerMP player=player(mc); EntityLootableBody corpse=corpse(mc); + RuntimeChecks.require(player.isEntityAlive(),"Connected respawn failed"); + player.connection.setPlayerLocation(24.5,80,7.5,0,0); + RuntimeChecks.saveExpected(folder(mc),"connected-death-before",corpse); + visibleExpected=read(folder(mc),"connected-death-before"); + mark("connected_player_death_and_respawn"); + }); next(3); + } else if(stage==3 && done()) { + if(open(mc)) next(4); + } else if(stage==4 && gui(mc) && frames>20) { + verifyGui(mc); screenshot(mc,"connected-corpse-before-recovery.png"); + RuntimeChecks.require(ItemStack.areItemStacksEqual(mc.player.openContainer.getSlot(6).getStack(),RuntimeChecks.stack(19)),"Client recovery slot NBT differs"); + mc.playerController.windowClick(mc.player.openContainer.windowId,6,0,ClickType.QUICK_MOVE,mc.player); + next(5); + } else if(stage==5 && frames>30) { + schedule(mc,() -> { + verifyRecovery(mc,"connected-death-before",6,19,"connected-after-recovery"); + savePlayer(mc,"connected-player-after-recovery"); + mark("network_shift_click_exact_nbt"); + }); next(6); + } else if(stage==6 && done()) { + verifyGui(mc); checkPlayer(mc.player.inventory,19); + screenshot(mc,"connected-corpse-after-shift-recovery.png"); + quit(mc); next(7); + } else if(stage==7 && mc.world==null && !mc.isIntegratedServerRunning()) { + mc.launchIntegratedServer("build-fixture","Build fixture",null); next(8); + } else if(stage==8 && ready(mc) && frames>30) { + schedule(mc,() -> { + verifyReload(mc,"connected-after-recovery","connected-player-after-recovery"); + RuntimeChecks.require(player(mc).inventory.mainInventory[9]==null,"Normal-click destination not empty"); + mark("first_save_reload_corpse_and_player"); + }); next(9); + } else if(stage==9 && done()) { + if(open(mc)) next(10); + } else if(stage==10 && gui(mc) && frames>20) { + verifyGui(mc); checkPlayer(mc.player.inventory,19); + screenshot(mc,"connected-corpse-first-reload.png"); + RuntimeChecks.require(ItemStack.areItemStacksEqual(mc.player.openContainer.getSlot(7).getStack(),RuntimeChecks.stack(20)),"Client normal-click slot NBT differs"); + int window=mc.player.openContainer.windowId; + mc.playerController.windowClick(window,7,0,ClickType.PICKUP,mc.player); + mc.playerController.windowClick(window,48,0,ClickType.PICKUP,mc.player); + next(11); + } else if(stage==11 && frames>30) { + schedule(mc,() -> { + verifyRecovery(mc,"connected-after-recovery",7,20,"connected-final"); + checkPlayer(player(mc).inventory,19); + RuntimeChecks.require(player(mc).inventory.getItemStack()==null,"Server cursor retained a duplicate"); + savePlayer(mc,"connected-player-final"); + mark("network_normal_click_exact_nbt"); + }); next(12); + } else if(stage==12 && done()) { + verifyGui(mc); checkPlayer(mc.player.inventory,19);checkPlayer(mc.player.inventory,20); + RuntimeChecks.require(mc.player.inventory.getItemStack()==null,"Client cursor retained a duplicate"); + screenshot(mc,"connected-corpse-after-normal-recovery.png"); + quit(mc); next(13); + } else if(stage==13 && mc.world==null && !mc.isIntegratedServerRunning()) { + mc.launchIntegratedServer("build-fixture","Build fixture",null); next(14); + } else if(stage==14 && ready(mc) && frames>30) { + schedule(mc,() -> { + verifyReload(mc,"connected-final","connected-player-final"); + checkPlayer(player(mc).inventory,19);checkPlayer(player(mc).inventory,20); + mark("second_save_reload_corpse_and_player"); + }); next(15); + } else if(stage==15 && done()) { + if(open(mc)) next(16); + } else if(stage==16 && gui(mc) && frames>20) { + verifyGui(mc); checkPlayer(mc.player.inventory,19);checkPlayer(mc.player.inventory,20); + screenshot(mc,"connected-corpse-final-reload.png"); + System.out.println("LOOTABLE_BODIES_CONNECTED_CLIENT_PASS"); + return true; + } + return false; + } + private void prepareDeath(Minecraft mc) throws Exception { + MinecraftServer server=mc.getIntegratedServer(); EntityPlayerMP player=player(mc); + for(int x=21;x<=27;x++) for(int z=5;z<=11;z++) + player.world.setBlockState(new BlockPos(x,79,z),Blocks.STONE.getDefaultState()); + player.world.getGameRules().setOrCreateGameRule("keepInventory","false"); + player.world.getGameRules().setOrCreateGameRule("doMobSpawning","false"); + player.setGameType(GameType.SURVIVAL); player.inventory.clear();player.inventory.currentItem=0; + for(int slot=0;slot<6;slot++)player.setItemStackToSlot(EntityLootableBody.EQUIPMENT_SLOTS[slot],RuntimeChecks.stack(slot)); + for(int slot=9;slot<=11;slot++)player.inventory.mainInventory[slot]=RuntimeChecks.stack(slot+10); + player.connection.setPlayerLocation(24.5,80,8.5,0,0); + player.inventoryContainer.detectAndSendChanges(); player.onKillCommand(); + RuntimeChecks.require(!player.isEntityAlive(),"Connected player did not die"); + EntityLootableBody corpse=null; + for(Entity entity:player.world.loadedEntityList) + if(entity instanceof EntityLootableBody && player.getName().equals(entity.getCustomNameTag())) corpse=(EntityLootableBody)entity; + RuntimeChecks.require(corpse!=null,"Connected death did not create a corpse"); + corpseId=corpse.getUniqueID();corpse.setNoGravity(true);corpse.motionX=0;corpse.motionY=0;corpse.motionZ=0; + RuntimeChecks.disableVacuum(corpse); + for(int slot=0;slot<6;slot++)RuntimeChecks.require(ItemStack.areItemStacksEqual(corpse.getStackInSlot(slot),RuntimeChecks.stack(slot)),"Connected death equipment slot "+slot); + for(int slot=6;slot<=8;slot++)RuntimeChecks.require(ItemStack.areItemStacksEqual(corpse.getStackInSlot(slot),RuntimeChecks.stack(slot+13)),"Connected death main slot "+slot); + RuntimeChecks.require(player.inventory.writeToNBT(new NBTTagList()).tagCount()==0,"Death retained player inventory alongside corpse"); + System.out.println("CONNECTED_CORPSE_UUID "+corpseId); + } + private void verifyRecovery(Minecraft mc,String before,int slot,int item,String after) throws Exception { + EntityLootableBody corpse=corpse(mc); + RuntimeChecks.require(corpse.getStackInSlot(slot)==null,"Network recovery left source stack"); + checkPlayer(player(mc).inventory,item); + NBTTagCompound expected=read(folder(mc),before),actual=new NBTTagCompound(); + NBTTagList equipment=expected.getTagList("Equipment",10); + for(int i=equipment.tagCount()-1;i>=0;i--)if(equipment.getCompoundTagAt(i).getByte("Slot")==slot)equipment.removeTag(i); + corpse.writeToNBTOptional(actual);RuntimeChecks.compare(expected,actual); + RuntimeChecks.saveExpected(folder(mc),after,corpse);visibleExpected=actual.copy(); + } + private void verifyReload(Minecraft mc,String corpseName,String playerName) throws Exception { + mc.getIntegratedServer().worldServerForDimension(0).getChunkFromChunkCoords(1,0); + EntityLootableBody corpse=corpse(mc);RuntimeChecks.require(corpse.isEntityAlive(),"Saved corpse is no longer accessible"); + corpse.onEntityUpdate();RuntimeChecks.verifyExpected(folder(mc),corpseName,corpse); + NBTTagCompound expected=read(folder(mc),playerName); + RuntimeChecks.require(expected.getTag("Inventory").equals(player(mc).inventory.writeToNBT(new NBTTagList())),"Recovered player inventory changed on disk reload"); + player(mc).connection.setPlayerLocation(24.5,80,7.5,0,0); + visibleExpected=read(folder(mc),corpseName); + } + private void savePlayer(Minecraft mc,String name) throws Exception { + NBTTagCompound expected=new NBTTagCompound();expected.setTag("Inventory",player(mc).inventory.writeToNBT(new NBTTagList())); + try(FileOutputStream out=new FileOutputStream(new File(folder(mc),name+".nbt"))){CompressedStreamTools.writeCompressed(expected,out);} + mc.getIntegratedServer().getPlayerList().saveAllPlayerData();mc.getIntegratedServer().saveAllWorlds(false); + } + private void verifyGui(Minecraft mc) { + RuntimeChecks.require(gui(mc) && mc.player.openContainer.inventorySlots.size()==84,"Connected GUI missing slots"); + ItemStack[] expected=new ItemStack[48];NBTTagList list=visibleExpected.getTagList("Equipment",10); + for(int i=0;i {try{action.run();}catch(Exception error){throw new RuntimeException(error);}});} + private boolean done() throws Exception {if(!pending.isDone())return false;pending.get();return true;} + private void next(int value){stage=value;frames=0;System.out.println("CONNECTED_CLIENT_STAGE "+stage);} + private static boolean ready(Minecraft mc){return mc.world!=null && mc.player!=null && mc.getIntegratedServer()!=null;} + private static boolean gui(Minecraft mc){return mc.currentScreen instanceof CorpseGUIContainer;} + private boolean open(Minecraft mc){ + for(Entity entity:mc.world.loadedEntityList)if(entity instanceof EntityLootableBody && entity.getUniqueID().equals(corpseId)){ + mc.playerController.interactWithEntity(mc.player,entity,null,EnumHand.MAIN_HAND);return true; + } + return false; + } + private static void screenshot(Minecraft mc,String name){ScreenShotHelper.saveScreenshot(mc.mcDataDir,name,mc.displayWidth,mc.displayHeight,mc.getFramebuffer());} + private static void quit(Minecraft mc){mc.player.closeScreen();mc.getIntegratedServer().initiateShutdown();} + private void mark(String name) throws Exception { + try(FileWriter out=new FileWriter(report,true)){out.write("PASS "+name+"\n");} + System.out.println("CLIENT_CHECK_PASS "+name); + } +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/ContainerInteractionChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/ContainerInteractionChecks.java new file mode 100644 index 0000000..7a49d79 --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/ContainerInteractionChecks.java @@ -0,0 +1,403 @@ +package cyano.lootable.buildtest; + +import com.mojang.authlib.GameProfile; +import cyano.lootable.entities.EntityLootableBody; +import cyano.lootable.graphics.CorpseContainer; +import io.netty.buffer.Unpooled; +import java.io.*; +import java.util.*; +import net.minecraft.entity.Entity; +import net.minecraft.entity.item.EntityItem; +import net.minecraft.entity.player.EntityPlayerMP; +import net.minecraft.init.Blocks; +import net.minecraft.inventory.*; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.*; +import net.minecraft.network.*; +import net.minecraft.network.play.client.*; +import net.minecraft.network.play.server.*; +import net.minecraft.server.MinecraftServer; +import net.minecraft.world.*; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.event.entity.EntityJoinWorldEvent; +import net.minecraftforge.event.entity.item.ItemTossEvent; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; + +/** Server packet/transaction tests; packets are decoded from their actual wire layout. */ +final class ContainerInteractionChecks { + private interface Check { void run() throws Exception; } + private static MinecraftServer server; + private static WorldServer world; + private static int sequence, failures; + private static short transaction; + private static final List results=new ArrayList<>(); + + static void run(MinecraftServer instance,WorldServer target,File folder) throws Exception { + server=instance;world=target;failures=0;results.clear(); + check("hotbar_all_nine_empty_and_occupied",ContainerInteractionChecks::hotbar); + check("hotbar_armor_validation_and_limits",ContainerInteractionChecks::armorSwap); + check("shift_wearable_stack_obeys_slot_limit",ContainerInteractionChecks::wearableShift); + check("left_drag_even_split_and_cursor_remainder",()->dragDistribution(0)); + check("right_drag_one_per_slot_and_cursor_remainder",()->dragDistribution(1)); + check("drag_rejects_invalid_full_and_different_nbt",ContainerInteractionChecks::dragMixed); + check("drag_armor_respects_capacity",ContainerInteractionChecks::dragArmor); + check("drag_cancel_and_out_of_order_do_not_move_items",ContainerInteractionChecks::dragCancel); + check("full_player_shift_is_unchanged",ContainerInteractionChecks::fullPlayer); + check("full_player_partial_merge_conserves_remainder",ContainerInteractionChecks::partialPlayer); + check("full_corpse_keeps_existing_aux",ContainerInteractionChecks::fullCorpse); + check("full_hotbar_displacement_success_conserves_items",()->displacement(false,false)); + check("full_hotbar_rejected_drop_keeps_displaced_item",()->displacement(true,false)); + check("full_hotbar_cancelled_toss_keeps_displaced_item",()->displacement(false,true)); + check("cursor_close_success_keeps_vanilla_drop",()->closeCursor(false,false)); + check("cursor_close_rejected_drop_with_player_space",()->closeCursor(true,false)); + check("cursor_close_rejected_drop_with_full_inventories",()->closeCursor(true,true)); + check("cursor_close_preserves_existing_death_capture",ContainerInteractionChecks::capturedCursor); + check("cursor_close_after_release_keeps_live_inventory_owner",ContainerInteractionChecks::dyingCursor); + check("two_player_same_slot_both_orders_and_resync",ContainerInteractionChecks::twoPlayerShift); + check("two_player_drag_vs_pickup_preserves_both_tags",()->interleavedDrag(false)); + check("two_player_drag_vs_hotbar_preserves_both_tags",()->interleavedDrag(true)); + check("two_player_cursor_close_and_stale_click",ContainerInteractionChecks::twoPlayerClose); + check("distant_packet_cannot_extract_before_tick_close",()->stale(false)); + check("removed_corpse_packet_cannot_extract",()->stale(true)); + check("drag_departure_resets_pending_distribution",ContainerInteractionChecks::staleDrag); + try(PrintWriter out=new PrintWriter(new File(folder,"container-checks.txt"),"UTF-8")) { + for(String line:results)out.println(line); + out.println("checks="+results.size()+" failures="+failures); + } + RuntimeChecks.require(failures==0,"Container regressions: "+failures+" / "+results.size()); + System.out.println("LOOTABLE_BODIES_CONTAINER_PASS checks="+results.size()); + } + + static void runDrops(MinecraftServer instance,WorldServer target,File folder) throws Exception { + server=instance;world=target;failures=0;results.clear(); + for(boolean creative:new boolean[]{false,true}) for(int source=0;source<3;source++) + for(int button=0;button<2;button++) for(int rejection=0;rejection<3;rejection++) { + final boolean c=creative;final int a=source,b=button,d=rejection; + check((c?"creative":"survival")+"_"+new String[]{"throw_corpse","throw_player","outside"}[a]+"_"+b+"_"+new String[]{"accepted","cancelled_toss","rejected_spawn"}[d],()->throwing(c,a,b,d,false)); + } + for(boolean creative:new boolean[]{false,true}) for(int source:new int[]{0,2}) { + final boolean c=creative;final int a=source; + check((c?"creative":"survival")+"_capture_"+a,()->throwing(c,a,0,0,true)); + } + for(boolean toss:new boolean[]{false,true}) { + final boolean t=toss; + check("creative_full_close_"+(t?"cancelled_toss":"rejected_spawn"),()->creativeFallback(t,false)); + check("creative_full_displacement_"+(t?"cancelled_toss":"rejected_spawn"),()->creativeFallback(t,true)); + } + for(int mode=0;mode<6;mode++) { final int m=mode;check("double_click_"+m,()->collect(m)); } + check("cancelled_close_preserves_hotbar_insertion_order",()->fallbackOrder(false)); + check("cancelled_close_preserves_offhand_merge",()->fallbackOrder(true)); + check("creative_clone_copies_complete_nbt",()->cloneStack(true,false)); + check("survival_cannot_clone",()->cloneStack(false,false)); + check("creative_clone_occupied_cursor_unchanged",()->cloneStack(true,true)); + check("creative_fill_drag_respects_limits_and_nbt",()->creativeDrag(true)); + check("survival_cannot_use_creative_fill_drag",()->creativeDrag(false)); + check("creative_inventory_packet_targets_player_not_corpse",()->creativePacket(true,false)); + check("survival_rejects_creative_inventory_packet",()->creativePacket(false,false)); + check("creative_cancelled_minted_drop_leaves_corpse_unchanged",()->creativePacket(true,true)); + for(int rejection=0;rejection<3;rejection++) {final int r=rejection;check("damaged_equipment_throw_"+r,()->toolThrow(r));} + check("throw_interrupts_pending_drag_without_dropping",()->interruptDrag(false)); + check("outside_click_interrupts_pending_drag_without_dropping",()->interruptDrag(true)); + try(PrintWriter out=new PrintWriter(new File(folder,"drop-creative-checks.txt"),"UTF-8")) { + for(String line:results)out.println(line);out.println("checks="+results.size()+" failures="+failures); + } + RuntimeChecks.require(failures==0,"Drop/creative regressions: "+failures+" / "+results.size()); + System.out.println("LOOTABLE_BODIES_DROP_CREATIVE_PASS checks="+results.size()); + } + private static void toolThrow(int rejection) throws Exception { + EntityLootableBody b=body();EntityPlayerMP p=player();open(p,b);b.setInventorySlotContents(4,item(4,1));Map before=total(b,p); + RejectedDrops reject=new RejectedDrops();reject.toss=rejection==1;reject.join=rejection==2;MinecraftForge.EVENT_BUS.register(reject); + try{packet(p,4,0,ClickType.THROW,null);}finally{MinecraftForge.EVENT_BUS.unregister(reject);} + conserved(before,b,p);equal(b.getStackInSlot(4),rejection==0?null:item(4,1),"Equipment throw changed damage/enchantment/custom data"); + } + private static void interruptDrag(boolean outside) throws Exception { + EntityLootableBody b=body();EntityPlayerMP p=player();open(p,b);b.setInventorySlotContents(6,item(120,17));p.inventory.setItemStack(item(121,10));Map before=total(b,p); + packet(p,-999,0,ClickType.QUICK_CRAFT,null);packet(p,7,1,ClickType.QUICK_CRAFT,null); + packet(p,outside?-999:6,0,outside?ClickType.PICKUP:ClickType.THROW,null); + equal(p.inventory.getItemStack(),item(121,10),"Interrupted drag dropped cursor");equal(b.getStackInSlot(6),item(120,17),"Interrupted drag threw source");conserved(before,b,p); + } + private static void throwing(boolean creative,int source,int button,int rejection,boolean capture) throws Exception { + EntityLootableBody b=body();EntityPlayerMP p=player();p.setGameType(creative?GameType.CREATIVE:GameType.SURVIVAL); + fill(b);fill(p);open(p,b);ItemStack original=item(120,17); + if(source==0)b.setInventorySlotContents(6,original.copy()); + else if(source==1)p.inventory.mainInventory[9]=original.copy();else p.inventory.setItemStack(original.copy()); + if(capture){p.captureDrops=true;p.capturedDrops.add(new EntityItem(world,96.5,80,96.5,item(121,11)));} + Map before=total(b,p);RejectedDrops reject=new RejectedDrops();reject.toss=rejection==1;reject.join=rejection==2; + RecordingConnection connection=(RecordingConnection)p.connection;int initialSyncs=connection.resyncs; + MinecraftForge.EVENT_BUS.register(reject); + try{packet(p,source==2?-999:source==0?6:48,button,source==2?ClickType.PICKUP:ClickType.THROW,null);} + finally{MinecraftForge.EVENT_BUS.unregister(reject);} + conserved(before,b,p);RuntimeChecks.require(p.captureDrops==capture,"Toss changed capture state"); + int amount=source==2?(button==0?17:1):(button==0?1:17); + ItemStack remaining=rejection==0 && amount==17?null:item(120,rejection==0?17-amount:17); + equal(source==2?p.inventory.getItemStack():p.openContainer.getSlot(source==0?6:48).getStack(),remaining,"Drop quantity/source restoration differs"); + if(rejection!=0)RuntimeChecks.require(connection.resyncs>initialSyncs,"Rejected predicted drop did not resync initiating client"); + if(capture)RuntimeChecks.require(p.capturedDrops.size()==2,"Toss discarded pre-existing captured item"); + if(rejection==0 && !capture){ + int dropped=0;for(Entity e:world.loadedEntityList)if(e instanceof EntityItem && !e.isDead){equal(((EntityItem)e).getEntityItem(),item(120,amount),"Accepted drop NBT differs");dropped++;} + RuntimeChecks.require(dropped==1,"Accepted toss did not create exactly one entity"); + } + } + private static void creativeFallback(boolean toss,boolean swap) throws Exception { + EntityLootableBody b=body();EntityPlayerMP p=player();fill(b);fill(p);p.setGameType(GameType.CREATIVE);open(p,b); + if(swap){b.setInventorySlotContents(0,item(0,1));p.inventory.mainInventory[0]=pumpkins(64);} + else p.inventory.setItemStack(item(120,17)); + Map before=total(b,p);RejectedDrops reject=new RejectedDrops();reject.toss=toss;reject.join=!toss;MinecraftForge.EVENT_BUS.register(reject); + try{if(swap)packet(p,0,0,ClickType.SWAP,null);else close(p);}finally{MinecraftForge.EVENT_BUS.unregister(reject);} + conserved(before,b,p);RuntimeChecks.require(b.isEntityAlive(),"Creative fallback owner no longer alive"); + } + private static void collect(int mode) throws Exception { + EntityLootableBody b=body();EntityPlayerMP p=player(),q=player();open(p,b);open(q,b); + b.setInventorySlotContents(6,item(120,20));b.setInventorySlotContents(7,item(120,30));b.setInventorySlotContents(8,item(121,14)); + ItemStack differentDamage=item(120,12);differentDamage.setItemDamage((differentDamage.getItemDamage()+1)%16);b.setInventorySlotContents(9,differentDamage); + p.inventory.mainInventory[9]=item(120,10);p.inventory.setItemStack(mode==3?null:item(120,mode==2?64:5)); + if(mode==4)p.setGameType(GameType.CREATIVE); + if(mode==5){q.inventory.setItemStack(item(120,4));packet(q,10,0,ClickType.PICKUP_ALL,null);} + Map before=total(b,p,q);int wanted=mode==3?0:mode==2?64:mode==5?15:64; + packet(p,10,mode==1?1:0,ClickType.PICKUP_ALL,null); + RuntimeChecks.require((p.inventory.getItemStack()==null?0:p.inventory.getItemStack().stackSize)==wanted,"Double click count/capacity differs"); + equal(b.getStackInSlot(8),item(121,14),"Double click collected different custom NBT");equal(b.getStackInSlot(9),differentDamage,"Double click collected different damage"); + if(mode==0)equal(p.inventory.mainInventory[9],item(120,1),"Forward collection order differs"); + if(mode==1)equal(b.getStackInSlot(6),item(120,1),"Reverse collection order differs"); + conserved(before,b,p,q); + } + private static void fallbackOrder(boolean offhand) throws Exception { + EntityLootableBody b=body();EntityPlayerMP p=player();open(p,b); + if(offhand){fill(p);p.inventory.offHandInventory[0]=item(120,60);} + p.inventory.setItemStack(item(120,10));Map before=total(b,p);RejectedDrops reject=new RejectedDrops();reject.toss=true;MinecraftForge.EVENT_BUS.register(reject); + try{close(p);}finally{MinecraftForge.EVENT_BUS.unregister(reject);} + equal(p.inventory.getStackInSlot(offhand?40:0),item(120,offhand?64:10),"Rejected-drop fallback changed vanilla player insertion priority");conserved(before,b,p); + } + private static void cloneStack(boolean creative,boolean occupied) throws Exception { + EntityLootableBody b=body();EntityPlayerMP p=player();p.setGameType(creative?GameType.CREATIVE:GameType.SURVIVAL);open(p,b);b.setInventorySlotContents(6,item(120,17)); + if(occupied)p.inventory.setItemStack(item(121,4));Map expected=total(b,p); + if(creative && !occupied)add(expected,item(120,64));packet(p,6,2,ClickType.CLONE,null); + equal(b.getStackInSlot(6),item(120,17),"Creative clone removed source");equal(p.inventory.getItemStack(),occupied?item(121,4):creative?item(120,64):null,"Clone cursor/NBT differs");conserved(expected,b,p); + } + private static void creativeDrag(boolean creative) throws Exception { + EntityLootableBody b=body();EntityPlayerMP p=player();p.setGameType(creative?GameType.CREATIVE:GameType.SURVIVAL);open(p,b);p.inventory.setItemStack(pumpkins(64));Map expected=total(b,p); + if(creative){add(expected,pumpkins(65));} // 1 armor + two main stacks, with original cursor consumed by vanilla 1.10. + drag(p,2,0,6,7);equal(b.getStackInSlot(0),creative?pumpkins(1):null,"Creative fill armor capacity differs"); + equal(b.getStackInSlot(6),creative?pumpkins(64):null,"Creative fill main capacity differs");equal(b.getStackInSlot(7),creative?pumpkins(64):null,"Creative fill second main differs");conserved(expected,b,p); + } + private static void creativePacket(boolean creative,boolean cancelled) throws Exception { + EntityLootableBody b=body();EntityPlayerMP p=player();p.setGameType(creative?GameType.CREATIVE:GameType.SURVIVAL);open(p,b);b.setInventorySlotContents(6,item(120,17));NBTTagCompound unchanged=data(b); + RejectedDrops reject=new RejectedDrops();reject.toss=cancelled;MinecraftForge.EVENT_BUS.register(reject);PacketBuffer wire=new PacketBuffer(Unpooled.buffer()); + try{wire.writeShort(cancelled?-1:9);wire.writeItemStack(item(121,23));CPacketCreativeInventoryAction packet=new CPacketCreativeInventoryAction();packet.readPacketData(wire);p.connection.processCreativeInventoryAction(packet);} + finally{wire.release();MinecraftForge.EVENT_BUS.unregister(reject);} + RuntimeChecks.require(unchanged.equals(data(b)),"Creative inventory packet changed corpse instead of player");equal(p.inventory.mainInventory[9],creative&&!cancelled?item(121,23):null,"Creative player packet permission/target differs"); + if(cancelled)RuntimeChecks.require(world.loadedEntityList.stream().noneMatch(e->e instanceof EntityItem && !e.isDead),"Cancelled creative minted drop spawned anyway"); + } + + private static void check(String name,Check test) { + Set before=new HashSet<>(world.loadedEntityList); + try {test.run();results.add("PASS "+name);System.out.println("CONTAINER_CHECK_PASS "+name);} + catch(Throwable error){failures++;results.add("FAIL "+name+": "+error);System.out.println("CONTAINER_CHECK_FAIL "+name+": "+error);error.printStackTrace();} + finally{for(Entity entity:new ArrayList(world.loadedEntityList))if(!before.contains(entity))world.removeEntityDangerously(entity);} + } + private static EntityPlayerMP player() { + EntityPlayerMP p=new EntityPlayerMP(server,world, + new GameProfile(UUID.nameUUIDFromBytes(("container-player-"+sequence++).getBytes(java.nio.charset.StandardCharsets.UTF_8)),"ContainerFixture"), + new net.minecraft.server.management.PlayerInteractionManager(world)); + p.connection=new RecordingConnection(p);p.setGameType(GameType.SURVIVAL); + p.setPosition(96.5,80,96.5);world.getChunkFromChunkCoords(6,6);return p; + } + private static EntityLootableBody body(){ + EntityLootableBody b=new EntityLootableBody(world);b.setPosition(96.5,80,96.5);b.setNoGravity(true);RuntimeChecks.disableVacuum(b); + world.getChunkFromChunkCoords(6,6);RuntimeChecks.require(world.spawnEntity(b),"Container corpse spawn");return b; + } + private static CorpseContainer open(EntityPlayerMP p,EntityLootableBody b){ + CorpseContainer c=new CorpseContainer(p.inventory,b);c.windowId=7;p.openContainer=c;c.addListener(p);return c; + } + private static ItemStack item(int id,int count){ItemStack s=RuntimeChecks.stack(id);s.stackSize=count;return s;} + private static ItemStack pumpkins(int count){ItemStack s=new ItemStack(Blocks.PUMPKIN,count);s.setTagCompound(item(120,1).getTagCompound().copy());return s;} + private static void fill(EntityPlayerMP p){for(int i=0;i<36;i++)p.inventory.mainInventory[i]=item(100+i,64);} + private static void fill(EntityLootableBody b){for(int i=0;i<48;i++)b.setInventorySlotContents(i,item(i,i<6?1:64));b.addItem(item(70,31));} + private static void packet(EntityPlayerMP p,int slot,int button,ClickType type,ItemStack predicted) throws Exception { + PacketBuffer wire=new PacketBuffer(Unpooled.buffer()); + try{ + wire.writeByte(p.openContainer.windowId);wire.writeShort(slot);wire.writeByte(button);wire.writeShort(++transaction); + wire.writeEnumValue(type);wire.writeItemStack(predicted); + CPacketClickWindow packet=new CPacketClickWindow();packet.readPacketData(wire);p.connection.processClickWindow(packet); + } finally{wire.release();} + } + private static void click(EntityPlayerMP p,int slot,int button,ClickType type) throws Exception { + ItemStack predicted=null; + if((type==ClickType.PICKUP || type==ClickType.QUICK_MOVE) && slot>=0){ + ItemStack s=p.openContainer.getSlot(slot).getStack();predicted=s==null?null:s.copy(); + } + packet(p,slot,button,type,predicted); + } + private static void close(EntityPlayerMP p) throws Exception { + PacketBuffer wire=new PacketBuffer(Unpooled.buffer()); + try{wire.writeByte(p.openContainer.windowId);CPacketCloseWindow packet=new CPacketCloseWindow();packet.readPacketData(wire);p.connection.processCloseWindow(packet);} + finally{wire.release();} + } + private static void drag(EntityPlayerMP p,int mode,int... slots) throws Exception { + packet(p,-999,mode<<2,ClickType.QUICK_CRAFT,null); + for(int slot:slots)packet(p,slot,1|(mode<<2),ClickType.QUICK_CRAFT,null); + packet(p,-999,2|(mode<<2),ClickType.QUICK_CRAFT,null); + } + private static void equal(ItemStack actual,ItemStack expected,String reason){RuntimeChecks.require(ItemStack.areItemStacksEqual(actual,expected),reason);} + private static NBTTagCompound data(EntityLootableBody b){NBTTagCompound n=new NBTTagCompound();b.writeEntityToNBT(n);return n;} + private static void add(Map all,ItemStack s){ + if(s==null || s.stackSize<=0)return;NBTTagCompound n=s.writeToNBT(new NBTTagCompound());n.removeTag("Count"); + String key=n.toString();all.put(key,all.getOrDefault(key,0)+s.stackSize); + } + private static Map total(EntityLootableBody b,EntityPlayerMP... players){ + Map all=new TreeMap<>(); + for(int i=0;i<48;i++)add(all,b.getStackInSlot(i));NBTTagList aux=data(b).getTagList("Aux",10); + for(int i=0;i expected,EntityLootableBody b,EntityPlayerMP... players){ + RuntimeChecks.require(expected.equals(total(b,players)),"Item count/type/damage/custom NBT lost or duplicated"); + NBTTagCompound before=data(b),immutable=before.copy();EntityLootableBody copy=new EntityLootableBody(world);copy.readEntityFromNBT(before); + RuntimeChecks.require(Objects.equals(before.getTag("Equipment"),data(copy).getTag("Equipment")) && Objects.equals(before.getTag("Aux"),data(copy).getTag("Aux")),"Post-interaction NBT round trip changed inventory"); + RuntimeChecks.require(before.equals(immutable),"Post-interaction reader mutated saved input"); + } + private static void hotbar() throws Exception { + EntityLootableBody b=body();EntityPlayerMP p=player();open(p,b); + for(int key=0;key<9;key++){ + p.inventory.mainInventory[key]=item(30+key,13);Map before=total(b,p); + click(p,6,key,ClickType.SWAP);equal(b.getStackInSlot(6),item(30+key,13),"Empty hotbar swap failed");equal(p.inventory.mainInventory[key],null,"Hotbar source remained"); + click(p,6,key,ClickType.SWAP);equal(b.getStackInSlot(6),null,"Hotbar return left source"); + b.setInventorySlotContents(6,item(19,23));before=total(b,p); + click(p,6,key,ClickType.SWAP);equal(b.getStackInSlot(6),item(30+key,13),"Occupied hotbar incoming differs");equal(p.inventory.mainInventory[key],item(19,23),"Occupied hotbar outgoing differs"); + click(p,6,key,ClickType.SWAP);conserved(before,b,p);b.setInventorySlotContents(6,null);p.inventory.mainInventory[key]=null; + } + } + private static void armorSwap() throws Exception { + EntityLootableBody b=body();EntityPlayerMP p=player();open(p,b);p.inventory.mainInventory[0]=item(4,1); + click(p,0,0,ClickType.SWAP);equal(b.getStackInSlot(0),null,"Sword inserted into armor slot"); + p.inventory.mainInventory[0]=pumpkins(64);Map before=total(b,p);click(p,0,0,ClickType.SWAP); + equal(b.getStackInSlot(0),pumpkins(1),"Hotbar armor limit ignored");equal(p.inventory.mainInventory[0],pumpkins(63),"Hotbar armor remainder lost");conserved(before,b,p); + } + private static void wearableShift() throws Exception { + EntityLootableBody b=body();EntityPlayerMP p=player();p.inventory.mainInventory[9]=pumpkins(64);open(p,b); + Map before=total(b,p);click(p,48,0,ClickType.QUICK_MOVE); + RuntimeChecks.require(b.getStackInSlot(0)!=null && b.getStackInSlot(0).stackSize==1,"Shift-click put a stack into the one-item head slot"); + conserved(before,b,p); + } + private static void dragDistribution(int mode) throws Exception { + EntityLootableBody b=body();EntityPlayerMP p=player();open(p,b);p.inventory.setItemStack(item(19,10));Map before=total(b,p); + drag(p,mode,6,7,8);int each=mode==0?3:1; + for(int i=6;i<=8;i++)equal(b.getStackInSlot(i),item(19,each),"Drag distribution differs"); + equal(p.inventory.getItemStack(),item(19,10-3*each),"Drag cursor remainder differs");conserved(before,b,p); + } + private static void dragMixed() throws Exception { + EntityLootableBody b=body();b.setInventorySlotContents(6,item(19,64));b.setInventorySlotContents(7,item(19,63));b.setInventorySlotContents(8,item(20,11)); + EntityPlayerMP p=player();open(p,b);p.inventory.setItemStack(item(19,12));Map before=total(b,p); + drag(p,0,0,6,7,8,9,48);equal(b.getStackInSlot(0),null,"Drag bypassed armor validity");equal(b.getStackInSlot(6),item(19,64),"Drag changed saturated slot");equal(b.getStackInSlot(8),item(20,11),"Drag merged different NBT"); + RuntimeChecks.require(b.getStackInSlot(7).stackSize<=64,"Drag overfilled partial slot");conserved(before,b,p); + } + private static void dragArmor() throws Exception { + EntityLootableBody b=body();EntityPlayerMP p=player();open(p,b);p.inventory.setItemStack(pumpkins(64));Map before=total(b,p); + drag(p,0,0,6);equal(b.getStackInSlot(0),pumpkins(1),"Drag head limit ignored");equal(b.getStackInSlot(6),pumpkins(32),"Drag main allocation differs");equal(p.inventory.getItemStack(),pumpkins(31),"Drag head remainder lost");conserved(before,b,p); + } + private static void dragCancel() throws Exception { + EntityLootableBody b=body();EntityPlayerMP p=player();open(p,b);p.inventory.setItemStack(item(19,10));Map before=total(b,p); + packet(p,-999,2,ClickType.QUICK_CRAFT,null);packet(p,-999,0,ClickType.QUICK_CRAFT,null);packet(p,6,1,ClickType.QUICK_CRAFT,null); + packet(p,48,0,ClickType.PICKUP,null);packet(p,-999,2,ClickType.QUICK_CRAFT,null); + equal(b.getStackInSlot(6),null,"Cancelled drag wrote selected slot");equal(p.inventory.getItemStack(),item(19,10),"Cancelled drag consumed cursor");conserved(before,b,p); + } + private static void fullPlayer() throws Exception { + EntityLootableBody b=body();b.setInventorySlotContents(6,item(19,23));EntityPlayerMP p=player();fill(p);open(p,b); + Map before=total(b,p);packet(p,6,0,ClickType.QUICK_MOVE,null);equal(b.getStackInSlot(6),item(19,23),"Full player inventory consumed corpse stack");conserved(before,b,p); + } + private static void partialPlayer() throws Exception { + EntityLootableBody b=body();b.setInventorySlotContents(6,item(19,23));EntityPlayerMP p=player();fill(p);p.inventory.mainInventory[35]=item(19,63);open(p,b); + Map before=total(b,p);click(p,6,0,ClickType.QUICK_MOVE);equal(p.inventory.mainInventory[35],item(19,64),"Partial merge did not fill one space");equal(b.getStackInSlot(6),item(19,22),"Partial merge discarded remainder");conserved(before,b,p); + } + private static void fullCorpse() throws Exception { + EntityLootableBody b=body();fill(b);EntityPlayerMP p=player();p.inventory.mainInventory[9]=item(110,23);open(p,b); + NBTTagCompound unchanged=data(b);Map before=total(b,p);packet(p,48,0,ClickType.QUICK_MOVE,null);b.onEntityUpdate(); + RuntimeChecks.require(unchanged.getTag("Equipment").equals(data(b).getTag("Equipment")) && unchanged.getTag("Aux").equals(data(b).getTag("Aux")),"Full corpse overwrote indexed/Aux contents");conserved(before,b,p); + } + public static final class RejectedDrops { + boolean join,toss; + @SubscribeEvent public void join(EntityJoinWorldEvent e){if(join && e.getEntity() instanceof EntityItem)e.setCanceled(true);} + @SubscribeEvent public void toss(ItemTossEvent e){if(toss)e.setCanceled(true);} + } + private static void displacement(boolean rejectJoin,boolean rejectToss) throws Exception { + EntityLootableBody b=body();b.setInventorySlotContents(0,item(0,1));EntityPlayerMP p=player();fill(p);p.inventory.mainInventory[0]=pumpkins(64);open(p,b);Map before=total(b,p); + RejectedDrops reject=new RejectedDrops();reject.join=rejectJoin;reject.toss=rejectToss;MinecraftForge.EVENT_BUS.register(reject); + try{click(p,0,0,ClickType.SWAP);}finally{MinecraftForge.EVENT_BUS.unregister(reject);} + equal(b.getStackInSlot(0),pumpkins(1),"Displacement changed normal incoming swap");equal(p.inventory.mainInventory[0],pumpkins(63),"Displacement changed hotbar remainder");conserved(before,b,p); + if(!rejectJoin && !rejectToss)RuntimeChecks.require(world.loadedEntityList.stream().anyMatch(e->e instanceof EntityItem && !e.isDead),"Successful displacement no longer drops normally"); + } + private static void closeCursor(boolean rejected,boolean full) throws Exception { + EntityLootableBody b=body();EntityPlayerMP p=player();if(full){fill(b);fill(p);}open(p,b);p.inventory.setItemStack(item(120,17));Map before=total(b,p); + RejectedDrops reject=new RejectedDrops();reject.join=rejected;MinecraftForge.EVENT_BUS.register(reject); + try{close(p);}finally{MinecraftForge.EVENT_BUS.unregister(reject);} + equal(p.inventory.getItemStack(),null,"Closing left a nonsaved cursor stack");conserved(before,b,p); + if(!rejected)RuntimeChecks.require(world.loadedEntityList.stream().anyMatch(e->e instanceof EntityItem && !e.isDead),"Cursor close no longer performs normal accepted drop"); + } + private static void capturedCursor() throws Exception { + EntityLootableBody b=body();EntityPlayerMP p=player();open(p,b);p.inventory.setItemStack(item(120,17)); + p.captureDrops=true;p.capturedDrops.add(new EntityItem(world,96.5,80,96.5,item(121,11)));Map before=total(b,p); + close(p);RuntimeChecks.require(p.captureDrops,"Container close changed caller's drop capture state");conserved(before,b,p); + } + private static void dyingCursor() throws Exception { + EntityLootableBody b=body();EntityPlayerMP p=player();fill(p);open(p,b);p.inventory.setItemStack(item(120,17)); + b.onKillCommand();b.onEntityUpdate();Map before=total(b,p); + RejectedDrops reject=new RejectedDrops();reject.join=true;MinecraftForge.EVENT_BUS.register(reject); + try{close(p);}finally{MinecraftForge.EVENT_BUS.unregister(reject);} + for(int i=0;i<15;i++)b.onEntityUpdate(); + RuntimeChecks.require(b.isEntityAlive() || b.isEmpty(),"Cursor fallback was put into an owner still scheduled for removal"); + conserved(before,b,p); + } + private static void twoPlayerShift() throws Exception { + for(int order=0;order<2;order++){ + EntityLootableBody b=body();b.setInventorySlotContents(6,item(19,23));EntityPlayerMP a=player(),c=player();open(a,b);open(c,b); + EntityPlayerMP first=order==0?a:c,second=order==0?c:a;Map before=total(b,a,c); + click(first,6,0,ClickType.QUICK_MOVE);packet(second,6,0,ClickType.QUICK_MOVE,item(19,23)); + a.openContainer.detectAndSendChanges();c.openContainer.detectAndSendChanges(); + RuntimeChecks.require(!second.openContainer.getCanCraft(second),"Stale transaction was not rejected"); + RuntimeChecks.require(((RecordingConnection)second.connection).resyncs>0 && ((RecordingConnection)second.connection).rejections>0,"Stale viewer did not receive inventory resync"); + // Accepted clicks rely on the initiating client's prediction; only the + // rejected stale viewer requires an authoritative window resync. + equal(first.openContainer.getSlot(6).getStack(),null,"First server container remained stale"); + equal(((RecordingConnection)second.connection).slots.get(6),null,"Rejected viewer source remained stale");conserved(before,b,a,c); + } + } + private static void interleavedDrag(boolean swap) throws Exception { + EntityLootableBody b=body();EntityPlayerMP a=player(),c=player();open(a,b);open(c,b);a.inventory.setItemStack(item(19,10)); + if(swap)c.inventory.mainInventory[0]=item(20,7);else c.inventory.setItemStack(item(20,7));Map before=total(b,a,c); + packet(a,-999,0,ClickType.QUICK_CRAFT,null);packet(a,6,1,ClickType.QUICK_CRAFT,null);packet(a,7,1,ClickType.QUICK_CRAFT,null); + click(c,6,0,swap?ClickType.SWAP:ClickType.PICKUP);packet(a,-999,2,ClickType.QUICK_CRAFT,null); + equal(b.getStackInSlot(6),item(20,7),"Other player's stack was overwritten by drag");equal(b.getStackInSlot(7),item(19,5),"Surviving drag slot differs");equal(a.inventory.getItemStack(),item(19,5),"Interrupted drag lost remainder");conserved(before,b,a,c); + } + private static void twoPlayerClose() throws Exception { + EntityLootableBody b=body();b.setInventorySlotContents(6,item(19,23));EntityPlayerMP a=player(),c=player();fill(a);open(a,b);open(c,b);Map before=total(b,a,c); + click(a,6,0,ClickType.PICKUP);packet(c,6,0,ClickType.QUICK_MOVE,item(19,23));RejectedDrops reject=new RejectedDrops();reject.join=true;MinecraftForge.EVENT_BUS.register(reject); + try{close(a);}finally{MinecraftForge.EVENT_BUS.unregister(reject);} + c.openContainer.detectAndSendChanges();conserved(before,b,a,c); + } + private static void stale(boolean removed) throws Exception { + EntityLootableBody b=body();b.setInventorySlotContents(6,item(19,23));EntityPlayerMP p=player();open(p,b);Map before=total(b,p); + if(removed)b.setDead();else p.setPosition(200,80,200); + packet(p,6,0,ClickType.QUICK_MOVE,null);equal(b.getStackInSlot(6),item(19,23),"Stale packet extracted before tick closed GUI");conserved(before,b,p); + } + private static void staleDrag() throws Exception { + EntityLootableBody b=body();EntityPlayerMP p=player();open(p,b);p.inventory.setItemStack(item(19,10));Map before=total(b,p); + packet(p,-999,0,ClickType.QUICK_CRAFT,null);packet(p,6,1,ClickType.QUICK_CRAFT,null);p.setPosition(200,80,200); + packet(p,-999,2,ClickType.QUICK_CRAFT,null);equal(b.getStackInSlot(6),null,"Distant drag still distributed items"); + p.setPosition(96.5,80,96.5);drag(p,0,7);equal(b.getStackInSlot(7),item(19,10),"New drag reused invalid selection");conserved(before,b,p); + } + private static final class RecordingConnection extends NetHandlerPlayServer { + final Map slots=new HashMap<>();int resyncs,rejections; + RecordingConnection(EntityPlayerMP p){super(server,new NetworkManager(EnumPacketDirection.SERVERBOUND),p);} + @Override public void sendPacket(Packet packet){ + if(!(packet instanceof SPacketWindowItems || packet instanceof SPacketSetSlot || packet instanceof SPacketConfirmTransaction))return; + PacketBuffer wire=new PacketBuffer(Unpooled.buffer()); + try{packet.writePacketData(wire);int window=wire.readByte(); + if(packet instanceof SPacketWindowItems){int n=wire.readShort();for(int i=0;i=0)slots.put(slot,s);} + else{wire.readShort();if(!wire.readBoolean())rejections++;} + }catch(IOException error){throw new RuntimeException(error);}finally{wire.release();} + } + } +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/DropClientChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/DropClientChecks.java new file mode 100644 index 0000000..4611967 --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/DropClientChecks.java @@ -0,0 +1,118 @@ +package cyano.lootable.buildtest; + +import cyano.lootable.entities.EntityLootableBody; +import cyano.lootable.graphics.CorpseGUIContainer; +import java.io.*; +import java.util.*; +import java.util.concurrent.Future; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiMainMenu; +import net.minecraft.entity.Entity; +import net.minecraft.entity.item.EntityItem; +import net.minecraft.entity.player.EntityPlayerMP; +import net.minecraft.init.Blocks; +import net.minecraft.inventory.ClickType; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.*; +import net.minecraft.util.EnumHand; +import net.minecraft.util.ScreenShotHelper; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.GameType; +import net.minecraftforge.common.MinecraftForge; + +/** Actual connected client predictions, authoritative corrections and disk reload. */ +final class DropClientChecks { + private interface Action {void run() throws Exception;} + private final UUID bodyId=UUID.fromString("00000000-0000-4000-8000-000000000109"); + private final ContainerInteractionChecks.RejectedDrops reject=new ContainerInteractionChecks.RejectedDrops(); + private int stage,frames; + private Future pending; + private UUID playerId; + private NBTTagCompound savedBody; + private NBTTagList savedPlayer; + void start(Minecraft mc){playerId=mc.player.getUniqueID();} + boolean tick(Minecraft mc) throws Exception { + frames++; + if(stage==0){schedule(mc,()->prepare(mc));next(1);} + else if(stage==1 && done()){if(open(mc))next(2);} + else if(stage==2 && gui(mc) && frames>20){ + equal(slot(mc,6),item(120,17),"Initial client source");click(mc,6,0,ClickType.THROW);next(3); + } else if(stage==3 && frames>20){ + equal(slot(mc,6),item(120,16),"Accepted client single throw"); + schedule(mc,()->{equal(body(mc).getStackInSlot(6),item(120,16),"Accepted server single throw");drop(mc,item(120,1));mark(mc,"network_accepted_throw");reject.toss=true;MinecraftForge.EVENT_BUS.register(reject);});next(4); + } else if(stage==4 && done()) {click(mc,6,1,ClickType.THROW);next(5);} + else if(stage==5 && frames>20){ + equal(slot(mc,6),item(120,16),"Cancelled throw did not correct client source");screenshot(mc,"drop-cancelled-source-resync.png"); + schedule(mc,()->{equal(body(mc).getStackInSlot(6),item(120,16),"Cancelled server throw");mark(mc,"network_cancelled_throw_source_resync");reject.toss=false;reject.join=true;});next(6); + } else if(stage==6 && done()){click(mc,6,0,ClickType.PICKUP);next(7);} + else if(stage==7 && frames>20){equal(mc.player.inventory.getItemStack(),item(120,16),"Pickup cursor");click(mc,-999,1,ClickType.PICKUP);next(8);} + else if(stage==8 && frames>20){ + equal(mc.player.inventory.getItemStack(),item(120,16),"Rejected outside drop did not correct client cursor");equal(slot(mc,6),null,"Outside source reappeared");screenshot(mc,"drop-rejected-cursor-resync.png"); + schedule(mc,()->{equal(player(mc).inventory.getItemStack(),item(120,16),"Rejected server outside cursor");mark(mc,"network_rejected_outside_cursor_resync");reject.join=false;});next(9); + } else if(stage==9 && done()){click(mc,-999,0,ClickType.PICKUP);next(10);} + else if(stage==10 && frames>20){ + equal(mc.player.inventory.getItemStack(),null,"Accepted outside client cursor"); + schedule(mc,()->{drop(mc,item(120,16));equal(player(mc).inventory.getItemStack(),null,"Accepted outside server cursor");mark(mc,"network_accepted_outside_drop"); + body(mc).setInventorySlotContents(6,item(120,17));player(mc).inventory.mainInventory[9]=item(120,5);refresh(mc);});next(11); + } else if(stage==11 && done() && frames>20){click(mc,48,0,ClickType.PICKUP);click(mc,10,0,ClickType.PICKUP_ALL);next(12);} + else if(stage==12 && frames>20){ + equal(mc.player.inventory.getItemStack(),item(120,42),"Double-click client cursor");equal(slot(mc,8),item(121,10),"Double-click changed unlike NBT"); + schedule(mc,()->{equal(player(mc).inventory.getItemStack(),item(120,42),"Double-click server cursor");equal(body(mc).getStackInSlot(6),null,"Double-click source 6");equal(body(mc).getStackInSlot(7),null,"Double-click source 7");mark(mc,"network_double_click_exact_nbt");});next(13); + } else if(stage==13 && done()){click(mc,48,0,ClickType.PICKUP);next(14);} + else if(stage==14 && frames>20){schedule(mc,()->{player(mc).setGameType(GameType.CREATIVE);body(mc).setInventorySlotContents(6,item(120,17));refresh(mc);});next(15);} + else if(stage==15 && done() && frames>20){RuntimeChecks.require(mc.player.capabilities.isCreativeMode,"Client not creative");click(mc,6,2,ClickType.CLONE);next(16);} + else if(stage==16 && frames>20){ + equal(mc.player.inventory.getItemStack(),item(120,64),"Creative clone client NBT");equal(slot(mc,6),item(120,17),"Creative clone changed source"); + click(mc,-999,8,ClickType.QUICK_CRAFT);click(mc,7,9,ClickType.QUICK_CRAFT);click(mc,9,9,ClickType.QUICK_CRAFT);click(mc,-999,10,ClickType.QUICK_CRAFT);next(17); + } else if(stage==17 && frames>20){ + equal(slot(mc,7),item(120,64),"Creative client fill first");equal(slot(mc,9),item(120,64),"Creative client fill second");equal(mc.player.inventory.getItemStack(),null,"Creative fill vanilla cursor");screenshot(mc,"drop-creative-filled.png"); + schedule(mc,()->{equal(body(mc).getStackInSlot(7),item(120,64),"Creative server fill first");equal(body(mc).getStackInSlot(9),item(120,64),"Creative server fill second");equal(player(mc).inventory.mainInventory[9],item(120,42),"Creative operation changed recovered inventory");mark(mc,"network_creative_clone_and_fill"); + for(int i=0;i<48;i++)body(mc).setInventorySlotContents(i,item(i,i<6?1:64));body(mc).addItem(item(70,31)); + for(int i=0;i<36;i++)player(mc).inventory.mainInventory[i]=item(100+i,64);player(mc).inventory.setItemStack(item(122,17));reject.toss=true;refresh(mc);});next(18); + } else if(stage==18 && done() && frames>20){equal(mc.player.inventory.getItemStack(),item(122,17),"Creative close cursor fixture");mc.player.closeScreen();next(19);} + else if(stage==19 && frames>20){ + equal(mc.player.inventory.getItemStack(),null,"Closed creative client cursor"); + schedule(mc,()->{equal(player(mc).inventory.getItemStack(),null,"Closed creative server cursor");NBTTagCompound data=new NBTTagCompound();body(mc).writeEntityToNBT(data);NBTTagList aux=data.getTagList("Aux",10);int count=0; + for(int i=0;i20){schedule(mc,()->{ + player(mc).world.getChunkFromChunkCoords(2,0);body(mc).onEntityUpdate();RuntimeChecks.verifyExpected(folder(mc),"drop-creative-final",body(mc)); + RuntimeChecks.require(savedPlayer.equals(player(mc).inventory.writeToNBT(new NBTTagList())),"Creative player inventory changed on disk reload");player(mc).connection.setPlayerLocation(40.5,80,7.5,0,0); + mark(mc,"creative_cancelled_close_full_inventory_disk_reload");});next(23); + } else if(stage==23 && done()){if(open(mc))next(24);} + else if(stage==24 && gui(mc) && frames>20){ + ItemStack[] expected=new ItemStack[48];NBTTagList list=savedBody.getTagList("Equipment",10);for(int i=0;i(player(mc).world.loadedEntityList))if(e instanceof EntityItem && !e.isDead){equal(((EntityItem)e).getEntityItem(),expected,"Accepted world drop NBT/count");count++;player(mc).world.removeEntityDangerously(e);}RuntimeChecks.require(count==1,"Expected exactly one accepted drop");} + private static ItemStack slot(Minecraft mc,int index){return mc.player.openContainer.getSlot(index).getStack();} + private static void click(Minecraft mc,int slot,int button,ClickType type){mc.playerController.windowClick(mc.player.openContainer.windowId,slot,button,type,mc.player);} + private static boolean gui(Minecraft mc){return mc.currentScreen instanceof CorpseGUIContainer;} + private static boolean ready(Minecraft mc){return mc.world!=null && mc.player!=null && mc.getIntegratedServer()!=null;} + private boolean open(Minecraft mc){for(Entity e:mc.world.loadedEntityList)if(e.getUniqueID().equals(bodyId)){mc.playerController.interactWithEntity(mc.player,e,null,EnumHand.MAIN_HAND);return true;}return false;} + private EntityPlayerMP player(Minecraft mc){return mc.getIntegratedServer().getPlayerList().getPlayerByUUID(playerId);} + private EntityLootableBody body(Minecraft mc){return RuntimeChecks.find(mc.getIntegratedServer().worldServerForDimension(0),bodyId);} + private File folder(Minecraft mc){return player(mc).world.getSaveHandler().getWorldDirectory();} + private void refresh(Minecraft mc){player(mc).openContainer.detectAndSendChanges();player(mc).sendContainerToPlayer(player(mc).openContainer);} + private void schedule(Minecraft mc,Action action){pending=mc.getIntegratedServer().addScheduledTask(()->{try{action.run();}catch(Exception e){throw new RuntimeException(e);}});} + private boolean done() throws Exception {if(!pending.isDone())return false;pending.get();return true;} + private void next(int value){stage=value;frames=0;System.out.println("DROP_CLIENT_STAGE "+stage);} + private static void screenshot(Minecraft mc,String name){ScreenShotHelper.saveScreenshot(mc.mcDataDir,name,mc.displayWidth,mc.displayHeight,mc.getFramebuffer());} + private void mark(Minecraft mc,String name) throws Exception {try(FileWriter out=new FileWriter(new File(mc.mcDataDir,"drop-client-checks.txt"),true)){out.write("PASS "+name+"\n");}System.out.println("DROP_CLIENT_CHECK_PASS "+name);} + private static void quit(Minecraft mc){mc.player.closeScreen();mc.getIntegratedServer().initiateShutdown();} +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/EffectHurtChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/EffectHurtChecks.java new file mode 100644 index 0000000..793b32d --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/EffectHurtChecks.java @@ -0,0 +1,95 @@ +package cyano.lootable.buildtest; + +import cyano.lootable.entities.EntityLootableBody; +import java.io.*; +import java.util.*; +import net.minecraft.entity.SharedMonsterAttributes; +import net.minecraft.init.MobEffects; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.*; +import net.minecraft.potion.*; +import net.minecraft.server.MinecraftServer; +import net.minecraft.world.WorldServer; + +/** Real legacy readers, ticking and round trips; no production test dependency. */ +final class EffectHurtChecks { + private interface Check {void run() throws Exception;} + private static WorldServer world; + private static int failures; + private static final List results=new ArrayList<>(); + private static Potion extended; + static void registerPotion(){ + RuntimeChecks.require(Potion.getPotionById(200)==null,"Fixture potion ID already occupied"); + extended=new Potion(false,0x123456){}; + Potion.REGISTRY.register(200,new net.minecraft.util.ResourceLocation("lootablebodiesbuildtest:extended_effect"),extended); + } + static void run(MinecraftServer server,WorldServer target,File folder) throws Exception { + world=target;failures=0;results.clear(); + NBTBase[] wrong={new NBTTagString("opaque"),new NBTTagCompound(),new NBTTagByteArray(new byte[]{1}),new NBTTagIntArray(new int[]{1}),new NBTTagList()}; + for(int i=0;ibadHurt(raw));} + for(NBTBase raw:new NBTBase[]{new NBTTagShort((short)-1),new NBTTagInt(32768),new NBTTagLong(65537),new NBTTagLong(Long.MAX_VALUE),new NBTTagFloat(2),new NBTTagDouble(2.5),new NBTTagDouble(Double.NaN),new NBTTagDouble(Double.POSITIVE_INFINITY)})check("HurtTime_invalid_"+raw,()->badHurt(raw)); + for(NBTBase raw:new NBTBase[]{new NBTTagByte((byte)7),new NBTTagShort((short)10),new NBTTagInt(11),new NBTTagLong(32767)})check("HurtTime_lossless_"+raw,()->validHurt(raw)); + check("missing_hurt_time_defaults_zero",()->{NBTTagCompound n=base();n.removeTag("HurtTime");EntityLootableBody b=read(n);require(b.hurtTime==0,"Missing hurt time not zero");stable(b);}); + for(int i=0;ibadList(raw));} + check("ActiveEffects_wrong_element_type",()->{NBTTagList l=new NBTTagList();l.appendTag(new NBTTagString("opaque"));badList(l);}); + check("empty_and_missing_effects_keep_defaults",()->{for(boolean empty:new boolean[]{false,true}){NBTTagCompound n=base();if(empty)n.setTag("ActiveEffects",new NBTTagList());EntityLootableBody b=read(n);require(b.getActivePotionEffects().isEmpty(),"Unexpected default effect");require(!saved(b).hasKey(NameLifetimeChecks.RECOVERY),"Healthy empty effects quarantined");stable(b);}}); + for(String key:new String[]{"Id","Duration","Amplifier"})for(int i=0;ibadEffect(k,raw));} + for(NBTBase raw:new NBTBase[]{new NBTTagInt(-1),new NBTTagInt(256),new NBTTagLong(4294967297L),new NBTTagDouble(1),new NBTTagByte((byte)201)})check("Id_invalid_or_absent_registry_"+raw,()->badEffect("Id",raw)); + check("Id_missing_retains_record",()->badEffect("Id",null)); + for(NBTBase raw:new NBTBase[]{new NBTTagInt(-1),new NBTTagLong(2147483648L),new NBTTagLong(4294967496L),new NBTTagFloat(100),new NBTTagDouble(Double.NaN),new NBTTagDouble(Double.NEGATIVE_INFINITY)})check("Duration_invalid_"+raw,()->badEffect("Duration",raw)); + for(NBTBase raw:new NBTBase[]{new NBTTagShort((short)-1),new NBTTagInt(128),new NBTTagInt(256),new NBTTagLong(4294967296L),new NBTTagFloat(1),new NBTTagDouble(Double.POSITIVE_INFINITY)})check("Amplifier_invalid_"+raw,()->badEffect("Amplifier",raw)); + for(String key:new String[]{"Ambient","ShowParticles"}){ + for(int i=0;ibadFlag(k,raw));} + for(NBTBase raw:new NBTBase[]{new NBTTagByte((byte)-1),new NBTTagInt(2),new NBTTagLong(256),new NBTTagFloat(1),new NBTTagDouble(Double.NaN)})check(key+"_invalid_"+raw,()->badFlag(key,raw)); + } + check("lossless_integral_effect_fields",EffectHurtChecks::numeric); + check("missing_optional_effect_fields_keep_legacy_defaults",EffectHurtChecks::missingOptional); + check("zero_duration_expires_normally",()->validDuration(0)); + check("maximum_duration_does_not_overflow",()->validDuration(Integer.MAX_VALUE)); + check("signed_byte_amplifier_encodings_keep_legacy_meaning",EffectHurtChecks::signedAmplifiers); + check("registered_unsigned_byte_effect_id_survives_reload",EffectHurtChecks::extendedId); + check("duplicate_id_numeric_alias_keeps_first",EffectHurtChecks::alias); + check("invalid_record_does_not_hide_valid_tail",EffectHurtChecks::validTail); + check("duplicate_effect_keeps_first_valid_record",EffectHurtChecks::duplicate); + check("unknown_effect_payload_retained_with_known_fields",EffectHurtChecks::unknown); + check("repeated_read_replaces_effects_and_metadata",EffectHurtChecks::repeated); + check("valid_potion_attributes_health_absorption_survive_reload",EffectHurtChecks::attributes); + check("repeated_read_removes_obsolete_potion_modifiers",EffectHurtChecks::repeatedAttributes); + check("reused_output_does_not_keep_previous_effect_list",EffectHurtChecks::reusedOutput); + check("invalid_effect_and_hurt_fields_keep_complete_unresolved_inventory",EffectHurtChecks::inventory); + check("metadata_only_does_not_prevent_empty_decay",EffectHurtChecks::empty); + try(PrintWriter out=new PrintWriter(new File(folder,"effect-hurt-checks.txt"),"UTF-8")){for(String s:results)out.println(s);out.println("checks="+results.size()+" failures="+failures);} + require(failures==0,"Effect/hurt regressions: "+failures+" / "+results.size());System.out.println("LOOTABLE_BODIES_EFFECT_HURT_PASS checks="+results.size()); + } + private static void check(String name,Check test){try{test.run();results.add("PASS "+name);System.out.println("EFFECT_HURT_PASS "+name);}catch(Throwable e){failures++;results.add("FAIL "+name+": "+e);System.out.println("EFFECT_HURT_FAIL "+name+": "+e);e.printStackTrace();}} + private static void require(boolean c,String why){RuntimeChecks.require(c,why);} + private static NBTTagCompound saved(EntityLootableBody b){return NameLifetimeChecks.saved(b);} + private static NBTTagCompound base(){EntityLootableBody b=new EntityLootableBody(world);b.setPosition(160.5,80,160.5);b.setNoGravity(true);b.setInventorySlotContents(6,NbtRecoveryChecks.item(120,17));RuntimeChecks.disableVacuum(b);return saved(b);} + private static EntityLootableBody read(NBTTagCompound n){NBTTagCompound before=n.copy();EntityLootableBody b=new EntityLootableBody(world);b.readFromNBT(n);require(NameLifetimeChecks.same(before,n),"Input mutated");return b;} + static NBTTagCompound effect(Potion p,int duration,int amplifier){return new PotionEffect(p,duration,amplifier,false,true).writeCustomPotionEffectToNBT(new NBTTagCompound());} + private static NBTTagCompound effect(){return effect(MobEffects.NIGHT_VISION,200,1);} + private static NBTTagCompound with(NBTTagCompound e){NBTTagCompound n=base();n.setTag("ActiveEffects",NbtRecoveryChecks.list(e));return n;} + private static void stable(EntityLootableBody b){NBTTagCompound a=saved(b),z=saved(read(a));for(String k:new String[]{"Equipment","Aux",NbtRecoveryChecks.RECOVERY,NameLifetimeChecks.RECOVERY,"ActiveEffects","HurtTime","Health","AbsorptionAmount","Attributes"})require(NameLifetimeChecks.same(a.getTag(k),z.getTag(k)),"Round trip changed "+k);require(ItemStack.areItemStacksEqual(b.getStackInSlot(6),NbtRecoveryChecks.item(120,17)),"Complete known inventory changed");} + private static void badHurt(NBTBase raw){NBTTagCompound n=base();n.setTag("HurtTime",raw.copy());EntityLootableBody b=read(n);require(b.hurtTime==0 && b.isEntityAlive(),"Bad hurt time activated");NameLifetimeChecks.retained(b,"HurtTime",raw);stable(b);} + private static void validHurt(NBTBase raw){NBTTagCompound n=base();n.setTag("HurtTime",raw);EntityLootableBody b=read(n);int v=((NBTPrimitive)raw).getInt();require(b.hurtTime==v,"Valid countdown narrowed");stable(b);b.onEntityUpdate();require(b.hurtTime==v-1,"Countdown no longer ticks");} + private static void badList(NBTBase raw){NBTTagCompound n=base();n.setTag("ActiveEffects",raw.copy());EntityLootableBody b=read(n);require(b.getActivePotionEffects().isEmpty(),"Bad effect list activated");NameLifetimeChecks.retained(b,"ActiveEffects",raw);stable(b);} + private static void badEffect(String key,NBTBase raw){NBTTagCompound e=effect();if(raw==null)e.removeTag(key);else e.setTag(key,raw.copy());EntityLootableBody b=read(with(e));require(b.getActivePotionEffects().isEmpty(),"Malformed effect activated");NameLifetimeChecks.retained(b,"ActiveEffect",e);stable(b);} + private static void badFlag(String key,NBTBase raw){NBTTagCompound e=effect();e.setTag(key,raw.copy());EntityLootableBody b=read(with(e));PotionEffect p=b.getActivePotionEffect(MobEffects.NIGHT_VISION);require(p!=null && p.getDuration()==200 && p.getAmplifier()==1,"Bad optional flag lost valid effect");require(!p.getIsAmbient() && p.doesShowParticles(),"Bad optional flag did not use defaults");NameLifetimeChecks.retained(b,"ActiveEffect",e);stable(b);} + private static void numeric(){for(int type=0;type<4;type++){NBTTagCompound e=effect();NBTBase[] ids={new NBTTagByte((byte)16),new NBTTagShort((short)16),new NBTTagInt(16),new NBTTagLong(16)};NBTBase[] one={new NBTTagByte((byte)1),new NBTTagShort((short)1),new NBTTagInt(1),new NBTTagLong(1)};e.setTag("Id",ids[type]);e.setTag("Duration",one[type]);e.setTag("Amplifier",one[type].copy());e.setTag("Ambient",one[type].copy());e.setTag("ShowParticles",one[type].copy());EntityLootableBody b=read(with(e));PotionEffect p=b.getActivePotionEffect(MobEffects.NIGHT_VISION);require(p!=null && p.getDuration()==1 && p.getAmplifier()==1 && p.getIsAmbient() && p.doesShowParticles(),"Lossless integral effect changed");require(!saved(b).hasKey(NameLifetimeChecks.RECOVERY),"Valid numbers quarantined");stable(b);}} + private static void missingOptional(){NBTTagCompound e=effect();for(String k:new String[]{"Duration","Amplifier","Ambient","ShowParticles"})e.removeTag(k);EntityLootableBody b=read(with(e));PotionEffect p=b.getActivePotionEffect(MobEffects.NIGHT_VISION);require(p!=null && p.getDuration()==0 && p.getAmplifier()==0 && !p.getIsAmbient() && p.doesShowParticles(),"Legacy defaults changed");require(!saved(b).hasKey(NameLifetimeChecks.RECOVERY),"Missing optional fields quarantined");stable(b);} + private static void validDuration(int d){EntityLootableBody b=read(with(effect(MobEffects.NIGHT_VISION,d,0)));b.onEntityUpdate();PotionEffect p=b.getActivePotionEffect(MobEffects.NIGHT_VISION);require(d==0?p==null:p!=null && p.getDuration()==d-1,"Valid duration tick/expiry changed");stable(b);} + private static void signedAmplifiers(){for(int a:new int[]{-128,-1,0,127}){EntityLootableBody b=read(with(effect(MobEffects.NIGHT_VISION,200,a)));require(b.getActivePotionEffect(MobEffects.NIGHT_VISION).getAmplifier()==a,"Existing byte amplifier interpretation changed");require(!saved(b).hasKey(NameLifetimeChecks.RECOVERY),"Canonical signed byte quarantined");stable(b);}} + private static void extendedId(){for(boolean wide:new boolean[]{false,true}){NBTTagCompound e=effect(extended,200,1);if(wide)e.setInteger("Id",200);EntityLootableBody b=read(with(e));require(b.getActivePotionEffect(extended)!=null && b.getActivePotionEffect(extended).getDuration()==200,"Registered unsigned ID changed");require(!saved(b).hasKey(NameLifetimeChecks.RECOVERY),"Valid extended ID quarantined");stable(b);}} + private static void alias(){NBTTagCompound first=effect(extended,200,1),second=effect(extended,900,3),n=base();second.setLong("Id",200);n.setTag("ActiveEffects",NbtRecoveryChecks.list(first,second));EntityLootableBody b=read(n);require(b.getActivePotionEffect(extended).getDuration()==200,"Numeric alias bypassed first valid effect");NameLifetimeChecks.retained(b,"ActiveEffect",second);stable(b);} + private static void validTail(){NBTTagCompound bad=effect();bad.setString("Duration","opaque");NBTTagCompound n=base();n.setTag("ActiveEffects",NbtRecoveryChecks.list(bad,effect()));EntityLootableBody b=read(n);require(b.getActivePotionEffect(MobEffects.NIGHT_VISION).getDuration()==200,"Bad first effect hid valid tail");NameLifetimeChecks.retained(b,"ActiveEffect",bad);stable(b);} + private static void duplicate(){NBTTagCompound first=effect(),second=effect(MobEffects.NIGHT_VISION,900,3),n=base();n.setTag("ActiveEffects",NbtRecoveryChecks.list(first,second));EntityLootableBody b=read(n);require(b.getActivePotionEffect(MobEffects.NIGHT_VISION).getDuration()==200,"Duplicate overwrote first effect");NameLifetimeChecks.retained(b,"ActiveEffect",second);stable(b);} + private static void unknown(){NBTTagCompound e=effect();e.setString("Opaque","retain effect extension");EntityLootableBody b=read(with(e));require(b.getActivePotionEffects().size()==1,"Unknown sibling lost understood effect");NameLifetimeChecks.retained(b,"ActiveEffect",e);stable(b);} + private static void repeated(){EntityLootableBody b=read(with(effect(MobEffects.INVISIBILITY,200,0)));b.onEntityUpdate();require(b.isInvisible(),"Fixture not invisible");b.readFromNBT(base());require(b.getActivePotionEffects().isEmpty() && !b.isInvisible(),"Previous effects or render state survived read");stable(b);} + private static void attributes(){EntityLootableBody original=new EntityLootableBody(world);original.setInventorySlotContents(6,NbtRecoveryChecks.item(120,17));RuntimeChecks.disableVacuum(original);original.addPotionEffect(new PotionEffect(MobEffects.HEALTH_BOOST,200,1));original.addPotionEffect(new PotionEffect(MobEffects.SPEED,200,0));original.addPotionEffect(new PotionEffect(MobEffects.ABSORPTION,200,0));original.setHealth(original.getMaxHealth());NBTTagCompound n=saved(original);EntityLootableBody b=read(n);require(b.getMaxHealth()==28 && b.getHealth()==28 && b.getAbsorptionAmount()==4 && b.getActivePotionEffects().size()==3,"Valid potion health/attributes/absorption changed");require(!saved(b).hasKey(NameLifetimeChecks.RECOVERY),"Healthy potion fields quarantined");stable(b);for(int i=0;i<200;i++)b.onEntityUpdate();require(b.getActivePotionEffects().isEmpty() && b.getMaxHealth()==20 && b.getAbsorptionAmount()==0 && b.getHealth()<=20,"Expiry did not clean up normal modifiers");} + private static void repeatedAttributes(){EntityLootableBody original=new EntityLootableBody(world);original.addPotionEffect(new PotionEffect(MobEffects.HEALTH_BOOST,200,1));EntityLootableBody b=read(saved(original));b.readFromNBT(base());require(b.getActivePotionEffects().isEmpty() && b.getMaxHealth()==20,"Obsolete effects/modifiers survived read");stable(b);} + private static void reusedOutput(){EntityLootableBody b=read(base());NBTTagCompound n=with(effect());b.writeEntityToNBT(n);require(!n.hasKey("ActiveEffects"),"Writer retained obsolete ActiveEffects");stable(b);} + private static void inventory(){NBTTagCompound n=base(),missing=NbtRecoveryChecks.entry(121,11,7);missing.setString("id","absentfixture:missing");n.getTagList("Equipment",10).appendTag(missing);NBTTagCompound raw=malformedFields();for(String k:raw.getKeySet())n.setTag(k,raw.getTag(k).copy());EntityLootableBody b=read(n);require(b.isEntityAlive(),"Bad fields made corpse inaccessible");NbtRecoveryChecks.retained(b,missing);verifyWorldFields(b);stable(b);stable(read(saved(b)));} + private static void empty(){NBTTagCompound n=base();n.setTag("Equipment",new NBTTagList());n.setString("HurtTime","opaque");EntityLootableBody b=read(n);require(b.isEmpty(),"Metadata counts as inventory");NameLifetimeChecks.retained(b,"HurtTime",n.getTag("HurtTime"));} + static NBTTagCompound malformedFields(){NBTTagCompound n=new NBTTagCompound();n.setLong("HurtTime",65537);NBTTagCompound bad=effect();bad.setString("Duration","opaque saved duration");NBTTagCompound unknown=effect();unknown.setInteger("Id",250);unknown.setString("Opaque","missing effect data");n.setTag("ActiveEffects",NbtRecoveryChecks.list(bad,unknown));return n;} + static void verifyWorldFields(EntityLootableBody b){require(b.hurtTime==0 && b.getActivePotionEffects().isEmpty(),"Malformed disk effect/hurt state activated");NBTTagCompound raw=malformedFields();NameLifetimeChecks.retained(b,"HurtTime",raw.getTag("HurtTime"));NBTTagList list=raw.getTagList("ActiveEffects",10);for(int i=0;i results=new ArrayList<>(); + private static int failures; + private static final UUID MOD=UUID.fromString("00000000-0000-4000-8000-000000000141"); + static void run(MinecraftServer server,WorldServer target,File folder) throws Exception { + world=target;results.clear();failures=0; + NBTBase[] wrong={new NBTTagString("opaque"),new NBTTagCompound(),new NBTTagList(),new NBTTagByteArray(new byte[]{1}),new NBTTagIntArray(new int[]{1})}; + for(String key:new String[]{"Health","AbsorptionAmount"}) { + for(int i=0;ibadScalar(key,raw));} + for(double value:new double[]{Double.NaN,Double.NEGATIVE_INFINITY,Double.POSITIVE_INFINITY,-1,Double.MAX_VALUE,1e-300})check(key+"_invalid_value_"+value,()->badScalar(key,new NBTTagDouble(value))); + } + check("health_above_effective_max_retained",()->badScalar("Health",new NBTTagFloat(21))); + for(int type=0;type<6;type++){final int t=type;check("valid_numeric_scalar_type_"+type,()->numeric(t));} + check("valid_zero_health_keeps_death_semantics",HealthAttributeChecks::zero); + check("missing_health_defaults_to_loaded_max",HealthAttributeChecks::missingHealth); + check("configured_health_and_follow_range_defaults",HealthAttributeChecks::configured); + check("configured_health_above_engine_cap_keeps_existing_clamp",HealthAttributeChecks::configuredCap); + check("previous_configured_health_survives_later_config_change",HealthAttributeChecks::configuredHistory); + for(int i=0;ibadList(raw));} + check("empty_attribute_list_keeps_constructor_defaults",()->{NBTTagCompound n=base();n.setTag("Attributes",new NBTTagList());EntityLootableBody b=read(n);value(b.getMaxHealth(),20,"Empty list changed max");RuntimeChecks.require(!NameLifetimeChecks.saved(b).hasKey(NameLifetimeChecks.RECOVERY),"Valid empty list quarantined");stable(b);}); + check("Attributes_wrong_element_type",()->{NBTTagList raw=new NBTTagList();raw.appendTag(new NBTTagString("opaque"));badList(raw);}); + check("attribute_missing_name",()->badAttribute("Name",null)); + check("attribute_wrong_name_type",()->badAttribute("Name",new NBTTagInt(1))); + check("attribute_unknown_name_retained",()->badAttribute("Name",new NBTTagString("absentfixture:attribute"))); + check("attribute_missing_base",()->badAttribute("Base",null)); + for(int i=0;ibadAttribute("Base",raw));} + for(double value:new double[]{Double.NaN,Double.POSITIVE_INFINITY,Double.NEGATIVE_INFINITY,-1,0,Double.MAX_VALUE})check("Base_invalid_value_"+value,()->badAttribute("Base",new NBTTagDouble(value))); + check("invalid_base_does_not_hide_later_valid_attribute",HealthAttributeChecks::validTail); + check("duplicate_attribute_keeps_first_valid_record",HealthAttributeChecks::duplicateAttribute); + check("unknown_attribute_payload_preserved_with_valid_fields",HealthAttributeChecks::unknownAttributeData); + check("Modifiers_wrong_list_type_keeps_base",()->badModifierList(new NBTTagString("opaque"))); + check("Modifiers_wrong_element_type_keeps_base",()->{NBTTagList l=new NBTTagList();l.appendTag(new NBTTagInt(3));badModifierList(l);}); + for(String field:new String[]{"Name","Amount","Operation","UUIDMost","UUIDLeast"}) { + check("modifier_missing_"+field,()->badModifier(field,null)); + check("modifier_wrong_type_"+field,()->badModifier(field,field.equals("Name")?new NBTTagInt(1):new NBTTagString("opaque"))); + } + check("modifier_blank_name",()->badModifier("Name",new NBTTagString(""))); + for(double value:new double[]{Double.NaN,Double.POSITIVE_INFINITY,Double.NEGATIVE_INFINITY})check("modifier_nonfinite_amount_"+value,()->badModifier("Amount",new NBTTagDouble(value))); + for(NBTBase value:new NBTBase[]{new NBTTagInt(-1),new NBTTagInt(3),new NBTTagLong(4294967296L),new NBTTagFloat(1F)})check("modifier_invalid_operation_"+value,()->badModifier("Operation",value)); + check("modifier_fractional_UUID_not_coerced",()->badModifier("UUIDMost",new NBTTagDouble(1.5))); + check("duplicate_modifier_UUID_keeps_first",HealthAttributeChecks::duplicateModifier); + check("bad_modifier_keeps_valid_base_and_tail",HealthAttributeChecks::modifierTail); + check("unknown_modifier_payload_retained",HealthAttributeChecks::unknownModifierData); + check("finite_modifier_overflow_not_activated",HealthAttributeChecks::overflow); + check("valid_signed_modifiers_all_three_operations",HealthAttributeChecks::validModifiers); + check("valid_zero_UUID_and_integral_modifier_numbers",HealthAttributeChecks::zeroUuid); + check("valid_base_numeric_types_and_fractional_health",HealthAttributeChecks::validBases); + check("repeated_load_restores_constructor_defaults",HealthAttributeChecks::repeated); + check("repeated_load_clears_saved_modifiers",HealthAttributeChecks::repeatedModifiers); + check("repaired_health_does_not_keep_previous_removal_countdown",HealthAttributeChecks::pendingRemoval); + check("invalid_health_attribute_and_unresolved_inventory_reload",HealthAttributeChecks::inventory); + check("invalid_metadata_alone_does_not_count_as_inventory",HealthAttributeChecks::empty); + check("healthy_save_layout_unchanged",HealthAttributeChecks::healthy); + try(PrintWriter out=new PrintWriter(new File(folder,"health-attribute-checks.txt"),"UTF-8")){for(String s:results)out.println(s);out.println("checks="+results.size()+" failures="+failures);} + RuntimeChecks.require(failures==0,"Health/attribute regressions: "+failures+" / "+results.size());System.out.println("LOOTABLE_BODIES_HEALTH_ATTRIBUTE_PASS checks="+results.size()); + } + private static void check(String name,Check test){ + float hp=LootableBodies.corpseHP;Set before=new HashSet<>(world.loadedEntityList); + try{test.run();results.add("PASS "+name);System.out.println("HEALTH_ATTRIBUTE_PASS "+name);} + catch(Throwable e){failures++;results.add("FAIL "+name+": "+e);System.out.println("HEALTH_ATTRIBUTE_FAIL "+name+": "+e);e.printStackTrace();} + finally{LootableBodies.corpseHP=hp;for(Entity e:new ArrayList(world.loadedEntityList))if(!before.contains(e))world.removeEntityDangerously(e);} + } + static NBTTagCompound base(){EntityLootableBody b=new EntityLootableBody(world);b.setPosition(160.5,80,160.5);b.setNoGravity(true);b.setUserName("HealthFixture");b.setInventorySlotContents(6,NbtRecoveryChecks.item(120,17));RuntimeChecks.disableVacuum(b);return NameLifetimeChecks.saved(b);} + static EntityLootableBody read(NBTTagCompound n){NBTTagCompound before=n.copy();EntityLootableBody b=new EntityLootableBody(world);b.readFromNBT(n);RuntimeChecks.require(NameLifetimeChecks.same(before,n),"Input NBT mutated");return b;} + static NBTTagCompound attribute(String name,double base){NBTTagCompound n=new NBTTagCompound();n.setString("Name",name);n.setDouble("Base",base);return n;} + static NBTTagCompound modifier(double amount,int operation){NBTTagCompound n=new NBTTagCompound();n.setString("Name","Fixture modifier");n.setDouble("Amount",amount);n.setInteger("Operation",operation);n.setUniqueId("UUID",MOD);return n;} + static NBTTagCompound with(NBTTagCompound a){NBTTagCompound n=base();n.setTag("Attributes",NbtRecoveryChecks.list(a));return n;} + private static void value(double a,double b,String why){RuntimeChecks.require(Double.isFinite(a) && Math.abs(a-b)<1e-6,why+" expected="+b+" actual="+a);} + private static void inventoryKept(EntityLootableBody b){RuntimeChecks.require(ItemStack.areItemStacksEqual(b.getStackInSlot(6),NbtRecoveryChecks.item(120,17)),"Complete recoverable inventory changed");} + private static void stable(EntityLootableBody b){NBTTagCompound a=NameLifetimeChecks.saved(b),z=NameLifetimeChecks.saved(read(a));for(String key:new String[]{"Health","AbsorptionAmount","Attributes","Equipment","Aux",NameLifetimeChecks.RECOVERY,NbtRecoveryChecks.RECOVERY})RuntimeChecks.require(NameLifetimeChecks.same(a.getTag(key),z.getTag(key)),"Round trip changed "+key);inventoryKept(b);} + private static void retained(EntityLootableBody b,String key,NBTBase raw){NameLifetimeChecks.retained(b,key,raw);} + private static void badScalar(String key,NBTBase raw){NBTTagCompound n=base();n.setTag(key,raw.copy());EntityLootableBody b=read(n);value(key.equals("Health")?b.getHealth():b.getAbsorptionAmount(),key.equals("Health")?20:0,"Bad scalar did not use safe default");RuntimeChecks.require(b.isEntityAlive(),"Malformed scalar made corpse inaccessible");retained(b,key,raw);stable(b);} + private static void numeric(int type){NBTBase[] values={new NBTTagByte((byte)1),new NBTTagShort((short)1),new NBTTagInt(1),new NBTTagLong(1),new NBTTagFloat(1),new NBTTagDouble(1)};NBTTagCompound n=base();n.setTag("Health",values[type]);n.setTag("AbsorptionAmount",values[type].copy());EntityLootableBody b=read(n);value(b.getHealth(),1,"Valid Health changed");value(b.getAbsorptionAmount(),1,"Valid absorption changed");stable(b);} + private static void zero(){NBTTagCompound n=base();n.setFloat("Health",0);EntityLootableBody b=read(n);value(b.getHealth(),0,"Legitimate zero revived");RuntimeChecks.require(!b.isEntityAlive(),"Zero health no longer starts death handling");inventoryKept(b);} + private static void missingHealth(){NBTTagCompound n=with(attribute("generic.maxHealth",30));n.removeTag("Health");EntityLootableBody b=read(n);value(b.getHealth(),30,"Missing Health did not use loaded max");stable(b);} + private static void configured(){LootableBodies.corpseHP=37;NBTTagCompound n=base();n.removeTag("Attributes");n.removeTag("Health");EntityLootableBody b=read(n);value(b.getMaxHealth(),37,"Config health changed");value(b.getEntityAttribute(SharedMonsterAttributes.FOLLOW_RANGE).getBaseValue(),16,"EntityLiving follow-range default changed");stable(b);} + private static void configuredCap(){LootableBodies.corpseHP=Short.MAX_VALUE;EntityLootableBody original=new EntityLootableBody(world);NBTTagCompound n=NameLifetimeChecks.saved(original);EntityLootableBody b=read(n);value(b.getMaxHealth(),original.getMaxHealth(),"Existing configured health clamp changed");RuntimeChecks.require(!NameLifetimeChecks.saved(b).hasKey(NameLifetimeChecks.RECOVERY),"Healthy configured base quarantined");} + private static void configuredHistory(){LootableBodies.corpseHP=Short.MAX_VALUE;EntityLootableBody old=new EntityLootableBody(world);old.setInventorySlotContents(6,NbtRecoveryChecks.item(120,17));NBTTagCompound n=NameLifetimeChecks.saved(old);LootableBodies.corpseHP=20;EntityLootableBody b=read(n);value(b.getMaxHealth(),old.getMaxHealth(),"Valid old configured base was replaced by today's default");value(b.getHealth(),old.getHealth(),"Valid old configured health changed");RuntimeChecks.require(!NameLifetimeChecks.saved(b).hasKey(NameLifetimeChecks.RECOVERY),"Healthy historical config quarantined");stable(b);} + private static void badList(NBTBase raw){NBTTagCompound n=base();n.setTag("Attributes",raw.copy());EntityLootableBody b=read(n);value(b.getMaxHealth(),20,"Bad list changed max health");retained(b,"Attributes",raw);stable(b);} + private static void badAttribute(String field,NBTBase raw){NBTTagCompound a=attribute("generic.maxHealth",30);if(raw==null)a.removeTag(field);else a.setTag(field,raw.copy());EntityLootableBody b=read(with(a));value(b.getMaxHealth(),20,"Bad attribute changed default");retained(b,"Attribute",a);stable(b);} + private static void validTail(){NBTTagCompound a=attribute("generic.maxHealth",Double.NaN),n=base();n.setTag("Attributes",NbtRecoveryChecks.list(a,attribute("generic.maxHealth",30),attribute("generic.followRange",24)));EntityLootableBody b=read(n);value(b.getMaxHealth(),30,"Invalid first entry hid valid max health");value(b.getEntityAttribute(SharedMonsterAttributes.FOLLOW_RANGE).getBaseValue(),24,"Valid tail lost");retained(b,"Attribute",a);stable(b);} + private static void duplicateAttribute(){NBTTagCompound duplicate=attribute("generic.maxHealth",40),n=base();n.setTag("Attributes",NbtRecoveryChecks.list(attribute("generic.maxHealth",30),duplicate));EntityLootableBody b=read(n);value(b.getMaxHealth(),30,"Duplicate overwrote first max health");retained(b,"Attribute",duplicate);stable(b);} + private static void unknownAttributeData(){NBTTagCompound a=attribute("generic.maxHealth",30);a.setString("Opaque","retain");EntityLootableBody b=read(with(a));value(b.getMaxHealth(),30,"Known fields lost with unknown sibling");retained(b,"Attribute",a);stable(b);} + private static void badModifierList(NBTBase raw){NBTTagCompound a=attribute("generic.maxHealth",30);a.setTag("Modifiers",raw.copy());EntityLootableBody b=read(with(a));value(b.getMaxHealth(),30,"Invalid modifiers lost valid base");retained(b,"Attribute",a);stable(b);} + private static void badModifier(String field,NBTBase raw){NBTTagCompound m=modifier(5,0);if(raw==null)m.removeTag(field);else m.setTag(field,raw.copy());NBTTagCompound a=attribute("generic.maxHealth",30);a.setTag("Modifiers",NbtRecoveryChecks.list(m));EntityLootableBody b=read(with(a));value(b.getMaxHealth(),30,"Malformed modifier activated");retained(b,"Attribute",a);stable(b);} + private static void duplicateModifier(){NBTTagCompound a=attribute("generic.maxHealth",20);a.setTag("Modifiers",NbtRecoveryChecks.list(modifier(2,0),modifier(7,0)));EntityLootableBody b=read(with(a));value(b.getMaxHealth(),22,"Duplicate modifier replaced first");retained(b,"Attribute",a);stable(b);} + private static void modifierTail(){NBTTagCompound bad=modifier(Double.NaN,0),good=modifier(5,0),a=attribute("generic.maxHealth",30);good.setUniqueId("UUID",new UUID(0,142));a.setTag("Modifiers",NbtRecoveryChecks.list(bad,good));EntityLootableBody b=read(with(a));value(b.getMaxHealth(),35,"Invalid modifier hid valid tail or base");retained(b,"Attribute",a);stable(b);} + private static void unknownModifierData(){NBTTagCompound m=modifier(5,0),a=attribute("generic.maxHealth",30);m.setString("Opaque","retain");a.setTag("Modifiers",NbtRecoveryChecks.list(m));EntityLootableBody b=read(with(a));value(b.getMaxHealth(),35,"Known modifier fields lost");retained(b,"Attribute",a);stable(b);} + private static void overflow(){NBTTagCompound a=attribute("generic.maxHealth",30);a.setTag("Modifiers",NbtRecoveryChecks.list(modifier(Double.MAX_VALUE,2)));EntityLootableBody b=read(with(a));value(b.getMaxHealth(),30,"Overflowing modifier activated");retained(b,"Attribute",a);stable(b);} + private static void validModifiers(){NBTTagCompound a=attribute("generic.maxHealth",30);NBTTagCompound m0=modifier(-2,0),m1=modifier(.5,1),m2=modifier(.25,2);m1.setUniqueId("UUID",new UUID(0,142));m2.setUniqueId("UUID",new UUID(0,143));a.setTag("Modifiers",NbtRecoveryChecks.list(m0,m1,m2));EntityLootableBody b=read(with(a));value(b.getMaxHealth(),52.5,"Valid signed/three-operation modifiers changed");RuntimeChecks.require(!NameLifetimeChecks.saved(b).hasKey(NameLifetimeChecks.RECOVERY),"Valid modifiers quarantined");stable(b);} + private static void zeroUuid(){NBTTagCompound a=attribute("generic.maxHealth",30),m=modifier(5,0);m.setLong("UUIDMost",0);m.setLong("UUIDLeast",0);m.setByte("Operation",(byte)0);m.setInteger("Amount",5);a.setTag("Modifiers",NbtRecoveryChecks.list(m));EntityLootableBody b=read(with(a));value(b.getMaxHealth(),35,"Zero UUID or lossless numbers rejected");stable(b);} + private static void validBases(){for(NBTBase raw:new NBTBase[]{new NBTTagByte((byte)30),new NBTTagShort((short)30),new NBTTagInt(30),new NBTTagLong(30),new NBTTagFloat(30.5F),new NBTTagDouble(30.5)}){NBTTagCompound a=attribute("generic.maxHealth",30);a.setTag("Base",raw);NBTTagCompound n=with(a);n.setDouble("Health",12.5);EntityLootableBody b=read(n);value(b.getMaxHealth(),((NBTPrimitive)raw).getDouble(),"Valid base changed");value(b.getHealth(),12.5,"Fractional Health changed");stable(b);}} + private static void repeated(){EntityLootableBody b=read(with(attribute("generic.maxHealth",30)));NBTTagCompound n=base();n.removeTag("Attributes");n.removeTag("Health");n.removeTag("AbsorptionAmount");b.setAbsorptionAmount(5);b.readFromNBT(n);value(b.getMaxHealth(),20,"Missing Attributes retained previous max");value(b.getHealth(),20,"Missing Health retained previous value");value(b.getAbsorptionAmount(),0,"Missing absorption retained previous value");stable(b);} + private static void repeatedModifiers(){NBTTagCompound a=attribute("generic.maxHealth",30);a.setTag("Modifiers",NbtRecoveryChecks.list(modifier(5,0)));EntityLootableBody b=read(with(a));b.readFromNBT(with(attribute("generic.maxHealth",20)));value(b.getMaxHealth(),20,"Repeated read kept obsolete modifier");stable(b);} + private static void pendingRemoval(){NBTTagCompound n=base();n.setFloat("Health",0);EntityLootableBody b=read(n);b.onEntityUpdate();n=base();n.setDouble("Health",Double.NaN);b.readFromNBT(n);b.onEntityUpdate();RuntimeChecks.require(b.isEntityAlive(),"Repaired owner still dead");inventoryKept(b);stable(b);n=base();n.setFloat("Health",3.5F);b.isDead=true;b.readFromNBT(n);RuntimeChecks.require(b.isEntityAlive(),"Reused entity kept stale dead flag");value(b.getHealth(),3.5,"Reset healed valid wounded corpse");} + private static void inventory(){NBTTagCompound n=base(),raw=NbtRecoveryChecks.entry(121,11,7);raw.setString("id","absentfixture:missing");n.getTagList("Equipment",10).appendTag(raw);n.setDouble("Health",Double.NaN);NBTTagCompound a=attribute("generic.maxHealth",Double.NaN);n.setTag("Attributes",NbtRecoveryChecks.list(a,attribute("generic.followRange",24)));EntityLootableBody b=read(n);RuntimeChecks.require(b.isEntityAlive(),"Invalid health made recoverable inventory inaccessible");NbtRecoveryChecks.retained(b,raw);retained(b,"Attribute",a);retained(b,"Health",n.getTag("Health"));stable(b);stable(read(NameLifetimeChecks.saved(b)));} + private static void empty(){NBTTagCompound n=base();n.setTag("Equipment",new NBTTagList());n.setDouble("Health",Double.NaN);EntityLootableBody b=read(n);RuntimeChecks.require(b.isEmpty(),"Health evidence counted as inventory");retained(b,"Health",n.getTag("Health"));} + private static void healthy(){NBTTagCompound n=base();EntityLootableBody b=read(n);RuntimeChecks.require(!NameLifetimeChecks.saved(b).hasKey(NameLifetimeChecks.RECOVERY),"Healthy save gained recovery records");stable(b);} + + static NBTTagCompound malformedFields(){ + NBTTagCompound n=new NBTTagCompound();n.setDouble("Health",Double.NaN);n.setString("AbsorptionAmount","opaque saved absorption"); + NBTTagCompound max=attribute("generic.maxHealth",Double.NaN),follow=attribute("generic.followRange",24),unknown=attribute("absentfixture:attribute",7); + NBTTagCompound bad=modifier(Double.NaN,0),good=modifier(2,0);good.setUniqueId("UUID",new UUID(0,144)); + follow.setTag("Modifiers",NbtRecoveryChecks.list(bad,good));unknown.setString("Opaque","retain original attribute"); + n.setTag("Attributes",NbtRecoveryChecks.list(max,follow,unknown));return n; + } + static void verifyWorldFields(EntityLootableBody b){ + value(b.getHealth(),20,"Disk health unsafe");value(b.getMaxHealth(),20,"Disk max health unsafe");value(b.getAbsorptionAmount(),0,"Disk absorption unsafe"); + value(b.getEntityAttribute(SharedMonsterAttributes.FOLLOW_RANGE).getAttributeValue(),26,"Valid attribute/modifier tail lost on disk"); + NBTTagCompound raw=malformedFields();retained(b,"Health",raw.getTag("Health"));retained(b,"AbsorptionAmount",raw.getTag("AbsorptionAmount")); + NBTTagList entries=raw.getTagList("Attributes",10);for(int i=0;i results = new ArrayList<>(); + private static int failures; + private static int sequence; + private static WorldServer world; + private static MinecraftServer server; + + public static void run(MinecraftServer instance, WorldServer target, File folder) throws Exception { + server=instance; world=target; results.clear(); failures=0; + check("failed_corpse_spawn_keeps_original_drops",InventorySafetyChecks::failedSpawn); + check("cancelled_corpse_spawn_leaves_vanilla_drops",InventorySafetyChecks::cancelledSpawn); + check("stale_equipment_cannot_overwrite_current_helmet",InventorySafetyChecks::staleEquipment); + check("equipment_cache_consumed_and_ignored_deaths",InventorySafetyChecks::cacheLifecycle); + check("failed_release_retains_inventory_and_body",InventorySafetyChecks::failedRelease); + check("partial_release_retries_without_duplicates",InventorySafetyChecks::partialRelease); + check("vanilla_equipment_drop_does_not_duplicate",InventorySafetyChecks::vanillaEquipment); + check("vacuum_claims_each_entity_once",InventorySafetyChecks::vacuumOnce); + check("empty_and_negative_removal_preserve_slots",InventorySafetyChecks::emptyRemoval); + check("repeated_nbt_read_replaces_without_mutating_source",InventorySafetyChecks::repeatedRead); + check("container_rejects_stale_access",InventorySafetyChecks::staleAccess); + check("overflow_never_overwrites_and_respects_limits",InventorySafetyChecks::overflow); + check("two_viewers_shift_click_conserve_custom_stacks",InventorySafetyChecks::twoViewers); + check("empty_slot_validation_is_safe",InventorySafetyChecks::emptyValidation); + check("dirty_inventory_schedules_chunk_save",InventorySafetyChecks::dirtyInventory); + check("direct_and_overflow_mutations_schedule_save",InventorySafetyChecks::dirtyMutations); + try(PrintWriter out=new PrintWriter(new File(folder,"inventory-checks.txt"),"UTF-8")) { + for(String line:results) out.println(line); + out.println("checks="+results.size()+" failures="+failures); + } + RuntimeChecks.require(failures==0,"Inventory regressions: "+failures+" / "+results.size()); + System.out.println("LOOTABLE_BODIES_INVENTORY_PASS checks="+results.size()); + } + private static void check(String name,Check check) { + List before=new ArrayList<>(world.loadedEntityList); + String keep=world.getGameRules().getString("keepInventory"); + String mobLoot=world.getGameRules().getString("doMobLoot"); + try { + world.getGameRules().setOrCreateGameRule("keepInventory","false"); + world.getGameRules().setOrCreateGameRule("doMobLoot","false"); + check.run(); results.add("PASS "+name); + System.out.println("INVENTORY_CHECK_PASS "+name); + } catch(Throwable error) { + failures++; results.add("FAIL "+name+": "+error); + System.out.println("INVENTORY_CHECK_FAIL "+name+": "+error); + error.printStackTrace(); + } finally { + world.getGameRules().setOrCreateGameRule("keepInventory",keep); + world.getGameRules().setOrCreateGameRule("doMobLoot",mobLoot); + for(Entity entity:new ArrayList(world.loadedEntityList)) + if(!before.contains(entity)) world.removeEntityDangerously(entity); + } + } + private static EntityPlayerMP player() { + EntityPlayerMP player=new EntityPlayerMP(server,world, + new GameProfile(UUID.nameUUIDFromBytes(("inventory-fixture-"+(sequence++)).getBytes(java.nio.charset.StandardCharsets.UTF_8)),"InventoryFixture"), + new net.minecraft.server.management.PlayerInteractionManager(world)); + player.connection=new net.minecraft.network.NetHandlerPlayServer(server, + new net.minecraft.network.NetworkManager(net.minecraft.network.EnumPacketDirection.SERVERBOUND),player); + player.setPosition(64.5,80,64.5); world.getChunkFromChunkCoords(4,4); + return player; + } + private static EntityLootableBody body() { + EntityLootableBody body=new EntityLootableBody(world); + body.setPosition(64.5,80,64.5); body.setNoGravity(true); + world.getChunkFromChunkCoords(4,4); RuntimeChecks.disableVacuum(body); + return body; + } + private static ItemStack item(int id,int count) { + ItemStack stack=RuntimeChecks.stack(id); stack.stackSize=count; return stack; + } + private static List drops(EntityPlayerMP player,ItemStack... stacks) { + List result=new ArrayList<>(); + for(ItemStack stack:stacks) result.add(new EntityItem(player.getEntityWorld(),player.posX,player.posY,player.posZ,stack)); + return result; + } + private static void handle(PlayerDeathEventHandler handler,EntityPlayerMP player,List drops) { + handler.entityDropEvent(new LivingDropsEvent(player,DamageSource.outOfWorld,drops,0,false)); + } + private static NBTTagCompound data(EntityLootableBody body) { + NBTTagCompound result=new NBTTagCompound(); body.writeEntityToNBT(result); return result; + } + private static NBTTagCompound encoded(ItemStack stack) { return stack.writeToNBT(new NBTTagCompound()); } + private static Map inventory(EntityLootableBody body) { + Map result=new TreeMap<>(); + for(int i=0;i result,ItemStack stack) { + if(stack==null || stack.stackSize<=0) return; + NBTTagCompound tag=encoded(stack); tag.removeTag("Count"); + String key=tag.toString(); result.put(key,result.getOrDefault(key,0)+stack.stackSize); + } + private static Map dropped() { + Map result=new TreeMap<>(); + for(Entity entity:world.loadedEntityList) if(entity instanceof EntityItem && !entity.isDead) add(result,((EntityItem)entity).getEntityItem()); + return result; + } + private static EntityLootableBody newest(Set before) { + for(Entity entity:world.loadedEntityList) if(entity instanceof EntityLootableBody && !before.contains(entity)) return (EntityLootableBody)entity; + throw new AssertionError("No corpse spawned"); + } + private static void failedSpawn() { + EntityPlayerMP player=player(); RejectingWorld rejected=new RejectingWorld(world); + player.world=rejected; + ItemStack bones=new ItemStack(Items.BONE,9); bones.setStackDisplayName("Saved bones"); + List drops=drops(player,bones,item(19,23)); + List before=new ArrayList<>(); for(EntityItem e:drops) before.add(encoded(e.getEntityItem())); + PlayerDeathEventHandler handler=new PlayerDeathEventHandler(); + handler.entityDeathEvent(new LivingDeathEvent(player,DamageSource.outOfWorld)); + handle(handler,player,drops); + RuntimeChecks.require(rejected.attempts==1,"Failed spawn branch was not reached"); + RuntimeChecks.require(drops.size()==before.size(),"Rejected corpse consumed vanilla death drops"); + for(int i=0;i expected=new TreeMap<>(); add(expected,player.inventory.mainInventory[0]);add(expected,player.inventory.mainInventory[9]); + Set before=new HashSet<>(world.loadedEntityList); + RejectCorpse reject=new RejectCorpse(); MinecraftForge.EVENT_BUS.register(reject); + try { player.onDeath(DamageSource.outOfWorld); } finally { MinecraftForge.EVENT_BUS.unregister(reject); } + RuntimeChecks.require(reject.attempts==1,"Corpse cancellation not exercised"); + for(Entity entity:world.loadedEntityList) RuntimeChecks.require(before.contains(entity) || !(entity instanceof EntityLootableBody),"Cancelled corpse spawn bypassed Forge cancellation"); + RuntimeChecks.require(expected.equals(dropped()),"Cancelled corpse did not fall back to exact vanilla drops"); + } + private static void staleEquipment() { + EntityPlayerMP player=player(); PlayerDeathEventHandler handler=new PlayerDeathEventHandler(); + ItemStack previous=item(0,1); player.setItemStackToSlot(EntityEquipmentSlot.HEAD,previous); + handler.entityDeathEvent(new LivingDeathEvent(player,DamageSource.outOfWorld)); + handle(handler,player,drops(player,previous)); + ItemStack current=item(1,1); player.setItemStackToSlot(EntityEquipmentSlot.HEAD,current); + handler.entityDeathEvent(new LivingDeathEvent(player,DamageSource.outOfWorld)); + Map expected=new TreeMap<>(); add(expected,current);add(expected,previous); + Set before=new HashSet<>(world.loadedEntityList); + handle(handler,player,drops(player,current,previous)); + EntityLootableBody corpse=newest(before); + Map actual=inventory(corpse); + // Ignore only the explicitly configured extra bones/flesh. + actual.entrySet().removeIf(e->e.getKey().contains("minecraft:bone") || e.getKey().contains("minecraft:rotten_flesh")); + RuntimeChecks.require(expected.equals(actual),"Stale equipment overwrote a current stack"); + } + @SuppressWarnings("unchecked") private static Map cache(PlayerDeathEventHandler handler) throws Exception { + Field field=PlayerDeathEventHandler.class.getDeclaredField("equipmentCache"); field.setAccessible(true); + return (Map)field.get(handler); + } + private static void cacheLifecycle() throws Exception { + EntityPlayerMP player=player(); PlayerDeathEventHandler handler=new PlayerDeathEventHandler(); + player.setItemStackToSlot(EntityEquipmentSlot.HEAD,item(0,1)); + handler.entityDeathEvent(new LivingDeathEvent(player,DamageSource.outOfWorld)); + handle(handler,player,drops(player,player.getItemStackFromSlot(EntityEquipmentSlot.HEAD))); + RuntimeChecks.require(!cache(handler).containsKey(player),"Equipment cache retains completed death"); + world.getGameRules().setOrCreateGameRule("keepInventory","true"); + handler.entityDeathEvent(new LivingDeathEvent(player,DamageSource.outOfWorld)); + RuntimeChecks.require(!cache(handler).containsKey(player),"keepInventory death cached equipment"); + world.getGameRules().setOrCreateGameRule("keepInventory","false"); + LivingDeathEvent canceled=new LivingDeathEvent(player,DamageSource.outOfWorld); canceled.setCanceled(true); + handler.entityDeathEvent(canceled); + RuntimeChecks.require(!cache(handler).containsKey(player),"Cancelled death cached equipment"); + handler.entityDeathEvent(new LivingDeathEvent(player,DamageSource.outOfWorld)); + List original=drops(player,item(19,23)); + LivingDropsEvent canceledDrops=new LivingDropsEvent(player,DamageSource.outOfWorld,original,0,false); + canceledDrops.setCanceled(true);handler.entityDropEvent(canceledDrops); + RuntimeChecks.require(!cache(handler).containsKey(player) && original.size()==1 + && ItemStack.areItemStacksEqual(original.get(0).getEntityItem(),item(19,23)),"Cancelled drops retained stale cache or changed inventory"); + } + public static final class RejectItems { + boolean rejectAll; + String rejectFixture; + @SubscribeEvent public void join(EntityJoinWorldEvent event) { + if(event.getEntity() instanceof EntityItem) { + ItemStack stack=((EntityItem)event.getEntity()).getEntityItem(); + if(rejectAll || stack.hasTagCompound() && rejectFixture.equals(stack.getTagCompound().getString("fixture"))) event.setCanceled(true); + } + } + } + private static void failedRelease() throws Exception { + EntityLootableBody corpse=body(); corpse.setInventorySlotContents(6,item(19,23)); + RuntimeChecks.require(world.spawnEntity(corpse),"Rejected-release owner spawn"); + Map expected=inventory(corpse); + RejectItems reject=new RejectItems(); reject.rejectAll=true; MinecraftForge.EVENT_BUS.register(reject); + try { corpse.onKillCommand(); for(int i=0;i<25;i++) corpse.onEntityUpdate(); } + finally { MinecraftForge.EVENT_BUS.unregister(reject); } + RuntimeChecks.require(expected.equals(inventory(corpse)),"Rejected item spawn erased corpse inventory"); + RuntimeChecks.require(corpse.isEntityAlive() && world.loadedEntityList.contains(corpse),"Rejected release removed the only inventory owner"); + NBTTagCompound saved=data(corpse); EntityLootableBody loaded=body();loaded.readEntityFromNBT(saved); + RuntimeChecks.require(expected.equals(inventory(loaded)),"Rejected items do not survive NBT reload"); + corpse.onKillCommand();for(int i=0;i<12;i++)corpse.onEntityUpdate(); + RuntimeChecks.require(expected.equals(dropped()),"Retry did not drop exact saved stacks once"); + } + private static void partialRelease() { + EntityLootableBody corpse=body();corpse.setInventorySlotContents(6,item(19,23));corpse.setInventorySlotContents(7,item(20,11)); + RuntimeChecks.require(world.spawnEntity(corpse),"Partial-release owner spawn"); + Map expected=inventory(corpse); + RejectItems reject=new RejectItems();reject.rejectFixture="slot-20";MinecraftForge.EVENT_BUS.register(reject); + try { corpse.onKillCommand();for(int i=0;i<12;i++)corpse.onEntityUpdate(); } + finally { MinecraftForge.EVENT_BUS.unregister(reject); } + Map combined=new TreeMap<>(dropped());inventory(corpse).forEach((k,v)->combined.put(k,combined.getOrDefault(k,0)+v)); + RuntimeChecks.require(expected.equals(combined),"Partial release lost/duplicated a stack"); + RuntimeChecks.require(corpse.getStackInSlot(6)==null && ItemStack.areItemStacksEqual(corpse.getStackInSlot(7),item(20,11)),"Partial release did not retain only rejected slot"); + corpse.onKillCommand();for(int i=0;i<12;i++)corpse.onEntityUpdate(); + RuntimeChecks.require(expected.equals(dropped()),"Partial retry duplicated previously accepted drops"); + } + private static void vanillaEquipment() { + world.getGameRules().setOrCreateGameRule("doMobLoot","true"); + EntityLootableBody corpse=body();corpse.setInventorySlotContents(0,item(0,1));corpse.setDropChance(EntityEquipmentSlot.HEAD,2F); + RuntimeChecks.require(world.spawnEntity(corpse),"Equipment-release owner spawn"); + Map expected=inventory(corpse); + corpse.onKillCommand();for(int i=0;i<12;i++)corpse.onEntityUpdate(); + RuntimeChecks.require(expected.equals(dropped()),"Vanilla mob equipment path duplicates corpse inventory"); + } + private static void vacuumOnce() { + EntityLootableBody first=body(),second=body(); + try { Field f=EntityLootableBody.class.getDeclaredField("vacuumTime");f.setAccessible(true);f.setByte(first,(byte)0);f.setByte(second,(byte)0); } + catch(Exception error){throw new AssertionError(error);} + EntityItem entity=new EntityItem(world,64.5,80,64.5,item(19,23));RuntimeChecks.require(world.spawnEntity(entity),"Vacuum input spawn"); + Map expected=new TreeMap<>();add(expected,entity.getEntityItem().copy()); + first.onEntityUpdate();second.onEntityUpdate(); + Map all=inventory(first);inventory(second).forEach((k,v)->all.put(k,all.getOrDefault(k,0)+v)); + RuntimeChecks.require(expected.equals(all),"Two corpses claimed the same dying item entity"); + } + private static void emptyRemoval() { + EntityLootableBody corpse=body(); + RuntimeChecks.require(corpse.decrStackSize(6,1)==null,"Empty removal must return null"); + corpse.setInventorySlotContents(6,item(19,23)); NBTTagCompound expected=encoded(corpse.getStackInSlot(6)); + RuntimeChecks.require(corpse.decrStackSize(6,-1)==null,"Negative removal created a stack"); + RuntimeChecks.require(expected.equals(encoded(corpse.getStackInSlot(6))),"Negative removal changed stack count"); + RuntimeChecks.require(corpse.decrStackSize(6,0)==null,"Zero removal created a stack"); + ItemStack removed=corpse.decrStackSize(6,40);RuntimeChecks.require(ItemStack.areItemStacksEqual(removed,item(19,23)) && corpse.getStackInSlot(6)==null,"Full extraction did not clear slot"); + } + private static void repeatedRead() { + EntityLootableBody source=body();for(int i=6;i<48;i++)source.setInventorySlotContents(i,item(i,1));source.addItem(item(70,7)); + NBTTagCompound input=data(source),immutable=input.copy(); + EntityLootableBody target=body(); target.readEntityFromNBT(input); target.readEntityFromNBT(input); + RuntimeChecks.require(data(target).getTag("Equipment").equals(input.getTag("Equipment")) && data(target).getTag("Aux").equals(input.getTag("Aux")),"Repeated read appended overflow or changed slots"); + NBTTagCompound sparse=new NBTTagCompound();NBTTagList list=new NBTTagList();NBTTagCompound slot=encoded(item(19,23));slot.setByte("Slot",(byte)47);list.appendTag(slot);sparse.setTag("Equipment",list); + target.readEntityFromNBT(sparse); + RuntimeChecks.require(target.getStackInSlot(6)==null && ItemStack.areItemStacksEqual(target.getStackInSlot(47),item(19,23)) && !data(target).hasKey("Aux"),"Sparse reread kept stale inventory/overflow"); + RuntimeChecks.require(immutable.equals(input),"Reader mutated source NBT"); + } + private static void staleAccess() { + EntityPlayerMP player=player();EntityLootableBody corpse=body();CorpseContainer container=new CorpseContainer(player.inventory,corpse); + RuntimeChecks.require(container.canInteractWith(player),"Near living access failed"); + player.setPosition(100,80,100);RuntimeChecks.require(!container.canInteractWith(player),"Distant container remained accessible"); + player.setPosition(64.5,80,64.5);corpse.setDead();RuntimeChecks.require(!container.canInteractWith(player),"Removed corpse remained accessible"); + corpse=body();container=new CorpseContainer(player.inventory,corpse);player.world=new RejectingWorld(world); + RuntimeChecks.require(!container.canInteractWith(player),"Cross-world container remained accessible"); + player.world=world;player.setHealth(0); + RuntimeChecks.require(!container.canInteractWith(player),"Dead player retained container access"); + player.setHealth(player.getMaxHealth());corpse.onKillCommand();corpse.onEntityUpdate(); + RuntimeChecks.require(!container.canInteractWith(player),"Dying corpse remained accessible"); + } + private static void overflow() { + EntityLootableBody corpse=body();for(int i=6;i<48;i++)corpse.setInventorySlotContents(i,item(i,1));corpse.addItem(item(70,7)); + NBTTagCompound before=data(corpse);corpse.onEntityUpdate(); + RuntimeChecks.require(before.getTag("Equipment").equals(data(corpse).getTag("Equipment")),"Full inventory overwritten by overflow"); + corpse.removeStackFromSlot(47);corpse.onEntityUpdate();RuntimeChecks.require(ItemStack.areItemStacksEqual(corpse.getStackInSlot(47),item(70,7)),"Overflow did not fill free slot"); + EntityLootableBody large=body();large.addItem(item(19,100)); + Map expected=new TreeMap<>();add(expected,item(19,100)); + RuntimeChecks.require(expected.equals(inventory(large)),"Oversized incoming stack lost items"); + for(int i=6;i<48;i++) RuntimeChecks.require(large.getStackInSlot(i)==null || large.getStackInSlot(i).stackSize<=64,"Oversized input bypassed slot capacity"); + EntityLootableBody queued=body();for(int i=6;i<48;i++)queued.setInventorySlotContents(i,item(i,1)); + queued.addItem(item(70,100));queued.removeStackFromSlot(47);expected=inventory(queued); + queued.onEntityUpdate(); + RuntimeChecks.require(expected.equals(inventory(queued)) && queued.getStackInSlot(47).stackSize==64,"Overflow transfer lost items or exceeded slot capacity"); + queued.removeStackFromSlot(46);queued.onEntityUpdate(); + RuntimeChecks.require(ItemStack.areItemStacksEqual(queued.getStackInSlot(46),item(70,36)) && !data(queued).hasKey("Aux"),"Overflow remainder changed count or custom NBT"); + } + private static void twoViewers() { + EntityLootableBody corpse=body();corpse.setInventorySlotContents(6,item(19,23));EntityPlayerMP a=player(),b=player(); + CorpseContainer ca=new CorpseContainer(a.inventory,corpse),cb=new CorpseContainer(b.inventory,corpse); + RuntimeChecks.require(ItemStack.areItemStacksEqual(ca.transferStackInSlot(a,6),item(19,23)),"First recovery failed"); + RuntimeChecks.require(cb.transferStackInSlot(b,6)==null,"Second viewer recovered the same stack"); + Map all=inventory(corpse);for(ItemStack s:a.inventory.mainInventory)add(all,s);for(ItemStack s:b.inventory.mainInventory)add(all,s); + Map expected=new TreeMap<>();add(expected,item(19,23));RuntimeChecks.require(expected.equals(all),"Two viewer recovery changed inventory/NBT"); + } + private static void emptyValidation() { + EntityLootableBody corpse=body();EntityPlayerMP player=player();CorpseContainer container=new CorpseContainer(player.inventory,corpse); + RuntimeChecks.require(!corpse.isItemValidForSlot(0,null),"Empty armor input accepted"); + RuntimeChecks.require(!container.getSlot(0).isItemValid(null),"Container empty armor input accepted"); + } + private static net.minecraft.world.chunk.Chunk cleanChunk(EntityLootableBody corpse) { + net.minecraft.world.chunk.Chunk chunk=world.getChunkFromBlockCoords(corpse.getPosition()); + chunk.setLastSaveTime(world.getTotalWorldTime());chunk.setModified(false); + RuntimeChecks.require(!chunk.needsSaving(false),"Dirty-chunk test precondition"); + return chunk; + } + private static void dirtyInventory() { + EntityLootableBody corpse=body();corpse.setInventorySlotContents(6,item(19,23)); + RuntimeChecks.require(world.spawnEntity(corpse),"Dirty-chunk corpse spawn"); + net.minecraft.world.chunk.Chunk chunk=cleanChunk(corpse);corpse.markDirty(); + RuntimeChecks.require(chunk.needsSaving(false),"Inventory markDirty does not schedule a chunk save"); + } + private static void dirtyMutations() { + EntityLootableBody corpse=body();RuntimeChecks.require(world.spawnEntity(corpse),"Mutation corpse spawn"); + net.minecraft.world.chunk.Chunk chunk=cleanChunk(corpse);corpse.setInventorySlotContents(6,item(19,23)); + RuntimeChecks.require(chunk.needsSaving(false),"Slot insertion not scheduled for save"); + cleanChunk(corpse);corpse.decrStackSize(6,1);RuntimeChecks.require(chunk.needsSaving(false),"Extraction not scheduled for save"); + cleanChunk(corpse);corpse.removeStackFromSlot(6);RuntimeChecks.require(chunk.needsSaving(false),"Removal not scheduled for save"); + cleanChunk(corpse);corpse.initializeItems(new ItemStack[]{item(20,11)});RuntimeChecks.require(chunk.needsSaving(false),"Initial inventory not scheduled for save"); + for(int i=6;i<48;i++)corpse.setInventorySlotContents(i,item(i,1)); + cleanChunk(corpse);corpse.addItem(item(70,7));RuntimeChecks.require(chunk.needsSaving(false),"Overflow insertion not scheduled for save"); + } + static void seedBlockedRelease(WorldServer target,File folder) throws Exception { + EntityLootableBody corpse=new EntityLootableBody(target); + corpse.setUniqueId(UUID.fromString("00000000-0000-4000-8000-000000000105")); + corpse.setPosition(48.5,80,8.5);corpse.setNoGravity(true);corpse.setUserName("BlockedRelease"); + target.getChunkFromChunkCoords(3,0); + for(int i=0;i<48;i++)corpse.setInventorySlotContents(i,RuntimeChecks.stack(i)); + corpse.addItem(RuntimeChecks.stack(70));corpse.addItem(RuntimeChecks.stack(71));RuntimeChecks.disableVacuum(corpse); + RuntimeChecks.require(target.spawnEntity(corpse),"Blocked-release fixture spawn"); + Map expected=inventory(corpse); + RejectItems reject=new RejectItems();reject.rejectAll=true;MinecraftForge.EVENT_BUS.register(reject); + try {corpse.onKillCommand();for(int i=0;i<25;i++)corpse.onEntityUpdate();} + finally {MinecraftForge.EVENT_BUS.unregister(reject);} + RuntimeChecks.require(corpse.isEntityAlive() && target.loadedEntityList.contains(corpse) && expected.equals(inventory(corpse)),"Blocked-release fixture lost inventory/owner"); + RuntimeChecks.saveExpected(folder,"rejected-release",corpse); + } + /** Controlled false-return seam; no dimension registration, disk writes or chunks of its own. */ + private static final class RejectingWorld extends World { + private final WorldServer delegate; int attempts; + RejectingWorld(WorldServer target) { + super(target.getSaveHandler(),new WorldInfo(target.getWorldInfo()),new WorldProviderSurface(),new Profiler(),false); + delegate=target; + } + @Override protected IChunkProvider createChunkProvider(){return delegate.getChunkProvider();} + @Override protected boolean isChunkLoaded(int x,int z,boolean empty){return true;} + @Override public long getTotalWorldTime(){return delegate==null?0:delegate.getTotalWorldTime();} + @Override public boolean spawnEntity(Entity entity){attempts++;return false;} + } +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/MultiplayerChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/MultiplayerChecks.java new file mode 100644 index 0000000..b05c194 --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/MultiplayerChecks.java @@ -0,0 +1,120 @@ +package cyano.lootable.buildtest; + +import cyano.lootable.LootableBodies; +import cyano.lootable.entities.EntityLootableBody; +import io.netty.buffer.ByteBuf; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.UUID; +import net.minecraft.entity.player.EntityPlayerMP; +import net.minecraft.init.Blocks; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.*; +import net.minecraft.server.MinecraftServer; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.*; +import net.minecraftforge.fml.common.FMLCommonHandler; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; +import net.minecraftforge.fml.common.gameevent.TickEvent; +import net.minecraftforge.fml.common.network.NetworkRegistry; +import net.minecraftforge.fml.common.network.simpleimpl.*; +import net.minecraftforge.fml.relauncher.Side; + +/** Disposable two-client coordinator. Step messages acknowledge prior TCP clicks on the server thread. */ +public final class MultiplayerChecks { + public static final UUID BODY=UUID.fromString("00000000-0000-4000-8000-000000000108"); + public static final SimpleNetworkWrapper CHANNEL=NetworkRegistry.INSTANCE.newSimpleChannel("lootabletest"); + private final MinecraftServer server; + private final File sync; + private EntityLootableBody body; + private int phase,ticks; + private boolean finishing; + public static void registerChannel(){CHANNEL.registerMessage(StepHandler.class,Step.class,0,Side.SERVER);} + public MultiplayerChecks(MinecraftServer server) throws Exception { + this.server=server;sync=sync(); + LootableBodies.allowCorpseDecay=false;LootableBodies.ticksPerItemDecay=-1; + setPhase(0);Files.write(new File(sync,"server.ready").toPath(),"ready\n".getBytes(StandardCharsets.UTF_8)); + FMLCommonHandler.instance().bus().register(this); + } + public static File sync(){return new File(System.getProperty("lootablebodies.multiplayer.sync"));} + private EntityPlayerMP player(String role){return server.getPlayerList().getPlayerByUsername("Container"+role);} + private boolean ack(String role,int step){return new File(sync,"Container"+role+".step-"+step).isFile();} + @SubscribeEvent public void tick(TickEvent.ServerTickEvent e){ + if(e.phase!=TickEvent.Phase.END || finishing)return; + try{ + RuntimeChecks.require(++ticks<2400,"Two-client timeout phase="+phase); + if(new File(sync,"abort").exists())throw new AssertionError("Runner aborted multiplayer fixture"); + EntityPlayerMP a=player("A"),b=player("B"); + if(phase==0 && a!=null && b!=null){ + WorldServer w=server.worldServerForDimension(0); + for(int x=93;x<=100;x++)for(int z=93;z<=100;z++)w.setBlockState(new BlockPos(x,79,z),Blocks.STONE.getDefaultState()); + w.getGameRules().setOrCreateGameRule("doMobSpawning","false"); + body=new EntityLootableBody(w);body.setUniqueId(BODY);body.setPosition(96.5,80.5,96.5);body.setNoGravity(true);body.setUserName("SharedFixture");RuntimeChecks.disableVacuum(body); + body.setInventorySlotContents(6,stack(19,23));RuntimeChecks.require(w.spawnEntity(body),"Shared corpse spawn"); + a.setGameType(GameType.SURVIVAL);b.setGameType(GameType.SURVIVAL);a.inventory.clear();b.inventory.clear(); + a.connection.setPlayerLocation(96.5,80,94.5,0,0);b.connection.setPlayerLocation(98.5,80,96.5,0,0); + setPhase(1); + }else if(phase==1 && ack("A",1) && ack("B",1))setPhase(2); + else if(phase==2 && ack("A",2) && ack("B",2)){ + RuntimeChecks.require(body.getStackInSlot(6)==null && count(a,19)+count(b,19)==23,"Two TCP clients duplicated/lost contested stack"); + RuntimeChecks.require(count(a,19)==0 || count(b,19)==0,"Both clients recovered the same stack"); + mark("two_tcp_clients_contested_shift_click"); + a.inventory.clear();b.inventory.clear();body.clear(); + a.inventory.setItemStack(stack(19,10));b.inventory.mainInventory[0]=stack(20,7);refresh(a);refresh(b);setPhase(3); + }else if(phase==3 && ack("A",3))setPhase(4); + else if(phase==4 && ack("B",4)){ + equal(body.getStackInSlot(6),stack(20,7),"TCP hotbar swap failed");setPhase(5); + }else if(phase==5 && ack("A",5)){ + equal(body.getStackInSlot(6),stack(20,7),"TCP drag overwrote other player's NBT");equal(body.getStackInSlot(7),stack(19,5),"TCP drag allocation");equal(a.inventory.getItemStack(),stack(19,5),"TCP cursor remainder"); + mark("two_tcp_clients_hotbar_interleaved_with_drag");setPhase(6); + }else if(phase==6 && ack("A",6)){ + equal(a.inventory.getItemStack(),null,"TCP deposit cursor not empty");RuntimeChecks.require(count(a,19)==5,"TCP drag remainder deposit");setPhase(7); + }else if(phase==7 && ack("A",7) && ack("B",7)){ + File folder=server.worldServerForDimension(0).getSaveHandler().getWorldDirectory(); + RuntimeChecks.saveExpected(folder,"multiplayer-body",body);savePlayer(folder,a,"A");savePlayer(folder,b,"B"); + server.getPlayerList().saveAllPlayerData();server.saveAllWorlds(false);mark("two_tcp_clients_final_gui_and_saved_nbt"); + setPhase(8); + }else if(phase==8 && server.getPlayerList().getCurrentPlayerCount()==0){ + Files.write(new File(sync,"multiplayer.pass").toPath(),"two TCP clients passed\n".getBytes(StandardCharsets.UTF_8)); + System.out.println("LOOTABLE_BODIES_MULTIPLAYER_PASS");finishing=true;server.initiateShutdown(); + } + }catch(Throwable error){error.printStackTrace();finishing=true;server.initiateShutdown();} + } + private void setPhase(int value) throws Exception { + phase=value;File temporary=new File(sync,"phase.tmp");Files.write(temporary.toPath(),Integer.toString(value).getBytes(StandardCharsets.UTF_8)); + Files.move(temporary.toPath(),new File(sync,"phase").toPath(),StandardCopyOption.REPLACE_EXISTING,StandardCopyOption.ATOMIC_MOVE); + System.out.println("MULTIPLAYER_PHASE "+value); + } + private void mark(String name) throws Exception{try(FileWriter out=new FileWriter(new File(sync,"checks.txt"),true)){out.write("PASS "+name+"\n");}System.out.println("MULTIPLAYER_CHECK_PASS "+name);} + public static ItemStack stack(int i,int n){ItemStack s=RuntimeChecks.stack(i);s.stackSize=n;return s;} + public static void equal(ItemStack a,ItemStack b,String reason){RuntimeChecks.require(ItemStack.areItemStacksEqual(a,b),reason);} + private static int count(EntityPlayerMP p,int index){int n=0;ItemStack expected=RuntimeChecks.stack(index);for(int i=0;i { + public IMessage onMessage(Step message,MessageContext context){ + EntityPlayerMP player=context.getServerHandler().playerEntity; + player.getServerWorld().addScheduledTask(()->{ + try{RuntimeChecks.require(player.getName().matches("Container[AB]"),"Unexpected fixture player");Files.write(new File(sync(),player.getName()+".step-"+message.number).toPath(),"ack\n".getBytes(StandardCharsets.UTF_8));} + catch(Exception error){throw new RuntimeException(error);} + });return null; + } + } +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/MultiplayerClientChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/MultiplayerClientChecks.java new file mode 100644 index 0000000..5faa206 --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/MultiplayerClientChecks.java @@ -0,0 +1,79 @@ +package cyano.lootable.buildtest; + +import cyano.lootable.entities.EntityLootableBody; +import cyano.lootable.graphics.CorpseGUIContainer; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.HashSet; +import java.util.Set; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiMainMenu; +import net.minecraft.client.multiplayer.GuiConnecting; +import net.minecraft.entity.Entity; +import net.minecraft.inventory.ClickType; +import net.minecraft.util.EnumHand; +import net.minecraft.util.ScreenShotHelper; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.fml.common.FMLCommonHandler; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; +import net.minecraftforge.fml.common.gameevent.TickEvent; + +/** Two independent real Forge clients; no native desktop input or control. */ +final class MultiplayerClientChecks { + private final String role=System.getProperty("lootablebodies.multiplayer.role"); + private final Set completed=new HashSet<>(); + private boolean connecting; + private boolean finished; + private int lastPhase=-1,frames,ticks; + void register(){if(role!=null&&!Boolean.getBoolean("lootablebodies.visibility")&&!Boolean.getBoolean("lootablebodies.respawn")){FMLCommonHandler.instance().bus().register(this);MinecraftForge.EVENT_BUS.register(this);}} + @SubscribeEvent public void mouse(net.minecraftforge.client.event.GuiScreenEvent.MouseInputEvent.Pre e){e.setCanceled(true);} + @SubscribeEvent public void key(net.minecraftforge.client.event.GuiScreenEvent.KeyboardInputEvent.Pre e){e.setCanceled(true);} + @SubscribeEvent public void tick(TickEvent.ClientTickEvent e){ + if(e.phase!=TickEvent.Phase.END)return;Minecraft mc=Minecraft.getMinecraft(); + if(mc.inGameHasFocus)mc.setIngameNotInFocus();net.minecraft.client.settings.KeyBinding.unPressAllKeys(); + try{ + if(finished){mc.shutdown();return;} + RuntimeChecks.require(++ticks<6000,"Multiplayer client timeout role="+role+" phase="+lastPhase); + if(new File(MultiplayerChecks.sync(),"abort").exists())throw new AssertionError("Multiplayer runner abort"); + if(!connecting && mc.currentScreen instanceof GuiMainMenu){ + connecting=true;mc.gameSettings.pauseOnLostFocus=false;mc.gameSettings.limitFramerate=30;mc.gameSettings.renderDistanceChunks=4; + mc.displayGuiScreen(new GuiConnecting(new GuiMainMenu(),mc,"127.0.0.1",Integer.parseInt(System.getProperty("lootablebodies.multiplayer.port")))); + } + if(mc.world==null || mc.player==null)return; + int phase;try{phase=Integer.parseInt(new String(Files.readAllBytes(new File(MultiplayerChecks.sync(),"phase").toPath()),StandardCharsets.UTF_8).trim());}catch(IOException busy){return;} + if(phase!=lastPhase){lastPhase=phase;frames=0;System.out.println("TCP_CLIENT_PHASE "+role+" "+phase);}frames++; + if(phase==8){ + try(FileWriter out=new FileWriter(new File(mc.mcDataDir,"multiplayer-client.pass"))){out.write("two-client packet/GUI checks passed\n");} + System.out.println("LOOTABLE_BODIES_MULTIPLAYER_CLIENT_PASS role="+role); + finished=true;mc.player.closeScreen();mc.world.sendQuittingDisconnectingPacket();mc.loadWorld(null);mc.displayGuiScreen(new GuiMainMenu());return; + } + if(frames%100==0)System.out.println("TCP_CLIENT_STATE role="+role+" phase="+phase+" screen="+(mc.currentScreen==null?"world":mc.currentScreen.getClass().getSimpleName())+" container="+mc.player.openContainer.getClass().getSimpleName()+" window="+mc.player.openContainer.windowId+" position="+mc.player.getPosition()+" completed="+completed); + if(!(mc.currentScreen instanceof CorpseGUIContainer)){ + if(frames%10==0)for(Entity entity:mc.world.loadedEntityList)if(entity instanceof EntityLootableBody && entity.getUniqueID().equals(MultiplayerChecks.BODY))mc.playerController.interactWithEntity(mc.player,entity,null,EnumHand.MAIN_HAND); + return; + } + if(completed.contains(phase) || frames<15)return;int window=mc.player.openContainer.windowId; + if(phase==1){ + if(!net.minecraft.item.ItemStack.areItemStacksEqual(mc.player.openContainer.getSlot(6).getStack(),MultiplayerChecks.stack(19,23)))return; + shot(mc,"shared-ready.png");ack(1); + }else if(phase==2){mc.playerController.windowClick(window,6,0,ClickType.QUICK_MOVE,mc.player);ack(2);} + else if(phase==3 && role.equals("A")){ + if(!net.minecraft.item.ItemStack.areItemStacksEqual(mc.player.inventory.getItemStack(),MultiplayerChecks.stack(19,10)))return; + mc.playerController.windowClick(window,-999,0,ClickType.QUICK_CRAFT,mc.player);mc.playerController.windowClick(window,6,1,ClickType.QUICK_CRAFT,mc.player);mc.playerController.windowClick(window,7,1,ClickType.QUICK_CRAFT,mc.player);ack(3); + }else if(phase==4 && role.equals("B")){ + if(!net.minecraft.item.ItemStack.areItemStacksEqual(mc.player.inventory.mainInventory[0],MultiplayerChecks.stack(20,7)))return; + mc.playerController.windowClick(window,6,0,ClickType.SWAP,mc.player);ack(4); + }else if(phase==5 && role.equals("A")){mc.playerController.windowClick(window,-999,2,ClickType.QUICK_CRAFT,mc.player);ack(5);} + else if(phase==6 && role.equals("A")){ + MultiplayerChecks.equal(mc.player.inventory.getItemStack(),MultiplayerChecks.stack(19,5),"TCP client cursor prediction differs"); + mc.playerController.windowClick(window,48,0,ClickType.PICKUP,mc.player);ack(6); + }else if(phase==7){ + MultiplayerChecks.equal(mc.player.openContainer.getSlot(6).getStack(),MultiplayerChecks.stack(20,7),"Shared GUI slot 6 differs");MultiplayerChecks.equal(mc.player.openContainer.getSlot(7).getStack(),MultiplayerChecks.stack(19,5),"Shared GUI slot 7 differs"); + MultiplayerChecks.equal(mc.player.inventory.getItemStack(),null,"Shared GUI cursor differs");shot(mc,"shared-final.png");ack(7); + } + }catch(Throwable error){error.printStackTrace();mc.shutdown();} + } + private void ack(int phase){completed.add(phase);MultiplayerChecks.CHANNEL.sendToServer(new MultiplayerChecks.Step(phase));} + private void shot(Minecraft mc,String name){ScreenShotHelper.saveScreenshot(mc.mcDataDir,name,mc.displayWidth,mc.displayHeight,mc.getFramebuffer());} +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/NameLifetimeChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/NameLifetimeChecks.java new file mode 100644 index 0000000..bebd551 --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/NameLifetimeChecks.java @@ -0,0 +1,117 @@ +package cyano.lootable.buildtest; + +import com.mojang.authlib.GameProfile; +import cyano.lootable.LootableBodies; +import cyano.lootable.entities.EntityLootableBody; +import java.io.*; +import java.lang.reflect.Field; +import java.util.*; +import net.minecraft.entity.Entity; +import net.minecraft.init.Items; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.*; +import net.minecraft.server.MinecraftServer; +import net.minecraft.world.WorldServer; + +/** Real legacy-engine contracts for owned scalar fields; no production dependency. */ +final class NameLifetimeChecks { + static final String RECOVERY="LootableBodiesInvalidFields"; + private interface Check {void run() throws Exception;} + private static WorldServer world; + private static final List results=new ArrayList<>(); + private static int failures; + static void run(MinecraftServer server,WorldServer target,File folder) throws Exception { + world=target;results.clear();failures=0; + NBTBase[] wrong={new NBTTagString("not a timer"),new NBTTagFloat(1.5F),new NBTTagDouble(Double.NaN),new NBTTagDouble(Double.POSITIVE_INFINITY),new NBTTagCompound(),new NBTTagList(),new NBTTagByteArray(new byte[]{1}),new NBTTagIntArray(new int[]{1})}; + for(String key:new String[]{"Vac","DeathTime","DecayTime"}) { + for(int i=0;iinvalid(key,value));} + for(long value:new long[]{Long.MIN_VALUE,-1,Long.MAX_VALUE,10001})check(key+"_invalid_value_"+value,()->invalid(key,new NBTTagLong(value))); + } + for(int i=0;ibadName("Name",value));} + check("Name_over_packet_string_limit",()->badName("Name",new NBTTagString(repeat('n',32768)))); + check("Name_utf8_bytes_over_packet_limit",()->badName("Name",new NBTTagString(repeat('\u20ac',10923)))); + check("CustomName_utf8_bytes_over_packet_limit",()->badName("CustomName",new NBTTagString(repeat('\u20ac',10923)))); + check("name_exact_packet_byte_limit_preserved",NameLifetimeChecks::packetBoundary); + check("CustomName_wrong_type_retained",()->badName("CustomName",new NBTTagInt(27))); + check("CustomName_over_packet_string_limit",()->badName("CustomName",new NBTTagString(repeat('c',32768)))); + check("custom_name_fallback_and_source_immutable",NameLifetimeChecks::fallback); + check("valid_name_authoritative_without_username_regex",NameLifetimeChecks::namePriority); + check("save_before_first_tick_keeps_name",NameLifetimeChecks::earlySave); + check("missing_name_clears_reused_profile_and_name",NameLifetimeChecks::missingName); + check("reused_writer_clears_name_and_completed_vacuum",NameLifetimeChecks::reusedWriter); + check("reused_full_writer_does_not_restore_cleared_name",NameLifetimeChecks::clearedCustomName); + check("blank_name_keeps_legacy_unnamed_behavior",NameLifetimeChecks::blankName); + check("missing_timers_reset_reused_instance_to_now",NameLifetimeChecks::missingTimers); + check("custom_timestamp_not_vanilla_death_animation",NameLifetimeChecks::animation); + for(int i=0;i<4;i++){final int type=i;check("integral_scalar_type_"+i,()->integral(type));} + check("zero_timestamps_and_vacuum_are_valid",NameLifetimeChecks::zero); + check("vacuum_upper_bound_and_invalid_21",NameLifetimeChecks::vacuumLimit); + check("future_timestamp_does_not_delay_forever",NameLifetimeChecks::future); + check("clock_rollback_rebases_both_timers",NameLifetimeChecks::rollback); + check("negative_world_clock_cannot_overflow_age",NameLifetimeChecks::negativeClock); + check("large_catchup_does_not_wrap_damage_or_repeat",NameLifetimeChecks::largeCatchup); + check("long_max_elapsed_time_and_remainder",NameLifetimeChecks::maxCatchup); + check("long_interval_catchup_does_not_overflow",NameLifetimeChecks::longInterval); + check("strict_decay_boundary_preserved",NameLifetimeChecks::boundary); + check("nonempty_default_pauses_corpse_decay",NameLifetimeChecks::occupied); + check("disabled_decay_preserves_items_and_timestamps",NameLifetimeChecks::disabled); + check("invalid_metadata_does_not_count_as_inventory",NameLifetimeChecks::metadataOnly); + check("scalar_and_unresolved_inventory_conserved_on_reload",NameLifetimeChecks::inventory); + check("unknown_scalar_recovery_record_preserved",NameLifetimeChecks::unknownRecovery); + check("wrong_scalar_recovery_tag_preserved_idempotently",NameLifetimeChecks::wrongRecovery); + check("healthy_save_has_no_scalar_recovery_tag",NameLifetimeChecks::healthy); + try(PrintWriter out=new PrintWriter(new File(folder,"name-lifetime-checks.txt"),"UTF-8")){for(String s:results)out.println(s);out.println("checks="+results.size()+" failures="+failures);} + RuntimeChecks.require(failures==0,"Name/lifetime regressions: "+failures+" / "+results.size()); + System.out.println("LOOTABLE_BODIES_NAME_LIFETIME_PASS checks="+results.size()); + } + private static void check(String name,Check test) { + long clock=world.getTotalWorldTime(),rate=LootableBodies.ticksPerItemDecay,life=LootableBodies.corpseDecayTime; + boolean decay=LootableBodies.allowCorpseDecay,empty=LootableBodies.decayOnlyWhenEmpty; + Set before=new HashSet<>(world.loadedEntityList); + try{world.getWorldInfo().setWorldTotalTime(10000);LootableBodies.allowCorpseDecay=false;LootableBodies.ticksPerItemDecay=-1;test.run();results.add("PASS "+name);System.out.println("NAME_LIFETIME_PASS "+name);} + catch(Throwable e){failures++;results.add("FAIL "+name+": "+e);System.out.println("NAME_LIFETIME_FAIL "+name+": "+e);e.printStackTrace();} + finally{world.getWorldInfo().setWorldTotalTime(clock);LootableBodies.ticksPerItemDecay=rate;LootableBodies.corpseDecayTime=life;LootableBodies.allowCorpseDecay=decay;LootableBodies.decayOnlyWhenEmpty=empty;for(Entity e:new ArrayList(world.loadedEntityList))if(!before.contains(e))world.removeEntityDangerously(e);} + } + static NBTTagCompound base(){EntityLootableBody b=new EntityLootableBody(world);b.setPosition(160.5,80,160.5);b.setNoGravity(true);b.setCustomNameTag("Saved Owner");b.setInventorySlotContents(6,NbtRecoveryChecks.item(120,17));NBTTagCompound n=new NBTTagCompound();b.writeToNBTOptional(n);n.setString("Name","Saved Owner");n.setByte("Vac",(byte)20);return n;} + static EntityLootableBody read(NBTTagCompound n){NBTTagCompound copy=n.copy();EntityLootableBody b=new EntityLootableBody(world);b.readFromNBT(n);RuntimeChecks.require(same(n,copy),"Input NBT mutated");return b;} + static NBTTagCompound saved(EntityLootableBody b){NBTTagCompound n=new NBTTagCompound();b.writeToNBTOptional(n);return n;} + static void retained(EntityLootableBody b,String key,NBTBase raw){NBTTagList l=saved(b).getTagList(RECOVERY,10);boolean found=false;for(int i=0;i=0 && saved(b).getLong("DecayTime")>=0 && termination(b)<0,"Negative world clock caused invalid lifetime");} + private static EntityLootableBody tool(long now,long interval){world.getWorldInfo().setWorldTotalTime(now);EntityLootableBody b=timed(0);b.clear();ItemStack tool=new ItemStack(Items.DIAMOND_PICKAXE);tool.setItemDamage(17);b.setInventorySlotContents(6,tool);LootableBodies.ticksPerItemDecay=interval;return b;} + private static void catchup(long now,long interval){EntityLootableBody b=tool(now,interval);b.onEntityUpdate();ItemStack s=b.getStackInSlot(6);RuntimeChecks.require(s!=null && s.stackSize==1 && s.getItemDamage()==s.getMaxDamage()-1,"Catchup overflow repaired, erased or under-decayed tool");long stamp=now-now%interval;RuntimeChecks.require(saved(b).getLong("DecayTime")==stamp,"Catchup timestamp wrapped or lost remainder");b.onEntityUpdate();RuntimeChecks.require(saved(b).getLong("DecayTime")==stamp && s.getItemDamage()==s.getMaxDamage()-1,"Catchup repeated");} + private static void largeCatchup(){catchup((1L<<31)+1,1);} + private static void maxCatchup(){catchup(Long.MAX_VALUE,6000);} + private static void longInterval(){EntityLootableBody b=tool(Long.MAX_VALUE,Long.MAX_VALUE/2);b.onEntityUpdate();RuntimeChecks.require(saved(b).getLong("DecayTime")==Long.MAX_VALUE-1 && b.getStackInSlot(6).getItemDamage()==19,"Wide interval arithmetic changed");} + private static void boundary(){EntityLootableBody b=tool(10,10);LootableBodies.allowCorpseDecay=true;LootableBodies.decayOnlyWhenEmpty=false;LootableBodies.corpseDecayTime=10;b.onEntityUpdate();RuntimeChecks.require(b.getStackInSlot(6).getItemDamage()==17 && termination(b)<0,"Strict > boundary changed");world.getWorldInfo().setWorldTotalTime(11);b.onEntityUpdate();RuntimeChecks.require(b.getStackInSlot(6).getItemDamage()==18 && termination(b)>=0,"Elapsed threshold ignored");} + private static void occupied(){EntityLootableBody b=timed(0);LootableBodies.allowCorpseDecay=true;LootableBodies.decayOnlyWhenEmpty=true;LootableBodies.corpseDecayTime=40;b.onEntityUpdate();RuntimeChecks.require(saved(b).getLong("DeathTime")==10000 && termination(b)<0,"Occupied default no longer pauses expiration");} + private static void disabled(){EntityLootableBody b=tool(Long.MAX_VALUE,-1);b.onEntityUpdate();RuntimeChecks.require(b.getStackInSlot(6).getItemDamage()==17 && saved(b).getLong("DecayTime")==0 && saved(b).getLong("DeathTime")==0,"Disabled timer changed state");} + private static int termination(EntityLootableBody b){try{Field f=EntityLootableBody.class.getDeclaredField("terminate");f.setAccessible(true);return f.getInt(b);}catch(Exception e){throw new AssertionError(e);}} + private static void metadataOnly(){NBTTagCompound n=base();n.setTag("Equipment",new NBTTagList());n.setString("DeathTime","opaque");EntityLootableBody b=read(n);RuntimeChecks.require(b.isEmpty(),"Scalar evidence counted as inventory");LootableBodies.allowCorpseDecay=true;LootableBodies.decayOnlyWhenEmpty=true;LootableBodies.corpseDecayTime=40;world.getWorldInfo().setWorldTotalTime(10041);b.onEntityUpdate();RuntimeChecks.require(termination(b)>=0,"Scalar evidence made empty corpse immortal");} + private static void inventory(){NBTTagCompound n=base();NBTTagCompound missing=NbtRecoveryChecks.entry(121,11,7);missing.setString("id","absentfixture:missing");n.getTagList("Equipment",10).appendTag(missing);n.setString("DeathTime","bad");n.setTag("Name",new NBTTagCompound());EntityLootableBody b=read(n);NbtRecoveryChecks.retained(b,missing);stable(b);b.readFromNBT(saved(b));stable(b);RuntimeChecks.require(saved(b).getTagList(RECOVERY,10).tagCount()==2 && saved(b).getTagList(NbtRecoveryChecks.RECOVERY,10).tagCount()==1,"Raw scalar/item evidence accumulated or disappeared");} + private static void unknownRecovery(){NBTTagCompound n=base(),record=new NBTTagCompound();record.setString("Future","opaque");n.setTag(RECOVERY,NbtRecoveryChecks.list(record));EntityLootableBody b=read(n);RuntimeChecks.require(saved(b).getTagList(RECOVERY,10).getCompoundTagAt(0).equals(record),"Unknown metadata evidence changed");stable(b);} + private static void wrongRecovery(){NBTTagCompound n=base();NBTTagString raw=new NBTTagString("opaque");n.setTag(RECOVERY,raw);EntityLootableBody b=read(n);retained(b,"Recovery",raw);stable(b);} + private static void healthy(){EntityLootableBody b=read(base());b.onEntityUpdate();stable(b);RuntimeChecks.require(!saved(b).hasKey(RECOVERY),"Healthy save gained invalid-field data");} + // NBTTagDouble.equals uses ==, so NaN copies are unequal without any mutation. + static boolean same(NBTBase a,NBTBase b){if(a==null || b==null)return a==b;return Arrays.equals(encoded(a),encoded(b));} + private static byte[] encoded(NBTBase value){try{NBTTagCompound n=new NBTTagCompound();n.setTag("Value",value);ByteArrayOutputStream out=new ByteArrayOutputStream();CompressedStreamTools.write(n,new DataOutputStream(out));return out.toByteArray();}catch(IOException e){throw new AssertionError(e);}} + private static String repeat(char c,int size){char[] a=new char[size];Arrays.fill(a,c);return new String(a);} +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/NbtClientChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/NbtClientChecks.java new file mode 100644 index 0000000..175cdb6 --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/NbtClientChecks.java @@ -0,0 +1,94 @@ +package cyano.lootable.buildtest; + +import cyano.lootable.entities.EntityLootableBody; +import cyano.lootable.graphics.CorpseGUIContainer; +import java.io.*; +import java.util.*; +import java.util.concurrent.Future; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiMainMenu; +import net.minecraft.entity.Entity; +import net.minecraft.entity.player.EntityPlayerMP; +import net.minecraft.init.Blocks; +import net.minecraft.inventory.ClickType; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.*; +import net.minecraft.util.EnumHand; +import net.minecraft.util.ScreenShotHelper; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.GameType; + +/** Real GUI recovery after loading malformed NBT, including unresolved data across another save. */ +final class NbtClientChecks { + private interface Action {void run() throws Exception;} + private final UUID bodyId=UUID.fromString("00000000-0000-4000-8000-000000000121"); + private UUID playerId; + private int stage,frames; + private Future pending; + private NBTTagCompound visible; + private Map before; + private NBTTagList expectedPlayer; + void start(Minecraft mc){playerId=mc.player.getUniqueID();} + boolean tick(Minecraft mc) throws Exception { + frames++;RuntimeChecks.require(frames<300,"NBT graphical stage timeout "+stage); + if(stage==0){schedule(mc,()->{ + EntityPlayerMP p=player(mc);p.inventory.clear();p.inventory.setItemStack(null);p.setGameType(GameType.SURVIVAL); + for(int x=157;x<=171;x++)for(int z=157;z<=163;z++)p.world.setBlockState(new BlockPos(x,79,z),Blocks.STONE.getDefaultState()); + NbtWorldChecks.seed(p.getServerWorld(),bodyId,160,folder(mc),"graphical"); + });next(1);} + else if(stage==1 && done() && frames>20){quit(mc);next(2);} + else if(stage==2 && mc.world==null && !mc.isIntegratedServerRunning()){mc.launchIntegratedServer("build-fixture","Build fixture",null);next(3);} + else if(stage==3 && ready(mc) && frames>20){schedule(mc,()->player(mc).connection.setPlayerLocation(168.5,80,159.5,0,0));next(30);} + else if(stage==30 && done() && frames>20){schedule(mc,()->{ + EntityLootableBody b=body(mc);for(int i=0;i<8;i++)b.onEntityUpdate();NbtWorldChecks.verify(b,false); + before=NbtRecoveryChecks.items(b);visible=full(b);RuntimeChecks.saveExpected(folder(mc),"nbt-client-before",b); + });next(4);} + else if(stage==4 && done() && frames>20){if(open(mc))next(5);} + else if(stage==5 && gui(mc) && frames>20){verifyGui(mc);equal(mc.player.openContainer.getSlot(6).getStack(),NbtRecoveryChecks.item(120,17),"Malformed GUI recovery source differs");screenshot(mc,"nbt-recovered-corpse.png");mark(mc,"malformed_disk_load_gui_exact_inventory");mc.playerController.windowClick(mc.player.openContainer.windowId,6,0,ClickType.QUICK_MOVE,mc.player);next(6);} + else if(stage==6 && frames>25){schedule(mc,()->{ + Map after=NbtRecoveryChecks.items(body(mc));for(int i=0;i20){schedule(mc,()->player(mc).connection.setPlayerLocation(168.5,80,159.5,0,0));next(90);} + else if(stage==90 && done() && frames>20){schedule(mc,()->{ + body(mc).onEntityUpdate();RuntimeChecks.verifyExpected(folder(mc),"nbt-client-after",body(mc)); + RuntimeChecks.require(Objects.equals(visible.getTag(NbtRecoveryChecks.RECOVERY),NbtRecoveryChecks.saved(body(mc)).getTag(NbtRecoveryChecks.RECOVERY)),"Unresolved raw data changed on graphical disk reload"); + NbtWorldChecks.verifyMetadata(body(mc));RuntimeChecks.require(NameLifetimeChecks.same(visible.getTag(NameLifetimeChecks.RECOVERY),full(body(mc)).getTag(NameLifetimeChecks.RECOVERY)),"Raw scalar evidence changed on graphical reload"); + RuntimeChecks.require(expectedPlayer.equals(player(mc).inventory.writeToNBT(new NBTTagList())),"Recovered player NBT changed on graphical disk reload");checkPlayer(player(mc).inventory);mark(mc,"malformed_corpse_and_player_disk_reload"); + });next(10);} + else if(stage==10 && done() && frames>20){if(open(mc))next(11);} + else if(stage==11 && gui(mc) && frames>20){verifyGui(mc);checkPlayer(mc.player.inventory);screenshot(mc,"nbt-after-recovery-reload.png");System.out.println("LOOTABLE_BODIES_NBT_CLIENT_PASS");return true;} + if((stage==4 || stage==5 || stage==10 || stage==11) && !gui(mc) && frames%10==0) { + open(mc); + if(frames==60) { + System.out.println("NBT_GUI_CLIENT_STATE position="+mc.player.getPosition()+" container="+mc.player.openContainer.getClass().getSimpleName()); + for(Entity entity:mc.world.loadedEntityList)if(entity instanceof EntityLootableBody)System.out.println("NBT_GUI_CLIENT_BODY uuid="+entity.getUniqueID()+" position="+entity.getPosition()+" id="+entity.getEntityId()); + schedule(mc,()->System.out.println("NBT_GUI_SERVER_STATE player="+player(mc).getPosition()+" body="+body(mc).getPosition()+" usable="+body(mc).isUsableByPlayer(player(mc))+" alive="+body(mc).isEntityAlive()+" health="+body(mc).getHealth()+" container="+player(mc).openContainer.getClass().getSimpleName())); + } + } + return false; + } + private void verifyGui(Minecraft mc){ItemStack[] expected=new ItemStack[48];NBTTagList list=visible.getTagList("Equipment",10);for(int i=0;i actual=new TreeMap<>();for(int i=0;i expected=new TreeMap<>();add(expected,NbtRecoveryChecks.item(120,17));RuntimeChecks.require(actual.equals(expected),"Malformed GUI recovered player stack changed");} + private static void add(Map map,ItemStack s){if(s==null || s.stackSize<=0)return;NBTTagCompound n=s.writeToNBT(new NBTTagCompound());n.removeTag("Count");String key=n.toString();map.put(key,map.getOrDefault(key,0)+s.stackSize);} + private static void equal(ItemStack a,ItemStack b,String why){RuntimeChecks.require(ItemStack.areItemStacksEqual(a,b),why);} + private static NBTTagCompound full(EntityLootableBody b){NBTTagCompound n=new NBTTagCompound();b.writeToNBTOptional(n);return n;} + private EntityPlayerMP player(Minecraft mc){return mc.getIntegratedServer().getPlayerList().getPlayerByUUID(playerId);} + private EntityLootableBody body(Minecraft mc){return RuntimeChecks.find(mc.getIntegratedServer().worldServerForDimension(0),bodyId);} + private File folder(Minecraft mc){return player(mc).world.getSaveHandler().getWorldDirectory();} + private static boolean ready(Minecraft mc){return mc.world!=null && mc.player!=null && mc.getIntegratedServer()!=null;} + private static boolean gui(Minecraft mc){return mc.currentScreen instanceof CorpseGUIContainer;} + private boolean open(Minecraft mc){for(Entity e:mc.world.loadedEntityList)if(e.getUniqueID().equals(bodyId)){RuntimeChecks.require(e.getCustomNameTag().equals("NbtFixture"),"Malformed name reached tracked client entity");RuntimeChecks.require(e instanceof EntityLootableBody && Float.isFinite(((EntityLootableBody)e).getHealth()) && ((EntityLootableBody)e).getHealth()>0,"Malformed health reached tracked client entity");RuntimeChecks.require(((EntityLootableBody)e).getActivePotionEffects().isEmpty() && ((EntityLootableBody)e).hurtTime==0,"Malformed effect/hurt state reached tracked client entity");PositionMotionChecks.finite((EntityLootableBody)e);RotationFallChecks.finite((EntityLootableBody)e);mc.playerController.interactWithEntity(mc.player,e,null,EnumHand.MAIN_HAND);return true;}return false;} + private void schedule(Minecraft mc,Action action){pending=mc.getIntegratedServer().addScheduledTask(()->{try{action.run();}catch(Exception e){throw new RuntimeException(e);}});} + private boolean done() throws Exception{if(!pending.isDone())return false;pending.get();return true;} + private void next(int value){stage=value;frames=0;System.out.println("NBT_CLIENT_STAGE "+stage);} + private static void screenshot(Minecraft mc,String name){ScreenShotHelper.saveScreenshot(mc.mcDataDir,name,mc.displayWidth,mc.displayHeight,mc.getFramebuffer());} + private static void quit(Minecraft mc){mc.player.closeScreen();mc.getIntegratedServer().initiateShutdown();} + private static void mark(Minecraft mc,String name) throws Exception{try(FileWriter out=new FileWriter(new File(mc.mcDataDir,"nbt-client-checks.txt"),true)){out.write("PASS "+name+"\n");}System.out.println("NBT_CLIENT_CHECK_PASS "+name);} +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/NbtRecoveryChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/NbtRecoveryChecks.java new file mode 100644 index 0000000..6597ecd --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/NbtRecoveryChecks.java @@ -0,0 +1,120 @@ +package cyano.lootable.buildtest; + +import cyano.lootable.LootableBodies; +import cyano.lootable.entities.EntityLootableBody; +import java.io.*; +import java.util.*; +import net.minecraft.entity.Entity; +import net.minecraft.entity.item.EntityItem; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.*; +import net.minecraft.server.MinecraftServer; +import net.minecraft.world.WorldServer; + +/** Malformed data is never permission to erase the surrounding inventory. */ +final class NbtRecoveryChecks { + static final String RECOVERY="LootableBodiesUnresolved"; + private interface Check {void run() throws Exception;} + private static WorldServer world; + private static int failures; + private static final List results=new ArrayList<>(); + static void run(MinecraftServer server,WorldServer target,File folder) throws Exception { + world=target;failures=0;results.clear(); + check("duplicate_distinct_stacks_keep_first_and_retain_second",()->duplicate(false)); + check("duplicate_identical_stacks_are_not_deduplicated",()->duplicate(true)); + check("missing_duplicate_does_not_erase_known_stack",NbtRecoveryChecks::missingDuplicate); + for(int slot:new int[]{-1,48,127,256}) {final int s=slot;check("invalid_slot_"+s,()->invalidSlot(new NBTTagInt(s)));} + check("missing_slot_does_not_default_to_helmet",()->invalidSlot(null)); + check("string_slot_is_not_coerced_to_zero",()->invalidSlot(new NBTTagString("7"))); + check("fractional_slot_is_not_truncated",()->invalidSlot(new NBTTagFloat(6.5F))); + check("long_slot_is_not_narrowed",()->invalidSlot(new NBTTagLong(4294967302L))); + check("valid_wide_slot_is_read_without_byte_wrap",NbtRecoveryChecks::validWideSlot); + check("missing_equipment_item_raw_retained",()->unreadable("Equipment","id",new NBTTagString("absentfixture:missing"))); + check("missing_aux_item_raw_retained",()->unreadable("Aux","id",new NBTTagString("absentfixture:missing"))); + check("air_item_is_not_a_replacement",()->unreadable("Equipment","id",new NBTTagString("minecraft:air"))); + check("numeric_item_id_not_guessed",()->unreadable("Equipment","id",new NBTTagShort((short)264))); + check("missing_count_retained_raw",()->unreadable("Equipment","Count",null)); + check("zero_count_retained_raw",()->unreadable("Equipment","Count",new NBTTagByte((byte)0))); + check("negative_count_retained_raw",()->unreadable("Equipment","Count",new NBTTagByte((byte)-1))); + check("wide_count_does_not_wrap_to_zero",()->unreadable("Equipment","Count",new NBTTagInt(256))); + check("string_count_retained_raw",()->unreadable("Equipment","Count",new NBTTagString("17"))); + check("fractional_count_retained_raw",()->unreadable("Equipment","Count",new NBTTagFloat(17.5F))); + check("valid_integer_count_preserved",NbtRecoveryChecks::integerCount); + check("wrong_damage_type_retained_raw",()->unreadable("Equipment","Damage",new NBTTagString("17"))); + check("negative_damage_not_reset",()->unreadable("Equipment","Damage",new NBTTagShort((short)-1))); + check("wide_damage_not_narrowed",()->unreadable("Equipment","Damage",new NBTTagInt(32768))); + check("wrong_custom_tag_type_retained_raw",()->unreadable("Equipment","tag",new NBTTagString("opaque"))); + check("unknown_capability_payload_retained_raw",NbtRecoveryChecks::unknownCaps); + check("unknown_stack_root_data_retained_raw",()->unreadable("Equipment","UnknownPayload",new NBTTagString("retain verbatim"))); + check("wrong_equipment_list_type_retained",()->wrongList("Equipment")); + check("wrong_aux_list_type_retained",()->wrongList("Aux")); + check("empty_aux_record_retained_without_null_queue",NbtRecoveryChecks::emptyAux); + check("repeated_read_replaces_unresolved_without_growth",NbtRecoveryChecks::repeated); + check("clear_and_reused_writer_do_not_resurrect_old_data",NbtRecoveryChecks::clear); + check("normal_save_layout_round_trip_unchanged",NbtRecoveryChecks::normal); + check("oversized_stack_split_without_count_loss",NbtRecoveryChecks::oversized); + check("invalid_armor_item_recoverable_in_overflow",NbtRecoveryChecks::invalidArmor); + check("raw_only_body_not_empty_or_removed",NbtRecoveryChecks::rawOwner); + check("partial_release_keeps_raw_owner_without_duplicate_drops",NbtRecoveryChecks::partialRelease); + check("read_is_detached_from_saved_input",NbtRecoveryChecks::detached); + check("retry_returned_item_at_original_free_slot",()->retry(false)); + check("retry_returned_item_never_overwrites_occupied_slot",()->retry(true)); + check("unknown_recovery_record_preserved_verbatim",NbtRecoveryChecks::futureRecord); + check("wrong_recovery_tag_retained_idempotently",NbtRecoveryChecks::wrongRecovery); + check("malformed_first_record_does_not_hide_valid_tail",NbtRecoveryChecks::tail); + check("valid_aux_queue_order_preserved",NbtRecoveryChecks::auxOrder); + check("entity_factory_keeps_malformed_corpse_and_identity",NbtRecoveryChecks::factory); + check("custom_equipment_authoritative_over_vanilla_mirrors",NbtRecoveryChecks::mirrors); + check("opaque_equipment_keeps_recoverable_vanilla_mirror",NbtRecoveryChecks::mirrorFallback); + check("missing_damage_keeps_vanilla_zero_default",NbtRecoveryChecks::missingDamage); + check("oversized_aux_split_without_loss",NbtRecoveryChecks::oversizedAux); + try(PrintWriter out=new PrintWriter(new File(folder,"nbt-checks.txt"),"UTF-8")) {for(String s:results)out.println(s);out.println("checks="+results.size()+" failures="+failures);} + RuntimeChecks.require(failures==0,"NBT recovery regressions: "+failures+" / "+results.size());System.out.println("LOOTABLE_BODIES_NBT_PASS checks="+results.size()); + } + private static void check(String name,Check test){ + Set before=new HashSet<>(world.loadedEntityList); + try{test.run();results.add("PASS "+name);System.out.println("NBT_CHECK_PASS "+name);} + catch(Throwable e){failures++;results.add("FAIL "+name+": "+e);System.out.println("NBT_CHECK_FAIL "+name+": "+e);e.printStackTrace();} + finally{for(Entity e:new ArrayList(world.loadedEntityList))if(!before.contains(e))world.removeEntityDangerously(e);} + } + static ItemStack item(int index,int count){ItemStack s=RuntimeChecks.stack(index);s.stackSize=count;return s;} + static NBTTagCompound entry(int index,int count,int slot){NBTTagCompound n=item(index,count).writeToNBT(new NBTTagCompound());n.setByte("Slot",(byte)slot);return n;} + static NBTTagList list(NBTTagCompound... entries){NBTTagList l=new NBTTagList();for(NBTTagCompound n:entries)l.appendTag(n.copy());return l;} + static NBTTagCompound root(NBTTagCompound... entries){NBTTagCompound n=new NBTTagCompound();n.setTag("Equipment",list(entries));n.setByte("Vac",(byte)20);return n;} + static NBTTagCompound saved(EntityLootableBody b){NBTTagCompound n=new NBTTagCompound();b.writeEntityToNBT(n);return n;} + static EntityLootableBody read(NBTTagCompound n){NBTTagCompound copy=n.copy();EntityLootableBody b=new EntityLootableBody(world);b.readEntityFromNBT(n);RuntimeChecks.require(n.equals(copy),"Reader modified source NBT");return b;} + private static void equal(ItemStack a,ItemStack b,String why){RuntimeChecks.require(ItemStack.areItemStacksEqual(a,b),why);} + private static void add(Map sums,ItemStack s){if(s==null || s.stackSize<=0)return;NBTTagCompound n=s.writeToNBT(new NBTTagCompound());n.removeTag("Count");String k=n.toString();sums.put(k,sums.getOrDefault(k,0)+s.stackSize);} + static Map items(EntityLootableBody b){Map sums=new TreeMap<>();for(int i=0;i<48;i++)add(sums,b.getStackInSlot(i));NBTTagList aux=saved(b).getTagList("Aux",10);for(int i=0;i sums=new TreeMap<>();for(ItemStack s:expected)add(sums,s);RuntimeChecks.require(items(b).equals(sums),"Recoverable item/count/damage/full NBT changed");} + static void retained(EntityLootableBody b,NBTBase raw){NBTTagList records=saved(b).getTagList(RECOVERY,10);boolean found=false;for(int i=0;i>4,coordinate>>4);RawBody b=new RawBody(w);b.setUniqueId(id);b.setPosition(coordinate+.5,80,coordinate+.5);b.setNoGravity(true);b.setUserName("NbtFixture");b.setRotation(37);RuntimeChecks.disableVacuum(b); + for(int i=0;i<48;i++)b.setInventorySlotContents(i,known(i));RuntimeChecks.require(w.spawnEntity(b),"Raw fixture spawn"); + NBTTagCompound n=new NBTTagCompound();RuntimeChecks.require(b.writeToNBTOptional(n),"Raw fixture serialization"); + try(FileOutputStream out=new FileOutputStream(new File(folder,"raw-"+name+".nbt"))){CompressedStreamTools.writeCompressed(n,out);} + } + private static ItemStack known(int i){return i==6?NbtRecoveryChecks.item(120,17):NbtRecoveryChecks.item(i,i<6?1:64);} + private static NBTTagCompound rawEquipment(){ + NBTTagCompound n=new NBTTagCompound();NBTTagList equipment=new NBTTagList(); + for(int i=0;i<48;i++) { + NBTTagCompound entry=known(i).writeToNBT(new NBTTagCompound());entry.setByte("Slot",(byte)i); + if(i==7){entry=NbtRecoveryChecks.entry(123,9,7);entry.setString("id",RETURNING);} + if(i==8)entry=missing(124,5,8); + if(i==9){entry=NbtRecoveryChecks.entry(125,1,9);entry.setByte("Count",(byte)0);} + if(i==10){entry=NbtRecoveryChecks.entry(129,6,10);NBTTagCompound caps=new NBTTagCompound();caps.setString("absentfixture:inventory","opaque capabilities");entry.setTag("ForgeCaps",caps);} + equipment.appendTag(entry); + } + equipment.appendTag(NbtRecoveryChecks.entry(121,11,6));NBTTagCompound bad=NbtRecoveryChecks.entry(122,13,6);bad.setInteger("Slot",999);equipment.appendTag(bad);n.setTag("Equipment",equipment); + n.setTag("Aux",NbtRecoveryChecks.list(NbtRecoveryChecks.entry(126,7,0),missing(127,3,0),NbtRecoveryChecks.entry(128,2,0),NbtRecoveryChecks.entry(130,19,0),NbtRecoveryChecks.entry(131,23,0))); + n.setInteger("Vac",256); + n.setLong("DeathTime",Long.MIN_VALUE); + n.setDouble("DecayTime",Double.NaN); + NBTTagCompound name=new NBTTagCompound();name.setString("Opaque","saved malformed attribution");n.setTag("Name",name); + NBTTagCompound health=HealthAttributeChecks.malformedFields();for(String key:health.getKeySet())n.setTag(key,health.getTag(key).copy()); + NBTTagCompound effects=EffectHurtChecks.malformedFields();for(String key:effects.getKeySet())n.setTag(key,effects.getTag(key).copy()); + NBTTagCompound rotation=RotationFallChecks.malformedFields();for(String key:rotation.getKeySet())n.setTag(key,rotation.getTag(key).copy()); + return n; + } + private static NBTTagCompound missing(int index,int count,int slot){NBTTagCompound n=NbtRecoveryChecks.entry(index,count,slot);n.setString("id","absentfixture:missing");return n;} + private static final class RawBody extends EntityLootableBody { + RawBody(World w){super(w);} + @Override public boolean writeToNBTOptional(NBTTagCompound n){if(isDead)return false;n.setString("id","lootablebodies.Corpse");writeToNBT(n);return true;} + @Override public void writeEntityToNBT(NBTTagCompound n){super.writeEntityToNBT(n);NBTTagCompound raw=rawEquipment();for(String key:raw.getKeySet())n.setTag(key,raw.getTag(key).copy());n.setTag("Pos",PositionMotionChecks.vector(Double.NaN,80,posZ));n.setTag("Motion",PositionMotionChecks.vector(Double.NaN,Double.POSITIVE_INFINITY,Double.NEGATIVE_INFINITY));} + } + private static void settle(EntityLootableBody b){for(int i=0;i<8;i++)b.onEntityUpdate();} + private static void identity(EntityLootableBody b,UUID id,int coordinate){RuntimeChecks.require(b.isEntityAlive() && b.getUniqueID().equals(id) && b.posX==coordinate+8.5 && b.posY==80 && b.posZ==coordinate+.5 && b.rotationYaw==37 && b.getCustomNameTag().equals("NbtFixture"),"Recovered corpse identity/position/rotation/name/health changed");} + private static void add(Map totals,ItemStack s){NBTTagCompound n=s.writeToNBT(new NBTTagCompound());n.removeTag("Count");String key=n.toString();totals.put(key,totals.getOrDefault(key,0)+s.stackSize);} + static void verify(EntityLootableBody b,boolean restored){ + verifyMetadata(b); + Map expected=new TreeMap<>();for(int i=0;i<48;i++)if(i<7 || i>10)add(expected,known(i)); + for(int[] spec:new int[][]{{121,11},{122,13},{126,7},{128,2},{130,19},{131,23}})add(expected,NbtRecoveryChecks.item(spec[0],spec[1])); + if(restored){NBTTagCompound raw=NbtRecoveryChecks.entry(123,9,7);raw.setString("id",RETURNING);ItemStack s=ItemStack.loadItemStackFromNBT(raw);RuntimeChecks.require(s!=null && s.getItem()==returning,"Returning fixture item not registered");add(expected,s);} + RuntimeChecks.require(expected.equals(NbtRecoveryChecks.items(b)),"Disk recovery item/count/damage/custom NBT lost or duplicated"); + NBTTagCompound source=rawEquipment();int rawCount=0; + for(String list:new String[]{"Equipment","Aux"}){NBTTagList entries=source.getTagList(list,10);for(int i=0;i=0 && saved.getLong("DeathTime")<=b.world.getTotalWorldTime() && saved.getLong("DecayTime")>=0 && saved.getLong("DecayTime")<=b.world.getTotalWorldTime(),"Malformed disk timers entered active state"); + for(String key:new String[]{"Name","Vac","DeathTime","DecayTime"})NameLifetimeChecks.retained(b,key,raw.getTag(key)); + RuntimeChecks.require(saved.getTagList(NameLifetimeChecks.RECOVERY,10).tagCount()==16,"Disk metadata evidence grew or disappeared"); + } + private static void snapshot(File folder,String name,EntityLootableBody b) throws Exception {RuntimeChecks.saveExpected(folder,"nbt-"+name,b);} + private static void verifySnapshot(File folder,String name,EntityLootableBody b) throws Exception { + b.onEntityUpdate();RuntimeChecks.verifyExpected(folder,"nbt-"+name,b);NBTTagCompound expected;try(FileInputStream in=new FileInputStream(new File(folder,"nbt-"+name+".nbt"))){expected=CompressedStreamTools.readCompressed(in);} + RuntimeChecks.require(Objects.equals(expected.getTag(NbtRecoveryChecks.RECOVERY),NbtRecoveryChecks.saved(b).getTag(NbtRecoveryChecks.RECOVERY)),"Raw recovery NBT changed on actual disk reload"); + RuntimeChecks.require(NameLifetimeChecks.same(expected.getTag(NameLifetimeChecks.RECOVERY),NbtRecoveryChecks.saved(b).getTag(NameLifetimeChecks.RECOVERY)),"Raw scalar data changed on actual disk reload"); + } +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/PositionMotionChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/PositionMotionChecks.java new file mode 100644 index 0000000..e847e99 --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/PositionMotionChecks.java @@ -0,0 +1,82 @@ +package cyano.lootable.buildtest; + +import cyano.lootable.entities.EntityLootableBody; +import java.io.*; +import java.util.*; +import net.minecraft.entity.*; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.*; +import net.minecraft.server.MinecraftServer; +import net.minecraft.util.math.AxisAlignedBB; +import net.minecraft.world.WorldServer; +import net.minecraft.world.chunk.Chunk; +import net.minecraft.world.chunk.storage.AnvilChunkLoader; + +/** Validate before vanilla builds geometry, then exercise actual chunk admission. */ +final class PositionMotionChecks { + private interface Check {void run() throws Exception;} + private static WorldServer world; + private static int failures; + private static final List results=new ArrayList<>(); + static void run(MinecraftServer server,WorldServer target,File folder) throws Exception { + world=target;failures=0;results.clear(); + NBTBase[] wrong={new NBTTagString("opaque"),new NBTTagCompound(),new NBTTagByteArray(new byte[]{1,2,3}),new NBTTagIntArray(new int[]{1,2,3}),new NBTTagDouble(Double.NaN)}; + for(String key:new String[]{"Pos","Motion"}) { + for(int i=0;ibadVector(key,raw));} + for(NBTBase raw:new NBTBase[]{new NBTTagString("opaque"),new NBTTagCompound(),new NBTTagByteArray(new byte[]{1})})check(key+"_wrong_element_type_"+raw.getId(),()->{NBTTagList list=new NBTTagList();for(int i=0;i<3;i++)list.appendTag(raw.copy());badVector(key,list);}); + for(int count:new int[]{0,1,2,4})check(key+"_wrong_length_"+count,()->length(key,count)); + for(int axis=0;axis<3;axis++)for(double v:new double[]{Double.NaN,Double.NEGATIVE_INFINITY,Double.POSITIVE_INFINITY}){final int a=axis;check(key+"_nonfinite_axis_"+axis+"_"+v,()->component(key,a,v));} + for(int type=0;type<6;type++){final int t=type;check(key+"_valid_numeric_type_"+type,()->numeric(key,t));} + check(key+"_missing_uses_safe_defaults",()->missing(key)); + } + for(int axis=0;axis<3;axis++)for(double v:new double[]{-10.01,10.01,Double.MAX_VALUE,-Double.MAX_VALUE}){final int a=axis;check("Motion_outside_engine_limit_axis_"+axis+"_"+v,()->component("Motion",a,v));} + for(int axis=0;axis<3;axis++)for(double v:new double[]{-30000000.01,30000000.01,Double.MAX_VALUE,-Double.MAX_VALUE}){final int a=axis;check("Pos_outside_engine_limit_axis_"+axis+"_"+v,()->component("Pos",a,v));} + check("valid_negative_coordinates_and_high_altitude",PositionMotionChecks::signed); + check("valid_boundary_values_preserved",PositionMotionChecks::boundaries); + check("valid_void_position_keeps_saved_value",PositionMotionChecks::voidPosition); + check("repeated_read_does_not_reuse_previous_geometry",PositionMotionChecks::repeated); + check("chunk_admission_repairs_missing_horizontal_coordinates",PositionMotionChecks::chunkAdmission); + check("chunk_admission_repairs_all_invalid_position",PositionMotionChecks::allInvalidChunk); + check("finite_saved_X_in_wrong_containing_chunk_keeps_inventory",()->finiteMismatch(true)); + check("finite_saved_Z_in_wrong_containing_chunk_keeps_inventory",()->finiteMismatch(false)); + check("healthy_chunk_admission_and_later_movement_unchanged",PositionMotionChecks::healthyChunk); + check("healthy_removed_entity_is_not_revived_on_chunk_entry",()->notRevived(false)); + check("valid_repeated_read_clears_previous_pending_position_repair",()->notRevived(true)); + check("position_repair_does_not_heal_saved_zero_health",PositionMotionChecks::deadHealth); + check("geometry_metadata_alone_is_not_inventory",PositionMotionChecks::empty); + check("unknown_items_and_complete_inventory_survive_geometry_repair",PositionMotionChecks::inventory); + try(PrintWriter out=new PrintWriter(new File(folder,"position-motion-checks.txt"),"UTF-8")){for(String s:results)out.println(s);out.println("checks="+results.size()+" failures="+failures);} + require(failures==0,"Position/motion regressions: "+failures+" / "+results.size());System.out.println("LOOTABLE_BODIES_POSITION_MOTION_PASS checks="+results.size()); + } + private static void check(String name,Check test){try{test.run();results.add("PASS "+name);System.out.println("POSITION_MOTION_PASS "+name);}catch(Throwable e){failures++;results.add("FAIL "+name+": "+e);System.out.println("POSITION_MOTION_FAIL "+name+": "+e);e.printStackTrace();}} + private static void require(boolean c,String why){RuntimeChecks.require(c,why);} + static NBTTagList vector(double... values){NBTTagList l=new NBTTagList();for(double v:values)l.appendTag(new NBTTagDouble(v));return l;} + private static NBTTagCompound saved(EntityLootableBody b){return NameLifetimeChecks.saved(b);} + private static NBTTagCompound base(){EntityLootableBody b=new EntityLootableBody(world);b.setPosition(160.5,80,160.5);b.setNoGravity(true);b.setUserName("PositionFixture");for(int i=0;i<48;i++)b.setInventorySlotContents(i,RuntimeChecks.stack(i));b.addItem(RuntimeChecks.stack(70));b.addItem(RuntimeChecks.stack(71));RuntimeChecks.disableVacuum(b);b.motionX=.25;b.motionY=-.5;b.motionZ=.75;return saved(b);} + private static EntityLootableBody read(NBTTagCompound n){NBTTagCompound before=n.copy();Entity e=EntityList.createEntityFromNBT(n,world);require(e instanceof EntityLootableBody,"Factory discarded corpse");require(NameLifetimeChecks.same(before,n),"Input NBT mutated");return (EntityLootableBody)e;} + static void finite(EntityLootableBody b){for(double v:new double[]{b.posX,b.posY,b.posZ,b.motionX,b.motionY,b.motionZ,b.prevPosX,b.prevPosY,b.prevPosZ,b.lastTickPosX,b.lastTickPosY,b.lastTickPosZ})require(Double.isFinite(v),"Nonfinite geometry/derived position");AxisAlignedBB a=b.getEntityBoundingBox();for(double v:new double[]{a.minX,a.minY,a.minZ,a.maxX,a.maxY,a.maxZ})require(Double.isFinite(v),"Nonfinite bounding box");require(Math.abs(a.minX-(b.posX-b.width/2))<1e-6 && a.minY==b.posY && Math.abs(a.maxZ-(b.posZ+b.width/2))<1e-6,"Bounding box differs from sanitized position");} + private static double[] values(EntityLootableBody b,String key){return key.equals("Pos")?new double[]{b.posX,b.posY,b.posZ}:new double[]{b.motionX,b.motionY,b.motionZ};} + private static double fallback(String key,int axis){return key.equals("Pos") && axis==1?64:0;} + private static void sameValue(double a,double z,String why){require(Double.isFinite(a) && a==z,why+" expected="+z+" actual="+a);} + private static void kept(EntityLootableBody b){NBTTagCompound n=base(),a=saved(b);for(String k:new String[]{"Equipment","Aux"})require(NameLifetimeChecks.same(n.getTag(k),a.getTag(k)),"Complete inventory changed "+k);require(b.getSizeInventory()==48 && b.getCustomNameTag().equals("PositionFixture"),"Identity/default changed");} + private static void stable(EntityLootableBody b){finite(b);kept(b);NBTTagCompound a=saved(b),z=saved(read(a));for(String k:new String[]{"Pos","Motion","Equipment","Aux",NbtRecoveryChecks.RECOVERY,NameLifetimeChecks.RECOVERY})require(NameLifetimeChecks.same(a.getTag(k),z.getTag(k)),"Round trip changed "+k);} + private static void badVector(String key,NBTBase raw){NBTTagCompound n=base();n.setTag(key,raw.copy());EntityLootableBody b=read(n);for(int i=0;i<3;i++)sameValue(values(b,key)[i],fallback(key,i),"Bad vector component");NameLifetimeChecks.retained(b,key,raw);stable(b);} + private static void component(String key,int axis,double value){double[] v=key.equals("Pos")?new double[]{160.5,80,160.5}:new double[]{.25,-.5,.75};v[axis]=value;NBTTagCompound n=base();NBTTagList raw=vector(v);n.setTag(key,raw);EntityLootableBody b=read(n);for(int i=0;i<3;i++)sameValue(values(b,key)[i],i==axis?fallback(key,i):v[i],"Lost valid sibling or activated invalid component");NameLifetimeChecks.retained(b,key,raw);stable(b);} + private static void length(String key,int count){double[] v=new double[count];for(int i=0;i results = new ArrayList<>(); + private static MinecraftServer server; + private static WorldServer world; + private static int failures, sequence, transaction; + private static File folder; + + static void run(MinecraftServer instance, WorldServer target, File directory) throws Exception { + server=instance; world=target; folder=directory; results.clear(); failures=0; + check("issue5_exact_two_deaths_hotbar_one_then_switch_without_inventory_sync", ReportedIssueChecks::deathSequence); + check("issue17_loaded_platform_keeps_gravity_and_inventory", ReportedIssueChecks::loadedPlatform); + check("issue17_normal_missing_neighbours_do_not_tick_or_load", ReportedIssueChecks::normalTickGate); + check("issue17_normal_chunk_unload_reload_preserves_complete_inventory", ReportedIssueChecks::unloadReload); + check("issue17_unloaded_east_support_preserves_inventory", ()->forcedSupport(0,1)); + check("issue17_unloaded_west_support_preserves_inventory", ()->forcedSupport(0,-1)); + check("issue17_unloaded_south_support_preserves_inventory", ()->forcedSupport(1,1)); + check("issue17_unloaded_north_support_preserves_inventory", ()->forcedSupport(1,-1)); + check("issue17_loaded_real_void_keeps_existing_fatal_behavior", ReportedIssueChecks::realVoid); + try(PrintWriter out=new PrintWriter(new File(folder,"reported-issue-checks.txt"),"UTF-8")) { + for(String line:results) out.println(line); + out.println("checks="+results.size()+" failures="+failures); + } + RuntimeChecks.require(failures==0,"Reported issue regressions: "+failures+" / "+results.size()); + System.out.println("LOOTABLE_BODIES_REPORTED_ISSUES_PASS checks="+results.size()); + } + private static void check(String name, Check action) { + Set before=new HashSet<>(world.loadedEntityList); + try {action.run(); results.add("PASS "+name); System.out.println("REPORTED_ISSUE_PASS "+name);} + catch(Throwable error) {failures++;results.add("FAIL "+name+": "+error);System.out.println("REPORTED_ISSUE_FAIL "+name+": "+error);error.printStackTrace();} + finally { + for(Entity entity:new ArrayList(world.loadedEntityList)) if(!before.contains(entity)) { + if(entity instanceof EntityPlayerMP) server.getPlayerList().playerLoggedOut((EntityPlayerMP)entity); + else world.removeEntityDangerously(entity); + } + } + } + private static ItemStack shovel() { + ItemStack stack=new ItemStack(Items.DIAMOND_SHOVEL,1,17); + stack.setTagCompound(RuntimeChecks.stack(300).getTagCompound().copy()); + stack.setStackDisplayName("Issue 5 original shovel"); return stack; + } + static Map inventory(EntityLootableBody body) { + Map total=new TreeMap<>(); + for(int i=0;i<48;i++) add(total,body.getStackInSlot(i)); + NBTTagCompound n=new NBTTagCompound();body.writeEntityToNBT(n); + NBTTagList aux=n.getTagList("Aux",10); + for(int i=0;i total,ItemStack stack) { + if(stack==null || stack.stackSize<=0)return; + NBTTagCompound n=stack.writeToNBT(new NBTTagCompound());n.removeTag("Count"); + total.put(n.toString(),total.getOrDefault(n.toString(),0)+stack.stackSize); + } + private static Map tagged(EntityLootableBody body) { + Map n=inventory(body); n.keySet().removeIf(k->!k.contains("fixture"));return n; + } + private static Map tagged(EntityPlayerMP player) { + Map n=new TreeMap<>(); + for(int i=0;i before=new HashSet<>(world.loadedEntityList);player.onKillCommand(); + for(Entity e:world.loadedEntityList)if(e instanceof EntityLootableBody && !before.contains(e))return (EntityLootableBody)e; + throw new AssertionError("Real player death did not create corpse"); + } + private static EntityPlayerMP respawn(EntityPlayerMP player) { + NetHandlerPlayServer connection=player.connection; + connection.processClientStatus(new CPacketClientStatus(CPacketClientStatus.State.PERFORM_RESPAWN)); + EntityPlayerMP replacement=connection.playerEntity; + RuntimeChecks.require(replacement!=player && replacement.isEntityAlive(),"Actual player recreation did not occur"); + replacement.setPosition(32.5,80,0.5);return replacement; + } + private static void click(EntityPlayerMP p,int slot,int button,ClickType type) throws IOException { + ItemStack predicted=type==ClickType.QUICK_MOVE?p.openContainer.getSlot(slot).getStack().copy():null; + PacketBuffer wire=new PacketBuffer(Unpooled.buffer()); + try { + wire.writeByte(p.openContainer.windowId);wire.writeShort(slot);wire.writeByte(button);wire.writeShort(++transaction); + wire.writeEnumValue(type);wire.writeItemStack(predicted); + CPacketClickWindow packet=new CPacketClickWindow();packet.readPacketData(wire);p.connection.processClickWindow(packet); + } finally {wire.release();} + } + private static void deathSequence() throws Exception { + world.getGameRules().setOrCreateGameRule("keepInventory","false");world.getChunkFromChunkCoords(2,0); + EntityPlayerMP p=new EntityPlayerMP(server,world,new GameProfile(UUID.randomUUID(),"IssueFive"),new PlayerInteractionManager(world)); + p.connection=new QuietConnection(p);p.setGameType(GameType.SURVIVAL);p.setPosition(32.5,80,0.5); + RuntimeChecks.require(world.spawnEntity(p),"Fixture player spawn");server.getPlayerList().getPlayers().add(p); + p.inventory.currentItem=4;p.inventory.mainInventory[4]=shovel();p.inventory.mainInventory[9]=RuntimeChecks.stack(301); + Map expected=tagged(p); + EntityLootableBody first=death(p);RuntimeChecks.require(expected.equals(tagged(first)),"First death lost inventory"); + p=respawn(p);CorpseContainer gui=new CorpseContainer(p.inventory,first);gui.windowId=17;p.openContainer=gui;gui.addListener(p); + click(p,4,0,ClickType.SWAP); + RuntimeChecks.require(ItemStack.areItemStacksEqual(p.inventory.mainInventory[0],shovel()),"Key1 did not recover original main hand"); + for(int slot=0;slot<48;slot++)if(gui.getSlot(slot).getHasStack())click(p,slot,0,ClickType.QUICK_MOVE); + RuntimeChecks.require(first.isEmpty() && expected.equals(tagged(p)),"First recovery lost or duplicated tagged inventory"); + PacketBuffer close=new PacketBuffer(Unpooled.buffer()); + try {close.writeByte(gui.windowId);CPacketCloseWindow packet=new CPacketCloseWindow();packet.readPacketData(close);p.connection.processCloseWindow(packet);} + finally {close.release();} + int active=-1;for(int slot=1;slot<9;slot++)if(p.inventory.mainInventory[slot]!=null && p.inventory.mainInventory[slot].hasTagCompound() && p.inventory.mainInventory[slot].getTagCompound().hasKey("fixture"))active=slot; + RuntimeChecks.require(active>0,"Shift-click did not leave a different occupied hotbar slot"); + PacketBuffer held=new PacketBuffer(Unpooled.buffer()); + try {held.writeShort(active);CPacketHeldItemChange packet=new CPacketHeldItemChange();packet.readPacketData(held);p.connection.processHeldItemChange(packet);} + finally {held.release();} + // Deliberately do not tick/sync inventoryContainer or reopen inventory here. + EntityLootableBody second=death(p); + RuntimeChecks.require(expected.equals(tagged(second)),"Second death lost original key1 shovel or another exact stack"); + RuntimeChecks.require(ItemStack.areItemStacksEqual(second.getStackInSlot(4),RuntimeChecks.stack(301)),"Second death active hand differs"); + System.out.println("ISSUE5_REAL_RESPAWN_AND_HELD_ITEM_PACKET_PASS"); + p=respawn(p);server.getPlayerList().playerLoggedOut(p); + NBTTagCompound n=new NBTTagCompound();second.writeToNBTOptional(n); + EntityLootableBody loaded=new EntityLootableBody(world);loaded.readFromNBT(n); + RuntimeChecks.require(expected.equals(tagged(loaded)),"Second corpse NBT reload lost recovery"); + } + private static EntityLootableBody fullBody(int cx,int cz,double offset) { + world.getChunkFromChunkCoords(cx,cz); + EntityLootableBody body=new EntityLootableBody(world);body.setUserName("OfflineIssue17"); + body.setPosition(cx*16+offset,80,cz*16+8.5); + for(int i=0;i<48;i++)body.setInventorySlotContents(i,RuntimeChecks.stack(i)); + body.addItem(RuntimeChecks.stack(70));body.addItem(RuntimeChecks.stack(71));RuntimeChecks.disableVacuum(body); + RuntimeChecks.require(!body.hasNoGravity(),"Issue17 fixture must use real gravity"); + RuntimeChecks.require(world.spawnEntity(body),"Physics fixture spawn");return body; + } + private static void loadedPlatform() { + int c=96;for(int x=c-2;x<=c+2;x++)for(int z=c-2;z<=c+2;z++)world.getChunkFromChunkCoords(x,z); + world.setBlockState(new BlockPos(c*16+8,79,c*16+8),Blocks.STONE.getDefaultState()); + EntityLootableBody b=fullBody(c,c,8.5);Map expected=inventory(b); + for(int i=0;i<40;i++)world.updateEntity(b); + RuntimeChecks.require(b.onGround && b.posY==80 && b.isEntityAlive() && expected.equals(inventory(b)),"Loaded platform/normal gravity lost body or inventory"); + } + private static void normalTickGate() { + EntityLootableBody b=fullBody(112,112,8.5);int ticks=b.ticksExisted; + for(int i=0;i<40;i++)world.updateEntity(b); + RuntimeChecks.require(b.ticksExisted==ticks && b.posY==80 && !world.getChunkProvider().chunkExists(113,112),"Vanilla unloaded-neighbour tick guard did not hold"); + } + private static void unloadReload() throws Exception { + EntityLootableBody b=fullBody(128,128,8.5);UUID uuid=b.getUniqueID();Map expected=inventory(b); + world.getChunkProvider().unload(world.getChunkFromChunkCoords(128,128));world.getChunkProvider().tick();flushChunks();world.updateEntities(); + RuntimeChecks.require(!world.getChunkProvider().chunkExists(128,128),"Fixture chunk did not actually unload"); + RuntimeChecks.require(!world.loadedEntityList.contains(b),"Unloaded corpse still receives normal world ticks"); + world.getChunkFromChunkCoords(128,128);EntityLootableBody loaded=RuntimeChecks.find(world,uuid); + RuntimeChecks.require(loaded.isEntityAlive() && expected.equals(inventory(loaded)),"Normal chunk unload/load lost inventory"); + } + private static final class Support implements AutoCloseable { + EntityLootableBody body; int cx,cz,nx,nz; BlockPos platform; + ForgeChunkManager.Ticket ticket; + Map expected; + void unload(){world.getChunkProvider().unload(world.getChunkFromChunkCoords(nx,nz));world.getChunkProvider().tick();flushChunks();RuntimeChecks.require(!world.getChunkProvider().chunkExists(nx,nz),"Support chunk did not unload");} + void frozen(){for(int i=0;i<100;i++)world.updateEntity(body);System.out.println("ISSUE17_UNLOADED_SUPPORT y="+body.posY+" alive="+body.isEntityAlive()+" inventory="+inventory(body).size()+" supportLoaded="+world.getChunkProvider().chunkExists(nx,nz));RuntimeChecks.require(body.isEntityAlive() && body.posY==80 && expected.equals(inventory(body)),"Corpse fell through unloaded neighbour support and lost recoverable inventory");} + void resume(){world.getChunkFromChunkCoords(nx,nz);for(int i=0;i<10;i++)world.updateEntity(body);RuntimeChecks.require(body.onGround && body.posY==80 && body.addedToChunk && expected.equals(inventory(body)),"Corpse failed to resume safely after support terrain loaded");} + @Override public void close(){ForgeChunkManager.releaseTicket(ticket);} + } + private static Support support(int axis,int direction) { + Support f=new Support();f.cx=144+(sequence++)*8;f.cz=f.cx; + f.nx=f.cx+(axis==0?direction:0);f.nz=f.cz+(axis==1?direction:0); + world.getChunkFromChunkCoords(f.cx,f.cz);world.getChunkFromChunkCoords(f.nx,f.nz); + double x=f.cx*16+(axis==0?(direction>0?15.6:.4):8.5); + double z=f.cz*16+(axis==1?(direction>0?15.6:.4):8.5); + int floorX=MathHelper.floor(x),floorZ=MathHelper.floor(z); + f.platform=new BlockPos(floorX+(axis==0?direction:0),79,floorZ+(axis==1?direction:0)); + // A body can straddle a chunk boundary while its centre remains in the ticketed chunk. + // Remove only the two fixture columns' ground, leaving one thin supporting platform. + for(int y=0;y<5;y++){world.setBlockToAir(new BlockPos(floorX,y,floorZ));world.setBlockToAir(new BlockPos(f.platform.getX(),y,f.platform.getZ()));} + world.setBlockState(f.platform,Blocks.STONE.getDefaultState()); + f.body=fullBody(f.cx,f.cz,8.5);f.body.setPosition(x,80,z);f.expected=inventory(f.body); + Object mod=net.minecraftforge.fml.common.Loader.instance().getIndexedModList().get("lootablebodiesbuildtest").getMod(); + ForgeChunkManager.setForcedChunkLoadingCallback(mod,(tickets,target)->{}); + f.ticket=ForgeChunkManager.requestTicket(mod,world,ForgeChunkManager.Type.NORMAL); + RuntimeChecks.require(f.ticket!=null,"Real Forge chunk-loader ticket unavailable"); + ForgeChunkManager.forceChunk(f.ticket,new ChunkPos(f.cx,f.cz)); + for(int i=0;i<10;i++)world.updateEntity(f.body); + RuntimeChecks.require(f.body.onGround && f.body.posY==80 && f.body.isEntityAlive(),"Loaded neighbour platform control failed"); + return f; + } + private static void forcedSupport(int axis,int direction) { + try(Support f=support(axis,direction)) { + NBTTagCompound before=new NBTTagCompound();f.body.writeToNBTOptional(before); + f.unload();f.frozen(); + NBTTagCompound after=new NBTTagCompound();f.body.writeToNBTOptional(after); + RuntimeChecks.require(before.equals(after),"Waiting for terrain changed healthy saved NBT"); + f.resume(); + } + } + private static void realVoid() { + try(Support f=support(0,1)) { + world.setBlockToAir(f.platform); + for(int i=0;i<100;i++)world.updateEntity(f.body); + RuntimeChecks.require(!f.body.isEntityAlive() && inventory(f.body).isEmpty(),"Loaded real void behavior changed into a rescue feature"); + } + } + private static void flushChunks() { + // Reload only after the engine's queued region write has completed. + try {net.minecraft.world.storage.ThreadedFileIOBase.getThreadedIOInstance().waitForFinish();} + catch(InterruptedException error){Thread.currentThread().interrupt();throw new AssertionError(error);} + } + static void seedPaused() throws Exception { + try(Support f=support(0,1)) { + f.body.setUniqueId(UUID.fromString("00000000-0000-4000-8000-000000000117")); + f.unload();f.frozen();RuntimeChecks.saveExpected(folder,"issue17-paused",f.body); + try(PrintWriter out=new PrintWriter(new File(folder,"issue17-chunks.txt"),"UTF-8")){out.println(f.cx+" "+f.cz+" "+f.nx+" "+f.nz);} + } + } + static void coldReload(MinecraftServer instance,WorldServer target,File directory) throws Exception { + server=instance;world=target;folder=directory; + String[] coords=new String(java.nio.file.Files.readAllBytes(new File(folder,"issue17-chunks.txt").toPath()),java.nio.charset.StandardCharsets.UTF_8).trim().split(" "); + int cx=Integer.parseInt(coords[0]),cz=Integer.parseInt(coords[1]),nx=Integer.parseInt(coords[2]),nz=Integer.parseInt(coords[3]); + world.getChunkFromChunkCoords(cx,cz); + RuntimeChecks.require(!world.getChunkProvider().chunkExists(nx,nz),"Deferred support chunk opened too early"); + EntityLootableBody b=RuntimeChecks.find(world,UUID.fromString("00000000-0000-4000-8000-000000000117")); + RuntimeChecks.verifyExpected(folder,"issue17-paused",b); + for(int i=0;i<100;i++)world.updateEntity(b); + RuntimeChecks.require(b.isEntityAlive() && b.posY==80,"Cold-loaded paused corpse fell before support loaded"); + RuntimeChecks.verifyExpected(folder,"issue17-paused",b); + for(int x=cx-2;x<=cx+2;x++)for(int z=cz-2;z<=cz+2;z++)world.getChunkFromChunkCoords(x,z); + for(int i=0;i<20;i++)world.updateEntity(b); + RuntimeChecks.require(b.onGround && b.posY==80 && b.isEntityAlive(),"Cold-loaded corpse failed to resume on restored platform"); + RuntimeChecks.verifyExpected(folder,"issue17-paused",b); + System.out.println("ISSUE17_COLD_RELOAD_DEFERRED_SUPPORT_AND_COMPLETE_NBT_PASS"); + } + private static final class QuietConnection extends NetHandlerPlayServer { + QuietConnection(EntityPlayerMP p){super(server,new NetworkManager(EnumPacketDirection.SERVERBOUND),p);} + @Override public void sendPacket(Packet packet) { } + } +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/ReportedIssueClientChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/ReportedIssueClientChecks.java new file mode 100644 index 0000000..0293ce3 --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/ReportedIssueClientChecks.java @@ -0,0 +1,157 @@ +package cyano.lootable.buildtest; + +import cyano.lootable.entities.EntityLootableBody; +import cyano.lootable.graphics.CorpseGUIContainer; +import java.io.*; +import java.util.*; +import java.util.concurrent.Future; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.*; +import net.minecraft.entity.Entity; +import net.minecraft.entity.player.EntityPlayerMP; +import net.minecraft.init.*; +import net.minecraft.inventory.ClickType; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.*; +import net.minecraft.network.play.client.CPacketHeldItemChange; +import net.minecraft.util.*; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.*; + +/** Exact issue5 sequence over a real connected client and two disk reloads. */ +final class ReportedIssueClientChecks { + private interface Action {void run() throws Exception;} + private int stage,frames,active; + private Future pending; + private UUID firstId,secondId; + private File report; + private NBTTagCompound savedBody,savedPlayer; + static ItemStack original() { + ItemStack s=new ItemStack(Items.DIAMOND_SHOVEL,1,17); + s.setTagCompound(RuntimeChecks.stack(300).getTagCompound().copy()); + s.setStackDisplayName("Issue 5 original shovel");return s; + } + void tick(Minecraft mc) throws Exception { + frames++; + if(stage==0 && mc.currentScreen instanceof GuiMainMenu) { + report=new File(mc.mcDataDir,"issue5-client-checks.txt");RuntimeChecks.require(!report.exists(),"Stale issue5 report"); + mc.gameSettings.pauseOnLostFocus=false;mc.gameSettings.limitFramerate=30;mc.gameSettings.renderDistanceChunks=4; + mc.launchIntegratedServer("issue-fixture","Issue fixture",new WorldSettings(4267321,GameType.SURVIVAL,false,false,WorldType.FLAT).enableCommands());next(1); + } else if(stage==1 && ready(mc)) { + schedule(mc,()->{ + WorldServer w=player(mc).getServerWorld();w.getGameRules().setOrCreateGameRule("keepInventory","false"); + w.getGameRules().setOrCreateGameRule("doMobSpawning","false"); + for(int x=29;x<=36;x++)for(int z=-3;z<=4;z++)w.setBlockState(new BlockPos(x,79,z),Blocks.STONE.getDefaultState()); + EntityPlayerMP p=player(mc);p.setGameType(GameType.SURVIVAL);p.inventory.clear();p.inventory.currentItem=4; + p.inventory.mainInventory[4]=original();p.inventory.mainInventory[9]=RuntimeChecks.stack(301); + p.connection.setPlayerLocation(32.5,80,.5,0,0);p.inventoryContainer.detectAndSendChanges(); + firstId=die(p,null).getUniqueID(); + });next(2); + } else if(stage==2 && done() && mc.currentScreen instanceof GuiGameOver && frames>10) { + mc.player.respawnPlayer();next(3); + } else if(stage==3 && ready(mc) && mc.player.isEntityAlive() && !(mc.currentScreen instanceof GuiGameOver)) { + schedule(mc,()->player(mc).connection.setPlayerLocation(32.5,80,.5,0,0));next(4); + } else if(stage==4 && done() && open(mc,firstId)) {next(5); + } else if(stage==5 && gui(mc) && frames>15) { + equal(mc.player.openContainer.getSlot(4).getStack(),original(),"First corpse main hand");shot(mc,"issue5-first-main-hand.png"); + mc.playerController.windowClick(mc.player.openContainer.windowId,4,0,ClickType.SWAP,mc.player);next(6); + } else if(stage==6 && frames>15) { + equal(mc.player.inventory.mainInventory[0],original(),"Key1 original shovel recovery"); + for(int slot=0;slot<48;slot++)if(mc.player.openContainer.getSlot(slot).getHasStack()) + mc.playerController.windowClick(mc.player.openContainer.windowId,slot,0,ClickType.QUICK_MOVE,mc.player); + next(7); + } else if(stage==7 && frames>20) { + equal(mc.player.inventory.mainInventory[0],original(),"Original shovel changed before switch"); + active=-1;for(int slot=1;slot<9;slot++)if(ItemStack.areItemStacksEqual(mc.player.inventory.mainInventory[slot],RuntimeChecks.stack(301)))active=slot; + RuntimeChecks.require(active>0,"No different recovered hotbar item"); + mark("first_death_respawn_key1_and_shift_recovery"); + mc.player.closeScreen();mc.player.inventory.currentItem=active; + mc.getConnection().sendPacket(new CPacketHeldItemChange(active)); + // Same ordered connection: held-item change, then /kill. No inventory open or forced resync. + mc.player.sendChatMessage("/kill");next(8); + } else if(stage==8 && mc.currentScreen instanceof GuiGameOver && frames>10) { + mc.player.respawnPlayer();next(9); + } else if(stage==9 && ready(mc) && mc.player.isEntityAlive() && !(mc.currentScreen instanceof GuiGameOver)) { + schedule(mc,()->{ + EntityPlayerMP p=player(mc);EntityLootableBody second=null; + for(Entity e:p.world.loadedEntityList)if(e instanceof EntityLootableBody && !e.getUniqueID().equals(firstId) && p.getName().equals(e.getCustomNameTag()))second=(EntityLootableBody)e; + RuntimeChecks.require(second!=null,"Second actual death corpse absent");secondId=second.getUniqueID(); + verifyOriginals(second);equal(second.getStackInSlot(4),RuntimeChecks.stack(301),"Second selected main hand"); + RuntimeChecks.require(p.inventory.writeToNBT(new NBTTagList()).tagCount()==0,"Second death duplicated player inventory"); + p.connection.setPlayerLocation(32.5,80,.5,0,0); + mark("second_death_without_inventory_resync_preserves_original_shovel_and_all_tags"); + });next(10); + } else if(stage==10 && done() && open(mc,secondId)) {next(11); + } else if(stage==11 && gui(mc) && frames>20) { + verifyClientOriginals(mc);shot(mc,"issue5-second-corpse.png"); + mc.player.closeScreen();schedule(mc,()->save(mc));next(12); + } else if(stage==12 && done()) {quit(mc);next(13); + } else if(stage==13 && mc.world==null && !mc.isIntegratedServerRunning()) { + mc.launchIntegratedServer("issue-fixture","Issue fixture",null);next(14); + } else if(stage==14 && ready(mc) && frames>25) { + schedule(mc,()->{verifyDisk(mc);verifyOriginals(body(mc));mark("first_disk_reload_complete_corpse_inventory");});next(15); + } else if(stage==15 && done() && open(mc,secondId)) {next(16); + } else if(stage==16 && gui(mc) && frames>20) { + verifyClientOriginals(mc);shot(mc,"issue5-reloaded-corpse.png"); + for(int slot=0;slot<48;slot++)if(mc.player.openContainer.getSlot(slot).getHasStack()) + mc.playerController.windowClick(mc.player.openContainer.windowId,slot,0,ClickType.QUICK_MOVE,mc.player); + next(17); + } else if(stage==17 && frames>20) { + mc.player.closeScreen();schedule(mc,()->{ + RuntimeChecks.require(body(mc).isEmpty(),"Final recovery left duplicate corpse items"); + Map expected=originals(),actual=new TreeMap<>(); + EntityPlayerMP p=player(mc); + for(int i=0;i25) { + schedule(mc,()->{verifyDisk(mc);mark("second_disk_reload_complete_player_and_corpse_inventory");});next(21); + } else if(stage==21 && done()) { + new File(mc.mcDataDir,"client.pass").createNewFile(); + System.out.println("LOOTABLE_BODIES_ISSUE5_CLIENT_PASS");quit(mc);next(22); + } else if(stage==22 && mc.world==null && !mc.isIntegratedServerRunning()) {mc.shutdown();next(23);} + } + private EntityLootableBody die(EntityPlayerMP p,UUID skip) { + Set before=new HashSet<>(p.world.loadedEntityList);p.onKillCommand(); + for(Entity e:p.world.loadedEntityList)if(e instanceof EntityLootableBody && !before.contains(e))return (EntityLootableBody)e; + throw new AssertionError("Actual connected death absent"); + } + private static void addTagged(Map result,ItemStack stack) { + if(stack==null || !stack.hasTagCompound() || !stack.getTagCompound().hasKey("fixture"))return; + NBTTagCompound n=stack.writeToNBT(new NBTTagCompound());n.removeTag("Count");result.put(n.toString(),result.getOrDefault(n.toString(),0)+stack.stackSize); + } + private static Map originals(){Map m=new TreeMap<>();addTagged(m,original());addTagged(m,RuntimeChecks.stack(301));return m;} + private static void verifyOriginals(EntityLootableBody b) { + Map n=new TreeMap<>(ReportedIssueChecks.inventory(b));n.keySet().removeIf(k->!k.contains("fixture")); + RuntimeChecks.require(originals().equals(n),"Second corpse lost/duplicated exact tagged inventory"); + } + private static void verifyClientOriginals(Minecraft mc){Map n=new TreeMap<>();for(int i=0;i<48;i++)addTagged(n,mc.player.openContainer.getSlot(i).getStack());RuntimeChecks.require(originals().equals(n),"Connected GUI differs from exact originals");} + private void save(Minecraft mc) throws Exception { + savedBody=new NBTTagCompound();body(mc).writeToNBTOptional(savedBody);savedPlayer=new NBTTagCompound();savedPlayer.setTag("Inventory",player(mc).inventory.writeToNBT(new NBTTagList())); + File folder=player(mc).getServerWorld().getSaveHandler().getWorldDirectory(); + try(FileOutputStream out=new FileOutputStream(new File(folder,"issue5-corpse-expected.nbt"))){CompressedStreamTools.writeCompressed(savedBody,out);} + try(FileOutputStream out=new FileOutputStream(new File(folder,"issue5-player-expected.nbt"))){CompressedStreamTools.writeCompressed(savedPlayer,out);} + mc.getIntegratedServer().getPlayerList().saveAllPlayerData();mc.getIntegratedServer().saveAllWorlds(false); + } + private void verifyDisk(Minecraft mc) { + player(mc).getServerWorld().getChunkFromChunkCoords(2,0);NBTTagCompound actual=new NBTTagCompound();body(mc).writeToNBTOptional(actual); + for(String key:Arrays.asList("id","UUIDMost","UUIDLeast","Equipment","Aux","Name"))RuntimeChecks.require(Objects.equals(savedBody.getTag(key),actual.getTag(key)),"Disk corpse differs: "+key); + RuntimeChecks.require(savedPlayer.getTagList("Inventory",10).equals(player(mc).inventory.writeToNBT(new NBTTagList())),"Complete player inventory differs after disk reload"); + } + private EntityPlayerMP player(Minecraft mc){return mc.getIntegratedServer().getPlayerList().getPlayerByUUID(mc.player.getUniqueID());} + private EntityLootableBody body(Minecraft mc){return RuntimeChecks.find(player(mc).getServerWorld(),secondId);} + private static void equal(ItemStack a,ItemStack b,String reason){RuntimeChecks.require(ItemStack.areItemStacksEqual(a,b),reason);} + private static boolean ready(Minecraft mc){return mc.world!=null && mc.player!=null && mc.getIntegratedServer()!=null;} + private static boolean gui(Minecraft mc){return mc.currentScreen instanceof CorpseGUIContainer;} + private static boolean open(Minecraft mc,UUID id){for(Entity e:mc.world.loadedEntityList)if(e instanceof EntityLootableBody && e.getUniqueID().equals(id)){mc.playerController.interactWithEntity(mc.player,e,null,EnumHand.MAIN_HAND);return true;}return false;} + private void schedule(Minecraft mc,Action a){pending=mc.getIntegratedServer().addScheduledTask(()->{try{a.run();}catch(Exception e){throw new RuntimeException(e);}});} + private boolean done() throws Exception {if(!pending.isDone())return false;pending.get();return true;} + private void next(int n){stage=n;frames=0;System.out.println("ISSUE5_CLIENT_STAGE "+n);} + private void mark(String name) throws Exception {try(FileWriter out=new FileWriter(report,true)){out.write("PASS "+name+"\n");}System.out.println("ISSUE5_CLIENT_CHECK_PASS "+name);} + private static void shot(Minecraft mc,String name){ScreenShotHelper.saveScreenshot(mc.mcDataDir,name,mc.displayWidth,mc.displayHeight,mc.getFramebuffer());} + private static void quit(Minecraft mc){mc.player.closeScreen();mc.getIntegratedServer().initiateShutdown();} +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/RespawnPacketChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/RespawnPacketChecks.java new file mode 100644 index 0000000..96ff4fe --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/RespawnPacketChecks.java @@ -0,0 +1,125 @@ +package cyano.lootable.buildtest; + +import cyano.lootable.entities.EntityLootableBody; +import cyano.lootable.LootableBodies; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.*; +import net.minecraft.entity.Entity; +import net.minecraft.entity.player.EntityPlayerMP; +import net.minecraft.init.Blocks; +import net.minecraft.item.ItemStack; +import net.minecraft.server.MinecraftServer; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.*; +import net.minecraftforge.fml.common.FMLCommonHandler; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; +import net.minecraftforge.fml.common.gameevent.*; + +/** Death/respawn timing matrix, with an independently connected observer. */ +public final class RespawnPacketChecks { + private final MinecraftServer server; + private int index,phase,ticks,phaseTicks; + private EntityPlayerMP dead; + private EntityPlayerMP observerDead; + private EntityLootableBody body; + private EntityLootableBody observerBody; + private final boolean simultaneous=Boolean.getBoolean("lootablebodies.respawn.simultaneous"); + private final int count=Integer.getInteger("lootablebodies.respawn.cases",16); + private final Set traced=new HashSet<>(); + private boolean finished; + RespawnPacketChecks(MinecraftServer server) throws Exception { + RuntimeChecks.require(!LootableBodies.useLocalSkin&&LootableBodies.allowCorpseDecay&&LootableBodies.decayOnlyWhenEmpty + &&LootableBodies.ticksPerItemDecay==6000&&LootableBodies.corpseDecayTime==72000&&LootableBodies.corpseHP==20 + &&!LootableBodies.hurtByEnvironment&&!LootableBodies.hurtByAttacks&&!LootableBodies.hurtByMisc,"Respawn matrix requires production defaults"); + this.server=server;state(0);Files.write(file("server.ready").toPath(),new byte[]{1});FMLCommonHandler.instance().bus().register(this); + } + static File file(String name){return new File(System.getProperty("lootablebodies.multiplayer.sync"),name);} + private EntityPlayerMP player(String role){return server.getPlayerList().getPlayerByUsername("Container"+role);} + private boolean ack(String role,int p){return file(role+".case-"+index+"-"+p).isFile();} + private void trace(EntityPlayerMP p){if(p!=null&&traced.add(p.getName()))RespawnPacketTrace.install(p.connection.netManager,file(p.getName()+"-server-packets.txt"),null);} + @SubscribeEvent public void tick(TickEvent.ServerTickEvent e) { + if(e.phase!=TickEvent.Phase.END||finished)return; + try { + RuntimeChecks.require(++ticks<20000,"Respawn matrix total timeout");phaseTicks++; + RuntimeChecks.require(!file("abort").exists(),"Respawn matrix aborted"); + EntityPlayerMP a=player("A"),b=player("B");trace(a);trace(b); + if(phaseTicks%100==0)System.out.println("RESPAWN_MATRIX_STATE case="+index+" phase="+phase+" A="+(a==null?null:a.getPosition())+" body="+(body==null?null:body.getPosition())+" tracked="+(body==null?null:body.world.getMinecraftServer().worldServerForDimension(body.dimension).getEntityTracker().getTrackingPlayers(body))); + RuntimeChecks.require(phaseTicks<500,"Corpse missing after accepted respawn case="+index+" phase="+phase); + if(phase==0 && a!=null&&b!=null&&ack("A",0)&&ack("B",0)) { + int kind=index%8;int dim=kind==7?-1:0;WorldServer w=server.worldServerForDimension(dim); + if(a.dimension!=dim)transfer(a,dim); + if(b.dimension!=dim)transfer(b,dim); + double x=kind==6?-.4:96.5,z=kind==6?-.4:96.5; + for(int dx=-6;dx<=6;dx++)for(int dz=-6;dz<=6;dz++) { + BlockPos floor=new BlockPos(x+dx,79,z+dz);w.setBlockState(floor,Blocks.OBSIDIAN.getDefaultState()); + for(int y=80;y<=84;y++)w.setBlockToAir(new BlockPos(x+dx,y,z+dz)); + } + w.getGameRules().setOrCreateGameRule("doMobSpawning","false");w.getGameRules().setOrCreateGameRule("keepInventory","false"); + a.setGameType(GameType.SURVIVAL);b.setGameType(GameType.SURVIVAL);a.inventory.clear();b.inventory.clear(); + a.setSpawnPoint(new BlockPos(kind==5?160:(int)Math.floor(x),80,(int)Math.floor(z)-3),true); + b.setSpawnPoint(new BlockPos((int)Math.floor(x),80,(int)Math.floor(z)-3),true); + if(kind==7)a.setSpawnPoint(new BlockPos(96,80,93),true); + for(int dx=157;dx<=164;dx++)for(int dz=90;dz<=98;dz++)w.setBlockState(new BlockPos(dx,79,dz),Blocks.OBSIDIAN.getDefaultState()); + a.inventory.mainInventory[9]=RuntimeChecks.stack(520+index);a.inventoryContainer.detectAndSendChanges(); + if(simultaneous){b.inventory.mainInventory[9]=RuntimeChecks.stack(620+index);b.inventoryContainer.detectAndSendChanges();} + a.connection.setPlayerLocation(x,80,z,0,0);b.connection.setPlayerLocation(kind==4?160.5:x+2,80,z-2.5,0,25); + state(1); + } else if(phase==1&&ack("A",1)&&ack("B",1)&&phaseTicks>10) { + dead=a;Set before=new HashSet<>(a.world.loadedEntityList);a.onKillCommand();body=null; + for(Entity ent:a.world.loadedEntityList)if(ent instanceof EntityLootableBody&&!before.contains(ent))body=(EntityLootableBody)ent; + RuntimeChecks.require(body!=null,"No server death corpse");state(2); + if(simultaneous) { + observerDead=b;before=new HashSet<>(b.world.loadedEntityList);b.onKillCommand();observerBody=null; + for(Entity ent:b.world.loadedEntityList)if(ent instanceof EntityLootableBody&&!before.contains(ent))observerBody=(EntityLootableBody)ent; + RuntimeChecks.require(observerBody!=null,"No observer death corpse"); + } + } else if(phase==2&&a!=null&&a!=dead&&a.isEntityAlive()&&(!simultaneous||(b!=null&&b!=observerDead&&b.isEntityAlive()))) { + RuntimeChecks.require(body.isEntityAlive()&&VisibilityChecks.has(body,520+index),"Server corpse/inventory lost during respawn"); + if(a.dimension!=body.dimension)transfer(a,body.dimension); + a.connection.setPlayerLocation(body.posX,body.posY,body.posZ-2.5,0,25); + if(b.dimension!=body.dimension)transfer(b,body.dimension); + b.connection.setPlayerLocation(body.posX+1.5,body.posY,body.posZ-2.5,0,25); + // With no players left, Forge may unload/reload the Nether between + // death and return. Saved UUIDs survive; runtime entity IDs do not. + body=live(body);if(simultaneous)observerBody=live(observerBody);state(3); + } else if(phase==3) { + if(phaseTicks==10){writeTracked(a,"A");writeTracked(b,"B");} + if(!ack("A",3)||!ack("B",3))return; + RuntimeChecks.require(a.openContainer instanceof cyano.lootable.graphics.CorpseContainer,"Owner GUI not backed by server container"); + RuntimeChecks.require(body.isEntityAlive()&&VisibilityChecks.has(body,520+index),"Corpse lost exact tagged items"); + if(simultaneous)RuntimeChecks.require(observerBody.isEntityAlive()&&VisibilityChecks.has(observerBody,620+index),"Observer corpse lost exact tagged items"); + try(FileWriter out=new FileWriter(file("checks.txt"),true)){out.write("PASS case="+index+" kind="+(index%8)+" owner_and_observer_after_respawn\n");} + a.closeScreen();b.closeScreen();index++; + if(index==count){state(9);}else state(0); + } else if(phase==9&&server.getPlayerList().getCurrentPlayerCount()==0) { + Files.write(file("respawn.pass").toPath(),new byte[]{1});System.out.println("LOOTABLE_BODIES_RESPAWN_MATRIX_PASS");finished=true;server.initiateShutdown(); + } + } catch(Throwable failure){failure.printStackTrace();try{Files.write(file("server.failure").toPath(),failure.toString().getBytes(StandardCharsets.UTF_8));}catch(IOException ignored){}finished=true;server.initiateShutdown();} + } + private void state(int value) throws Exception { + phase=value;phaseTicks=0;String line=index+" "+phase+" "+(body==null?"none":body.getUniqueID())+" "+(body==null?-1:body.getEntityId()); + File tmp=file("state.tmp");Files.write(tmp.toPath(),line.getBytes(StandardCharsets.UTF_8)); + // Windows readers briefly deny replacement while their handle is open. + for(int retry=0;;retry++)try{Files.move(tmp.toPath(),file("state").toPath(),StandardCopyOption.REPLACE_EXISTING,StandardCopyOption.ATOMIC_MOVE);break;} + catch(java.nio.file.AccessDeniedException busy){if(retry==50)throw busy;Thread.sleep(1);} + System.out.println("RESPAWN_MATRIX_PHASE "+line); + } + private void transfer(EntityPlayerMP player,int dimension) { + WorldServer target=server.worldServerForDimension(dimension); + server.getPlayerList().transferPlayerToDimension(player,dimension,new Teleporter(target){ + @Override public void placeInPortal(Entity entity,float yaw){entity.setLocationAndAngles(96.5,80,96.5,yaw,0);} + }); + } + private void writeTracked(EntityPlayerMP player,String role) throws Exception { + List expected=new ArrayList<>();WorldServer w=player.getServerWorld(); + for(Entity e:w.loadedEntityList)if(e instanceof EntityLootableBody&&e.isEntityAlive()&&w.getEntityTracker().getTrackingPlayers(e).contains(player))expected.add(e.getEntityId()+" "+e.getUniqueID()); + Collections.sort(expected);Files.write(file("expected-"+index+"-"+role+".txt").toPath(),expected,StandardCharsets.UTF_8); + } + private EntityLootableBody live(EntityLootableBody previous) { + WorldServer w=server.worldServerForDimension(previous.dimension); + w.getChunkFromChunkCoords((int)Math.floor(previous.posX/16),(int)Math.floor(previous.posZ/16)); + return RuntimeChecks.find(w,previous.getUniqueID()); + } +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/RespawnPacketClientChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/RespawnPacketClientChecks.java new file mode 100644 index 0000000..5ad9374 --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/RespawnPacketClientChecks.java @@ -0,0 +1,128 @@ +package cyano.lootable.buildtest; + +import cyano.lootable.entities.EntityLootableBody; +import cyano.lootable.graphics.CorpseGUIContainer; +import com.mojang.authlib.GameProfile; +import com.mojang.authlib.properties.Property; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.*; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.*; +import net.minecraft.client.multiplayer.GuiConnecting; +import net.minecraft.entity.Entity; +import net.minecraft.item.ItemStack; +import net.minecraft.util.*; +import net.minecraft.server.management.PlayerProfileCache; +import net.minecraft.tileentity.TileEntitySkull; +import net.minecraft.world.World; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.fml.common.FMLCommonHandler; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; +import net.minecraftforge.fml.common.gameevent.TickEvent; + +/** Respawns on the death notification, without waiting for a corpse spawn. */ +final class RespawnPacketClientChecks { + private final String role=System.getProperty("lootablebodies.multiplayer.role"); + private int index=-1,phase=-1,frames,ticks,deathTicks,guiFrames; + private boolean connecting,traced,requested,finished; + private UUID uuid; + private int entityId; + private final Set completed=new HashSet<>(); + private File observations; + private World profileWorld; + void register(){if(Boolean.getBoolean("lootablebodies.respawn")&&role!=null){FMLCommonHandler.instance().bus().register(this);MinecraftForge.EVENT_BUS.register(this);}} + @SubscribeEvent public void mouse(net.minecraftforge.client.event.GuiScreenEvent.MouseInputEvent.Pre e){e.setCanceled(true);} + @SubscribeEvent public void key(net.minecraftforge.client.event.GuiScreenEvent.KeyboardInputEvent.Pre e){e.setCanceled(true);} + private void respawn(Minecraft mc) { + if((!role.equals("A")&&!Boolean.getBoolean("lootablebodies.respawn.simultaneous"))||mc.player==null)return; + if(mc.currentScreen instanceof GuiGameOver) { + int kind=index%8,delay=kind==1?1:kind==2?20:kind==3?60:0; + if(!requested&&deathTicks>=delay){ + if(Boolean.getBoolean("lootablebodies.respawn.counterCollision")&&kind==2) { + if(phase!=2)return; + try {java.lang.reflect.Field counter=net.minecraftforge.fml.relauncher.ReflectionHelper.findField(Entity.class,"nextEntityID","field_70152_a");counter.setInt(null,entityId);} + catch(Exception error){throw new RuntimeException(error);} + observe(mc,"SEEDED_CLIENT_COUNTER_COLLISION next_id="+entityId); + } + requested=true;mc.player.respawnPlayer();observe(mc,"REQUEST_RESPAWN without_corpse_barrier delay="+delay); + } + } else if(mc.player.isEntityAlive()){requested=false;deathTicks=0;} + } + @SubscribeEvent public void tick(TickEvent.ClientTickEvent e) { + Minecraft mc=Minecraft.getMinecraft(); + if(e.phase==TickEvent.Phase.START) { + if(mc.world!=null&&mc.world!=profileWorld) { + profileWorld=mc.world; + // Packet tests use cached synthetic profiles, never real accounts or + // live profile-service availability. The local_texture default stays false. + TileEntitySkull.setProfileCache(new PlayerProfileCache(null,new File(mc.mcDataDir,"fixture-usercache.json")) { + @Override public GameProfile getGameProfileForUsername(String name) { + RuntimeChecks.require(name.matches("Container[AB]"),"Unexpected packet fixture profile"); + UUID id=UUID.nameUUIDFromBytes(("OfflinePlayer:"+name).getBytes(StandardCharsets.UTF_8)); + GameProfile p=new GameProfile(id,name); + String texture="{\"timestamp\":0,\"profileId\":\""+id.toString().replace("-","")+"\",\"profileName\":\""+name+"\",\"textures\":{}}"; + p.getProperties().put("textures",new Property("textures",Base64.getEncoder().encodeToString(texture.getBytes(StandardCharsets.UTF_8))));return p; + } + }); + } + return; + } + if(mc.inGameHasFocus)mc.setIngameNotInFocus();net.minecraft.client.settings.KeyBinding.unPressAllKeys(); + try { + if(finished){mc.shutdown();return;} + RuntimeChecks.require(++ticks<20000,"Respawn client total timeout");RuntimeChecks.require(!RespawnPacketChecks.file("abort").exists(),"Respawn runner aborted"); + String[] state=new String(Files.readAllBytes(RespawnPacketChecks.file("state").toPath()),StandardCharsets.UTF_8).trim().split(" "); + int nextIndex=Integer.parseInt(state[0]),nextPhase=Integer.parseInt(state[1]); + if(nextIndex!=index||nextPhase!=phase){index=nextIndex;phase=nextPhase;frames=0;guiFrames=0;System.out.println("RESPAWN_CLIENT_PHASE "+role+" "+index+" "+phase);} + uuid=state[2].equals("none")?null:UUID.fromString(state[2]);entityId=Integer.parseInt(state[3]);frames++; + if(!connecting&&mc.currentScreen instanceof GuiMainMenu) { + connecting=true;mc.gameSettings.pauseOnLostFocus=false;mc.gameSettings.limitFramerate=30;mc.gameSettings.renderDistanceChunks=4; + mc.displayGuiScreen(new GuiConnecting(new GuiMainMenu(),mc,"127.0.0.1",Integer.parseInt(System.getProperty("lootablebodies.multiplayer.port")))); + } + if(mc.world==null||mc.player==null)return; + if(!traced) { + traced=true;observations=new File(mc.mcDataDir,"main-packet-observations.txt"); + RespawnPacketTrace.install(mc.getConnection().getNetworkManager(),new File(mc.mcDataDir,"client-packets.txt"),packet->mc.addScheduledTask(()->{observe(mc,"AFTER "+packet);respawn(mc);})); + } + if(mc.currentScreen instanceof GuiGameOver)deathTicks++; + respawn(mc); + if(frames%100==0)observe(mc,"TICK phase="+phase+" frame="+frames); + RuntimeChecks.require(frames<450,"Client corpse missing case="+index+" phase="+phase+" role="+role); + String key=index+"-"+phase;if(completed.contains(key))return; + if(phase==0&&frames>10){mc.player.closeScreen();ack(key);} + else if(phase==1&&frames>20){ + if(index%4==1)Thread.sleep(250); // Coalesce an ordinary FIFO packet burst; no packet reordering. + ack(key); + } else if(phase==3&&frames>20) { + File expected=RespawnPacketChecks.file("expected-"+index+"-"+role+".txt");if(!expected.isFile())return; + for(String line:Files.readAllLines(expected.toPath(),StandardCharsets.UTF_8)) { + String[] pair=line.split(" ");Entity tracked=mc.world.getEntityByID(Integer.parseInt(pair[0])); + if(!(tracked instanceof EntityLootableBody)||!tracked.getUniqueID().equals(UUID.fromString(pair[1]))||tracked.isDead||!mc.world.loadedEntityList.contains(tracked))return; + } + Entity entity=mc.world.getEntityByID(entityId); + if(!(entity instanceof EntityLootableBody)||!entity.getUniqueID().equals(uuid)||!mc.world.loadedEntityList.contains(entity)||entity.isDead)return; + if(role.equals("A")) { + if(!(mc.currentScreen instanceof CorpseGUIContainer)){mc.playerController.interactWithEntity(mc.player,entity,null,EnumHand.MAIN_HAND);return;} + boolean found=false;for(int slot=0;slot<48;slot++)if(ItemStack.areItemStacksEqual(mc.player.openContainer.getSlot(slot).getStack(),RuntimeChecks.stack(520+index)))found=true; + if(!found||++guiFrames<10)return; + } + observe(mc,"VERIFIED owner_or_observer exact_uuid_loaded_after_respawn"); + if(index%8==0||index%8==7)ScreenShotHelper.saveScreenshot(mc.mcDataDir,"respawn-case-"+index+".png",mc.displayWidth,mc.displayHeight,mc.getFramebuffer()); + ack(key); + } else if(phase==9) { + new File(mc.mcDataDir,"respawn-client.pass").createNewFile();System.out.println("LOOTABLE_BODIES_RESPAWN_CLIENT_PASS "+role); + mc.player.closeScreen();mc.world.sendQuittingDisconnectingPacket();mc.loadWorld(null);mc.displayGuiScreen(new GuiMainMenu());finished=true; + } + } catch(Throwable failure){failure.printStackTrace();try{Files.write(RespawnPacketChecks.file(role+".failure").toPath(),failure.toString().getBytes(StandardCharsets.UTF_8));}catch(IOException ignored){}finished=true;mc.shutdown();} + } + private void ack(String key) throws Exception {completed.add(key);Files.write(RespawnPacketChecks.file(role+".case-"+key).toPath(),new byte[]{1});} + private synchronized void observe(Minecraft mc,String event) { + try(FileWriter out=new FileWriter(observations,true)) { + StringBuilder bodies=new StringBuilder();if(mc.world!=null)for(Entity ent:mc.world.loadedEntityList)if(ent instanceof EntityLootableBody)bodies.append(ent.getEntityId()).append(':').append(ent.getUniqueID()).append(':').append(ent.isDead).append(','); + Entity mapped=mc.world==null?null:mc.world.getEntityByID(entityId); + out.write(System.nanoTime()+" case="+index+" phase="+phase+" "+event+" world="+(mc.world==null?"none":mc.world.provider.getDimension())+" player="+(mc.player==null?"none":mc.player.getEntityId())+" mapped="+(mapped==null?null:mapped.getUniqueID())+" loaded="+bodies+"\n"); + } catch(IOException error){throw new RuntimeException(error);} + } +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/RespawnPacketTrace.java b/src/integrationTest/java/cyano/lootable/buildtest/RespawnPacketTrace.java new file mode 100644 index 0000000..6a22977 --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/RespawnPacketTrace.java @@ -0,0 +1,66 @@ +package cyano.lootable.buildtest; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.channel.*; +import java.io.*; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import net.minecraft.network.NetworkManager; +import net.minecraft.network.Packet; +import net.minecraft.network.PacketBuffer; +import net.minecraft.network.play.server.*; +import net.minecraft.network.play.client.CPacketClientStatus; +import net.minecraftforge.fml.common.network.ByteBufUtils; +import net.minecraftforge.fml.common.network.internal.FMLProxyPacket; + +/** Observes decoded packets without dropping, delaying, mutating or reordering them. */ +final class RespawnPacketTrace extends ChannelDuplexHandler { + interface After {void received(String packet);} + private final File log; + private final After after; + private long sequence; + private RespawnPacketTrace(File log,After after){this.log=log;this.after=after;} + static void install(NetworkManager manager,File log,After after) { + Channel channel=manager.channel(); + channel.eventLoop().submit(()->channel.pipeline().addBefore("packet_handler","corpse_packet_trace",new RespawnPacketTrace(log,after))).syncUninterruptibly(); + } + @Override public void channelRead(ChannelHandlerContext context,Object packet) throws Exception { + String description=describe(packet);if(description!=null)record("IN "+description); + if(Boolean.getBoolean("lootablebodies.respawn.dropSpawn")&&log.getName().equals("client-packets.txt") + &&description!=null&&description.startsWith("MOD_SPAWN ")&&description.contains("mod=lootablebodies ")) { + record("FAULT_DROPPED "+description);((FMLProxyPacket)packet).payload().release();return; + } + super.channelRead(context,packet); + if(description!=null&&after!=null)after.received(description); + } + @Override public void write(ChannelHandlerContext context,Object packet,ChannelPromise promise) throws Exception { + String description=describe(packet);if(description!=null)record("OUT "+description); + super.write(context,packet,promise); + } + private synchronized void record(String line) throws IOException {try(FileWriter out=new FileWriter(log,true)){out.write((++sequence)+" "+System.nanoTime()+" "+line+"\n");}} + static String describe(Object packet) { + if(packet instanceof FMLProxyPacket) { + FMLProxyPacket f=(FMLProxyPacket)packet;if(!f.channel().equals("FML"))return null; + ByteBuf b=f.payload().duplicate();if(b.readableBytes()<6||b.readUnsignedByte()!=2)return null; + int id=b.readInt();String mod=ByteBufUtils.readUTF8String(b);int type=b.readInt(); + return "MOD_SPAWN id="+id+" mod="+mod+" type="+type+" uuid="+new UUID(b.readLong(),b.readLong()); + } + if(!(packet instanceof SPacketRespawn||packet instanceof SPacketDestroyEntities||packet instanceof SPacketUnloadChunk + ||packet instanceof SPacketChunkData||packet instanceof SPacketCombatEvent||packet instanceof SPacketEntityMetadata||packet instanceof CPacketClientStatus))return null; + // Vanilla strips receiving-side accessors from a dedicated distribution. + // Read an independent serialization, never its live reader index/fields. + PacketBuffer b=new PacketBuffer(Unpooled.buffer()); + try { + ((Packet)packet).writePacketData(b); + if(packet instanceof SPacketRespawn)return "RESPAWN dimension="+b.readInt(); + if(packet instanceof SPacketDestroyEntities){int[] ids=new int[b.readVarInt()];for(int i=0;i results=new ArrayList<>(); + static void run(MinecraftServer server,WorldServer target,File folder) throws Exception { + world=target;failures=0;results.clear(); + NBTBase[] wrong={new NBTTagString("opaque"),new NBTTagCompound(),new NBTTagByteArray(new byte[]{1,2}),new NBTTagIntArray(new int[]{1,2}),new NBTTagList()}; + for(int i=0;ibadFall(raw));} + for(int i=0;ibadRotation(raw));} + for(NBTBase raw:new NBTBase[]{new NBTTagString("opaque"),new NBTTagCompound(),new NBTTagByteArray(new byte[]{1})})check("Rotation_wrong_element_type_"+raw.getId(),()->{NBTTagList l=new NBTTagList();l.appendTag(raw.copy());l.appendTag(raw.copy());badRotation(l);}); + for(int count:new int[]{0,1,3})check("Rotation_wrong_length_"+count,()->length(count)); + for(int axis=0;axis<2;axis++)for(double value:new double[]{Double.NaN,Double.NEGATIVE_INFINITY,Double.POSITIVE_INFINITY,Double.MAX_VALUE,-Double.MAX_VALUE,1e-300,-1e-300}){final int a=axis;check("Rotation_bad_component_"+axis+"_"+value,()->component(a,value));} + for(NBTBase raw:new NBTBase[]{new NBTTagFloat(Float.NaN),new NBTTagFloat(Float.NEGATIVE_INFINITY),new NBTTagFloat(Float.POSITIVE_INFINITY),new NBTTagDouble(Double.NaN),new NBTTagDouble(Double.NEGATIVE_INFINITY),new NBTTagDouble(Double.POSITIVE_INFINITY),new NBTTagInt(-1),new NBTTagFloat(-.25F),new NBTTagDouble(-Double.MAX_VALUE),new NBTTagDouble(Double.MAX_VALUE),new NBTTagDouble(1e-300)})check("FallDistance_invalid_"+raw,()->badFall(raw)); + for(int type=0;type<6;type++){final int t=type;check("valid_rotation_numeric_type_"+type,()->numericRotation(t));check("valid_fall_numeric_type_"+type,()->numericFall(t));} + for(float yaw:new float[]{0F,-0F,37.25F,-37.25F,359.75F,-359.75F,360F,-360F,720.25F,-720.25F,Float.MAX_VALUE,-Float.MAX_VALUE})check("valid_yaw_orientation_and_derived_angles_"+Float.floatToIntBits(yaw),()->validYaw(yaw)); + for(float pitch:new float[]{-90,90,720,-720,Float.MAX_VALUE,-Float.MAX_VALUE})check("valid_pitch_keeps_existing_flat_corpse_behavior_"+pitch,()->validPitch(pitch)); + for(float distance:new float[]{0F,-0F,Float.MIN_VALUE,12.5F,Float.MAX_VALUE})check("valid_representable_fall_distance_"+Float.floatToIntBits(distance),()->validFall(new NBTTagFloat(distance),distance)); + check("missing_rotation_and_fall_default_to_zero",RotationFallChecks::missing); + check("repeated_read_resets_previous_head_body_and_fall_state",RotationFallChecks::repeated); + check("healthy_saved_layout_and_defaults_unchanged",RotationFallChecks::healthy); + check("malformed_angles_and_position_keep_containing_chunk_inventory",RotationFallChecks::chunkAdmission); + check("malformed_fields_do_not_heal_valid_zero_health",RotationFallChecks::zeroHealth); + check("malformed_fields_keep_complete_and_unresolved_inventory",RotationFallChecks::inventory); + check("metadata_only_does_not_count_as_items",RotationFallChecks::empty); + check("landing_keeps_default_environmental_damage_disabled",()->landing(false,false)); + check("landing_keeps_enabled_environmental_damage",()->landing(true,false)); + check("repaired_fall_distance_does_not_invent_landing_damage",()->landing(true,true)); + try(PrintWriter out=new PrintWriter(new File(folder,"rotation-fall-checks.txt"),"UTF-8")){for(String s:results)out.println(s);out.println("checks="+results.size()+" failures="+failures);} + require(failures==0,"Rotation/fall regressions: "+failures+" / "+results.size());System.out.println("LOOTABLE_BODIES_ROTATION_FALL_PASS checks="+results.size()); + } + private static void check(String name,Check test){boolean env=LootableBodies.hurtByEnvironment,inv=LootableBodies.completelyInvulnerable;try{test.run();results.add("PASS "+name);System.out.println("ROTATION_FALL_PASS "+name);}catch(Throwable e){failures++;results.add("FAIL "+name+": "+e);System.out.println("ROTATION_FALL_FAIL "+name+": "+e);e.printStackTrace();}finally{LootableBodies.hurtByEnvironment=env;LootableBodies.completelyInvulnerable=inv;}} + private static void require(boolean c,String why){RuntimeChecks.require(c,why);} + private static NBTTagCompound saved(EntityLootableBody b){return NameLifetimeChecks.saved(b);} + private static NBTTagCompound base(){EntityLootableBody b=new EntityLootableBody(world);b.setPosition(160.5,80,160.5);b.setNoGravity(true);b.setUserName("RotationFixture");b.setRotation(37.25F);b.fallDistance=12.5F;for(int i=0;i<48;i++)b.setInventorySlotContents(i,RuntimeChecks.stack(i));b.addItem(RuntimeChecks.stack(70));b.addItem(RuntimeChecks.stack(71));RuntimeChecks.disableVacuum(b);return saved(b);} + private static EntityLootableBody read(NBTTagCompound n){NBTTagCompound before=n.copy();Entity e=EntityList.createEntityFromNBT(n,world);require(e instanceof EntityLootableBody,"Factory discarded corpse");require(NameLifetimeChecks.same(before,n),"Source NBT mutated");return (EntityLootableBody)e;} + static NBTTagList rotation(float... angles){NBTTagList l=new NBTTagList();for(float a:angles)l.appendTag(new NBTTagFloat(a));return l;} + static NBTTagList wideRotation(double... angles){NBTTagList l=new NBTTagList();for(double a:angles)l.appendTag(new NBTTagDouble(a));return l;} + static void finite(EntityLootableBody b){for(float v:new float[]{b.rotationYaw,b.rotationPitch,b.prevRotationYaw,b.prevRotationPitch,b.rotationYawHead,b.prevRotationYawHead,b.renderYawOffset,b.prevRenderYawOffset,b.fallDistance})require(Float.isFinite(v),"Nonfinite rotation/derived angle/fall distance");require(b.fallDistance>=0,"Negative fall distance");} + private static void value(float a,float z,String why){require(Float.isFinite(a) && a==z,why+" expected="+z+" actual="+a);} + private static void angles(EntityLootableBody b,float yaw){finite(b);for(float v:new float[]{b.rotationYaw,b.prevRotationYaw,b.rotationYawHead,b.prevRotationYawHead,b.renderYawOffset,b.prevRenderYawOffset})value(v,yaw,"Yaw/derived orientation differs");value(b.rotationPitch,0,"Corpse pitch no longer flat");value(b.prevRotationPitch,0,"Previous pitch differs");} + private static void kept(EntityLootableBody b){NBTTagCompound n=base(),a=saved(b);for(String k:new String[]{"Equipment","Aux"})require(NameLifetimeChecks.same(n.getTag(k),a.getTag(k)),"Complete inventory changed "+k);require(b.getSizeInventory()==48 && b.getCustomNameTag().equals("RotationFixture"),"Corpse identity/slots changed");} + private static void stable(EntityLootableBody b){finite(b);kept(b);NBTTagCompound a=saved(b),z=saved(read(a));for(String k:new String[]{"Rotation","FallDistance","Pos","Motion","Equipment","Aux",NbtRecoveryChecks.RECOVERY,NameLifetimeChecks.RECOVERY})require(NameLifetimeChecks.same(a.getTag(k),z.getTag(k)),"Round trip changed "+k);} + private static void badFall(NBTBase raw){NBTTagCompound n=base();n.setTag("FallDistance",raw.copy());EntityLootableBody b=read(n);value(b.fallDistance,0,"Bad fall distance activated");NameLifetimeChecks.retained(b,"FallDistance",raw);stable(b);} + private static void badRotation(NBTBase raw){NBTTagCompound n=base();n.setTag("Rotation",raw.copy());EntityLootableBody b=read(n);angles(b,0);NameLifetimeChecks.retained(b,"Rotation",raw);stable(b);} + private static void component(int axis,double raw){NBTTagCompound n=base();NBTTagList r=axis==0?wideRotation(raw,17):wideRotation(37.25,raw);n.setTag("Rotation",r);EntityLootableBody b=read(n);angles(b,axis==0?0:37.25F);NameLifetimeChecks.retained(b,"Rotation",r);stable(b);} + private static void length(int count){float[] a=new float[count];for(int i=0;i0 && b.getHealth()<20:b.getHealth()==20,"Landing changed configured damage behavior");kept(b);} + static NBTTagCompound malformedFields(){NBTTagCompound n=new NBTTagCompound();n.setTag("Rotation",wideRotation(37,Double.NaN));n.setDouble("FallDistance",Double.NaN);return n;} + static void verifyWorldFields(EntityLootableBody b){finite(b);value(b.rotationYaw,37,"Valid disk yaw lost");value(b.rotationPitch,0,"Disk corpse pitch changed");value(b.fallDistance,0,"Disk fall distance unsafe");NBTTagCompound raw=malformedFields();NameLifetimeChecks.retained(b,"Rotation",raw.getTag("Rotation"));NameLifetimeChecks.retained(b,"FallDistance",raw.getTag("FallDistance"));} +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/RuntimeChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/RuntimeChecks.java new file mode 100644 index 0000000..7992a35 --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/RuntimeChecks.java @@ -0,0 +1,247 @@ +package cyano.lootable.buildtest; + +import com.mojang.authlib.GameProfile; +import cyano.lootable.LootableBodies; +import cyano.lootable.entities.EntityLootableBody; +import cyano.lootable.graphics.CorpseContainer; +import cyano.lootable.graphics.GUIHandler; +import java.io.*; +import java.util.*; +import net.minecraft.entity.Entity; +import net.minecraft.entity.player.EntityPlayerMP; +import net.minecraft.init.Items; +import net.minecraft.inventory.EntityEquipmentSlot; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.*; +import net.minecraft.server.MinecraftServer; +import net.minecraft.util.DamageSource; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.WorldServer; +import net.minecraftforge.common.util.FakePlayer; +import net.minecraftforge.common.util.FakePlayerFactory; +import net.minecraftforge.fml.common.*; +import net.minecraftforge.fml.common.event.*; + +/** Build-only verifier. Never included in the production source set or jars. */ +@Mod(modid="lootablebodiesbuildtest", name="Lootable Bodies Build Tests", version="1", dependencies="required-after:lootablebodies") +public final class RuntimeChecks { + static final UUID NEAR = UUID.fromString("00000000-0000-4000-8000-000000000101"); + static final UUID FAR = UUID.fromString("00000000-0000-4000-8000-000000000102"); + static final UUID DEATH = UUID.fromString("00000000-0000-4000-8000-000000000103"); + public static volatile Throwable failure; + private static boolean defaultsChecked; + @Mod.EventHandler public void preInit(FMLPreInitializationEvent event){MultiplayerChecks.registerChannel();NbtWorldChecks.registerItem();EffectHurtChecks.registerPotion();} + @Mod.EventHandler public void clientInit(FMLInitializationEvent event) { + if (FMLCommonHandler.instance().getSide().isClient()) { + new ClientChecks().register();new MultiplayerClientChecks().register();new VisibilityClientChecks().register();new RespawnPacketClientChecks().register(); + } + } + @Mod.EventHandler public void started(FMLServerStartedEvent event) { + if (FMLCommonHandler.instance().getSide().isClient()) return; + MinecraftServer server = FMLCommonHandler.instance().getMinecraftServerInstance(); + if(Boolean.getBoolean("lootablebodies.respawn")) { + try{new RespawnPacketChecks(server);}catch(Exception error){throw new RuntimeException(error);}return; + } + if(Boolean.getBoolean("lootablebodies.visibility")) { + try{new VisibilityChecks(server);}catch(Exception error){throw new RuntimeException(error);}return; + } + if(Boolean.getBoolean("lootablebodies.multiplayer.server")) { + try{new MultiplayerChecks(server);}catch(Exception error){throw new RuntimeException(error);}return; + } + try { + run(server, System.getProperty("lootablebodies.phase", "fresh")); + } catch (Throwable error) { + failure = error; + error.printStackTrace(); + } finally { server.initiateShutdown(); } + } + static void require(boolean condition, String message) { + if (!condition) throw new AssertionError(message); + } + public static void run(MinecraftServer server, String phase) throws Exception { + WorldServer world = server.worldServerForDimension(0); + File folder = world.getSaveHandler().getWorldDirectory(); + require(LootableBodies.MODID.equals("lootablebodies") && LootableBodies.VERSION.equals("2.4.1.110021"), "identity/version"); + require(net.minecraftforge.fml.common.Loader.instance().getIndexedModList().get("lootablebodies") + .getVersion().equals(phase.equals("seed") ? "2.2.0" : phase.equals("issues") ? + System.getProperty("lootablebodies.expectedIssueVersion","2.4.1.110021") : "2.4.1.110021"), "loaded release version"); + if (!defaultsChecked) require(LootableBodies.addBonesToCorpse && LootableBodies.displayNameTag && !LootableBodies.hurtByEnvironment && + !LootableBodies.hurtByAttacks && !LootableBodies.hurtByMisc && LootableBodies.corpseHP == 20 && + LootableBodies.ticksPerItemDecay == 6000 && LootableBodies.corpseDecayTime == 72000 && + LootableBodies.allowCorpseDecay && LootableBodies.decayOnlyWhenEmpty, "default configuration"); + defaultsChecked=true; + // Freeze timers only in this verifier while comparing exact inventories. + LootableBodies.allowCorpseDecay = false; + LootableBodies.ticksPerItemDecay = -1; + if(phase.equals("visibility-reload")) { + VisibilityChecks.reload(server); + } else if(phase.equals("issues")) { + ReportedIssueChecks.run(server,world,folder); + ReportedIssueChecks.seedPaused(); + } else if(phase.equals("issues-reload")) { + ReportedIssueChecks.coldReload(server,world,folder); + } else if(phase.equals("multiplayer-reload")) { + MultiplayerChecks.verifyReload(server); + } else if (phase.equals("rotation")) { + RotationFallChecks.run(server,world,folder); + } else if (phase.equals("geometry")) { + PositionMotionChecks.run(server,world,folder); + } else if (phase.equals("effects")) { + EffectHurtChecks.run(server,world,folder); + } else if (phase.equals("health")) { + HealthAttributeChecks.run(server,world,folder); + } else if (phase.equals("scalars")) { + NameLifetimeChecks.run(server,world,folder); + } else if (phase.startsWith("nbt-")) { + NbtWorldChecks.run(server,phase); + } else if (phase.equals("nbt")) { + NbtRecoveryChecks.run(server,world,folder); + } else if (phase.equals("drops")) { + ContainerInteractionChecks.runDrops(server,world,folder); + } else if (phase.equals("interactions")) { + ContainerInteractionChecks.run(server,world,folder); + } else if (phase.equals("inventory")) { + InventorySafetyChecks.run(server,world,folder); + } else if (phase.equals("fresh") || phase.equals("seed")) { + if (phase.equals("fresh")) ReportedIssueChecks.run(server,world,folder); + if (phase.equals("fresh")) InventorySafetyChecks.run(server,world,folder); + if (phase.equals("fresh")) ContainerInteractionChecks.run(server,world,folder); + if (phase.equals("fresh")) ContainerInteractionChecks.runDrops(server,world,folder); + if (phase.equals("fresh")) NbtRecoveryChecks.run(server,world,folder); + if (phase.equals("fresh")) NameLifetimeChecks.run(server,world,folder); + if (phase.equals("fresh")) HealthAttributeChecks.run(server,world,folder); + if (phase.equals("fresh")) EffectHurtChecks.run(server,world,folder); + if (phase.equals("fresh")) PositionMotionChecks.run(server,world,folder); + if (phase.equals("fresh")) RotationFallChecks.run(server,world,folder); + require(!new File(folder, "near.nbt").exists(), "fresh fixture already exists"); + EntityLootableBody near = fixture(world, NEAR, 8, true); + EntityLootableBody far = fixture(world, FAR, 4096, false); + saveExpected(folder, "near", near); + saveExpected(folder, "far", far); + verifyRoundTrip(world, near); + verifyRoundTrip(world, far); + death(server, world, folder); + if (phase.equals("fresh")) InventorySafetyChecks.seedBlockedRelease(world,folder); + if (phase.equals("fresh")) ReportedIssueChecks.seedPaused(); + } else if (phase.equals("reload") || phase.equals("deferred")) { + if(new File(folder,"issue17-chunks.txt").isFile()) ReportedIssueChecks.coldReload(server,world,folder); + world.getChunkFromChunkCoords(0, 0); + EntityLootableBody near = find(world, NEAR); + verifyExpected(folder, "near", near); + world.getChunkFromChunkCoords(2, 0); + verifyExpected(folder, "death", find(world, DEATH)); + if(new File(folder,"rejected-release.nbt").isFile()) { + world.getChunkFromChunkCoords(3,0); + EntityLootableBody retained=find(world,UUID.fromString("00000000-0000-4000-8000-000000000105")); + require(retained.isEntityAlive(),"Blocked-release owner did not survive reload"); + // The saved profile is recreated lazily on the first entity tick. + retained.onEntityUpdate(); + verifyExpected(folder,"rejected-release",retained); + } + if (phase.equals("deferred")) { + world.getChunkFromChunkCoords(256, 256); + verifyExpected(folder, "far", find(world, FAR)); + } else { + require(!world.getChunkProvider().chunkExists(256, 256), "deferred chunk opened early"); + } + } else throw new AssertionError("Unknown phase " + phase); + server.saveAllWorlds(false); + File marker = new File(folder, phase + ".pass"); + try (FileWriter output = new FileWriter(marker)) { output.write("corpse-inventory-preserved\n"); } + System.out.println("LOOTABLE_BODIES_RUNTIME_PASS phase=" + phase); + } + static ItemStack stack(int index) { + ItemStack stack = index < 4 ? new ItemStack(new net.minecraft.item.Item[]{Items.DIAMOND_HELMET, Items.DIAMOND_CHESTPLATE, Items.DIAMOND_LEGGINGS, Items.DIAMOND_BOOTS}[index],1,index+2) : + index == 4 ? new ItemStack(Items.DIAMOND_SWORD,1,17) : index == 5 ? new ItemStack(Items.SHIELD,1,9) : + new ItemStack(Items.DYE, 1 + index % 32, index % 16); + NBTTagCompound custom = new NBTTagCompound(); + custom.setString("fixture", "slot-" + index); + NBTTagCompound nested = new NBTTagCompound(); nested.setInteger("unchanged", index * 17); + NBTTagList contents = new NBTTagList(); + NBTTagCompound item = new NBTTagCompound(); new ItemStack(Items.DIAMOND,3).writeToNBT(item); contents.appendTag(item); + nested.setTag("contents", contents); custom.setTag("nested", nested); + stack.setTagCompound(custom); stack.setStackDisplayName("Fixture " + index); + if (index == 4) stack.addEnchantment(net.minecraft.init.Enchantments.UNBREAKING, 2); + return stack; + } + static EntityLootableBody fixture(WorldServer world, UUID uuid, int coordinate, boolean full) { + world.getChunkFromChunkCoords(coordinate >> 4, coordinate >> 4); + EntityLootableBody body = new EntityLootableBody(world); + body.setUniqueId(uuid); body.setPosition(coordinate+0.5, 80, coordinate+0.5); + body.setNoGravity(true); body.setUserName("BuildFixture"); body.setRotation(37); + require(body.getSizeInventory() == 48, "48 inventory slots"); + for (int i=0; i<48; i++) if (full || i % 7 == 0 || i == 47) body.setInventorySlotContents(i,stack(i)); + if (full) { body.addItem(stack(70)); body.addItem(stack(71)); } + disableVacuum(body); + require(world.spawnEntity(body), "fixture spawn"); + return body; + } + static void verifyRoundTrip(WorldServer world, EntityLootableBody body) { + NBTTagCompound before = new NBTTagCompound(); body.writeToNBTOptional(before); + require(before.getString("id").equals("lootablebodies.Corpse"), "persistent entity name: " + before.getString("id")); + Entity copy = net.minecraft.entity.EntityList.createEntityFromNBT(before.copy(), world); + require(copy instanceof EntityLootableBody, "NBT factory registration"); + NBTTagCompound after = new NBTTagCompound(); copy.writeToNBTOptional(after); + compare(before,after); + if (body.getUniqueID().equals(NEAR)) { + require(before.getTagList("Equipment",10).tagCount()==48, "all indexed stacks"); + require(before.getTagList("Aux",10).tagCount()==2, "ordered overflow"); + } + } + static void death(MinecraftServer server, WorldServer world, File folder) throws Exception { + EntityPlayerMP player = new EntityPlayerMP(server, world, + new GameProfile(UUID.fromString("00000000-0000-4000-8000-000000000104"),"BuildDeath"), + new net.minecraft.server.management.PlayerInteractionManager(world)); + player.connection = new net.minecraft.network.NetHandlerPlayServer(server, + new net.minecraft.network.NetworkManager(net.minecraft.network.EnumPacketDirection.SERVERBOUND), player); + player.setPosition(32.5,80,0.5); world.getChunkFromChunkCoords(2,0); + world.getGameRules().setOrCreateGameRule("keepInventory","false"); + player.inventory.mainInventory[0] = stack(4); + player.inventory.mainInventory[9] = stack(19); + player.setItemStackToSlot(EntityEquipmentSlot.HEAD,stack(0)); + player.setItemStackToSlot(EntityEquipmentSlot.OFFHAND,stack(5)); + player.onDeath(DamageSource.outOfWorld); + EntityLootableBody corpse = null; + for (Entity entity : new ArrayList(world.loadedEntityList)) + if (entity instanceof EntityLootableBody && "BuildDeath".equals(entity.getCustomNameTag())) corpse=(EntityLootableBody)entity; + require(corpse != null, "actual player death created corpse"); + require(ItemStack.areItemStacksEqual(corpse.getStackInSlot(0),stack(0)),"death helmet"); + require(ItemStack.areItemStacksEqual(corpse.getStackInSlot(4),stack(4)),"death main hand"); + require(ItemStack.areItemStacksEqual(corpse.getStackInSlot(5),stack(5)),"death offhand"); + require(ItemStack.areItemStacksEqual(corpse.getStackInSlot(6),stack(19)),"death inventory custom NBT"); + Object gui = GUIHandler.getInstance().getServerGuiElement(0,player,world,corpse.getEntityId(),0,0); + require(gui instanceof CorpseContainer,"server inventory GUI"); + CorpseContainer container = (CorpseContainer)gui; + require(container.inventorySlots.size()==84 && container.canInteractWith(player),"container slots/access"); + ItemStack recovered = container.transferStackInSlot(player,6); + require(ItemStack.areItemStacksEqual(recovered,stack(19)),"shift-click recovery"); + require(corpse.getStackInSlot(6)==null,"no duplicate in corpse"); + corpse.setUniqueId(DEATH); corpse.setNoGravity(true); + corpse.motionX=0; corpse.motionY=0; corpse.motionZ=0; + disableVacuum(corpse); + saveExpected(folder,"death",corpse); + } + static EntityLootableBody find(WorldServer world, UUID uuid) { + for(Entity entity : world.loadedEntityList) if(entity.getUniqueID().equals(uuid)) return (EntityLootableBody)entity; + throw new AssertionError("Saved corpse missing " + uuid); + } + static void disableVacuum(EntityLootableBody body) { + try { + java.lang.reflect.Field field = EntityLootableBody.class.getDeclaredField("vacuumTime"); + field.setAccessible(true); field.setByte(body,(byte)20); + } catch (Exception error) { throw new AssertionError(error); } + } + static void saveExpected(File folder,String name,EntityLootableBody body) throws Exception { + NBTTagCompound data = new NBTTagCompound(); body.writeToNBTOptional(data); + CompressedStreamTools.writeCompressed(data,new FileOutputStream(new File(folder,name+".nbt"))); + } + static void verifyExpected(File folder,String name,EntityLootableBody body) throws Exception { + NBTTagCompound expected = CompressedStreamTools.readCompressed(new FileInputStream(new File(folder,name+".nbt"))); + NBTTagCompound actual = new NBTTagCompound(); body.writeToNBTOptional(actual); compare(expected,actual); + } + static void compare(NBTTagCompound expected,NBTTagCompound actual) { + for(String key : Arrays.asList("id","UUIDMost","UUIDLeast","Pos","Rotation","CustomName","Equipment","Aux","DeathTime","DecayTime")) + require(Objects.equals(expected.getTag(key),actual.getTag(key)),"Saved field differs " + key + ": " + expected.getTag(key) + " != " + actual.getTag(key)); + if(expected.hasKey("Name")) require(expected.getString("Name").equals(actual.getString("Name")),"profile name"); + } +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/VisibilityChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/VisibilityChecks.java new file mode 100644 index 0000000..a1445d0 --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/VisibilityChecks.java @@ -0,0 +1,154 @@ +package cyano.lootable.buildtest; + +import cyano.lootable.LootableBodies; +import cyano.lootable.entities.EntityLootableBody; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.*; +import net.minecraft.entity.*; +import net.minecraft.entity.item.EntityTNTPrimed; +import net.minecraft.entity.player.EntityPlayerMP; +import net.minecraft.init.Blocks; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.*; +import net.minecraft.server.MinecraftServer; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.*; +import net.minecraftforge.fml.common.FMLCommonHandler; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; +import net.minecraftforge.fml.common.gameevent.TickEvent; + +/** Default-config real deaths and tracking, coordinated with two independent TCP clients. */ +public final class VisibilityChecks { + private final MinecraftServer server; + private int phase,ticks,phaseTicks; + private long waitStart; + private EntityLootableBody first,second; + private Set before; + private boolean finished; + private final boolean resumed=Boolean.getBoolean("lootablebodies.visibility.reload"); + public VisibilityChecks(MinecraftServer server) throws Exception { + this.server=server; + RuntimeChecks.require(!LootableBodies.useLocalSkin && LootableBodies.allowCorpseDecay && LootableBodies.decayOnlyWhenEmpty + && LootableBodies.ticksPerItemDecay==6000 && LootableBodies.corpseDecayTime==72000 + && !LootableBodies.hurtByEnvironment && !LootableBodies.hurtByAttacks && !LootableBodies.hurtByMisc,"Visibility defaults changed"); + phase(0);Files.write(file("server.ready").toPath(),new byte[]{1});FMLCommonHandler.instance().bus().register(this); + } + static File file(String name){return new File(System.getProperty("lootablebodies.multiplayer.sync"),name);} + private EntityPlayerMP player(String role){return server.getPlayerList().getPlayerByUsername("Container"+role);} + private boolean ack(String role,int n){return file(role+".visibility-"+n).isFile();} + private boolean both(int n){return ack("A",n)&&ack("B",n);} + private WorldServer world(){return server.worldServerForDimension(0);} + @SubscribeEvent public void tick(TickEvent.ServerTickEvent e) { + if(e.phase!=TickEvent.Phase.END || finished)return; + try { + RuntimeChecks.require(++ticks<14000,"Visibility server timeout phase="+phase);phaseTicks++; + RuntimeChecks.require(!file("abort").exists(),"Visibility runner aborted"); + EntityPlayerMP a=player("A"),b=player("B"); + if(phaseTicks%100==0 && phase!=12)System.out.println("VISIBILITY_SERVER_STATE phase="+phase+" A="+(a==null?null:a.getPosition())+" B="+(b==null?null:b.getPosition())+" corpse="+(second==null?null:second.getPosition())+" usableA="+(second!=null&&second.isUsableByPlayer(a))+" usableB="+(second!=null&&second.isUsableByPlayer(b))); + if(phase==0 && resumed && a!=null && b!=null && both(0)) { + NBTTagCompound n;try(FileInputStream in=new FileInputStream(new File(world().getSaveHandler().getWorldDirectory(),"visibility-state.nbt"))){n=CompressedStreamTools.readCompressed(in);} + first=loadExpected(world(),"first");second=loadExpected(world(),"second"); + saveId("first",first);saveId("second",second);verifyInventories(); + a.connection.setPlayerLocation(first.posX,first.posY,first.posZ-2.5,0,25);b.connection.setPlayerLocation(first.posX+1.5,first.posY,first.posZ-2.5,0,25);phase(19); + } else if(phase==0 && a!=null && b!=null && both(0)) { + WorldServer w=world();w.getGameRules().setOrCreateGameRule("doMobSpawning","false");w.getGameRules().setOrCreateGameRule("keepInventory","false"); + w.setWorldTime(6000); + for(int x=88;x<=170;x++)for(int z=89;z<=103;z++)w.setBlockState(new BlockPos(x,79,z),Blocks.OBSIDIAN.getDefaultState()); + for(EntityPlayerMP p:new EntityPlayerMP[]{a,b}) {p.setGameType(GameType.SURVIVAL);p.inventory.clear();p.setSpawnPoint(new BlockPos(96,80,93),true);} + a.connection.setPlayerLocation(96.5,80,96.5,0,0);b.connection.setPlayerLocation(98.5,80,93.5,0,25); + a.inventory.mainInventory[9]=RuntimeChecks.stack(410);a.inventoryContainer.detectAndSendChanges();phase(1); + } else if(phase==1 && both(1)) { + before=new HashSet<>(world().loadedEntityList);a.onKillCommand();first=newCorpse();saveId("first",first);phase(2); + } else if(phase==2 && both(2)) {mark("both_clients_receive_actual_death_before_respawn");phase(3); + } else if(phase==3 && both(3) && a!=null && a.isEntityAlive()) { + RuntimeChecks.require(first.isEntityAlive(),"Server lost corpse after respawn"); + a.connection.setPlayerLocation(96.5,80,93.5,0,25);phase(4); + } else if(phase==4 && both(4)) { + RuntimeChecks.require(a.openContainer instanceof cyano.lootable.graphics.CorpseContainer && b.openContainer instanceof cyano.lootable.graphics.CorpseContainer,"GUI ack without server access"); + mark("both_clients_render_and_open_corpse_after_respawn");phase(5); + } else if(phase==5 && both(5)) { + a.closeScreen();b.closeScreen();a.connection.setPlayerLocation(160.5,80,93.5,0,25);phase(6); + } else if(phase==6 && both(6)) { + RuntimeChecks.require(world().getEntityTracker().getTrackingPlayers(first).contains(b) && !world().getEntityTracker().getTrackingPlayers(first).contains(a),"Range assertions differ from tracker"); + mark("range_exit_removes_only_distant_client");a.connection.setPlayerLocation(96.5,80,93.5,0,25);phase(7); + } else if(phase==7 && both(7)) {mark("range_return_respawns_same_uuid");phase(8); + } else if(phase==8 && a==null) {phase(9); + } else if(phase==9 && a!=null && both(9)) { + mark("reconnect_owner_with_observer_still_online");phase(10); + } else if(phase==10 && b==null) {phase(11); + } else if(phase==11 && b!=null && both(11)) { + mark("reconnect_observer_with_owner_still_online");waitStart=System.nanoTime();phase(12); + } else if(phase==12 && phaseTicks>Integer.getInteger("lootablebodies.visibility.waitTicks",6200) && (System.nanoTime()-waitStart)>Long.getLong("lootablebodies.visibility.waitMillis",310000L)*1000000L && both(12)) { + RuntimeChecks.require(first.isEntityAlive() && has(first,410),"Default corpse lost before five-minute TNT test"); + phase(120); + } else if(phase==120 && both(120)) { + mark(Integer.getInteger("lootablebodies.visibility.waitTicks",6200)==6200?"default_corpse_visible_after_6200_ticks_and_310_seconds":"short_fixture_control_default_corpse_visible"); + a.connection.setPlayerLocation(100.5,80,96.5,0,0);b.connection.setPlayerLocation(102.5,80,93.5,0,25); + a.inventory.mainInventory[9]=RuntimeChecks.stack(411);a.inventoryContainer.detectAndSendChanges();phase(13); + } else if(phase==13 && both(13)) { + before=new HashSet<>(world().loadedEntityList);EntityTNTPrimed tnt=new EntityTNTPrimed(world(),a.posX,a.posY,a.posZ,null);tnt.setFuse(0); + RuntimeChecks.require(world().spawnEntity(tnt),"TNT fixture spawn");phase(14); + } else if(phase==14) { + if(second==null && a!=null && !a.isEntityAlive()) {second=newCorpse();saveId("second",second);} + if(second!=null && both(14)) {RuntimeChecks.require(second.isEntityAlive() && has(second,411),"TNT destroyed default corpse/inventory");mark("tnt_death_after_five_minutes_both_clients_receive_corpse");phase(15);} + } else if(phase==15 && both(15) && a!=null && a.isEntityAlive()) { + // TNT can displace both the observer and the death position. + a.connection.setPlayerLocation(second.posX,second.posY,second.posZ-2.5,0,25); + b.connection.setPlayerLocation(second.posX+1.5,second.posY,second.posZ-2.5,0,25);phase(16); + } else if(phase==16 && both(16)) {mark("both_clients_render_and_open_tnt_corpse_after_respawn");phase(17); + } else if(phase==17 && both(17) && phaseTicks>20) { + RuntimeChecks.require(!has(second,411) && playerHas(a,411),"TNT corpse recovery lost or duplicated tagged stack"); + RuntimeChecks.require(has(first,410),"Earlier corpse inventory changed during TNT/reconnect checks"); + a.closeScreen();b.closeScreen();File folder=world().getSaveHandler().getWorldDirectory(); + RuntimeChecks.saveExpected(folder,"visibility-first",first);RuntimeChecks.saveExpected(folder,"visibility-second",second); + NBTTagCompound state=new NBTTagCompound();state.setUniqueId("First",first.getUniqueID());state.setUniqueId("Second",second.getUniqueID());state.setUniqueId("Player",a.getUniqueID());state.setTag("Inventory",a.inventory.writeToNBT(new NBTTagList())); + try(FileOutputStream out=new FileOutputStream(new File(folder,"visibility-state.nbt"))){CompressedStreamTools.writeCompressed(state,out);} + server.getPlayerList().saveAllPlayerData();server.saveAllWorlds(false);mark("tagged_inventory_recovered_and_complete_nbt_saved");phase(18); + } else if(phase==18 && server.getPlayerList().getCurrentPlayerCount()==0) { + Files.write(file("visibility.pass").toPath(),new byte[]{1});System.out.println("LOOTABLE_BODIES_VISIBILITY_PASS");finished=true;server.initiateShutdown(); + } else if(phase==19 && both(19)) { + mark("cold_server_restart_both_clients_render_and_open_saved_corpse");a.closeScreen();b.closeScreen(); + for(int x=397;x<=404;x++)for(int z=397;z<=404;z++)world().setBlockState(new BlockPos(x,79,z),Blocks.OBSIDIAN.getDefaultState()); + a.connection.setPlayerLocation(400.5,80,400.5,0,25);b.connection.setPlayerLocation(402.5,80,400.5,0,25);phase(20); + } else if(phase==20 && both(20) && phaseTicks>240 && !world().getChunkProvider().chunkExists(first.chunkCoordX,first.chunkCoordZ) && !world().getChunkProvider().chunkExists(second.chunkCoordX,second.chunkCoordZ)) { + mark("both_clients_leave_and_corpse_chunk_really_unloads"); + net.minecraft.world.storage.ThreadedFileIOBase.getThreadedIOInstance().waitForFinish(); + a.connection.setPlayerLocation(first.posX,first.posY,first.posZ-2.5,0,25);b.connection.setPlayerLocation(first.posX+1.5,first.posY,first.posZ-2.5,0,25);phase(21); + } else if(phase==21 && both(21)) { + first=RuntimeChecks.find(world(),first.getUniqueID());second=RuntimeChecks.find(world(),second.getUniqueID());verifyInventories(); + mark("chunk_reentry_both_clients_render_same_uuids_and_inventory");server.getPlayerList().saveAllPlayerData();server.saveAllWorlds(false);phase(18); + } + } catch(Throwable failure) {failure.printStackTrace();finished=true;server.initiateShutdown();} + } + private EntityLootableBody newCorpse(){for(Entity e:world().loadedEntityList)if(e instanceof EntityLootableBody&&!before.contains(e))return (EntityLootableBody)e;throw new AssertionError("No server corpse for real death");} + private void saveId(String name,EntityLootableBody b) throws Exception {Files.write(file(name+".uuid").toPath(),b.getUniqueID().toString().getBytes(StandardCharsets.UTF_8));} + private void phase(int n) throws Exception {phase=n;phaseTicks=0;File tmp=file("phase.tmp");Files.write(tmp.toPath(),Integer.toString(n).getBytes(StandardCharsets.UTF_8));Files.move(tmp.toPath(),file("phase").toPath(),StandardCopyOption.REPLACE_EXISTING,StandardCopyOption.ATOMIC_MOVE);System.out.println("VISIBILITY_PHASE "+n);} + private void mark(String n) throws Exception {try(FileWriter out=new FileWriter(file("checks.txt"),true)){out.write("PASS "+n+"\n");}System.out.println("VISIBILITY_CHECK_PASS "+n);} + static boolean has(EntityLootableBody b,int i){for(int s=0;s<48;s++)if(ItemStack.areItemStacksEqual(b.getStackInSlot(s),RuntimeChecks.stack(i)))return true;return false;} + private static boolean playerHas(EntityPlayerMP p,int i){int count=0;for(int s=0;s=expected.getLong(key)&&actual.getLong(key)<=w.getTotalWorldTime(),"Reloaded live timer out of range: "+key); + } +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/VisibilityClientChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/VisibilityClientChecks.java new file mode 100644 index 0000000..9f9aa65 --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/VisibilityClientChecks.java @@ -0,0 +1,104 @@ +package cyano.lootable.buildtest; + +import cyano.lootable.entities.EntityLootableBody; +import cyano.lootable.graphics.CorpseGUIContainer; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.*; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.*; +import net.minecraft.client.multiplayer.GuiConnecting; +import net.minecraft.entity.Entity; +import net.minecraft.inventory.ClickType; +import net.minecraft.item.ItemStack; +import net.minecraft.util.*; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.client.event.RenderLivingEvent; +import net.minecraftforge.fml.common.FMLCommonHandler; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; +import net.minecraftforge.fml.common.gameevent.TickEvent; + +/** Receives real Forge spawn/destroy/respawn packets; never takes native input focus. */ +final class VisibilityClientChecks { + private final String role=System.getProperty("lootablebodies.multiplayer.role"); + private int phase=-1,frames,ticks,guiFrames; + private boolean connecting,respawnSent,finished; + private final Set done=new HashSet<>(); + private final Set rendered=new HashSet<>(); + void register(){if(Boolean.getBoolean("lootablebodies.visibility")&&role!=null){FMLCommonHandler.instance().bus().register(this);MinecraftForge.EVENT_BUS.register(this);}} + @SubscribeEvent public void mouse(net.minecraftforge.client.event.GuiScreenEvent.MouseInputEvent.Pre e){e.setCanceled(true);} + @SubscribeEvent public void key(net.minecraftforge.client.event.GuiScreenEvent.KeyboardInputEvent.Pre e){e.setCanceled(true);} + @SubscribeEvent public void rendered(RenderLivingEvent.Post e){if(e.getEntity() instanceof EntityLootableBody)rendered.add(e.getEntity().getUniqueID());} + @SubscribeEvent public void tick(TickEvent.ClientTickEvent e) { + if(e.phase!=TickEvent.Phase.END)return;Minecraft mc=Minecraft.getMinecraft(); + if(mc.inGameHasFocus)mc.setIngameNotInFocus();net.minecraft.client.settings.KeyBinding.unPressAllKeys(); + try { + if(finished){mc.shutdown();return;} + RuntimeChecks.require(++ticks<15000,"Visibility client timeout role="+role+" phase="+phase); + RuntimeChecks.require(!VisibilityChecks.file("abort").exists(),"Visibility runner aborted"); + int n=Integer.parseInt(new String(Files.readAllBytes(VisibilityChecks.file("phase").toPath()),StandardCharsets.UTF_8).trim()); + if(n!=phase){phase=n;frames=0;guiFrames=0;respawnSent=false;rendered.clear();System.out.println("VISIBILITY_CLIENT_PHASE "+role+" "+phase);}frames++; + if(!connecting && mc.world==null && mc.currentScreen instanceof GuiMainMenu && (phase==0 || phase==9 || phase==11)) { + connecting=true;mc.gameSettings.pauseOnLostFocus=false;mc.gameSettings.limitFramerate=30;mc.gameSettings.renderDistanceChunks=4; + mc.displayGuiScreen(new GuiConnecting(new GuiMainMenu(),mc,"127.0.0.1",Integer.parseInt(System.getProperty("lootablebodies.multiplayer.port")))); + } + if(mc.world==null || mc.player==null)return; + if(frames%200==0)System.out.println("VISIBILITY_CLIENT_STATE "+role+" phase="+phase+" position="+mc.player.getPosition()+" rendered="+rendered+" screen="+(mc.currentScreen==null?null:mc.currentScreen.getClass().getSimpleName())+" container="+mc.player.openContainer.getClass().getSimpleName()); + if(phase==12 && frames%200==0) { + EntityLootableBody b=body(mc,"first");RuntimeChecks.require(b!=null&&!b.isDead&&!b.isInvisible(),"Corpse disappeared during five-minute wait"); + } + if(done.contains(phase) || frames<20)return; + if(phase==0 || phase==1 || phase==13){ack();return;} + if(phase==2 || phase==14) { + EntityLootableBody b=body(mc,phase==2?"first":"second");if(b==null)return; + if(role.equals("A") && !(mc.currentScreen instanceof GuiGameOver))return; + RuntimeChecks.require(!b.isDead&&!b.isInvisible(),"Received corpse is dead/invisible");shot(mc,"death-"+phase+".png");ack(); + } else if(phase==3 || phase==15) { + if(role.equals("A")) { + if(mc.currentScreen instanceof GuiGameOver){if(!respawnSent){mc.player.respawnPlayer();respawnSent=true;}return;} + if(!mc.player.isEntityAlive())return; + } + if(body(mc,phase==3?"first":"second")!=null)ack(); + } else if(phase==4 || phase==16 || phase==19) { + EntityLootableBody b=body(mc,phase==16?"second":"first");if(b==null)return; + if(!(mc.currentScreen instanceof CorpseGUIContainer)) { + if(!rendered.contains(b.getUniqueID()))return; + mc.playerController.interactWithEntity(mc.player,b,null,EnumHand.MAIN_HAND);return; + } + int index=phase==16?411:410;boolean found=false; + for(int slot=0;slot<48;slot++)if(ItemStack.areItemStacksEqual(mc.player.openContainer.getSlot(slot).getStack(),RuntimeChecks.stack(index)))found=true; + if(!found || ++guiFrames<10)return;shot(mc,"corpse-gui-"+phase+".png");ack(); + } else if(phase==5) {mc.player.closeScreen();ack(); + } else if(phase==6) { + boolean present=body(mc,"first")!=null;if(present==role.equals("B"))ack(); + } else if(phase==7 || phase==9 || phase==11) { + EntityLootableBody b=body(mc,"first");if(b!=null&&!b.isDead&&!b.isInvisible()&&rendered.contains(b.getUniqueID())){shot(mc,"tracked-"+phase+".png");ack();} + } else if(phase==8 || phase==10) { + if(role.equals(phase==8?"A":"B")){mc.world.sendQuittingDisconnectingPacket();mc.loadWorld(null);mc.displayGuiScreen(new GuiMainMenu());connecting=false;} + ack(); + } else if(phase==12 || phase==120) { + EntityLootableBody b=body(mc,"first");if(b!=null&&!b.isDead&&rendered.contains(b.getUniqueID()))ack(); + } else if(phase==17) { + if(role.equals("A"))for(int slot=0;slot<48;slot++)if(ItemStack.areItemStacksEqual(mc.player.openContainer.getSlot(slot).getStack(),RuntimeChecks.stack(411)))mc.playerController.windowClick(mc.player.openContainer.windowId,slot,0,ClickType.QUICK_MOVE,mc.player); + // The following acknowledgement is not inventory proof; the server checks the resulting packet state. + ack(); + } else if(phase==18) { + new File(mc.mcDataDir,"visibility-client.pass").createNewFile();System.out.println("LOOTABLE_BODIES_VISIBILITY_CLIENT_PASS role="+role); + mc.player.closeScreen();mc.world.sendQuittingDisconnectingPacket();mc.loadWorld(null);mc.displayGuiScreen(new GuiMainMenu());finished=true; + } else if(phase==20) { + if(body(mc,"first")==null&&body(mc,"second")==null)ack(); + } else if(phase==21) { + mc.player.rotationYaw=frames*3;mc.player.rotationPitch=25; + EntityLootableBody a=body(mc,"first"),b=body(mc,"second"); + if(a!=null&&b!=null&&!a.isDead&&!b.isDead&&rendered.contains(a.getUniqueID())&&rendered.contains(b.getUniqueID())) {shot(mc,"chunk-reentry.png");ack();} + } + } catch(Throwable error){error.printStackTrace();finished=true;mc.shutdown();} + } + private EntityLootableBody body(Minecraft mc,String name) throws Exception { + File file=VisibilityChecks.file(name+".uuid");if(!file.isFile())return null;UUID id=UUID.fromString(new String(Files.readAllBytes(file.toPath()),StandardCharsets.UTF_8)); + for(Entity e:mc.world.loadedEntityList)if(e instanceof EntityLootableBody&&e.getUniqueID().equals(id))return (EntityLootableBody)e;return null; + } + private void ack() throws Exception {done.add(phase);Files.write(VisibilityChecks.file(role+".visibility-"+phase).toPath(),new byte[]{1});} + private static void shot(Minecraft mc,String name){ScreenShotHelper.saveScreenshot(mc.mcDataDir,name,mc.displayWidth,mc.displayHeight,mc.getFramebuffer());} +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/VisibilitySkinChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/VisibilitySkinChecks.java new file mode 100644 index 0000000..d070c64 --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/VisibilitySkinChecks.java @@ -0,0 +1,81 @@ +package cyano.lootable.buildtest; + +import com.mojang.authlib.GameProfile; +import com.mojang.authlib.minecraft.*; +import cyano.lootable.LootableBodies; +import cyano.lootable.entities.EntityLootableBody; +import cyano.lootable.graphics.CorpseRenderer; +import java.io.*; +import java.lang.reflect.*; +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import net.minecraft.client.Minecraft; +import net.minecraft.client.resources.*; +import net.minecraft.server.management.PlayerProfileCache; +import net.minecraft.tileentity.TileEntitySkull; +import net.minecraft.util.ResourceLocation; +import net.minecraftforge.fml.relauncher.ReflectionHelper; + +/** Deterministic service faults; no real account or authentication service required. */ +final class VisibilitySkinChecks { + static void run(Minecraft mc) throws Exception { + RuntimeChecks.require(!LootableBodies.useLocalSkin,"Visibility checks require the production texture default"); + File report=new File(mc.mcDataDir,"skin-checks.txt"); + List failures=new ArrayList<>(); + Field skinField=field(Minecraft.class,SkinManager.class); + SkinManager original=(SkinManager)skinField.get(mc); + GameProfile profile=new GameProfile(UUID.fromString("00000000-0000-4000-8000-000000000201"),"VisibilityFault"); + try { + skinField.set(mc,new SkinManager(mc.getTextureManager(),new File(mc.mcDataDir,"skin-fixture"),mc.getSessionService()) { + @Override public Map loadSkinFromCache(GameProfile p) { + throw new IllegalStateException("injected skin cache failure"); + } + }); + try { + ResourceLocation texture=CorpseRenderer.getSkin(profile); + RuntimeChecks.require(DefaultPlayerSkin.getDefaultSkin(profile.getId()).equals(texture),"Failed skin lookup did not use vanilla fallback"); + mark(report,"skin_cache_failure_has_visible_default",true); + } catch(Throwable failure) {failures.add("skin cache: "+failure);mark(report,"skin_cache_failure_has_visible_default",false);} + } finally {skinField.set(mc,original);} + Field cache=field(TileEntitySkull.class,PlayerProfileCache.class); + Field service=field(TileEntitySkull.class,MinecraftSessionService.class); + Object oldCache=cache.get(null),oldService=service.get(null); + AtomicInteger requests=new AtomicInteger(); + CountDownLatch failedRequest=new CountDownLatch(1); + try { + TileEntitySkull.setProfileCache(new PlayerProfileCache(null,new File(mc.mcDataDir,"fault-usercache.json")) { + @Override public GameProfile getGameProfileForUsername(String name) {return new GameProfile(profile.getId(),name);} + }); + TileEntitySkull.setSessionService((MinecraftSessionService)Proxy.newProxyInstance(MinecraftSessionService.class.getClassLoader(),new Class[]{MinecraftSessionService.class},(proxy,method,args)->{ + if(method.getName().equals("fillProfileProperties")) {requests.incrementAndGet();failedRequest.countDown();throw new IllegalStateException("injected profile lookup failure");} + throw new UnsupportedOperationException(method.getName()); + })); + EntityLootableBody body=new EntityLootableBody(mc.world);body.setUserName("VisibilityFault"); + try {body.onEntityUpdate();RuntimeChecks.require(body.getGameProfile()!=null,"Failure erased fallback profile");mark(report,"profile_failure_does_not_abort_client_tick",true);} + catch(Throwable failure) {failures.add("profile: "+failure);mark(report,"profile_failure_does_not_abort_client_tick",false);} + RuntimeChecks.require(failedRequest.await(2,TimeUnit.SECONDS),"Profile failure was never injected"); + // Deliberately slow lookup must leave the render/tick thread responsive. + TileEntitySkull.setSessionService((MinecraftSessionService)Proxy.newProxyInstance(MinecraftSessionService.class.getClassLoader(),new Class[]{MinecraftSessionService.class},(proxy,method,args)->{ + if(method.getName().equals("fillProfileProperties")) {requests.incrementAndGet();Thread.sleep(400);return args[0];} + throw new UnsupportedOperationException(method.getName()); + })); + EntityLootableBody slow=new EntityLootableBody(mc.world);slow.setUserName("VisibilitySlow"); + long start=System.nanoTime();slow.onEntityUpdate();long millis=(System.nanoTime()-start)/1000000; + try {RuntimeChecks.require(millis<150,"Slow profile blocked client tick for "+millis+"ms");mark(report,"delayed_profile_does_not_block_client_tick",true);} + catch(Throwable failure) {failures.add("delay: "+failure);mark(report,"delayed_profile_does_not_block_client_tick",false);} + boolean local=LootableBodies.useLocalSkin;LootableBodies.useLocalSkin=true; + slow.setUserName("VisibilityRenamed");slow.onEntityUpdate();LootableBodies.useLocalSkin=local; + Thread.sleep(650); // Only the fault fixture waits; never production code. + RuntimeChecks.require(requests.get()==2,"Faults never exercised actual profile services"); + RuntimeChecks.require(slow.getGameProfile().getName().equals("VisibilityRenamed"),"Late skin result overwrote renamed corpse"); + mark(report,"late_profile_result_preserves_new_name",true); + } finally {cache.set(null,oldCache);service.set(null,oldService);} + if(!failures.isEmpty())throw new AssertionError("VISIBILITY_SKIN_FAILURES "+failures); + } + private static void mark(File report,String name,boolean passed) throws Exception { + try(FileWriter w=new FileWriter(report,true)){w.write((passed?"PASS ":"FAIL ")+name+"\n");} + } + private static Field field(Class owner,Class type) {for(Field f:owner.getDeclaredFields())if(f.getType()==type){f.setAccessible(true);return f;}throw new AssertionError(type);} +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/VisibilitySkinClientChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/VisibilitySkinClientChecks.java new file mode 100644 index 0000000..83da70c --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/VisibilitySkinClientChecks.java @@ -0,0 +1,53 @@ +package cyano.lootable.buildtest; + +import java.io.File; +import java.lang.reflect.Field; +import java.util.*; +import com.mojang.authlib.GameProfile; +import com.mojang.authlib.minecraft.MinecraftProfileTexture; +import cyano.lootable.LootableBodies; +import cyano.lootable.entities.EntityLootableBody; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.*; +import net.minecraft.client.gui.inventory.GuiInventory; +import net.minecraft.client.resources.SkinManager; +import net.minecraft.util.ScreenShotHelper; +import net.minecraft.world.*; + +final class VisibilitySkinClientChecks { + private int stage,frames; + private Field skinField; + private SkinManager original; + void tick(Minecraft mc) throws Exception { + if(stage==0 && mc.currentScreen instanceof GuiMainMenu) { + mc.gameSettings.pauseOnLostFocus=false;mc.gameSettings.limitFramerate=30;mc.gameSettings.renderDistanceChunks=4; + mc.launchIntegratedServer("skin-fixture","Skin fixture",new WorldSettings(4267321,GameType.SURVIVAL,false,false,WorldType.FLAT).enableCommands());stage=1; + } else if(stage==1 && mc.world!=null && mc.player!=null && ++frames>60) { + VisibilitySkinChecks.run(mc); + for(Field field:Minecraft.class.getDeclaredFields())if(field.getType()==SkinManager.class){skinField=field;skinField.setAccessible(true);} + original=(SkinManager)skinField.get(mc); + skinField.set(mc,new SkinManager(mc.getTextureManager(),new File(mc.mcDataDir,"skin-fixture"),mc.getSessionService()) { + @Override public Map loadSkinFromCache(GameProfile p){throw new IllegalStateException("injected render skin cache failure");} + }); + mc.displayGuiScreen(new Panel(mc));stage=2;frames=0; + } else if(stage==2 && ++frames>30) { + ScreenShotHelper.saveScreenshot(mc.mcDataDir,"skin-fallback-models.png",mc.displayWidth,mc.displayHeight,mc.getFramebuffer()); + skinField.set(mc,original);new File(mc.mcDataDir,"client.pass").createNewFile(); + System.out.println("LOOTABLE_BODIES_VISIBILITY_SKIN_PASS");mc.shutdown();stage=3; + } + } + private static final class Panel extends GuiScreen { + private final EntityLootableBody[] bodies=new EntityLootableBody[3]; + Panel(Minecraft mc){for(int i=0;i<3;i++){bodies[i]=new EntityLootableBody(mc.world);bodies[i].setGameProfile(new GameProfile(new UUID(0,512+i),"VisibilityModel"));}} + @Override public void drawScreen(int mouseX,int mouseY,float partial) { + drawRect(0,0,width,height,0xff304050); + String[] names={"Failed skin: wide arms","Failed skin: slim arms","Local texture"};boolean local=LootableBodies.useLocalSkin; + try {for(int i=0;i<3;i++) { + LootableBodies.useLocalSkin=i==2;int x=(2*i+1)*width/6; + drawCenteredString(fontRendererObj,names[i],x,35,0xffffff); + GuiInventory.drawEntityOnScreen(x,height*2/3,65,20,-20,bodies[i]); + }} finally {LootableBodies.useLocalSkin=local;} + } + @Override public boolean doesGuiPauseGame(){return false;} + } +} diff --git a/src/integrationTest/resources/log4j2.xml b/src/integrationTest/resources/log4j2.xml new file mode 100644 index 0000000..d15f663 --- /dev/null +++ b/src/integrationTest/resources/log4j2.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/integrationTest/resources/mcmod.info b/src/integrationTest/resources/mcmod.info new file mode 100644 index 0000000..b4cf69c --- /dev/null +++ b/src/integrationTest/resources/mcmod.info @@ -0,0 +1 @@ +[{"modid":"lootablebodiesbuildtest","name":"Lootable Bodies Build Tests","version":"1","mcversion":"1.10.2","authorList":[],"dependencies":[]}] diff --git a/src/main/java/cyano/lootable/ClientProxy.java b/src/main/java/cyano/lootable/ClientProxy.java index f173e9f..23a7e26 100644 --- a/src/main/java/cyano/lootable/ClientProxy.java +++ b/src/main/java/cyano/lootable/ClientProxy.java @@ -14,6 +14,7 @@ public class ClientProxy extends Proxy{ @Override public void preInit(FMLPreInitializationEvent e) { super.preInit(e); + net.minecraftforge.common.MinecraftForge.EVENT_BUS.register(new cyano.lootable.graphics.ClientConfigChanges()); // client-only pre-init code RenderingRegistry.registerEntityRenderingHandler(EntityLootableBody.class, new IRenderFactory() { @Override diff --git a/src/main/java/cyano/lootable/LootableBodies.java b/src/main/java/cyano/lootable/LootableBodies.java index 806a6ce..142aa67 100644 --- a/src/main/java/cyano/lootable/LootableBodies.java +++ b/src/main/java/cyano/lootable/LootableBodies.java @@ -14,14 +14,17 @@ import net.minecraftforge.fml.common.event.FMLPreInitializationEvent; import net.minecraftforge.fml.common.network.NetworkRegistry; import net.minecraftforge.fml.common.registry.EntityRegistry; +import java.io.File; @Mod(modid = LootableBodies.MODID, name=LootableBodies.NAME, version = LootableBodies.VERSION, - acceptedMinecraftVersions = "[1.10.2,)") + acceptedMinecraftVersions = "[1.10.2]", + guiFactory = "cyano.lootable.graphics.CorpseConfigGuiFactory") public class LootableBodies { public static final String MODID = "lootablebodies"; public static final String NAME ="DrCyano's Lootable Bodies"; - public static final String VERSION = "2.2.0"; + public static final String VERSION = "2.4.1.110021"; private static LootableBodies INSTANCE = null; + private static Configuration configuration; public static boolean displayNameTag = true; public static boolean addBonesToCorpse = true; @@ -45,9 +48,16 @@ public class LootableBodies { @EventHandler public void preInit(FMLPreInitializationEvent event) { INSTANCE = this; + loadConfiguration(event.getSuggestedConfigurationFile()); + proxy.preInit(event); + } + + /** Reads startup rules. GUI saves deliberately do not call this method. */ + public static void loadConfiguration(File file) { // Do stuff in pre-init phase (read config, create blocks and items, register them) // load config - Configuration config = new Configuration(event.getSuggestedConfigurationFile()); + Configuration config = new Configuration(file); + configuration = config; config.load(); corpseHP = config.getFloat("corpse_HP", "options", corpseHP, 1,Short.MAX_VALUE, @@ -95,12 +105,36 @@ public class LootableBodies { + "The format is hours:minutes:seconds or just hours:minutes"); corpseDecayTime = Math.max(parseTimeInSeconds(decayTime),2)*20; // 2 second minimum + for (String category : new String[] {"options", "corpse damage", "corpse decay"}) { + for (net.minecraftforge.common.config.Property property : config.getCategory(category).values()) { + String key = property.getName(); + // Unknown pack options are retained without exposing them in our GUI. + if (!isKnownOption(key)) continue; + property.setLanguageKey("lootablebodies.config." + key); + property.setRequiresMcRestart(!key.equals("local_texture") && !key.equals("display_nametag")); + } + } config.save(); - proxy.preInit(event); } - - private int parseTimeInSeconds(String time) { + private static boolean isKnownOption(String key) { + return java.util.Arrays.asList("corpse_HP", "local_texture", "display_nametag", "add_bones_to_corpse", + "hurt_by_environment", "hurt_by_weapons", "hurt_by_misc", "item_decay_rate", + "enable_corpse_decay", "empty_only_decay", "corpse_decay_time").contains(key); + } + + public static Configuration getConfiguration() { + return configuration; + } + + /** Persists pending rules, applying only this client's appearance preferences. */ + public static void saveClientConfiguration() { + if (configuration == null) return; + useLocalSkin = configuration.getCategory("options").get("local_texture").getBoolean(); + displayNameTag = configuration.getCategory("options").get("display_nametag").getBoolean(); + configuration.save(); + } + private static int parseTimeInSeconds(String time) { String[] component = time.split(":"); int hr = 0,min=0,sec=0; if(component.length > 0)hr = Integer.parseInt(component[0].trim()); diff --git a/src/main/java/cyano/lootable/entities/EntityLootableBody.java b/src/main/java/cyano/lootable/entities/EntityLootableBody.java index d86f1be..5beb403 100644 --- a/src/main/java/cyano/lootable/entities/EntityLootableBody.java +++ b/src/main/java/cyano/lootable/entities/EntityLootableBody.java @@ -6,6 +6,8 @@ import net.minecraft.entity.EntityLiving; import net.minecraft.entity.EnumCreatureAttribute; import net.minecraft.entity.SharedMonsterAttributes; +import net.minecraft.entity.ai.attributes.AttributeModifier; +import net.minecraft.entity.ai.attributes.IAttributeInstance; import net.minecraft.entity.item.EntityItem; import net.minecraft.entity.player.EntityPlayer; import net.minecraft.inventory.EntityEquipmentSlot; @@ -13,13 +15,17 @@ import net.minecraft.item.Item; import net.minecraft.item.ItemSpade; import net.minecraft.item.ItemStack; +import net.minecraft.nbt.NBTBase; +import net.minecraft.nbt.NBTPrimitive; import net.minecraft.nbt.NBTTagCompound; import net.minecraft.nbt.NBTTagList; +import net.minecraft.potion.Potion; import net.minecraft.tileentity.TileEntitySkull; import net.minecraft.util.DamageSource; import net.minecraft.util.EntityDamageSource; import net.minecraft.util.EnumHand; import net.minecraft.util.SoundEvent; +import net.minecraft.util.ResourceLocation; import net.minecraft.util.math.AxisAlignedBB; import net.minecraft.util.math.BlockPos; import net.minecraft.world.World; @@ -28,7 +34,12 @@ import org.apache.commons.lang3.ObjectUtils; import java.util.*; +import java.nio.charset.StandardCharsets; import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.RejectedExecutionException; public class EntityLootableBody extends EntityLiving implements IInventory{ @@ -41,20 +52,48 @@ public class EntityLootableBody extends EntityLiving implements IInventory{ private final ItemStack[] mainInventory = new ItemStack[6*7]; private final Deque auxInventory = new LinkedList<>(); + private static final String UNRESOLVED_ITEMS_TAG = "LootableBodiesUnresolved"; + private final List unresolvedInventory = new ArrayList<>(); + private static final String INVALID_FIELDS_TAG = "LootableBodiesInvalidFields"; + // Evidence about metadata is separate from inventory and must not prevent decay. + private final List invalidFields = new ArrayList<>(); + private final Map defaultAttributeBases = new HashMap<>(); + private final Map> defaultAttributeModifiers = new HashMap<>(); private byte vacuumTime = 0; + private boolean positionNeedsChunk; + private boolean malformedPosition; + private NBTBase loadedPosition; + private boolean missingPositionX, missingPositionZ; private long deathTimestamp; private long decayTimestamp; private final AtomicReference gpSwap = new AtomicReference<>(null) ; // for lazy-evaluation of player skins + // Profile services can be slow or unavailable. A bounded daemon worker must + // never hold up client ticks or prevent the default corpse from being drawn. + private static final ThreadPoolExecutor PROFILE_LOOKUPS = new ThreadPoolExecutor(0, 1, 30L, + TimeUnit.SECONDS, new ArrayBlockingQueue(64), task -> { + Thread thread = new Thread(task,"Lootable Bodies skin lookup"); + thread.setDaemon(true); + return thread; + }); private int terminate = -1; private static final int DEATH_COUNTDOWN = 10; public EntityLootableBody(World w) { super(w); + // Keep EntityLiving's actual defaults (including follow range 16 and + // configured corpse health), rather than the generic attribute defaults. + for(IAttributeInstance attribute : getAttributeMap().getAllAttributes()) { + String name = attribute.getAttribute().getName(); + defaultAttributeBases.put(name,attribute.getBaseValue()); + defaultAttributeModifiers.put(name,new ArrayList<>(attribute.getModifiers())); + } deathTimestamp = w.getTotalWorldTime(); decayTimestamp = deathTimestamp; - this.setAlwaysRenderNameTag(LootableBodies.displayNameTag); + // Local display preferences must not become world/server entity state. + // Existing saved visibility flags are still read unchanged by vanilla. + this.setAlwaysRenderNameTag(true); this.setSize(0.85F, 0.75F); this.isImmuneToFire = LootableBodies.completelyInvulnerable || (!LootableBodies.hurtByEnvironment); @@ -86,6 +125,21 @@ private void log(Object o){ } private String oldName = null; + @Override + public void onUpdate() { + if(!getEntityWorld().isRemote) { + // A Forge ticket can tick our centre chunk while neighbouring support + // terrain is absent. Never treat those missing collision boxes as air. + AxisAlignedBB bounds = getEntityBoundingBox(); + BlockPos from = new BlockPos(bounds.minX + Math.min(0D,motionX), 0, + bounds.minZ + Math.min(0D,motionZ)); + BlockPos to = new BlockPos(bounds.maxX + Math.max(0D,motionX), 0, + bounds.maxZ + Math.max(0D,motionZ)); + if(!getEntityWorld().isAreaLoaded(from,to)) return; + } + super.onUpdate(); + } + @Override public void onEntityUpdate(){ //log("position=%s; terminate=%s; time=%s; age=%s; deathTimestamp=%s; decayTimestamp=%s; Health=%s; isDead=%s" ,getPosition(),terminate,getEntityWorld().getTotalWorldTime(),getEntityWorld().getTotalWorldTime() - deathTimestamp, deathTimestamp, decayTimestamp, getHealth(), isDead); @@ -95,7 +149,11 @@ public void onEntityUpdate(){ if(isServer && terminate >= 0){ // in death mode - if(terminate == DEATH_COUNTDOWN) dropAllItems(); + if(terminate == DEATH_COUNTDOWN && !dropAllItems()) { + // Keep rejected stacks recoverable; vanilla must not remove their owner. + cancelRemoval(); + return; + } // handle self-removal from entity list in world if(terminate > 0) terminate--; if(terminate == 0){ @@ -114,8 +172,8 @@ public void onEntityUpdate(){ oldName = nameUpdate; if(nameUpdate != null && nameUpdate.trim().length() > 0) { GameProfile gp = new GameProfile(null, nameUpdate); - if((!LootableBodies.useLocalSkin) && isClient) gp = TileEntitySkull.updateGameprofile(gp); setGameProfile(gp); + if((!LootableBodies.useLocalSkin) && isClient) resolveProfile(gp); this.setCustomNameTag(nameUpdate); } else { setGameProfile(null); @@ -124,7 +182,12 @@ public void onEntityUpdate(){ } // handle decay time - final long currentTime = getEntityWorld().getTotalWorldTime(); + final long currentTime = Math.max(0L,getEntityWorld().getTotalWorldTime()); + if(isServer) { + // A clock rollback must not suspend decay indefinitely or overflow age. + deathTimestamp = checkedTimestamp("DeathTime",deathTimestamp,currentTime); + decayTimestamp = checkedTimestamp("DecayTime",decayTimestamp,currentTime); + } if(isServer && LootableBodies.allowCorpseDecay){ if(LootableBodies.decayOnlyWhenEmpty && isEmpty() == false){ deathTimestamp = currentTime; @@ -139,9 +202,10 @@ public void onEntityUpdate(){ if(isServer && LootableBodies.ticksPerItemDecay > 0){ long age = currentTime - decayTimestamp; if(age > LootableBodies.ticksPerItemDecay){ - int decayAmount = (int)(age / LootableBodies.ticksPerItemDecay); - decayTimestamp += decayAmount * LootableBodies.ticksPerItemDecay; - decayItems(decayAmount); + long elapsed = age / LootableBodies.ticksPerItemDecay; + // Consume the whole interval, even when damage exceeds an int. + decayTimestamp = currentTime - age % LootableBodies.ticksPerItemDecay; + decayItems((int)Math.min(elapsed,Integer.MAX_VALUE)); } } @@ -150,7 +214,7 @@ public void onEntityUpdate(){ if(isServer && !auxInventory.isEmpty()) { for (int slot = this.getSizeInventory() - 1; slot >= EQUIPMENT_SLOTS.length; slot--) { if(this.getStackInSlot(slot) == null){ - this.setInventorySlotContents(slot,auxInventory.pollFirst()); + if(mergeItem(slot,auxInventory.peekFirst()) == null) auxInventory.removeFirst(); this.markDirty(); break; } @@ -164,10 +228,9 @@ public void onEntityUpdate(){ posX - VACUUM_RADIUS, posY - VACUUM_RADIUS, posZ - VACUUM_RADIUS, posX + VACUUM_RADIUS, posY + VACUUM_RADIUS, posZ + VACUUM_RADIUS)); for(EntityItem item : items){ + if(item.isDead) continue; ItemStack stack = item.getEntityItem(); this.addItem(stack); - } - for(EntityItem item : items){ getEntityWorld().removeEntity(item); } } @@ -202,6 +265,24 @@ public void addItem(ItemStack stack) { if(stack == null) return; } auxInventory.addLast(stack); + markDirty(); + } + + /** Retain an item whose container release was rejected. Uses the existing Aux format. */ + public void retainItem(ItemStack stack) { + if(stack == null || stack.stackSize <= 0) return; + if(terminate >= 0 || !isEntityAlive()) cancelRemoval(); + addItem(stack); + } + + private void cancelRemoval() { + terminate = -1; + deathTime = 0; + dead = false; + isDead = false; + setHealth(getMaxHealth()); + vacuumTime = VACUUM_TIMELIMIT; + markDirty(); } @@ -211,15 +292,17 @@ public void initializeItems(ItemStack[] inv) { for(int i = mainInventory.length; i < inv.length; i++){ auxInventory.addLast(inv[i]); } + markDirty(); } public ItemStack mergeItem(int slot, ItemStack stack) { - if(stack == null) return null; + if(stack == null || stack.stackSize <= 0) return null; ItemStack current = this.getStackInSlot(slot); final int inventorySizeLimit = Math.min(stack.getMaxStackSize(),this.getInventoryStackLimit()); if(current == null){ - this.setInventorySlotContents(slot, stack); - return null; + int amount = Math.min(stack.stackSize,inventorySizeLimit); + if(amount > 0) this.setInventorySlotContents(slot,stack.splitStack(amount)); + return stack.stackSize > 0 ? stack : null; } else if(current.stackSize < inventorySizeLimit && ItemStack.areItemsEqual(stack,current) && ItemStack.areItemStackTagsEqual(stack,current)){ @@ -283,6 +366,28 @@ public GameProfile getGameProfile(){ public void setGameProfile(GameProfile gp){ gpSwap.set(gp); } + /** Refreshes an existing client body after enabling player skins in the GUI. */ + public void refreshClientSkin() { + GameProfile profile = gpSwap.get(); + if (getEntityWorld().isRemote && !LootableBodies.useLocalSkin && profile != null && profile.getId() == null) + resolveProfile(profile); + } + private void resolveProfile(GameProfile initial) { + try { + PROFILE_LOOKUPS.execute(() -> { + try { + GameProfile resolved = TileEntitySkull.updateGameprofile(initial); + // A rename, reload or explicit profile update wins over a late lookup. + if(resolved != null) gpSwap.compareAndSet(initial,resolved); + } catch(RuntimeException unavailable) { + FMLLog.fine("Corpse profile lookup unavailable; using default skin"); + } + }); + } catch(RejectedExecutionException busy) { + // Keep the already installed default profile when the bounded queue is full. + FMLLog.fine("Corpse profile lookup busy; using default skin"); + } + } public void setUserName(String name){ this.setCustomNameTag(name); } @@ -411,6 +516,11 @@ protected Item getDropItem() { return null; } + @Override + protected void dropEquipment(boolean wasRecentlyHit, int lootingModifier) { + // Corpse equipment belongs to the inventory release path, not mob loot. + } + @Override public boolean canBeSteered() { return false; @@ -474,6 +584,7 @@ public void setInventorySlotContents(int index, ItemStack stack) { } else { mainInventory[index - EQUIPMENT_SLOTS.length] = stack; } + markDirty(); } /** @@ -497,6 +608,7 @@ public ItemStack removeStackFromSlot(int index) { @Override public ItemStack decrStackSize(int index, int count) { ItemStack i = this.getStackInSlot(index); + if(i == null || count <= 0) return null; ItemStack result = i.splitStack(count); if (i.stackSize <= 0) { i = null; @@ -519,7 +631,9 @@ public int getInventoryStackLimit() { */ @Override public void markDirty() { - // do nothing + if(world != null && !world.isRemote && addedToChunk) { + world.markChunkDirty(getPosition(),null); + } } /** @@ -529,12 +643,14 @@ public void markDirty() { */ @Override public boolean isUsableByPlayer(EntityPlayer player) { - return true; + return player != null && player.isEntityAlive() && this.isEntityAlive() + && terminate < 0 && player.getEntityWorld() == getEntityWorld() + && player.getPosition().distanceSq(this.getPosition()) < 25; } @Override public boolean processInteract(EntityPlayer player, EnumHand hand, ItemStack item){ - if(player.getPosition().distanceSq(this.getPosition()) < 25) { + if(isUsableByPlayer(player)) { FMLNetworkHandler.openGui(player, LootableBodies.getInstance(), 0, getEntityWorld(), this.getEntityId(), 0, 0); return true; } @@ -557,6 +673,7 @@ public void closeInventory(EntityPlayer player) { */ @Override public boolean isItemValidForSlot(int index, ItemStack stack) { + if(stack == null || stack.stackSize <= 0) return false; if(index < 4){ return stack.getItem().isValidArmor (stack, EQUIPMENT_SLOTS[index], this); } else { @@ -586,29 +703,35 @@ public void clear() { } Arrays.fill(mainInventory,null); auxInventory.clear(); + unresolvedInventory.clear(); } ////////// End of IInventory ////////// public boolean isEmpty(){ - if(!auxInventory.isEmpty()) return false; + if(!auxInventory.isEmpty() || !unresolvedInventory.isEmpty()) return false; for(int slot = 0; slot < this.getSizeInventory(); slot++){ if(this.getStackInSlot(slot) != null) return false; } return true; } - private void dropAllItems(){ - for(EntityEquipmentSlot e : EQUIPMENT_SLOTS){ - ItemStack i = this.getItemStackFromSlot(e); - if(i != null)this.getEntityWorld().spawnEntity(new EntityItem(this.getEntityWorld(),posX,posY,posZ, - i)); - }for(ItemStack i : mainInventory){ - if(i != null)this.getEntityWorld().spawnEntity(new EntityItem(this.getEntityWorld(),posX,posY,posZ,i)); - }for(ItemStack i : auxInventory){ - if(i != null)this.getEntityWorld().spawnEntity(new EntityItem(this.getEntityWorld(),posX,posY,posZ,i)); + private boolean dropAllItems(){ + for(int slot = 0; slot < getSizeInventory(); slot++){ + ItemStack stack = getStackInSlot(slot); + if(stack != null && getEntityWorld().spawnEntity( + new EntityItem(getEntityWorld(),posX,posY,posZ,stack.copy()))) { + setInventorySlotContents(slot,null); + } } - this.clear(); + Iterator iterator = auxInventory.iterator(); + while(iterator.hasNext()) { + ItemStack stack = iterator.next(); + if(stack == null || getEntityWorld().spawnEntity( + new EntityItem(getEntityWorld(),posX,posY,posZ,stack.copy()))) iterator.remove(); + } + markDirty(); + return isEmpty(); } private static boolean matchesAny(String damageType, DamageSource... list){ @@ -627,6 +750,7 @@ public EnumCreatureAttribute getCreatureAttribute() @Override public void writeEntityToNBT(final NBTTagCompound root) { + root.removeTag("ActiveEffects"); super.writeEntityToNBT(root); final NBTTagList equipmentListTag = new NBTTagList(); for (int i = 0; i < this.getSizeInventory(); i++) { @@ -640,6 +764,7 @@ public void writeEntityToNBT(final NBTTagCompound root) { } root.setTag("Equipment", equipmentListTag); + root.removeTag("Aux"); if(!this.auxInventory.isEmpty()){ final NBTTagList nbtauxtaglist = new NBTTagList(); Iterator iter = this.auxInventory.iterator(); @@ -652,48 +777,442 @@ public void writeEntityToNBT(final NBTTagCompound root) { } root.setTag("Aux", nbtauxtaglist); } - if(vacuumTime < VACUUM_TIMELIMIT)root.setByte("Vac", vacuumTime); - if(getGameProfile() != null){ - root.setString("Name", getGameProfile().getName()); + root.removeTag(UNRESOLVED_ITEMS_TAG); + if(!unresolvedInventory.isEmpty()) { + NBTTagList retained = new NBTTagList(); + for(NBTTagCompound record : unresolvedInventory) retained.appendTag(record.copy()); + root.setTag(UNRESOLVED_ITEMS_TAG,retained); + } + root.removeTag(INVALID_FIELDS_TAG); + if(!invalidFields.isEmpty()) { + NBTTagList retained = new NBTTagList(); + for(NBTTagCompound record : invalidFields) retained.appendTag(record.copy()); + root.setTag(INVALID_FIELDS_TAG,retained); } + root.removeTag("Vac"); + if(vacuumTime < VACUUM_TIMELIMIT)root.setByte("Vac", vacuumTime); + root.removeTag("Name"); + String name = getCustomNameTag(); + if(name != null && !name.trim().isEmpty()) root.setString("Name",name); + else root.removeTag("CustomName"); root.setLong("DeathTime", this.getDeathTimestamp()); root.setLong("DecayTime",this.decayTimestamp); } + @Override + public void readFromNBT(final NBTTagCompound root) { + // Entity reads geometry before our inventory helper. Sanitize a copy + // before it builds bounding boxes or derives previous-tick positions. + NBTTagCompound clean = root.copy(); + boolean[] badPosition = new boolean[4], badMotion = new boolean[4]; + boolean[] badRotation = new boolean[3]; + clean.setTag("Pos",readVector(root.getTag("Pos"),false,badPosition)); + clean.setTag("Motion",readVector(root.getTag("Motion"),true,badMotion)); + clean.setTag("Rotation",readRotation(root.getTag("Rotation"),badRotation)); + Float distance = finiteFloat(root.getTag("FallDistance")); + boolean badFall = root.hasKey("FallDistance") && (distance == null || distance < 0F); + clean.setFloat("FallDistance",distance != null && distance >= 0F ? distance : 0F); + super.readFromNBT(clean); + // A repeated load must not interpolate from obsolete head/body angles. + prevRotationYaw = rotationYaw; prevRotationPitch = rotationPitch; + setRotationYawHead(rotationYaw); prevRotationYawHead = rotationYaw; + setRenderYawOffset(rotationYaw); prevRenderYawOffset = rotationYaw; + positionNeedsChunk = true; + malformedPosition = hasInvalidComponent(badPosition); + loadedPosition = root.hasKey("Pos") ? root.getTag("Pos").copy() : null; + missingPositionX = badPosition[0]; missingPositionZ = badPosition[2]; + if(malformedPosition && root.hasKey("Pos")) retainInvalid("Pos",root.getTag("Pos")); + if(hasInvalidComponent(badMotion) && root.hasKey("Motion")) retainInvalid("Motion",root.getTag("Motion")); + if(hasInvalidComponent(badRotation) && root.hasKey("Rotation")) retainInvalid("Rotation",root.getTag("Rotation")); + if(badFall) retainInvalid("FallDistance",root.getTag("FallDistance")); + } + + private static Float finiteFloat(NBTBase raw) { + Double number = finiteNumber(raw); + if(number == null) return null; + float value = number.floatValue(); + return Float.isFinite(value) && (number == 0D || value != 0F) ? value : null; + } + + private static NBTTagList readRotation(NBTBase raw, boolean[] invalid) { + NBTTagList list = raw instanceof NBTTagList ? (NBTTagList)raw : null; + invalid[2] = list == null || list.tagCount() != 2 || list.getTagType() < 1 || list.getTagType() > 6; + NBTTagList clean = new NBTTagList(); + for(int axis = 0; axis < 2; axis++) { + Float angle = list != null && axis < list.tagCount() ? finiteFloat(list.get(axis)) : null; + if(angle == null) { invalid[axis] = true; angle = 0F; } + // Preserve the legacy float remainder and the corpse's existing + // flat pitch before vanilla copies angles into head/render state. + clean.appendTag(new net.minecraft.nbt.NBTTagFloat(axis == 0 ? angle % 360F : 0F)); + } + return clean; + } + + private static boolean hasInvalidComponent(boolean[] invalid) { + for(boolean value : invalid) if(value) return true; + return false; + } + + private static NBTTagList readVector(NBTBase raw, boolean motion, boolean[] invalid) { + NBTTagList list = raw instanceof NBTTagList ? (NBTTagList)raw : null; + invalid[3] = list == null || list.tagCount() != 3 || list.getTagType() < 1 || list.getTagType() > 6; + NBTTagList clean = new NBTTagList(); + for(int axis = 0; axis < 3; axis++) { + Double value = list != null && axis < list.tagCount() ? finiteNumber(list.get(axis)) : null; + if(value == null || Math.abs(value) > (motion ? 10D : 3.0E7D)) { + invalid[axis] = true; + // A lost height must not put recoverable inventory in the void. + value = !motion && axis == 1 ? 64D : 0D; + } + clean.appendTag(new net.minecraft.nbt.NBTTagDouble(value)); + } + return clean; + } + + /** Complete a malformed NBT position using the authoritative containing chunk. */ + public void recoverPositionInChunk(int chunkX, int chunkZ) { + if(!positionNeedsChunk) return; + positionNeedsChunk = false; + boolean wrongX = net.minecraft.util.math.MathHelper.floor(posX / 16D) != chunkX; + boolean wrongZ = net.minecraft.util.math.MathHelper.floor(posZ / 16D) != chunkZ; + if(!malformedPosition && !wrongX && !wrongZ) { loadedPosition = null; return; } + if(!malformedPosition && loadedPosition != null) retainInvalid("Pos",loadedPosition); + loadedPosition = null; + double x = missingPositionX || wrongX ? chunkX * 16D + 8.5D : posX; + double z = missingPositionZ || wrongZ ? chunkZ * 16D + 8.5D : posZ; + // Chunk.addEntity flags a location mismatch dead immediately before + // EnteringChunk. Only the first pending NBT admission may undo that + // flag; saved zero health and normal later removals remain effective. + if(wrongX || wrongZ) isDead = false; + setLocationAndAngles(x,posY,z,rotationYaw,rotationPitch); + FMLLog.warning("Lootable Bodies recovered malformed corpse position in chunk %s,%s",chunkX,chunkZ); + } + @Override public void readEntityFromNBT(final NBTTagCompound root) { - super.readEntityFromNBT(root); - if (root.hasKey("Equipment", 9)) { - final NBTTagList nbttaglist = root.getTagList("Equipment", 10); - for (int i = 0; i < nbttaglist.tagCount(); ++i) { - NBTTagCompound slotTag = nbttaglist.getCompoundTagAt(i); - int slot = slotTag.getByte("Slot"); - ItemStack item = ItemStack.loadItemStackFromNBT(slotTag); - this.setInventorySlotContents(slot,item); + // Replace, rather than append to, an existing inventory on a repeated load. + this.clear(); + // These flags are not saved. A reused entity must behave like a fresh + // load; its new Health still decides whether normal death handling starts. + terminate = -1; + dead = false; + isDead = false; + // Item readers may update their input tags; the caller's saved data is immutable. + NBTTagCompound vanilla = root.copy(); + // Our historical DeathTime is a world timestamp, not vanilla's animation. + vanilla.setShort("DeathTime",(short)0); + invalidFields.clear(); + NBTBase metadata = root.getTag(INVALID_FIELDS_TAG); + if(isInventoryList(metadata)) { + NBTTagList records = (NBTTagList)metadata; + for(int i = 0; i < records.tagCount(); i++) invalidFields.add(records.getCompoundTagAt(i).copy()); + } else if(metadata != null) retainInvalid("Recovery",metadata); + // NBT replaces state. Clear the old map without effect callbacks: saved + // attributes and absorption are restored below, just as on a fresh entity. + getActivePotionEffects().clear(); + for(IAttributeInstance attribute : getAttributeMap().getAllAttributes()) restoreAttribute(attribute); + vanilla.setTag("Attributes",readAttributes(root.getTag("Attributes"))); + vanilla.setTag("ActiveEffects",readEffects(root.getTag("ActiveEffects"))); + vanilla.setShort("HurtTime",(short)readTimer(root,"HurtTime",Short.MAX_VALUE,0)); + // Health must be checked against the loaded, validated effective maximum. + vanilla.removeTag("Health"); + vanilla.setFloat("AbsorptionAmount",readLivingFloat(root,"AbsorptionAmount",0F,Float.MAX_VALUE)); + super.readEntityFromNBT(vanilla); + updatePotionMetadata(); + for(IAttributeInstance attribute : getAttributeMap().getAllAttributes()) { + if(!Double.isFinite(attribute.getAttributeValue())) { + // Also protect against engine ordering/parent-attribute arithmetic. + if(root.hasKey("Attributes")) retainInvalid("Attributes",root.getTag("Attributes")); + restoreAttribute(attribute); } } - if(root.hasKey("Aux")){ - final NBTTagList nbttaglist = root.getTagList("Aux", 10); - for (int i = 0; i < nbttaglist.tagCount(); ++i) { - this.auxInventory.addLast(ItemStack.loadItemStackFromNBT(nbttaglist.getCompoundTagAt(i))); + setHealth(readLivingFloat(root,"Health",getMaxHealth(),getMaxHealth())); + NBTBase equipment = root.getTag("Equipment"); + if(isInventoryList(equipment)) { + // Vanilla ArmorItems/HandItems mirror these six slots. The custom + // list is authoritative when present, so do not load both copies. + for(int i = 0; i < EQUIPMENT_SLOTS.length; i++) setInventorySlotContents(i,null); + } + readInventoryTag("Equipment",equipment); + readInventoryTag("Aux",root.getTag("Aux")); + NBTBase recovery = root.getTag(UNRESOLVED_ITEMS_TAG); + if(isInventoryList(recovery)) { + NBTTagList records = (NBTTagList)recovery; + for(int i = 0; i < records.tagCount(); i++) { + NBTTagCompound record = records.getCompoundTagAt(i); + String source = record.getString("Source"); + if(record.getKeySet().size() == 2 && record.hasKey("Source",8) && record.hasKey("Data",10) + && (source.equals("Equipment") || source.equals("Aux"))) { + readInventoryEntry(source,record.getCompoundTag("Data"),record); + } else unresolvedInventory.add(record.copy()); } + } else if(recovery != null) retainRaw("Recovery",recovery); + if(!unresolvedInventory.isEmpty()) FMLLog.warning( + "Lootable Bodies retained %s unreadable inventory records for later recovery",unresolvedInventory.size()); + + long now = Math.max(0L,getEntityWorld().getTotalWorldTime()); + this.vacuumTime = (byte)readTimer(root,"Vac",VACUUM_TIMELIMIT,VACUUM_TIMELIMIT); + this.setDeathTimestamp(readTimer(root,"DeathTime",now,now)); + this.decayTimestamp = readTimer(root,"DecayTime",now,now); + String fallback = readName(root,"CustomName"); + String name = readName(root,"Name"); + this.setUserName(name != null ? name : fallback != null ? fallback : ""); + this.setGameProfile(null); + this.oldName = null; + this.setRotation(this.rotationYaw, 0); + } + + private void retainInvalid(String source, NBTBase data) { + NBTTagCompound record = new NBTTagCompound(); + record.setString("Source",source); + record.setTag("Data",data.copy()); + if(!invalidFields.contains(record)) invalidFields.add(record); + } + + private void restoreAttribute(IAttributeInstance attribute) { + String name = attribute.getAttribute().getName(); + for(AttributeModifier modifier : new ArrayList<>(attribute.getModifiers())) attribute.removeModifier(modifier); + attribute.setBaseValue(defaultAttributeBases.getOrDefault(name,attribute.getAttribute().getDefaultValue())); + List defaults = defaultAttributeModifiers.get(name); + if(defaults != null) for(AttributeModifier modifier : defaults) attribute.applyModifier(modifier); + } + + private NBTTagList readEffects(NBTBase raw) { + NBTTagList clean = new NBTTagList(); + if(raw == null) return clean; + if(!isInventoryList(raw)) { retainInvalid("ActiveEffects",raw); return clean; } + Set seen = new HashSet<>(); + NBTTagList list = (NBTTagList)raw; + for(int i = 0; i < list.tagCount(); i++) { + NBTTagCompound record = list.getCompoundTagAt(i); + NBTBase rawId = record.getTag("Id"), rawAmplifier = record.getTag("Amplifier"); + Long id = integral(rawId); + // IDs are unsigned bytes on disk. Amplifiers are read as signed + // bytes by this engine; preserve all canonical byte encodings. + if(rawId != null && rawId.getId() == 1) id = id & 255L; + Long amplifier = rawAmplifier == null ? Long.valueOf(0) : integral(rawAmplifier); + Long duration = record.hasKey("Duration") ? integral(record.getTag("Duration")) : Long.valueOf(0); + Potion potion = id != null && id >= 0 && id <= 255 ? Potion.getPotionById(id.intValue()) : null; + if(potion == null || duration == null || duration < 0 || duration > Integer.MAX_VALUE + || amplifier == null || amplifier > 127 || (amplifier < 0 && rawAmplifier.getId() != 1) + || !seen.add(potion)) { + retainInvalid("ActiveEffect",record); continue; + } + NBTTagCompound accepted = new NBTTagCompound(); + accepted.setByte("Id",id.byteValue()); accepted.setInteger("Duration",duration.intValue()); + accepted.setByte("Amplifier",amplifier.byteValue()); + boolean changed = !Arrays.asList("Id","Duration","Amplifier","Ambient","ShowParticles").containsAll(record.getKeySet()); + for(String key : Arrays.asList("Ambient","ShowParticles")) { + boolean fallback = key.equals("ShowParticles"); + Long flag = integral(record.getTag(key)); + if(!record.hasKey(key)) accepted.setBoolean(key,fallback); + else if(flag != null && flag >= 0 && flag <= 1) accepted.setBoolean(key,flag == 1); + else { accepted.setBoolean(key,fallback); changed = true; } + } + if(changed) retainInvalid("ActiveEffect",record); + clean.appendTag(accepted); } - if(root.hasKey("Vac")){ - this.vacuumTime = root.getByte("Vac"); - } else { - this.vacuumTime = VACUUM_TIMELIMIT; + return clean; + } + + private static Double finiteNumber(NBTBase raw) { + if(!(raw instanceof NBTPrimitive) || raw.getId() < 1 || raw.getId() > 6) return null; + double value = ((NBTPrimitive)raw).getDouble(); + return Double.isFinite(value) ? value : null; + } + + private float readLivingFloat(NBTTagCompound root, String key, float fallback, float maximum) { + NBTBase raw = root.getTag(key); + if(raw == null) return fallback; + Double value = finiteNumber(raw); + if(value != null && value >= 0 && value <= maximum) { + float narrowed = value.floatValue(); + if(Float.isFinite(narrowed) && (value == 0 || narrowed > 0)) return narrowed; } - if(root.hasKey("DeathTime")){ - this.setDeathTimestamp(root.getLong("DeathTime")); + retainInvalid(key,raw); + return fallback; + } + + private NBTTagList readAttributes(NBTBase raw) { + NBTTagList clean = new NBTTagList(); + if(raw == null) return clean; + if(!isInventoryList(raw)) { retainInvalid("Attributes",raw); return clean; } + Set seen = new HashSet<>(); + NBTTagList list = (NBTTagList)raw; + for(int i = 0; i < list.tagCount(); i++) { + NBTTagCompound record = list.getCompoundTagAt(i); + IAttributeInstance attribute = record.hasKey("Name",8) + ? getAttributeMap().getAttributeInstanceByName(record.getString("Name")) : null; + Double base = finiteNumber(record.getTag("Base")); + if(attribute == null || base == null) { retainInvalid("Attribute",record); continue; } + String name = attribute.getAttribute().getName(); + // corpse_HP has always allowed 1..Short.MAX_VALUE. Preserve a base + // saved under an older config, including vanilla's existing cap. + boolean configuredHealth = attribute.getAttribute() == SharedMonsterAttributes.MAX_HEALTH + && base >= 1D && base <= Short.MAX_VALUE; + if((attribute.getAttribute().clampValue(base) != base + && !configuredHealth && !base.equals(defaultAttributeBases.get(name))) || !seen.add(name)) { + retainInvalid("Attribute",record); continue; + } + boolean changed = !Arrays.asList("Name","Base","Modifiers").containsAll(record.getKeySet()); + NBTTagCompound accepted = new NBTTagCompound(); + accepted.setString("Name",name); accepted.setDouble("Base",base); + NBTBase rawModifiers = record.getTag("Modifiers"); + if(rawModifiers != null) { + NBTTagList modifiers = new NBTTagList(); + if(!isInventoryList(rawModifiers)) changed = true; + else { + Set ids = new HashSet<>(); + NBTTagList entries = (NBTTagList)rawModifiers; + for(int m = 0; m < entries.tagCount(); m++) { + NBTTagCompound entry = entries.getCompoundTagAt(m); + Long operation = integral(entry.getTag("Operation")); + Long most = integral(entry.getTag("UUIDMost")), least = integral(entry.getTag("UUIDLeast")); + Double amount = finiteNumber(entry.getTag("Amount")); + if(!entry.hasKey("Name",8) || entry.getString("Name").isEmpty() || operation == null + || operation < 0 || operation > 2 || most == null || least == null || amount == null) { + changed = true; continue; + } + UUID id = new UUID(most,least); + if(ids.contains(id)) { changed = true; continue; } + NBTTagCompound modifier = new NBTTagCompound(); + modifier.setString("Name",entry.getString("Name")); modifier.setDouble("Amount",amount); + modifier.setInteger("Operation",operation.intValue()); modifier.setUniqueId("UUID",id); + NBTTagList candidate = modifiers.copy(); candidate.appendTag(modifier); + if(!finiteAttributeValue(base,candidate)) { changed = true; continue; } + ids.add(id); modifiers.appendTag(modifier); + if(!Arrays.asList("Name","Amount","Operation","UUIDMost","UUIDLeast").containsAll(entry.getKeySet())) changed = true; + } + } + accepted.setTag("Modifiers",modifiers); + } + if(changed) retainInvalid("Attribute",record); + clean.appendTag(accepted); + } + return clean; + } + + private static boolean finiteAttributeValue(double base, NBTTagList modifiers) { + double value = base; + for(int i = 0; i < modifiers.tagCount(); i++) { + NBTTagCompound m = modifiers.getCompoundTagAt(i); + if(m.getInteger("Operation") == 0) value += m.getDouble("Amount"); } - if(root.hasKey("DecayTime")){ - this.decayTimestamp = root.getLong("DecayTime"); + if(!Double.isFinite(value)) return false; + double additive = value; + for(int i = 0; i < modifiers.tagCount(); i++) { + NBTTagCompound m = modifiers.getCompoundTagAt(i); + if(m.getInteger("Operation") == 1) value += additive * m.getDouble("Amount"); } - if (root.hasKey("Name")) { - this.setUserName(root.getString("Name")); + if(!Double.isFinite(value)) return false; + for(int i = 0; i < modifiers.tagCount(); i++) { + NBTTagCompound m = modifiers.getCompoundTagAt(i); + if(m.getInteger("Operation") == 2) value *= 1D + m.getDouble("Amount"); } - this.setRotation(this.rotationYaw, 0); + return Double.isFinite(value); + } + + private long readTimer(NBTTagCompound root, String key, long maximum, long fallback) { + NBTBase raw = root.getTag(key); + if(raw == null) return fallback; + Long value = integral(raw); + if(value != null && value >= 0 && value <= maximum) return value; + retainInvalid(key,raw); + return fallback; + } + + private long checkedTimestamp(String key, long timestamp, long now) { + if(timestamp >= 0 && timestamp <= now) return timestamp; + retainInvalid(key,new net.minecraft.nbt.NBTTagLong(timestamp)); + markDirty(); + return now; + } + + private String readName(NBTTagCompound root, String key) { + NBTBase raw = root.getTag(key); + if(raw == null) return null; + // Entity metadata packets accept at most 32767 UTF-8 bytes. Preserve display + // names, Unicode and formatting; do not impose an account-name pattern. + if(raw.getId() == 8 && root.getString(key).length() <= 32767 + && root.getString(key).getBytes(StandardCharsets.UTF_8).length <= 32767) { + String name = root.getString(key); + return name.trim().isEmpty() ? "" : name; + } + retainInvalid(key,raw); + return null; + } + + + private static boolean isInventoryList(NBTBase data) { + return data instanceof NBTTagList && (((NBTTagList)data).tagCount() == 0 || ((NBTTagList)data).getTagType() == 10); + } + + private void retainRaw(String source, NBTBase data) { + NBTTagCompound record = new NBTTagCompound(); + record.setString("Source",source); + record.setTag("Data",data.copy()); + unresolvedInventory.add(record); + } + + private void readInventoryTag(String source, NBTBase data) { + if(data == null) return; + if(!isInventoryList(data)) { retainRaw(source + "List",data); return; } + NBTTagList list = (NBTTagList)data; + for(int i = 0; i < list.tagCount(); i++) readInventoryEntry(source,list.getCompoundTagAt(i),null); + } + + private static Long integral(NBTBase data) { + return data instanceof NBTPrimitive && data.getId() >= 1 && data.getId() <= 4 ? ((NBTPrimitive)data).getLong() : null; + } + + private ItemStack decodeInventoryItem(NBTTagCompound raw) { + // Ambiguous fields, unavailable IDs and unhandled capability data stay + // verbatim. Never guess an item, truncate a count or erase custom data. + for(String key : raw.getKeySet()) if(!Arrays.asList("id","Count","Damage","tag","ForgeCaps","Slot").contains(key)) return null; + if(!raw.hasKey("id",8)) return null; + Long count = integral(raw.getTag("Count")); + Long damage = raw.hasKey("Damage") ? integral(raw.getTag("Damage")) : Long.valueOf(0L); + if(count == null || count <= 0 || count > 127 || damage == null || damage < 0 || damage > 32767) return null; + if((raw.hasKey("tag") && !raw.hasKey("tag",10)) || (raw.hasKey("ForgeCaps") && !raw.hasKey("ForgeCaps",10))) return null; + try { + ResourceLocation id = new ResourceLocation(raw.getString("id")); + if(!Item.REGISTRY.containsKey(id)) return null; + Item registered = Item.REGISTRY.getObject(id); + if(registered == null || Item.getIdFromItem(registered) == 0) return null; + NBTTagCompound input = raw.copy(); + input.setString("id",id.toString()); + input.setByte("Count",count.byteValue()); + input.setShort("Damage",damage.shortValue()); + ItemStack stack = ItemStack.loadItemStackFromNBT(input); + if(stack == null || stack.getItem() != registered || stack.stackSize != count.intValue() + || stack.getItemDamage() != damage.intValue() || stack.getMaxStackSize() <= 0) return null; + NBTTagCompound encoded = stack.writeToNBT(new NBTTagCompound()); + if(!Objects.equals(raw.getTag("tag"),encoded.getTag("tag")) + || !Objects.equals(raw.getTag("ForgeCaps"),encoded.getTag("ForgeCaps"))) return null; + return stack; + } catch(RuntimeException unreadable) { return null; } + } + + private void readInventoryEntry(String source, NBTTagCompound raw, NBTTagCompound retained) { + ItemStack stack = decodeInventoryItem(raw); + if(stack == null) { + if(retained == null) retainRaw(source,raw); else unresolvedInventory.add(retained.copy()); + return; + } + int limit = Math.min(stack.getMaxStackSize(),getInventoryStackLimit()); + if(source.equals("Equipment")) { + Long number = integral(raw.getTag("Slot")); + if(number != null && number >= 0 && number < getSizeInventory()) { + int slot = number.intValue(); + if(getStackInSlot(slot) == null && isItemValidForSlot(slot,stack)) { + setInventorySlotContents(slot,stack.splitStack(Math.min(stack.stackSize,slot < 4 ? 1 : limit))); + } + } + } + // Keep each record, including identical duplicates. Existing overflow + // handles later GUI recovery without overwriting any indexed slot. + while(stack.stackSize > 0) auxInventory.addLast(stack.splitStack(Math.min(stack.stackSize,limit))); } public boolean useThinArms() { diff --git a/src/main/java/cyano/lootable/events/PlayerDeathEventHandler.java b/src/main/java/cyano/lootable/events/PlayerDeathEventHandler.java index eada2a8..6f4a6b0 100644 --- a/src/main/java/cyano/lootable/events/PlayerDeathEventHandler.java +++ b/src/main/java/cyano/lootable/events/PlayerDeathEventHandler.java @@ -11,6 +11,7 @@ import net.minecraft.util.EnumHandSide; import net.minecraft.world.World; import net.minecraftforge.event.entity.living.LivingDeathEvent; +import net.minecraftforge.event.entity.EntityEvent; import net.minecraftforge.event.entity.living.LivingDropsEvent; import net.minecraftforge.fml.common.FMLLog; import net.minecraftforge.fml.common.eventhandler.Event; @@ -18,24 +19,36 @@ import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; import java.util.ArrayList; -import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.WeakHashMap; +import java.util.EnumSet; +import java.util.Set; +import java.util.Collections; import java.util.List; import java.util.Map; public class PlayerDeathEventHandler { - private static final Map> equipmentCache = new HashMap<>(); + private static final Map> equipmentCache = new WeakHashMap<>(); - @SubscribeEvent(priority= EventPriority.LOW) + @SubscribeEvent(priority= EventPriority.HIGHEST) + public void corpseEnteringChunk(EntityEvent.EnteringChunk e) { + if(e.getEntity() instanceof EntityLootableBody && !e.getEntity().getEntityWorld().isRemote) + ((EntityLootableBody)e.getEntity()).recoverPositionInChunk(e.getNewChunkX(),e.getNewChunkZ()); + } + + @SubscribeEvent(priority= EventPriority.LOW, receiveCanceled = true) public void entityDeathEvent(LivingDeathEvent e){ if(e.getEntity() instanceof EntityPlayer - && e.getResult() != Event.Result.DENY && !e.getEntity().getEntityWorld().isRemote) { final EntityPlayer player = (EntityPlayer)e.getEntity(); - if(player.isSpectator()) return; - Map cache = equipmentCache.computeIfAbsent(player,(EntityPlayer p)->new HashMap<>()); + equipmentCache.remove(player); + if(e.isCanceled() || e.getResult() == Event.Result.DENY || player.isSpectator() + || player.getEntityWorld().getGameRules().getBoolean("keepInventory")) return; + Map cache = new IdentityHashMap<>(); for(EntityEquipmentSlot slot : EntityLootableBody.EQUIPMENT_SLOTS){ - cache.put(player.getItemStackFromSlot(slot),slot); + ItemStack stack = player.getItemStackFromSlot(slot); + if(stack != null) cache.put(stack,slot); } if(player.getPrimaryHand() == EnumHandSide.LEFT){ @@ -43,32 +56,37 @@ public void entityDeathEvent(LivingDeathEvent e){ cache.put(player.getItemStackFromSlot(EntityEquipmentSlot.MAINHAND),EntityEquipmentSlot.OFFHAND); cache.put(player.getItemStackFromSlot(EntityEquipmentSlot.OFFHAND),EntityEquipmentSlot.MAINHAND); } + equipmentCache.put(player,cache); } } - @SubscribeEvent(priority= EventPriority.LOWEST) + @SubscribeEvent(priority= EventPriority.LOWEST, receiveCanceled = true) public void entityDropEvent(LivingDropsEvent e){ if(e.getEntity() instanceof EntityPlayer - && e.getResult() != Event.Result.DENY && !e.getEntity().getEntityWorld().isRemote) { final EntityPlayer player = (EntityPlayer)e.getEntity(); - if(player.isSpectator()) return; + Map cache = equipmentCache.remove(player); + if(e.isCanceled() || e.getResult() == Event.Result.DENY || player.isSpectator() + || player.getEntityWorld().getGameRules().getBoolean("keepInventory")) return; + if(cache == null) cache = Collections.emptyMap(); final World w = player.getEntityWorld(); - Map cache = equipmentCache.computeIfAbsent(player, (EntityPlayer p) -> new HashMap<>()); EntityLootableBody corpse = new EntityLootableBody(player); - corpse.forceSpawn = true; CommandSummon k; corpse.setUserName(player.getName()); corpse.setRotation(player.rotationYaw); List items = new ArrayList<>(); + Set assignedSlots = EnumSet.noneOf(EntityEquipmentSlot.class); for (EntityItem itemEntity : e.getDrops()) { ItemStack item = itemEntity.getEntityItem(); - if (item != null && cache.containsKey(item)) { - corpse.setItemStackToSlot(cache.get(item),item); + if(item == null || item.stackSize <= 0) continue; + EntityEquipmentSlot slot = cache.get(item); + // Stage independent stacks so a rejected spawn cannot mutate vanilla drops. + if (slot != null && assignedSlots.add(slot)) { + corpse.setItemStackToSlot(slot,item.copy()); } else { - items.add(item); + items.add(item.copy()); } } corpse.initializeItems(items.toArray(new ItemStack[0])); @@ -78,10 +96,11 @@ public void entityDropEvent(LivingDropsEvent e){ corpse.addItem(new ItemStack(Items.ROTTEN_FLESH,1+w.rand.nextInt(3))); } - w.spawnEntity(corpse); - - - e.getDrops().clear(); + if(w.spawnEntity(corpse)) { + e.getDrops().clear(); + } else { + FMLLog.warning("%s: Corpse spawn rejected; original death drops retained", LootableBodies.MODID); + } } } diff --git a/src/main/java/cyano/lootable/graphics/ClientConfigChanges.java b/src/main/java/cyano/lootable/graphics/ClientConfigChanges.java new file mode 100644 index 0000000..59f17d2 --- /dev/null +++ b/src/main/java/cyano/lootable/graphics/ClientConfigChanges.java @@ -0,0 +1,24 @@ +package cyano.lootable.graphics; + +import cyano.lootable.LootableBodies; +import cyano.lootable.entities.EntityLootableBody; +import net.minecraft.client.Minecraft; +import net.minecraft.entity.Entity; +import net.minecraftforge.fml.client.event.ConfigChangedEvent; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; +import net.minecraftforge.fml.relauncher.Side; +import net.minecraftforge.fml.relauncher.SideOnly; + +@SideOnly(Side.CLIENT) +public final class ClientConfigChanges { + @SubscribeEvent public void changed(ConfigChangedEvent.OnConfigChangedEvent event) { + if (!LootableBodies.MODID.equals(event.getModID())) return; + boolean wasLocalSkin = LootableBodies.useLocalSkin; + LootableBodies.saveClientConfiguration(); + Minecraft minecraft = Minecraft.getMinecraft(); + if (wasLocalSkin && !LootableBodies.useLocalSkin && minecraft.world != null) { + for (Entity entity : minecraft.world.loadedEntityList) + if (entity instanceof EntityLootableBody) ((EntityLootableBody) entity).refreshClientSkin(); + } + } +} diff --git a/src/main/java/cyano/lootable/graphics/CorpseConfigGui.java b/src/main/java/cyano/lootable/graphics/CorpseConfigGui.java new file mode 100644 index 0000000..c4ede43 --- /dev/null +++ b/src/main/java/cyano/lootable/graphics/CorpseConfigGui.java @@ -0,0 +1,78 @@ +package cyano.lootable.graphics; + +import cyano.lootable.LootableBodies; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import net.minecraft.client.gui.GuiScreen; +import net.minecraft.client.resources.I18n; +import net.minecraftforge.common.config.ConfigElement; +import net.minecraftforge.common.config.Property; +import net.minecraftforge.fml.client.config.DummyConfigElement.DummyCategoryElement; +import net.minecraftforge.fml.client.config.GuiConfig; +import net.minecraftforge.fml.client.config.GuiConfigEntries; +import net.minecraftforge.fml.client.config.IConfigElement; +import net.minecraftforge.fml.relauncher.Side; +import net.minecraftforge.fml.relauncher.SideOnly; + +@SideOnly(Side.CLIENT) +public final class CorpseConfigGui extends GuiConfig { + public CorpseConfigGui(GuiScreen parent) { + super(parent, elements(), LootableBodies.MODID, false, false, LootableBodies.NAME, + I18n.format("lootablebodies.config.local_file")); + } + + private static List elements() { + List appearance = new ArrayList<>(); + appearance.add(option("options", "local_texture")); + appearance.add(option("options", "display_nametag")); + List rules = new ArrayList<>(); + for (String key : Arrays.asList("corpse_HP", "add_bones_to_corpse", "item_decay_rate")) + rules.add(option("options", key)); + for (String key : Arrays.asList("hurt_by_environment", "hurt_by_weapons", "hurt_by_misc")) + rules.add(option("corpse damage", key)); + for (String key : Arrays.asList("enable_corpse_decay", "empty_only_decay", "corpse_decay_time")) + rules.add(option("corpse decay", key)); + return Arrays.asList( + new DummyCategoryElement("appearance", "lootablebodies.config.appearance", appearance), + new DummyCategoryElement("rules", "lootablebodies.config.rules", rules)); + } + + private static IConfigElement option(String category, String key) { + Property property = LootableBodies.getConfiguration().getCategory(category).get(key); + if (key.equals("corpse_decay_time")) property.setConfigEntryClass(DecayTimeEntry.class); + return new ConfigElement(property); + } + + /** Only constrain new GUI input; existing file values keep their startup semantics. */ + public static boolean isValidDecayTime(String value) { + if (!value.matches("\\s*[0-9]+\\s*:\\s*[0-9]+\\s*(?::\\s*[0-9]+\\s*)?")) return false; + String[] parts = value.trim().split(":"); + long total = 0; + int[] multiplier = {3600, 60, 1}; + long limit = Integer.MAX_VALUE / 20; + try { + for (int i = 0; i < parts.length; i++) { + long part = Long.parseLong(parts[i].trim()); + if (part > limit / multiplier[i]) return false; + total += part * multiplier[i]; + } + return total <= limit; + } catch (NumberFormatException invalid) { return false; } + } + + public static final class DecayTimeEntry extends GuiConfigEntries.StringEntry { + public DecayTimeEntry(GuiConfig screen, GuiConfigEntries entries, IConfigElement element) { + super(screen, entries, element); + isValidValue = isValidDecayTime(textFieldValue.getText()); + } + @Override public void keyTyped(char character, int key) { + super.keyTyped(character, key); + isValidValue = isValidDecayTime(textFieldValue.getText()); + } + @Override public boolean saveConfigElement() { + // Keep the previous value on invalid input instead of Forge's default reset. + return isValidDecayTime(textFieldValue.getText()) && super.saveConfigElement(); + } + } +} diff --git a/src/main/java/cyano/lootable/graphics/CorpseConfigGuiFactory.java b/src/main/java/cyano/lootable/graphics/CorpseConfigGuiFactory.java new file mode 100644 index 0000000..33c1fbd --- /dev/null +++ b/src/main/java/cyano/lootable/graphics/CorpseConfigGuiFactory.java @@ -0,0 +1,18 @@ +package cyano.lootable.graphics; + +import java.util.Collections; +import java.util.Set; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiScreen; +import net.minecraftforge.fml.client.IModGuiFactory; +import net.minecraftforge.fml.relauncher.Side; +import net.minecraftforge.fml.relauncher.SideOnly; + +/** The regular Forge Mods-list Config button entry point. */ +@SideOnly(Side.CLIENT) +public final class CorpseConfigGuiFactory implements IModGuiFactory { + @Override public void initialize(Minecraft minecraft) { } + @Override public Class mainConfigGuiClass() { return CorpseConfigGui.class; } + @Override public Set runtimeGuiCategories() { return Collections.emptySet(); } + @Override public RuntimeOptionGuiHandler getHandlerFor(RuntimeOptionCategoryElement element) { return null; } +} diff --git a/src/main/java/cyano/lootable/graphics/CorpseContainer.java b/src/main/java/cyano/lootable/graphics/CorpseContainer.java index 25c2e99..bd45429 100644 --- a/src/main/java/cyano/lootable/graphics/CorpseContainer.java +++ b/src/main/java/cyano/lootable/graphics/CorpseContainer.java @@ -2,15 +2,20 @@ import cyano.lootable.entities.EntityLootableBody; import net.minecraft.entity.Entity; +import net.minecraft.entity.item.EntityItem; import net.minecraft.entity.player.EntityPlayer; +import net.minecraft.entity.player.EntityPlayerMP; import net.minecraft.entity.player.InventoryPlayer; import net.minecraft.inventory.EntityEquipmentSlot; import net.minecraft.inventory.IInventory; import net.minecraft.inventory.Slot; +import net.minecraft.inventory.ClickType; import net.minecraft.item.ItemArmor; import net.minecraft.item.ItemStack; import net.minecraftforge.fml.relauncher.Side; import net.minecraftforge.fml.relauncher.SideOnly; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.event.entity.item.ItemTossEvent; /** * Created by Chris on 4/12/2016. @@ -20,6 +25,7 @@ public class CorpseContainer extends net.minecraft.inventory.Container { private static final EntityEquipmentSlot[] EQUIPMENT_SLOTS = EntityLootableBody.EQUIPMENT_SLOTS; private final IInventory targetInventory; + private boolean dragging; public CorpseContainer(InventoryPlayer playerItems, IInventory entity){ this.targetInventory = entity; net.minecraft.inventory.ContainerHorseInventory k; @@ -36,7 +42,7 @@ public int getSlotStackLimit() { @Override public boolean isItemValid(ItemStack item) { - return super.isItemValid(item) && item.getItem().isValidArmor(item, EQUIPMENT_SLOTS[i], (Entity) targetInventory); + return item != null && item.stackSize > 0 && super.isItemValid(item) && item.getItem().isValidArmor(item, EQUIPMENT_SLOTS[i], (Entity) targetInventory); } @SideOnly(Side.CLIENT) public String getSlotTexture() @@ -79,6 +85,188 @@ public boolean canInteractWith(EntityPlayer entityplayer) { return targetInventory.isUsableByPlayer(entityplayer); } + @Override + public ItemStack slotClick(int slotId, int button, ClickType type, EntityPlayer player) { + // A click can arrive before the next player tick closes a stale window. + if(!canInteractWith(player)) { + resetDrag(); + return null; + } + if(type != ClickType.QUICK_CRAFT && dragging) { + resetDrag(); + return null; + } + if(type == ClickType.QUICK_CRAFT && getDragEvent(button) == 0 + && player.inventory.getItemStack() != null && isValidDragMode(extractDragMode(button),player)) dragging = true; + if(type == ClickType.THROW && player.inventory.getItemStack() == null + && slotId >= 0 && slotId < inventorySlots.size()) { + Slot slot = getSlot(slotId); + if(slot.getHasStack() && slot.canTakeStack(player)) { + ItemStack original = slot.getStack().copy(); + ItemStack removed = slot.decrStackSize(button == 0 ? 1 : original.stackSize); + slot.onPickupFromSlot(player,removed); + if(!tryRelease(player,removed,true)) { + slot.putStack(original); + resync(player); + } + } + return null; + } + if((type == ClickType.PICKUP || type == ClickType.QUICK_MOVE) && slotId == -999 && (button == 0 || button == 1)) { + ItemStack cursor = player.inventory.getItemStack(); + if(cursor != null) { + ItemStack original = cursor.copy(); + ItemStack removed = cursor.splitStack(button == 0 ? cursor.stackSize : 1); + if(cursor.stackSize == 0) player.inventory.setItemStack(null); + if(!tryRelease(player,removed,true)) { + player.inventory.setItemStack(original); + resync(player); + } + } + return null; + } + if(type == ClickType.SWAP && button >= 0 && button < 9 + && slotId >= 0 && slotId < targetInventory.getSizeInventory()) { + Slot slot = getSlot(slotId); + ItemStack incoming = player.inventory.getStackInSlot(button); + ItemStack displaced = slot.getStack(); + if(incoming != null && displaced != null && slot.canTakeStack(player) && slot.isItemValid(incoming)) { + int limit = Math.min(slot.getItemStackLimit(incoming),incoming.getMaxStackSize()); + if(limit > 0 && incoming.stackSize > limit) { + slot.putStack(incoming.splitStack(limit)); + slot.onPickupFromSlot(player,displaced); + storeInPlayerInventory(player.inventory,displaced); + if(displaced.stackSize > 0) releaseOrRetain(player,displaced); + return null; + } + } + } + return super.slotClick(slotId,button,type,player); + } + + @Override + public void onContainerClosed(EntityPlayer player) { + ItemStack cursor = player.inventory.getItemStack(); + if(cursor != null) { + releaseOrRetain(player,cursor); + player.inventory.setItemStack(null); + } + } + + @Override + protected void resetDrag() { + dragging = false; + super.resetDrag(); + } + + private void resync(EntityPlayer player) { + // Accepted transaction replies assume client prediction succeeded. A + // rejected toss needs an explicit source/cursor correction as well. + if(player instanceof EntityPlayerMP) ((EntityPlayerMP)player).sendContainerToPlayer(this); + } + + private void releaseOrRetain(EntityPlayer player, ItemStack stack) { + if(stack == null || stack.stackSize <= 0 || tryRelease(player,stack,false)) return; + ItemStack remainder = stack.copy(); + // InventoryPlayer discards overflow in creative mode. Retain real + // corpse/cursor items even when every player slot is full. + storeInPlayerInventory(player.inventory,remainder); + if(remainder.stackSize > 0) ((EntityLootableBody)targetInventory).retainItem(remainder); + } + + private void storeInPlayerInventory(InventoryPlayer inventory, ItemStack remainder) { + // Keep vanilla's selected-slot, off-hand, main-slot and first-empty + // priority, without InventoryPlayer's creative overflow discard. + while(remainder.stackSize > 0) { + int destination = -1; + if(!remainder.isItemDamaged()) { + if(canStore(inventory,inventory.currentItem,remainder)) destination = inventory.currentItem; + else if(canStore(inventory,40,remainder)) destination = 40; + else for(int i = 0; i < inventory.mainInventory.length; i++) { + if(canStore(inventory,i,remainder)) { destination = i; break; } + } + } + if(destination < 0) destination = inventory.getFirstEmptyStack(); + if(destination < 0) return; + ItemStack current = inventory.getStackInSlot(destination); + int limit = Math.min(remainder.getMaxStackSize(),inventory.getInventoryStackLimit()); + int amount = Math.min(remainder.stackSize,Math.max(0,limit - (current == null ? 0 : current.stackSize))); + if(amount == 0) return; + if(current == null) { + inventory.setInventorySlotContents(destination,remainder.splitStack(amount)); + current = inventory.getStackInSlot(destination); + } else { + current.stackSize += amount; + remainder.stackSize -= amount; + } + current.animationsToGo = 5; + inventory.markDirty(); + } + } + + private boolean canStore(InventoryPlayer inventory, int slot, ItemStack incoming) { + ItemStack current = inventory.getStackInSlot(slot); + return current != null && current.isStackable() && current.getItem() == incoming.getItem() + && (!current.getHasSubtypes() || current.getMetadata() == incoming.getMetadata()) + && ItemStack.areItemStackTagsEqual(current,incoming) + && current.stackSize < Math.min(current.getMaxStackSize(),inventory.getInventoryStackLimit()); + } + + private boolean tryRelease(EntityPlayer player, ItemStack stack, boolean includeName) { + if(stack == null || stack.stackSize <= 0) return false; + if(player.world.isRemote) { + player.dropItem(stack.copy(),includeName); + return true; + } + // Forge's legacy toss helper clears the caller's captured drops and + // ignores spawn rejection. Preserve both ownership and the toss event. + boolean capturing = player.captureDrops; + EntityItem dropped; + try { + player.captureDrops = true; + dropped = player.dropItem(stack.copy(),false,includeName); + if(dropped != null) player.capturedDrops.remove(dropped); + } finally { + player.captureDrops = capturing; + } + if(dropped == null) return false; + ItemTossEvent event = new ItemTossEvent(dropped,player); + if(MinecraftForge.EVENT_BUS.post(event)) return false; + if(capturing) { + player.capturedDrops.add(event.getEntityItem()); + return true; + } + return player.world.spawnEntity(event.getEntityItem()); + } + + @Override + protected boolean mergeItemStack(ItemStack stack, int start, int end, boolean reverse) { + boolean changed = false; + int step = reverse ? -1 : 1; + for(int pass = 0; pass < 2 && stack.stackSize > 0; pass++) { + for(int i = reverse ? end - 1 : start; i >= start && i < end && stack.stackSize > 0; i += step) { + Slot slot = getSlot(i); + if(!slot.isItemValid(stack)) continue; + ItemStack current = slot.getStack(); + int limit = Math.min(slot.getItemStackLimit(stack),stack.getMaxStackSize()); + if(pass == 0 && stack.isStackable() && current != null + && ItemStack.areItemsEqual(stack,current) && ItemStack.areItemStackTagsEqual(stack,current)) { + int amount = Math.min(stack.stackSize,Math.max(0,limit - current.stackSize)); + if(amount > 0) { + current.stackSize += amount; + stack.stackSize -= amount; + slot.onSlotChanged(); + changed = true; + } + } else if(pass == 1 && current == null && limit > 0) { + slot.putStack(stack.splitStack(Math.min(stack.stackSize,limit))); + changed = true; + } + } + } + return changed; + } + @Override public ItemStack transferStackInSlot(EntityPlayer player, int slot) { diff --git a/src/main/java/cyano/lootable/graphics/CorpseRenderer.java b/src/main/java/cyano/lootable/graphics/CorpseRenderer.java index 0266d9e..9b58af9 100644 --- a/src/main/java/cyano/lootable/graphics/CorpseRenderer.java +++ b/src/main/java/cyano/lootable/graphics/CorpseRenderer.java @@ -81,13 +81,21 @@ protected ResourceLocation getEntityTexture(EntityLootableBody entity) { } public static ResourceLocation getSkin(GameProfile profile) { + ResourceLocation fallback = profile == null || profile.getId() == null + ? DefaultPlayerSkin.getDefaultSkinLegacy() : DefaultPlayerSkin.getDefaultSkin(profile.getId()); + if(profile == null || profile.getId() == null) return fallback; + try { final Minecraft minecraft = Minecraft.getMinecraft(); final Map loadSkinFromCache = minecraft.getSkinManager().loadSkinFromCache(profile); // returned map may or may not be typed if (loadSkinFromCache.containsKey(MinecraftProfileTexture.Type.SKIN)) { ResourceLocation skin = minecraft.getSkinManager().loadSkin((MinecraftProfileTexture) loadSkinFromCache.get(MinecraftProfileTexture.Type.SKIN), MinecraftProfileTexture.Type.SKIN); return skin; } else { - return DefaultPlayerSkin.getDefaultSkin(profile.getId()); + return fallback; + } + } catch(RuntimeException unavailable) { + // A service/cache failure must not abort renderModel and hide the body. + return fallback; } } diff --git a/src/main/resources/assets/lootablebodies/lang/en_US.lang b/src/main/resources/assets/lootablebodies/lang/en_US.lang index 6ef23f7..0016a72 100644 --- a/src/main/resources/assets/lootablebodies/lang/en_US.lang +++ b/src/main/resources/assets/lootablebodies/lang/en_US.lang @@ -1 +1,29 @@ entity.lootablebodies.Corpse.name=Corpse + +lootablebodies.config.local_file=Settings for this installation +lootablebodies.config.appearance=Appearance +lootablebodies.config.appearance.tooltip=Local display preferences. Save with Done to apply to this client. +lootablebodies.config.rules=World/server rules (restart required) +lootablebodies.config.rules.tooltip=Local singleplayer and server rules. Restart Minecraft to apply. A remote server uses its own configuration file; these controls do not change it. Rules cannot be edited while a world is open. +lootablebodies.config.local_texture=Use generic corpse texture +lootablebodies.config.local_texture.tooltip=Use the bundled corpse texture instead of player skins. Affects only your view. +lootablebodies.config.display_nametag=Show owner names +lootablebodies.config.display_nametag.tooltip=Show names above corpses. Affects only your view. +lootablebodies.config.corpse_HP=Corpse health +lootablebodies.config.corpse_HP.tooltip=Damage needed to destroy a corpse and release its items. 20 HP is 10 hearts. Used when corpse damage is enabled; existing saved health is preserved. Requires a restart. +lootablebodies.config.add_bones_to_corpse=Add bones and rotten flesh +lootablebodies.config.add_bones_to_corpse.tooltip=Add bones and rotten flesh on death. These items keep a corpse nonempty until removed, which delays empty-only decay. Requires a restart. +lootablebodies.config.item_decay_rate=Item wear interval (minutes) +lootablebodies.config.item_decay_rate.tooltip=Damageable items lose one durability point per interval, but are never completely destroyed by this wear. Default: 5 minutes. Zero or negative disables wear. Requires a restart. +lootablebodies.config.hurt_by_environment=Damage from environmental hazards +lootablebodies.config.hurt_by_environment.tooltip=Allow fire, lava, falls and similar hazards to damage corpses. Requires a restart. +lootablebodies.config.hurt_by_weapons=Damage from attacks +lootablebodies.config.hurt_by_weapons.tooltip=Allow attacks to damage corpses. Requires a restart. +lootablebodies.config.hurt_by_misc=Damage from other sources +lootablebodies.config.hurt_by_misc.tooltip=Allow damage sources outside the other two groups. Corpses are invulnerable when all three damage settings are disabled. Requires a restart. +lootablebodies.config.enable_corpse_decay=Enable corpse decay +lootablebodies.config.enable_corpse_decay.tooltip=Remove corpses after the configured lifetime. Default: enabled, but only while empty. Requires a restart. +lootablebodies.config.empty_only_decay=Decay only while empty +lootablebodies.config.empty_only_decay.tooltip=Count lifetime only while a corpse is empty. Added bones and rotten flesh count as items. Used only when corpse decay is enabled. Requires a restart. +lootablebodies.config.corpse_decay_time=Corpse lifetime (hours:minutes:seconds) +lootablebodies.config.corpse_decay_time.tooltip=Enter hours:minutes or hours:minutes:seconds using nonnegative whole numbers. Default: 1:00:00; minimum effective time: 2 seconds. Invalid or overflowing input is not saved. Used only when corpse decay is enabled. Requires a restart. diff --git a/src/main/resources/mcmod.info b/src/main/resources/mcmod.info index 747c047..5eeaf22 100644 --- a/src/main/resources/mcmod.info +++ b/src/main/resources/mcmod.info @@ -1,14 +1,15 @@ [ { - "modid": "lootable", + "modid": "lootablebodies", "name": "DrCyano's Lootable Bodies", "description": "Saves your items when you die in a container so that you can get your items back when you return.", "version": "${version}", "mcversion": "${mcversion}", - "url": "https://github.com/cyanobacterium/DrCyanos_Lootable-Bodies", + "url": "${mod_url}", + "license": "${mod_license}", "updateUrl": "", - "authorList": ["Cyanobacterium"], - "credits": "The Forge and FML guys, for making the Forge modding API (you guys are awesome), plant-eating zombies, and the mod author Cyanobacterium (aka Synechocystis)", + "authorList": ["Cyanobacterium", "SkyBlade1978"], + "credits": "Created by Cyanobacterium (DrCyano / Synechocystis). Maintained by SkyBlade1978 and Minecraft Mod Development.", "logoFile": "", "screenshots": [], "dependencies": [] diff --git a/src/test/java/cyano/lootable/contracts/BuildContractTest.java b/src/test/java/cyano/lootable/contracts/BuildContractTest.java new file mode 100644 index 0000000..94d3814 --- /dev/null +++ b/src/test/java/cyano/lootable/contracts/BuildContractTest.java @@ -0,0 +1,65 @@ +package cyano.lootable.contracts; + +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.*; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class BuildContractTest { + private String text(String path) throws Exception { + return new String(Files.readAllBytes(Paths.get(path)), StandardCharsets.UTF_8); + } + @Test void originalIdentityAndDeclaredReleaseRemainConsistent() throws Exception { + String source = text("src/main/java/cyano/lootable/LootableBodies.java"); + assertTrue(source.contains("MODID = \"lootablebodies\"")); + Properties release = new Properties(); + try (java.io.InputStream input = Files.newInputStream(Paths.get("gradle.properties"))) { release.load(input); } + assertTrue(source.contains("VERSION = \"" + release.getProperty("mod_version") + "\"")); + assertTrue(source.contains("String idName = \"Corpse\"")); + assertTrue(source.contains("EntityRegistry.registerModEntity(entityClass, idName")); + assertTrue(text("LICENSE-CC0").startsWith("CC0 1.0 Universal")); + } + @Test void processedResourcesHaveNoTemplatesOrDuplicateMetadata() throws Exception { + String info = text("build/resources/main/mcmod.info"); + assertTrue(info.contains("\"modid\": \"lootablebodies\"")); + Properties release = new Properties(); + try (java.io.InputStream input = Files.newInputStream(Paths.get("gradle.properties"))) { release.load(input); } + assertTrue(info.contains("\"version\": \"" + release.getProperty("mod_version") + "\"")); + assertTrue(info.contains("\"mcversion\": \"1.10.2\"")); + assertTrue(info.contains("Cyanobacterium")); + assertFalse(info.contains("${")); + assertFalse(Files.exists(Paths.get("mcmod.info"))); + assertFalse(Files.exists(Paths.get("resources/mcmod.info"))); + assertTrue(text("build/resources/main/pack.mcmeta").contains("\"pack_format\":2")); + } + @Test void legacyLocalesRetainEntityTranslation() throws Exception { + try (DirectoryStream locales = Files.newDirectoryStream(Paths.get("build/resources/main/assets/lootablebodies/lang"), "*.lang")) { + int count = 0; + for (Path locale : locales) { + assertTrue(locale.getFileName().toString().matches("[a-z]{2}_[A-Z]{2}\\.lang")); + String value = new String(Files.readAllBytes(locale), StandardCharsets.UTF_8); + assertTrue(value.startsWith("entity.lootablebodies.Corpse.name="), locale.toString()); + assertFalse(value.contains("\uFFFD")); + count++; + } + assertEquals(14, count); + } + } + @Test void corpseSaveKeysAndSlotOrderRemainStable() throws Exception { + String source = text("src/main/java/cyano/lootable/entities/EntityLootableBody.java"); + for (String key : Arrays.asList("Equipment", "Aux", "Slot", "Vac", "Name", "DeathTime", "DecayTime")) + assertTrue(source.contains("\"" + key + "\""), key); + assertTrue(source.contains("new ItemStack[6*7]")); + assertTrue(source.contains("EntityEquipmentSlot.HEAD, EntityEquipmentSlot.CHEST, EntityEquipmentSlot.LEGS, EntityEquipmentSlot.FEET, EntityEquipmentSlot.MAINHAND, EntityEquipmentSlot.OFFHAND")); + } + @Test void configurationNamesAndDefaultsRemainStable() throws Exception { + String source = text("src/main/java/cyano/lootable/LootableBodies.java"); + for (String key : Arrays.asList("corpse_HP", "local_texture", "display_nametag", "add_bones_to_corpse", "hurt_by_environment", "hurt_by_weapons", "hurt_by_misc", "item_decay_rate", "enable_corpse_decay", "empty_only_decay", "corpse_decay_time")) + assertTrue(source.contains("\"" + key + "\""), key); + assertTrue(source.contains("5F, -1F, 1e9F")); + assertTrue(source.contains("\"1:00:00\"")); + assertTrue(source.contains("addBonesToCorpse = true")); + assertTrue(source.contains("useLocalSkin = false")); + } +} diff --git a/src/test/java/cyano/lootable/contracts/ConfigScreenTest.java b/src/test/java/cyano/lootable/contracts/ConfigScreenTest.java new file mode 100644 index 0000000..104aac6 --- /dev/null +++ b/src/test/java/cyano/lootable/contracts/ConfigScreenTest.java @@ -0,0 +1,119 @@ +package cyano.lootable.contracts; + +import cyano.lootable.LootableBodies; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.*; +import net.minecraftforge.common.config.Configuration; +import net.minecraftforge.common.config.Property; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.io.TempDir; +import static org.junit.jupiter.api.Assertions.*; + +class ConfigScreenTest { + @TempDir Path directory; + private Object previousHome; + @BeforeEach void prepareForgeConfig() throws Exception { + java.lang.reflect.Field home = net.minecraftforge.fml.relauncher.FMLInjectionData.class.getDeclaredField("minecraftHome"); + home.setAccessible(true); previousHome = home.get(null); home.set(null, directory.toFile()); + } + @AfterEach void restoreForgeConfig() throws Exception { + java.lang.reflect.Field home = net.minecraftforge.fml.relauncher.FMLInjectionData.class.getDeclaredField("minecraftHome"); + home.setAccessible(true); home.set(null, previousHome); + } + + private Configuration load(String contents) throws Exception { + Path file = directory.resolve("lootablebodies.cfg"); + Files.write(file, contents.getBytes(StandardCharsets.UTF_8)); + LootableBodies.class.getMethod("loadConfiguration", File.class).invoke(null, file.toFile()); + return (Configuration) LootableBodies.class.getMethod("getConfiguration").invoke(null); + } + + private void save() throws Exception { + LootableBodies.class.getMethod("saveClientConfiguration").invoke(null); + } + + @Test void modsListUsesAClientOnlyFactory() throws Exception { + String source = new String(Files.readAllBytes(Paths.get( + "src/main/java/cyano/lootable/LootableBodies.java")), StandardCharsets.UTF_8); + assertTrue(source.contains("guiFactory = \"cyano.lootable.graphics.CorpseConfigGuiFactory\"")); + assertFalse(source.contains("import net.minecraft.client")); + } + + @Test void existingKeysHaveCorrectRestartPolicyAndDefaults() throws Exception { + // Other tests deliberately alter the static gameplay fields. + LootableBodies.corpseHP = 20; LootableBodies.addBonesToCorpse = true; + LootableBodies.hurtByEnvironment = LootableBodies.hurtByAttacks = LootableBodies.hurtByMisc = false; + LootableBodies.allowCorpseDecay = LootableBodies.decayOnlyWhenEmpty = true; + LootableBodies.useLocalSkin = false; LootableBodies.displayNameTag = true; + Configuration config = load(""); + assertEquals(new TreeSet<>(Arrays.asList("options", "corpse damage", "corpse decay")), config.getCategoryNames()); + int count = 0; + for (String category : config.getCategoryNames()) for (Property property : config.getCategory(category).values()) { + boolean appearance = property.getName().equals("local_texture") || property.getName().equals("display_nametag"); + assertEquals(!appearance, property.requiresMcRestart(), property.getName()); + assertEquals(!appearance, property.requiresWorldRestart(), property.getName()); + assertTrue(property.getLanguageKey().startsWith("lootablebodies.config.")); + count++; + } + assertEquals(11, count); + assertEquals(6000, LootableBodies.ticksPerItemDecay); + assertEquals(72000, LootableBodies.corpseDecayTime); + assertEquals("1:00:00", config.getCategory("corpse decay").get("corpse_decay_time").getString()); + assertTrue(LootableBodies.completelyInvulnerable); + } + + @Test void appearanceSaveDoesNotActivatePendingGameplayChanges() throws Exception { + Configuration config = load("options {\n D:corpse_HP=37\n D:item_decay_rate=7\n B:local_texture=false\n B:display_nametag=true\n}\n" + + "\"corpse decay\" {\n S:corpse_decay_time=2:03:04\n}\n"); + config.getCategory("options").get("local_texture").set(true); + config.getCategory("options").get("display_nametag").set(false); + config.getCategory("options").get("corpse_HP").set(99); + config.getCategory("options").get("item_decay_rate").set(0); + config.getCategory("corpse decay").get("corpse_decay_time").set("0:00:10"); + save(); + assertTrue(LootableBodies.useLocalSkin); assertFalse(LootableBodies.displayNameTag); + assertEquals(37, LootableBodies.corpseHP); + assertEquals(8400, LootableBodies.ticksPerItemDecay); + assertEquals((2 * 3600 + 3 * 60 + 4) * 20, LootableBodies.corpseDecayTime); + Configuration disk = new Configuration(directory.resolve("lootablebodies.cfg").toFile()); disk.load(); + assertEquals(99, disk.getCategory("options").get("corpse_HP").getDouble()); + LootableBodies.class.getMethod("loadConfiguration", File.class).invoke(null, directory.resolve("lootablebodies.cfg").toFile()); + assertEquals(99, LootableBodies.corpseHP); + assertEquals(0, LootableBodies.ticksPerItemDecay); + assertEquals(200, LootableBodies.corpseDecayTime); + } + + @Test void savingPreservesUnknownOptionsAndLegacyCategories() throws Exception { + Configuration config = load("options {\n S:pack_note=keep this\n}\ncustom {\n I:example=42\n}\n"); + config.getCategory("options").get("local_texture").set(true); save(); + Configuration disk = new Configuration(directory.resolve("lootablebodies.cfg").toFile()); disk.load(); + assertEquals("keep this", disk.getCategory("options").get("pack_note").getString()); + assertEquals(42, disk.getCategory("custom").get("example").getInt()); + assertEquals(3, disk.getCategory("corpse damage").size()); + } + + @Test void timeInputRejectsMalformedAndOverflowingValues() throws Exception { + java.lang.reflect.Method validate = Class.forName("cyano.lootable.graphics.CorpseConfigGui") + .getMethod("isValidDecayTime", String.class); + for (String valid : Arrays.asList("1:00:00", "0:00", " 1 : 2 : 3 ", "0:120", "29826:9:42")) + assertEquals(true, validate.invoke(null, valid), valid); + for (String invalid : Arrays.asList("", "garbage", "1", "1:2:3:4", "1:-2", "NaN:00", "1.5:00", + "999999999999999999999:00", "29826:9:43", "0:0:2147483647")) + assertEquals(false, validate.invoke(null, invalid), invalid); + } + + @Test void englishLabelsCoverAllVisibleOptions() throws Exception { + Configuration config = load(""); + Properties language = new Properties(); + try (java.io.Reader reader = Files.newBufferedReader(Paths.get( + "build/resources/main/assets/lootablebodies/lang/en_US.lang"), StandardCharsets.UTF_8)) { language.load(reader); } + for (String category : config.getCategoryNames()) for (Property property : config.getCategory(category).values()) { + assertNotNull(language.getProperty(property.getLanguageKey()), property.getName()); + assertNotNull(language.getProperty(property.getLanguageKey() + ".tooltip"), property.getName()); + } + } +} diff --git a/src/test/java/cyano/lootable/contracts/ReleaseContractTest.java b/src/test/java/cyano/lootable/contracts/ReleaseContractTest.java new file mode 100644 index 0000000..9d2678a --- /dev/null +++ b/src/test/java/cyano/lootable/contracts/ReleaseContractTest.java @@ -0,0 +1,86 @@ +package cyano.lootable.contracts; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.Properties; +import javax.xml.parsers.DocumentBuilderFactory; +import org.junit.jupiter.api.Test; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import static org.junit.jupiter.api.Assertions.*; + +class ReleaseContractTest { + private Properties properties() throws Exception { + Properties values = new Properties(); + try (InputStream input = Files.newInputStream(Paths.get("gradle.properties"))) { + values.load(input); + } + return values; + } + + private String text(String path) throws Exception { + return new String(Files.readAllBytes(Paths.get(path)), StandardCharsets.UTF_8); + } + + @Test void releaseVersionEncodesTheActualMinecraftAndLoader() throws Exception { + Properties values = properties(); + String version = values.getProperty("mod_version"); + assertTrue(version.matches("[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+"), version); + assertEquals("2.4.1.110021", version); + assertEquals("forge", values.getProperty("loader_name")); + assertEquals("1", values.getProperty("loader_code")); + assertTrue(text("src/main/java/cyano/lootable/LootableBodies.java") + .contains("VERSION = \"" + version + "\"")); + } + + @Test void mavenIdentityIsIndependentOfSavedEntityIdentity() throws Exception { + Properties values = properties(); + assertEquals("zone.moddev.mc.lootablebodies", values.getProperty("mod_group")); + assertEquals("LootableBodies", values.getProperty("mod_artifact")); + assertEquals("lootablebodies", values.getProperty("mod_id")); + assertEquals("240968", values.getProperty("curseforge_project_id")); + String source = text("src/main/java/cyano/lootable/LootableBodies.java"); + assertTrue(source.startsWith("package cyano.lootable;")); + assertTrue(source.contains("String idName = \"Corpse\"")); + assertTrue(source.contains("acceptedMinecraftVersions = \"[1.10.2]\"")); + } + + @Test void maintainedReleaseHasLgplAndRetainsTheOriginalNotice() throws Exception { + assertTrue(text("LICENSE").contains("GNU LESSER GENERAL PUBLIC LICENSE")); + assertTrue(text("LICENSE").contains("Version 2.1, February 1999")); + assertTrue(text("LICENSE-CC0").startsWith("CC0 1.0 Universal")); + assertTrue(text("NOTICE").contains("Cyanobacterium")); + assertEquals("LGPL-2.1-only", properties().getProperty("mod_license")); + } + + @Test void pomDescribesThisModWithoutBundlingRuntimeOrTestingDependencies() throws Exception { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + Document pom = factory.newDocumentBuilder().parse("build/publications/mavenJava/pom-default.xml"); + Properties values = properties(); + assertEquals(values.getProperty("mod_group"), pom.getElementsByTagName("groupId").item(0).getTextContent()); + assertEquals(values.getProperty("mod_artifact"), pom.getElementsByTagName("artifactId").item(0).getTextContent()); + assertEquals(values.getProperty("mod_version"), pom.getElementsByTagName("version").item(0).getTextContent()); + assertEquals(values.getProperty("mod_license_name"), ((Element) pom.getElementsByTagName("license").item(0)) + .getElementsByTagName("name").item(0).getTextContent()); + assertEquals(0, pom.getElementsByTagName("dependency").getLength()); + assertTrue(text("build/publications/mavenJava/pom-default.xml").contains("Cyanobacterium")); + } + + @Test void productionMetadataLinksToTheMaintainedProject() throws Exception { + Properties values = properties(); + assertEquals("https://github.com/MinecraftModDevelopmentMods/LootableBodies", values.getProperty("mod_url")); + assertTrue(text("build/resources/main/mcmod.info").contains(values.getProperty("mod_url"))); + assertTrue(text("README.md").contains("LootableBodies-" + values.getProperty("mod_version") + ".jar")); + } + + @Test void adoptedModCreditsTheOriginalAuthorAndMaintainer() throws Exception { + assertEquals("Cyanobacterium,SkyBlade1978", properties().getProperty("mod_authors")); + String metadata = text("build/resources/main/mcmod.info"); + assertTrue(metadata.contains("\"authorList\": [\"Cyanobacterium\", \"SkyBlade1978\"]")); + assertTrue(text("build/publications/mavenJava/pom-default.xml").contains("SkyBlade1978")); + assertTrue(text("README.md").contains("SkyBlade1978")); + } +} diff --git a/tools/audit_runtime_logs.py b/tools/audit_runtime_logs.py new file mode 100644 index 0000000..772cd41 --- /dev/null +++ b/tools/audit_runtime_logs.py @@ -0,0 +1,35 @@ +"""Reject unexpected failures in complete legacy runtime logs.""" +import argparse +from pathlib import Path +import re + + +def audit(path, development=False): + text=path.read_text(encoding='utf-8',errors='replace') + failures=[] + # These exact diagnostics are reproduced by the independent Forge control. + platform=['FML appears to be missing any signature data. This is not a good thing'] + if development: + platform+=['The binary patch set is missing. Either you are in a development environment, or things are not going to work!', + 'Unable to read a class file correctly', + 'There was a problem reading the entry META-INF/versions/11/net/minecraftforge/launcher/shadow/util/download/DownloadUtilsImpl.class'] + for line in text.splitlines(): + if re.search(r'/ERROR\]|/FATAL\]|\bERROR (?:Error processing|Unable to locate)',line) and not any(message in line for message in platform): + failures.append(line) + if re.search(r'AssertionError|NoSuchMethodError|NoSuchFieldError|NoClassDefFoundError|ClassNotFoundException|NullPointerException|OutOfMemoryError|Exception stopping server|Encountered an unexpected exception|Couldn.t render entity|Missing sound for event:|Patcher expecting empty class data|Skipping Entity with id|Unidentified mapping|Unable to load model',line): + failures.append(line) + if 'Caused by:' in line and not (development and line.strip()=='Caused by: java.lang.IllegalArgumentException' and + 'META-INF/versions/11/net/minecraftforge/launcher/shadow/util/download/DownloadUtilsImpl.class' in text): + failures.append(line) + if not text.strip(): failures.append('Empty runtime log') + if failures: raise RuntimeError(str(path)+'\n'+'\n'.join(failures)) + return {'path':str(path),'lines':len(text.splitlines()),'expected_platform_errors':sum( + 1 for line in text.splitlines() if re.search(r'/ERROR\]|/FATAL\]',line))} + + +if __name__=='__main__': + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--development',action='store_true') + parser.add_argument('logs',nargs='+',type=Path) + args=parser.parse_args() + for path in args.logs: print(audit(path,args.development)) diff --git a/tools/release_identity.py b/tools/release_identity.py new file mode 100644 index 0000000..2364b98 --- /dev/null +++ b/tools/release_identity.py @@ -0,0 +1,9 @@ +"""Release filenames shared by packaged verifiers; no publication or credentials.""" +from pathlib import Path + +PROJECT = Path(__file__).resolve().parents[1] +PROPERTIES = dict(line.split("=", 1) for line in + (PROJECT / "gradle.properties").read_text(encoding="utf-8").splitlines() + if line.strip() and not line.lstrip().startswith("#") and "=" in line) +MOD_VERSION = PROPERTIES["mod_version"] +MAIN_JAR = f'{PROPERTIES["mod_artifact"]}-{MOD_VERSION}.jar' diff --git a/tools/verify_multiplayer.py b/tools/verify_multiplayer.py new file mode 100644 index 0000000..9111119 --- /dev/null +++ b/tools/verify_multiplayer.py @@ -0,0 +1,109 @@ +"""Two real loopback Forge clients sharing a disposable packaged dedicated server.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import socket +import subprocess +import time +from audit_runtime_logs import audit +from verify_packaged import MAIN, FORGE, digest, prepare, run +from verify_packaged_client import prepare_assets + + +def client_command(project, java, distribution, root, cache, shared, port, role): + root.mkdir() + (root/'mods').mkdir(); (root/'config').mkdir() + for path in (project/'build/libs'/MAIN, project/'build/integration-test-mod/lootablebodies-build-tests-reobf.jar'): + shutil.copy2(path,root/'mods'/path.name) + (root/'config/lootablebodies.cfg').write_text('options {\n B:local_texture=true\n}\n') + forge=distribution/(FORGE+'-universal.jar');shutil.copy2(forge,root/forge.name) + vanilla=cache/'minecraftforge/forgegradle/mavenizer/caches/minecraft_tasks/1.10.2/client.jar' + if hashlib.sha1(vanilla.read_bytes()).hexdigest()!='dc8e75ac7274ff6af462b0dcec43c307de668e40': raise RuntimeError('Vanilla client hash') + natives=cache/'minecraftforge/forgegradle/slime-launcher/cache/net/minecraftforge/forge/1.10.2-12.18.3.2511/natives' + deps=[Path(p) for p in json.loads((project/'build/runtime-classpath.json').read_text())] + deps=[p for p in deps if p.suffix=='.jar' and 'forge-1.10.2-12.18.3.2511-stable' not in p.name] + if any(any(s in str(p).lower() for s in ('junit','mockito','test-mod','slime-launcher')) for p in deps): raise RuntimeError('Foreign packaged runtime library') + return [str(java),'-Xmx1G','-Dlog4j.skipJansi=true','-Dlootablebodies.multiplayer.role='+role, + '-Dlootablebodies.multiplayer.sync='+str(shared),'-Dlootablebodies.multiplayer.port='+str(port), + '-Djava.library.path='+str(natives),'-Dorg.lwjgl.librarypath='+str(natives), + '-Dlog4j.configurationFile='+str(project/'src/integrationTest/resources/log4j2.xml'), + '-cp',os.pathsep.join(map(str,[vanilla,root/forge.name]+deps)), + 'net.minecraft.launchwrapper.Launch','--tweakClass','net.minecraftforge.fml.common.launcher.FMLTweaker', + '--username','Container'+role,'--uuid','0000000000004000800000000000010'+('5' if role=='A' else '6'), + '--accessToken','0','--version','1.10.2','--userType','legacy','--gameDir',str(root), + '--assetsDir',str(project/'.gradle/packaged-assets'),'--assetIndex','1.10','--width','854','--height','480'] + + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--java',type=Path,required=True) + parser.add_argument('--forge-home',type=Path,required=True) + parser.add_argument('--gradle-home',type=Path,required=True) + parser.add_argument('--output',type=Path,required=True) + args=parser.parse_args();project=Path(__file__).resolve().parents[1] + java=args.java.resolve();distribution=args.forge_home.resolve();root=args.output.resolve();root.mkdir(parents=True,exist_ok=False) + if digest(distribution/(FORGE+'-universal.jar'))!='809C2EA5B4D4F3E5A294D3A326744F20FEC412A1263E708297D00F512E377542': raise RuntimeError('Forge hash') + shared=root/'sync';shared.mkdir();server=root/'server' + jar=project/'build/libs'/MAIN;before=digest(jar) + prepare(server,distribution,jar,project/'build/integration-test-mod/lootablebodies-build-tests-reobf.jar') + with socket.socket() as reservation: + reservation.bind(('127.0.0.1',0));port=reservation.getsockname()[1] + properties=(server/'server.properties').read_text().replace('server-port=0','server-port='+str(port)) + (server/'server.properties').write_text(properties+'view-distance=4\n') + prepare_assets(project/'.gradle/packaged-assets') + commands={'server':[str(java),'-Xmx1G','-Dlootablebodies.multiplayer.server=true', + '-Dlootablebodies.multiplayer.sync='+str(shared),'-jar',FORGE+'-universal.jar','nogui']} + for role in ('A','B'): + commands['client'+role]=client_command(project,java,distribution,root/('client'+role),args.gradle_home.resolve()/'caches',shared,port,role) + processes={};logs=[] + def start(name): + log=(root/(name+'.log')).open('w',encoding='utf-8');logs.append(log) + processes[name]=subprocess.Popen(commands[name],cwd=server if name=='server' else root/name,stdout=log,stderr=subprocess.STDOUT) + try: + start('server');deadline=time.monotonic()+60 + while not (shared/'server.ready').is_file(): + if processes['server'].poll() is not None or time.monotonic()>deadline: raise RuntimeError('Disposable server did not start') + time.sleep(.2) + start('clientA');start('clientB');deadline=time.monotonic()+180 + while any(p.poll() is None for p in processes.values()): + if any(p.poll() not in (None,0) for p in processes.values()): raise RuntimeError('Multiplayer process failed') + if time.monotonic()>deadline: raise RuntimeError('Multiplayer timed out') + time.sleep(.2) + if any(p.returncode for p in processes.values()): raise RuntimeError('Multiplayer exit code') + finally: + (shared/'abort').touch() + # Only the three exact processes created above are eligible for termination. + for process in processes.values(): + if process.poll() is None: process.terminate() + for process in processes.values(): + try: process.wait(timeout=10) + except subprocess.TimeoutExpired: process.kill();process.wait() + for log in logs: log.close() + if not (shared/'multiplayer.pass').is_file(): raise RuntimeError('Missing multiplayer marker') + checks=['two_tcp_clients_contested_shift_click','two_tcp_clients_hotbar_interleaved_with_drag', + 'two_tcp_clients_final_gui_and_saved_nbt'] + if (shared/'checks.txt').read_text().splitlines()!=['PASS '+name for name in checks]: + raise RuntimeError('Missing multiplayer check result') + for name in ('server','clientA','clientB'): + audit(root/(name+'.log'));audit(root/name/'logs/latest.log') + if list((root/name).rglob('crash-reports/*')): raise RuntimeError('Crash report '+name) + if digest(root/name/'mods'/MAIN)!=before: raise RuntimeError('Candidate mismatch '+name) + for role in ('A','B'): + home=root/('client'+role) + if not (home/'multiplayer-client.pass').is_file(): raise RuntimeError('Client did not finish '+role) + if 'LOOTABLE_BODIES_MULTIPLAYER_CLIENT_PASS role='+role not in (root/('client'+role+'.log')).read_text(errors='replace'): + raise RuntimeError('Missing client log marker '+role) + for shot in ('shared-ready.png','shared-final.png'): + if not (home/'screenshots'/shot).is_file(): raise RuntimeError('Missing screenshot '+role+' '+shot) + (server/'server.properties').write_text(properties.replace('server-port='+str(port),'server-port=0')+'view-distance=4\n') + run(java,server,'multiplayer-reload') + if digest(jar)!=before: raise RuntimeError('Candidate changed') + (root/'result.json').write_text(json.dumps({'candidate_sha256':before,'two_real_tcp_clients':True, + 'checks':(shared/'checks.txt').read_text().splitlines(),'corpse_and_real_player_disk_reload':True},indent=2)+'\n') + print('MULTIPLAYER_PACKAGED_PASS',flush=True) + + +if __name__=='__main__':main() diff --git a/tools/verify_nbt_recovery.py b/tools/verify_nbt_recovery.py new file mode 100644 index 0000000..aa5951e --- /dev/null +++ b/tools/verify_nbt_recovery.py @@ -0,0 +1,25 @@ +"""Exact packaged recovery of raw malformed fixtures, with deferred chunks and registry return.""" +import argparse,json,shutil +from pathlib import Path +from verify_packaged import MAIN,prepare,run,digest,fingerprint + +def main(): + p=argparse.ArgumentParser(description=__doc__) + p.add_argument('--java',type=Path,required=True);p.add_argument('--forge-home',type=Path,required=True) + p.add_argument('--baseline-jar',type=Path);p.add_argument('--output',type=Path,required=True);a=p.parse_args() + project=Path(__file__).resolve().parents[1];root=a.output.resolve();root.mkdir(parents=True,exist_ok=False) + java=a.java.resolve();distribution=a.forge_home.resolve();main=project/'build/libs'/MAIN;baseline=a.baseline_jar.resolve() if a.baseline_jar else main;probe=project/'build/integration-test-mod/lootablebodies-build-tests-reobf.jar' + assert digest(distribution/'forge-1.10.2-12.18.3.2511-universal.jar')=='809C2EA5B4D4F3E5A294D3A326744F20FEC412A1263E708297D00F512E377542','Forge distribution hash mismatch' + original=digest(baseline);candidate=digest(main) + seed=root/'sealed-seed';prepare(seed,distribution,baseline,probe);run(java,seed,'nbt-seed');sealed=fingerprint(seed/'world') + home=root/'candidate';prepare(home,distribution,main,probe);shutil.copytree(seed/'world',home/'world') + far=home/'world/region/r.8.8.mca';deferred=digest(far) + run(java,home,'nbt-reload');assert digest(far)==deferred,'Unopened malformed region changed' + for phase in ('nbt-deferred','nbt-deferred-repeat','nbt-return','nbt-return-reload'):run(java,home,phase) + expected=['nbt-reload','nbt-deferred','nbt-deferred-repeat','nbt-return','nbt-return-reload'] + assert (home/'world/nbt-world-checks.txt').read_text().splitlines()==['PASS '+s+' exact_items_raw_records_identity' for s in expected] + assert fingerprint(seed/'world')==sealed and digest(baseline)==original and digest(main)==candidate,'A sealed source/candidate changed' + report={'candidate_sha256':candidate,'baseline_sha256':original,'sealed_world_unchanged':True,'unopened_malformed_region_unchanged':deferred,'checks':expected,'raw_records_per_corpse_before_return':5,'raw_records_per_corpse_after_return':4,'raw_scalar_records_per_corpse':4,'raw_health_attribute_records_per_corpse':5,'raw_effect_hurt_records_per_corpse':3,'raw_geometry_records_per_corpse':2,'raw_rotation_fall_records_per_corpse':2,'raw_metadata_records_per_corpse':16,'source_world':sealed} + (root/'result.json').write_text(json.dumps(report,indent=2)+'\n');print('PACKAGED_NBT_RECOVERY_PASS',flush=True) + +if __name__=='__main__':main() diff --git a/tools/verify_packaged.py b/tools/verify_packaged.py new file mode 100644 index 0000000..af636fe --- /dev/null +++ b/tools/verify_packaged.py @@ -0,0 +1,129 @@ +"""Verify the exact SRG jar in disposable Forge 1.10.2 servers (no Gradle bootstrap).""" +import argparse +import hashlib +import json +from pathlib import Path +import shutil +import subprocess +import urllib.request +from audit_runtime_logs import audit +from release_identity import MAIN_JAR, MOD_VERSION + +FORGE = "forge-1.10.2-12.18.3.2511" +MAIN = MAIN_JAR + + +def digest(path): + return hashlib.sha256(path.read_bytes()).hexdigest().upper() + + +def fingerprint(folder): + return {p.relative_to(folder).as_posix(): digest(p) for p in sorted(folder.rglob("*")) if p.is_file()} + + +def run(java, home, phase): + log = home / (phase + ".log") + marker = home / "world" / (phase + ".pass") + if marker.exists(): + raise RuntimeError(f"Refusing stale marker {marker}") + with log.open("w", encoding="utf-8") as output: + completed = subprocess.run([str(java), "-Xmx2G", f"-Dlootablebodies.phase={phase}", + "-jar", FORGE + "-universal.jar", "nogui"], + cwd=home, stdout=output, stderr=subprocess.STDOUT, timeout=180) + text = log.read_text(encoding="utf-8", errors="replace") + if completed.returncode or not marker.is_file() or "LOOTABLE_BODIES_RUNTIME_PASS phase=" + phase not in text: + raise RuntimeError(f"Packaged {phase} failed: {log}") + if any(word in text for word in ("AssertionError", "Caused by:", "Encountered an unexpected exception", "Exception stopping server")): + raise RuntimeError(f"Failure in complete log {log}") + if list((home / "crash-reports").glob("*")): + raise RuntimeError(f"Crash reports in {home}") + audit(log) + print(f"PACKAGED_PASS {home.name} {phase}", flush=True) + + +def prepare(root, distribution, jar, probe): + root.mkdir(parents=True, exist_ok=False) + (root / "mods").mkdir() + for name in (FORGE + "-universal.jar", "minecraft_server.1.10.2.jar"): + shutil.copy2(distribution / name, root / name) + shutil.copytree(distribution / "libraries", root / "libraries") + shutil.copy2(jar, root / "mods" / MAIN) + shutil.copy2(probe, root / "mods" / probe.name) + (root / "eula.txt").write_text("eula=true\n") + (root / "server.properties").write_text("level-name=world\nlevel-seed=4267321\nlevel-type=FLAT\nonline-mode=false\nserver-ip=127.0.0.1\nserver-port=0\ngenerate-structures=false\nspawn-animals=false\nspawn-monsters=false\nmax-tick-time=120000\n") + for name in ("ops.json", "whitelist.json", "banned-players.json", "banned-ips.json"): + (root / name).write_text("[]\n") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--java", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--forge-home", type=Path) + parser.add_argument("--original-jar", type=Path) + args = parser.parse_args() + project = Path(__file__).resolve().parents[1] + main_jar = project / "build/libs" / MAIN + probe = project / "build/integration-test-mod/lootablebodies-build-tests-reobf.jar" + for required in (args.java, main_jar, probe): + if not required.is_file(): + raise FileNotFoundError(required) + output = args.output.resolve() + if output.exists(): + raise RuntimeError("Preserve existing evidence; use a new output directory") + output.mkdir(parents=True) + distribution = args.forge_home + if distribution is None: + distribution = output / "forge-distribution" + distribution.mkdir() + installer = distribution / (FORGE + "-installer.jar") + request = urllib.request.Request("https://maven.minecraftforge.net/net/minecraftforge/forge/1.10.2-12.18.3.2511/" + installer.name, + headers={"User-Agent": "LootableBodiesBuild/" + MOD_VERSION}) + installer.write_bytes(urllib.request.urlopen(request,timeout=45).read()) + if digest(installer) != '664393C5A924B8F06CFB953903ABCF9FE5E99ECBE6AF8C7EA217339C766E519C': + raise RuntimeError('Forge installer hash mismatch') + with (distribution / "install.log").open("w") as log: + subprocess.run([str(args.java), "-jar", str(installer), "--installServer"], cwd=distribution, + stdout=log, stderr=subprocess.STDOUT, check=True, timeout=300) + if digest(distribution / (FORGE + '-universal.jar')) != '809C2EA5B4D4F3E5A294D3A326744F20FEC412A1263E708297D00F512E377542': + raise RuntimeError('Forge universal hash mismatch') + if hashlib.sha1((distribution / 'minecraft_server.1.10.2.jar').read_bytes()).hexdigest() != '3d501b23df53c548254f5e3f66492d178a48db63': + raise RuntimeError('Vanilla server hash mismatch') + fresh = output / "candidate-fresh" + prepare(fresh, distribution, main_jar, probe) + run(args.java, fresh, "fresh") + far = fresh / "world/region/r.8.8.mca" + before = digest(far) + run(args.java, fresh, "reload") + if digest(far) != before: + raise RuntimeError("Unopened far region changed") + run(args.java, fresh, "deferred") + report = {"candidate_sha256": digest(main_jar), "deferred_region_unchanged": before} + if args.original_jar: + original_hash = digest(args.original_jar) + seed = output / "original-seed" + prepare(seed, distribution, args.original_jar, probe) + run(args.java, seed, "seed") + source_before = fingerprint(seed / "world") + upgrade = output / "original-upgrade" + prepare(upgrade, distribution, main_jar, probe) + shutil.copytree(seed / "world", upgrade / "world") + far = upgrade / "world/region/r.8.8.mca" + before = digest(far) + run(args.java, upgrade, "reload") + if digest(far) != before: + raise RuntimeError("Old deferred region changed before opening") + run(args.java, upgrade, "deferred") + # Open/save the formerly deferred corpse again in a separate JVM. + (upgrade / "world/deferred.pass").rename(upgrade / "world/deferred-first.pass") + (upgrade / "deferred.log").rename(upgrade / "deferred-first.log") + run(args.java, upgrade, "deferred") + if fingerprint(seed / "world") != source_before or digest(args.original_jar) != original_hash: + raise RuntimeError("Original fixture changed") + report.update(original_sha256=original_hash, original_world=source_before, + original_world_unchanged=True, original_deferred_region_unchanged=before) + (output / "result.json").write_text(json.dumps(report, indent=2) + "\n") + + +if __name__ == "__main__": + main() diff --git a/tools/verify_packaged_client.py b/tools/verify_packaged_client.py new file mode 100644 index 0000000..724a57c --- /dev/null +++ b/tools/verify_packaged_client.py @@ -0,0 +1,111 @@ +"""Run the exact production jar and build-only verifier in a vanilla Forge client.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import urllib.request +from concurrent.futures import ThreadPoolExecutor +from audit_runtime_logs import audit +from release_identity import MAIN_JAR + + +def prepare_assets(folder): + folder.mkdir(parents=True,exist_ok=True) + (folder/'indexes').mkdir(exist_ok=True) + data=urllib.request.urlopen('https://piston-meta.mojang.com/v1/packages/7c2800b458376b8fc0b738382fb7784328fddda9/1.10.json').read() + if hashlib.sha1(data).hexdigest()!='7c2800b458376b8fc0b738382fb7784328fddda9': raise RuntimeError('Asset index hash mismatch') + (folder/'indexes/1.10.json').write_bytes(data) + def fetch(obj): + sha=obj['hash']; path=folder/'objects'/sha[:2]/sha + if path.is_file() and hashlib.sha1(path.read_bytes()).hexdigest()==sha: return + payload=urllib.request.urlopen('https://resources.download.minecraft.net/'+sha[:2]+'/'+sha,timeout=45).read() + if len(payload)!=obj['size'] or hashlib.sha1(payload).hexdigest()!=sha: raise RuntimeError('Asset hash mismatch '+sha) + path.parent.mkdir(parents=True,exist_ok=True); path.write_bytes(payload) + with ThreadPoolExecutor(max_workers=8) as pool: + list(pool.map(fetch,json.loads(data)['objects'].values())) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--java',type=Path,required=True) + parser.add_argument('--forge-jar',type=Path,required=True) + parser.add_argument('--output',type=Path,required=True) + parser.add_argument('--gradle-home',type=Path,required=True) + args=parser.parse_args() + project=Path(__file__).resolve().parents[1] + root=args.output.resolve() + if root.exists(): raise RuntimeError('Preserve previous evidence; use a new output directory') + root.mkdir(parents=True) + (root/'mods').mkdir(); (root/'config').mkdir() + main=project/'build/libs'/MAIN_JAR + probe=project/'build/integration-test-mod/lootablebodies-build-tests-reobf.jar' + candidate_hash=hashlib.sha256(main.read_bytes()).hexdigest().upper() + for jar in [main,probe]: shutil.copy2(jar,root/'mods'/jar.name) + if hashlib.sha256((root/'mods'/main.name).read_bytes()).hexdigest().upper()!=candidate_hash: + raise RuntimeError('Candidate copy hash mismatch') + (root/'config/lootablebodies.cfg').write_text('options {\n B:local_texture=true\n}\n') + cache=args.gradle_home.resolve()/'caches' + vanilla=cache/'minecraftforge/forgegradle/mavenizer/caches/minecraft_tasks/1.10.2/client.jar' + if hashlib.sha1(vanilla.read_bytes()).hexdigest()!='dc8e75ac7274ff6af462b0dcec43c307de668e40': + raise RuntimeError('Vanilla Minecraft 1.10.2 client hash mismatch') + # A pinned index avoids other Minecraft versions replacing a shared generic index. + assets=project/'.gradle/packaged-assets' + prepare_assets(assets) + natives=cache/'minecraftforge/forgegradle/slime-launcher/cache/net/minecraftforge/forge/1.10.2-12.18.3.2511/natives' + dependencies=[Path(p) for p in json.loads((project/'build/runtime-classpath.json').read_text())] + # Only official runtime libraries; the mapped Forge jar and development mappings stay out. + dependencies=[p for p in dependencies if p.suffix=='.jar' and 'forge-1.10.2-12.18.3.2511-stable' not in p.name] + if any(any(token in str(p).lower() for token in ['junit','mockito','test-mod','slime-launcher']) for p in dependencies): + raise RuntimeError('Testing/development library in packaged classpath') + forge=root/args.forge_jar.name + shutil.copy2(args.forge_jar,forge) + command=[str(args.java.resolve()),'-Xmx2G','-Dlootablebodies.clientCheck=true','-Dlog4j.skipJansi=true', + '-Djava.library.path='+str(natives),'-Dorg.lwjgl.librarypath='+str(natives), + '-Dlog4j.configurationFile='+str(project/'src/integrationTest/resources/log4j2.xml'), + # Vanilla client precedes Forge's server-oriented manifest Class-Path. + '-cp',os.pathsep.join(map(str,[vanilla,forge]+dependencies)), + 'net.minecraft.launchwrapper.Launch','--tweakClass','net.minecraftforge.fml.common.launcher.FMLTweaker', + '--username','BuildClient','--uuid','00000000000040008000000000000104','--accessToken','0', + '--version','1.10.2','--userType','legacy','--gameDir',str(root), + '--assetsDir',str(assets),'--assetIndex','1.10','--width','854','--height','480'] + with (root/'client.log').open('w',encoding='utf-8') as log: + result=subprocess.run(command,cwd=root,stdout=log,stderr=subprocess.STDOUT,timeout=180) + text=(root/'client.log').read_text(errors='replace') + if result.returncode or not (root/'client.pass').is_file() or 'LOOTABLE_BODIES_CLIENT_PASS' not in text: + raise RuntimeError('Packaged client failed: '+str(root/'client.log')) + if any(token in text for token in ['AssertionError','NoSuchMethodError','NoClassDefFoundError','[Client thread/FATAL]',"Couldn't render entity",'Missing sound for event:','Patcher expecting empty class data']): + raise RuntimeError('Client log contains a runtime failure') + if list((root/'crash-reports').glob('*')): raise RuntimeError('Client crash reports') + audit(root/'client.log') + checks=['connected_player_death_and_respawn','network_shift_click_exact_nbt', + 'first_save_reload_corpse_and_player','network_normal_click_exact_nbt', + 'second_save_reload_corpse_and_player'] + if (root/'connected-checks.txt').read_text().splitlines()!=['PASS '+name for name in checks]: + raise RuntimeError('Missing connected-player client checks') + drop_checks=['network_accepted_throw','network_cancelled_throw_source_resync', + 'network_rejected_outside_cursor_resync','network_accepted_outside_drop','network_double_click_exact_nbt', + 'network_creative_clone_and_fill','creative_cancelled_close_full_inventory_disk_reload'] + if (root/'drop-client-checks.txt').read_text().splitlines()!=['PASS '+name for name in drop_checks]: + raise RuntimeError('Missing connected drop/creative client checks') + nbt_checks=['malformed_disk_load_gui_exact_inventory','malformed_network_recovery_preserves_raw_records', + 'malformed_corpse_and_player_disk_reload'] + if (root/'nbt-client-checks.txt').read_text().splitlines()!=['PASS '+name for name in nbt_checks]: + raise RuntimeError('Missing malformed NBT graphical recovery checks') + screenshots=['nbt-recovered-corpse.png','nbt-after-recovery-reload.png','drop-cancelled-source-resync.png','drop-rejected-cursor-resync.png','drop-creative-filled.png','drop-creative-final-reload.png','corpse-gui-fresh.png','corpse-gui-reload.png','connected-player-death.png', + 'connected-corpse-before-recovery.png','connected-corpse-after-shift-recovery.png', + 'connected-corpse-first-reload.png','connected-corpse-after-normal-recovery.png', + 'connected-corpse-final-reload.png'] + for name in screenshots: + if not (root/'screenshots'/name).is_file() or (root/'screenshots'/name).stat().st_size==0: + raise RuntimeError('Missing client screenshot '+name) + if hashlib.sha256(main.read_bytes()).hexdigest().upper()!=candidate_hash: + raise RuntimeError('Candidate changed during graphical checks') + (root/'result.json').write_text(json.dumps({'candidate_sha256':candidate_hash, + 'fresh_and_reload_gui':True,'connected_player_checks':checks,'drop_creative_checks':drop_checks,'nbt_checks':nbt_checks,'screenshots':screenshots},indent=2)+'\n') + print('PACKAGED_CLIENT_PASS',flush=True) + + +if __name__=='__main__': main() diff --git a/tools/verify_release_setup.py b/tools/verify_release_setup.py new file mode 100644 index 0000000..625b23a --- /dev/null +++ b/tools/verify_release_setup.py @@ -0,0 +1,130 @@ +"""Exercise release rejection paths locally; every upload task must fail before execution.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import zipfile +from release_identity import PROJECT, PROPERTIES, MOD_VERSION, MAIN_JAR + + +def sha(path): + return hashlib.sha256(path.read_bytes()).hexdigest().upper() + + +def fingerprint(folder): + return {p.relative_to(folder).as_posix(): sha(p) for p in sorted(folder.rglob("*")) if p.is_file()} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + output = args.output.resolve() + output.mkdir(parents=True, exist_ok=False) + bundle = PROJECT / "build/release/bundle" + before = fingerprint(bundle) + if len(before) != 6: + raise RuntimeError("Run prepareReleaseBundle first; exactly six bundle files are required") + wrapper = str(PROJECT / ("gradlew.bat" if os.name == "nt" else "gradlew")) + results = [] + + def run(name, tasks, expected=None, prepared=False): + log = output / (name + ".log") + with log.open("w", encoding="utf-8") as stream: + process = subprocess.run([wrapper, *tasks, "--offline", "--no-daemon", "--console=plain"], + cwd=PROJECT, stdout=stream, stderr=subprocess.STDOUT, timeout=180) + text = log.read_text(encoding="utf-8", errors="replace") + if expected is None: + if process.returncode or "BUILD SUCCESSFUL" not in text: + raise RuntimeError(f"Valid release contract failed: {log}") + elif process.returncode == 0 or expected not in text: + raise RuntimeError(f"Expected rejection {expected!r} was not proved: {log}") + if prepared and any(f"> Task :{task}" in text for task in ("compileJava", "jar", "sourcesJar", "javadocJar")): + raise RuntimeError(f"Prepared input caused a jar rebuild: {log}") + results.append({"case": name, "expected_rejection": expected, "exit": process.returncode}) + print("RELEASE_CONTRACT_PASS", name, flush=True) + + run("valid-prepared", ["verifyPreparedReleaseArtifacts", f"-PpreparedReleaseDir={bundle}"], prepared=True) + for name, override, expected in [ + ("three-component-version", "mod_version=2.4.1", "Major.Minor.Bug.110021"), + ("wrong-target", "mod_version=2.4.1.110022", "Major.Minor.Bug.110021"), + ("wrong-group", "mod_group=zone.moddev.mc.orespawn", "Release identity mismatch: mod_group"), + ("wrong-artifact", "mod_artifact=OreSpawn", "Release identity mismatch: mod_artifact"), + ("wrong-mod-id", "mod_id=orespawn", "Release identity mismatch: mod_id"), + ("wrong-project", "curseforge_project_id=245586", "Release identity mismatch: curseforge_project_id"), + ("wrong-loader", "loader_name=neoforge", "Release identity mismatch: loader_name"), + ("wrong-license", "mod_license=CC0-1.0", "Release identity mismatch: mod_license"), + ("wrong-java", "java_version=17", "Release identity mismatch: java_version"), + ("stale-java-version", "mod_version=2.4.2.110021", "Java release version or persistent identity differs"), + ]: + run(name, ["verifyReleaseConfiguration", "-P" + override], expected) + + def fixture(name): + target = output / name + shutil.copytree(bundle, target) + return target + + def sums(folder): + (folder / "SHA256SUMS").write_text("".join( + f"{sha(p)} {p.name}\n" for p in sorted(folder.glob("*.jar"))), encoding="utf-8") + + def rewrite_jar(folder, entry, replacement): + jar = folder / MAIN_JAR + temporary = jar.with_suffix(".rewritten") + with zipfile.ZipFile(jar) as source, zipfile.ZipFile(temporary, "w") as target: + for info in source.infolist(): + target.writestr(info, replacement if info.filename == entry else source.read(info.filename)) + temporary.replace(jar) + sums(folder) + + for name, expected in [ + ("missing-sources", "exactly the declared jars"), + ("empty-main", "Empty artifact"), + ("bad-checksum", "checksums do not match"), + ("changed-pom", "POM differs"), + ("changed-changelog", "changelog differs"), + ("extra-jar", "exactly the declared jars"), + ("deobfuscated-main", "runtime SRG mappings"), + ("changed-license", "licence or attribution"), + ("foreign-class", "Foreign production class"), + ]: + folder = fixture(name) + if name == "missing-sources": + (folder / MAIN_JAR.replace(".jar", "-sources.jar")).unlink() + elif name == "empty-main": + (folder / MAIN_JAR).write_bytes(b"") + elif name == "bad-checksum": + (folder / "SHA256SUMS").write_text("0" * 64 + " " + MAIN_JAR + "\n") + elif name == "changed-pom": + pom = folder / f'{PROPERTIES["mod_artifact"]}-{MOD_VERSION}.pom' + pom.write_bytes(pom.read_bytes().replace(b"Cyanobacterium", b"OtherAuthor")) + elif name == "changed-changelog": + (folder / "CHANGELOG.txt").write_text("unreviewed release notes\n") + elif name == "extra-jar": + (folder / "unexpected.jar").write_bytes(b"unexpected") + elif name == "deobfuscated-main": + shutil.copyfile(PROJECT / "build/libs-dev" / MAIN_JAR.replace(".jar", "-deobf.jar"), folder / MAIN_JAR) + sums(folder) + elif name == "changed-license": + rewrite_jar(folder, "META-INF/LICENSE", b"wrong licence\n") + elif name == "foreign-class": + with zipfile.ZipFile(folder / MAIN_JAR, "a") as jar: + jar.writestr("foreign/Injected.class", bytes.fromhex("CAFEBABE00000034")) + sums(folder) + run(name, ["verifyPreparedReleaseArtifacts", f"-PpreparedReleaseDir={folder}"], expected, prepared=True) + + for task in ["publish", "publishToMavenLocal", "publishMavenJavaPublicationToReleaseRepository"]: + run("blocked-" + task, [task, "-PpublicationEnabled=true", f"-Dmaven.repo.local={output / 'unused-maven-local'}"], + "Publication is disabled") + if (output / "unused-maven-local").exists() or fingerprint(bundle) != before: + raise RuntimeError("Publication guard wrote a repository or immutable input changed") + run("valid-prepared-final", ["verifyPreparedReleaseArtifacts", f"-PpreparedReleaseDir={bundle}"], prepared=True) + (output / "result.json").write_text(json.dumps({"checks": results, "bundle_unchanged": True, + "prepared_jar_rebuild": False, "publication_disabled": True}, indent=2) + "\n", encoding="utf-8") + + +if __name__ == "__main__": + main() From 24dc88c07403bd065dad3a6443a8cbc3423da409 Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Wed, 21 Oct 2026 14:22:11 +0100 Subject: [PATCH 2/5] Install the display and audio tools for Linux client checks --- .github/workflows/ci.yml | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 65f9f93..507c232 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -118,10 +118,22 @@ jobs: -Dorg.gradle.java.installations.auto-detect=false -Dorg.gradle.java.installations.auto-download=false + - name: Install legacy client display and audio support + run: | + sudo apt-get update + sudo apt-get install --yes x11-xserver-utils libgl1-mesa-dri libglu1-mesa libopenal1 pulseaudio + command -v xrandr + pulseaudio --start --exit-idle-time=-1 + pactl load-module module-null-sink sink_name=minecraft + echo "PULSE_SINK=minecraft" >> "$GITHUB_ENV" + echo "ALSOFT_DRIVERS=pulse" >> "$GITHUB_ENV" + - name: Verify real graphical client and corpse GUI env: LIBGL_ALWAYS_SOFTWARE: 'true' - run: xvfb-run -a ./gradlew clientIntegrationTest preparePackagedIntegrationProbe exportRuntimeClasspath --no-daemon + run: >- + xvfb-run -a -s '-screen 0 1280x720x24 +extension GLX +render -noreset' + sh -c 'xrandr --query && ./gradlew clientIntegrationTest preparePackagedIntegrationProbe exportRuntimeClasspath --no-daemon' - name: Verify exact packaged dedicated servers run: >- @@ -143,7 +155,7 @@ jobs: env: LIBGL_ALWAYS_SOFTWARE: 'true' run: >- - xvfb-run -a python3 tools/verify_packaged_client.py --java "$JAVA_HOME_8_X64/bin/java" + xvfb-run -a -s '-screen 0 1280x720x24 +extension GLX +render -noreset' python3 tools/verify_packaged_client.py --java "$JAVA_HOME_8_X64/bin/java" --forge-jar build/packaged-runtime/forge-distribution/forge-1.10.2-12.18.3.2511-universal.jar --output build/packaged-client --gradle-home "$HOME/.gradle" @@ -151,7 +163,7 @@ jobs: env: LIBGL_ALWAYS_SOFTWARE: 'true' run: >- - xvfb-run -a python3 tools/verify_multiplayer.py --java "$JAVA_HOME_8_X64/bin/java" + xvfb-run -a -s '-screen 0 1280x720x24 +extension GLX +render -noreset' python3 tools/verify_multiplayer.py --java "$JAVA_HOME_8_X64/bin/java" --forge-home build/packaged-runtime/forge-distribution --gradle-home "$HOME/.gradle" --output build/packaged-multiplayer From 180179075bc16aa52653e0a7e1fb27221fb09d78 Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Wed, 21 Oct 2026 14:34:44 +0100 Subject: [PATCH 3/5] Wait for queued chunks to unload in the runtime tests --- .../lootable/buildtest/ReportedIssueChecks.java | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/integrationTest/java/cyano/lootable/buildtest/ReportedIssueChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/ReportedIssueChecks.java index 59a254d..3b01dc8 100644 --- a/src/integrationTest/java/cyano/lootable/buildtest/ReportedIssueChecks.java +++ b/src/integrationTest/java/cyano/lootable/buildtest/ReportedIssueChecks.java @@ -164,7 +164,7 @@ private static void normalTickGate() { } private static void unloadReload() throws Exception { EntityLootableBody b=fullBody(128,128,8.5);UUID uuid=b.getUniqueID();Map expected=inventory(b); - world.getChunkProvider().unload(world.getChunkFromChunkCoords(128,128));world.getChunkProvider().tick();flushChunks();world.updateEntities(); + unloadChunk(128,128);world.updateEntities(); RuntimeChecks.require(!world.getChunkProvider().chunkExists(128,128),"Fixture chunk did not actually unload"); RuntimeChecks.require(!world.loadedEntityList.contains(b),"Unloaded corpse still receives normal world ticks"); world.getChunkFromChunkCoords(128,128);EntityLootableBody loaded=RuntimeChecks.find(world,uuid); @@ -174,7 +174,7 @@ private static final class Support implements AutoCloseable { EntityLootableBody body; int cx,cz,nx,nz; BlockPos platform; ForgeChunkManager.Ticket ticket; Map expected; - void unload(){world.getChunkProvider().unload(world.getChunkFromChunkCoords(nx,nz));world.getChunkProvider().tick();flushChunks();RuntimeChecks.require(!world.getChunkProvider().chunkExists(nx,nz),"Support chunk did not unload");} + void unload(){unloadChunk(nx,nz);RuntimeChecks.require(!world.getChunkProvider().chunkExists(nx,nz),"Support chunk did not unload");} void frozen(){for(int i=0;i<100;i++)world.updateEntity(body);System.out.println("ISSUE17_UNLOADED_SUPPORT y="+body.posY+" alive="+body.isEntityAlive()+" inventory="+inventory(body).size()+" supportLoaded="+world.getChunkProvider().chunkExists(nx,nz));RuntimeChecks.require(body.isEntityAlive() && body.posY==80 && expected.equals(inventory(body)),"Corpse fell through unloaded neighbour support and lost recoverable inventory");} void resume(){world.getChunkFromChunkCoords(nx,nz);for(int i=0;i<10;i++)world.updateEntity(body);RuntimeChecks.require(body.onGround && body.posY==80 && body.addedToChunk && expected.equals(inventory(body)),"Corpse failed to resume safely after support terrain loaded");} @Override public void close(){ForgeChunkManager.releaseTicket(ticket);} @@ -217,6 +217,16 @@ private static void realVoid() { RuntimeChecks.require(!f.body.isEntityAlive() && inventory(f.body).isEmpty(),"Loaded real void behavior changed into a rescue feature"); } } + private static void unloadChunk(int x,int z) { + net.minecraft.world.gen.ChunkProviderServer provider=world.getChunkProvider(); + provider.unload(world.getChunkFromChunkCoords(x,z)); + // Each 1.10 provider tick saves/unloads at most 100 chunks. An integrated + // client can leave a larger queue when the test teleports its players. + int attempts=provider.getLoadedChunks().size()/100+2; + for(int i=0;i Date: Wed, 21 Oct 2026 14:46:30 +0100 Subject: [PATCH 4/5] Announce the synthetic player before tracked respawn --- .../java/cyano/lootable/buildtest/ReportedIssueChecks.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/integrationTest/java/cyano/lootable/buildtest/ReportedIssueChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/ReportedIssueChecks.java index 3b01dc8..f80c5af 100644 --- a/src/integrationTest/java/cyano/lootable/buildtest/ReportedIssueChecks.java +++ b/src/integrationTest/java/cyano/lootable/buildtest/ReportedIssueChecks.java @@ -114,6 +114,10 @@ private static void deathSequence() throws Exception { world.getGameRules().setOrCreateGameRule("keepInventory","false");world.getChunkFromChunkCoords(2,0); EntityPlayerMP p=new EntityPlayerMP(server,world,new GameProfile(UUID.randomUUID(),"IssueFive"),new PlayerInteractionManager(world)); p.connection=new QuietConnection(p);p.setGameType(GameType.SURVIVAL);p.setPosition(32.5,80,0.5); + // Vanilla clients need player-list information before a tracked player + // spawn, including the brief spawn-point visit during real respawn. + server.getPlayerList().sendPacketToAllPlayers(new net.minecraft.network.play.server.SPacketPlayerListItem( + net.minecraft.network.play.server.SPacketPlayerListItem.Action.ADD_PLAYER,p)); RuntimeChecks.require(world.spawnEntity(p),"Fixture player spawn");server.getPlayerList().getPlayers().add(p); p.inventory.currentItem=4;p.inventory.mainInventory[4]=shovel();p.inventory.mainInventory[9]=RuntimeChecks.stack(301); Map expected=tagged(p); From d7260a522a50bbd21f9794ac09fab3da73f01b80 Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Wed, 21 Oct 2026 15:07:22 +0100 Subject: [PATCH 5/5] Preserve UTF-8 translations in release archives --- CHANGELOG.txt | 1 + build.gradle | 2 ++ gradle/audit.gradle | 4 +++ .../lootable/contracts/BuildContractTest.java | 25 +++++++++++++++++++ 4 files changed, 32 insertions(+) diff --git a/CHANGELOG.txt b/CHANGELOG.txt index a8adb57..0a5f148 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -9,6 +9,7 @@ Updated the Minecraft 1.10.2 build and release setup. - Failed player skin lookups use a default skin without blocking the game. - Added a Config button on the Mods list. Appearance changes apply locally; world and server rules take effect after restarting Minecraft. +- Kept translations intact when building jars on Windows. Unreadable item and damaged field records stay with the corpse for recovery. Keep backups and keep using this maintained reader for affected saves; older diff --git a/build.gradle b/build.gradle index 9e6bb3b..5649597 100644 --- a/build.gradle +++ b/build.gradle @@ -78,6 +78,7 @@ tasks.withType(Javadoc).configureEach { tasks.withType(AbstractArchiveTask).configureEach { preserveFileTimestamps = false reproducibleFileOrder = true + filteringCharset = 'UTF-8' } ext.archiveTextPatterns = ['**/*.info', '**/*.java', '**/*.lang', '**/*.mcmeta', '**/*.html', '**/*.css', '**/*.js', '**/*.md', '**/*.txt', '**/package-list', '**/LICENSE', '**/LICENSE-CC0', '**/NOTICE'] @@ -183,6 +184,7 @@ ext.releaseJar = renamer.classes(tasks.named('jar', Jar)) { artifacts { add(name, releaseJar) } } tasks.named('assemble') { dependsOn releaseJar, 'sourcesJar', 'javadocJar' } +tasks.named('test') { dependsOn 'jar', 'sourcesJar' } apply from: 'gradle/ide.gradle' apply from: 'gradle/audit.gradle' diff --git a/gradle/audit.gradle b/gradle/audit.gradle index c04b5b9..4a3794d 100644 --- a/gradle/audit.gradle +++ b/gradle/audit.gradle @@ -35,6 +35,10 @@ ext.auditReleaseArchives = { File directory -> int major = ((bytes[6] & 255) << 8) | (bytes[7] & 255) if (major != 52) throw new GradleException("${path} is class major ${major}") } + if (path.endsWith('.lang')) { + java.nio.charset.StandardCharsets.UTF_8.newDecoder().decode( + java.nio.ByteBuffer.wrap(zip.getInputStream(entry).bytes)) + } String content = new String(zip.getInputStream(entry).bytes, 'ISO-8859-1') if (content =~ /(?i)([a-z]:[\\\/]users[\\\/]|[a-z]:[\\\/]LootableBodies|\/home\/runner|\/Users\/|gh[pousr]_[A-Za-z0-9]{20}|-----BEGIN .*PRIVATE KEY-----)/) throw new GradleException("Private path/credential in ${name}: ${path}") diff --git a/src/test/java/cyano/lootable/contracts/BuildContractTest.java b/src/test/java/cyano/lootable/contracts/BuildContractTest.java index 94d3814..690a737 100644 --- a/src/test/java/cyano/lootable/contracts/BuildContractTest.java +++ b/src/test/java/cyano/lootable/contracts/BuildContractTest.java @@ -10,6 +10,31 @@ class BuildContractTest { private String text(String path) throws Exception { return new String(Files.readAllBytes(Paths.get(path)), StandardCharsets.UTF_8); } + @Test void packagedTranslationsRetainTheirUtf8Bytes() throws Exception { + Properties release = new Properties(); + try (java.io.InputStream input = Files.newInputStream(Paths.get("gradle.properties"))) { release.load(input); } + String prefix = "LootableBodies-" + release.getProperty("mod_version"); + for (String archive : Arrays.asList("build/libs-dev/" + prefix + "-deobf.jar", "build/libs/" + prefix + "-sources.jar")) { + try (java.util.zip.ZipFile zip = new java.util.zip.ZipFile(archive)) { + java.util.Enumeration entries = zip.entries(); + int locales = 0; + while (entries.hasMoreElements()) { + java.util.zip.ZipEntry entry = entries.nextElement(); + if (!entry.getName().endsWith(".lang")) continue; + byte[] bytes; + try (java.io.InputStream input = zip.getInputStream(entry); java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream()) { + byte[] buffer = new byte[1024]; int count; + while ((count = input.read(buffer)) != -1) out.write(buffer, 0, count); + bytes = out.toByteArray(); + } + StandardCharsets.UTF_8.newDecoder().decode(java.nio.ByteBuffer.wrap(bytes)); + assertArrayEquals(Files.readAllBytes(Paths.get("build/resources/main", entry.getName())), bytes, archive + ": " + entry.getName()); + locales++; + } + assertEquals(14, locales, archive); + } + } + } @Test void originalIdentityAndDeclaredReleaseRemainConsistent() throws Exception { String source = text("src/main/java/cyano/lootable/LootableBodies.java"); assertTrue(source.contains("MODID = \"lootablebodies\""));