diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..998e11e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,17 @@ +* text=auto +*.java text eol=lf +*.gradle text eol=lf +*.properties text eol=lf +*.json text eol=lf +*.lang text eol=lf +*.mcmeta text eol=lf +*.info text eol=lf +*.md text eol=lf +*.txt text eol=lf +LICENSE* text eol=lf +*.yml text eol=lf +*.py text eol=lf +gradlew text eol=lf +*.bat text eol=crlf +*.jar binary +*.png binary diff --git a/.github/scripts/test_release_workflow.py b/.github/scripts/test_release_workflow.py index 1012756..e932114 100644 --- a/.github/scripts/test_release_workflow.py +++ b/.github/scripts/test_release_workflow.py @@ -13,6 +13,10 @@ REPOSITORY = 'MinecraftModDevelopmentMods/LootableBodies' SHA = 'a' * 40 CORE = ['Cold Forge bootstrap', 'Build, test, and audit', 'Analyze Java', 'Validation'] +MATRIX112 = [origin + ' / ' + stamps for origin in + ['original-110', 'released-110', 'original-111', 'recovery-110', + 'released-111', 'original-112', 'recovery-111', 'via111-110'] + for stamps in ['normal', 'stamps']] MATRIX = [origin + ' / ' + stamps for origin in ['original-110', 'released-110', 'original-111', 'recovery-110'] for stamps in ['normal', 'stamps']] @@ -42,7 +46,10 @@ def select(self, version, repository=REPOSITORY, ref='refs/heads/master-1.12'): return result.returncode, values def validate(self, minecraft, names=None, mutation=None, release_exists=False, wrong_tag=False, audit_success=True): - suffix, branch, forge = ('110021', '1.10.2', '12.18.3.2511') if minecraft == '1.10.2' else ('111021', '1.11.2-dev', '13.20.1.2588') + suffix, branch, forge = { + '1.10.2': ('110021', '1.10.2', '12.18.3.2511'), + '1.11.2': ('111021', '1.11.2-dev', '13.20.1.2588'), + '1.12.2': ('112021', 'master-1.12', '14.23.5.2859')}[minecraft] version = '2.4.2.' + suffix values = dict(minecraft_version=minecraft, forge_version=forge, loader_name='forge', loader_code='1', java_version='8', java_toolchain_version='8.0.502+7', gradle_java_version='17', mod_id='lootablebodies', @@ -50,12 +57,12 @@ def validate(self, minecraft, names=None, mutation=None, release_exists=False, w curseforge_project_id='240968', mod_version=version) if mutation: values.update(mutation) - checks = CORE + (MATRIX if minecraft == '1.11.2' else []) if names is None else names + checks = CORE + {'1.11.2': MATRIX, '1.12.2': MATRIX112}.get(minecraft, []) if names is None else names def command(args, **options): if args[0] == 'git': return SHA + '\n' if '/actions/workflows/' in args[-1]: - return json.dumps({'workflow_runs': [{'id': 1, 'display_title': 'Audit 1.11.2 artifacts from ' + SHA, + return json.dumps({'workflow_runs': [{'id': 1, 'display_title': 'Audit ' + minecraft + ' artifacts from ' + SHA, 'conclusion': 'success' if audit_success else 'failure'}]}) return json.dumps({'check_runs': [dict(name=name, id=i, conclusion='success') for i, name in enumerate(checks)]}) def existing(args, **options): @@ -73,8 +80,8 @@ def existing(args, **options): patch('subprocess.check_output', side_effect=command), patch('subprocess.run', side_effect=existing): exec(compile(code, 'release-preflight', 'exec'), {}) - def test_routes_both_supported_versions(self): - for suffix, minecraft, branch in [('110021', '1.10.2', '1.10.2'), ('111021', '1.11.2', '1.11.2-dev')]: + def test_routes_all_supported_versions(self): + for suffix, minecraft, branch in [('110021', '1.10.2', '1.10.2'), ('111021', '1.11.2', '1.11.2-dev'), ('112021', '1.12.2', 'master-1.12')]: with self.subTest(minecraft=minecraft): status, values = self.select('2.4.2.' + suffix) self.assertEqual(0, status) @@ -82,7 +89,7 @@ def test_routes_both_supported_versions(self): self.assertEqual(branch, values['source_branch']) def test_invalid_versions_are_rejected(self): - for version in ['2.4.2', '2.4.2.112021', '02.4.2.111021', '2.4.2.111021;exit 0']: + for version in ['2.4.2', '2.4.2.113021', '02.4.2.111021', '2.4.2.111021;exit 0']: with self.subTest(version=version): self.assertNotEqual(0, self.select(version)[0]) @@ -103,6 +110,16 @@ def test_111_requires_an_independent_audit_of_its_exact_artifact(self): with self.assertRaises(SystemExit): self.validate('1.11.2', audit_success=False) + def test_112_requires_every_upgrade_case(self): + self.validate('1.12.2') + for missing in MATRIX112: + with self.subTest(missing=missing), self.assertRaises(SystemExit): + self.validate('1.12.2', names=CORE + [name for name in MATRIX112 if name != missing]) + + def test_112_requires_an_independent_audit_of_its_exact_artifact(self): + with self.assertRaises(SystemExit): + self.validate('1.12.2', audit_success=False) + def test_identity_changes_are_rejected(self): for mutation in [{'mod_id': 'other'}, {'minecraft_version': '1.10.2'}, {'mod_license': 'CC0-1.0'}]: with self.subTest(mutation=mutation), self.assertRaises(SystemExit): diff --git a/.github/workflows/artifact-audit-1.11.yml b/.github/workflows/artifact-audit-1.11.yml index 38e9cc7..5b58860 100644 --- a/.github/workflows/artifact-audit-1.11.yml +++ b/.github/workflows/artifact-audit-1.11.yml @@ -3,7 +3,7 @@ run-name: Audit 1.11.2 artifacts from ${{ github.event.workflow_run.head_sha }} on: workflow_run: - workflows: [LootableBodies 1.10 CI, LootableBodies 1.11 CI] + workflows: [LootableBodies 1.10 CI, LootableBodies 1.11 CI, LootableBodies 1.12 CI] types: [completed] permissions: diff --git a/.github/workflows/artifact-audit-1.12.yml b/.github/workflows/artifact-audit-1.12.yml new file mode 100644 index 0000000..3f812f9 --- /dev/null +++ b/.github/workflows/artifact-audit-1.12.yml @@ -0,0 +1,64 @@ +name: Audit LootableBodies 1.12 artifacts +run-name: Audit 1.12.2 artifacts from ${{ github.event.workflow_run.head_sha }} + +on: + workflow_run: + workflows: [LootableBodies 1.10 CI, LootableBodies 1.11 CI, LootableBodies 1.12 CI] + types: [completed] + +permissions: + contents: read + actions: read + +jobs: + audit: + # Pull-request merge trees are audited inside CI. Push artifacts use the + # workflow run's exact head SHA, which is also their declared artifact name. + if: >- + github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && + (github.event.workflow_run.head_branch == 'master-1.12.2' || github.event.workflow_run.head_branch == 'master-1.12') + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.event.workflow_run.head_sha }} + - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '25.0.3+9.0.LTS' + - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '8.0.502+7' + - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '17.0.1+12' + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: LootableBodies-1.12.2-${{ github.event.workflow_run.head_sha }} + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + path: downloaded + - name: Require exact inputs and assemble an immutable audit bundle + run: | + set -euo pipefail + mkdir audited + for name in LootableBodies-2.4.3.112021.jar LootableBodies-2.4.3.112021-sources.jar LootableBodies-2.4.3.112021-javadoc.jar; do + test -s "downloaded/build/libs/$name" + cp "downloaded/build/libs/$name" audited/ + done + test -s downloaded/build/release/SHA256SUMS + test -s downloaded/build/publications/mavenJava/pom-default.xml + test -s downloaded/CHANGELOG.txt + cp downloaded/build/release/SHA256SUMS audited/ + cp downloaded/build/publications/mavenJava/pom-default.xml audited/LootableBodies-2.4.3.112021.pom + cp downloaded/CHANGELOG.txt audited/ + - name: Audit original bytes without recompiling the jars + env: + LOOTABLEBODIES_JAVA8_HOME: ${{ env.JAVA_HOME_8_X64 }} + LOOTABLEBODIES_MAVENIZER_JAVA_HOME: ${{ env.JAVA_HOME_25_X64 }} + run: | + chmod +x ./gradlew + ./gradlew verifyPreparedReleaseArtifacts -PpreparedReleaseDir=audited --no-daemon diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ddfe637..c1c17e0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,36 +1,344 @@ -name: "Validate Build" +name: LootableBodies 1.12 CI + on: push: - paths-ignore: - - '.github/workflows/**' - - '.github/scripts/**' - - '**/*.md' + branches: + - master-1.12.2 + - master-1.12 + - 'feature/**' pull_request: - paths-ignore: - - '.github/workflows/**' - - '.github/scripts/**' - - '**/*.md' + branches: + - master-1.12.2 + - master-1.12 + +permissions: + contents: read + +concurrency: + group: lootablebodies-1.12-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: - validate: + 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 }} + LOOTABLEBODIES_MAVENIZER_JAVA_HOME: ${{ env.JAVA_HOME_25_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 }} + LOOTABLEBODIES_MAVENIZER_JAVA_HOME: ${{ env.JAVA_HOME_25_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: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - 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" + echo "LOOTABLEBODIES_MAVENIZER_JAVA_HOME=$JAVA_HOME_25_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: Test runtime log contracts + run: python3 -m unittest discover -s tools -p 'test_*.py' -v + + - name: Build, test, and audit release artifacts + run: >- + ./gradlew clean check build javadoc forgeControlServer verifyReleaseArtifacts writeReleaseChecksums + upgradeContractTest 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: Install legacy client display and audio support + timeout-minutes: 10 + run: | + if [ -f /etc/apt/apt-mirrors.txt ]; then + echo 'https://archive.ubuntu.com/ubuntu/' | sudo tee /etc/apt/apt-mirrors.txt > /dev/null + fi + sudo apt-get update -o Acquire::Retries=2 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 + sudo apt-get install --yes --no-install-recommends -o Acquire::Retries=2 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 x11-xserver-utils libgl1-mesa-dri libglu1-mesa libopenal1 pulseaudio pulseaudio-utils + 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 -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: >- + 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: Verify ordinary production servers exclude the test mod + run: | + python3 tools/verify_normal_server.py --java "$JAVA_HOME_8_X64/bin/java" --forge-home build/packaged-runtime/forge-distribution --output build/ordinary-development --development + python3 tools/verify_normal_server.py --java "$JAVA_HOME_8_X64/bin/java" --forge-home build/packaged-runtime/forge-distribution --output build/ordinary-packaged + + - 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 -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.12.2-14.23.5.2859-universal.jar + --output build/packaged-client --gradle-home "$HOME/.gradle" + + - name: Verify configuration, skins and the reported death sequence + env: + LIBGL_ALWAYS_SOFTWARE: 'true' + run: | + for mode in config skin issues names; do + 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.12.2-14.23.5.2859-universal.jar --output "build/packaged-client-$mode" --gradle-home "$HOME/.gradle" --mode "$mode" + done + + - name: Verify two real loopback clients and persisted shared inventory + env: + LIBGL_ALWAYS_SOFTWARE: 'true' + run: >- + 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 + + - name: Verify respawn packets with a connected observer + env: + LIBGL_ALWAYS_SOFTWARE: 'true' + run: >- + xvfb-run -a -s '-screen 0 1280x720x24 +extension GLX +render -noreset' python3 tools/verify_visibility.py + --java "$JAVA_HOME_8_X64/bin/java" --forge-home build/packaged-runtime/forge-distribution + --gradle-home "$HOME/.gradle" --output build/packaged-respawn --mode respawn + + - name: Verify simultaneous respawns after restart + env: + LIBGL_ALWAYS_SOFTWARE: 'true' + run: >- + xvfb-run -a -s '-screen 0 1280x720x24 +extension GLX +render -noreset' python3 tools/verify_visibility.py + --java "$JAVA_HOME_8_X64/bin/java" --forge-home build/packaged-runtime/forge-distribution + --gradle-home "$HOME/.gradle" --output build/packaged-respawn-restart --mode respawn + --source-server build/packaged-respawn/server --simultaneous --counter-collision + + - name: Verify default timing, TNT death and connected chunk re-entry + env: + LIBGL_ALWAYS_SOFTWARE: 'true' + run: | + xvfb-run -a -s '-screen 0 1280x720x24 +extension GLX +render -noreset' python3 tools/verify_visibility.py --java "$JAVA_HOME_8_X64/bin/java" --forge-home build/packaged-runtime/forge-distribution --gradle-home "$HOME/.gradle" --output build/packaged-visibility --mode visibility + xvfb-run -a -s '-screen 0 1280x720x24 +extension GLX +render -noreset' python3 tools/verify_visibility.py --java "$JAVA_HOME_8_X64/bin/java" --forge-home build/packaged-runtime/forge-distribution --gradle-home "$HOME/.gradle" --output build/packaged-visibility-restart --mode visibility --source-server build/packaged-visibility/server + + - name: Audit complete runtime file logs and crash directories + run: >- + python3 tools/audit_runtime_logs.py --recursive --fixture-logins + build/packaged-runtime build/packaged-nbt build/ordinary-packaged + build/packaged-client build/packaged-client-config build/packaged-client-skin build/packaged-client-issues build/packaged-client-names + build/packaged-multiplayer build/packaged-respawn build/packaged-respawn-restart + build/packaged-visibility build/packaged-visibility-restart + + - name: Compare a second forced build + run: | + mkdir -p "$RUNNER_TEMP/first-build" + cp build/libs/LootableBodies-2.4.3.112021{,-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.3.112021{,-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: Set up JDK 1.8 - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 - with: - distribution: temurin - java-version: '8.0.502+7' - java-package: jdk # (jre, jdk, or jdk+fx) - defaults to jdk - architecture: x64 # (x64 or x86) - defaults to x64 + - 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: Make Wrapper Executable - run: chmod a+x gradlew + - name: Require all six declared artifacts + run: | + for path in build/libs/LootableBodies-2.4.3.112021.jar build/libs/LootableBodies-2.4.3.112021-sources.jar build/libs/LootableBodies-2.4.3.112021-javadoc.jar build/release/SHA256SUMS build/publications/mavenJava/pom-default.xml CHANGELOG.txt; do + test -s "$path" + done - - name: Setup CI Workspace - run: ./gradlew clean setupciworkspace + - name: Upload audited release candidate + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: LootableBodies-1.12.2-${{ github.sha }} + if-no-files-found: error + retention-days: 30 + path: | + build/libs/LootableBodies-2.4.3.112021.jar + build/libs/LootableBodies-2.4.3.112021-sources.jar + build/libs/LootableBodies-2.4.3.112021-javadoc.jar + build/release/SHA256SUMS + build/publications/mavenJava/pom-default.xml + CHANGELOG.txt - - name: Build with Gradle - run: ./gradlew build + - name: Upload diagnostics on failure + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: LootableBodies-1.12.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-*/**/*.login-retries.json + build/packaged-*/sync/login-* + 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/packaged-respawn/**/*.log + build/packaged-respawn/**/crash-reports/** + build/packaged-respawn/sync/*.txt + build/packaged-client-config/**/*.log + build/packaged-client-config/*.txt + build/packaged-client-config/screenshots/*.png + build/packaged-client-skin/**/*.log + build/packaged-client-skin/*.txt + build/packaged-client-skin/screenshots/*.png + build/packaged-client-issues/**/*.log + build/packaged-client-issues/*.txt + build/packaged-client-issues/screenshots/*.png + build/packaged-client-names/**/*.log + build/packaged-client-names/*.txt + build/packaged-client-names/screenshots/*.png + build/ordinary-development/**/*.log + build/ordinary-packaged/**/*.log + build/packaged-*/client*/*.pass + build/packaged-*/sync/state + build/packaged-*/sync/phase + build/packaged-respawn-restart/**/*.log + build/packaged-respawn-restart/sync/*.txt + build/packaged-visibility/**/*.log + build/packaged-visibility/sync/*.txt + build/packaged-visibility/clientA/screenshots/*.png + build/packaged-visibility/clientB/screenshots/*.png + build/packaged-visibility-restart/**/*.log + build/packaged-visibility-restart/sync/*.txt + build/packaged-visibility-restart/clientA/screenshots/*.png + build/packaged-visibility-restart/clientB/screenshots/*.png + build/problems/** diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 0000000..78e3313 --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,76 @@ +name: CodeQL + +on: + push: + branches: + - master-1.12.2 + - master-1.12 + - 'feature/**' + pull_request: + branches: + - master-1.12.2 + - master-1.12 + 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" + echo "LOOTABLEBODIES_MAVENIZER_JAVA_HOME=$JAVA_HOME_25_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 index e0f1a97..b6aae57 100644 --- a/.github/workflows/deploy-release.yml +++ b/.github/workflows/deploy-release.yml @@ -5,7 +5,7 @@ on: workflow_dispatch: inputs: release_version: - description: Complete version from gradle.properties, ending in 110021 or 111021. + description: Complete version from gradle.properties, ending in 110021, 111021 or 112021. required: true type: string curseforge_release_level: @@ -48,10 +48,11 @@ jobs: 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|111021)$ ]] + [[ "$RELEASE_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(110021|111021|112021)$ ]] case "$RELEASE_VERSION" in *.110021) minecraft_version=1.10.2; source_branch=1.10.2 ;; *.111021) minecraft_version=1.11.2; source_branch=1.11.2-dev ;; + *.112021) minecraft_version=1.12.2; source_branch=master-1.12 ;; *) exit 1 ;; esac echo "minecraft_version=$minecraft_version" >> "$GITHUB_OUTPUT" @@ -77,7 +78,8 @@ jobs: if line.strip() and not line.lstrip().startswith('#') and '=' in line) minecraft = os.environ['MINECRAFT_VERSION'] targets = {'1.10.2': ('1.10.2', '12.18.3.2511', '110021'), - '1.11.2': ('1.11.2-dev', '13.20.1.2588', '111021')} + '1.11.2': ('1.11.2-dev', '13.20.1.2588', '111021'), + '1.12.2': ('master-1.12', '14.23.5.2859', '112021')} branch, forge, suffix = targets[minecraft] if os.environ['SOURCE_BRANCH'] != branch: raise SystemExit('The selected branch does not match its Minecraft version') @@ -96,18 +98,21 @@ jobs: 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'} - if minecraft == '1.11.2': - required.update(origin + ' / ' + stamps - for origin in ['original-110', 'released-110', 'original-111', 'recovery-110'] + origins = ['original-110', 'released-110', 'original-111', 'recovery-110'] + if minecraft == '1.12.2': + origins += ['released-111', 'original-112', 'recovery-111', 'via111-110'] + if minecraft in ('1.11.2', '1.12.2'): + required.update(origin + ' / ' + stamps for origin in origins for stamps in ['normal', 'stamps']) 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') - if minecraft == '1.11.2': + if minecraft in ('1.11.2', '1.12.2'): + target = '1.11' if minecraft == '1.11.2' else '1.12' audits = json.loads(subprocess.check_output(['gh', 'api', - f'repos/{repo}/actions/workflows/artifact-audit-1.11.yml/runs?event=workflow_run&per_page=100'], text=True))['workflow_runs'] - matching = [run for run in audits if run['display_title'] == f'Audit 1.11.2 artifacts from {sha}'] + f'repos/{repo}/actions/workflows/artifact-audit-{target}.yml/runs?event=workflow_run&per_page=100'], text=True))['workflow_runs'] + matching = [run for run in audits if run['display_title'] == f'Audit {minecraft} artifacts from {sha}'] if not matching or max(matching, key=lambda run: run['id'])['conclusion'] != 'success': raise SystemExit('The selected commit has no successful independent artifact audit') release = subprocess.run(['gh', 'api', f'repos/{repo}/releases/tags/{version}'], capture_output=True, text=True) diff --git a/.github/workflows/migration-matrix.yml b/.github/workflows/migration-matrix.yml new file mode 100644 index 0000000..4df1aee --- /dev/null +++ b/.github/workflows/migration-matrix.yml @@ -0,0 +1,98 @@ +name: LootableBodies 1.12 world upgrades + +on: + push: + branches: [master-1.12.2, master-1.12] + pull_request: + branches: [master-1.12.2, master-1.12] + workflow_dispatch: + +permissions: + contents: read + +jobs: + upgrade: + name: ${{ matrix.origin }} / ${{ matrix.stamps }} + runs-on: ubuntu-latest + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + origin: [original-110, released-110, original-111, recovery-110, released-111, original-112, recovery-111, via111-110] + stamps: [normal, stamps] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '25.0.3+9.0.LTS' + - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '8.0.502+7' + - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '17.0.1+12' + - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6 + with: + cache-disabled: true + - name: Pin separate toolchains + run: | + echo "LOOTABLEBODIES_JAVA8_HOME=$JAVA_HOME_8_X64" >> "$GITHUB_ENV" + echo "LOOTABLEBODIES_MAVENIZER_JAVA_HOME=$JAVA_HOME_25_X64" >> "$GITHUB_ENV" + chmod +x ./gradlew + - name: Build candidate and isolated fixture writers + run: ./gradlew build upgradeContractTest prepareLegacyWorldFixtures preparePackagedIntegrationProbe preparePackagedForgeControl --no-daemon + - name: Download and verify historical artifacts + run: python3 tools/prepare_migration_inputs.py --java "$JAVA_HOME_8_X64/bin/java" --output build/migration-inputs + - name: Create worlds with historical mod writers + run: >- + python3 tools/verify_world_upgrades.py --java "$JAVA_HOME_8_X64/bin/java" + --forge110-home build/migration-inputs/forge110 --forge111-home build/migration-inputs/forge111 --forge112-home build/migration-inputs/forge112 + --original-110 build/migration-inputs/original-110.jar --released-110 build/migration-inputs/released-110.jar + --original-111 build/migration-inputs/original-111.jar --released-111 build/migration-inputs/released-111.jar --original-112 build/migration-inputs/original-112.jar --origins ${{ matrix.origin }} + --seed-only --output build/migration-seeds + - name: Reproduce the Forge diagnostic without Lootable Bodies + run: >- + python3 tools/verify_forge_upgrade_control.py --java "$JAVA_HOME_8_X64/bin/java" + --forge110-home build/migration-inputs/forge110 --forge112-home build/migration-inputs/forge112 + --output build/migration-control + - name: Verify first load, recovery, deferred dimensions and restarts + run: >- + python3 tools/verify_world_upgrades.py --java "$JAVA_HOME_8_X64/bin/java" + --forge110-home build/migration-inputs/forge110 --forge111-home build/migration-inputs/forge111 --forge112-home build/migration-inputs/forge112 + --original-110 build/migration-inputs/original-110.jar --released-110 build/migration-inputs/released-110.jar + --original-111 build/migration-inputs/original-111.jar --released-111 build/migration-inputs/released-111.jar --original-112 build/migration-inputs/original-112.jar --origins ${{ matrix.origin }} + --seeds build/migration-seeds --variants ${{ matrix.stamps }} + --control-report build/migration-control/result.json --output build/migration-results + - name: Verify unsafe preservation stops world loading + run: >- + python3 tools/verify_upgrade_abort.py --java "$JAVA_HOME_8_X64/bin/java" + --forge-home build/migration-inputs/forge112 + --source-world build/migration-seeds/${{ matrix.origin }}/world --output build/migration-abort + - name: Require every report + run: | + for path in build/migration-inputs/inputs.json build/migration-seeds/seed-report.json build/migration-control/result.json build/migration-results/result.json build/migration-abort/result.json; do + test -s "$path" + done + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: LootableBodies-1.12.2-upgrade-${{ matrix.origin }}-${{ matrix.stamps }}-${{ github.sha }} + if-no-files-found: error + path: | + build/migration-inputs/inputs.json + build/migration-seeds/seed-report.json + build/migration-control/result.json + build/migration-results/result.json + build/migration-abort/result.json + - name: Upload failed run diagnostics + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: LootableBodies-1.12.2-upgrade-failure-${{ matrix.origin }}-${{ matrix.stamps }}-${{ github.sha }} + if-no-files-found: error + path: | + build/reports/** + build/migration-*/**/*.log + build/migration-*/**/crash-reports/** diff --git a/.github/workflows/release-workflow-checks.yml b/.github/workflows/release-workflow-checks.yml index f37fc32..1ca556c 100644 --- a/.github/workflows/release-workflow-checks.yml +++ b/.github/workflows/release-workflow-checks.yml @@ -1,7 +1,7 @@ name: Validate release workflow on: push: - branches: ['feature/**', 'master-1.12'] + branches: ['feature/**', 'master-1.12', 'master-1.12.2'] paths: ['.github/workflows/**', '.github/scripts/**'] pull_request: branches: ['master-1.12'] @@ -26,7 +26,7 @@ jobs: - name: Check release workflow syntax run: >- "$RUNNER_TEMP/actionlint/actionlint" -shellcheck= -pyflakes= - .github/workflows/deploy-release.yml .github/workflows/release-workflow-checks.yml .github/workflows/artifact-audit-1.11.yml + .github/workflows/deploy-release.yml .github/workflows/release-workflow-checks.yml .github/workflows/artifact-audit-1.11.yml .github/workflows/artifact-audit-1.12.yml - name: Test version selection and publication checks run: python3 .github/scripts/test_release_workflow.py -v diff --git a/.gitignore b/.gitignore index 9171f8e..66da4f4 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,18 @@ /.idea/* temp/* /run/* -/out/* +/.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 61e88ec..0000000 --- a/.travis.yml +++ /dev/null @@ -1,30 +0,0 @@ -language: java -os: linux -dist: trusty -addons: - apt: - update: true - packages: -# - oracle-java8-installer -git: - quiet: true -arch: amd64 -before_cache: - - rm -f $HOME/.gradle/caches/modules-2/modules-2.lock - - rm -fr $HOME/.gradle/caches/*/plugin-resolution/ -cache: - directories: - - $HOME/.gradle/caches/ - - $HOME/.gradle/wrapper/ -notifications: - email: false -jdk: - - oraclejdk8 -before_install: skip -install: skip -before_script: - - chmod a+x gradlew -script: - - ./gradlew setupCIWorkspace -S - - ./gradlew clean build -S - diff --git a/CHANGELOG.txt b/CHANGELOG.txt new file mode 100644 index 0000000..d9c1ff2 --- /dev/null +++ b/CHANGELOG.txt @@ -0,0 +1,8 @@ +2.4.3.112021 - Minecraft 1.12.2, Forge + +- Brings the maintained inventory and corpse recovery fixes to 1.12.2. +- Adds the Config button on the Mods screen. +- Protects older corpses and their items when upgrading from 1.10.2, 1.11.2 or an earlier 1.12.2 build, including chunks visited later. +- Keeps the existing buoyancy and void-platform options and their defaults. + +Back up the whole world before upgrading, including its data folder. 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 7293ed4..d193e43 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,25 @@ -# DrCyano's Lootable Bodies +# Lootable Bodies for Minecraft 1.12.2 -When a player dies, a corpse holds their dropped items. Right-click the corpse -to recover them. +Your items stay on a corpse when you die. Right-click the body to recover them. -The maintained versions are: +Use Forge 14.23.5.2859 and Java 8. Install `LootableBodies-2.4.3.112021.jar` +in the client and server mods folders. Back up your world before upgrading. -- [Minecraft 1.10.2](https://github.com/MinecraftModDevelopmentMods/LootableBodies/tree/1.10.2) -- [Minecraft 1.11.2](https://github.com/MinecraftModDevelopmentMods/LootableBodies/tree/1.11.2-dev) +Created by Cyanobacterium (DrCyano), maintained by SkyBlade1978. +Licensed under LGPLv2.1; the original CC0 dedication is included in LICENSE-CC0. -Each version's README covers installation, settings and saved-item recovery. +Open Mods, select Lootable Bodies and click Config to change its settings. +Appearance changes apply straight away on your client. World and server rules +take effect after restarting Minecraft or the server. -This default branch contains the older Minecraft 1.12.2 source. It has not -received the maintained build and inventory updates. It requires Forge -1.12.2-14.23.4.2705 or later. +This version adds upgrade protection for existing 1.10.2, 1.11.2 and 1.12.2 corpses, +including bodies in chunks you visit later. Keep the whole world backup, +including its `data` folder. If preservation cannot finish safely, world loading +stops with an explanation in the log. Items from missing mods are retained for +recovery when those mods return. -Download the file for your Minecraft version from -[Releases](https://github.com/MinecraftModDevelopmentMods/LootableBodies/releases) -and place it in the client and server `mods` folders after installing Forge. -Keep a backup before changing an existing modpack or world. +It cannot recover items an older build already lost. Downgrading an upgraded +world is unsupported. -Created by Cyanobacterium (DrCyano, also known as Synechocystis). -Maintained by [SkyBlade1978](https://github.com/SkyBlade1978) and Minecraft Mod Development. +The existing float-in-fluids and void-platform settings are also available under +Config. Their defaults are unchanged. diff --git a/build.gradle b/build.gradle index 0a83576..ff3a2cd 100644 --- a/build.gradle +++ b/build.gradle @@ -1,121 +1,225 @@ -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.3-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" -sourceCompatibility = targetCompatibility = "1.8" - -minecraft { - version = "1.12.2-14.23.4.2705" - runDir = "run" - mappings = "stable_39" -} +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 +def pinnedJava25 = providers.environmentVariable('LOOTABLEBODIES_MAVENIZER_JAVA_HOME') + .orElse(providers.gradleProperty('lootablebodies.mavenizer.java.home')) + .orElse(providers.provider { localJavaSetup.getProperty('lootablebodies.mavenizer.java.home') }) +def exactJava25Spec = pinnedJava25.isPresent() ? + org.gradle.jvm.toolchain.internal.SpecificInstallationToolchainSpec.fromJavaHome( + project.services.get(org.gradle.api.internal.provider.PropertyFactory), file(pinnedJava25.get())) : null +ext.mavenizerLauncher = exactJava25Spec != null ? javaToolchains.launcherFor(exactJava25Spec) : + javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(25); vendor = JvmVendorSpec.ADOPTIUM } +fgtools.configure('mavenizer') { javaLauncher.set(mavenizerLauncher) } +java { + toolchain { + languageVersion = JavaLanguageVersion.of(8) + vendor = JvmVendorSpec.ADOPTIUM } + withSourcesJar() + withJavadocJar() +} +ext.productionLauncher = exactJava8Spec != null ? javaToolchains.launcherFor(exactJava8Spec) : + javaToolchains.launcherFor(java.toolchain) +fgtools.configure('slimelauncher') { javaLauncher.set(productionLauncher) } +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}") + def worker = mavenizerLauncher.get().metadata + Properties workerRelease = new Properties() + worker.installationPath.file('release').asFile.withInputStream { workerRelease.load(it) } + if (workerRelease.getProperty('JAVA_RUNTIME_VERSION') != '"25.0.3+9-LTS"' || + workerRelease.getProperty('IMPLEMENTOR') != '"Eclipse Adoptium"') + throw new GradleException('The separate Mavenizer worker requires Temurin 25.0.3+9-LTS') + logger.lifecycle("Mavenizer worker: ${worker}; game/compilation remain on Java 8") } } - -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 + filteringCharset = 'UTF-8' +} +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('forge12-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.12.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' } +tasks.named('test') { dependsOn 'jar', 'sourcesJar' } + +apply from: 'gradle/ide.gradle' +apply from: 'gradle/audit.gradle' +apply from: 'gradle/release.gradle' +apply from: 'gradle/runtime.gradle' +apply from: 'gradle/baseline.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/gradle.properties b/gradle.properties index 96e1903..26ce7d3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1,34 @@ -archivesBaseName = DrCyanosLootableBodies_1.12 \ No newline at end of file +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.12.2 +forge_version=14.23.5.2859 +mapping_channel=stable +mapping_version=39-1.12 + +# 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.3.112021 +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..4a3794d --- /dev/null +++ b/gradle/audit.gradle @@ -0,0 +1,110 @@ +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}") + } + 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}") + } + 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/baseline.gradle b/gradle/baseline.gradle new file mode 100644 index 0000000..f738acc --- /dev/null +++ b/gradle/baseline.gradle @@ -0,0 +1,23 @@ +// A separate probe can exercise the historical jar without compiling the port. +def baselineClasses=layout.buildDirectory.dir('baseline-test/classes') +tasks.register('compileOriginal112Checks',JavaCompile) { + source fileTree('src/baselineTest/java'), + file('src/integrationTest/java/cyano/lootable/buildtest/UpgradeChecks.java'), + file('src/integrationTest/java/cyano/lootable/buildtest/TypedNbt.java') + classpath=files(sourceSets.main.output,sourceSets.main.compileClasspath) + destinationDirectory=baselineClasses + dependsOn 'classes' + options.compilerArgs.add('-proc:none') +} +def baselineJar=tasks.register('original112ChecksJar',Jar) { + dependsOn 'compileOriginal112Checks' + from baselineClasses + archiveFileName='lootablebodies-original-112-checks.jar' + destinationDirectory=layout.buildDirectory.dir('baseline-test') +} +def baselineRuntime=renamer.classes(baselineJar) { + map.from minecraft.dependency.toSrgFile + libraries.from(sourceSets.main.output,sourceSets.main.compileClasspath) + output=layout.buildDirectory.file('baseline-test/lootablebodies-original-112-checks-reobf.jar') +} +tasks.register('prepareOriginal112Checks') {dependsOn baselineRuntime} diff --git a/gradle/ide.gradle b/gradle/ide.gradle new file mode 100644 index 0000000..47747c5 --- /dev/null +++ b/gradle/ide.gradle @@ -0,0 +1,111 @@ +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) + toolchains.setProperty('lootablebodies.mavenizer.java.home', + mavenizerLauncher.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.replace('key="MC_VERSION" value="${MC_VERSION}"', 'key="MC_VERSION" value="1.12.2"') + 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 (!xml.contains('key="MC_VERSION" value="1.12.2"')) + throw new GradleException('Eclipse Minecraft version is unresolved') + def launchModel = new groovy.xml.XmlSlurper().parseText(xml) + String directory = launchModel.stringAttribute.find { + it.@key.text() == 'org.eclipse.jdt.launching.WORKING_DIRECTORY' + }.@value.text() + if (file(directory).canonicalFile != file(providers.gradleProperty('gameRunDirectory').getOrElse('run')).canonicalFile) + throw new GradleException("${name} must use the production run directory, found ${directory}") + 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..4d4346d --- /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.12.2', forge_version: '14.23.5.2859', + mapping_channel: 'stable', mapping_version: '39-1.12', + 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.12.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..1e81369 --- /dev/null +++ b/gradle/runtime.gradle @@ -0,0 +1,273 @@ +// 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 legacySeedClasses=layout.buildDirectory.dir('legacy-seed/classes') +tasks.register('compileLegacySeed',JavaCompile) { + source fileTree('src/legacySeed/java'), file('src/integrationTest/java/cyano/lootable/buildtest/FixtureItems.java') + classpath=files(sourceSets.main.output,sourceSets.main.compileClasspath) + destinationDirectory=legacySeedClasses + options.compilerArgs.add('-proc:none') +} +def legacySeedJar=tasks.register('legacySeedJar',Jar) { + dependsOn 'compileLegacySeed' + from legacySeedClasses + archiveFileName='lootablebodies-world-fixtures.jar' + destinationDirectory=layout.buildDirectory.dir('legacy-seed') +} +def legacySeedRuntime=renamer.classes(legacySeedJar) { + map.from minecraft.dependency.toSrgFile + libraries.from(tasks.named('jar'),sourceSets.main.compileClasspath) + output=layout.buildDirectory.file('legacy-seed/lootablebodies-world-fixtures-reobf.jar') +} +tasks.register('prepareLegacyWorldFixtures') {dependsOn legacySeedRuntime} + +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 upgrades = phaseTask('upgrade-contracts','prepareIntegrationTest') +tasks.register('upgradeContractTest') { group='verification'; dependsOn upgrades } +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 wallEscape = phaseTask('wall-escape','prepareIntegrationTest') +def wallEscapeReload = phaseTask('wall-escape-reload',wallEscape) +tasks.register('wallEscapeTest') { group='verification'; dependsOn wallEscapeReload } + +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 = file(providers.gradleProperty('clientIntegrationDirectory').getOrElse('build/client-integration-run')) + if(root.exists()) throw new GradleException('Preserve the previous client evidence before rerunning') + new File(root,'mods').mkdirs() + new File(root,'config').mkdirs() + new File(root,'options.txt').text='realmsNotifications:false\n' + 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') + File previousDir = options.workingDir.get().asFile + File previousRunDir = run.workingDir + def previousProperties = new LinkedHashMap(run.systemProperties) + def previousClasspath = files(run.classpath) + String previousExecutable = run.executable + try { + 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() + } finally { + options.workingDir.set(previousDir) + run.workingDir(previousRunDir) + run.setSystemProperties(previousProperties) + run.setClasspath(previousClasspath) + run.executable = previousExecutable + } + 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') + def options = minecraft.runs.getByName('server') + File previousDir = options.workingDir.get().asFile + File previousRunDir = run.workingDir + def previousProperties = new LinkedHashMap(run.systemProperties) + def previousClasspath = files(run.classpath) + try { + options.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() + } finally { + options.workingDir.set(previousDir) + run.workingDir(previousRunDir) + run.setSystemProperties(previousProperties) + run.setClasspath(previousClasspath) + } + 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') + } +} + +// The intermediate writer runs with the checksum-locked released 1.11 jar. +def transitionClasses=layout.buildDirectory.dir('transition-fixture/classes') +tasks.register('compileTransitionFixture',JavaCompile) { + source fileTree('src/transitionFixture/java'),file('src/integrationTest/java/cyano/lootable/buildtest/FixtureItems.java') + classpath=files(sourceSets.main.output,sourceSets.main.compileClasspath) + destinationDirectory=transitionClasses + options.compilerArgs.add('-proc:none') +} +def transitionJar=tasks.register('transitionFixtureJar',Jar) { + dependsOn 'compileTransitionFixture' + from transitionClasses + archiveFileName='lootablebodies-111-transition.jar' + destinationDirectory=layout.buildDirectory.dir('transition-fixture') +} +def transitionRuntime=renamer.classes(transitionJar) { + map.from minecraft.dependency.toSrgFile + libraries.from(tasks.named('jar'),sourceSets.main.compileClasspath) + output=layout.buildDirectory.file('transition-fixture/lootablebodies-111-transition-reobf.jar') +} +tasks.named('prepareLegacyWorldFixtures') {dependsOn transitionRuntime} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 0d4a951..b1b8ef5 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 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 old mode 100644 new mode 100755 index cccdd3d..249efbb --- 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 index 0e64f4a..d6dc9f9 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,2 +1,4 @@ -rootProject.name = archivesBaseName -enableFeaturePreview('STABLE_PUBLISHING') +plugins { + id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0' +} +rootProject.name = 'LootableBodies' diff --git a/src/baselineTest/java/cyano/lootable/buildtest/NameLifetimeChecks.java b/src/baselineTest/java/cyano/lootable/buildtest/NameLifetimeChecks.java new file mode 100644 index 0000000..aa4e81e --- /dev/null +++ b/src/baselineTest/java/cyano/lootable/buildtest/NameLifetimeChecks.java @@ -0,0 +1,3 @@ +package cyano.lootable.buildtest; +import net.minecraft.nbt.NBTBase; +final class NameLifetimeChecks { static boolean same(NBTBase a,NBTBase b){return TypedNbt.canonical(a).equals(TypedNbt.canonical(b));}} diff --git a/src/baselineTest/java/cyano/lootable/buildtest/Original112Checks.java b/src/baselineTest/java/cyano/lootable/buildtest/Original112Checks.java new file mode 100644 index 0000000..7f9874a --- /dev/null +++ b/src/baselineTest/java/cyano/lootable/buildtest/Original112Checks.java @@ -0,0 +1,42 @@ +package cyano.lootable.buildtest; + +import cyano.lootable.LootableBodies; +import cyano.lootable.entities.EntityLootableBody; +import java.io.*; +import java.util.*; +import net.minecraft.init.Items; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.*; +import net.minecraft.world.WorldServer; +import net.minecraftforge.fml.common.*; +import net.minecraftforge.fml.common.event.*; + +/** Independent assertions run against the checksum-locked original 1.12 jar. */ +@Mod(modid="lootablebodiesbuildtest",name="Lootable Bodies Original Checks",version="1",dependencies="required-after:lootablebodies") +public final class Original112Checks { + @Mod.EventHandler public void preInit(FMLPreInitializationEvent event){UpgradeChecks.registerOwnFixer();} + @Mod.EventHandler public void started(FMLServerStartedEvent event) { + net.minecraft.server.MinecraftServer server=FMLCommonHandler.instance().getMinecraftServerInstance(); + int failed=0; + try { + try {UpgradeChecks.run(server);} catch(Throwable expected){failed++;expected.printStackTrace();} + WorldServer world=server.getWorld(0); + LootableBodies.allowCorpseDecay=false;LootableBodies.ticksPerItemDecay=-1; + EntityLootableBody body=new EntityLootableBody(world);body.setPosition(.5,80,.5);body.setNoGravity(true); + body.setInventorySlotContents(6,new ItemStack(Items.DIAMOND,44)); + NBTTagCompound saved=new NBTTagCompound();body.writeToNBT(saved);saved.setByte("Vac",(byte)20); + NBTTagList overflow=new NBTTagList();overflow.appendTag(new ItemStack(Items.PAPER,17).writeToNBT(new NBTTagCompound()));saved.setTag("Aux",overflow);body.readFromNBT(saved); + try { + body.onEntityUpdate();NBTTagCompound after=new NBTTagCompound();body.writeToNBT(after); + RuntimeChecks.require(body.getStackInSlot(6).getItem()==Items.DIAMOND && body.getStackInSlot(6).getCount()==44,"Aux refill overwrote an occupied slot"); + int paper=0;for(int slot=6;slot<48;slot++)if(body.getStackInSlot(slot).getItem()==Items.PAPER)paper+=body.getStackInSlot(slot).getCount(); + for(int i=0;i 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(); + private final NamelessCorpseClientChecks namelessNames = new NamelessCorpseClientChecks(); + @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.gameDir,"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.gameDir,"abort").exists(),"Disposable graphical check aborted"); + if(Boolean.getBoolean("lootablebodies.configUiCheck")) {configScreen.tick(mc);return;} + if(Boolean.getBoolean("lootablebodies.namelessClientCheck")) {namelessNames.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(); + NamelessCorpseClientChecks.englishContract(mc); + for(Entity entity : mc.world.loadedEntityList) if(entity instanceof EntityLootableBody && entity.getUniqueID().equals(RuntimeChecks.NEAR)) { + mc.playerController.interactWithEntity(mc.player,entity,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.gameDir,"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,EnumHand.MAIN_HAND); + stage=7; frames=0; break; + } + } else if(stage==7 && mc.currentScreen instanceof CorpseGUIContainer && ++frames>20) { + ScreenShotHelper.saveScreenshot(mc.gameDir,"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.gameDir,"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..1d51b2c --- /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.gameDir, "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().getWorld(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().getWorld(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.gameDir, "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().getWorld(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().getWorld(0); world.getChunk(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.gameDir, 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.gameDir, 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..75be221 --- /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.gameDir,"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(RuntimeChecks.sameStack(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.get(9).isEmpty(),"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(RuntimeChecks.sameStack(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()).isEmpty(),"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()).isEmpty(),"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.set(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(RuntimeChecks.sameStack(corpse.getStackInSlot(slot),RuntimeChecks.stack(slot)),"Connected death equipment slot "+slot); + for(int slot=6;slot<=8;slot++)RuntimeChecks.require(RuntimeChecks.sameStack(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)).isEmpty(),"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().getWorld(0).getChunk(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,EnumHand.MAIN_HAND);return true; + } + return false; + } + private static void screenshot(Minecraft mc,String name){ScreenShotHelper.saveScreenshot(mc.gameDir,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..39cd65f --- /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.set(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).getItem(),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.set(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.set(9,item(120,10));p.inventory.setItemStack(mode==3?ItemStack.EMPTY: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()).isEmpty()?0:p.inventory.getItemStack().getCount())==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.get(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.set(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.get(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.getChunk(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.getChunk(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.setCount(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.set(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 == null ? ItemStack.EMPTY : 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.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(RuntimeChecks.sameStack(actual == null ? ItemStack.EMPTY : actual,expected == null ? ItemStack.EMPTY : 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.isEmpty()) || s.getCount()<=0)return;NBTTagCompound n=s.writeToNBT(new NBTTagCompound());n.removeTag("Count"); + String key=n.toString();all.put(key,all.getOrDefault(key,0)+s.getCount()); + } + 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.set(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.get(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.get(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.set(key,ItemStack.EMPTY); + } + } + private static void armorSwap() throws Exception { + EntityLootableBody b=body();EntityPlayerMP p=player();open(p,b);p.inventory.mainInventory.set(0,item(4,1)); + click(p,0,0,ClickType.SWAP);equal(b.getStackInSlot(0),null,"Sword inserted into armor slot"); + p.inventory.mainInventory.set(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.get(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.set(9,pumpkins(64));open(p,b); + Map before=total(b,p);click(p,48,0,ClickType.QUICK_MOVE); + RuntimeChecks.require(!(b.getStackInSlot(0)).isEmpty() && b.getStackInSlot(0).getCount()==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).getCount()<=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.set(35,item(19,63));open(p,b); + Map before=total(b,p);click(p,6,0,ClickType.QUICK_MOVE);equal(p.inventory.mainInventory.get(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.set(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.set(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.get(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.set(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..4b900a6 --- /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.set(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.get(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.set(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.getChunk(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).getItem(),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,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().getWorld(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.gameDir,name,mc.displayWidth,mc.displayHeight,mc.getFramebuffer());} + private void mark(Minecraft mc,String name) throws Exception {try(FileWriter out=new FileWriter(new File(mc.gameDir,"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..52748c6 --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/EffectHurtChecks.java @@ -0,0 +1,104 @@ +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){}; + // Forge 1.12 assigns IDs through registry events. Fill the isolated test + // registry through its public API so unsigned-byte coverage still uses 200. + for(int i=0;Potion.getPotionById(199)==null && i<200;i++) + net.minecraftforge.fml.common.registry.ForgeRegistries.POTIONS.register(new Potion(false,0){}.setRegistryName("lootablebodiesbuildtest:padding_"+i)); + net.minecraftforge.fml.common.registry.ForgeRegistries.POTIONS.register(extended.setRegistryName("lootablebodiesbuildtest:extended_effect")); + RuntimeChecks.require(Potion.getIdFromPotion(extended)==200,"Fixture potion must exercise unsigned byte ID 200"); + } + 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_native_111_clamping",EffectHurtChecks::signedAmplifiers); + check("forge_curative_items_keep_complete_item_data",EffectHurtChecks::curativeItems); + check("malformed_curative_items_retained_with_safe_defaults",EffectHurtChecks::badCurativeItems); + 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(RuntimeChecks.sameStack(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()==Math.max(0,a),"Native 1.11 byte amplifier clamping changed");require(!saved(b).hasKey(NameLifetimeChecks.RECOVERY),"Canonical signed byte quarantined");stable(b);}} + private static void curativeItems(){NBTTagCompound e=effect();NBTTagCompound item=NbtRecoveryChecks.item(120,7).writeToNBT(new NBTTagCompound());NBTTagList list=new NBTTagList();list.appendTag(item);e.setTag("CurativeItems",list);EntityLootableBody b=read(with(e));require(b.getActivePotionEffect(MobEffects.NIGHT_VISION).getCurativeItems().size()==1 && RuntimeChecks.sameStack(b.getActivePotionEffect(MobEffects.NIGHT_VISION).getCurativeItems().get(0),new ItemStack(item)),"Forge curative item NBT changed");require(!saved(b).hasKey(NameLifetimeChecks.RECOVERY),"Healthy Forge curative items quarantined");stable(b);} + private static void badCurativeItems(){NBTTagCompound e=effect();e.setString("CurativeItems","opaque");EntityLootableBody b=read(with(e));NameLifetimeChecks.retained(b,"ActiveEffect",e);require(!b.getActivePotionEffect(MobEffects.NIGHT_VISION).getCurativeItems().isEmpty(),"Malformed curative list did not use native defaults");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 event) {event.getRegistry().register(keepsake);} + } + private static final class Stored implements ICapabilitySerializable { + private NBTTagCompound data=new NBTTagCompound(); + Stored(){data.setString("owner","Disposable fixture");data.setLong("number",9876543210123L);data.setByteArray("opaque",new byte[]{0,1,-1,17});} + public boolean hasCapability(Capability capability,EnumFacing side){return false;} + public T getCapability(Capability capability,EnumFacing side){return null;} + public NBTTagCompound serializeNBT(){return data.copy();} + public void deserializeNBT(NBTTagCompound nbt){data=nbt.copy();} + } +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/HealthAttributeChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/HealthAttributeChecks.java new file mode 100644 index 0000000..c93c4df --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/HealthAttributeChecks.java @@ -0,0 +1,130 @@ +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.SharedMonsterAttributes; +import net.minecraft.entity.ai.attributes.*; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.*; +import net.minecraft.server.MinecraftServer; +import net.minecraft.world.WorldServer; + +/** Load real entities, compare complete item data and exercise the legacy attribute engine. */ +final class HealthAttributeChecks { + private interface Check {void run() throws Exception;} + private static WorldServer world; + private static final List 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(RuntimeChecks.sameStack(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.getChunk(4,4); + return player; + } + private static EntityLootableBody body() { + EntityLootableBody body=new EntityLootableBody(world); + body.setPosition(64.5,80,64.5); body.setNoGravity(true); + world.getChunk(4,4); RuntimeChecks.disableVacuum(body); + return body; + } + private static ItemStack item(int id,int count) { + ItemStack stack=RuntimeChecks.stack(id); stack.setCount(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.OUT_OF_WORLD,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.isEmpty()) || stack.getCount()<=0) return; + NBTTagCompound tag=encoded(stack); tag.removeTag("Count"); + String key=tag.toString(); result.put(key,result.getOrDefault(key,0)+stack.getCount()); + } + private static Map dropped() { + Map result=new TreeMap<>(); + for(Entity entity:world.loadedEntityList) if(entity instanceof EntityItem && !entity.isDead) add(result,((EntityItem)entity).getItem()); + 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.getItem())); + PlayerDeathEventHandler handler=new PlayerDeathEventHandler(); + handler.entityDeathEvent(new LivingDeathEvent(player,DamageSource.OUT_OF_WORLD)); + 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.get(0));add(expected,player.inventory.mainInventory.get(9)); + Set before=new HashSet<>(world.loadedEntityList); + RejectCorpse reject=new RejectCorpse(); MinecraftForge.EVENT_BUS.register(reject); + try { player.onDeath(DamageSource.OUT_OF_WORLD); } 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.OUT_OF_WORLD)); + handle(handler,player,drops(player,previous)); + ItemStack current=item(1,1); player.setItemStackToSlot(EntityEquipmentSlot.HEAD,current); + handler.entityDeathEvent(new LivingDeathEvent(player,DamageSource.OUT_OF_WORLD)); + 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.OUT_OF_WORLD)); + 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.OUT_OF_WORLD)); + RuntimeChecks.require(!cache(handler).containsKey(player),"keepInventory death cached equipment"); + world.getGameRules().setOrCreateGameRule("keepInventory","false"); + LivingDeathEvent canceled=new LivingDeathEvent(player,DamageSource.OUT_OF_WORLD); canceled.setCanceled(true); + handler.entityDeathEvent(canceled); + RuntimeChecks.require(!cache(handler).containsKey(player),"Cancelled death cached equipment"); + handler.entityDeathEvent(new LivingDeathEvent(player,DamageSource.OUT_OF_WORLD)); + List original=drops(player,item(19,23)); + LivingDropsEvent canceledDrops=new LivingDropsEvent(player,DamageSource.OUT_OF_WORLD,original,0,false); + canceledDrops.setCanceled(true);handler.entityDropEvent(canceledDrops); + RuntimeChecks.require(!cache(handler).containsKey(player) && original.size()==1 + && RuntimeChecks.sameStack(original.get(0).getItem(),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()).getItem(); + 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)).isEmpty() && RuntimeChecks.sameStack(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.getItem().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)).isEmpty(),"Empty removal must return EMPTY"); + corpse.setInventorySlotContents(6,item(19,23)); NBTTagCompound expected=encoded(corpse.getStackInSlot(6)); + RuntimeChecks.require((corpse.decrStackSize(6,-1)).isEmpty(),"Negative removal created a stack"); + RuntimeChecks.require(expected.equals(encoded(corpse.getStackInSlot(6))),"Negative removal changed stack count"); + RuntimeChecks.require((corpse.decrStackSize(6,0)).isEmpty(),"Zero removal created a stack"); + ItemStack removed=corpse.decrStackSize(6,40);RuntimeChecks.require(RuntimeChecks.sameStack(removed,item(19,23)) && (corpse.getStackInSlot(6)).isEmpty(),"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)).isEmpty() && RuntimeChecks.sameStack(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(RuntimeChecks.sameStack(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)).isEmpty() || large.getStackInSlot(i).getCount()<=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).getCount()==64,"Overflow transfer lost items or exceeded slot capacity"); + queued.removeStackFromSlot(46);queued.onEntityUpdate(); + RuntimeChecks.require(RuntimeChecks.sameStack(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(RuntimeChecks.sameStack(ca.transferStackInSlot(a,6),item(19,23)),"First recovery failed"); + RuntimeChecks.require((cb.transferStackInSlot(b,6)).isEmpty(),"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.getChunk(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.getChunk(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..57b16a7 --- /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.getWorld(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)).isEmpty() && 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.set(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.getWorld(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.setCount(n);return s;} + public static void equal(ItemStack a,ItemStack b,String reason){RuntimeChecks.require(RuntimeChecks.sameStack(a == null ? ItemStack.EMPTY : a,b == null ? ItemStack.EMPTY : 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().player; + 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..fa4d264 --- /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.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(FixtureConnections.retry(mc,role))connecting=false; + if(!connecting && FixtureConnections.ready(role) && mc.currentScreen instanceof GuiMainMenu){ + connecting=true;mc.gameSettings.pauseOnLostFocus=false;mc.gameSettings.limitFramerate=30;mc.gameSettings.renderDistanceChunks=4; + FixtureConnections.connect(mc); + } + 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.gameDir,"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,EnumHand.MAIN_HAND); + return; + } + if(completed.contains(phase) || frames<15)return;int window=mc.player.openContainer.windowId; + if(phase==1){ + if(!RuntimeChecks.sameStack(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(!RuntimeChecks.sameStack(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(!RuntimeChecks.sameStack(mc.player.inventory.mainInventory.get(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.gameDir,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..278854c --- /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.isEmpty()) && s.getCount()==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/NamelessCorpseClientChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/NamelessCorpseClientChecks.java new file mode 100644 index 0000000..b0f7674 --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/NamelessCorpseClientChecks.java @@ -0,0 +1,155 @@ +package cyano.lootable.buildtest; + +import cyano.lootable.LootableBodies; +import cyano.lootable.entities.EntityLootableBody; +import cyano.lootable.graphics.CorpseGUIContainer; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.concurrent.Future; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiMainMenu; +import net.minecraft.client.resources.Language; +import net.minecraft.entity.Entity; +import net.minecraft.entity.EntityList; +import net.minecraft.init.Blocks; +import net.minecraft.init.Items; +import net.minecraft.item.ItemMonsterPlacer; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.util.EnumHand; +import net.minecraft.util.ResourceLocation; +import net.minecraft.util.ScreenShotHelper; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.*; +import net.minecraftforge.client.event.RenderLivingEvent; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; + +/** Exercise the actual client locale, entity label, spawn egg and saved name. */ +final class NamelessCorpseClientChecks { + private static final String[] LOCALES = {"en_us","de_de","en_au","en_ca","en_en","en_gb","en_pt", + "es_es","es_mx","fr_ca","fr_fr","it_it","nl_nl","zh_cn"}; + private static final UUID BODY = UUID.fromString("00000000-0000-4000-8000-000000000119"); + private int stage, frames, locale; + private Future pending; + private Language originalLanguage; + private boolean originalUnicode, originalBidi; + private boolean renderedBody; + + @SubscribeEvent public void rendered(RenderLivingEvent.Post event) { + if(event.getEntity().getUniqueID().equals(BODY)) renderedBody=true; + } + + static void englishContract(Minecraft mc) throws Exception { + assertLocale(mc, "en_us"); + } + + private static String translation(Minecraft mc, String code) throws Exception { + Properties values = new Properties(); + try (Reader in = new InputStreamReader(mc.getResourceManager().getResource( + new ResourceLocation("lootablebodies", "lang/"+code+".lang")).getInputStream(), StandardCharsets.UTF_8)) { + values.load(in); + } + String expected = values.getProperty("entity.lootablebodies.Corpse.name"); + RuntimeChecks.require(expected != null && !expected.isEmpty(), "Missing original locale "+code); + return expected; + } + + private static void assertLocale(Minecraft mc, String code) throws Exception { + String expected = translation(mc, code); + EntityLootableBody body = new EntityLootableBody(mc.world); + body.setPosition(18.5,80,18.5); + RuntimeChecks.disableVacuum(body); + for (int slot=0;slot<48;slot++) body.setInventorySlotContents(slot,RuntimeChecks.stack(slot)); + body.addItem(RuntimeChecks.stack(70)); + NBTTagCompound data = NameLifetimeChecks.saved(body); + NBTTagCompound retained = new NBTTagCompound(); + retained.setString("Source","FutureFixture"); retained.setString("Data","retain verbatim"); + data.setTag("LootableBodiesUnresolved",NbtRecoveryChecks.list(retained)); + body = (EntityLootableBody)EntityList.createEntityFromNBT(data,mc.world); + RuntimeChecks.require(body != null && "Corpse".equals(EntityList.getEntityString(body)) && + new ResourceLocation("lootablebodies:corpse").equals(EntityList.getKey(body)), "Registry/display identity changed"); + NBTTagCompound before = NameLifetimeChecks.saved(body); + RuntimeChecks.require(expected.equals(body.getName()), "Untranslated nameless corpse in "+code+": "+body.getName()); + RuntimeChecks.require(expected.equals(body.getDisplayName().getUnformattedText()), "Wrong rendered name in "+code); + RuntimeChecks.require(!body.hasCustomName() && !before.hasKey("Name") && !before.hasKey("CustomName"), "Localized fallback became an owner name"); + RuntimeChecks.require(NameLifetimeChecks.same(before,NameLifetimeChecks.saved(body)), "Reading fallback mutated complete NBT"); + EntityLootableBody loaded = (EntityLootableBody)EntityList.createEntityFromNBT(before.copy(),mc.world); + RuntimeChecks.require(expected.equals(loaded.getName()) && NameLifetimeChecks.same(before,NameLifetimeChecks.saved(loaded)), + "Fallback reload changed UUID, inventory, recovery or other NBT"); + for (String owner : new String[]{"Owner Fixture", "entity.Corpse.name", "Owner \u00e9\u540d"}) { + body.setUserName(owner); NBTTagCompound named = NameLifetimeChecks.saved(body); + RuntimeChecks.require(owner.equals(body.getName()) && owner.equals(body.getDisplayName().getUnformattedText()), "Owner name was translated/renamed"); + RuntimeChecks.require(NameLifetimeChecks.same(named,NameLifetimeChecks.saved(body)), "Owner lookup mutated NBT"); + } + body.setUserName(""); NBTTagCompound cleared = NameLifetimeChecks.saved(body); + RuntimeChecks.require(expected.equals(body.getName()) && !body.hasCustomName() && + NameLifetimeChecks.same(cleared,NameLifetimeChecks.saved(body)), "Cleared owner did not regain transient fallback"); + ItemStack egg = new ItemStack(Items.SPAWN_EGG); + ItemMonsterPlacer.applyEntityIdToItemStack(egg,new ResourceLocation("lootablebodies:corpse")); + NBTTagCompound eggBefore = egg.writeToNBT(new NBTTagCompound()); + RuntimeChecks.require(egg.getDisplayName().endsWith(" "+expected) && !egg.getDisplayName().contains("entity.Corpse.name"), "Untranslated spawn egg in "+code); + RuntimeChecks.require(NameLifetimeChecks.same(eggBefore,egg.writeToNBT(new NBTTagCompound())), "Egg name lookup mutated its native ID/NBT"); + System.out.println("NAME_TRANSLATION_PASS "+code); + } + + 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; + originalLanguage=mc.getLanguageManager().getCurrentLanguage(); + originalUnicode=mc.fontRenderer.getUnicodeFlag(); originalBidi=mc.fontRenderer.getBidiFlag(); + MinecraftForge.EVENT_BUS.register(this); + mc.launchIntegratedServer("name-fixture","Name fixture",new WorldSettings(4267321,GameType.CREATIVE,false,false,WorldType.FLAT));stage=1; + } else if(stage==1 && mc.world!=null && mc.player!=null && mc.getIntegratedServer()!=null) { + pending=mc.getIntegratedServer().addScheduledTask(() -> { + WorldServer world=mc.getIntegratedServer().getWorld(0); + world.getGameRules().setOrCreateGameRule("doMobSpawning","false"); + for(int x=12;x<=24;x++)for(int z=12;z<=24;z++)world.setBlockState(new BlockPos(x,79,z),Blocks.STONE.getDefaultState()); + EntityLootableBody body=new EntityLootableBody(world);body.setUniqueId(BODY);body.setPosition(18.5,80,18.5);body.setNoGravity(true);RuntimeChecks.disableVacuum(body); + for(int i=0;i<48;i++)body.setInventorySlotContents(i,RuntimeChecks.stack(i)); + RuntimeChecks.require(world.spawnEntity(body),"Spawn nameless graphical fixture"); + EntityLootableBody named=new EntityLootableBody(world);named.setPosition(20.5,80,18.5);named.setNoGravity(true);named.setUserName("Owner Fixture");RuntimeChecks.disableVacuum(named);world.spawnEntity(named); + net.minecraft.entity.player.EntityPlayerMP player=mc.getIntegratedServer().getPlayerList().getPlayerByUUID(mc.player.getUniqueID()); + player.capabilities.isFlying=true;player.sendPlayerAbilities();player.connection.setPlayerLocation(18.5,80,14.5,0,12); + });stage=2;frames=0; + } else if(stage==2 && pending.isDone() && ++frames>30) { + pending.get();stage=3;frames=0; + } else if(stage==3) { + // Keep the disposable view attached to the current network player. + // The integrated-server join may replace its initial camera entity. + mc.setRenderViewEntity(mc.player);mc.gameSettings.thirdPersonView=0; + mc.player.rotationYaw=mc.player.prevRotationYaw=0; + mc.player.rotationPitch=mc.player.prevRotationPitch=20; + String code=LOCALES[locale]; + if(frames++==0) { + renderedBody=false; + mc.getLanguageManager().setCurrentLanguage(new Language(code,code,code,false)); + mc.getLanguageManager().onResourceManagerReload(mc.getResourceManager()); + mc.fontRenderer.setUnicodeFlag(mc.getLanguageManager().isCurrentLocaleUnicode()); + mc.fontRenderer.setBidiFlag(mc.getLanguageManager().isCurrentLanguageBidirectional()); + assertLocale(mc,code); + try(PrintWriter out=new PrintWriter(new OutputStreamWriter(new FileOutputStream(new File(mc.gameDir,"name-checks.txt"),true),StandardCharsets.UTF_8))) {out.println("PASS locale_"+code+"_fallback_owner_egg_complete_nbt");} + } + if(frames==15 && Arrays.asList("en_us","de_de","en_pt","es_es","zh_cn").contains(code)) { + System.out.println("NAME_CAMERA player="+mc.player.getPosition()+" view="+mc.getRenderViewEntity().getPosition()+" yaw="+mc.player.rotationYaw+" pitch="+mc.player.rotationPitch+" rendered="+renderedBody); + RuntimeChecks.require(renderedBody,"No corpse rendered for label screenshot "+code); + ScreenShotHelper.saveScreenshot(mc.gameDir,"name-"+code+".png",mc.displayWidth,mc.displayHeight,mc.getFramebuffer()); + } + if(frames>20) {frames=0;locale++;if(locale==LOCALES.length) { + mc.getLanguageManager().setCurrentLanguage(originalLanguage);mc.getLanguageManager().onResourceManagerReload(mc.getResourceManager()); + mc.fontRenderer.setUnicodeFlag(originalUnicode);mc.fontRenderer.setBidiFlag(originalBidi);stage=4; + }} + } else if(stage==4) { + for(Entity entity:mc.world.loadedEntityList)if(entity.getUniqueID().equals(BODY)) { + RuntimeChecks.require("Corpse".equals(entity.getName()) && !entity.hasCustomName(),"Network corpse has wrong English fallback/custom name"); + mc.playerController.interactWithEntity(mc.player,entity,EnumHand.MAIN_HAND);stage=5;frames=0;break; + } + } else if(stage==5 && mc.currentScreen instanceof CorpseGUIContainer && ++frames>20) { + for(int slot=0;slot<48;slot++)RuntimeChecks.require(RuntimeChecks.sameStack(mc.player.openContainer.getSlot(slot).getStack(),RuntimeChecks.stack(slot)),"Name fix changed network inventory slot "+slot); + ScreenShotHelper.saveScreenshot(mc.gameDir,"name-english-inventory.png",mc.displayWidth,mc.displayHeight,mc.getFramebuffer()); + new File(mc.gameDir,"client.pass").createNewFile();System.out.println("LOOTABLE_BODIES_NAME_TRANSLATION_PASS locales="+LOCALES.length); + mc.player.closeScreen();mc.getIntegratedServer().initiateShutdown();stage=6; + } else if(stage==6 && mc.world==null && !mc.isIntegratedServerRunning()) {mc.shutdown();stage=7;} + } +} 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..c403581 --- /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(ItemStack.EMPTY);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.isEmpty()) || s.getCount()<=0)return;NBTTagCompound n=s.writeToNBT(new NBTTagCompound());n.removeTag("Count");String key=n.toString();map.put(key,map.getOrDefault(key,0)+s.getCount());} + private static void equal(ItemStack a,ItemStack b,String why){RuntimeChecks.require(RuntimeChecks.sameStack(a == null ? ItemStack.EMPTY : a,b == null ? ItemStack.EMPTY : 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().getWorld(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,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.gameDir,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.gameDir,"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..e285ba7 --- /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.setCount(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(RuntimeChecks.sameStack(a == null ? ItemStack.EMPTY : a,b == null ? ItemStack.EMPTY : b),why);} + private static void add(Map sums,ItemStack s){if((s == null || s.isEmpty()) || s.getCount()<=0)return;NBTTagCompound n=s.writeToNBT(new NBTTagCompound());n.removeTag("Count");String k=n.toString();sums.put(k,sums.getOrDefault(k,0)+s.getCount());} + 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.getCount());} + 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=new ItemStack(raw);RuntimeChecks.require((s != null && !s.isEmpty()) && 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/PortBaselineChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/PortBaselineChecks.java new file mode 100644 index 0000000..23ff857 --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/PortBaselineChecks.java @@ -0,0 +1,82 @@ +package cyano.lootable.buildtest; + +import cyano.lootable.entities.EntityLootableBody; +import cyano.lootable.events.PlayerDeathEventHandler; +import java.util.*; +import net.minecraft.entity.item.EntityItem; +import net.minecraft.entity.player.EntityPlayerMP; +import net.minecraft.init.Items; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.*; +import net.minecraft.server.MinecraftServer; +import net.minecraft.util.DamageSource; +import net.minecraft.world.WorldServer; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.common.util.FakePlayerFactory; +import com.mojang.authlib.GameProfile; +import net.minecraftforge.event.entity.EntityJoinWorldEvent; +import net.minecraftforge.event.entity.living.LivingDropsEvent; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; + +/** Small behavioral red/green gates, runnable against the unmodified 1.11 source. */ +public final class PortBaselineChecks { + private interface Check { void run() throws Exception; } + private static int failures; + private static void check(String name, Check action) { + try { action.run(); System.out.println("PORT_BASELINE_PASS " + name); } + catch (Throwable error) { failures++; System.out.println("PORT_BASELINE_FAIL " + name + " " + error); } + } + private static void require(boolean ok, String message) { if (!ok) throw new AssertionError(message); } + public static void run(MinecraftServer server) throws Exception { + failures=0; + WorldServer world=server.getWorld(0); + check("rejected_spawn_keeps_death_inventory", () -> { + EntityPlayerMP player=FakePlayerFactory.get(world,new GameProfile(UUID.randomUUID(),"PortFixture")); + ArrayList drops=new ArrayList<>(); + drops.add(new EntityItem(world,8,80,8,new ItemStack(Items.DIAMOND,7))); + Object veto=new Object() { @SubscribeEvent public void join(EntityJoinWorldEvent e) { + if(e.getEntity() instanceof EntityLootableBody) e.setCanceled(true); + }}; + MinecraftForge.EVENT_BUS.register(veto); + try { new PlayerDeathEventHandler().entityDropEvent(new LivingDropsEvent(player,DamageSource.GENERIC,drops,0,false)); } + finally { MinecraftForge.EVENT_BUS.unregister(veto); } + require(drops.size()==1 && drops.get(0).getItem().getCount()==7,"original drops were discarded"); + }); + check("duplicate_slots_preserve_both_records", () -> { + EntityLootableBody body=new EntityLootableBody(world); + NBTTagCompound nbt=new NBTTagCompound(); body.writeEntityToNBT(nbt); + NBTTagList equipment=new NBTTagList(); + for(int i=0;i<2;i++) { NBTTagCompound item=new ItemStack(Items.DIAMOND,i+2).writeToNBT(new NBTTagCompound()); item.setByte("Slot",(byte)10); equipment.appendTag(item); } + nbt.setTag("Equipment",equipment); body.readEntityFromNBT(nbt); + NBTTagCompound saved=new NBTTagCompound();body.writeEntityToNBT(saved); + int count=0; for(String key:Arrays.asList("Equipment","Aux")) { + NBTTagList list=saved.getTagList(key,10); + for(int i=0;i { + EntityLootableBody body=new EntityLootableBody(world); NBTTagCompound nbt=new NBTTagCompound();body.writeEntityToNBT(nbt); + NBTTagCompound item=new ItemStack(Items.DIAMOND,7).writeToNBT(new NBTTagCompound());item.setString("id","missingfixture:keepsake");item.setByte("Slot",(byte)10); + NBTTagList list=new NBTTagList();list.appendTag(item);nbt.setTag("Equipment",list);body.readEntityFromNBT(nbt); + NBTTagCompound saved=new NBTTagCompound();body.writeEntityToNBT(saved); + require(saved.getTagList("LootableBodiesUnresolved",10).tagCount()==1,"missing item was erased"); + require(!body.isEmpty(),"recoverable item allowed empty-body decay"); + }); + check("invalid_slot_retains_inventory", () -> { + EntityLootableBody body=new EntityLootableBody(world); NBTTagCompound nbt=new NBTTagCompound();body.writeEntityToNBT(nbt); + NBTTagCompound item=new ItemStack(Items.EMERALD,9).writeToNBT(new NBTTagCompound());item.setInteger("Slot",12345); + NBTTagList list=new NBTTagList();list.appendTag(item);nbt.setTag("Equipment",list);body.readEntityFromNBT(nbt); + NBTTagCompound saved=new NBTTagCompound();body.writeEntityToNBT(saved); + require(!body.isEmpty(),"invalid slot discarded inventory"); + }); + check("malformed_name_and_timer_are_retained", () -> { + EntityLootableBody body=new EntityLootableBody(world); NBTTagCompound nbt=new NBTTagCompound();body.writeEntityToNBT(nbt); + nbt.setTag("Name",new NBTTagInt(17));nbt.setDouble("DeathTime",Double.NaN);body.readEntityFromNBT(nbt); + NBTTagCompound saved=new NBTTagCompound();body.writeEntityToNBT(saved); + require(saved.getTagList("LootableBodiesInvalidFields",10).tagCount()==2,"malformed fields lost without recovery"); + }); + System.out.println("PORT_BASELINE_RESULT failures="+failures); + require(failures==0,"Port baseline regressions: "+failures); + } +} 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>4,coordinate>>4);BlockPos center=new BlockPos(coordinate,0,coordinate); + for(int x=-1;x<=1;x++)for(int z=-1;z<=1;z++)world.setBlockToAir(center.add(x,0,z)); + world.setBlockState(center,Blocks.STONE.getDefaultState()); + EntityLootableBody body=body(world,coordinate,-1); + LootableBodies.voidPlatform=false;body.onEntityUpdate(); + RuntimeChecks.require(body.posY==-1 && world.isAirBlock(center.add(1,0,0)),"Disabled void platform changed behaviour"); + LootableBodies.voidPlatform=true;LootableBodies.dimensionSpecificPlatform=dimensionBlocks;body.onEntityUpdate(); + Block expected=!dimensionBlocks || dimension==0?Blocks.COBBLESTONE:dimension==-1?Blocks.NETHERRACK:Blocks.END_STONE; + RuntimeChecks.require(body.posY==1.5 && body.isEntityAlive(),"Enabled void platform did not rescue corpse"); + for(int x=-1;x<=1;x++)for(int z=-1;z<=1;z++) + RuntimeChecks.require(world.getBlockState(center.add(x,0,z)).getBlock()==(x==0 && z==0?Blocks.STONE:expected),"Void platform changed existing terrain or dimension block"); + conserved(body);checks++; + } + WorldServer world=server.getWorld(0);world.getChunk(30,30); + for(int x=487;x<=489;x++)for(int z=487;z<=489;z++)for(int y=79;y<=82;y++)world.setBlockState(new BlockPos(x,y,z),Blocks.WATER.getDefaultState()); + EntityLootableBody body=body(world,488,80); + RuntimeChecks.require(body.handleWaterMovement(),"Water fixture was not submerged"); + LootableBodies.corpseBuoyancy=false;body.motionY=0;body.onEntityUpdate(); + RuntimeChecks.require(body.motionY==0,"Disabled buoyancy accelerated the corpse"); + LootableBodies.corpseBuoyancy=true;body.motionY=0;body.onEntityUpdate(); + RuntimeChecks.require(body.motionY>0,"Enabled buoyancy did not lift the corpse");conserved(body);checks++; + System.out.println("LOOTABLE_BODIES_112_RECOVERY_PASS checks="+checks); + } finally {LootableBodies.corpseBuoyancy=buoyancy;LootableBodies.voidPlatform=platform;LootableBodies.dimensionSpecificPlatform=specific;} + } + private static EntityLootableBody body(WorldServer world,int coordinate,double y) { + EntityLootableBody body=new EntityLootableBody(world);body.setPosition(coordinate+.5,y,coordinate+.5);body.setNoGravity(true); + body.setInventorySlotContents(6,RuntimeChecks.stack(420));RuntimeChecks.disableVacuum(body);return body; + } + private static void conserved(EntityLootableBody body) { + RuntimeChecks.require(RuntimeChecks.sameStack(body.getStackInSlot(6),RuntimeChecks.stack(420)),"Recovery option changed complete item NBT"); + NBTTagCompound saved=new NBTTagCompound();body.writeToNBTOptional(saved);EntityLootableBody reloaded=new EntityLootableBody(body.world);reloaded.readFromNBT(saved); + RuntimeChecks.require(RuntimeChecks.sameStack(reloaded.getStackInSlot(6),RuntimeChecks.stack(420)),"Recovery option inventory changed on reload"); + } +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/ReportedIssueChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/ReportedIssueChecks.java new file mode 100644 index 0000000..2f76178 --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/ReportedIssueChecks.java @@ -0,0 +1,273 @@ +package cyano.lootable.buildtest; + +import com.mojang.authlib.GameProfile; +import cyano.lootable.entities.EntityLootableBody; +import cyano.lootable.graphics.CorpseContainer; +import java.io.*; +import java.util.*; +import net.minecraft.entity.Entity; +import net.minecraft.entity.player.EntityPlayerMP; +import net.minecraft.init.Blocks; +import net.minecraft.init.Items; +import net.minecraft.inventory.ClickType; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.*; +import net.minecraft.network.*; +import net.minecraft.network.play.client.*; +import io.netty.buffer.Unpooled; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.management.PlayerInteractionManager; +import net.minecraft.util.math.*; +import net.minecraft.world.*; +import net.minecraft.world.chunk.Chunk; +import net.minecraftforge.common.ForgeChunkManager; + +/** Public issue reproductions in real Forge; excluded from production artifacts. */ +final class ReportedIssueChecks { + private interface Check { void run() throws Exception; } + private static final List 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.isEmpty()) || stack.getCount()<=0)return; + NBTTagCompound n=stack.writeToNBT(new NBTTagCompound());n.removeTag("Count"); + total.put(n.toString(),total.getOrDefault(n.toString(),0)+stack.getCount()); + } + 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.player; + 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 { + // 1.12 QUICK_MOVE returns EMPTY after completely draining its source slot. + ItemStack predicted=ItemStack.EMPTY; + 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 == null ? ItemStack.EMPTY : predicted); + CPacketClickWindow packet=new CPacketClickWindow();packet.readPacketData(wire);p.connection.processClickWindow(packet); + RuntimeChecks.require(p.openContainer.getCanCraft(p),"Packet prediction rejected the click transaction"); + } finally {wire.release();} + } + private static void deathSequence() throws Exception { + world.getGameRules().setOrCreateGameRule("keepInventory","false");world.getChunk(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.set(4,shovel());p.inventory.mainInventory.set(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(RuntimeChecks.sameStack(p.inventory.mainInventory.get(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: remaining="+inventory(first)+" expected="+expected+" player="+tagged(p)+" canCraft="+gui.getCanCraft(p)); + 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.get(slot)).isEmpty() && p.inventory.mainInventory.get(slot).hasTagCompound() && p.inventory.mainInventory.get(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(RuntimeChecks.sameStack(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.getChunk(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.getChunk(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); + 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.getChunk(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(){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.getChunk(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.getChunk(f.cx,f.cz);world.getChunk(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); + // 1.11 applies ordinary 4-HP void hits with hurt-resistance intervals. + // Allow that native sequence to finish; retain the same conservation gate. + for(int i=0;i<250 && f.body.isEntityAlive();i++)world.updateEntity(f.body); + for(int i=0;i<15;i++)f.body.onEntityUpdate(); + System.out.println("VOID_CONTROL y="+f.body.posY+" alive="+f.body.isEntityAlive()+" items="+inventory(f.body).size()+" motion="+f.body.motionY); + 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.queueUnload(world.getChunk(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 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..0b542a3 --- /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.gameDir,"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.set(4,original());p.inventory.mainInventory.set(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.get(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.get(0),original(),"Original shovel changed before switch"); + active=-1;for(int slot=1;slot<9;slot++)if(RuntimeChecks.sameStack(mc.player.inventory.mainInventory.get(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.gameDir,"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.isEmpty()) || !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.getCount()); + } + 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().getChunk(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(RuntimeChecks.sameStack(a == null ? ItemStack.EMPTY : a,b == null ? ItemStack.EMPTY : 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,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.gameDir,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..3f8f553 --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/RespawnPacketChecks.java @@ -0,0 +1,150 @@ +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,startupTicks; + 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 void verifySlowClientStartup(MinecraftServer server) throws Exception { + String previous=System.getProperty("lootablebodies.multiplayer.sync"); + File folder=new File(server.getWorld(0).getSaveHandler().getWorldDirectory(),"respawn-startup-regression"); + Files.createDirectories(folder.toPath()); + RespawnPacketChecks fixture=null; + try { + System.setProperty("lootablebodies.multiplayer.sync",folder.getAbsolutePath()); + fixture=new RespawnPacketChecks(server); + // Cold software-rendered clients may need more than 500 server ticks + // to reach their first login. Exercise the actual fixture tick path. + for(int tick=0;tick<1000;tick++)fixture.tick(new TickEvent.ServerTickEvent(TickEvent.Phase.END)); + RuntimeChecks.require(!fixture.finished&&fixture.index==0&&fixture.phase==0&&fixture.phaseTicks==0, + "Client startup consumed the death/respawn case deadline"); + System.out.println("RESPAWN_STARTUP_CLOCK_PASS waiting_ticks=1000 case_ticks=0"); + } finally { + if(fixture!=null)FMLCommonHandler.instance().bus().unregister(fixture); + if(previous==null)System.clearProperty("lootablebodies.multiplayer.sync"); + else System.setProperty("lootablebodies.multiplayer.sync",previous); + } + } + 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"); + RuntimeChecks.require(!file("abort").exists(),"Respawn matrix aborted"); + EntityPlayerMP a=player("A"),b=player("B");trace(a);trace(b); + if(index==0&&phase==0&&(a==null||b==null||!ack("A",0)||!ack("B",0))) { + RuntimeChecks.require(++startupTicks<2400,"Clients did not finish startup before the respawn matrix"); + return; + } + phaseTicks++; + 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().getWorld(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.getWorld(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.set(9,RuntimeChecks.stack(520+index));a.inventoryContainer.detectAndSendChanges(); + if(simultaneous){b.inventory.mainInventory.set(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.getWorld(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.getWorld(previous.dimension); + w.getChunk((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..b1aba16 --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/RespawnPacketClientChecks.java @@ -0,0 +1,133 @@ +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.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.gameDir,"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(FixtureConnections.retry(mc,role))connecting=false; + if(!connecting&&FixtureConnections.ready(role)&&mc.currentScreen instanceof GuiMainMenu) { + connecting=true;mc.gameSettings.pauseOnLostFocus=false;mc.gameSettings.limitFramerate=30;mc.gameSettings.renderDistanceChunks=4; + FixtureConnections.connect(mc); + } + if(mc.world==null||mc.player==null)return; + if(!traced) { + traced=true;observations=new File(mc.gameDir,"main-packet-observations.txt"); + RespawnPacketTrace.install(mc.getConnection().getNetworkManager(),new File(mc.gameDir,"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,EnumHand.MAIN_HAND);return;} + boolean found=false;for(int slot=0;slot<48;slot++)if(RuntimeChecks.sameStack(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.gameDir,"respawn-case-"+index+".png",mc.displayWidth,mc.displayHeight,mc.getFramebuffer()); + ack(key); + } else if(phase==9) { + new File(mc.gameDir,"respawn-client.pass").createNewFile();System.out.println("LOOTABLE_BODIES_RESPAWN_CLIENT_PASS "+role); + mc.player.closeScreen(); + // Close the transport before clearing the world. The next main + // loop drains already queued destroy packets against that world; + // clearing it here races vanilla 1.11's unguarded packet handler. + mc.getConnection().getNetworkManager().closeChannel(new net.minecraft.util.text.TextComponentString("Disposable check complete")); + 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..d980e4c --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/RuntimeChecks.java @@ -0,0 +1,268 @@ +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();net.minecraftforge.common.MinecraftForge.EVENT_BUS.register(new TestRegistries());UpgradeChecks.registerOwnFixer();FixtureItems.register();} + @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); + } + static boolean sameStack(ItemStack first,ItemStack second) { + return ItemStack.areItemStacksEqual(first==null?ItemStack.EMPTY:first,second==null?ItemStack.EMPTY:second); + } + public static void run(MinecraftServer server, String phase) throws Exception { + WorldServer world = server.getWorld(0); + File folder = world.getSaveHandler().getWorldDirectory(); + require(LootableBodies.MODID.equals("lootablebodies") && LootableBodies.VERSION.equals("2.4.3.112021"), "identity/version"); + require(net.minecraftforge.fml.common.Loader.instance().getIndexedModList().get("lootablebodies") + .getVersion().equals(phase.equals("seed") ? "2.4.0" : phase.equals("issues") ? + System.getProperty("lootablebodies.expectedIssueVersion","2.4.3.112021") : "2.4.3.112021"), "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 && + (phase.equals("seed") || !LootableBodies.corpseBuoyancy && !LootableBodies.voidPlatform && LootableBodies.dimensionSpecificPlatform), "default configuration"); + defaultsChecked=true; + if(phase.equals("fresh")&&FMLCommonHandler.instance().getSide().isServer()) + RespawnPacketChecks.verifySlowClientStartup(server); + // Freeze timers only in this verifier while comparing exact inventories. + LootableBodies.allowCorpseDecay = false; + LootableBodies.ticksPerItemDecay = -1; + if(phase.equals("upgrade-contracts")) { + UpgradeChecks.run(server); + } else if(phase.startsWith("upgrade-")) { + WorldUpgradeChecks.run(server,phase); + } else 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("wall-escape")) { + WallEscapeChecks.run(server,world,folder); + } else if (phase.equals("wall-escape-reload")) { + WallEscapeChecks.reload(world,folder); + } else if (phase.startsWith("wall-historical")) { + WallEscapeChecks.historical(server,folder,phase.endsWith("reload")); + } 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")) PortBaselineChecks.run(server); + if (phase.equals("fresh")) Recovery112Checks.run(server); + 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); + if (phase.equals("fresh")) WallEscapeChecks.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 (phase.equals("reload") && new File(folder,"wall-escape-checks.txt").isFile()) WallEscapeChecks.reload(world,folder); + if(new File(folder,"issue17-chunks.txt").isFile()) ReportedIssueChecks.coldReload(server,world,folder); + world.getChunk(0, 0); + EntityLootableBody near = find(world, NEAR); + verifyExpected(folder, "near", near); + world.getChunk(2, 0); + verifyExpected(folder, "death", find(world, DEATH)); + if(new File(folder,"rejected-release.nbt").isFile()) { + world.getChunk(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.getChunk(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); + custom.setTag("typed_longs",new NBTTagLongArray(new long[]{Long.MIN_VALUE,index,Long.MAX_VALUE})); + 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.getChunk(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.getChunk(2,0); + world.getGameRules().setOrCreateGameRule("keepInventory","false"); + player.inventory.mainInventory.set(0,stack(4)); + player.inventory.mainInventory.set(9,stack(19)); + player.setItemStackToSlot(EntityEquipmentSlot.HEAD,stack(0)); + player.setItemStackToSlot(EntityEquipmentSlot.OFFHAND,stack(5)); + player.onDeath(DamageSource.OUT_OF_WORLD); + 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)).isEmpty(),"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/TestRegistries.java b/src/integrationTest/java/cyano/lootable/buildtest/TestRegistries.java new file mode 100644 index 0000000..0f4da20 --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/TestRegistries.java @@ -0,0 +1,12 @@ +package cyano.lootable.buildtest; + +import net.minecraft.item.Item; +import net.minecraft.potion.Potion; +import net.minecraftforge.event.RegistryEvent; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; + +/** Forge 1.12 registers controlled dependencies during their registry events. */ +public final class TestRegistries { + @SubscribeEvent public void items(RegistryEvent.Register event) {NbtWorldChecks.registerItem();} + @SubscribeEvent public void potions(RegistryEvent.Register event) {EffectHurtChecks.registerPotion();} +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/TypedNbt.java b/src/integrationTest/java/cyano/lootable/buildtest/TypedNbt.java new file mode 100644 index 0000000..b621b68 --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/TypedNbt.java @@ -0,0 +1,26 @@ +package cyano.lootable.buildtest; + +import java.util.TreeSet; +import net.minecraft.nbt.*; + +/** Compound order is immaterial; NBT types, list order and every array byte matter. */ +final class TypedNbt { + static String canonical(NBTBase tag) { + if(tag==null)return "null"; + StringBuilder text=new StringBuilder().append(tag.getId()).append(':'); + if(tag instanceof NBTTagCompound) { + NBTTagCompound compound=(NBTTagCompound)tag; + for(String key:new TreeSet<>(compound.getKeySet()))text.append(key.length()).append(':').append(key).append('=').append(canonical(compound.getTag(key))).append(';'); + } else if(tag instanceof NBTTagList) { + NBTTagList list=(NBTTagList)tag;text.append(list.getTagType()).append('/').append(list.tagCount()).append('['); + for(int i=0;iconversion("lootablebodies.Corpse",new NBTTagInt(512))); + check("native_111_identity_survives",()->conversion("lootablebodies:corpse",new NBTTagInt(922))); + check("missing_chunk_version_preserves_48_slots",()->conversion("lootablebodies.Corpse",null)); + check("wrong_type_chunk_version_preserves_48_slots",()->conversion("lootablebodies.Corpse",new NBTTagString("damaged"))); + check("nonfinite_chunk_version_preserves_48_slots",()->conversion("lootablebodies.Corpse",new NBTTagDouble(Double.NaN))); + check("negative_chunk_version_preserves_48_slots",()->conversion("lootablebodies.Corpse",new NBTTagInt(-1))); + check("incorrect_new_stamp_does_not_skip_legacy_items",()->conversion("lootablebodies.Corpse",new NBTTagInt(922))); + check("unknown_items_keep_source_context",UpgradeChecks::missingItem); + check("repeated_fixing_and_loading_never_duplicates",UpgradeChecks::repeated); + check("old_recovery_payload_is_not_mutated",UpgradeChecks::recovery); + check("full_save_does_not_reapply_original_item_versions",UpgradeChecks::savedCounter); + check("damaged_stamp_on_migrated_corpse_preserves_inventory",UpgradeChecks::migratedDamagedStamp); + check("forged_mod_stamp_does_not_bypass_preservation",UpgradeChecks::forgedModStamp); + check("reused_output_compound_cannot_resurrect_old_payload",UpgradeChecks::reusedOutput); + check("all_current_stamps_do_not_hide_legacy_identity",UpgradeChecks::allCurrentStamps); + check("falsely_current_stamps_still_convert_vanilla_container_items",UpgradeChecks::vanillaFalseStamp); + check("native112_bed_colour_is_preserved",()->bed(1343,0,0)); + check("maintained111_still_receives112_item_fixers",()->bed(922,1,14)); + check("current112_marker_never_reapplies_older_item_fixers",()->bed(1343,2,0)); + check("legacy_xp_sound_remaps_without_ignoring_unrelated_entries",UpgradeChecks::soundMapping); + check("unstamped_native112_chunk_keeps_white_bed_in_vanilla_chest",()->nativeChest(false)); + check("legacy_corpse_does_not_downgrade_native112_chunk_items",()->nativeChest(true)); + check("bed_with_no_source_evidence_stays_raw_and_retriable",UpgradeChecks::ambiguousBed); + System.out.println("LOOTABLE_BODIES_UPGRADE_CONTRACT_RESULT checks=23 failures="+failures); + RuntimeChecks.require(failures==0,"Upgrade contracts: "+failures); + } + private static void check(String name,Check check) { + try {check.run();System.out.println("UPGRADE_CONTRACT_PASS "+name);} + catch(Throwable error){failures++;System.out.println("UPGRADE_CONTRACT_FAIL "+name+": "+error);error.printStackTrace();} + } + private static void nativeChest(boolean legacy) throws Exception { + NBTTagCompound entity=body(legacy?"lootablebodies.Corpse":"lootablebodies:corpse"); + if(!legacy)entity.getTagList("Equipment",10).getCompoundTagAt(40).getCompoundTag("tag").getCompoundTag("EntityTag").setString("id","minecraft:pig"); + NBTTagCompound input=chunk(entity,legacy?new NBTTagInt(1343):null); + NBTTagCompound chest=new NBTTagCompound();chest.setString("id","minecraft:chest");chest.setInteger("x",2);chest.setInteger("y",80);chest.setInteger("z",2); + NBTTagCompound bed=new NBTTagCompound();bed.setString("id","minecraft:bed");bed.setByte("Count",(byte)1);bed.setShort("Damage",(short)0);bed.setByte("Slot",(byte)0); + NBTTagList items=new NBTTagList();items.appendTag(bed);chest.setTag("Items",items);NBTTagList tiles=new NBTTagList();tiles.appendTag(chest);input.getCompoundTag("Level").setTag("TileEntities",tiles); + NBTTagCompound fixed=processChunk(input,"1.12.2"); + NBTTagCompound actual=fixed.getCompoundTag("Level").getTagList("TileEntities",10).getCompoundTagAt(0).getTagList("Items",10).getCompoundTagAt(0); + RuntimeChecks.require(NameLifetimeChecks.same(bed,actual),"Native white bed changed under chunk conversion: "+actual); + NBTTagCompound converted=saved(fixed.getCompoundTag("Level").getTagList("Entities",10).getCompoundTagAt(0)); + RuntimeChecks.require(inventory(converted).size()==48,"Legacy/native corpse inventory changed while correcting vanilla context"); + } + private static void ambiguousBed() throws Exception { + NBTTagCompound input=body("lootablebodies:corpse"),item=new NBTTagCompound();item.setString("id","minecraft:bed");item.setByte("Count",(byte)1);item.setShort("Damage",(short)0);item.setByte("Slot",(byte)6); + NBTTagList items=new NBTTagList();items.appendTag(item);input.setTag("Equipment",items); + NBTTagCompound once=saved(process(chunk(input,null))),record=once.getTagList("LootableBodiesUnresolved",10).getCompoundTagAt(0); + RuntimeChecks.require(once.getTagList("LootableBodiesUnresolved",10).tagCount()==1 && NameLifetimeChecks.same(item,record.getTag("Data")) && record.getCompoundTag("SourceVersions").getBoolean("Ambiguous"),"Ambiguous bed was guessed or lost"); + EntityLootableBody again=new EntityLootableBody(world);again.readEntityFromNBT(once); + RuntimeChecks.require(!again.isEmpty(),"Raw recoverable bed allowed empty-corpse decay"); + NBTTagCompound twice=new NBTTagCompound();again.writeEntityToNBT(twice); + RuntimeChecks.require(NameLifetimeChecks.same(once.getTag("LootableBodiesUnresolved"),twice.getTag("LootableBodiesUnresolved")),"Ambiguous bed duplicated or changed during reload"); + } + private static void soundMapping() throws Exception { + net.minecraftforge.registries.IForgeRegistry registry=net.minecraftforge.fml.common.registry.ForgeRegistries.SOUND_EVENTS; + net.minecraftforge.event.RegistryEvent.MissingMappings.Mapping old=new net.minecraftforge.event.RegistryEvent.MissingMappings.Mapping<>(registry,registry,new net.minecraft.util.ResourceLocation("minecraft:entity.experience_orb.touch"),196); + net.minecraftforge.event.RegistryEvent.MissingMappings.Mapping other=new net.minecraftforge.event.RegistryEvent.MissingMappings.Mapping<>(registry,registry,new net.minecraft.util.ResourceLocation("absentfixture:other_sound"),197); + net.minecraftforge.event.RegistryEvent.MissingMappings event=new net.minecraftforge.event.RegistryEvent.MissingMappings<>(new net.minecraft.util.ResourceLocation("minecraft:soundevents"),registry,Arrays.asList(old,other)); + Class.forName("cyano.lootable.upgrade.LegacyRegistryMappings").getMethod("sounds",net.minecraftforge.event.RegistryEvent.MissingMappings.class).invoke(null,event); + RuntimeChecks.require(old.getAction()==net.minecraftforge.event.RegistryEvent.MissingMappings.Action.REMAP && old.getTarget()==registry.getValue(new net.minecraft.util.ResourceLocation("minecraft:entity.experience_orb.pickup")),"Known vanilla sound rename not remapped"); + RuntimeChecks.require(other.getAction()==net.minecraftforge.event.RegistryEvent.MissingMappings.Action.DEFAULT,"Unrelated missing mappings must retain Forge's normal handling"); + } + private static void bed(int source,int marker,int damage) throws Exception { + NBTTagCompound input=body("lootablebodies:corpse"); + input.getTagList("Equipment",10).getCompoundTagAt(40).getCompoundTag("tag").getCompoundTag("EntityTag").setString("id","minecraft:pig"); + NBTTagCompound item=new NBTTagCompound();item.setString("id","minecraft:bed");item.setByte("Count",(byte)2);item.setShort("Damage",(short)0);item.setByte("Slot",(byte)41);input.getTagList("Equipment",10).set(41,item); + if(marker>0)input.setInteger("LootableBodiesDataVersion",marker); + NBTTagCompound result=saved(process(chunk(input,new NBTTagInt(source)))); + NBTTagList equipment=result.getTagList("Equipment",10);NBTTagCompound found=null; + for(int i=0;i preservation=null;Object previous=null;Field active=null; + try { + // The red run exercises the real unprotected Forge pipeline. Once + // implemented, activate the same preflight used before world loading. + try {preservation=Class.forName("cyano.lootable.upgrade.UpgradePreservation");active=preservation.getDeclaredField("journal");active.setAccessible(true);previous=active.get(null);preservation.getMethod("prepare",Path.class).invoke(null,path);} + catch(ClassNotFoundException beforeImplementation) { } + NBTTagCompound input=source.copy(); + if(preservation!=null)try{input=(NBTTagCompound)preservation.getMethod("chunkContext",NBTTagCompound.class).invoke(null,input);}catch(NoSuchMethodException beforeBoundaryFix){} + NBTTagCompound result=FMLCommonHandler.instance().getDataFixer().process(FixTypes.CHUNK,input); + if(preservation!=null)try{preservation.getMethod("clearChunkContext",NBTTagCompound.class).invoke(null,result);}catch(NoSuchMethodException beforeBoundaryFix){} + return result; + } finally {if(active!=null)active.set(null,previous);} + } + private static NBTTagCompound saved(NBTTagCompound fixed) { + RuntimeChecks.require(fixed.getString("id").equals("lootablebodies:corpse"),"legacy identity was not converted before construction"); + Entity entity=net.minecraft.entity.EntityList.createEntityFromNBT(fixed.copy(),world); + RuntimeChecks.require(entity instanceof EntityLootableBody,"corpse factory returned no body"); + NBTTagCompound saved=new NBTTagCompound();((EntityLootableBody)entity).writeEntityToNBT(saved);return saved; + } + private static Map inventory(NBTTagCompound body) { + Map counts=new TreeMap<>();for(String key:Arrays.asList("Equipment","Aux")) { + NBTTagList list=body.getTagList(key,10);for(int i=0;i0,"malformed original fields were lost in vanilla fixing"); + } + private static void missingItem() throws Exception { + NBTTagCompound body=body("lootablebodies.Corpse"),raw=body.getTagList("Equipment",10).getCompoundTagAt(20);raw.setString("id","absentfixture:keepsake"); + NBTTagCompound result=saved(process(chunk(body,new NBTTagInt(512))));NBTTagList recovery=result.getTagList("LootableBodiesUnresolved",10); + RuntimeChecks.require(recovery.tagCount()==1,"unavailable item not retained exactly once");NBTTagCompound record=recovery.getCompoundTagAt(0); + RuntimeChecks.require(NameLifetimeChecks.same(raw,record.getTag("Data")) && record.getCompoundTag("SourceVersions").getInteger("DataVersion")==512,"unavailable item lost its original payload or source context"); + } + private static void repeated() throws Exception { + NBTTagCompound raw=body("lootablebodies.Corpse"),fixed=process(chunk(raw,new NBTTagInt(512))),once=saved(fixed); + NBTTagCompound again=FMLCommonHandler.instance().getDataFixer().process(FixTypes.CHUNK,chunk(fixed.copy(),new NBTTagInt(512))).getCompoundTag("Level").getTagList("Entities",10).getCompoundTagAt(0); + NBTTagCompound twice=saved(again);RuntimeChecks.require(inventory(once).equals(inventory(twice)),"repeated data fixing duplicated or lost items"); + } + private static void recovery() throws Exception { + NBTTagCompound body=body("lootablebodies.Corpse"),raw=body.getTagList("Equipment",10).getCompoundTagAt(20).copy();raw.setString("id","absentfixture:keepsake"); + NBTTagCompound record=new NBTTagCompound();record.setString("Source","Aux");record.setTag("Data",raw.copy());NBTTagList records=new NBTTagList();records.appendTag(record);body.setTag("LootableBodiesUnresolved",records); + NBTTagCompound output=saved(process(chunk(body,new NBTTagInt(512))));NBTTagCompound kept=output.getTagList("LootableBodiesUnresolved",10).getCompoundTagAt(0); + RuntimeChecks.require(kept.getString("Source").equals("Aux") && NameLifetimeChecks.same(raw,kept.getTag("Data")),"existing Source/Data payload changed"); + RuntimeChecks.require(kept.getCompoundTag("SourceVersions").getInteger("DataVersion")==512,"existing recovery extension lost source version context"); + } + private static NBTTagCompound fullSave(NBTTagCompound fixed) { + Entity entity=net.minecraft.entity.EntityList.createEntityFromNBT(fixed.copy(),world);RuntimeChecks.require(entity instanceof EntityLootableBody,"Missing body for full save"); + NBTTagCompound result=new NBTTagCompound();entity.writeToNBTOptional(result);return result; + } + private static void savedCounter() throws Exception { + NBTTagCompound source=body("lootablebodies.Corpse");source.getTagList("Equipment",10).getCompoundTagAt(20).getCompoundTag("tag").setInteger("upgrade_counter",0); + NBTTagCompound once=fullSave(process(chunk(source,new NBTTagInt(512)))); + RuntimeChecks.require(once.getTagList("Equipment",10).getCompoundTagAt(20).getCompoundTag("tag").getInteger("upgrade_counter")==1,"Item fixer did not run exactly once on first load"); + NBTTagCompound twice=fullSave(process(chunk(once,new NBTTagInt(922))));RuntimeChecks.require(inventory(once).equals(inventory(twice)),"Old item version was applied again after saving migrated items"); + } + private static NBTTagCompound currentStamps(NBTTagCompound chunk) {FMLCommonHandler.instance().getDataFixer().writeVersionData(chunk);chunk.getCompoundTag("ForgeDataVersion").setInteger("minecraft",-1);return chunk;} + private static void migratedDamagedStamp() throws Exception { + NBTTagCompound source=body("lootablebodies:corpse");source.setInteger("LootableBodiesDataVersion",1);source.getTagList("Equipment",10).getCompoundTagAt(40).getCompoundTag("tag").getCompoundTag("EntityTag").setString("id","minecraft:pig"); + NBTTagCompound result=saved(process(currentStamps(chunk(source,null))));RuntimeChecks.require(inventory(source).equals(inventory(result)),"Damaged version stamp truncated an already migrated Equipment list"); + } + private static void forgedModStamp() throws Exception { + NBTTagCompound source=body("lootablebodies.Corpse"),expected=source.copy();expected.getTagList("Equipment",10).getCompoundTagAt(40).getCompoundTag("tag").getCompoundTag("EntityTag").setString("id","minecraft:pig"); + NBTTagCompound input=currentStamps(chunk(source,new NBTTagInt(512)));input.getCompoundTag("ForgeDataVersion").setInteger("minecraft",512); + RuntimeChecks.require(inventory(expected).equals(inventory(saved(process(input)))),"Incorrect mod stamp bypassed custom inventory preservation"); + } + private static void reusedOutput() throws Exception { + NBTTagCompound fixed=process(chunk(body("lootablebodies.Corpse"),new NBTTagInt(512))); + EntityLootableBody entity=(EntityLootableBody)net.minecraft.entity.EntityList.createEntityFromNBT(fixed.copy(),world);entity.clear();entity.writeEntityToNBT(fixed); + EntityLootableBody reloaded=(EntityLootableBody)net.minecraft.entity.EntityList.createEntityFromNBT(fixed.copy(),world); + RuntimeChecks.require(reloaded.isEmpty(),"Reused output compound resurrected a consumed upgrade payload"); + } + private static void allCurrentStamps() throws Exception { + NBTTagCompound input=chunk(body("lootablebodies.Corpse"),new NBTTagInt(922)),versions=new NBTTagCompound(); + versions.setInteger("minecraft",922);versions.setInteger("lootablebodies",1);versions.setInteger("lootablebodiesbuildtest",1);input.setTag("ForgeDataVersion",versions); + saved(process(input)); + } + private static void vanillaFalseStamp() throws Exception { + NBTTagCompound input=chunk(body("lootablebodies.Corpse"),new NBTTagInt(922)),versions=new NBTTagCompound(); + versions.setInteger("minecraft",922);versions.setInteger("lootablebodies",1);versions.setInteger("lootablebodiesbuildtest",1);input.setTag("ForgeDataVersion",versions); + NBTTagCompound chest=new NBTTagCompound();chest.setString("id","Chest");chest.setInteger("x",2);chest.setInteger("y",80);chest.setInteger("z",2); + NBTTagCompound banner=new NBTTagCompound();banner.setString("id","minecraft:banner");banner.setByte("Count",(byte)2);banner.setShort("Damage",(short)0);banner.setByte("Slot",(byte)0); + NBTTagCompound tag=new NBTTagCompound(),block=new NBTTagCompound();block.setInteger("Base",5);tag.setTag("BlockEntityTag",block);banner.setTag("tag",tag);NBTTagList items=new NBTTagList();items.appendTag(banner);chest.setTag("Items",items);NBTTagList tiles=new NBTTagList();tiles.appendTag(chest);input.getCompoundTag("Level").setTag("TileEntities",tiles); + NBTTagCompound fixed=processChunk(input).getCompoundTag("Level").getTagList("TileEntities",10).getCompoundTagAt(0).getTagList("Items",10).getCompoundTagAt(0); + RuntimeChecks.require(fixed.getShort("Damage")==5 && fixed.getByte("Count")==2 && !fixed.getCompoundTag("tag").getCompoundTag("BlockEntityTag").hasKey("Base"),"False version stamps skipped the vanilla banner conversion"); + } +} 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..6632e15 --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/VisibilityChecks.java @@ -0,0 +1,158 @@ +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.getWorld(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)) { + // Only this disposable tracking fixture moves the world spawn. + // The corpse chunk must be eligible to unload when both clients + // leave; keeping it inside the spawn area would test no unload. + world().setSpawnPoint(new BlockPos(8192,80,8192)); + 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.set(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.set(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(RuntimeChecks.sameStack(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..56e862e --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/VisibilityClientChecks.java @@ -0,0 +1,106 @@ +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.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(FixtureConnections.retry(mc,role))connecting=false; + if(!connecting && FixtureConnections.ready(role) && 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; + FixtureConnections.connect(mc); + } + 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,EnumHand.MAIN_HAND);return; + } + int index=phase==16?411:410;boolean found=false; + for(int slot=0;slot<48;slot++)if(RuntimeChecks.sameStack(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(RuntimeChecks.sameStack(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.gameDir,"visibility-client.pass").createNewFile();System.out.println("LOOTABLE_BODIES_VISIBILITY_CLIENT_PASS role="+role); + mc.player.closeScreen(); + mc.getConnection().getNetworkManager().closeChannel(new net.minecraft.util.text.TextComponentString("Disposable check complete")); + 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.gameDir,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..d55ee2c --- /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.gameDir,"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.gameDir,"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.gameDir,"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..52befda --- /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.gameDir,"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.gameDir,"skin-fallback-models.png",mc.displayWidth,mc.displayHeight,mc.getFramebuffer()); + skinField.set(mc,original);new File(mc.gameDir,"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(fontRenderer,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/java/cyano/lootable/buildtest/WallEscapeChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/WallEscapeChecks.java new file mode 100644 index 0000000..e51fb82 --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/WallEscapeChecks.java @@ -0,0 +1,245 @@ +package cyano.lootable.buildtest; + +import cyano.lootable.entities.EntityLootableBody; +import java.io.*; +import java.util.*; +import net.minecraft.block.BlockSnow; +import net.minecraft.entity.EntityList; +import net.minecraft.init.Blocks; +import net.minecraft.nbt.*; +import net.minecraft.server.MinecraftServer; +import net.minecraft.util.DamageSource; +import net.minecraft.util.EnumFacing; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.WorldServer; + +/** Real block collision and chunk-provider regressions; never shipped with the mod. */ +final class WallEscapeChecks { + private interface Check { void run() throws Exception; } + private static WorldServer world; + private static int sequence, failures; + private static final List results = new ArrayList<>(); + + static void run(MinecraftServer server, WorldServer target, File folder) throws Exception { + world = target; sequence = 0; failures = 0; results.clear(); + check("centred_body_never_uses_NaN_as_destination", WallEscapeChecks::centred); + for (EnumFacing direction : EnumFacing.values()) + check("only_clear_exit_" + direction, () -> oneExit(direction)); + check("off_centre_destination_checks_entire_body", WallEscapeChecks::overlap); + check("snow_layer_is_not_clear_air", WallEscapeChecks::snow); + check("fence_below_exit_is_not_clear_air", WallEscapeChecks::fence); + check("sealed_body_waits_without_random_drift", WallEscapeChecks::sealed); + check("already_clear_body_does_not_teleport", WallEscapeChecks::alreadyClear); + check("negative_coordinates_keep_finite_safe_exit", WallEscapeChecks::negative); + check("suffocation_damage_uses_escape_and_keeps_default_health", WallEscapeChecks::damage); + for (EnumFacing direction : EnumFacing.HORIZONTALS) + check("unloaded_exit_is_not_generated_" + direction, () -> unloaded(direction)); + check("ticking_sealed_body_waits_then_escapes_opened_wall", WallEscapeChecks::ticking); + check("escaped_and_sealed_bodies_saved_for_cold_reload", () -> seed(folder)); + try (PrintWriter out = new PrintWriter(new File(folder, "wall-escape-checks.txt"), "UTF-8")) { + for (String result : results) out.println(result); + out.println("checks=" + results.size() + " failures=" + failures); + } + RuntimeChecks.require(failures == 0, "Wall escape regressions: " + failures + " / " + results.size()); + System.out.println("LOOTABLE_BODIES_WALL_ESCAPE_PASS checks=" + results.size()); + } + + private static void check(String name, Check test) { + try { test.run(); results.add("PASS " + name); System.out.println("WALL_ESCAPE_PASS " + name); } + catch (Throwable error) { + failures++; results.add("FAIL " + name + ": " + error); + System.out.println("WALL_ESCAPE_FAIL " + name + ": " + error); error.printStackTrace(); + } + } + + private static BlockPos site(boolean negative) { + int x = (200 + sequence++ * 4) * 16 + 8; + return new BlockPos(negative ? -x : x, 80, 3208); + } + + private static void terrain(BlockPos centre, boolean solid) { + for (int x = -2; x <= 2; x++) for (int y = -2; y <= 3; y++) for (int z = -2; z <= 2; z++) + world.setBlockState(centre.add(x,y,z), (solid || y == -1 ? Blocks.STONE : Blocks.AIR).getDefaultState(), 2); + } + + private static EntityLootableBody body(BlockPos centre) { + EntityLootableBody body = new EntityLootableBody(world); + body.setPosition(centre.getX()+.5, centre.getY()+.015625, centre.getZ()+.5); + body.setNoGravity(true); body.setUserName("Wall Escape Fixture"); + for (int slot = 0; slot < 48; slot++) body.setInventorySlotContents(slot, RuntimeChecks.stack(slot)); + body.addItem(RuntimeChecks.stack(70)); body.addItem(RuntimeChecks.stack(71)); + RuntimeChecks.disableVacuum(body); + NBTTagCompound saved = NameLifetimeChecks.saved(body); + NBTTagCompound unresolved = new NBTTagCompound(); + unresolved.setString("Source", "FutureFixture"); unresolved.setString("Data", "retain verbatim"); + saved.setTag("LootableBodiesUnresolved", NbtRecoveryChecks.list(unresolved)); + NBTTagCompound invalid = new NBTTagCompound(); + invalid.setString("Source", "FutureMetadata"); invalid.setTag("Data", new NBTTagByteArray(new byte[]{4,3,2,1})); + saved.setTag("LootableBodiesInvalidFields", NbtRecoveryChecks.list(invalid)); + return (EntityLootableBody) EntityList.createEntityFromNBT(saved, world); + } + + private static void unchanged(NBTTagCompound before, EntityLootableBody body) { + NBTTagCompound after = NameLifetimeChecks.saved(body); + before = before.copy(); before.removeTag("Pos"); after.removeTag("Pos"); + RuntimeChecks.require(NameLifetimeChecks.same(before, after), "Escape changed saved fields besides position"); + PositionMotionChecks.finite(body); + } + + private static void clear(EntityLootableBody body) { + PositionMotionChecks.finite(body); + RuntimeChecks.require(world.getCollisionBoxes(body, body.getEntityBoundingBox()).isEmpty(), "Escape left body intersecting terrain"); + } + + private static void centred() { + BlockPos centre = site(false); terrain(centre, false); world.setBlockState(centre, Blocks.STONE.getDefaultState(), 2); + // A zero-vector lookup previously sampled (0,0,0). Test it as air, too. + net.minecraft.block.state.IBlockState origin = world.getBlockState(BlockPos.ORIGIN); + try { + world.setBlockToAir(BlockPos.ORIGIN); + EntityLootableBody body = body(centre); NBTTagCompound before = NameLifetimeChecks.saved(body); + body.jumpOutOfWall(); clear(body); unchanged(before, body); + RuntimeChecks.require(body.posY == centre.getY()+1.015625, "Centred body missed the existing upward exit priority"); + } finally { world.setBlockState(BlockPos.ORIGIN, origin, 2); } + } + + private static void oneExit(EnumFacing direction) { + BlockPos centre = site(false); terrain(centre, true); BlockPos exit = centre.offset(direction); world.setBlockToAir(exit); + EntityLootableBody body = body(centre); NBTTagCompound before = NameLifetimeChecks.saved(body); + body.jumpOutOfWall(); clear(body); unchanged(before, body); + RuntimeChecks.require(body.posX == exit.getX()+.5 && body.posY == exit.getY()+.015625 && body.posZ == exit.getZ()+.5, "Missed sole adjacent exit"); + } + + private static void overlap() { + BlockPos centre = site(false); terrain(centre, false); world.setBlockState(centre, Blocks.STONE.getDefaultState(), 2); + EntityLootableBody body = body(centre); body.setPosition(centre.getX()+.1, centre.getY()+.015625, centre.getZ()+.5); + NBTTagCompound before = NameLifetimeChecks.saved(body); body.jumpOutOfWall(); clear(body); unchanged(before, body); + } + + private static void snow() { + BlockPos centre = site(false); terrain(centre, true); world.setBlockToAir(centre.north()); + world.setBlockState(centre.up(), Blocks.SNOW_LAYER.getDefaultState().withProperty(BlockSnow.LAYERS, 8), 2); + EntityLootableBody body = body(centre); body.jumpOutOfWall(); clear(body); + RuntimeChecks.require(body.getPosition().equals(centre.north()), "Snow collision was ignored"); + } + + private static void fence() { + BlockPos centre = site(false); terrain(centre, true); world.setBlockToAir(centre.north()); world.setBlockToAir(centre.east()); + world.setBlockState(centre.north().down(), Blocks.OAK_FENCE.getDefaultState(), 2); + EntityLootableBody body = body(centre); body.jumpOutOfWall(); clear(body); + RuntimeChecks.require(body.getPosition().equals(centre.east()), "Tall collision below destination was ignored"); + } + + private static void sealed() { + BlockPos centre = site(false); terrain(centre, true); EntityLootableBody body = body(centre); + NBTTagCompound before = NameLifetimeChecks.saved(body); + for (int i = 0; i < 32; i++) body.jumpOutOfWall(); + RuntimeChecks.require(NameLifetimeChecks.same(before, NameLifetimeChecks.saved(body)), "Sealed corpse drifted or changed saved data"); + PositionMotionChecks.finite(body); + } + + private static void alreadyClear() { + BlockPos centre = site(false); terrain(centre, false); EntityLootableBody body = body(centre); + NBTTagCompound before = NameLifetimeChecks.saved(body); body.jumpOutOfWall(); + RuntimeChecks.require(NameLifetimeChecks.same(before, NameLifetimeChecks.saved(body)), "Clear corpse teleported on stale suffocation damage"); + } + + private static void negative() { + BlockPos centre = site(true); terrain(centre, true); world.setBlockToAir(centre.west()); + EntityLootableBody body = body(centre); NBTTagCompound before = NameLifetimeChecks.saved(body); + body.jumpOutOfWall(); clear(body); unchanged(before, body); + RuntimeChecks.require(body.getPosition().equals(centre.west()), "Negative coordinate exit was rounded incorrectly"); + } + + private static void damage() { + BlockPos centre = site(false); terrain(centre, true); world.setBlockToAir(centre.up()); + EntityLootableBody body = body(centre); float health = body.getHealth(); + body.attackEntityFrom(DamageSource.IN_WALL, 1); clear(body); + RuntimeChecks.require(body.isEntityAlive() && body.getHealth() == health, "Default suffocation health rules changed"); + } + + private static void unloaded(EnumFacing direction) throws Exception { + BlockPos centre = site(false); + int x = centre.getX() >> 4, z = centre.getZ() >> 4; + centre = new BlockPos(x*16+(direction == EnumFacing.WEST ? 0 : direction == EnumFacing.EAST ? 15 : 8), 80, + z*16+(direction == EnumFacing.NORTH ? 0 : direction == EnumFacing.SOUTH ? 15 : 8)); + terrain(centre, true); world.setBlockToAir(centre.offset(direction)); + int nx = x+direction.getXOffset(), nz = z+direction.getZOffset(); + world.getChunkProvider().queueUnload(world.getChunk(nx,nz)); + int attempts = world.getChunkProvider().getLoadedChunks().size()/100+2; + for (int i = 0; i < attempts && world.getChunkProvider().chunkExists(nx,nz); i++) world.getChunkProvider().tick(); + net.minecraft.world.storage.ThreadedFileIOBase.getThreadedIOInstance().waitForFinish(); + RuntimeChecks.require(!world.getChunkProvider().chunkExists(nx,nz), "Fixture neighbour did not unload"); + EntityLootableBody body = body(centre); NBTTagCompound before = NameLifetimeChecks.saved(body); + body.jumpOutOfWall(); + RuntimeChecks.require(!world.getChunkProvider().chunkExists(nx,nz), "Escape forced an unloaded neighbour to load"); + RuntimeChecks.require(NameLifetimeChecks.same(before, NameLifetimeChecks.saved(body)), "Escape moved without loaded terrain"); + world.getChunk(nx,nz); body.jumpOutOfWall(); clear(body); unchanged(before, body); + } + + private static void ticking() { + BlockPos centre = site(false); terrain(centre, true); EntityLootableBody body = body(centre); + NBTTagCompound before = NameLifetimeChecks.saved(body); + for (int i = 0; i < 100; i++) body.onUpdate(); + RuntimeChecks.require(body.posX == centre.getX()+.5 && body.posY == centre.getY()+.015625 && body.posZ == centre.getZ()+.5 && body.isEntityAlive(), "Ticking sealed corpse drifted or died"); + for (String key : new String[]{"Equipment","Aux","LootableBodiesUnresolved","LootableBodiesInvalidFields","DeathTime","DecayTime"}) + RuntimeChecks.require(NameLifetimeChecks.same(before.getTag(key),NameLifetimeChecks.saved(body).getTag(key)), "Ticking escape changed " + key); + world.setBlockToAir(centre.up()); + for (int i = 0; i < 30; i++) body.onUpdate(); + clear(body); RuntimeChecks.require(body.getPosition().equals(centre.up()), "Ticking corpse did not use newly opened exit"); + } + + private static void seed(File folder) throws Exception { + for (boolean trapped : new boolean[]{false,true}) { + BlockPos centre = site(false); terrain(centre, true); + if (!trapped) world.setBlockToAir(centre.up()); + EntityLootableBody body = body(centre); + body.setUniqueId(UUID.nameUUIDFromBytes(("wall-escape-"+trapped).getBytes(java.nio.charset.StandardCharsets.UTF_8))); + NBTTagCompound before = NameLifetimeChecks.saved(body); body.jumpOutOfWall(); unchanged(before,body); + if (!trapped) clear(body); + else RuntimeChecks.require(NameLifetimeChecks.same(before,NameLifetimeChecks.saved(body)), "Sealed disk fixture moved"); + RuntimeChecks.require(world.spawnEntity(body), "Could not save wall fixture"); + CompressedStreamTools.writeCompressed(NameLifetimeChecks.saved(body), new FileOutputStream(new File(folder,"wall-escape-"+trapped+".nbt"))); + } + } + + static void reload(WorldServer target, File folder) throws Exception { + world = target; + for (boolean trapped : new boolean[]{false,true}) { + NBTTagCompound expected; + try (InputStream in = new FileInputStream(new File(folder,"wall-escape-"+trapped+".nbt"))) { expected = CompressedStreamTools.readCompressed(in); } + NBTTagList pos = expected.getTagList("Pos",6); + world.getChunk(new BlockPos(pos.getDoubleAt(0),pos.getDoubleAt(1),pos.getDoubleAt(2))); + EntityLootableBody body = RuntimeChecks.find(world, expected.getUniqueId("UUID")); + RuntimeChecks.require(NameLifetimeChecks.same(expected,NameLifetimeChecks.saved(body)), "Cold reload changed wall fixture NBT"); + body.jumpOutOfWall(); + RuntimeChecks.require(NameLifetimeChecks.same(expected,NameLifetimeChecks.saved(body)), "Repeated escape changed cold-loaded fixture"); + if (!trapped) clear(body); + } + System.out.println("LOOTABLE_BODIES_WALL_ESCAPE_RELOAD_PASS bodies=2"); + } + + /** The sealed 2.2.0 Nether fixture that originally exposed random wall drift. */ + static void historical(MinecraftServer server, File folder, boolean reload) throws Exception { + world = server.getWorld(-1); + for (int x = 7; x <= 9; x++) for (int z = 7; z <= 9; z++) world.getChunk(x,z); + EntityLootableBody body = null; + for (net.minecraft.entity.Entity entity : world.loadedEntityList) + if (entity instanceof EntityLootableBody && Math.abs(entity.posX-128.5)<.001 && Math.abs(entity.posZ-128.5)<.001) + body = (EntityLootableBody) entity; + RuntimeChecks.require(body != null, "Original Nether corpse not found at its saved position"); + RuntimeChecks.require(!world.getCollisionBoxes(body,body.getEntityBoundingBox()).isEmpty(), "Historical buried-body terrain was cleared"); + NBTTagCompound expected = NameLifetimeChecks.saved(body); + File file = new File(folder,"wall-historical.nbt"); + if (reload) { + try (InputStream in = new FileInputStream(file)) { + RuntimeChecks.require(NameLifetimeChecks.same(CompressedStreamTools.readCompressed(in),expected), "Historical cold reload changed complete corpse NBT"); + } + } + for (int i = 0; i < 32; i++) body.jumpOutOfWall(); + RuntimeChecks.require(NameLifetimeChecks.same(expected,NameLifetimeChecks.saved(body)), "Historical sealed corpse drifted or lost saved data"); + PositionMotionChecks.finite(body); + if (!reload) try (OutputStream out = new FileOutputStream(file)) { CompressedStreamTools.writeCompressed(expected,out); } + System.out.println("LOOTABLE_BODIES_WALL_HISTORICAL_PASS reload="+reload+" slots="+body.getSizeInventory()); + } +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/WorldUpgradeChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/WorldUpgradeChecks.java new file mode 100644 index 0000000..8c195a7 --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/WorldUpgradeChecks.java @@ -0,0 +1,166 @@ +package cyano.lootable.buildtest; + +import com.mojang.authlib.GameProfile; +import cyano.lootable.LootableBodies; +import cyano.lootable.entities.EntityLootableBody; +import cyano.lootable.graphics.CorpseContainer; +import java.io.*; +import java.util.*; +import net.minecraft.entity.Entity; +import net.minecraft.entity.player.EntityPlayerMP; +import net.minecraft.inventory.*; +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.*; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.management.PlayerInteractionManager; +import net.minecraft.tileentity.TileEntity; +import net.minecraft.util.ResourceLocation; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.WorldServer; +import net.minecraftforge.common.util.FakePlayerFactory; + +/** Acceptance against worlds written by the actual historical production jars. */ +final class WorldUpgradeChecks { + private static NBTTagCompound manifest,ledger; + private static File folder; + private static boolean old,native112; + private static int checks; + static void run(MinecraftServer server,String phase) throws Exception { + folder=server.getWorld(0).getSaveHandler().getWorldDirectory(); + manifest=read(new File(folder,"upgrade-fixtures.nbt"));old=manifest.getString("Minecraft").equals("1.10.2");native112=manifest.getString("Minecraft").equals("1.12.2"); + File stored=new File(folder,"upgrade-recovered.nbt");ledger=stored.isFile()?read(stored):new NBTTagCompound();checks=0; + if(!ledger.hasKey("Recoveries",10))ledger.setTag("Recoveries",new NBTTagCompound()); + LootableBodies.allowCorpseDecay=false;LootableBodies.ticksPerItemDecay=-1; + int wave=phase.startsWith("upgrade-near")?0:phase.startsWith("upgrade-far")?1:phase.equals("upgrade-late")?2:-1; + boolean recover=phase.equals("upgrade-near-recover") || phase.equals("upgrade-far-reload") || phase.equals("upgrade-late") || phase.equals("upgrade-return"); + NBTTagList bodies=manifest.getTagList("Bodies",10); + for(int i=0;i=0 && record.getInteger("Wave")!=wave)continue; + WorldServer world=server.getWorld(record.getInteger("Dimension"));NBTTagCompound source=record.getCompoundTag("Body"); + int x=(int)source.getTagList("Pos",6).getDoubleAt(0),z=(int)source.getTagList("Pos",6).getDoubleAt(2);world.getChunk(x>>4,z>>4); + UUID uuid=source.getUniqueId("UUID");EntityLootableBody body=find(world,uuid);String id=uuid.toString(); + if(ledger.getString("Removed").equals(id)) {require(body==null,"Journal resurrected a deliberately removed corpse");continue;} + require(body!=null,"Missing historical corpse "+uuid); + require(body.getUniqueID().equals(uuid) && body.dimension==record.getInteger("Dimension"),"UUID/dimension changed"); + require(body.getCustomNameTag().equals(source.getString("CustomName")),"Owner attribution changed"); + require(body.posX==source.getTagList("Pos",6).getDoubleAt(0) && body.posY==source.getTagList("Pos",6).getDoubleAt(1) && body.posZ==source.getTagList("Pos",6).getDoubleAt(2),"Position changed in dimension "+world.provider.getDimension()+": "+body.getPositionVector()); + NBTTagCompound actual=save(body);require(actual.getInteger("LootableBodiesDataVersion")==2,"Missing corpse migration marker"); + require(NameLifetimeChecks.same(source.getTag("LootableBodiesInvalidFields"),actual.getTag("LootableBodiesInvalidFields")),"Original invalid-field evidence changed"); + Map expected=expectedInventory(source);Map observed=inventory(actual); + NBTTagCompound recovery=ledger.getCompoundTag("Recoveries").getCompoundTag(id); + Map recovered=storage(world,recovery); + merge(observed,recovered);require(expected.equals(observed),"Inventory conservation mismatch "+uuid+" expected="+expected+" actual="+observed); + verifyRaw(source,actual); + if(recover) { + if(!ledger.getCompoundTag("Recoveries").hasKey(id,10)) {ledger.getCompoundTag("Recoveries").setTag(id,new NBTTagCompound());recovery=ledger.getCompoundTag("Recoveries").getCompoundTag(id);} + recover(world,body,recovery); + require(inventory(save(body)).isEmpty(),"Supported inventory remained after GUI recovery"); + require(expected.equals(storage(world,recovery)),"GUI recovery lost or duplicated complete item data"); + verifyRaw(source,save(body)); + } + verifyChest(world,record.getInteger("Wave")); + } + verifyPlayer(server); + if(wave==0 || wave<0) verifyVanilla(server); + if(phase.equals("upgrade-final") && ledger.getString("Removed").isEmpty()) { + for(int i=0;i(item.getKeySet()))normalize(item.getTag(key)); + } + private static void add(Map map,NBTTagCompound source,boolean indexed) { + if(!source.hasKey("id",8) || source.getByte("Count")<=0)return; + NBTTagCompound item=source.copy();int count=item.getByte("Count")&255;item.removeTag("Count");if(!indexed)item.removeTag("Slot"); + String key=TypedNbt.canonical(item);map.put(key,map.getOrDefault(key,0)+count); + } + private static void merge(Map a,Map b){for(Map.Entry item:b.entrySet())a.put(item.getKey(),a.getOrDefault(item.getKey(),0)+item.getValue());} + private static Map inventory(NBTTagCompound body){Map map=new TreeMap<>();for(String key:new String[]{"Equipment","Aux"}){NBTTagList items=body.getTagList(key,10);for(int i=0;i expectedInventory(NBTTagCompound source) { + Map expected=new TreeMap<>();for(String key:new String[]{"Equipment","Aux"}) {NBTTagList list=source.getTagList(key,10);for(int i=0;i expected=new ArrayList<>(); + for(String key:new String[]{"Equipment","Aux"}) {NBTTagList list=source.getTagList(key,10);for(int i=0;i contents(IInventory inventory,boolean indexed) {Map map=new TreeMap<>();for(int slot=0;slot indexed(NBTTagList list) {Map map=new TreeMap<>();for(int i=0;i storage(WorldServer world,NBTTagCompound recovery) {Map map=new TreeMap<>();NBTTagList list=storageList(recovery);for(int i=0;i0) {destination=(IInventory)world.getTileEntity(position(stored.getCompoundTagAt(stored.tagCount()-1)));for(int i=0;i expected=indexed(source.getTagList("Items",10)),actual=contents((IInventory)tile,true);if(!expected.equals(actual)){Map missing=new TreeMap<>(expected),unexpected=new TreeMap<>(actual);missing.entrySet().removeAll(actual.entrySet());unexpected.entrySet().removeAll(expected.entrySet());System.out.println("VANILLA_CHEST_DIFF missing="+missing+" unexpected="+unexpected);}require(expected.equals(actual),"Vanilla source chest slots/data changed");} + } + private static void verifyPlayer(MinecraftServer server) throws Exception { + WorldServer world=server.getWorld(0);UUID id=manifest.getUniqueId("Player"); + NBTTagCompound expected=read(new File(folder,"upgrade-player-source.nbt")); + EntityPlayerMP player=new EntityPlayerMP(server,world,new GameProfile(id,"UpgradeFixture"),new PlayerInteractionManager(world));world.getSaveHandler().getPlayerNBTManager().readPlayerData(player); + // InventoryPlayer serializes armour as 100..103 and offhand as 150. + NBTTagList actual=player.inventory.writeToNBT(new NBTTagList());require(indexed(expected.getTagList("Inventory",10)).equals(indexedCurrent(actual)),"Player inventory slots, counts or custom data changed"); + require(indexed(expected.getTagList("EnderItems",10)).equals(indexedCurrent(player.getInventoryEnderChest().saveInventoryToNBT())),"Ender chest slots/data changed"); + require(player.getUniqueID().equals(id),"Player UUID changed");world.getSaveHandler().getPlayerNBTManager().writePlayerData(player); + } + private static Map indexedCurrent(NBTTagList list){Map map=new TreeMap<>();for(int i=0;i>4,((int)pos.getDoubleAt(2))>>4);Entity found=null;for(Entity e:world.loadedEntityList)if(e.getUniqueID().equals(source.getUniqueId("UUID")))found=e;require(found!=null,"Vanilla entity disappeared");NBTTagCompound actual=new NBTTagCompound();found.writeToNBTOptional(actual);require(actual.getString("id").equals(record.getString("ExpectedType")),"Vanilla entity-name conversion failed");require(found.dimension==record.getInteger("Dimension"),"Vanilla entity dimension changed");if(found instanceof IInventory)require(indexed(source.getTagList("Items",10)).equals(contents((IInventory)found,true)),"Vanilla minecart inventory changed");if(record.getString("ExpectedType").equals("minecraft:armor_stand"))require(indexedEquipment(source.getTagList("ArmorItems",10)).equals(indexedEquipment(actual.getTagList("ArmorItems",10))),"Vanilla armour stand equipment changed");} + } + private static Map indexedEquipment(NBTTagList list){Map map=new TreeMap<>();for(int i=0;i + + + + + + + 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/legacySeed/java/cyano/lootable/fixture/LegacyWorldSeeder.java b/src/legacySeed/java/cyano/lootable/fixture/LegacyWorldSeeder.java new file mode 100644 index 0000000..30f52c9 --- /dev/null +++ b/src/legacySeed/java/cyano/lootable/fixture/LegacyWorldSeeder.java @@ -0,0 +1,120 @@ +package cyano.lootable.fixture; + +import com.mojang.authlib.GameProfile; +import cyano.lootable.LootableBodies; +import cyano.lootable.entities.EntityLootableBody; +import cyano.lootable.buildtest.FixtureItems; +import java.io.*; +import java.util.*; +import net.minecraft.entity.item.EntityArmorStand; +import net.minecraft.entity.item.EntityMinecartChest; +import net.minecraft.entity.passive.EntityPig; +import net.minecraft.entity.player.EntityPlayerMP; +import net.minecraft.init.*; +import net.minecraft.inventory.EntityEquipmentSlot; +import net.minecraft.inventory.IInventory; +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.*; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.management.PlayerInteractionManager; +import net.minecraft.tileentity.TileEntityChest; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.WorldServer; +import net.minecraftforge.fml.common.*; +import net.minecraftforge.fml.common.event.*; + +/** Uses the installed historical mod's own writer; never part of a release jar. */ +@Mod(modid="lootablebodiesbuildtest",name="Lootable Bodies World Fixtures",version="1",dependencies="required-after:lootablebodies") +public final class LegacyWorldSeeder { + private static final UUID PLAYER=UUID.fromString("00000000-2222-4222-8222-000000000001"); + private static boolean oldMinecraft,native112; + @Mod.EventHandler public void preInit(FMLPreInitializationEvent event){FixtureItems.register();} + @Mod.EventHandler public void started(FMLServerStartedEvent event) { + MinecraftServer server=FMLCommonHandler.instance().getMinecraftServerInstance(); + try {seed(server);} catch(Throwable error) {error.printStackTrace();} finally {server.initiateShutdown();} + } + private static void seed(MinecraftServer server) throws Exception { + String minecraft=server.getMinecraftVersion(),version=Loader.instance().getIndexedModList().get("lootablebodies").getVersion(); + oldMinecraft=minecraft.equals("1.10.2");native112=minecraft.equals("1.12.2"); + if(!oldMinecraft && !minecraft.equals("1.11.2") && !minecraft.equals("1.12.2")) throw new AssertionError("Wrong fixture Minecraft "+minecraft); + if(!version.equals(System.getProperty("lootablebodies.fixture.version"))) throw new AssertionError("Wrong historical mod "+version); + LootableBodies.allowCorpseDecay=false;LootableBodies.ticksPerItemDecay=-1; + NBTTagCompound manifest=new NBTTagCompound();manifest.setString("Minecraft",minecraft);manifest.setString("ModVersion",version); + NBTTagList bodies=new NBTTagList(),containers=new NBTTagList(),vanilla=new NBTTagList(); + for(int dimension:new int[]{0,-1,1}) { + WorldServer world=server.getWorld(dimension); + for(int wave=0;wave<3;wave++) { + int coordinate=wave==0?128:wave==1?4096:8192; + world.getChunk(coordinate>>4,coordinate>>4); + EntityLootableBody body=new EntityLootableBody(world); + body.setUniqueId(UUID.nameUUIDFromBytes((minecraft+"/"+version+"/"+dimension+"/"+wave).getBytes("UTF-8"))); + body.setPosition(coordinate+.5,80,coordinate+.5);body.setNoGravity(true);body.setUserName("SavedFixtureOwner");body.setRotation(37); + for(int slot=0;slot<48;slot++)body.setInventorySlotContents(slot,item(slot)); + for(int slot=70;slot<73;slot++)body.addItem(item(slot)); + if(Boolean.getBoolean("lootablebodies.fixture.recovery")) { + if(!version.equals("2.4.1.110021") && !version.equals("2.4.2.111021")) throw new AssertionError("Recovery fixtures require a checksum-pinned maintained writer"); + NBTTagCompound damaged=new NBTTagCompound();body.writeToNBTOptional(damaged); + NBTTagList equipment=damaged.getTagList("Equipment",10); + equipment.getCompoundTagAt(20).setString("id","lootablebodiesbuildtest:returning_item"); + NBTTagCompound duplicate=equipment.getCompoundTagAt(21).copy();duplicate.setByte("Count",(byte)9);duplicate.getCompoundTag("tag").setString("extension","duplicate slot");equipment.appendTag(duplicate); + NBTTagCompound invalid=equipment.getCompoundTagAt(22).copy();invalid.setInteger("Slot",999);invalid.setByte("Count",(byte)7);invalid.getCompoundTag("tag").setString("extension","invalid slot");equipment.appendTag(invalid); + damaged.setInteger("Name",17);damaged.setDouble("DeathTime",Double.NaN);damaged.setString("Health","opaque health"); + damaged.setString("ActiveEffects","opaque effects");damaged.setString("HurtTime","opaque hurt timer");damaged.setDouble("FallDistance",Double.POSITIVE_INFINITY); + body.readFromNBT(damaged);body.setPosition(coordinate+.5,80,coordinate+.5); + } + java.lang.reflect.Field vacuum=EntityLootableBody.class.getDeclaredField("vacuumTime");vacuum.setAccessible(true);vacuum.setByte(body,(byte)20); + if(!world.spawnEntity(body))throw new AssertionError("Historical fixture corpse spawn"); + NBTTagCompound record=new NBTTagCompound(),payload=new NBTTagCompound(); + if(!body.writeToNBTOptional(payload))throw new AssertionError("Historical corpse writer"); + record.setInteger("Dimension",dimension);record.setInteger("Wave",wave);record.setTag("Body",payload);bodies.appendTag(record); + BlockPos chestPos=new BlockPos(coordinate+2,80,coordinate+2);world.setBlockState(chestPos,Blocks.CHEST.getDefaultState()); + TileEntityChest chest=(TileEntityChest)world.getTileEntity(chestPos);for(int slot=0;slot<16;slot++)chest.setInventorySlotContents(slot,item(slot+6)); + NBTTagCompound chestRecord=new NBTTagCompound(),chestNbt=new NBTTagCompound(); + chest.writeToNBT(chestNbt); + chestRecord.setInteger("Dimension",dimension);chestRecord.setInteger("Wave",wave);chestRecord.setTag("Tile",chestNbt);containers.appendTag(chestRecord); + if(wave==0) { + EntityPig pig=new EntityPig(world);pig.setUniqueId(UUID.nameUUIDFromBytes((minecraft+"/pig/"+dimension).getBytes("UTF-8")));pig.setPosition(coordinate+4,82,coordinate+4);pig.setNoGravity(true);pig.setNoAI(true);pig.setCustomNameTag("Saved pig");world.spawnEntity(pig); + NBTTagCompound p=new NBTTagCompound();pig.writeToNBTOptional(p);NBTTagCompound v=new NBTTagCompound();v.setInteger("Dimension",dimension);v.setString("ExpectedType","minecraft:pig");v.setTag("Entity",p);vanilla.appendTag(v); + EntityArmorStand stand=new EntityArmorStand(world);stand.setUniqueId(UUID.nameUUIDFromBytes((minecraft+"/stand/"+dimension).getBytes("UTF-8")));stand.setPosition(coordinate+5,82,coordinate+5);stand.setNoGravity(true);stand.setItemStackToSlot(EntityEquipmentSlot.HEAD,item(0));world.spawnEntity(stand); + p=new NBTTagCompound();stand.writeToNBTOptional(p);v=new NBTTagCompound();v.setInteger("Dimension",dimension);v.setString("ExpectedType","minecraft:armor_stand");v.setTag("Entity",p);vanilla.appendTag(v); + EntityMinecartChest cart=new EntityMinecartChest(world,coordinate+6,82,coordinate+6);cart.setUniqueId(UUID.nameUUIDFromBytes((minecraft+"/cart/"+dimension).getBytes("UTF-8")));cart.setNoGravity(true);for(int slot=0;slot<8;slot++)cart.setInventorySlotContents(slot,item(slot+6));world.spawnEntity(cart); + p=new NBTTagCompound();cart.writeToNBTOptional(p);v=new NBTTagCompound();v.setInteger("Dimension",dimension);v.setString("ExpectedType","minecraft:chest_minecart");v.setTag("Entity",p);vanilla.appendTag(v); + } + } + } + WorldServer world=server.getWorld(0); + EntityPlayerMP player=new EntityPlayerMP(server,world,new GameProfile(PLAYER,"UpgradeFixture"),new PlayerInteractionManager(world));player.setPosition(136.5,82,136.5); + for(int slot=0;slot<36;slot++)player.inventory.setInventorySlotContents(slot,item(slot+6)); + for(int slot=0;slot<4;slot++)player.setItemStackToSlot(new EntityEquipmentSlot[]{EntityEquipmentSlot.HEAD,EntityEquipmentSlot.CHEST,EntityEquipmentSlot.LEGS,EntityEquipmentSlot.FEET}[slot],item(slot)); + player.setItemStackToSlot(EntityEquipmentSlot.OFFHAND,item(5));for(int slot=0;slot<27;slot++)player.getInventoryEnderChest().setInventorySlotContents(slot,item(slot+6)); + world.getSaveHandler().getPlayerNBTManager().writePlayerData(player); + manifest.setUniqueId("Player",PLAYER);manifest.setTag("Bodies",bodies);manifest.setTag("Containers",containers);manifest.setTag("Vanilla",vanilla); + server.saveAllWorlds(false);net.minecraft.world.storage.ThreadedFileIOBase.getThreadedIOInstance().waitForFinish(); + File folder=world.getSaveHandler().getWorldDirectory();try(FileOutputStream file=new FileOutputStream(new File(folder,"upgrade-fixtures.nbt"))){CompressedStreamTools.writeCompressed(manifest,file);} + try(FileWriter marker=new FileWriter(new File(folder,"legacy-seed.pass"))){marker.write(minecraft+" "+version+"\n");} + System.out.println("LOOTABLE_BODIES_LEGACY_SEED_PASS minecraft="+minecraft+" mod="+version+" corpses=9 dimensions=3"); + } + private static ItemStack item(int slot) { + ItemStack stack; + if(slot<4)stack=new ItemStack(new Item[]{Items.DIAMOND_HELMET,Items.DIAMOND_CHESTPLATE,Items.DIAMOND_LEGGINGS,Items.DIAMOND_BOOTS}[slot],1,slot+2); + else if(slot==4)stack=new ItemStack(Items.DIAMOND_SWORD,1,17); + else if(slot==5)stack=new ItemStack(Items.SHIELD,1,9); + else if(slot==6)stack=new ItemStack(Items.SPAWN_EGG,3); + else if(slot==7)stack=new ItemStack(Item.getItemFromBlock(Blocks.CHEST),2); + else if(slot==8)stack=new ItemStack(Items.BANNER,2,oldMinecraft?0:5); + else if(slot==9)stack=new ItemStack(FixtureItems.keepsake,11); + else if(slot==10)stack=new ItemStack(Items.BED,1,0); + else if(slot==11 && native112)stack=new ItemStack(Items.BED,1,5); + else stack=new ItemStack(Items.DIAMOND,slot%32+1); + NBTTagCompound tag=new NBTTagCompound();tag.setString("fixture_slot","original-"+slot);tag.setLong("typed_long",1234567890123L);tag.setByteArray("typed_bytes",new byte[]{0,-1,17}); + if(slot==6){NBTTagCompound pig=new NBTTagCompound();pig.setString("id",oldMinecraft?"Pig":"minecraft:pig");pig.setString("CustomName","Saved pig egg");tag.setTag("EntityTag",pig);} + if(slot==7){NBTTagCompound tile=new NBTTagCompound();NBTTagList items=new NBTTagList();NBTTagCompound nested=new ItemStack(Items.EMERALD,17).writeToNBT(new NBTTagCompound());nested.setByte("Slot",(byte)3);items.appendTag(nested);tile.setTag("Items",items);tag.setTag("BlockEntityTag",tile);} + if(slot==8 && oldMinecraft){NBTTagCompound banner=new NBTTagCompound();banner.setInteger("Base",5);tag.setTag("BlockEntityTag",banner);} + if(native112) { + try {tag.setTag("typed_longs",(NBTBase)Class.forName("net.minecraft.nbt.NBTTagLongArray").getConstructor(long[].class).newInstance((Object)new long[]{Long.MIN_VALUE,slot,Long.MAX_VALUE}));} + catch(ReflectiveOperationException failure){throw new IllegalStateException("Native 1.12 long-array fixture",failure);} + } + stack.setTagCompound(tag);stack.setStackDisplayName("Original keepsake "+slot);if(slot==4)stack.addEnchantment(Enchantments.UNBREAKING,2);return stack; + } +} 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/ItemStacks.java b/src/main/java/cyano/lootable/ItemStacks.java new file mode 100644 index 0000000..3eaac03 --- /dev/null +++ b/src/main/java/cyano/lootable/ItemStacks.java @@ -0,0 +1,7 @@ +package cyano.lootable; +import net.minecraft.item.ItemStack; +/** Shared guard for legacy integrations and the native empty-stack sentinel. */ +public final class ItemStacks { + private ItemStacks() { } + public static boolean emptyStack(ItemStack stack) { return stack == null || stack.isEmpty(); } +} diff --git a/src/main/java/cyano/lootable/LootableBodies.java b/src/main/java/cyano/lootable/LootableBodies.java index d9cfcd7..a479047 100644 --- a/src/main/java/cyano/lootable/LootableBodies.java +++ b/src/main/java/cyano/lootable/LootableBodies.java @@ -1,11 +1,14 @@ package cyano.lootable; +import net.minecraft.entity.Entity; +import cyano.lootable.upgrade.UpgradePreservation; +import net.minecraftforge.fml.common.FMLCommonHandler; +import net.minecraftforge.fml.common.event.FMLServerAboutToStartEvent; +import net.minecraftforge.fml.common.event.FMLServerStoppedEvent; import cyano.lootable.entities.EntityLootableBody; import cyano.lootable.events.PlayerDeathEventHandler; import cyano.lootable.graphics.GUIHandler; -import net.minecraft.entity.Entity; -import net.minecraft.util.ResourceLocation; import net.minecraftforge.common.MinecraftForge; import net.minecraftforge.common.config.Configuration; import net.minecraftforge.fml.common.Mod; @@ -16,14 +19,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.12,)") + acceptedMinecraftVersions = "[1.12.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.4.0"; + public static final String VERSION = "2.4.3.112021"; private static LootableBodies INSTANCE = null; + private static Configuration configuration; public static boolean displayNameTag = true; public static boolean addBonesToCorpse = true; @@ -34,26 +40,34 @@ public class LootableBodies { public static boolean hurtByMisc = false; public static boolean completelyInvulnerable = false; public static float corpseHP = 20; - + public static boolean allowCorpseDecay = true; public static boolean decayOnlyWhenEmpty = true; public static long corpseDecayTime = 3600*20; // in game ticks - - public static boolean corpseBuoyancy = false; - public static boolean voidPlatform= false; - public static boolean dimensionSpecificPlatform = true; - - @SidedProxy(clientSide="cyano.lootable.ClientProxy", serverSide="cyano.lootable.ServerProxy") + + public static boolean corpseBuoyancy = false; + public static boolean voidPlatform = false; + public static boolean dimensionSpecificPlatform = true; + + @SidedProxy(clientSide="cyano.lootable.ClientProxy", serverSide="cyano.lootable.ServerProxy") public static Proxy proxy; - - + + // Mark this method for receiving an FMLEvent (in this case, it's the FMLPreInitializationEvent) @EventHandler public void preInit(FMLPreInitializationEvent event) { + UpgradePreservation.register(FMLCommonHandler.instance().getDataFixer()); 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, @@ -66,7 +80,7 @@ public class LootableBodies { + "location: assets/"+MODID+"/textures/entity/corpse/corpse.png"); displayNameTag = config.getBoolean("display_nametag", "options", displayNameTag, "If true, corpses will show their owner's name"); - + addBonesToCorpse = config.getBoolean("add_bones_to_corpse", "options", addBonesToCorpse, "If true, corpses will have bones and rotten flesh added to them."); @@ -86,7 +100,7 @@ public class LootableBodies { + "for every X number of minutes (default is 5 minutes) that they are. \n" + "Items damaged in this way will never be completely destroyed. \n" + "Set to 0 (or negative) to disable.")); - + allowCorpseDecay = config.getBoolean("enable_corpse_decay", "corpse decay", allowCorpseDecay, "If true, corpses will self-destruct after a period of time."); decayOnlyWhenEmpty = config.getBoolean("empty_only_decay", "corpse decay", decayOnlyWhenEmpty, @@ -100,7 +114,7 @@ public class LootableBodies { + "enable_corpse_decay option is set to true). \n" + "The format is hours:minutes:seconds or just hours:minutes"); corpseDecayTime = Math.max(parseTimeInSeconds(decayTime),2)*20; // 2 second minimum - + corpseBuoyancy = config.getBoolean("enable_corpse_buoyancy","corpse recovery", corpseBuoyancy, "If true, corpses will float in fluids."); voidPlatform = config.getBoolean("prevent_void_loss", "corpse recovery", voidPlatform, @@ -108,12 +122,37 @@ public class LootableBodies { dimensionSpecificPlatform= config.getBoolean("dim_specific_platform", "corpse recovery", dimensionSpecificPlatform, "If true, corpses generate dimension specific platforms, otherwise they only make cobblestone platforms"); + for (String category : new String[] {"options", "corpse damage", "corpse decay", "corpse recovery"}) { + 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", + "enable_corpse_buoyancy", "prevent_void_loss", "dim_specific_platform").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()); @@ -127,22 +166,22 @@ private int parseTimeInSeconds(String time) { public void init(FMLInitializationEvent event) { // register entities registerItemRenders(); - + registerEntity(EntityLootableBody.class); MinecraftForge.EVENT_BUS.register(new PlayerDeathEventHandler()); NetworkRegistry.INSTANCE.registerGuiHandler( LootableBodies.getInstance(), GUIHandler.getInstance()); - + proxy.init(event); - - + + } private int entityIndex = 0; - private void registerEntity(Class entityClass){ + private void registerEntity(Class entityClass){ String idName = "Corpse"; - EntityRegistry.registerModEntity(new ResourceLocation("lootablebodies",idName), entityClass, idName, entityIndex++/*mod-specific entity id*/, this, 32/*trackingRange*/, 1/*updateFrequency*/, true/*sendsVelocityUpdates*/); - + EntityRegistry.registerModEntity(new net.minecraft.util.ResourceLocation("lootablebodies:corpse"), (Class)entityClass, idName, entityIndex++/*mod-specific entity id*/, this, 32/*trackingRange*/, 1/*updateFrequency*/, true/*sendsVelocityUpdates*/, entityIndex, entityIndex); + } - + private void registerItemRenders() { // client-side only if(proxy instanceof ServerProxy) return; @@ -150,7 +189,7 @@ private void registerItemRenders() { } - + @EventHandler public void postInit(FMLPostInitializationEvent event) { proxy.postInit(event); } @@ -158,15 +197,26 @@ private void registerItemRenders() { public static LootableBodies getInstance(){ return INSTANCE; } - - + + @EventHandler public void beforeWorldLoading(FMLServerAboutToStartEvent event) { + java.io.File world=event.getServer().getActiveAnvilConverter().getFile(event.getServer().getFolderName(), ""); + try { + int protectedBodies=UpgradePreservation.prepare(world.toPath()).size(); + net.minecraftforge.fml.common.FMLLog.info("Lootable Bodies preserved %s existing corpses before world loading",protectedBodies); + } catch(java.io.IOException unsafe) { + throw new IllegalStateException("Lootable Bodies could not preserve this world's corpses. World loading has been stopped; keep the original world and recovery journal.",unsafe); + } + } + @EventHandler public void worldStopped(FMLServerStoppedEvent event) {UpgradePreservation.close();} + + private static boolean or(boolean... bools){ for(int i = 0; i < bools.length; i++){ if(bools[i] == true) return true; } return false; } - + private static boolean and(boolean... bools){ for(int i = 0; i < bools.length; i++){ if(bools[i] == false) return false; diff --git a/src/main/java/cyano/lootable/entities/EntityLootableBody.java b/src/main/java/cyano/lootable/entities/EntityLootableBody.java index 9c97fc2..ee328ac 100644 --- a/src/main/java/cyano/lootable/entities/EntityLootableBody.java +++ b/src/main/java/cyano/lootable/entities/EntityLootableBody.java @@ -1,28 +1,39 @@ package cyano.lootable.entities; +import static cyano.lootable.ItemStacks.emptyStack; +import cyano.lootable.upgrade.UpgradePreservation; +import cyano.lootable.upgrade.ItemMigration; + import com.mojang.authlib.GameProfile; import cyano.lootable.LootableBodies; import net.minecraft.block.material.Material; import net.minecraft.block.state.IBlockState; +import net.minecraft.init.Blocks; 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.init.Blocks; import net.minecraft.inventory.EntityEquipmentSlot; import net.minecraft.inventory.IInventory; 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.EnumFacing; import net.minecraft.util.NonNullList; 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; @@ -31,7 +42,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,23 +57,53 @@ public class EntityLootableBody extends EntityLiving implements IInventory{ final static byte VACUUM_TIMELIMIT = 20; final static int VACUUM_RADIUS = 3; - + private final NonNullList mainInventory = NonNullList.withSize(6*7, ItemStack.EMPTY); private final Deque auxInventory = new LinkedList<>(); + private static final String UNRESOLVED_ITEMS_TAG = "LootableBodiesUnresolved"; + private final List unresolvedInventory = new ArrayList<>(); + private NBTTagCompound sourceVersions = new NBTTagCompound(); + private NBTTagCompound inventoryVersions = new NBTTagCompound(); + 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); @@ -77,8 +123,9 @@ public EntityLootableBody(EntityPlayer player){ //this.newPosZ = posZ; // no newPosZ? this.markDirty(); } - - + + + private void log(String format, Object... o){ FMLLog.info("%s: %s",getClass().getSimpleName(), String.format(format, o)); } @@ -88,6 +135,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); @@ -97,7 +159,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){ @@ -116,8 +182,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); @@ -126,7 +192,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; @@ -141,9 +212,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)); } } @@ -151,8 +223,8 @@ public void onEntityUpdate(){ // move items from buffer to main inventory if(isServer && !auxInventory.isEmpty()) { for (int slot = this.getSizeInventory() - 1; slot >= EQUIPMENT_SLOTS.length; slot--) { - if(!this.getStackInSlot(slot).isEmpty()){ - this.setInventorySlotContents(slot,auxInventory.pollFirst()); + if(emptyStack(this.getStackInSlot(slot))){ + if(emptyStack(mergeItem(slot,auxInventory.peekFirst()))) auxInventory.removeFirst(); this.markDirty(); break; } @@ -166,14 +238,13 @@ 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.getItem(); this.addItem(stack); - } - for(EntityItem item : items){ getEntityWorld().removeEntity(item); } } - + if(isServer && LootableBodies.corpseBuoyancy && vacuumTime >= VACUUM_TIMELIMIT && (this.inWater || this.isInLava())) { this.motionY += 0.025D; this.motionY *= 0.9; @@ -185,7 +256,7 @@ public void onEntityUpdate(){ } } - + @Override public boolean handleWaterMovement() { if (this.world.handleMaterialAcceleration(this.getEntityBoundingBox().grow(0.0D, -0.4000000059604645D, 0.0D).shrink(0.001D), Material.WATER, this)) { @@ -234,7 +305,7 @@ private void decayItems(int amount) { } private ItemStack decayItem(ItemStack item, int amount) { - if(!item.isEmpty() + if(!emptyStack(item) && item.getCount() == 1 && item.isItemStackDamageable() && item.getItem().isDamageable()){ @@ -249,30 +320,57 @@ private ItemStack decayItem(ItemStack item, int amount) { public void addItem(ItemStack stack) { for(int slot = EQUIPMENT_SLOTS.length; slot < this.getSizeInventory(); slot++){ stack = mergeItem(slot,stack); - if(stack.isEmpty()) return; + if(emptyStack(stack)) return; } auxInventory.addLast(stack); + markDirty(); } + /** Retain an item whose container release was rejected. Uses the existing Aux format. */ + public void retainItem(ItemStack stack) { + if(emptyStack(stack) || stack.getCount() <= 0) return; + if(terminate >= 0 || !isEntityAlive()) cancelRemoval(); + addItem(stack); + } - public void initializeItems(NonNullList inv) { - - for(int i = 0; i < inv.size(); i++) { - if(i < mainInventory.size()) - mainInventory.set(i,inv.get(i)); - else - auxInventory.addLast(inv.get(i)); - } - + private void cancelRemoval() { + terminate = -1; + deathTime = 0; + dead = false; + isDead = false; + setHealth(getMaxHealth()); + vacuumTime = VACUUM_TIMELIMIT; + markDirty(); } + + public void initializeItems(ItemStack[] inv) { + initializeItems(Arrays.asList(inv)); + } + + public void initializeItems(NonNullList inv) { + initializeItems((List)inv); + } + + public void initializeItems(List inv) { + mainInventory.clear(); + for(int i=0; i 0) this.setInventorySlotContents(slot,stack.splitStack(amount)); + return stack.getCount() > 0 ? stack : ItemStack.EMPTY; } else if(current.getCount() < inventorySizeLimit && ItemStack.areItemsEqual(stack,current) && ItemStack.areItemStackTagsEqual(stack,current)){ @@ -293,7 +391,7 @@ protected void damageEntity(DamageSource src, float amount){ if(src instanceof EntityDamageSource && src.getTrueSource() instanceof EntityPlayer){ EntityPlayer player = (EntityPlayer)src.getTrueSource(); ItemStack item = player.getHeldItem(EnumHand.MAIN_HAND); - if(!item.isEmpty()) { + if(!emptyStack(item)) { if (item.getItem() instanceof ItemSpade || item.getItem().getToolClasses(item).contains("shovel")) { this.onKillCommand(); } @@ -336,6 +434,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); } @@ -370,63 +490,43 @@ protected boolean canDespawn() { } public void jumpOutOfWall(){ - log("Escaping from wall at position %s",this.getPosition()); - BlockPos currentCoord = this.getPosition(); - // first try going out to the nearest adjacent block - double[] vector = new double[3]; - vector[0] = currentCoord.getX()+0.5 - this.posX; - vector[1] = 0; - vector[2] = currentCoord.getZ()+0.5 - this.posZ; - double normalizer = 1.0/Math.sqrt(vector[0]*vector[0]+vector[1]*vector[1]+vector[2]*vector[2]); - vector[0] *= normalizer; - vector[1] *= normalizer; - vector[2] *= normalizer; - IBlockState bs = world.getBlockState(new BlockPos(this.posX+vector[0], this.posY+vector[1], this.posZ+vector[2])); - if(!(bs.getMaterial().blocksMovement())){ - this.setPosition(this.posX+vector[0], this.posY+vector[1], this.posZ+vector[2]); - return; + if(world.isRemote) return; + AxisAlignedBB bounds = getEntityBoundingBox(); + // Collision queries omit unloaded chunks. They cannot establish an exit + // until the complete local terrain (including tall neighbour shapes) exists. + if(!escapeTerrainLoaded(bounds) || world.getCollisionBoxes(this,bounds).isEmpty()) return; + BlockPos currentCoord = getPosition(); + double x = currentCoord.getX()+0.5 - posX; + double z = currentCoord.getZ()+0.5 - posZ; + double length = Math.hypot(x,z); + // At a block centre there is no horizontal direction to normalize. + if(length > 0 && tryWallExit(posX+x/length,posY,posZ+z/length)) return; + + // Retain the original adjacent-exit priority, but check the whole body. + for(EnumFacing direction : new EnumFacing[]{EnumFacing.UP,EnumFacing.NORTH, + EnumFacing.EAST,EnumFacing.SOUTH,EnumFacing.WEST,EnumFacing.DOWN}) { + BlockPos exit = currentCoord.offset(direction); + if(tryWallExit(exit.getX()+0.5,exit.getY()+0.015625,exit.getZ()+0.5)) return; } + // A sealed corpse must remain recoverable here, rather than wander through + // solid terrain or load chunks looking for an unchecked random destination. + } - // then try finding an open space in all adjacent blocks - BlockPos n; - n = currentCoord.up(); - if(!(world.getBlockState(n).getMaterial().blocksMovement())){ - this.setPosition(n.getX()+0.5,n.getY()+0.015625, n.getZ()+0.5); - return; - } - n = currentCoord.north(); - if(!(world.getBlockState(n).getMaterial().blocksMovement())){ - this.setPosition(n.getX()+0.5,n.getY()+0.015625, n.getZ()+0.5); - return; - } - n = currentCoord.east(); - if(!(world.getBlockState(n).getMaterial().blocksMovement())){ - this.setPosition(n.getX()+0.5,n.getY()+0.015625, n.getZ()+0.5); - return; - } - n = currentCoord.south(); - if(!(world.getBlockState(n).getMaterial().blocksMovement())){ - this.setPosition(n.getX()+0.5,n.getY()+0.015625, n.getZ()+0.5); - return; - } - n = currentCoord.west(); - if(!(world.getBlockState(n).getMaterial().blocksMovement())){ - this.setPosition(n.getX()+0.5,n.getY()+0.015625, n.getZ()+0.5); - return; - } - n = currentCoord.down(); - if(!(world.getBlockState(n).getMaterial().blocksMovement())){ - this.setPosition(n.getX()+0.5,n.getY()+0.015625, n.getZ()+0.5); - return; - } - // then if the above fails, move 2 blocks in a random direction - vector[0] = world.rand.nextDouble() * 2; - vector[1] = world.rand.nextDouble() * 2; - vector[2] = world.rand.nextDouble() * 2; - this.setPosition(this.posX+vector[0], this.posY+vector[1], this.posZ+vector[2]); - log("jumped to %s",this.getPosition()); + private boolean escapeTerrainLoaded(AxisAlignedBB bounds) { + return world.isAreaLoaded(new BlockPos(bounds.minX-1,0,bounds.minZ-1), + new BlockPos(bounds.maxX+1,0,bounds.maxZ+1)); } - + + private boolean tryWallExit(double x,double y,double z) { + AxisAlignedBB bounds = getEntityBoundingBox().offset(x-posX,y-posY,z-posZ); + if(bounds.minY < 0 || bounds.maxY > world.getHeight() || + !world.getWorldBorder().contains(bounds) || !escapeTerrainLoaded(bounds) || + !world.getCollisionBoxes(this,bounds).isEmpty()) return false; + setPosition(x,y,z); + return true; + } + + @Override public void onKillCommand() { if(terminate < 0) terminate = DEATH_COUNTDOWN; @@ -462,6 +562,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; @@ -520,11 +625,13 @@ public ItemStack getStackInSlot(int index) { */ @Override public void setInventorySlotContents(int index, ItemStack stack) { + if(stack == null) stack = ItemStack.EMPTY; if(index < EQUIPMENT_SLOTS.length){ super.setItemStackToSlot(EQUIPMENT_SLOTS[index], stack); } else { mainInventory.set(index - EQUIPMENT_SLOTS.length, stack); } + markDirty(); } /** @@ -535,7 +642,7 @@ public void setInventorySlotContents(int index, ItemStack stack) { @Override public ItemStack removeStackFromSlot(int index) { ItemStack i = this.getStackInSlot(index); - this.setInventorySlotContents(index,ItemStack.EMPTY); + this.setInventorySlotContents(index,null); return i; } @@ -548,6 +655,7 @@ public ItemStack removeStackFromSlot(int index) { @Override public ItemStack decrStackSize(int index, int count) { ItemStack i = this.getStackInSlot(index); + if(emptyStack(i) || count <= 0) return ItemStack.EMPTY; ItemStack result = i.splitStack(count); if (i.getCount() <= 0) { i = ItemStack.EMPTY; @@ -570,7 +678,9 @@ public int getInventoryStackLimit() { */ @Override public void markDirty() { - // do nothing + if(world != null && !world.isRemote && addedToChunk) { + world.markChunkDirty(getPosition(),null); + } } /** @@ -580,13 +690,15 @@ 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 - protected boolean processInteract(EntityPlayer player, EnumHand hand){ - if(player.getPosition().distanceSq(this.getPosition()) < 25) { - FMLNetworkHandler.openGui(player, LootableBodies.getInstance(), 0, getEntityWorld(), getEntityId(), 0, 0); + public boolean processInteract(EntityPlayer player, EnumHand hand){ + if(isUsableByPlayer(player)) { + FMLNetworkHandler.openGui(player, LootableBodies.getInstance(), 0, getEntityWorld(), this.getEntityId(), 0, 0); return true; } return false; @@ -608,6 +720,7 @@ public void closeInventory(EntityPlayer player) { */ @Override public boolean isItemValidForSlot(int index, ItemStack stack) { + if(emptyStack(stack) || stack.getCount() <= 0) return false; if(index < 4){ return stack.getItem().isValidArmor (stack, EQUIPMENT_SLOTS[index], this); } else { @@ -633,34 +746,39 @@ public int getFieldCount() { @Override public void clear() { for(int i = 0; i < EQUIPMENT_SLOTS.length; i++){ - this.setInventorySlotContents(i,ItemStack.EMPTY); + this.setInventorySlotContents(i,null); } - mainInventory.clear(); 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).isEmpty()) return false; + if(!emptyStack(this.getStackInSlot(slot))) return false; } return true; } - private void dropAllItems(){ - for(EntityEquipmentSlot e : EQUIPMENT_SLOTS){ - ItemStack i = this.getItemStackFromSlot(e); - if(!i.isEmpty())this.getEntityWorld().spawnEntity(new EntityItem(this.getEntityWorld(),posX,posY,posZ, - i)); - }for(ItemStack i : mainInventory){ - if(!i.isEmpty())this.getEntityWorld().spawnEntity(new EntityItem(this.getEntityWorld(),posX,posY,posZ,i)); - }for(ItemStack i : auxInventory){ - if(!i.isEmpty())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(!emptyStack(stack) && 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(emptyStack(stack) || getEntityWorld().spawnEntity( + new EntityItem(getEntityWorld(),posX,posY,posZ,stack.copy()))) iterator.remove(); + } + markDirty(); + return isEmpty(); } private static boolean matchesAny(String damageType, DamageSource... list){ @@ -679,11 +797,16 @@ public EnumCreatureAttribute getCreatureAttribute() @Override public void writeEntityToNBT(final NBTTagCompound root) { + UpgradePreservation.clearTransient(root); + root.setInteger(UpgradePreservation.VERSION,UpgradePreservation.DATA_VERSION); + if(sourceVersions.isEmpty()) root.removeTag(UpgradePreservation.SOURCES); + else root.setTag(UpgradePreservation.SOURCES,sourceVersions.copy()); + root.removeTag("ActiveEffects"); super.writeEntityToNBT(root); final NBTTagList equipmentListTag = new NBTTagList(); for (int i = 0; i < this.getSizeInventory(); i++) { ItemStack item = this.getStackInSlot(i); - if (!item.isEmpty()) { + if (!emptyStack(item)) { NBTTagCompound slotTag = new NBTTagCompound(); slotTag.setByte("Slot", (byte)i); item.writeToNBT(slotTag); @@ -692,60 +815,483 @@ 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(); while (iter.hasNext()) { ItemStack i = iter.next(); - if(i.isEmpty()) continue; + if(emptyStack(i)) continue; final NBTTagCompound nbttagcompound1 = new NBTTagCompound(); i.writeToNBT(nbttagcompound1); nbtauxtaglist.appendTag(nbttagcompound1); } 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 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 = new ItemStack(slotTag); - this.setInventorySlotContents(slot,item); + public void readFromNBT(final NBTTagCompound supplied) { + final NBTTagCompound root=UpgradePreservation.restore(supplied); + // 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)); } - if(root.hasKey("Aux")){ - final NBTTagList nbttaglist = root.getTagList("Aux", 10); - for (int i = 0; i < nbttaglist.tagCount(); ++i) { - this.auxInventory.addLast(new ItemStack(nbttaglist.getCompoundTagAt(i))); + 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 supplied) { + final NBTTagCompound root=UpgradePreservation.restore(supplied); + sourceVersions=root.hasKey(UpgradePreservation.SOURCES,10) ? root.getCompoundTag(UpgradePreservation.SOURCES).copy() : new NBTTagCompound(); + // Persisted Equipment/Aux are already current after the first save. + // Only the transient protected original and retained raw records use old versions. + inventoryVersions=root.getBoolean(UpgradePreservation.ITEMS_PENDING) ? sourceVersions : new NBTTagCompound(); + // 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("Vac")){ - this.vacuumTime = root.getByte("Vac"); - } else { - this.vacuumTime = VACUUM_TIMELIMIT; + 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.getKeySet().size() == 3 && record.hasKey("SourceVersions",10))) + && 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","CurativeItems").containsAll(record.getKeySet()); + // Forge 1.11 persists curative stacks. Preserve their complete custom + // data; an unreadable list uses Forge's defaults and stays recoverable. + if(record.hasKey("CurativeItems")) { + NBTBase rawCuratives = record.getTag("CurativeItems"); + boolean valid = isInventoryList(rawCuratives); + if(valid) { + NBTTagList items = (NBTTagList)rawCuratives; + for(int c=0; c= 0 && flag <= 1) accepted.setBoolean(key,flag == 1); + else { accepted.setBoolean(key,fallback); changed = true; } + } + if(changed) retainInvalid("ActiveEffect",record); + clean.appendTag(accepted); + } + 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; + } + 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); } - if(root.hasKey("DeathTime")){ - this.setDeathTimestamp(root.getLong("DeathTime")); + 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()); + if(!inventoryVersions.isEmpty()) record.setTag("SourceVersions",inventoryVersions.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 = new ItemStack(input); + if(emptyStack(stack) || stack.getItem() != registered || stack.getCount() != 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) { + NBTTagCompound context=retained!=null && retained.hasKey("SourceVersions",10) ? retained.getCompoundTag("SourceVersions") : inventoryVersions; + NBTTagCompound converted=ItemMigration.convert(raw,context); + ItemStack stack = converted==null ? null : decodeInventoryItem(converted); + if(emptyStack(stack)) { + if(retained == null) retainRaw(source,raw); + else { + NBTTagCompound copy=retained.copy(); + if(!copy.hasKey("SourceVersions") && !context.isEmpty()) copy.setTag("SourceVersions",context.copy()); + unresolvedInventory.add(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(emptyStack(getStackInSlot(slot)) && isItemValidForSlot(slot,stack)) { + setInventorySlotContents(slot,stack.splitStack(Math.min(stack.getCount(),slot < 4 ? 1 : limit))); + } + } + } + // Keep each record, including identical duplicates. Existing overflow + // handles later GUI recovery without overwriting any indexed slot. + while(stack.getCount() > 0) auxInventory.addLast(stack.splitStack(Math.min(stack.getCount(),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 805707f..5b1cb97 100644 --- a/src/main/java/cyano/lootable/events/PlayerDeathEventHandler.java +++ b/src/main/java/cyano/lootable/events/PlayerDeathEventHandler.java @@ -1,5 +1,7 @@ package cyano.lootable.events; +import static cyano.lootable.ItemStacks.emptyStack; + import cyano.lootable.LootableBodies; import cyano.lootable.entities.EntityLootableBody; import net.minecraft.command.server.CommandSummon; @@ -9,9 +11,9 @@ import net.minecraft.inventory.EntityEquipmentSlot; import net.minecraft.item.ItemStack; import net.minecraft.util.EnumHandSide; -import net.minecraft.util.NonNullList; 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; @@ -19,24 +21,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.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) + @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(!emptyStack(stack)) cache.put(stack,slot); } if(player.getPrimaryHand() == EnumHandSide.LEFT){ @@ -44,55 +58,58 @@ 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); - - NonNullList items = NonNullList.create(); + + List items = new ArrayList<>(); + Set assignedSlots = EnumSet.noneOf(EntityEquipmentSlot.class); for (EntityItem itemEntity : e.getDrops()) { ItemStack item = itemEntity.getItem(); - if (!item.isEmpty() && cache.containsKey(item)) { - corpse.setItemStackToSlot(cache.get(item),item); + if(emptyStack(item) || item.getCount() <= 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); + corpse.initializeItems(items.toArray(new ItemStack[0])); if(LootableBodies.addBonesToCorpse){ corpse.addItem(new ItemStack(Items.BONE,1+w.rand.nextInt(3))); corpse.addItem(new ItemStack(Items.ROTTEN_FLESH,1+w.rand.nextInt(3))); } - w.spawnEntity(corpse); - w.updateEntityWithOptionalForce(corpse, false); - - e.getDrops().clear(); - - equipmentCache.remove(e.getEntity()); - + if(w.spawnEntity(corpse)) { + e.getDrops().clear(); + } else { + FMLLog.warning("%s: Corpse spawn rejected; original death drops retained", LootableBodies.MODID); + } } } - - @SuppressWarnings("unused") + + private static void log(String s, Object... o){ FMLLog.info("%s: %s", LootableBodies.MODID,String.format(s,o)); } - @SuppressWarnings("unused") private static void log(Object o){ FMLLog.info("%s: %s", LootableBodies.MODID,String.valueOf(o)); } 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..3607c23 --- /dev/null +++ b/src/main/java/cyano/lootable/graphics/CorpseConfigGui.java @@ -0,0 +1,80 @@ +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)); + for (String key : Arrays.asList("enable_corpse_buoyancy", "prevent_void_loss", "dim_specific_platform")) + rules.add(option("corpse recovery", 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..901386b --- /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 boolean hasConfigGui() { return true; } + @Override public GuiScreen createConfigGui(GuiScreen parent) { return new CorpseConfigGui(parent); } + @Override public Set runtimeGuiCategories() { return Collections.emptySet(); } +} diff --git a/src/main/java/cyano/lootable/graphics/CorpseContainer.java b/src/main/java/cyano/lootable/graphics/CorpseContainer.java index 6c3895b..bca9590 100644 --- a/src/main/java/cyano/lootable/graphics/CorpseContainer.java +++ b/src/main/java/cyano/lootable/graphics/CorpseContainer.java @@ -1,16 +1,23 @@ package cyano.lootable.graphics; +import static cyano.lootable.ItemStacks.emptyStack; + 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,8 +27,11 @@ 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; + net.minecraft.inventory.ContainerPlayer h; int index = 0; while(index < 4) { // armor @@ -34,7 +44,7 @@ public int getSlotStackLimit() { @Override public boolean isItemValid(ItemStack item) { - return super.isItemValid(item) && item.getItem().isValidArmor(item, EQUIPMENT_SLOTS[i], (Entity) targetInventory); + return !emptyStack(item) && item.getCount() > 0 && super.isItemValid(item) && item.getItem().isValidArmor(item, EQUIPMENT_SLOTS[i], (Entity) targetInventory); } @SideOnly(Side.CLIENT) public String getSlotTexture() @@ -77,12 +87,194 @@ 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 ItemStack.EMPTY; + } + if(type != ClickType.QUICK_CRAFT && dragging) { + resetDrag(); + return ItemStack.EMPTY; + } + if(type == ClickType.QUICK_CRAFT && getDragEvent(button) == 0 + && !emptyStack(player.inventory.getItemStack()) && isValidDragMode(extractDragMode(button),player)) dragging = true; + if(type == ClickType.THROW && emptyStack(player.inventory.getItemStack()) + && 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.getCount()); + slot.onTake(player,removed); + if(!tryRelease(player,removed,true)) { + slot.putStack(original); + resync(player); + } + } + return ItemStack.EMPTY; + } + if((type == ClickType.PICKUP || type == ClickType.QUICK_MOVE) && slotId == -999 && (button == 0 || button == 1)) { + ItemStack cursor = player.inventory.getItemStack(); + if(!emptyStack(cursor)) { + ItemStack original = cursor.copy(); + ItemStack removed = cursor.splitStack(button == 0 ? cursor.getCount() : 1); + if(cursor.getCount() == 0) player.inventory.setItemStack(ItemStack.EMPTY); + if(!tryRelease(player,removed,true)) { + player.inventory.setItemStack(original); + resync(player); + } + } + return ItemStack.EMPTY; + } + 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(!emptyStack(incoming) && !emptyStack(displaced) && slot.canTakeStack(player) && slot.isItemValid(incoming)) { + int limit = Math.min(slot.getItemStackLimit(incoming),incoming.getMaxStackSize()); + if(limit > 0 && incoming.getCount() > limit) { + slot.putStack(incoming.splitStack(limit)); + slot.onTake(player,displaced); + storeInPlayerInventory(player.inventory,displaced); + if(displaced.getCount() > 0) releaseOrRetain(player,displaced); + return ItemStack.EMPTY; + } + } + } + return super.slotClick(slotId,button,type,player); + } + + @Override + public void onContainerClosed(EntityPlayer player) { + ItemStack cursor = player.inventory.getItemStack(); + if(!emptyStack(cursor)) { + releaseOrRetain(player,cursor); + player.inventory.setItemStack(ItemStack.EMPTY); + } + } + + @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(emptyStack(stack) || stack.getCount() <= 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.getCount() > 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.getCount() > 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.size(); 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.getCount(),Math.max(0,limit - (emptyStack(current) ? 0 : current.getCount()))); + if(amount == 0) return; + if(emptyStack(current)) { + inventory.setInventorySlotContents(destination,remainder.splitStack(amount)); + current = inventory.getStackInSlot(destination); + } else { + current.grow(amount); + remainder.shrink(amount); + } + current.setAnimationsToGo(5); + inventory.markDirty(); + } + } + + private boolean canStore(InventoryPlayer inventory, int slot, ItemStack incoming) { + ItemStack current = inventory.getStackInSlot(slot); + return !emptyStack(current) && current.isStackable() && current.getItem() == incoming.getItem() + && (!current.getHasSubtypes() || current.getMetadata() == incoming.getMetadata()) + && ItemStack.areItemStackTagsEqual(current,incoming) + && current.getCount() < Math.min(current.getMaxStackSize(),inventory.getInventoryStackLimit()); + } + + private boolean tryRelease(EntityPlayer player, ItemStack stack, boolean includeName) { + if(emptyStack(stack) || stack.getCount() <= 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.getCount() > 0; pass++) { + for(int i = reverse ? end - 1 : start; i >= start && i < end && stack.getCount() > 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() && !emptyStack(current) + && ItemStack.areItemsEqual(stack,current) && ItemStack.areItemStackTagsEqual(stack,current)) { + int amount = Math.min(stack.getCount(),Math.max(0,limit - current.getCount())); + if(amount > 0) { + current.grow(amount); + stack.shrink(amount); + slot.onSlotChanged(); + changed = true; + } + } else if(pass == 1 && emptyStack(current) && limit > 0) { + slot.putStack(stack.splitStack(Math.min(stack.getCount(),limit))); + changed = true; + } + } + } + return changed; + } + @Override public ItemStack transferStackInSlot(EntityPlayer player, int slot) { int hostSize = targetInventory.getSizeInventory(); ItemStack stack = ItemStack.EMPTY; - Slot slotObject = inventorySlots.get(slot); + Slot slotObject = (Slot) inventorySlots.get(slot); //null checks and checks if the item can be stacked (maxStackSize > 1) if (slotObject != null && slotObject.getHasStack()) { ItemStack stackInSlot = slotObject.getStack(); diff --git a/src/main/java/cyano/lootable/graphics/CorpseRenderer.java b/src/main/java/cyano/lootable/graphics/CorpseRenderer.java index 8bc69c3..9b58af9 100644 --- a/src/main/java/cyano/lootable/graphics/CorpseRenderer.java +++ b/src/main/java/cyano/lootable/graphics/CorpseRenderer.java @@ -2,7 +2,6 @@ import com.mojang.authlib.GameProfile; import com.mojang.authlib.minecraft.MinecraftProfileTexture; -import com.mojang.authlib.minecraft.MinecraftProfileTexture.Type; import cyano.lootable.LootableBodies; import cyano.lootable.entities.EntityLootableBody; import net.minecraft.client.Minecraft; @@ -82,20 +81,28 @@ 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 + 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; } } @Override public void doRender(EntityLootableBody entity, double x, double y, double z, float yaw, float partialTick) { - if (net.minecraftforge.common.MinecraftForge.EVENT_BUS.post(new net.minecraftforge.client.event.RenderLivingEvent.Pre(entity, this, partialTick, x, y, z))) + if (net.minecraftforge.common.MinecraftForge.EVENT_BUS.post(new net.minecraftforge.client.event.RenderLivingEvent.Pre(entity, this, x, y, z))) return; this.setModel(entity.useThinArms()); @@ -175,7 +182,7 @@ public void doRender(EntityLootableBody entity, double x, double y, double z, fl this.renderName(entity, x, y, z); } - net.minecraftforge.common.MinecraftForge.EVENT_BUS.post(new net.minecraftforge.client.event.RenderLivingEvent.Post(entity, this, partialTick, x, y, z)); + net.minecraftforge.common.MinecraftForge.EVENT_BUS.post(new net.minecraftforge.client.event.RenderLivingEvent.Post(entity, this, x, y, z)); this.setModel(true); } diff --git a/src/main/java/cyano/lootable/upgrade/ChunkVersionContext.java b/src/main/java/cyano/lootable/upgrade/ChunkVersionContext.java new file mode 100644 index 0000000..32b3183 --- /dev/null +++ b/src/main/java/cyano/lootable/upgrade/ChunkVersionContext.java @@ -0,0 +1,51 @@ +package cyano.lootable.upgrade; + +import cyano.lootable.LootableBodies; +import java.lang.reflect.Field; +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.util.datafix.DataFixer; +import net.minecraft.util.datafix.FixTypes; +import net.minecraft.util.datafix.IFixType; +import net.minecraft.world.WorldServer; +import net.minecraft.world.chunk.storage.AnvilChunkLoader; +import net.minecraft.world.chunk.storage.IChunkLoader; +import net.minecraftforge.event.world.WorldEvent; +import net.minecraftforge.fml.common.Mod; +import net.minecraftforge.fml.common.eventhandler.EventPriority; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; +import net.minecraftforge.fml.relauncher.ReflectionHelper; + +/** Supplies preserved source context before the Anvil loader invokes Forge's fixers. */ +@Mod.EventBusSubscriber(modid=LootableBodies.MODID) +public final class ChunkVersionContext { + private ChunkVersionContext() { } + + @SubscribeEvent(priority=EventPriority.HIGHEST) + public static void load(WorldEvent.Load event) { + if(!(event.getWorld() instanceof WorldServer))return; + IChunkLoader loader=((WorldServer)event.getWorld()).getChunkProvider().chunkLoader; + if(!(loader instanceof AnvilChunkLoader))return; + try { + // Forge has no setter for this pinned 1.12 Anvil boundary. World.Load + // precedes spawn preparation and later dimension chunk loading. + Field field=ReflectionHelper.findField(AnvilChunkLoader.class,"fixer","field_193416_e"); + DataFixer delegate=(DataFixer)field.get(loader); + if(!(delegate instanceof ContextFixer))field.set(loader,new ContextFixer(delegate)); + } catch(ReflectiveOperationException failure) { + throw new IllegalStateException("Lootable Bodies could not install safe chunk version handling",failure); + } + } + + private static final class ContextFixer extends DataFixer { + private final DataFixer delegate; + ContextFixer(DataFixer delegate){super(UpgradePreservation.MINECRAFT_VERSION);this.delegate=delegate;} + @Override public NBTTagCompound process(IFixType type,NBTTagCompound input) { + NBTTagCompound result=delegate.process(type,type==FixTypes.CHUNK?UpgradePreservation.chunkContext(input):input); + if(type==FixTypes.CHUNK)UpgradePreservation.clearChunkContext(result); + return result; + } + @Override public NBTTagCompound process(IFixType type,NBTTagCompound input,int version) { + return delegate.process(type,input,version); + } + } +} diff --git a/src/main/java/cyano/lootable/upgrade/ItemMigration.java b/src/main/java/cyano/lootable/upgrade/ItemMigration.java new file mode 100644 index 0000000..22a9283 --- /dev/null +++ b/src/main/java/cyano/lootable/upgrade/ItemMigration.java @@ -0,0 +1,123 @@ +package cyano.lootable.upgrade; + +import java.util.*; +import net.minecraft.item.Item; +import net.minecraft.nbt.*; +import net.minecraft.util.ResourceLocation; +import net.minecraft.util.datafix.*; +import net.minecraftforge.common.util.CompoundDataFixer; + +/** Runs item fixers on copies with the original source-version context. */ +public final class ItemMigration { + private enum Envelope implements IFixType { ITEM } + private static CompoundDataFixer pipeline; + private ItemMigration() { } + + static void register(CompoundDataFixer fixer) { + pipeline=fixer; + fixer.registerVanillaWalker(Envelope.ITEM,(nested,envelope,version)-> { + if(!envelope.getBoolean("Done")) { + envelope.setBoolean("Done",true); + envelope.setTag("Item",nested.process(FixTypes.ITEM_INSTANCE,envelope.getCompoundTag("Item"),version)); + } + return envelope; + }); + } + + /** Null means that conversion is ambiguous; retain the original raw record. */ + public static NBTTagCompound convert(NBTTagCompound raw,NBTTagCompound versions) { + if(versions==null || versions.isEmpty()) return raw.copy(); + if(versions.getBoolean("Ambiguous")) { + NBTTagCompound agreed=null; + for(int candidate:versions.getIntArray("CandidateDataVersions")) { + NBTTagCompound context=versions.copy();context.removeTag("Ambiguous");context.removeTag("CandidateDataVersions");context.setInteger("DataVersion",candidate); + NBTTagCompound converted=convert(raw,context); + if(converted==null || (agreed!=null && !agreed.equals(converted)))return null; + agreed=converted; + } + return agreed; + } + if(pipeline==null || !versions.hasKey("DataVersion",3)) return null; + int source=versions.getInteger("DataVersion"); + if(source<512 || source>1343 || integral(raw.getTag("Count"))==null) return null; + NBTTagCompound item=raw.copy(); + try { + NBTTagCompound current=new NBTTagCompound();pipeline.writeVersionData(current); + NBTTagCompound targets=current.getCompoundTag("ForgeDataVersion"); + List mods=new ArrayList<>(targets.getKeySet());Collections.sort(mods);mods.remove("minecraft");mods.add(0,"minecraft"); + // CompoundDataFixer invokes its global walkers once per outdated mod. + // Run one version context at a time so a nested banner/container is + // not converted twice merely because this mod also has a data fixer. + for(String mod:mods) { + if(mod.equals("lootablebodies") || mod.equals("lootablebodies_upgrade")) continue; + NBTTagCompound observed=versions.getCompoundTag("ForgeDataVersion"); + int old=mod.equals("minecraft") ? source : observed.hasKey(mod,3) ? observed.getInteger(mod) : -1; + if(old>=targets.getInteger(mod)) continue; + NBTTagCompound envelope=current.copy();envelope.setInteger("DataVersion",1343); + envelope.getCompoundTag("ForgeDataVersion").setInteger(mod,old); + if(mod.equals("minecraft")) envelope.setInteger("DataVersion",old); + envelope.setTag("Item",item); + item=pipeline.process(Envelope.ITEM,envelope).getCompoundTag("Item"); + } + if(!Objects.equals(integral(raw.getTag("Count")),integral(item.getTag("Count"))) + || !Objects.equals(raw.getTag("Slot"),item.getTag("Slot")) + || !Objects.equals(raw.getTag("ForgeCaps"),item.getTag("ForgeCaps"))) return null; + Long damage=raw.hasKey("Damage") ? integral(raw.getTag("Damage")) : Long.valueOf(0); + Long converted=item.hasKey("Damage") ? integral(item.getTag("Damage")) : Long.valueOf(0); + if(!Objects.equals(damage,converted)) { + NBTTagCompound banner=raw.getCompoundTag("tag").getCompoundTag("BlockEntityTag"); + boolean bannerFix=raw.getString("id").equals("minecraft:banner") && source<804 && banner.hasKey("Base",99) + && converted!=null && converted==(banner.getShort("Base")&15); + boolean bedFix=raw.getString("id").equals("minecraft:bed") && source<1125 + && Long.valueOf(0).equals(damage) && Long.valueOf(14).equals(converted); + if(!bannerFix && !bedFix)return null; + } + for(String key:raw.getKeySet()) if(!Arrays.asList("id","Count","Damage","Slot","tag").contains(key) + && !Objects.equals(raw.getTag(key),item.getTag(key))) return null; + if(!knownNestedItems(item) || !nestedInventoriesSurvive(raw,item)) return null; + return item; + } catch(RuntimeException ambiguous) {return null;} + } + + private static Long integral(NBTBase nbt) { + return nbt instanceof NBTPrimitive && nbt.getId()>=1 && nbt.getId()<=4 ? ((NBTPrimitive)nbt).getLong() : null; + } + private static boolean knownNestedItems(NBTBase tag) { + if(tag instanceof NBTTagCompound) { + NBTTagCompound compound=(NBTTagCompound)tag; + if(compound.hasKey("id") && compound.hasKey("Count")) { + if(!compound.hasKey("id",8)) return false; + ResourceLocation id=new ResourceLocation(compound.getString("id")); + if(!Item.REGISTRY.containsKey(id) || Item.getIdFromItem(Item.REGISTRY.getObject(id))==0) return false; + } + for(String key:compound.getKeySet()) if(!key.equals("ForgeCaps") && !knownNestedItems(compound.getTag(key))) return false; + } else if(tag instanceof NBTTagList) { + NBTTagList list=(NBTTagList)tag;for(int i=0;i event) { + for(RegistryEvent.MissingMappings.Mapping mapping:event.getAllMappings()) + if(mapping.key.equals(OLD_XP_SOUND)) mapping.remap(event.getRegistry().getValue(XP_SOUND)); + } +} diff --git a/src/main/java/cyano/lootable/upgrade/UpgradeJournal.java b/src/main/java/cyano/lootable/upgrade/UpgradeJournal.java new file mode 100644 index 0000000..42d0d8c --- /dev/null +++ b/src/main/java/cyano/lootable/upgrade/UpgradeJournal.java @@ -0,0 +1,347 @@ +package cyano.lootable.upgrade; + +import java.io.*; +import java.nio.channels.FileChannel; +import java.nio.file.*; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.*; +import java.util.regex.*; +import java.util.stream.Stream; +import java.util.zip.*; +import net.minecraft.nbt.*; + +/** Read-only Anvil inspection and immutable, checksummed corpse originals. */ +public final class UpgradeJournal { + private static final int FORMAT=2; + private static final long MAX_NBT=64L*1024*1024; + private static final Pattern REGION=Pattern.compile("r\\.(-?[0-9]+)\\.(-?[0-9]+)\\.mca"); + private final Path world, directory, snapshots, chunks; + private final Map active=new HashMap<>(); + + private UpgradeJournal(Path world) throws IOException { + this.world=world.toRealPath(); + directory=this.world.resolve("data/lootablebodies-upgrade"); + snapshots=directory.resolve("snapshots"); + chunks=directory.resolve("chunks"); + } + + /** Complete preservation before Minecraft opens any existing chunk. */ + public static UpgradeJournal scan(Path world) throws IOException { + Files.createDirectories(world); + UpgradeJournal journal=new UpgradeJournal(world); + journal.inspect(); + return journal; + } + + public int size() { return active.size(); } + + /** Only an entity already present in this chunk may consume its original. */ + public NBTTagCompound originalFor(UUID id,int chunkX,int chunkZ) throws IOException { + Path path=active.get(id); + if(path==null) return null; + NBTTagCompound original=readSnapshot(path); + return original.getInteger("ChunkX")==chunkX && original.getInteger("ChunkZ")==chunkZ ? original : null; + } + + NBTTagCompound originalFor(UUID id) throws IOException { + Path path=active.get(id); + return path==null ? null : readSnapshot(path); + } + + static boolean corpse(NBTTagCompound entity) { + String id=entity.getString("id"); + return id.equals("lootablebodies:corpse") || id.equals("lootablebodies.Corpse"); + } + + private void inspect() throws IOException { + // Reject links rather than silently skipping a linked dimension or writing + // a recovery journal outside this world. No region is opened in rw mode. + List regions=new ArrayList<>(); + if(Files.isSymbolicLink(directory) || Files.isSymbolicLink(world.resolve("data"))) + throw new IOException("Lootable Bodies upgrade journal must be inside the world"); + try(Stream paths=Files.walk(world)) { + for(Iterator it=paths.iterator();it.hasNext();) { + Path path=it.next(); + if(Files.isSymbolicLink(path)) throw new IOException("Cannot preserve a world containing linked paths: "+world.relativize(path)); + if(Files.isRegularFile(path) && path.getParent().getFileName().toString().equals("region") + && REGION.matcher(path.getFileName().toString()).matches()) regions.add(path); + } + } + regions.sort(Comparator.comparing(Path::toString)); + Files.createDirectories(snapshots); + Files.createDirectories(chunks); + // Old originals remain an integrity-checked recovery archive even after + // their corpses have been looted or removed. They are never reinserted. + try(DirectoryStream files=Files.newDirectoryStream(snapshots,"*.nbt")) { + for(Path path:files) readSnapshot(path); + } + Map previous=readIndex(); + NBTTagCompound worldVersions=readWorldVersions(); + NBTTagList updated=new NBTTagList(); + for(Path region:regions) { + String relative=world.relativize(region).toString().replace('\\','/'); + String hash=fileDigest(region); + NBTTagCompound record=previous.get(relative); + if(record==null || !hash.equals(record.getString("Digest"))) + record=scanRegion(region,relative,hash,worldVersions,record); + else record=record.copy(); + NBTTagList originals=record.getTagList("Snapshots",8); + for(int i=0;i readIndex() throws IOException { + Map records=new HashMap<>(); + Path index=directory.resolve("index.nbt"); + if(!Files.exists(index)) return records; + NBTTagCompound envelope=readCompressed(index); + byte[] payload=envelope.getByteArray("Payload"); + if(!envelope.hasKey("Format",3) || envelope.getInteger("Format")!=FORMAT + || !envelope.hasKey("Digest",8) || !envelope.hasKey("Payload",7) + || !digest(payload).equals(envelope.getString("Digest"))) + throw new IOException("Lootable Bodies upgrade index checksum mismatch"); + NBTTagCompound nbt; + try(DataInputStream input=new DataInputStream(new GZIPInputStream(new ByteArrayInputStream(payload)))) { + nbt=CompressedStreamTools.read(input,new NBTSizeTracker(MAX_NBT)); + } + if(!nbt.hasKey("Format",3) || nbt.getInteger("Format")!=FORMAT || !compoundList(nbt.getTag("Regions"))) + throw new IOException("Invalid Lootable Bodies upgrade index"); + NBTTagList regions=nbt.getTagList("Regions",10); + for(int i=0;i0 && list.getTagType()!=8) throw new IOException("Invalid recovery snapshot list"); + if(records.put(record.getString("Path"),record)!=null) throw new IOException("Duplicate recovery region record"); + for(int j=0;jInteger.MAX_VALUE/32 || rzInteger.MAX_VALUE/32) + throw new IOException("Region coordinates out of range"); + NBTTagList preserved=new NBTTagList(); + Map prior=new HashMap<>(); + Map priorLevels=new HashMap<>(); + Map matchingChunks=new HashMap<>(); + if(previous!=null) { + NBTTagList originals=previous.getTagList("Snapshots",8); + for(int i=0;iUpgradePreservation.chunkSourceVersion(selected.getCompoundTag("SourceVersions")))prior.put(key,original); + } + } + try(RandomAccessFile input=new RandomAccessFile(file.toFile(),"r")) { + long length=input.length(); + if(length<8192 || length%4096!=0) throw new IOException("Incomplete region: "+relative); + int[] offsets=new int[1024];for(int i=0;i<1024;i++) offsets[i]=input.readInt(); + Set allocated=new HashSet<>();allocated.add(0);allocated.add(1); + for(int i=0;i<1024;i++) { + if(offsets[i]==0) continue; + int sector=offsets[i]>>>8,count=offsets[i]&255; + if(sector<2 || count==0 || ((long)sector+count)*4096>length) throw new IOException("Invalid region allocation: "+relative); + for(int s=sector;s(count*4096-4)) throw new IOException("Invalid compressed chunk size: "+relative); + byte[] payload=new byte[bytes-1];input.readFully(payload); + InputStream stream=compression==1 ? new GZIPInputStream(new ByteArrayInputStream(payload)) : + compression==2 ? new InflaterInputStream(new ByteArrayInputStream(payload)) : null; + if(stream==null) throw new IOException("Unknown chunk compression: "+relative); + NBTTagCompound chunk; + try(DataInputStream nbt=new DataInputStream(stream)) { chunk=CompressedStreamTools.read(nbt,new NBTSizeTracker(MAX_NBT)); } + catch(RuntimeException damaged) { throw new IOException("Unreadable chunk in "+relative,damaged); } + if(!chunk.hasKey("Level",10)) throw new IOException("Missing chunk Level: "+relative); + NBTTagCompound level=chunk.getCompoundTag("Level");int x=(int)rx*32+(i%32),z=(int)rz*32+(i/32); + if(!level.hasKey("xPos",3) || !level.hasKey("zPos",3) || level.getInteger("xPos")!=x || level.getInteger("zPos")!=z) + throw new IOException("Chunk coordinate mismatch in "+relative); + if(!level.hasKey("Entities")) continue; + if(!compoundList(level.getTag("Entities"))) throw new IOException("Unreadable entity list in "+relative); + NBTTagList entities=level.getTagList("Entities",10); + String originalChunk=digest(payload); + for(int e=0;eUpgradePreservation.DATA_VERSION) + throw new IOException("Corpse uses a newer preservation format"); + NBTTagCompound snapshot=new NBTTagCompound();snapshot.setInteger("Format",FORMAT);snapshot.setString("Region",relative);snapshot.setString("RegionDigest",hash); + Path chunkFile=chunks.resolve(originalChunk+".bin"); + if(!Files.exists(chunkFile)) atomic(chunkFile,payload,false); + else if(!Arrays.equals(payload,Files.readAllBytes(chunkFile))) throw new IOException("Original chunk checksum collision"); + snapshot.setString("OriginalChunk",originalChunk);snapshot.setByte("OriginalCompression",(byte)compression);snapshot.setInteger("EntityIndex",e); + snapshot.setString("Dimension",world.relativize(file.getParent().getParent()).toString().replace('\\','/')); + snapshot.setInteger("ChunkX",x);snapshot.setInteger("ChunkZ",z);snapshot.setTag("Entity",entity.copy()); + NBTTagCompound versions=sourceVersions(chunk,worldVersions,entity),priorSnapshot=prior.get(chunkKey(x,z)); + if(priorSnapshot!=null) { + String savedChunk=priorSnapshot.getString("OriginalChunk"); + NBTTagCompound oldLevel=priorLevels.get(savedChunk); + if(oldLevel==null) { + byte[] originalBytes=Files.readAllBytes(chunks.resolve(savedChunk+".bin")); + InputStream originalStream=priorSnapshot.getByte("OriginalCompression")==1?new GZIPInputStream(new ByteArrayInputStream(originalBytes)):new InflaterInputStream(new ByteArrayInputStream(originalBytes)); + try(DataInputStream originalInput=new DataInputStream(originalStream)){oldLevel=CompressedStreamTools.read(originalInput,new NBTSizeTracker(MAX_NBT)).getCompoundTag("Level");} + priorLevels.put(savedChunk,oldLevel); + } + // Only metadata outside Level may differ. Never let an older + // journal replace changed terrain, inventory or live entities. + long key=chunkKey(x,z);Boolean same=matchingChunks.get(key); + if(same==null){same=sameNbt(oldLevel,level);matchingChunks.put(key,same);} + if(same)versions.setInteger("VerifiedChunkDataVersion",UpgradePreservation.chunkSourceVersion(priorSnapshot.getCompoundTag("SourceVersions"))); + } + snapshot.setTag("SourceVersions",versions); + byte[] original=compressed(snapshot);String filename=digest(original)+".nbt";Path destination=snapshotPath(filename); + if(Files.exists(destination)) {if(!Arrays.equals(original,Files.readAllBytes(destination))) throw new IOException("Recovery snapshot collision");} + else atomic(destination,original,false); + if(entity.hasKey("LootableBodiesUpgradePayload") || entity.hasKey("LootableBodiesInventorySource")) + throw new IOException("Unexpected intermediate corpse migration data on disk; original chunk has been preserved"); + preserved.appendTag(new NBTTagString(filename)); + } + } + } catch(RuntimeException damaged) { throw new IOException("Cannot safely preserve "+relative,damaged); } + NBTTagCompound record=new NBTTagCompound();record.setString("Path",relative);record.setString("Digest",hash);record.setTag("Snapshots",preserved);return record; + } + + private NBTTagCompound readWorldVersions() throws IOException { + NBTTagCompound context=new NBTTagCompound(); + Path level=world.resolve("level.dat"); + if(Files.isRegularFile(level)) { + NBTTagCompound root=readCompressed(level),data=root.getCompoundTag("Data"); + if(data.hasKey("DataVersion")) context.setTag("WorldDataVersion",data.getTag("DataVersion").copy()); + if(root.hasKey("FML")) context.setTag("WorldFML",root.getTag("FML").copy()); + else if(data.hasKey("FML")) context.setTag("WorldFML",data.getTag("FML").copy()); + } + return context; + } + + private static NBTTagCompound sourceVersions(NBTTagCompound chunk,NBTTagCompound world,NBTTagCompound entity) { + NBTTagCompound context=world.copy(); + if(chunk.hasKey("DataVersion")) context.setTag("ChunkDataVersion",chunk.getTag("DataVersion").copy()); + if(chunk.hasKey("ForgeDataVersion")) context.setTag("ForgeDataVersion",chunk.getTag("ForgeDataVersion").copy()); + int version=chunk.hasKey("DataVersion",3) ? chunk.getInteger("DataVersion") : -1; + if(version<512 || version>1343) version=world.hasKey("WorldDataVersion",3) ? world.getInteger("WorldDataVersion") : -1; + // Dotted IDs and our own per-corpse markers identify known source families. + // A lowercase ID alone cannot distinguish original 1.11 and 1.12 saves. + boolean legacy=entity.getString("id").equals("lootablebodies.Corpse"); + int marker=entity.hasKey(UpgradePreservation.VERSION,3) ? entity.getInteger(UpgradePreservation.VERSION) : 0; + if(marker==1) version=922; + else if(marker==UpgradePreservation.DATA_VERSION) version=1343; + if(legacy) {if(version!=512)context.setBoolean("Inferred",true);version=512;} + int family=-1; + NBTTagList mods=world.getCompoundTag("WorldFML").getTagList("ModList",10); + for(int i=0;i1343) { + version=family;context.setBoolean("Inferred",true); + } else if(marker==0 && !legacy && family>=512 && family<1125 && version>family) { + // An original older writer cannot have emitted a newer item format. + // Its retained FML metadata disambiguates a falsely current stamp. + version=family;context.setBoolean("Inferred",true); + } + if(version<512 || version>1343) { + // Keep ambiguous records retriable without guessing a bed colour or + // discarding known items whose conversion agrees in both families. + context.setBoolean("Ambiguous",true);context.setIntArray("CandidateDataVersions",new int[]{922,1343});version=922; + } + context.setInteger("DataVersion",version);return context; + } + + private static long chunkKey(int x,int z){return ((long)x<<32)|(z&0xffffffffL);} + private static boolean sameNbt(NBTBase a,NBTBase b) { + if(a==null || b==null)return a==b; + if(a.getId()!=b.getId())return false; + if(a instanceof NBTTagDouble)return Double.doubleToRawLongBits(((NBTTagDouble)a).getDouble())==Double.doubleToRawLongBits(((NBTTagDouble)b).getDouble()); + if(a instanceof NBTTagFloat)return Float.floatToRawIntBits(((NBTTagFloat)a).getFloat())==Float.floatToRawIntBits(((NBTTagFloat)b).getFloat()); + if(a instanceof NBTTagCompound) { + NBTTagCompound x=(NBTTagCompound)a,y=(NBTTagCompound)b; + if(!x.getKeySet().equals(y.getKeySet()))return false; + for(String key:x.getKeySet())if(!sameNbt(x.getTag(key),y.getTag(key)))return false; + return true; + } + if(a instanceof NBTTagList) { + NBTTagList x=(NBTTagList)a,y=(NBTTagList)b; + if(x.getTagType()!=y.getTagType() || x.tagCount()!=y.tagCount())return false; + for(int i=0;i { + NBTTagCompound level=chunk.getCompoundTag("Level");NBTTagList entities=level.getTagList("Entities",10); + for(int i=0;i {protect(entity,0,0,false);return entity;}); + } + + /** Called by FMLServerAboutToStartEvent, before loadAllWorlds on both sides. */ + public static UpgradeJournal prepare(Path world) throws IOException { + UpgradeJournal complete=UpgradeJournal.scan(world); + journal=complete;return complete; + } + public static void close() {journal=null;} + + /** Resolve damaged chunk stamps before Forge captures its immutable version map. */ + public static NBTTagCompound chunkContext(NBTTagCompound chunk) { + if(journal==null) return chunk; + NBTTagCompound level=chunk.getCompoundTag("Level"); + NBTTagList entities=level.getTagList("Entities",10);int source=-1; + try { + for(int i=0;i0) { + int stamped=versions.hasKey("ChunkDataVersion",3)?versions.getInteger("ChunkDataVersion"):-1; + version=stamped>=512 && stamped<=1343?Math.min(stamped,family):family; + } + return version; + } + + public static void clearChunkContext(NBTTagCompound chunk){chunk.removeTag(CONTEXT);} + + private static NBTTagCompound repairContext(NBTTagCompound chunk) { + if(journal==null || chunk.getBoolean(CONTEXT)) return chunk; + NBTTagCompound level=chunk.getCompoundTag("Level"); + NBTTagList entities=level.getTagList("Entities",10); + int source=-1; + try { + for(int i=0;i 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\"")); + 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("new net.minecraft.util.ResourceLocation(\"lootablebodies:corpse\")")); + 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.12.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\":3")); + } + @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("NonNullList.withSize(6*7, ItemStack.EMPTY)")); + 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..b8d7ba7 --- /dev/null +++ b/src/test/java/cyano/lootable/contracts/ConfigScreenTest.java @@ -0,0 +1,120 @@ +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", "corpse recovery")), 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(14, count); + assertFalse(LootableBodies.corpseBuoyancy);assertFalse(LootableBodies.voidPlatform);assertTrue(LootableBodies.dimensionSpecificPlatform); + 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/CorpseTranslationTest.java b/src/test/java/cyano/lootable/contracts/CorpseTranslationTest.java new file mode 100644 index 0000000..5ffb0fa --- /dev/null +++ b/src/test/java/cyano/lootable/contracts/CorpseTranslationTest.java @@ -0,0 +1,31 @@ +package cyano.lootable.contracts; + +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.Properties; +import java.util.stream.Stream; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.TestFactory; +import static org.junit.jupiter.api.Assertions.*; + +class CorpseTranslationTest { + @TestFactory Stream everyLocaleTranslatesTheRegisteredEntityName() throws Exception { + Path folder = Paths.get("build/resources/main/assets/lootablebodies/lang"); + try (Stream files = Files.list(folder)) { + Path[] locales = files.filter(p -> p.toString().endsWith(".lang")).sorted().toArray(Path[]::new); + assertEquals(14, locales.length); + return Stream.of(locales).map(locale -> DynamicTest.dynamicTest(locale.getFileName().toString(), () -> { + Properties language = new Properties(); + try (Reader reader = Files.newBufferedReader(locale, StandardCharsets.UTF_8)) { language.load(reader); } + String original = language.getProperty("entity.lootablebodies.Corpse.name"); + assertNotNull(original, "Retain the original translation"); + assertEquals(original, language.getProperty("entity.lootablebodies.corpse.name")); + // EntityList.getEntityString returns the registered display name + // Corpse; the namespaced registry/save ID is a separate value. + assertEquals(original, language.getProperty("entity.Corpse.name"), + "Nameless corpses and their spawn eggs must resolve the actual runtime key"); + })); + } + } +} 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..dba7b11 --- /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.3.112021", 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.12.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/src/test/java/cyano/lootable/contracts/UpgradeJournalTest.java b/src/test/java/cyano/lootable/contracts/UpgradeJournalTest.java new file mode 100644 index 0000000..faf01b5 --- /dev/null +++ b/src/test/java/cyano/lootable/contracts/UpgradeJournalTest.java @@ -0,0 +1,137 @@ +package cyano.lootable.contracts; + +import java.io.*; +import java.lang.reflect.*; +import java.nio.file.*; +import java.security.MessageDigest; +import java.util.*; +import java.util.zip.DeflaterOutputStream; +import net.minecraft.nbt.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import static org.junit.jupiter.api.Assertions.*; + +/** Synthetic region files test the preservation boundary, not old-world qualification. */ +class UpgradeJournalTest { + @TempDir Path world; + private static final UUID ID=UUID.fromString("00000000-1111-4111-8111-000000000001"); + private Object scan() throws Exception { + try { return Class.forName("cyano.lootable.upgrade.UpgradeJournal").getMethod("scan",Path.class).invoke(null,world); } + catch(InvocationTargetException error) { throw (Exception)error.getCause(); } + } + private NBTTagCompound original(Object journal,UUID id,int x,int z) throws Exception { + return (NBTTagCompound)journal.getClass().getMethod("originalFor",UUID.class,int.class,int.class).invoke(journal,id,x,z); + } + private NBTTagCompound body(UUID id) { + NBTTagCompound body=new NBTTagCompound();body.setString("id","lootablebodies.Corpse");body.setUniqueId("UUID",id); + body.setDouble("FallDistance",Double.NaN);body.setString("Name","Saved owner");body.setLong("DeathTime",700); + NBTTagList equipment=new NBTTagList(); + for(int i=0;i<48;i++) {NBTTagCompound item=new NBTTagCompound();item.setString("id","minecraft:diamond");item.setByte("Count",(byte)(i+1));item.setShort("Damage",(short)i);item.setByte("Slot",(byte)i);NBTTagCompound custom=new NBTTagCompound();custom.setString("keepsake","slot-"+i);item.setTag("tag",custom);equipment.appendTag(item);} + body.setTag("Equipment",equipment); + NBTTagCompound record=new NBTTagCompound();record.setString("Source","Equipment");record.setTag("Data",equipment.getCompoundTagAt(47).copy()); + NBTTagList recovery=new NBTTagList();recovery.appendTag(record);body.setTag("LootableBodiesUnresolved",recovery); + return body; + } + private Path region(String dimension,NBTTagCompound... bodies) throws Exception { + return regionVersion(dimension,512,bodies); + } + private Path regionVersion(String dimension,int version,NBTTagCompound... bodies) throws Exception { + NBTTagCompound chunk=new NBTTagCompound(),level=new NBTTagCompound();if(version>=0)chunk.setInteger("DataVersion",version);chunk.setTag("Level",level);level.setInteger("xPos",0);level.setInteger("zPos",0); + NBTTagList entities=new NBTTagList();for(NBTTagCompound body:bodies)entities.appendTag(body.copy());level.setTag("Entities",entities); + ByteArrayOutputStream bytes=new ByteArrayOutputStream(); + try(DataOutputStream compressed=new DataOutputStream(new DeflaterOutputStream(bytes))){CompressedStreamTools.write(chunk,compressed);} + int sectors=(bytes.size()+5+4095)/4096;byte[] contents=new byte[8192+sectors*4096]; + java.nio.ByteBuffer buffer=java.nio.ByteBuffer.wrap(contents);buffer.putInt((2<<8)|sectors);buffer.position(8192);buffer.putInt(bytes.size()+1);buffer.put((byte)2);buffer.put(bytes.toByteArray()); + Path region=world.resolve(dimension).resolve("region/r.0.0.mca");Files.createDirectories(region.getParent());Files.write(region,contents);return region; + } + private byte[] encoded(NBTTagCompound nbt) throws Exception {ByteArrayOutputStream bytes=new ByteArrayOutputStream();CompressedStreamTools.write(nbt,new DataOutputStream(bytes));return bytes.toByteArray();} + @Test void native112VersionIsNotDowngradedTo111() throws Exception { + NBTTagCompound b=body(ID);b.setString("id","lootablebodies:corpse");regionVersion("",1343,b); + assertEquals(1343,original(scan(),ID,0,0).getCompoundTag("SourceVersions").getInteger("DataVersion")); + } + @Test void current112MarkerCanBePreservedAndReloaded() throws Exception { + NBTTagCompound b=body(ID);b.setString("id","lootablebodies:corpse");b.setInteger("LootableBodiesDataVersion",2);regionVersion("",1343,b); + assertNotNull(original(scan(),ID,0,0));assertNotNull(original(scan(),ID,0,0)); + } + @Test void nativeIdentityWithoutAnyVersionEvidenceIsExplicitlyAmbiguous() throws Exception { + NBTTagCompound b=body(ID);b.setString("id","lootablebodies:corpse");regionVersion("",-1,b); + assertTrue(original(scan(),ID,0,0).getCompoundTag("SourceVersions").getBoolean("Ambiguous")); + } + @Test void old111MarkerProvesItsOwnSourceDespiteAnIncorrectNewChunkStamp() throws Exception { + NBTTagCompound b=body(ID);b.setString("id","lootablebodies:corpse");b.setInteger("LootableBodiesDataVersion",1);regionVersion("",1343,b); + assertEquals(922,original(scan(),ID,0,0).getCompoundTag("SourceVersions").getInteger("DataVersion")); + } + @Test void rootFmlMetadataDistinguishesAnUnstampedOriginal112World() throws Exception { + NBTTagCompound b=body(ID);b.setString("id","lootablebodies:corpse");regionVersion("",-1,b); + NBTTagCompound root=new NBTTagCompound(),fml=new NBTTagCompound(),mod=new NBTTagCompound();mod.setString("ModId","lootablebodies");mod.setString("ModVersion","2.4.0");NBTTagList mods=new NBTTagList();mods.appendTag(mod);fml.setTag("ModList",mods);root.setTag("FML",fml);root.setTag("Data",new NBTTagCompound()); + try(OutputStream output=Files.newOutputStream(world.resolve("level.dat"))){CompressedStreamTools.writeCompressed(root,output);} + NBTTagCompound context=original(scan(),ID,0,0).getCompoundTag("SourceVersions");assertTrue(context.getInteger("DataVersion")>=1139);assertFalse(context.getBoolean("Ambiguous"));assertEquals(fml,context.getCompoundTag("WorldFML")); + } + @Test void unmarkedOriginal111CorpseWithFalselyNewStampStillUses111ItemRules() throws Exception { + NBTTagCompound b=body(ID);b.setString("id","lootablebodies:corpse");regionVersion("",1343,b); + NBTTagCompound root=new NBTTagCompound(),fml=new NBTTagCompound(),mod=new NBTTagCompound();mod.setString("ModId","lootablebodies");mod.setString("ModVersion","2.3.0");NBTTagList mods=new NBTTagList();mods.appendTag(mod);fml.setTag("ModList",mods);root.setTag("FML",fml);root.setTag("Data",new NBTTagCompound()); + try(OutputStream output=Files.newOutputStream(world.resolve("level.dat"))){CompressedStreamTools.writeCompressed(root,output);} + assertEquals(922,original(scan(),ID,0,0).getCompoundTag("SourceVersions").getInteger("DataVersion")); + } + @Test void stampOnlyChangesRetainPreviouslyVerifiedChunkVersion() throws Exception { + NBTTagCompound b=body(ID);regionVersion("",512,b);scan(); + regionVersion("",-1,b); + NBTTagCompound versions=original(scan(),ID,0,0).getCompoundTag("SourceVersions"); + assertEquals(512,versions.getInteger("VerifiedChunkDataVersion")); + } + @Test void changedChunkPayloadCannotReusePreviousVersionEvidence() throws Exception { + NBTTagCompound b=body(ID);regionVersion("",512,b);scan(); + NBTTagCompound added=new NBTTagCompound();added.setString("id","minecraft:pig"); + regionVersion("",-1,b,added); + assertFalse(original(scan(),ID,0,0).getCompoundTag("SourceVersions").hasKey("VerifiedChunkDataVersion")); + } + private List snapshots() throws Exception {try(java.util.stream.Stream files=Files.walk(world.resolve("data/lootablebodies-upgrade/snapshots"))){List result=new ArrayList<>();files.filter(p->p.toString().endsWith(".nbt")).forEach(result::add);return result;}} + @Test void preservesCompleteTypedPayloadWithoutWritingSourceRegions() throws Exception { + NBTTagCompound body=body(ID);Path region=region("",body);byte[] before=Files.readAllBytes(region);java.nio.file.attribute.FileTime stamp=Files.getLastModifiedTime(region); + Object journal=scan();NBTTagCompound saved=original(journal,ID,0,0); + assertArrayEquals(encoded(body),encoded(saved.getCompoundTag("Entity")));assertEquals(48,saved.getCompoundTag("Entity").getTagList("Equipment",10).tagCount()); + assertEquals(512,saved.getCompoundTag("SourceVersions").getInteger("DataVersion")); + assertArrayEquals(before,Files.readAllBytes(region));assertEquals(stamp,Files.getLastModifiedTime(region)); + } + @Test void repeatedScanReusesOriginalBackups() throws Exception {region("",body(ID));scan();List first=snapshots();scan();assertEquals(first,snapshots());} + @Test void changedRegionsRetainOriginalsWithoutResurrectingRemovedBodies() throws Exception { + region("",body(ID));scan();List first=snapshots();UUID replacement=UUID.fromString("00000000-1111-4111-8111-000000000002");region("",body(replacement));Object journal=scan(); + assertNull(original(journal,ID,0,0));assertNotNull(original(journal,replacement,0,0));assertTrue(snapshots().containsAll(first));assertEquals(2,snapshots().size()); + } + @Test void staleIndexCannotOverwriteNewInventory() throws Exception { + NBTTagCompound body=body(ID);region("",body);scan();Path index=world.resolve("data/lootablebodies-upgrade/index.nbt");byte[] stale=Files.readAllBytes(index); + body.getTagList("Equipment",10).getCompoundTagAt(40).setByte("Count",(byte)99);region("",body);scan();Files.write(index,stale); + assertArrayEquals(encoded(body),encoded(original(scan(),ID,0,0).getCompoundTag("Entity"))); + } + @Test void interruptedTemporaryWriteIsNeverTrusted() throws Exception { + region("",body(ID));Path partial=world.resolve("data/lootablebodies-upgrade/.pending-interrupted");Files.createDirectories(partial.getParent());Files.write(partial,new byte[]{31,-117,8}); + assertNotNull(original(scan(),ID,0,0));assertArrayEquals(new byte[]{31,-117,8},Files.readAllBytes(partial)); + } + @Test void corruptBackupAbortsPreservation() throws Exception {region("",body(ID));scan();Path snapshot=snapshots().get(0);Files.write(snapshot,new byte[]{0,1,2});assertThrows(IOException.class,()->scan());} + @Test void corruptIndexAbortsPreservation() throws Exception {region("",body(ID));scan();Files.write(world.resolve("data/lootablebodies-upgrade/index.nbt"),new byte[]{0,1,2});assertThrows(IOException.class,()->scan());} + @Test void readableIndexDamageCannotSilentlyOmitPreservedCorpses() throws Exception { + region("",body(ID));scan();Path path=world.resolve("data/lootablebodies-upgrade/index.nbt"); + NBTTagCompound index=CompressedStreamTools.readCompressed(Files.newInputStream(path)); + if(index.hasKey("Payload",7)) {byte[] payload=index.getByteArray("Payload");payload[payload.length/2]^=1;index.setByteArray("Payload",payload);} + else index.getTagList("Regions",10).getCompoundTagAt(0).setTag("Snapshots",new NBTTagList()); + try(OutputStream output=Files.newOutputStream(path)){CompressedStreamTools.writeCompressed(index,output);} + assertThrows(IOException.class,()->scan(),"A readable damaged index must not be accepted as a complete scan"); + } + @Test void truncatedRegionAbortsBeforeLoading() throws Exception {Path region=region("",body(ID));Files.write(region,new byte[8193]);assertThrows(IOException.class,()->scan());} + @Test void duplicateUuidAcrossDimensionsAbortsInsteadOfGuessing() throws Exception {region("",body(ID));region("DIM-1",body(ID));assertThrows(IOException.class,()->scan());} + @Test void newDimensionsAreScannedOnLaterStarts() throws Exception {region("",body(ID));scan();UUID id=UUID.fromString("00000000-1111-4111-8111-000000000003");region("DIM1",body(id));assertEquals("DIM1",original(scan(),id,0,0).getString("Dimension"));} + @Test void chunkCoordinateMismatchCannotApplyAnOldPayload() throws Exception {region("",body(ID));assertNull(original(scan(),ID,32,0));} + @Test void retainsExactCompressedChunkAsWellAsDecodedCorpse() throws Exception { + Path region=region("",body(ID));byte[] source=Files.readAllBytes(region);int size=java.nio.ByteBuffer.wrap(source,8192,4).getInt(); + byte[] originalChunk=Arrays.copyOfRange(source,8197,8192+4+size);NBTTagCompound snapshot=original(scan(),ID,0,0); + assertTrue(snapshot.hasKey("OriginalChunk",8),"A decoded NBT copy cannot retain every original byte encoding"); + assertEquals(2,snapshot.getByte("OriginalCompression"));assertEquals(0,snapshot.getInteger("EntityIndex")); + assertArrayEquals(originalChunk,Files.readAllBytes(world.resolve("data/lootablebodies-upgrade/chunks/"+snapshot.getString("OriginalChunk")+".bin"))); + } + @Test void diskCannotSupplyAProcessedTemporaryPayload() throws Exception { + NBTTagCompound body=body(ID),fake=body(ID);fake.setInteger("LootableBodiesDataVersion",1);fake.setTag("Equipment",new NBTTagList());body.setTag("LootableBodiesUpgradePayload",fake); + Path region=region("",body);byte[] before=Files.readAllBytes(region); + IOException failure=assertThrows(IOException.class,()->scan());assertTrue(failure.getMessage().contains("intermediate"));assertArrayEquals(before,Files.readAllBytes(region)); + assertFalse(snapshots().isEmpty(),"Unsafe input must still have a forensic original"); + } +} diff --git a/src/transitionFixture/java/cyano/lootable/fixture/TransitionFixture.java b/src/transitionFixture/java/cyano/lootable/fixture/TransitionFixture.java new file mode 100644 index 0000000..50cce5b --- /dev/null +++ b/src/transitionFixture/java/cyano/lootable/fixture/TransitionFixture.java @@ -0,0 +1,90 @@ +package cyano.lootable.fixture; + +import cyano.lootable.LootableBodies; +import cyano.lootable.entities.EntityLootableBody; +import cyano.lootable.buildtest.FixtureItems; +import java.io.*; +import java.util.*; +import net.minecraft.entity.Entity; +import net.minecraft.nbt.*; +import net.minecraft.server.MinecraftServer; +import net.minecraft.world.WorldServer; +import net.minecraftforge.fml.common.*; +import net.minecraftforge.fml.common.event.*; + +/** Writes a real 1.11 intermediate save while leaving distant 1.10 chunks unopened. */ +@Mod(modid="lootablebodiesbuildtest",name="Lootable Bodies Transition Fixture",version="1",dependencies="required-after:lootablebodies") +public final class TransitionFixture { + @Mod.EventHandler public void preInit(FMLPreInitializationEvent event){FixtureItems.register();} + @Mod.EventHandler public void started(FMLServerStartedEvent event) { + MinecraftServer server=FMLCommonHandler.instance().getMinecraftServerInstance(); + try {run(server);}catch(Throwable error){error.printStackTrace();}finally{server.initiateShutdown();} + } + private static void require(boolean ok,String message){if(!ok)throw new AssertionError(message);} + private static void run(MinecraftServer server) throws Exception { + require(server.getMinecraftVersion().equals("1.11.2"),"Wrong transition Minecraft"); + require(Loader.instance().getIndexedModList().get("lootablebodies").getVersion().equals("2.4.2.111021"),"Wrong intermediate release"); + LootableBodies.allowCorpseDecay=false;LootableBodies.ticksPerItemDecay=-1; + File folder=server.getWorld(0).getSaveHandler().getWorldDirectory();NBTTagCompound manifest; + try(InputStream input=new FileInputStream(new File(folder,"upgrade-fixtures.nbt"))){manifest=CompressedStreamTools.readCompressed(input);} + require(manifest.getString("Minecraft").equals("1.10.2"),"Transition source was not written by Minecraft 1.10"); + NBTTagList bodies=manifest.getTagList("Bodies",10);int loaded=0; + for(int i=0;i 1.11.2/2.4.2.111021; near=3, unopened=6\n");} + System.out.println("LOOTABLE_BODIES_TRANSITION_PASS loaded=3 unopened=6"); + } + private static Map inventory(NBTTagCompound body) { + Map result=new TreeMap<>(); + for(String key:new String[]{"Equipment","Aux"}) { + NBTTagList list=body.getTagList(key,10); + for(int i=0;i(compound.getKeySet()))text.append(key.length()).append(':').append(key).append('=').append(canonical(compound.getTag(key))).append(';'); + } else if(tag instanceof NBTTagList) { + NBTTagList list=(NBTTagList)tag;text.append(list.getTagType()).append('/').append(list.tagCount()).append('['); + for(int i=0;i(item.getKeySet()))normalize111(item.getTag(key)); + } +} diff --git a/tools/audit_runtime_logs.py b/tools/audit_runtime_logs.py new file mode 100644 index 0000000..f801727 --- /dev/null +++ b/tools/audit_runtime_logs.py @@ -0,0 +1,101 @@ +"""Reject unexpected failures in complete legacy runtime logs.""" +import argparse +import gzip +from pathlib import Path +import re + + +FORGE_UPGRADE_RENAME='[FML.ModTracker]: This world was saved with mod Forge which appears to be missing, things may not work well' + + +def development_control_lines(lines): + """Accept only consecutive bootstrap blocks reproduced without this mod.""" + accepted=set() + header=re.compile(r'^\[\d{2}:\d{2}:\d{2}\] \[[^]]+\] \[FML/?\]: (.*)$') + boundaries=[i for i,line in enumerate(lines) if line.startswith('[')]+[len(lines)] + blocks=[(a,b,header.fullmatch(lines[a])) for a,b in zip(boundaries,boundaries[1:])] + def stack(a,b): + body=lines[a+1:b] + return any('discovery.asm.ASMModParser.' in line for line in body) and all( + not line.strip() or line in ('java.lang.IllegalArgumentException: null', + 'net.minecraftforge.fml.common.LoaderException: java.lang.IllegalArgumentException', + 'Caused by: java.lang.IllegalArgumentException') or + re.fullmatch(r'\s+at (?:org\.objectweb\.asm\.|net\.minecraftforge\.fml\.|net\.minecraft\.|java\.|sun\.|net\.minecraftforge\.launcher\.|net\.minecraftforge\.gradle\.)[^\r\n]+',line) or + re.fullmatch(r'\s+\.\.\. \d+ more',line) for line in body) + scan=re.compile(r'There was a problem reading the entry (?:' + r'META-INF/versions/11/net/minecraftforge/launcher/shadow/util/download/DownloadUtilsImpl\.class in the jar .*[\\/]slime-launcher-0\.2\.2\.jar|' + r'module-info\.class in the jar .*[\\/]asm(?:-util|-analysis|-tree)?-6\.2\.jar|' + r'META-INF/versions/9/module-info\.class in the jar .*[\\/]slf4j-api-1\.8\.0-beta4\.jar) - probably a corrupt zip') + for i,(a,b,match) in enumerate(blocks): + if not match: continue + message=match.group(1) + if message=='Apache Maven library folder was not in the format expected. Using default libraries directory.' and i+20 and i+1LIMIT:raise RuntimeError('Forge login retry limit: '+role) + (self.home/'sync'/('login-retry-'+role)).write_text(str(self.counts[role])) + elif 'lost connection: Internal Exception:' in line: + reset=RESET.fullmatch(line) + if not reset or not completed_client(self.home,reset[1]): + raise RuntimeError('Unrecognised connection failure: '+line) + self.seen=len(lines) + +def start_clients(start): + """Boot both clients together; the client B controller waits for A's join marker.""" + start('clientA');start('clientB') + + +def receipt_lines(path, text): + receipt=path.with_name(path.name+'.login-retries.json') + if not receipt.is_file():return set() + data=json.loads(receipt.read_text(encoding='utf-8')) + if data.get('schema')!=1 or data.get('sha256')!=hashlib.sha256(path.read_bytes()).hexdigest(): + raise RuntimeError('Invalid or stale Forge login receipt: '+str(path)) + lines=text.splitlines();allowed=set(data['lines']);counts={'A':0,'B':0};online=set();pending=set();actual=set() + for i,line in enumerate(lines): + join=JOIN.fullmatch(line) + if join: + role=join[1] + if join[2]=='joined':online.add(role);pending.discard(role) + else:online.discard(role) + match=FAILURE.fullmatch(line) + if match: + role=match[1];counts[role]+=1;pending.add(role);actual.add(i) + if role in online or counts[role]>LIMIT:raise RuntimeError('Failure outside pre-login retry scope') + if not actual or pending or actual!=allowed:raise RuntimeError('Unrecovered or incomplete Forge login receipt') + return allowed + +def write_receipts(home): + home=Path(home) + for path in [home/'server.log',home/'server/logs/latest.log',home/'server/logs/debug.log']: + if not path.is_file():continue + text=path.read_text(encoding='utf-8',errors='replace');lines=text.splitlines() + allowed=[i for i,line in enumerate(lines) if FAILURE.fullmatch(line)] + if not allowed:continue + counts={'A':0,'B':0} + for i in allowed:counts[FAILURE.fullmatch(lines[i])[1]]+=1 + for role,count in counts.items(): + used=home/'sync'/('login-retried-'+role) + if count and (not used.is_file() or used.read_text().strip()!=str(count)): + raise RuntimeError('Unconsumed Forge login retry: '+role) + receipt=path.with_name(path.name+'.login-retries.json') + receipt.write_text(json.dumps({'schema':1,'sha256':hashlib.sha256(path.read_bytes()).hexdigest(),'lines':allowed},indent=2)+'\n') + try:receipt_lines(path,text) + except Exception: + receipt.unlink();raise + +def archive_live_logs(home): + """Preserve each first-process log and receipt before Forge replaces latest.log.""" + home=Path(home) + for name in ('latest.log','debug.log'): + source=home/'server/logs'/name + if not source.is_file():continue + target=home/('server-live-'+name) + receipt=source.with_name(source.name+'.login-retries.json') + target_receipt=target.with_name(target.name+'.login-retries.json') + if target.exists() or target_receipt.exists():raise RuntimeError('Refusing existing archived fixture log') + source.rename(target) + if receipt.is_file():receipt.rename(target_receipt) diff --git a/tools/fixtures/forge14-bootstrap-diagnostics.txt b/tools/fixtures/forge14-bootstrap-diagnostics.txt new file mode 100644 index 0000000..d490ee2 --- /dev/null +++ b/tools/fixtures/forge14-bootstrap-diagnostics.txt @@ -0,0 +1,65 @@ +[12:00:00] [main/ERROR] [FML]: Apache Maven library folder was not in the format expected. Using default libraries directory. +[12:00:00] [main/ERROR] [FML]: Full: /fixture-cache/modules-2\files-2.1\org.apache.maven\maven-artifact\3.5.3\7dc72b6d6d8a6dced3d294ed54c2cc3515ade9f4\maven-artifact-3.5.3.jar +[12:00:00] [main/ERROR] [FML]: Trimmed: /fixture-cache/modules-2/files-2.1/org.apache.maven/maven-artifact/3.5.3/ +[12:00:00] [Server thread/ERROR] [FML]: Unable to read a class file correctly +java.lang.IllegalArgumentException: null + at org.objectweb.asm.ClassReader.(ClassReader.java:185) ~[asm-debug-all-5.2.jar:5.2] + at org.objectweb.asm.ClassReader.(ClassReader.java:168) ~[asm-debug-all-5.2.jar:5.2] + at org.objectweb.asm.ClassReader.(ClassReader.java:439) ~[asm-debug-all-5.2.jar:5.2] + at net.minecraftforge.fml.common.discovery.asm.ASMModParser.(ASMModParser.java:57) [ASMModParser.class:?] + at net.minecraftforge.fml.common.discovery.JarDiscoverer.findClassesASM(JarDiscoverer.java:102) [JarDiscoverer.class:?] + at net.minecraftforge.fml.common.discovery.JarDiscoverer.discover(JarDiscoverer.java:77) [JarDiscoverer.class:?] + at net.minecraftforge.fml.common.discovery.ContainerType.findMods(ContainerType.java:47) [ContainerType.class:?] + at net.minecraftforge.fml.common.discovery.ModCandidate.explore(ModCandidate.java:74) [ModCandidate.class:?] + at net.minecraftforge.fml.common.discovery.ModDiscoverer.identifyMods(ModDiscoverer.java:93) [ModDiscoverer.class:?] + at net.minecraftforge.fml.common.Loader.identifyMods(Loader.java:427) [Loader.class:?] + at net.minecraftforge.fml.common.Loader.loadMods(Loader.java:568) [Loader.class:?] + at net.minecraftforge.fml.server.FMLServerHandler.beginServerLoading(FMLServerHandler.java:98) [FMLServerHandler.class:?] + at net.minecraftforge.fml.common.FMLCommonHandler.onServerStart(FMLCommonHandler.java:333) [FMLCommonHandler.class:?] + at net.minecraft.server.dedicated.DedicatedServer.init(DedicatedServer.java:128) [DedicatedServer.class:?] + at net.minecraft.server.MinecraftServer.run(MinecraftServer.java:552) [MinecraftServer.class:?] + at java.lang.Thread.run(Thread.java:750) [?:1.8.0_502] +[12:00:00] [Server thread/ERROR] [FML]: There was a problem reading the entry META-INF/versions/11/net/minecraftforge/launcher/shadow/util/download/DownloadUtilsImpl.class in the jar /fixture-cache/modules-2\files-2.1\net.minecraftforge\slime-launcher\0.2.2\c3f4d4b8306cf40d8a0a86ecdf816be088b6d6bf\slime-launcher-0.2.2.jar - probably a corrupt zip +net.minecraftforge.fml.common.LoaderException: java.lang.IllegalArgumentException + + at net.minecraftforge.fml.common.discovery.asm.ASMModParser.(ASMModParser.java:63) ~[ASMModParser.class:?] + at net.minecraftforge.fml.common.discovery.JarDiscoverer.findClassesASM(JarDiscoverer.java:102) [JarDiscoverer.class:?] + at net.minecraftforge.fml.common.discovery.JarDiscoverer.discover(JarDiscoverer.java:77) [JarDiscoverer.class:?] + at net.minecraftforge.fml.common.discovery.ContainerType.findMods(ContainerType.java:47) [ContainerType.class:?] + at net.minecraftforge.fml.common.discovery.ModCandidate.explore(ModCandidate.java:74) [ModCandidate.class:?] + at net.minecraftforge.fml.common.discovery.ModDiscoverer.identifyMods(ModDiscoverer.java:93) [ModDiscoverer.class:?] + at net.minecraftforge.fml.common.Loader.identifyMods(Loader.java:427) [Loader.class:?] + at net.minecraftforge.fml.common.Loader.loadMods(Loader.java:568) [Loader.class:?] + at net.minecraftforge.fml.server.FMLServerHandler.beginServerLoading(FMLServerHandler.java:98) [FMLServerHandler.class:?] + at net.minecraftforge.fml.common.FMLCommonHandler.onServerStart(FMLCommonHandler.java:333) [FMLCommonHandler.class:?] + at net.minecraft.server.dedicated.DedicatedServer.init(DedicatedServer.java:128) [DedicatedServer.class:?] + at net.minecraft.server.MinecraftServer.run(MinecraftServer.java:552) [MinecraftServer.class:?] + at java.lang.Thread.run(Thread.java:750) [?:1.8.0_502] +Caused by: java.lang.IllegalArgumentException + at org.objectweb.asm.ClassReader.(ClassReader.java:185) ~[asm-debug-all-5.2.jar:5.2] + at org.objectweb.asm.ClassReader.(ClassReader.java:168) ~[asm-debug-all-5.2.jar:5.2] + at org.objectweb.asm.ClassReader.(ClassReader.java:439) ~[asm-debug-all-5.2.jar:5.2] + at net.minecraftforge.fml.common.discovery.asm.ASMModParser.(ASMModParser.java:57) ~[ASMModParser.class:?] + ... 12 more +[12:00:00] [Server thread/WARN] [FML]: Zip file slime-launcher-0.2.2.jar failed to read properly, it will be ignored +net.minecraftforge.fml.common.LoaderException: java.lang.IllegalArgumentException + + at net.minecraftforge.fml.common.discovery.asm.ASMModParser.(ASMModParser.java:63) ~[ASMModParser.class:?] + at net.minecraftforge.fml.common.discovery.JarDiscoverer.findClassesASM(JarDiscoverer.java:102) ~[JarDiscoverer.class:?] + at net.minecraftforge.fml.common.discovery.JarDiscoverer.discover(JarDiscoverer.java:77) [JarDiscoverer.class:?] + at net.minecraftforge.fml.common.discovery.ContainerType.findMods(ContainerType.java:47) [ContainerType.class:?] + at net.minecraftforge.fml.common.discovery.ModCandidate.explore(ModCandidate.java:74) [ModCandidate.class:?] + at net.minecraftforge.fml.common.discovery.ModDiscoverer.identifyMods(ModDiscoverer.java:93) [ModDiscoverer.class:?] + at net.minecraftforge.fml.common.Loader.identifyMods(Loader.java:427) [Loader.class:?] + at net.minecraftforge.fml.common.Loader.loadMods(Loader.java:568) [Loader.class:?] + at net.minecraftforge.fml.server.FMLServerHandler.beginServerLoading(FMLServerHandler.java:98) [FMLServerHandler.class:?] + at net.minecraftforge.fml.common.FMLCommonHandler.onServerStart(FMLCommonHandler.java:333) [FMLCommonHandler.class:?] + at net.minecraft.server.dedicated.DedicatedServer.init(DedicatedServer.java:128) [DedicatedServer.class:?] + at net.minecraft.server.MinecraftServer.run(MinecraftServer.java:552) [MinecraftServer.class:?] + at java.lang.Thread.run(Thread.java:750) [?:1.8.0_502] +Caused by: java.lang.IllegalArgumentException + at org.objectweb.asm.ClassReader.(ClassReader.java:185) ~[asm-debug-all-5.2.jar:5.2] + at org.objectweb.asm.ClassReader.(ClassReader.java:168) ~[asm-debug-all-5.2.jar:5.2] + at org.objectweb.asm.ClassReader.(ClassReader.java:439) ~[asm-debug-all-5.2.jar:5.2] + at net.minecraftforge.fml.common.discovery.asm.ASMModParser.(ASMModParser.java:57) ~[ASMModParser.class:?] + ... 12 more diff --git a/tools/nbt_regions.py b/tools/nbt_regions.py new file mode 100644 index 0000000..16d4f85 --- /dev/null +++ b/tools/nbt_regions.py @@ -0,0 +1,152 @@ +"""Typed NBT and read-only region access for disposable migration fixtures.""" +from dataclasses import dataclass +from io import BytesIO +import gzip +import struct +import zlib + + +@dataclass +class Tag: + kind: int + value: object + + +def _string(stream): + size = struct.unpack('>H', _read(stream, 2))[0] + return _read(stream, size).replace(b'\xc0\x80', b'\0').decode('utf-8', 'surrogatepass') + + +def _string_bytes(text): + data = text.encode('utf-8', 'surrogatepass').replace(b'\0', b'\xc0\x80') + if len(data) > 65535: + raise ValueError('NBT string exceeds Java writeUTF limit') + return struct.pack('>H', len(data)) + data + + +def _read(stream, count): + if count < 0 or count > 64 * 1024 * 1024: + raise ValueError('Invalid NBT length') + data = stream.read(count) + if len(data) != count: + raise ValueError('Truncated NBT') + return data + + +def _payload(stream, kind, depth=0): + if depth > 512: + raise ValueError('NBT too deeply nested') + if kind in (1, 2, 3, 4): + fmt = {1: '>b', 2: '>h', 3: '>i', 4: '>q'}[kind] + return Tag(kind, struct.unpack(fmt, _read(stream, struct.calcsize(fmt)))[0]) + if kind in (5, 6): + # Preserve NaN encodings and signed zero without Python normalization. + return Tag(kind, _read(stream, 4 if kind == 5 else 8)) + if kind in (7, 11, 12): + count = struct.unpack('>i', _read(stream, 4))[0] + return Tag(kind, _read(stream, count * {7: 1, 11: 4, 12: 8}[kind])) + if kind == 8: + return Tag(kind, _string(stream)) + if kind == 9: + element, count = struct.unpack('>bi', _read(stream, 5)) + if count < 0 or count > 1_000_000 or (element == 0 and count): + raise ValueError('Invalid list length/type') + return Tag(kind, (element, [_payload(stream, element, depth + 1) for _ in range(count)])) + if kind == 10: + compound = {} + while True: + child = _read(stream, 1)[0] + if child == 0: + return Tag(kind, compound) + name = _string(stream) + if name in compound: + raise ValueError('Duplicate NBT compound key') + compound[name] = _payload(stream, child, depth + 1) + raise ValueError('Unknown NBT type ' + str(kind)) + + +def decode(data): + stream = BytesIO(data) + if _read(stream, 1) != b'\x0a': + raise ValueError('Root must be a compound') + name = _string(stream) + root = _payload(stream, 10) + if stream.read(1): + raise ValueError('Trailing NBT bytes') + return name, root + + +def _encode(tag): + kind, value = tag.kind, tag.value + if kind in (1, 2, 3, 4): + return struct.pack({1: '>b', 2: '>h', 3: '>i', 4: '>q'}[kind], value) + if kind in (5, 6): + return value + if kind in (7, 11, 12): + return struct.pack('>i', len(value) // {7: 1, 11: 4, 12: 8}[kind]) + value + if kind == 8: + return _string_bytes(value) + if kind == 9: + element, children = value + if any(child.kind != element for child in children): + raise ValueError('Mixed NBT list') + return struct.pack('>bi', element, len(children)) + b''.join(_encode(child) for child in children) + if kind == 10: + return b''.join(bytes([child.kind]) + _string_bytes(name) + _encode(child) + for name, child in value.items()) + b'\0' + raise ValueError('Unknown NBT type') + + +def encode(name, root): + if root.kind != 10: + raise ValueError('Root must be compound') + return b'\x0a' + _string_bytes(name) + _encode(root) + + +def read_region(path): + data = path.read_bytes() + if len(data) < 8192 or len(data) % 4096: + raise ValueError('Incomplete region') + chunks = {} + allocated = {0, 1} + for index in range(1024): + location = struct.unpack_from('>I', data, index * 4)[0] + if not location: + continue + offset, sectors = location >> 8, location & 255 + if offset < 2 or not sectors or (offset + sectors) * 4096 > len(data): + raise ValueError('Invalid region allocation') + occupied = set(range(offset, offset + sectors)) + if allocated & occupied: + raise ValueError('Overlapping chunks') + allocated |= occupied + length, compression = struct.unpack_from('>IB', data, offset * 4096) + if length < 2 or length > sectors * 4096 - 4: + raise ValueError('Invalid compressed chunk length') + payload = data[offset * 4096 + 5:offset * 4096 + 4 + length] + raw = gzip.decompress(payload) if compression == 1 else zlib.decompress(payload) if compression == 2 else None + if raw is None: + raise ValueError('Unknown compression') + name, root = decode(raw) + chunks[index] = {'name': name, 'root': root, 'raw': raw, 'compression': compression, + 'timestamp': data[4096 + index * 4:4100 + index * 4]} + return chunks + + +def write_disposable_region(path, chunks): + """Caller must validate that path belongs to its new disposable output.""" + header = bytearray(8192) + body = bytearray() + for index, chunk in sorted(chunks.items()): + raw = encode(chunk['name'], chunk['root']) + payload = gzip.compress(raw, mtime=0) if chunk['compression'] == 1 else zlib.compress(raw) + sectors = (len(payload) + 5 + 4095) // 4096 + if sectors > 255: + raise ValueError('Chunk exceeds Anvil sector limit') + offset = 2 + len(body) // 4096 + struct.pack_into('>I', header, index * 4, (offset << 8) | sectors) + header[4096 + index * 4:4100 + index * 4] = chunk['timestamp'] + body.extend(struct.pack('>IB', len(payload) + 1, chunk['compression'])) + body.extend(payload) + body.extend(b'\0' * (sectors * 4096 - len(payload) - 5)) + path.write_bytes(header + body) diff --git a/tools/prepare_migration_inputs.py b/tools/prepare_migration_inputs.py new file mode 100644 index 0000000..1f70486 --- /dev/null +++ b/tools/prepare_migration_inputs.py @@ -0,0 +1,106 @@ +"""Fetch checksum-pinned public legacy jars and isolated Forge server distributions.""" +import argparse +import hashlib +import json +from pathlib import Path +import subprocess +import http.client +import time +import urllib.error +import urllib.request + +MODS = { + 'original-110.jar': ('https://mediafilez.forgecdn.net/files/2323/108/DrCyanosLootableBodies_1.10.2-2.2.0.jar', + '688AFBBF4485D1F3746C68903C9E0885D57F42CCFBFA0CB4A0FC42982C0B3F2B'), + 'released-110.jar': ('https://github.com/MinecraftModDevelopmentMods/LootableBodies/releases/download/2.4.1.110021/LootableBodies-2.4.1.110021.jar', + '7F9F0EE7BF09F943ED85398982EB7C2207F3DD55B75E9E92A7531C393BE1045B'), + 'original-111.jar': ('https://mediafilez.forgecdn.net/files/2529/838/DrCyanosLootableBodies-1.11.2-2.3.0.jar', + 'C82CC329BA53A218CADB681D646F64A9C457341577BC4F9AA07E2F9926434CCF'), +} +MODS.update({ + 'released-111.jar': ('https://github.com/MinecraftModDevelopmentMods/LootableBodies/releases/download/2.4.2.111021/LootableBodies-2.4.2.111021.jar', + '59ACF5652505F08A7F011307B3CD68F3754FCFC14C4C330BD952475351AEC932'), + 'original-112.jar': ('https://mediafilez.forgecdn.net/files/2529/840/DrCyanosLootableBodies_1.12-2.4.0.jar', + 'DE701F657A03BFFE08D6F9FEB1516B37B23B0171B91716087C7C84D9C19B9B5C'), +}) +DISTRIBUTIONS = { + 'forge112': ('1.12.2', '14.23.5.2859', + 'DBBA1CD1BF34A4B82148BAC50A6150F63FFCB2D5DC7D88A0508C27FDCFF15B76', + 'BD8314729D787E35164476D75AFB337AFB9783917E4F6FA1F77F049ADBA22F2D', + '886945bfb2b978778c3a0288fd7fab09d315b25f'), + 'forge110': ('1.10.2', '12.18.3.2511', + '664393C5A924B8F06CFB953903ABCF9FE5E99ECBE6AF8C7EA217339C766E519C', + '809C2EA5B4D4F3E5A294D3A326744F20FEC412A1263E708297D00F512E377542', + '3d501b23df53c548254f5e3f66492d178a48db63'), + 'forge111': ('1.11.2', '13.20.1.2588', + '00AEA3DDF1E78BBB4D5D05E8C22FB3E849DE09B47D828E57DAD373A16BB8A26F', + 'B6DFF35B5CCB0E6353FB89B5330FE930DB8FBE8B63787C9FC2F70196BD108998', + 'f00c294a1576e03fddcac777c3cf4c7d404c4ba4'), +} + + +def sha(path): + return hashlib.sha256(path.read_bytes()).hexdigest().upper() + + +def fetch(url, path, expected): + payload = None + for address in [url, url + '?source=lootablebodies-migration', url + '?source=lootablebodies111']: + try: + print('MIGRATION_INPUT_FETCH',path.name,flush=True) + request = urllib.request.Request(address, headers={'User-Agent': 'LootableBodiesMigrationTests/2.4.3.112021'}) + with urllib.request.urlopen(request, timeout=30) as response: + chunks=[]; started=time.monotonic() + while True: + part=response.read1(65536) + if not part: break + chunks.append(part) + if time.monotonic()-started>120: raise TimeoutError('Artifact transfer exceeded two minutes') + payload=b''.join(chunks) + break + except urllib.error.HTTPError as error: + if error.code not in (403, 429, 500, 502, 503, 504): + raise + except (TimeoutError,urllib.error.URLError,http.client.IncompleteRead) as error: + print('MIGRATION_INPUT_RETRY',path.name,type(error).__name__,flush=True) + if payload is None or hashlib.sha256(payload).hexdigest().upper() != expected: + raise RuntimeError('Missing artifact or checksum mismatch: ' + path.name) + path.write_bytes(payload) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--java', type=Path, required=True) + parser.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + root = args.output.resolve() + root.mkdir(parents=True, exist_ok=False) + report = {} + for name, (url, expected) in MODS.items(): + path = root / name + fetch(url, path, expected) + report[name] = {'source': url, 'sha256': sha(path)} + for name, (minecraft, forge, installer_sha, universal_sha, server_sha) in DISTRIBUTIONS.items(): + folder = root / name + folder.mkdir() + prefix = f'forge-{minecraft}-{forge}' + installer = folder / (prefix + '-installer.jar') + url = f'https://maven.minecraftforge.net/net/minecraftforge/forge/{minecraft}-{forge}/{installer.name}' + fetch(url, installer, installer_sha) + with (folder / 'install.log').open('w', encoding='utf-8') as log: + subprocess.run([str(args.java.resolve()), '-Djava.awt.headless=true', '-jar', installer.name, '--installServer'], + cwd=folder, stdout=log, stderr=subprocess.STDOUT, check=True, timeout=300) + universal = folder / (prefix + '-universal.jar') + if not universal.is_file() and (folder / (prefix + '.jar')).is_file(): + import shutil + shutil.copy2(folder / (prefix + '.jar'), universal) + server = folder / f'minecraft_server.{minecraft}.jar' + if sha(universal) != universal_sha or hashlib.sha1(server.read_bytes()).hexdigest() != server_sha: + raise RuntimeError('Installed Forge/Minecraft artifact checksum mismatch') + report[name] = {'installer_sha256': installer_sha, 'universal_sha256': universal_sha, 'server_sha1': server_sha} + (root / 'inputs.json').write_text(json.dumps(report, indent=2) + '\n', encoding='utf-8') + print('MIGRATION_INPUTS_VERIFIED', flush=True) + + +if __name__ == '__main__': + main() 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/test_audit_runtime_logs.py b/tools/test_audit_runtime_logs.py new file mode 100644 index 0000000..491e64c --- /dev/null +++ b/tools/test_audit_runtime_logs.py @@ -0,0 +1,96 @@ +import tempfile +import unittest +from pathlib import Path + +from audit_runtime_logs import audit, FORGE_UPGRADE_RENAME + + +class RuntimeLogAuditTests(unittest.TestCase): + def check_log(self, text, **options): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / 'runtime.log' + path.write_text(text, encoding='utf-8') + return audit(path, **options) + + def rename(self, debug=False): + message = FORGE_UPGRADE_RENAME.replace(']:', '/]:') if debug else FORGE_UPGRADE_RENAME + return '[12:00:00] [Server thread/ERROR] ' + message + '\n' + + def redirect(self, old=False): + logger, number = ('Forge', 216) if old else ('forge', 227) + return ('[12:00:00] [Forge Version Check/DEBUG] [ForgeVersionCheck/' + logger + + ']: Failed to process update information\n' + 'Moved Permanently\n' + 'Caused by: java.lang.IllegalStateException: Expected BEGIN_OBJECT but was STRING at line 1 column 1\n' + '\tat net.minecraftforge.common.ForgeVersion$1.process(ForgeVersion.java:' + str(number) + ')\n') + + def test_login_encoding_failure_at_info_level_is_rejected(self): + text = ("[12:00:00] [Server thread/INFO] [minecraft/NetHandlerPlayServer]: " + "ContainerA lost connection: Internal Exception: io.netty.handler.codec.EncoderException: " + "java.io.IOException: Can't serialize unregistered packet\n") + for options in ({}, {'development': True}, {'upgrade': True}): + with self.subTest(options=options), self.assertRaises(RuntimeError): + self.check_log(text, **options) + + def test_upgrade_warning_in_both_control_proven_logger_formats(self): + for debug in [False, True]: + with self.subTest(debug=debug): + self.check_log(self.rename(debug), upgrade=True) + + def test_upgrade_warning_requires_upgrade_mode(self): + for debug in [False, True]: + with self.subTest(debug=debug), self.assertRaises(RuntimeError): + self.check_log(self.rename(debug)) + + def test_other_missing_mod_or_extra_error_is_not_allowed(self): + for text in [self.rename(True).replace('mod Forge', 'mod Other'), + self.rename(True).rstrip() + ' unexpected failure\n', + self.rename(True) + '[12:00:01] [Server thread/ERROR] Unexpected failure\n']: + with self.subTest(text=text), self.assertRaises(RuntimeError): + self.check_log(text, upgrade=True) + + def test_both_control_proven_updater_stacks(self): + self.check_log(self.redirect()) + self.check_log(self.redirect(old=True), upgrade=True) + + def test_old_updater_stack_requires_upgrade_mode(self): + with self.assertRaises(RuntimeError): + self.check_log(self.redirect(old=True)) + + def test_redirect_requires_exact_body_and_frame(self): + for text in [self.redirect(True).replace('Moved Permanently', 'different response'), + self.redirect(True).replace('ForgeVersion.java:216', 'ForgeVersion.java:215')]: + with self.subTest(text=text), self.assertRaises(RuntimeError): + self.check_log(text, upgrade=True) + + def test_unrelated_exception_after_updater_still_fails(self): + text = self.redirect(True) + ('[12:00:01] [Server thread/INFO] Saving\n' + 'Caused by: java.lang.IllegalStateException: unexpected\n') + with self.assertRaises(RuntimeError): + self.check_log(text, upgrade=True) + + def forge14_control(self): + return (Path(__file__).parent / 'fixtures/forge14-bootstrap-diagnostics.txt').read_text(encoding='utf-8') + + def test_control_proven_forge14_development_blocks(self): + self.check_log(self.forge14_control(), development=True) + + def test_development_control_diagnostics_require_development_mode(self): + with self.assertRaises(RuntimeError): self.check_log(self.forge14_control()) + + def test_control_does_not_hide_new_errors_or_unrelated_stacks(self): + control=self.forge14_control() + for extra in ['[12:00:01] [Server thread/ERROR] [FML]: Unexpected error\n', + 'Caused by: java.lang.IllegalArgumentException\n', + 'Caused by: java.lang.NullPointerException\n']: + with self.subTest(extra=extra), self.assertRaises(RuntimeError): + self.check_log(control+'[12:00:01] [Server thread/INFO] [FML]: Saving\n'+extra, development=True) + + def test_unknown_scanned_jar_and_changed_control_cause_are_rejected(self): + for text in [self.forge14_control().replace('slime-launcher-0.2.2.jar','other-mod.jar'), + self.forge14_control().replace('Caused by: java.lang.IllegalArgumentException','Caused by: java.lang.IllegalStateException')]: + with self.subTest(text=text), self.assertRaises(RuntimeError): self.check_log(text,development=True) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/test_client_runtime.py b/tools/test_client_runtime.py new file mode 100644 index 0000000..67c20b8 --- /dev/null +++ b/tools/test_client_runtime.py @@ -0,0 +1,19 @@ +import tempfile +import unittest +from pathlib import Path +from client_runtime import packaged_library, library_destination + +class ClientRuntimeTests(unittest.TestCase): + def test_development_and_test_libraries_are_excluded(self): + for name in ['legacydev-0.2.3.1-fatjar.jar','mergetool-1.0.13-forge.jar','client-extra-1.12.2.jar','asm-6.2.jar','asm-util-6.2.jar','asm-analysis-6.2.jar','asm-tree-6.2.jar','slf4j-api-1.8.0-beta4.jar','slime-launcher-0.2.2.jar','junit-jupiter-5.jar']: + with self.subTest(name=name): self.assertFalse(packaged_library(Path(name))) + def test_official_forge_libraries_are_retained(self): + for name in ['asm-debug-all-5.2.jar','log4j-api-2.15.0.jar','maven-artifact-3.5.3.jar','launchwrapper-1.12.jar']: + with self.subTest(name=name): self.assertTrue(packaged_library(Path(name))) + def test_libraries_use_the_standard_launcher_layout(self): + source=Path('/cache/caches/modules-2/files-2.1/org.apache.maven/maven-artifact/3.5.3/hash/maven-artifact-3.5.3.jar') + self.assertEqual(library_destination(source,Path('/game')),Path('/game/libraries/org/apache/maven/maven-artifact/3.5.3/maven-artifact-3.5.3.jar')) + def test_unknown_cache_shapes_are_rejected(self): + with self.assertRaises(ValueError): library_destination(Path('/some/unknown.jar'),Path('/game')) + +if __name__=='__main__': unittest.main() diff --git a/tools/test_fixture_login.py b/tools/test_fixture_login.py new file mode 100644 index 0000000..4cc0725 --- /dev/null +++ b/tools/test_fixture_login.py @@ -0,0 +1,107 @@ +import json +import tempfile +import unittest +from pathlib import Path +from fixture_login import LoginMonitor, write_receipts, archive_live_logs, start_clients +from audit_runtime_logs import audit + +FAILURE = "[12:00:00] [Server thread/INFO] [minecraft/NetHandlerPlayServer]: ContainerA lost connection: Internal Exception: io.netty.handler.codec.EncoderException: java.io.IOException: Can't serialize unregistered packet\n" +JOIN = "[12:00:02] [Server thread/INFO] [minecraft/DedicatedServer]: ContainerA joined the game\n" + +class FixtureLoginTests(unittest.TestCase): + def setUp(self): + self.temp=tempfile.TemporaryDirectory();self.addCleanup(self.temp.cleanup) + self.root=Path(self.temp.name);(self.root/'sync').mkdir() + self.log=self.root/'server.log';self.log.write_text('') + self.monitor=LoginMonitor(self.root) + def test_completed_respawn_client_may_close_its_transport(self): + reset=FAILURE.replace("io.netty.handler.codec.EncoderException: java.io.IOException: Can't serialize unregistered packet",'io.netty.channel.unix.Errors$NativeIoException: syscall:read(..) failed: Connection reset by peer') + self.log.write_text(reset) + client=self.root/'clientA';client.mkdir();(client/'respawn-client.pass').touch() + (self.root/'clientA.log').write_text('LOOTABLE_BODIES_RESPAWN_CLIENT_PASS A\n') + (self.root/'sync/state').write_text('16 9 none 0') + self.monitor.poll() + self.assertFalse((self.root/'sync/login-retry-A').exists()) + def test_completed_visibility_client_may_close_its_transport(self): + reset=FAILURE.replace("io.netty.handler.codec.EncoderException: java.io.IOException: Can't serialize unregistered packet",'io.netty.channel.unix.Errors$NativeIoException: syscall:read(..) failed: Connection reset by peer') + self.log.write_text(reset);client=self.root/'clientA';client.mkdir() + (client/'visibility-client.pass').touch() + (self.root/'clientA.log').write_text('LOOTABLE_BODIES_VISIBILITY_CLIENT_PASS role=A\n') + (self.root/'sync/phase').write_text('18');self.monitor.poll() + def test_completed_shared_inventory_client_may_close_its_transport(self): + reset=FAILURE.replace("io.netty.handler.codec.EncoderException: java.io.IOException: Can't serialize unregistered packet",'io.netty.channel.unix.Errors$NativeIoException: syscall:read(..) failed: Connection reset by peer') + self.log.write_text(reset);client=self.root/'clientA';client.mkdir() + (client/'multiplayer-client.pass').touch() + (self.root/'clientA.log').write_text('LOOTABLE_BODIES_MULTIPLAYER_CLIENT_PASS role=A\n') + (self.root/'sync/phase').write_text('8');self.monitor.poll() + + def test_reset_before_client_pass_or_in_active_phase_still_fails(self): + reset=FAILURE.replace("io.netty.handler.codec.EncoderException: java.io.IOException: Can't serialize unregistered packet",'io.netty.channel.unix.Errors$NativeIoException: syscall:read(..) failed: Connection reset by peer') + self.log.write_text(reset) + with self.assertRaises(RuntimeError):self.monitor.poll() + client=self.root/'clientA';client.mkdir();(client/'respawn-client.pass').touch() + (self.root/'clientA.log').write_text('LOOTABLE_BODIES_RESPAWN_CLIENT_PASS A\n') + (self.root/'sync/state').write_text('15 3 none 0') + with self.assertRaises(RuntimeError):self.monitor.poll() + def test_client_marker_without_actual_completed_client_log_still_fails(self): + reset=FAILURE.replace("io.netty.handler.codec.EncoderException: java.io.IOException: Can't serialize unregistered packet",'io.netty.channel.unix.Errors$NativeIoException: syscall:read(..) failed: Connection reset by peer') + self.log.write_text(reset) + client=self.root/'clientA';client.mkdir();(client/'respawn-client.pass').touch() + (self.root/'clientA.log').write_text('RESPAWN_CLIENT_PHASE A 16 9\n') + (self.root/'sync/state').write_text('16 9 none 0') + with self.assertRaises(RuntimeError):self.monitor.poll() + + def test_clients_boot_together_but_only_a_join_opens_b_connection_gate(self): + starts=[] + start_clients(starts.append) + self.assertEqual(starts,['clientA','clientB']) + self.assertFalse((self.root/'sync/client-A-joined').exists()) + self.log.write_text(JOIN);self.monitor.poll() + self.assertTrue((self.root/'sync/client-A-joined').is_file()) + + def test_live_log_receipts_survive_the_cold_reload(self): + self.log.write_text(FAILURE+JOIN);self.monitor.poll() + (self.root/'sync/login-retried-A').write_text('1') + latest=self.root/'server/logs/latest.log';latest.parent.mkdir(parents=True) + latest.write_text(FAILURE+JOIN);write_receipts(self.root) + archive_live_logs(self.root) + latest.write_text('[12:01:00] [Server thread/INFO] [FML]: Cold reload passed\n') + audit(latest,fixture_logins=True) + audit(self.root/'server-live-latest.log',fixture_logins=True) + self.assertEqual((self.root/'server-live-latest.log').read_text(),FAILURE+JOIN) + + def test_only_exact_stock_failure_grants_retry(self): + self.log.write_text(FAILURE);self.monitor.poll() + self.assertEqual((self.root/'sync/login-retry-A').read_text(),'1') + def test_unrelated_login_error_never_grants_retry(self): + self.log.write_text(FAILURE.replace("Can't serialize unregistered packet",'Other failure')) + with self.assertRaises(RuntimeError):self.monitor.poll() + self.assertFalse((self.root/'sync/login-retry-A').exists()) + def test_retries_are_bounded(self): + self.log.write_text(FAILURE*4) + with self.assertRaises(RuntimeError):self.monitor.poll() + def test_receipt_requires_recovered_connection_and_consumed_permit(self): + self.log.write_text(FAILURE);self.monitor.poll() + with self.assertRaises(RuntimeError):write_receipts(self.root) + self.log.write_text(FAILURE+JOIN) + with self.assertRaises(RuntimeError):write_receipts(self.root) + (self.root/'sync/login-retried-A').write_text('1') + write_receipts(self.root) + audit(self.log,fixture_logins=True) + with self.assertRaises(RuntimeError):audit(self.log) + def test_receipt_cannot_accept_changed_logs_or_other_errors(self): + self.log.write_text(FAILURE+JOIN);self.monitor.poll() + (self.root/'sync/login-retried-A').write_text('1');write_receipts(self.root) + self.log.write_text(FAILURE+JOIN+'[12:00:03] [Server thread/ERROR] [FML]: Bad inventory\n') + with self.assertRaises(RuntimeError):audit(self.log,fixture_logins=True) + def test_retry_after_a_play_session_is_not_allowed(self): + self.log.write_text(JOIN+FAILURE);self.monitor.poll() + (self.root/'sync/login-retried-A').write_text('1') + with self.assertRaises(RuntimeError):write_receipts(self.root) + def test_expected_disconnect_then_reconnect_can_be_retried(self): + left='[12:00:01] [Server thread/INFO] [minecraft/DedicatedServer]: ContainerA left the game\n' + self.log.write_text(JOIN+left+FAILURE+JOIN);self.monitor.poll() + (self.root/'sync/login-retried-A').write_text('1');write_receipts(self.root) + audit(self.log,fixture_logins=True) + +if __name__=='__main__':unittest.main() diff --git a/tools/test_upgrade_abort.py b/tools/test_upgrade_abort.py new file mode 100644 index 0000000..63f6f84 --- /dev/null +++ b/tools/test_upgrade_abort.py @@ -0,0 +1,61 @@ +import shutil +import tempfile +import unittest +from pathlib import Path +from verify_upgrade_abort import inject_unsafe_journal + + +class UpgradeAbortFixtureTests(unittest.TestCase): + def check_fixture(self, case, existing): + with tempfile.TemporaryDirectory() as directory: + home = Path(directory) + source = home / 'source' + journal = source / 'data/lootablebodies-upgrade' + journal.parent.mkdir(parents=True) + if existing: + journal.mkdir() + (journal / 'index.nbt').write_bytes(b'original journal index') + (journal / 'original-corpse.nbt').write_bytes(b'original corpse payload') + run = home / 'run' + world = run / 'world' + shutil.copytree(source, world) + inject_unsafe_journal(world, case) + changed = world / 'data/lootablebodies-upgrade' + if case == 'damaged-index': + self.assertEqual(b'interrupted invalid index', (changed / 'index.nbt').read_bytes()) + else: + self.assertEqual(b'cannot create a journal directory here', changed.read_bytes()) + if existing: + saved = run / 'original-journal' + self.assertEqual(b'original journal index', (saved / 'index.nbt').read_bytes()) + self.assertEqual(b'original corpse payload', (saved / 'original-corpse.nbt').read_bytes()) + self.assertEqual(b'original journal index', (journal / 'index.nbt').read_bytes()) + self.assertEqual(b'original corpse payload', (journal / 'original-corpse.nbt').read_bytes()) + else: + self.assertFalse((run / 'original-journal').exists()) + + def test_existing_journal_damaged_index(self): + self.check_fixture('damaged-index', True) + + def test_existing_journal_blocked_directory(self): + self.check_fixture('blocked-journal', True) + + def test_new_journal_damaged_index(self): + self.check_fixture('damaged-index', False) + + def test_new_journal_blocked_directory(self): + self.check_fixture('blocked-journal', False) + + def test_unknown_case_preserves_existing_journal(self): + with tempfile.TemporaryDirectory() as directory: + world = Path(directory) / 'world' + journal = world / 'data/lootablebodies-upgrade' + journal.mkdir(parents=True) + (journal / 'index.nbt').write_bytes(b'original') + with self.assertRaises(ValueError): + inject_unsafe_journal(world, 'unknown') + self.assertEqual(b'original', (journal / 'index.nbt').read_bytes()) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/verify_forge_upgrade_control.py b/tools/verify_forge_upgrade_control.py new file mode 100644 index 0000000..ffcc0f2 --- /dev/null +++ b/tools/verify_forge_upgrade_control.py @@ -0,0 +1,59 @@ +"""Independent 1.10-to-1.12 Forge log control, without Lootable Bodies installed.""" +import argparse +import json +from pathlib import Path +import shutil +import subprocess +from verify_world_upgrades import FORGE, prepare, fingerprint, digest + +FORGE_RENAME = '[FML.ModTracker]: This world was saved with mod Forge which appears to be missing, things may not work well' + + +def run(java, home, minecraft): + log = home / 'control.log' + with log.open('w', encoding='utf-8') as output: + result = subprocess.run([str(java), '-Xmx1G', *(['-Dfml.queryResult=confirm'] if minecraft == '1.12.2' else []), '-jar', FORGE[minecraft] + '-universal.jar', 'nogui'], + cwd=home, stdout=output, stderr=subprocess.STDOUT, timeout=180) + text = log.read_text(encoding='utf-8', errors='replace') + if result.returncode or 'FORGE_PLATFORM_CONTROL_PASS' not in text or 'lootablebodies@' in text: + raise RuntimeError('Independent Forge control failed: ' + str(log)) + if list((home / 'crash-reports').glob('*')): + raise RuntimeError('Independent Forge control crashed') + return text + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument('--java', type=Path, required=True) + p.add_argument('--forge110-home', type=Path, required=True) + p.add_argument('--forge112-home', type=Path, required=True) + p.add_argument('--output', type=Path, required=True) + a = p.parse_args() + project = Path(__file__).resolve().parents[1] + control = project / 'build/control-test-mod/forge-platform-control-reobf.jar' + root = a.output.resolve() + root.mkdir(parents=True, exist_ok=False) + old, new = root / 'source-110', root / 'target-112' + prepare(old, a.forge110_home.resolve(), '1.10.2', control, control) + run(a.java.resolve(), old, '1.10.2') + sealed = fingerprint(old / 'world') + prepare(new, a.forge112_home.resolve(), '1.12.2', control, control) + shutil.copytree(old / 'world', new / 'world') + text = run(a.java.resolve(), new, '1.12.2') + if FORGE_RENAME not in text or fingerprint(old / 'world') != sealed: + raise RuntimeError('Forge rename control did not reproduce the precise diagnostic or changed its source') + errors = [line for line in text.splitlines() if '/ERROR]' in line] + expected_sound=('Unidentified mapping from registry minecraft:soundevents', + 'minecraft:entity.experience_orb.touch: 196', + 'There are unidentified mappings in this world - we are going to attempt to process anyway') + if any(FORGE_RENAME not in line and 'FML appears to be missing any signature data' not in line and not any(message in line for message in expected_sound) for line in errors): + raise RuntimeError('Unexpected control errors: ' + repr(errors)) + report = {'control_jar_sha256': digest(control), 'lootable_bodies_installed': False, + 'source_unchanged': True, 'reproduced_diagnostic': FORGE_RENAME, 'error_lines': errors, + 'source_world': sealed, 'empty_control_only_confirm': True, 'known_vanilla_sound_rename': 'minecraft:entity.experience_orb.touch -> minecraft:entity.experience_orb.pickup'} + (root / 'result.json').write_text(json.dumps(report, indent=2) + '\n', encoding='utf-8') + print('FORGE_UPGRADE_CONTROL_PASS', flush=True) + + +if __name__ == '__main__': + main() diff --git a/tools/verify_multiplayer.py b/tools/verify_multiplayer.py new file mode 100644 index 0000000..9d80ab6 --- /dev/null +++ b/tools/verify_multiplayer.py @@ -0,0 +1,116 @@ +"""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 fixture_login import LoginMonitor, start_clients, write_receipts, archive_live_logs +from client_runtime import assemble_libraries +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() + (root/'options.txt').write_text('realmsNotifications:false\n') + 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.12.2/client.jar' + if hashlib.sha1(vanilla.read_bytes()).hexdigest()!='0f275bc1547d01fa5f56ba34bdc87d981ee12daf': raise RuntimeError('Vanilla client hash') + natives=cache/'minecraftforge/forgegradle/slime-launcher/cache/net/minecraftforge/forge/1.12.2-14.23.5.2859/natives' + deps=[Path(p) for p in json.loads((project/'build/runtime-classpath.json').read_text())] + deps=assemble_libraries(deps,root) + 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='+(project/'src/integrationTest/resources/log4j2.xml').as_uri(), + '-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.12.2','--userType','legacy','--gameDir',str(root), + '--assetsDir',str(project/'.gradle/packaged-assets'),'--assetIndex','1.12','--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'))!='BD8314729D787E35164476D75AFB337AFB9783917E4F6FA1F77F049ADBA22F2D': 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) + monitor=LoginMonitor(root) + start_clients(start);deadline=time.monotonic()+180 + while any(p.poll() is None for p in processes.values()): + monitor.poll() + 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') + write_receipts(root) + for name in ('server','clientA','clientB'): + audit(root/(name+'.log'),fixture_logins=True);audit(root/name/'logs/latest.log',fixture_logins=True) + 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') + archive_live_logs(root) + 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..4a9ef20 --- /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.12.2-14.23.5.2859-universal.jar')=='BD8314729D787E35164476D75AFB337AFB9783917E4F6FA1F77F049ADBA22F2D','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_normal_server.py b/tools/verify_normal_server.py new file mode 100644 index 0000000..7b250d4 --- /dev/null +++ b/tools/verify_normal_server.py @@ -0,0 +1,59 @@ +"""Start and cleanly stop ordinary servers with no build-test mod installed.""" +import argparse +import json +import os +from pathlib import Path +import subprocess +import time +from audit_runtime_logs import audit +from verify_packaged import prepare, digest, FORGE, MAIN + + +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('--output',type=Path,required=True) + parser.add_argument('--development',action='store_true') + args=parser.parse_args(); project=Path(__file__).resolve().parents[1] + root=args.output.resolve();root.mkdir(parents=True,exist_ok=False) + home=root/'server'; jar=project/'build/libs'/MAIN + if args.development: + home.mkdir();(home/'eula.txt').write_text('eula=true\n') + (home/'server.properties').write_text('level-name=world\nlevel-type=FLAT\nserver-ip=127.0.0.1\nserver-port=0\nonline-mode=false\n') + for name in ['ops','whitelist','banned-players','banned-ips']: (home/(name+'.json')).write_text('[]\n') + init=root/'ordinary.init.gradle' + path=home.as_posix().replace("'","\\'") + init.write_text("gradle.projectsEvaluated { def p=gradle.rootProject; p.minecraft.runs.getByName('server').workingDir.set(new File('"+path+"')); p.tasks.named('runServer') { workingDir new File('"+path+"') } }\n") + command=[str(project/('gradlew.bat' if os.name=='nt' else 'gradlew')),'runServer','--init-script',str(init),'--no-daemon','--console=plain'] + cwd=project + else: + # Reusing the same path for both inputs installs exactly one production jar. + prepare(home,args.forge_home.resolve(),jar,jar) + command=[str(args.java.resolve()),'-Xmx1G','-jar',FORGE+'-universal.jar','nogui'];cwd=home + results=[] + for phase in ['fresh','reload']: + log=root/(phase+'.log') + with log.open('w',encoding='utf-8') as output: + process=subprocess.Popen(command,cwd=cwd,stdin=subprocess.PIPE,stdout=output,stderr=subprocess.STDOUT,text=True) + try: + deadline=time.monotonic()+150 + while 'Done (' not in log.read_text(encoding='utf-8',errors='replace'): + if process.poll() is not None or time.monotonic()>deadline: raise RuntimeError('Ordinary server did not start: '+str(log)) + time.sleep(.2) + process.stdin.write('stop\n');process.stdin.flush();process.wait(timeout=60) + finally: + if process.poll() is None: process.terminate();process.wait(timeout=15) + text=log.read_text(encoding='utf-8',errors='replace') + if process.returncode or 'Stopping server' not in text or 'Forge Mod Loader has identified 5 mods to load' not in text: + raise RuntimeError('Ordinary server failed clean startup/stop: '+str(log)) + if 'lootablebodiesbuildtest' in text or list((home/'crash-reports').glob('*')): raise RuntimeError('Probe leak or crash') + audit(log,development=args.development) + if not args.development and (len(list((home/'mods').glob('*.jar')))!=1 or digest(home/'mods'/MAIN)!=digest(jar)): + raise RuntimeError('Ordinary packaged mod set changed') + results.append(phase);print('ORDINARY_SERVER_PASS',phase,'development='+str(args.development),flush=True) + (root/'result.json').write_text(json.dumps({'candidate_sha256':digest(jar),'development':args.development, + 'phases':results,'build_test_mod_installed':False},indent=2)+'\n') + + +if __name__=='__main__':main() diff --git a/tools/verify_packaged.py b/tools/verify_packaged.py new file mode 100644 index 0000000..67f165a --- /dev/null +++ b/tools/verify_packaged.py @@ -0,0 +1,136 @@ +"""Verify the exact SRG jar in disposable Forge 1.12.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 +from prepare_migration_inputs import fetch + +FORGE = "forge-1.12.2-14.23.5.2859" +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) + for name in ['latest.log','debug.log','fml-server-latest.log']: + debug=home/'logs'/name + if debug.is_file(): + shutil.copy2(debug,home/(phase+'.'+name)) + audit(debug) + 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.12.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") + fetch("https://maven.minecraftforge.net/net/minecraftforge/forge/1.12.2-14.23.5.2859/" + installer.name, + installer,'DBBA1CD1BF34A4B82148BAC50A6150F63FFCB2D5DC7D88A0508C27FDCFF15B76') + if digest(installer) != 'DBBA1CD1BF34A4B82148BAC50A6150F63FFCB2D5DC7D88A0508C27FDCFF15B76': + 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 not (distribution / (FORGE + '-universal.jar')).is_file() and (distribution / (FORGE + '.jar')).is_file(): + shutil.copy2(distribution / (FORGE + '.jar'), distribution / (FORGE + '-universal.jar')) + if digest(distribution / (FORGE + '-universal.jar')) != 'BD8314729D787E35164476D75AFB337AFB9783917E4F6FA1F77F049ADBA22F2D': + raise RuntimeError('Forge universal hash mismatch') + if hashlib.sha1((distribution / 'minecraft_server.1.12.2.jar').read_bytes()).hexdigest() != '886945bfb2b978778c3a0288fd7fab09d315b25f': + 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..7d1f83b --- /dev/null +++ b/tools/verify_packaged_client.py @@ -0,0 +1,141 @@ +"""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 client_runtime import assemble_libraries +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/a21e1ded1a24ea1548dd8db0cf30b6acb02655a9/1.12.json').read() + if hashlib.sha1(data).hexdigest()!='a21e1ded1a24ea1548dd8db0cf30b6acb02655a9': raise RuntimeError('Asset index hash mismatch') + (folder/'indexes/1.12.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) + parser.add_argument('--mode',choices=['default','config','skin','issues','names'],default='default') + 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() + (root/'options.txt').write_text('realmsNotifications:false\n') + 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') + if args.mode not in ('default','names'): + (root/'config/lootablebodies.cfg').write_text('options {\n B:local_texture=false\n}\n') + cache=args.gradle_home.resolve()/'caches' + vanilla=cache/'minecraftforge/forgegradle/mavenizer/caches/minecraft_tasks/1.12.2/client.jar' + if hashlib.sha1(vanilla.read_bytes()).hexdigest()!='0f275bc1547d01fa5f56ba34bdc87d981ee12daf': + raise RuntimeError('Vanilla Minecraft 1.12.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.12.2-14.23.5.2859/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=assemble_libraries(dependencies,root) + 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='+(project/'src/integrationTest/resources/log4j2.xml').as_uri(), + # 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.12.2','--userType','legacy','--gameDir',str(root), + '--assetsDir',str(assets),'--assetIndex','1.12','--width','854','--height','480'] + if args.mode!='default': + option={'config':'configUiCheck','skin':'visibilitySkinCheck','issues':'issueClientCheck','names':'namelessClientCheck'}[args.mode] + command.insert(2,'-Dlootablebodies.'+option+'=true') + 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') + expected={'default':'LOOTABLE_BODIES_CLIENT_PASS','config':'LOOTABLE_BODIES_CONFIG_UI_PASS', + 'skin':'LOOTABLE_BODIES_VISIBILITY_SKIN_PASS','issues':'LOOTABLE_BODIES_ISSUE5_CLIENT_PASS','names':'LOOTABLE_BODIES_NAME_TRANSLATION_PASS'}[args.mode] + if result.returncode or not (root/'client.pass').is_file() or expected 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') + if args.mode!='default': + reports={'config':'config-checks.txt','skin':'skin-checks.txt','issues':'issue5-client-checks.txt','names':'name-checks.txt'} + report=root/reports[args.mode] + if not report.is_file() or not report.read_text().strip() or any( + not line.startswith('PASS ') for line in report.read_text().splitlines()): + raise RuntimeError('Missing graphical mode assertions: '+str(report)) + if args.mode=='config': + command.insert(2,'-Dlootablebodies.configUiReload=true') + with (root/'restart.log').open('w',encoding='utf-8') as log: + restart=subprocess.run(command,cwd=root,stdout=log,stderr=subprocess.STDOUT,timeout=120) + if restart.returncode or not (root/'config-restart.pass').is_file(): + raise RuntimeError('Configuration did not survive process restart') + audit(root/'restart.log') + if list((root/'crash-reports').glob('*')) or hashlib.sha256(main.read_bytes()).hexdigest().upper()!=candidate_hash: + raise RuntimeError('Crash or candidate mutation in graphical mode') + (root/'result.json').write_text(json.dumps({'candidate_sha256':candidate_hash,'mode':args.mode, + 'checks':report.read_text().splitlines(),'screenshots':sorted(p.name for p in (root/'screenshots').glob('*.png')), + 'computer_control':'none'},indent=2)+'\n') + print('PACKAGED_CLIENT_MODE_PASS',args.mode,flush=True) + return + 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..f18ec81 --- /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.112021"), + ("wrong-target", "mod_version=2.4.1.111022", "Major.Minor.Bug.112021"), + ("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.4.112021", "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() diff --git a/tools/verify_upgrade_abort.py b/tools/verify_upgrade_abort.py new file mode 100644 index 0000000..cd91e55 --- /dev/null +++ b/tools/verify_upgrade_abort.py @@ -0,0 +1,80 @@ +"""Prove packaged servers stop before world loading when preservation is unsafe.""" +import argparse +import json +from pathlib import Path +import shutil +import subprocess +from verify_world_upgrades import prepare, fingerprint, digest, FORGE +from release_identity import MAIN_JAR + + +def source_files(world): + return {name: value for name, value in fingerprint(world).items() + if not name.startswith('data/lootablebodies-upgrade')} + + +def inject_unsafe_journal(world, case): + if case not in ('damaged-index', 'blocked-journal'): + raise ValueError('Unknown abort fixture: ' + case) + world = world.resolve() + journal = world / 'data/lootablebodies-upgrade' + journal.parent.mkdir(exist_ok=True) + if journal.exists(): + # This is the disposable world copy; retain its prior journal outside + # the world before injecting the deliberate fault. Never touch source. + saved = world.parent / 'original-journal' + if journal.is_symlink() or not journal.resolve().is_relative_to(world): + raise RuntimeError('Abort fixture journal is outside its disposable world') + if saved.parent.resolve() != world.parent or saved.exists(): + raise RuntimeError('Abort fixture journal backup is not a new sibling') + journal.rename(saved) + if case == 'damaged-index': + journal.mkdir() + (journal / 'index.nbt').write_bytes(b'interrupted invalid index') + else: + journal.write_bytes(b'cannot create a journal directory here') + + +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('--source-world', type=Path, required=True) + parser.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + project = Path(__file__).resolve().parents[1] + candidate = project / 'build/libs' / MAIN_JAR + probe = project / 'build/integration-test-mod/lootablebodies-build-tests-reobf.jar' + source = args.source_world.resolve() + sealed = fingerprint(source) + root = args.output.resolve() + root.mkdir(parents=True, exist_ok=False) + report = {'candidate_sha256': digest(candidate), 'cases': {}} + for case in ['damaged-index', 'blocked-journal']: + home = root / case + prepare(home, args.forge_home.resolve(), '1.12.2', candidate, probe) + shutil.copytree(source, home / 'world') + world = home / 'world' + inject_unsafe_journal(world, case) + before = source_files(world) + log = home / 'abort.log' + with log.open('w', encoding='utf-8') as output: + result = subprocess.run([str(args.java.resolve()), '-Xmx1G', '-jar', + FORGE['1.12.2'] + '-universal.jar', 'nogui'], + cwd=home, stdout=output, stderr=subprocess.STDOUT, timeout=180) + text = log.read_text(encoding='utf-8', errors='replace') + if "Lootable Bodies could not preserve this world's corpses" not in text: + raise RuntimeError('Missing explicit preservation abort: ' + str(log)) + if any(token in text for token in ['Preparing start region', 'Preparing spawn area', + 'LOOTABLE_BODIES_RUNTIME_PASS', 'Done (']): + raise RuntimeError('World loading continued after unsafe preservation') + if before != source_files(world) or fingerprint(source) != sealed: + raise RuntimeError('A failed preflight changed source world data') + report['cases'][case] = {'exit': result.returncode, 'source_unchanged': True, + 'world_data_unchanged': True, 'world_loading_started': False} + print('UPGRADE_ABORT_PASS', case, flush=True) + (root / 'result.json').write_text(json.dumps(report, indent=2) + '\n', encoding='utf-8') + + +if __name__ == '__main__': + main() diff --git a/tools/verify_visibility.py b/tools/verify_visibility.py new file mode 100644 index 0000000..190f344 --- /dev/null +++ b/tools/verify_visibility.py @@ -0,0 +1,109 @@ +"""Run real two-client corpse visibility and death/respawn packet checks.""" +import argparse +import json +import socket +import subprocess +import time +from pathlib import Path +import shutil +from audit_runtime_logs import audit +from fixture_login import LoginMonitor, start_clients, write_receipts, archive_live_logs +from verify_multiplayer import client_command +from verify_packaged import prepare, FORGE, MAIN, digest, run +from verify_packaged_client import prepare_assets + + +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) + parser.add_argument('--mode',choices=['visibility','respawn'],required=True) + parser.add_argument('--source-server',type=Path) + parser.add_argument('--cases',type=int,default=16) + parser.add_argument('--simultaneous',action='store_true') + parser.add_argument('--drop-spawn',action='store_true') + parser.add_argument('--counter-collision',action='store_true') + args=parser.parse_args() + project=Path(__file__).resolve().parents[1] + java=args.java.resolve(); distribution=args.forge_home.resolve(); home=args.output.resolve() + home.mkdir(parents=True,exist_ok=False); shared=home/'sync'; shared.mkdir() + jar=project/'build/libs'/MAIN; before=digest(jar) + probe=project/'build/integration-test-mod/lootablebodies-build-tests-reobf.jar' + prepare(home/'server',distribution,jar,probe) + source_before=None + if args.source_server: + from verify_world_upgrades import fingerprint + source_before=fingerprint(args.source_server/'world') + shutil.copytree(args.source_server/'world',home/'server/world') + with socket.socket() as reservation: + reservation.bind(('127.0.0.1',0)); port=reservation.getsockname()[1] + props=(home/'server/server.properties').read_text().replace('server-port=0','server-port='+str(port))+'view-distance=4\n' + (home/'server/server.properties').write_text(props) + commands={'server':[str(java),'-Xmx1G','-Dlootablebodies.'+args.mode+'=true', + '-Dlootablebodies.multiplayer.sync='+str(shared),'-Dlootablebodies.respawn.cases='+str(args.cases), + '-jar',FORGE+'-universal.jar','nogui']} + if args.source_server: commands['server'].insert(2,'-Dlootablebodies.visibility.reload=true') + if args.simultaneous: commands['server'].insert(2,'-Dlootablebodies.respawn.simultaneous=true') + prepare_assets(project/'.gradle/packaged-assets') + for role in ['A','B']: + command=client_command(project,java,distribution,home/('client'+role),args.gradle_home.resolve()/'caches',shared,port,role) + command.insert(2,'-Dlootablebodies.'+args.mode+'=true') + if args.simultaneous: command.insert(2,'-Dlootablebodies.respawn.simultaneous=true') + if args.counter_collision: command.insert(2,'-Dlootablebodies.respawn.counterCollision=true') + if args.drop_spawn and role=='A': command.insert(2,'-Dlootablebodies.respawn.dropSpawn=true') + commands['client'+role]=command + (home/('client'+role)/'config/lootablebodies.cfg').write_text('options {\n B:local_texture=false\n}\n') + processes={}; logs=[] + def start(name): + log=(home/(name+'.log')).open('w',encoding='utf-8');logs.append(log) + processes[name]=subprocess.Popen(commands[name],cwd=home/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('Server did not start') + time.sleep(.2) + monitor=LoginMonitor(home) + start_clients(start);deadline=time.monotonic()+650 + while any(process.poll() is None for process in processes.values()): + monitor.poll() + if processes['server'].poll() is not None and not (shared/(args.mode+'.pass')).is_file(): + raise RuntimeError('Server failed: '+str(home/'server.log')) + if any(process.poll() not in (None,0) for process in processes.values()): raise RuntimeError('Owned client failed') + if time.monotonic()>deadline: raise RuntimeError('Visibility/respawn check timed out') + time.sleep(.2) + if any(process.returncode for process in processes.values()): raise RuntimeError('Process exit code') + finally: + (shared/'abort').touch() + 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/(args.mode+'.pass')).is_file(): raise RuntimeError('Missing server result') + write_receipts(home) + for name in ['server','clientA','clientB']: + if digest(home/name/'mods'/MAIN)!=before: raise RuntimeError('Candidate copy changed') + audit(home/(name+'.log'),fixture_logins=True);audit(home/name/'logs/latest.log',fixture_logins=True) + if list((home/name).rglob('crash-reports/*.txt')): raise RuntimeError('Crash report') + for role in ['A','B']: + if not (home/('client'+role)/(args.mode+'-client.pass')).is_file(): raise RuntimeError('Client did not finish '+role) + if args.mode=='visibility' and not args.source_server: + (home/'server/server.properties').write_text(props.replace('server-port='+str(port),'server-port=0')) + archive_live_logs(home) + run(java,home/'server','visibility-reload') + if args.source_server and fingerprint(args.source_server/'world')!=source_before: + raise RuntimeError('Source world changed') + if digest(jar)!=before: raise RuntimeError('Candidate changed') + checks=(shared/'checks.txt').read_text().splitlines() + if not checks or (args.mode=='respawn' and len(checks)!=args.cases): raise RuntimeError('Incomplete case matrix') + (home/'result.json').write_text(json.dumps({'candidate_sha256':before,'mode':args.mode, + 'checks':checks,'two_tcp_clients':True,'simultaneous':args.simultaneous,'drop_spawn':args.drop_spawn, + 'counter_collision':args.counter_collision,'connected_restart':bool(args.source_server), + 'computer_control':'none'},indent=2)+'\n') + print('VISIBILITY_RESPAWN_PASS',args.mode,flush=True) + + +if __name__=='__main__':main() diff --git a/tools/verify_world_upgrades.py b/tools/verify_world_upgrades.py new file mode 100644 index 0000000..6723f5c --- /dev/null +++ b/tools/verify_world_upgrades.py @@ -0,0 +1,299 @@ +"""Sealed historical-writer fixtures and exact packaged forward-upgrade matrix.""" +import argparse +import hashlib +import json +from pathlib import Path +import shutil +import subprocess +import gzip +import struct +from audit_runtime_logs import audit, FORGE_UPGRADE_RENAME +from release_identity import MAIN_JAR +from nbt_regions import Tag, decode, encode, read_region, write_disposable_region + +ORIGINS = { + 'original-110': ('1.10.2', '2.2.0', '688AFBBF4485D1F3746C68903C9E0885D57F42CCFBFA0CB4A0FC42982C0B3F2B'), + 'released-110': ('1.10.2', '2.4.1.110021', '7F9F0EE7BF09F943ED85398982EB7C2207F3DD55B75E9E92A7531C393BE1045B'), + 'original-111': ('1.11.2', '2.3.0', 'C82CC329BA53A218CADB681D646F64A9C457341577BC4F9AA07E2F9926434CCF'), + 'recovery-110': ('1.10.2', '2.4.1.110021', '7F9F0EE7BF09F943ED85398982EB7C2207F3DD55B75E9E92A7531C393BE1045B'), +} +ORIGINS.update({ + 'via111-110': ('1.10.2','2.2.0','688AFBBF4485D1F3746C68903C9E0885D57F42CCFBFA0CB4A0FC42982C0B3F2B'), + 'released-111': ('1.11.2', '2.4.2.111021', '59ACF5652505F08A7F011307B3CD68F3754FCFC14C4C330BD952475351AEC932'), + 'original-112': ('1.12.2', '2.4.0', 'DE701F657A03BFFE08D6F9FEB1516B37B23B0171B91716087C7C84D9C19B9B5C'), + 'recovery-111': ('1.11.2', '2.4.2.111021', '59ACF5652505F08A7F011307B3CD68F3754FCFC14C4C330BD952475351AEC932'), +}) +FORGE = {'1.12.2': 'forge-1.12.2-14.23.5.2859','1.10.2': 'forge-1.10.2-12.18.3.2511', '1.11.2': 'forge-1.11.2-13.20.1.2588'} +FORGE_SHA = {'1.12.2': 'BD8314729D787E35164476D75AFB337AFB9783917E4F6FA1F77F049ADBA22F2D','1.10.2': '809C2EA5B4D4F3E5A294D3A326744F20FEC412A1263E708297D00F512E377542', + '1.11.2': 'B6DFF35B5CCB0E6353FB89B5330FE930DB8FBE8B63787C9FC2F70196BD108998'} + + +def digest(path): + return hashlib.sha256(path.read_bytes()).hexdigest().upper() + + +def fingerprint(path): + return {p.relative_to(path).as_posix(): digest(p) for p in sorted(path.rglob('*')) if p.is_file()} + + +def prepare(home, distribution, minecraft, mod, probe): + home.mkdir(parents=True, exist_ok=False) + (home / 'mods').mkdir() + forge = FORGE[minecraft] + '-universal.jar' + if digest(distribution / forge) != FORGE_SHA[minecraft]: + raise RuntimeError('Incorrect Forge distribution') + for name in [forge, f'minecraft_server.{minecraft}.jar']: + shutil.copy2(distribution / name, home / name) + shutil.copytree(distribution / 'libraries', home / 'libraries') + shutil.copy2(mod, home / 'mods' / mod.name) + shutil.copy2(probe, home / 'mods' / probe.name) + (home / 'eula.txt').write_text('eula=true\n', encoding='utf-8') + (home / '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\nspawn-protection=0\nmax-tick-time=120000\n', encoding='utf-8') + for name in ['ops', 'whitelist', 'banned-players', 'banned-ips']: + (home / (name + '.json')).write_text('[]\n', encoding='utf-8') + + +def seed(java, home, minecraft, version, recovery=False): + with (home / 'seed.log').open('w', encoding='utf-8') as log: + completed = subprocess.run([str(java), '-Xmx2G', '-Dlootablebodies.fixture.version=' + version, + '-Dlootablebodies.fixture.recovery=' + str(recovery).lower(), + '-jar', FORGE[minecraft] + '-universal.jar', 'nogui'], cwd=home, + stdout=log, stderr=subprocess.STDOUT, timeout=240) + log = home / 'seed.log' + text = log.read_text(encoding='utf-8', errors='replace') + expected = f'LOOTABLE_BODIES_LEGACY_SEED_PASS minecraft={minecraft} mod={version} corpses=9 dimensions=3' + if completed.returncode or expected not in text or not (home / 'world/legacy-seed.pass').is_file(): + raise RuntimeError(f'Historical writer failed: {log}') + if list((home / 'crash-reports').glob('*')): + raise RuntimeError(f'Historical writer crashed: {home}') + audit(log) + return fingerprint(home / 'world') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--java', type=Path, required=True) + parser.add_argument('--forge110-home', type=Path, required=True) + parser.add_argument('--forge111-home', type=Path, required=True) + parser.add_argument('--forge112-home', type=Path, required=True) + for origin in ORIGINS: + if origin.startswith('recovery-') or origin=='via111-110': + continue + parser.add_argument('--' + origin, type=Path, required=True) + parser.add_argument('--origins', nargs='+', choices=list(ORIGINS), default=list(ORIGINS)) + parser.add_argument('--output', type=Path, required=True) + parser.add_argument('--seed-only', action='store_true') + parser.add_argument('--seeds', nargs='+', type=Path) + parser.add_argument('--variants', nargs='+', choices=['normal', 'stamps'], default=['normal', 'stamps']) + parser.add_argument('--control-report', type=Path) + args = parser.parse_args() + project = Path(__file__).resolve().parents[1] + if not args.seed_only: + verify(args, project) + return + root = args.output.resolve() + root.mkdir(parents=True, exist_ok=False) + seeder = project / 'build/legacy-seed/lootablebodies-world-fixtures-reobf.jar' + report = {'seeder_sha256': digest(seeder), 'origins': {}} + for origin, (minecraft, version, expected) in ORIGINS.items(): + if origin not in args.origins: + continue + if origin=='via111-110': + details=seed_transition(args,project,root,seeder) + report['origins'][origin]=details + (root/'seed-report.json').write_text(json.dumps(report,indent=2)+'\n',encoding='utf-8') + print('LEGACY_WORLD_SEALED',origin,len(details['world']),'files',flush=True) + continue + source = getattr(args, origin.replace('recovery-', 'released-').replace('-', '_')).resolve() + if digest(source) != expected: + raise RuntimeError('Historical jar checksum mismatch: ' + origin) + distribution = {'1.10.2':args.forge110_home,'1.11.2':args.forge111_home,'1.12.2':args.forge112_home}[minecraft] + home = root / origin + prepare(home, distribution.resolve(), minecraft, source, seeder) + hashes = seed(args.java.resolve(), home, minecraft, version, origin.startswith('recovery-')) + report['origins'][origin] = {'minecraft': minecraft, 'mod_version': version, 'jar_sha256': expected, 'world': hashes} + (root / 'seed-report.json').write_text(json.dumps(report, indent=2) + '\n', encoding='utf-8') + print('LEGACY_WORLD_SEALED', origin, len(hashes), 'files', flush=True) + + +def seed_transition(args,project,root,seeder): + original=args.original_110.resolve();released=args.released_111.resolve() + if digest(original)!=ORIGINS['original-110'][2] or digest(released)!=ORIGINS['released-111'][2]: + raise RuntimeError('Transition production jar checksum mismatch') + source=root/'via111-110-source' + prepare(source,args.forge110_home.resolve(),'1.10.2',original,seeder) + sealed=seed(args.java.resolve(),source,'1.10.2','2.2.0') + target=root/'via111-110' + probe=project/'build/transition-fixture/lootablebodies-111-transition-reobf.jar' + prepare(target,args.forge111_home.resolve(),'1.11.2',released,probe) + shutil.copytree(source/'world',target/'world') + distant={str(p.relative_to(target/'world')):digest(p) for p in (target/'world').rglob('region/r.*.mca') if p.name in ('r.8.8.mca','r.16.16.mca')} + with (target/'transition.log').open('w',encoding='utf-8') as log: + result=subprocess.run([str(args.java.resolve()),'-Xmx2G','-jar',FORGE['1.11.2']+'-universal.jar','nogui'],cwd=target,stdout=log,stderr=subprocess.STDOUT,timeout=180) + text=(target/'transition.log').read_text(encoding='utf-8',errors='replace') + if result.returncode or 'LOOTABLE_BODIES_TRANSITION_PASS loaded=3 unopened=6' not in text or not (target/'world/transition.pass').is_file(): + raise RuntimeError('Real released 1.11 intermediate writer failed: '+str(target/'transition.log')) + audit(target/'transition.log',upgrade=True) + if fingerprint(source/'world')!=sealed or len(distant)!=6 or any(digest(target/'world'/p)!=value for p,value in distant.items()): + raise RuntimeError('Transition changed its sealed source or unopened distant regions') + if not (target/'world/data/lootablebodies-upgrade/index.nbt').is_file() or list(target.rglob('crash-reports/*.txt')): + raise RuntimeError('Missing intermediate journal or unexpected crash') + return {'minecraft':'1.10.2 via 1.11.2','mod_version':'2.2.0 via 2.4.2.111021', + 'jar_sha256':digest(original),'intermediate_jar_sha256':digest(released),'transition_probe_sha256':digest(probe), + 'source_world_unchanged':True,'distant_regions_unchanged':distant,'world':fingerprint(target/'world')} + + +PHASES = ['upgrade-near', 'upgrade-near-reload', 'upgrade-near-recover', 'upgrade-near-recovered', + 'upgrade-far', 'upgrade-far-reload', 'upgrade-late', 'upgrade-return', 'upgrade-final', 'upgrade-final-reload'] + + +def run_upgrade(java, home, phase): + marker = home / 'world' / (phase + '.pass') + if marker.exists(): + raise RuntimeError('Refusing stale phase marker') + log = home / (phase + '.log') + with log.open('w', encoding='utf-8') as output: + result = subprocess.run([str(java), '-Xmx2G', '-Dlootablebodies.phase=' + phase, + '-jar', FORGE['1.12.2'] + '-universal.jar', 'nogui'], cwd=home, + stdout=output, stderr=subprocess.STDOUT, timeout=240) + text = log.read_text(encoding='utf-8', errors='replace') + if result.returncode or not marker.is_file() or 'LOOTABLE_BODIES_WORLD_UPGRADE_PASS phase=' + phase not in text or 'LOOTABLE_BODIES_RUNTIME_PASS phase=' + phase not in text: + raise RuntimeError('World upgrade failed: ' + str(log)) + audit(log, upgrade=True) + for name in ['latest.log','debug.log','fml-server-latest.log']: + debug=home/'logs'/name + if debug.is_file(): + shutil.copy2(debug,home/(phase+'.'+name)) + audit(debug,upgrade=True) + if list((home / 'crash-reports').glob('*')): + raise RuntimeError('World upgrade crash report: ' + str(home)) + + +def patch_stamps(world, disposable): + if not world.resolve().is_relative_to(disposable.resolve()): + raise RuntimeError('Fixture mutation outside disposable output') + changes = [] + for path in sorted(world.rglob('region/*.mca')): + chunks = read_region(path) + changed = False + for chunk in chunks.values(): + root = chunk['root'].value + level = root['Level'].value + bodies = [e for e in level.get('Entities', Tag(9, (10, []))).value[1] + if e.value.get('id', Tag(8, '')).value in ('lootablebodies.Corpse', 'lootablebodies:corpse')] + if not bodies: + continue + dimension = -1 if 'DIM-1' in path.parts else 1 if 'DIM1' in path.parts else 0 + wave = 0 if level['xPos'].value == 8 else 1 if level['xPos'].value == 256 else 2 + kind = wave * 3 + {0: 0, -1: 1, 1: 2}[dimension] + root.pop('DataVersion', None) + root.pop('ForgeDataVersion', None) + if kind == 1: + root['DataVersion'] = Tag(8, 'damaged chunk stamp') + elif kind == 2: + root['DataVersion'] = Tag(6, struct.pack('>d', float('nan'))) + elif kind == 3: + root['DataVersion'] = Tag(3, -1) + elif kind == 4: + root['ForgeDataVersion'] = Tag(8, 'damaged Forge stamp') + elif kind == 5: + root['DataVersion'] = Tag(3, 1343) + root['ForgeDataVersion'] = Tag(10, {'minecraft': Tag(3, 1343), 'lootablebodies': Tag(3, 2)}) + elif kind == 6: + root['DataVersion'] = Tag(3, 2147483647) + elif kind == 7: + root['DataVersion'] = Tag(3, 0) + elif kind == 8: + root['DataVersion'] = Tag(2, 512) + changed = True + changes.append({'region': path.relative_to(world).as_posix(), 'chunk': [level['xPos'].value, level['zPos'].value], 'stamp_case': kind}) + if changed: + write_disposable_region(path, chunks) + path = world / 'level.dat' + name, level = decode(gzip.decompress(path.read_bytes())) + level.value['Data'].value['DataVersion'] = Tag(8, 'damaged world stamp') + path.write_bytes(gzip.compress(encode(name, level), mtime=0)) + return changes + + +def verify(args, project): + if not args.seeds: + raise ValueError('--seeds is required for the upgrade matrix') + if not args.control_report: + raise ValueError('Run the independent Forge upgrade control first and supply --control-report') + control = json.loads(args.control_report.read_text(encoding='utf-8')) + if control.get('lootable_bodies_installed') is not False or control.get('source_unchanged') is not True or control.get('reproduced_diagnostic') != FORGE_UPGRADE_RENAME or control.get('control_jar_sha256') != digest(project / 'build/control-test-mod/forge-platform-control-reobf.jar'): + raise RuntimeError('Invalid independent Forge control evidence') + root = args.output.resolve() + root.mkdir(parents=True, exist_ok=False) + candidate = project / 'build/libs' / MAIN_JAR + probe = project / 'build/integration-test-mod/lootablebodies-build-tests-reobf.jar' + candidate_hash = digest(candidate) + probe_hash = digest(probe) + sources = {} + for seed_root in args.seeds: + seed_root = seed_root.resolve() + report = json.loads((seed_root / 'seed-report.json').read_text(encoding='utf-8')) + for name, details in report['origins'].items(): + if name not in args.origins: + continue + if name in sources: + raise ValueError('Ambiguous duplicate origin ' + name) + source = seed_root / name / 'world' + if fingerprint(source) != details['world']: + raise RuntimeError('Sealed source fixture changed: ' + name) + sources[name] = (source, details) + if set(sources) != set(args.origins): + raise RuntimeError('Missing requested historical fixture') + report = {'candidate_sha256': candidate_hash, 'probe_sha256':probe_hash, 'cases': {}} + for origin, (source, details) in sources.items(): + for variant in args.variants: + name = origin + '-' + variant + home = root / name + prepare(home, args.forge112_home.resolve(), '1.12.2', candidate, probe) + shutil.copytree(source, home / 'world') + world = home / 'world' + player_files = list((world / 'playerdata').glob('*.dat')) + if len(player_files) != 1: + raise RuntimeError('Expected exactly one sealed fixture player') + shutil.copy2(player_files[0], world / 'upgrade-player-source.nbt') + changes = patch_stamps(world, root) if variant == 'stamps' else [] + distant = {str(path.relative_to(world)): digest(path) for path in world.rglob('region/r.*.mca') + if path.name in ('r.8.8.mca', 'r.16.16.mca')} + if len(distant) != 6: + raise RuntimeError('Missing distant region fixtures') + phases = [] + for phase in PHASES: + run_upgrade(args.java.resolve(), home, phase) + wave = 0 if phase.startswith('upgrade-near') else 1 if phase.startswith('upgrade-far') else 2 if phase == 'upgrade-late' else -1 + persisted = 0 + for region in world.rglob('region/r.*.mca'): + if wave >= 0 and region.name != ['r.0.0.mca', 'r.8.8.mca', 'r.16.16.mca'][wave]: + continue + for chunk in read_region(region).values(): + for entity in chunk['root'].value['Level'].value.get('Entities', Tag(9, (10, []))).value[1]: + if entity.value.get('id', Tag(8, '')).value not in ('lootablebodies.Corpse', 'lootablebodies:corpse'): + continue + if wave >= 0 or phase in ('upgrade-return', 'upgrade-final', 'upgrade-final-reload'): + if entity.value['id'].value != 'lootablebodies:corpse' or entity.value.get('LootableBodiesDataVersion') != Tag(3, 2): + raise RuntimeError('A loaded corpse was not actually saved in the new format') + persisted += 1 + if persisted < (3 if wave >= 0 else 8): + raise RuntimeError('Missing persisted corpses after world save') + for relative, original in distant.items(): + unopened = (relative.endswith('r.8.8.mca') and phase.startswith('upgrade-near')) or ( + relative.endswith('r.16.16.mca') and phase in PHASES[:6]) + if unopened and digest(world / relative) != original: + raise RuntimeError('Unopened distant region was modified: ' + relative) + phases.append(phase) + if fingerprint(source) != details['world'] or digest(candidate) != candidate_hash or digest(probe) != probe_hash: + raise RuntimeError('Sealed source/candidate changed') + report['cases'][name] = {'phases': phases, 'distant_regions': distant, 'stamp_mutations': changes, + 'source_unchanged': True, 'origin_jar_sha256': details['jar_sha256']} + (root / 'result.json').write_text(json.dumps(report, indent=2) + '\n', encoding='utf-8') + print('WORLD_UPGRADE_PASS', name, phase, flush=True) + print('WORLD_UPGRADE_MATRIX_PASS cases=' + str(len(report['cases'])), flush=True) + + +if __name__ == '__main__': + main()