diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..1e5f4c3
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,15 @@
+* text=auto
+*.java text eol=lf
+*.gradle text eol=lf
+*.properties text eol=lf
+*.yml text eol=lf
+*.py text eol=lf
+*.md text eol=lf
+*.txt text eol=lf
+LICENSE* text eol=lf
+NOTICE text eol=lf
+*.lang text eol=lf
+gradlew text eol=lf
+gradlew.bat text eol=crlf
+*.jar binary
+*.png binary
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..507c232
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,250 @@
+name: LootableBodies 1.10 CI
+
+on:
+ push:
+ branches:
+ - master-1.10.2
+ - 1.10.2
+ - 'feature/**'
+ pull_request:
+ branches:
+ - master-1.10.2
+ - 1.10.2
+
+permissions:
+ contents: read
+
+concurrency:
+ group: lootablebodies-1.10-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ cold-forge-bootstrap:
+ name: Cold Forge bootstrap
+ runs-on: ubuntu-latest
+ timeout-minutes: 60
+ steps:
+ - name: Check out source
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+
+ - name: Install pinned Java 25 ForgeGradle Mavenizer runtime
+ uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5
+ with:
+ distribution: temurin
+ java-version: '25.0.3+9.0.LTS'
+
+ - name: Install pinned Java 8 compilation toolchain
+ uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5
+ with:
+ distribution: temurin
+ java-version: '8.0.502+7'
+
+ - name: Install pinned Java 17 Gradle runtime
+ uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5
+ with:
+ distribution: temurin
+ java-version: '17.0.1+12'
+
+ - name: Bootstrap Forge from an empty cache
+ env:
+ GRADLE_USER_HOME: ${{ runner.temp }}/lootablebodies-cold-gradle
+ LOOTABLEBODIES_JAVA8_HOME: ${{ env.JAVA_HOME_8_X64 }}
+ run: |
+ set -euo pipefail
+ test ! -e .gradle
+ test ! -e "$GRADLE_USER_HOME"
+ mkdir -p "$GRADLE_USER_HOME"
+ chmod +x ./gradlew
+ ./gradlew classes --no-daemon --no-build-cache --stacktrace \
+ -Dorg.gradle.java.installations.paths="$JAVA_HOME,$JAVA_HOME_8_X64,$JAVA_HOME_25_X64" \
+ -Dorg.gradle.java.installations.auto-detect=false \
+ -Dorg.gradle.java.installations.auto-download=false
+
+ - name: Verify the same cache offline
+ env:
+ GRADLE_USER_HOME: ${{ runner.temp }}/lootablebodies-cold-gradle
+ LOOTABLEBODIES_JAVA8_HOME: ${{ env.JAVA_HOME_8_X64 }}
+ run: |
+ ./gradlew classes --offline --rerun-tasks --no-daemon --no-build-cache --stacktrace \
+ -Dorg.gradle.java.installations.paths="$JAVA_HOME,$JAVA_HOME_8_X64,$JAVA_HOME_25_X64" \
+ -Dorg.gradle.java.installations.auto-detect=false \
+ -Dorg.gradle.java.installations.auto-download=false
+
+ build:
+ name: Build, test, and audit
+ runs-on: ubuntu-latest
+ timeout-minutes: 60
+
+ steps:
+ - name: Check out source
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+
+ - name: Install pinned Java 25 ForgeGradle Mavenizer runtime
+ uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5
+ with:
+ distribution: temurin
+ java-version: '25.0.3+9.0.LTS'
+
+ - name: Install pinned Java 8 compilation toolchain
+ uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5
+ with:
+ distribution: temurin
+ java-version: '8.0.502+7'
+
+ - name: Install pinned Java 17 Gradle runtime
+ uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5
+ with:
+ distribution: temurin
+ java-version: '17.0.1+12'
+
+ - name: Pin production JDK
+ run: echo "LOOTABLEBODIES_JAVA8_HOME=$JAVA_HOME_8_X64" >> "$GITHUB_ENV"
+
+ - name: Set up Gradle
+ uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6
+ with:
+ cache-disabled: true
+
+ - name: Make the wrapper executable
+ run: chmod +x ./gradlew
+
+ - name: Build, test, and audit release artifacts
+ run: >-
+ ./gradlew clean check build javadoc forgeControlServer verifyReleaseArtifacts writeReleaseChecksums
+ inventoryRegressionTest containerInteractionTest dropCreativeInteractionTest nbtRecoveryTest
+ nameLifetimeTest healthAttributeTest effectHurtTest positionMotionTest rotationFallTest
+ verifyEclipseProductionClasspath --no-daemon --stacktrace
+ -Dorg.gradle.java.installations.paths="$JAVA_HOME,$JAVA_HOME_8_X64,$JAVA_HOME_25_X64"
+ -Dorg.gradle.java.installations.auto-detect=false
+ -Dorg.gradle.java.installations.auto-download=false
+
+ - name: Install legacy client display and audio support
+ run: |
+ sudo apt-get update
+ sudo apt-get install --yes x11-xserver-utils libgl1-mesa-dri libglu1-mesa libopenal1 pulseaudio
+ command -v xrandr
+ pulseaudio --start --exit-idle-time=-1
+ pactl load-module module-null-sink sink_name=minecraft
+ echo "PULSE_SINK=minecraft" >> "$GITHUB_ENV"
+ echo "ALSOFT_DRIVERS=pulse" >> "$GITHUB_ENV"
+
+ - name: Verify real graphical client and corpse GUI
+ env:
+ LIBGL_ALWAYS_SOFTWARE: 'true'
+ run: >-
+ xvfb-run -a -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: 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.10.2-12.18.3.2511-universal.jar
+ --output build/packaged-client --gradle-home "$HOME/.gradle"
+
+ - name: Verify two real loopback clients and persisted shared inventory
+ env:
+ LIBGL_ALWAYS_SOFTWARE: 'true'
+ run: >-
+ xvfb-run -a -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: Compare a second forced build
+ run: |
+ mkdir -p "$RUNNER_TEMP/first-build"
+ cp build/libs/LootableBodies-2.4.1.110021{,-sources,-javadoc}.jar build/release/SHA256SUMS build/publications/mavenJava/pom-default.xml "$RUNNER_TEMP/first-build/"
+ ./gradlew assemble writeReleaseChecksums verifyMavenCoordinates --rerun-tasks --offline --no-daemon --no-build-cache
+ for name in LootableBodies-2.4.1.110021{,-sources,-javadoc}.jar; do
+ cmp "$RUNNER_TEMP/first-build/$name" "build/libs/$name"
+ done
+ cmp "$RUNNER_TEMP/first-build/SHA256SUMS" build/release/SHA256SUMS
+ cmp "$RUNNER_TEMP/first-build/pom-default.xml" build/publications/mavenJava/pom-default.xml
+
+ - name: Prepare and test the local release contract
+ run: |
+ ./gradlew prepareReleaseBundle --offline --no-daemon
+ python3 tools/verify_release_setup.py --output build/release-contracts
+
+ - name: Upload audited release candidate
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: LootableBodies-1.10.2-${{ github.sha }}
+ if-no-files-found: error
+ retention-days: 30
+ path: |
+ build/libs/LootableBodies-2.4.1.110021.jar
+ build/libs/LootableBodies-2.4.1.110021-sources.jar
+ build/libs/LootableBodies-2.4.1.110021-javadoc.jar
+ build/release/SHA256SUMS
+ build/publications/mavenJava/pom-default.xml
+ CHANGELOG.txt
+
+ - name: Upload diagnostics on failure
+ if: failure()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: LootableBodies-1.10.2-diagnostics-${{ github.sha }}
+ if-no-files-found: error
+ retention-days: 14
+ path: |
+ build/test-results/**
+ build/reports/**
+ build/release-contracts/**
+ build/*-run/logs/**
+ build/*-run/crash-reports/**
+ build/integration-run/world/inventory-checks.txt
+ build/integration-run/world/container-checks.txt
+ build/integration-run/world/drop-creative-checks.txt
+ build/integration-run/world/nbt-checks.txt
+ build/integration-run/world/name-lifetime-checks.txt
+ build/integration-run/world/health-attribute-checks.txt
+ build/integration-run/world/effect-hurt-checks.txt
+ build/integration-run/world/position-motion-checks.txt
+ build/integration-run/world/rotation-fall-checks.txt
+ build/packaged-runtime/**/*.log
+ build/packaged-runtime/**/crash-reports/**
+ build/packaged-runtime/candidate-fresh/world/inventory-checks.txt
+ build/packaged-runtime/candidate-fresh/world/container-checks.txt
+ build/packaged-runtime/candidate-fresh/world/drop-creative-checks.txt
+ build/packaged-runtime/candidate-fresh/world/nbt-checks.txt
+ build/packaged-runtime/candidate-fresh/world/name-lifetime-checks.txt
+ build/packaged-runtime/candidate-fresh/world/health-attribute-checks.txt
+ build/packaged-runtime/candidate-fresh/world/effect-hurt-checks.txt
+ build/packaged-runtime/candidate-fresh/world/position-motion-checks.txt
+ build/packaged-runtime/candidate-fresh/world/rotation-fall-checks.txt
+ build/packaged-nbt/**/*.log
+ build/packaged-nbt/**/crash-reports/**
+ build/packaged-nbt/candidate/world/nbt-world-checks.txt
+ build/client-integration-run/screenshots/*.png
+ build/client-integration-run/connected-checks.txt
+ build/client-integration-run/drop-client-checks.txt
+ build/client-integration-run/nbt-client-checks.txt
+ build/packaged-client/client.log
+ build/packaged-client/drop-client-checks.txt
+ build/packaged-client/nbt-client-checks.txt
+ build/packaged-client/connected-checks.txt
+ build/packaged-client/screenshots/*.png
+ build/packaged-multiplayer/**/*.log
+ build/packaged-multiplayer/**/crash-reports/**
+ build/packaged-multiplayer/clientA/screenshots/*.png
+ build/packaged-multiplayer/clientB/screenshots/*.png
+ build/packaged-multiplayer/sync/checks.txt
+ build/problems/**
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
new file mode 100644
index 0000000..8e95ee3
--- /dev/null
+++ b/.github/workflows/codeql-analysis.yml
@@ -0,0 +1,74 @@
+name: CodeQL
+
+on:
+ push:
+ branches:
+ - master-1.10.2
+ - 1.10.2
+ - 'feature/**'
+ pull_request:
+ branches:
+ - master-1.10.2
+ - 1.10.2
+ schedule:
+ - cron: '43 7 * * 4'
+
+permissions:
+ actions: read
+ contents: read
+ security-events: write
+
+jobs:
+ analyze:
+ name: Analyze Java
+ runs-on: ubuntu-latest
+ timeout-minutes: 45
+
+ steps:
+ - name: Check out source
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+
+ - name: Install pinned Java 25 ForgeGradle Mavenizer runtime
+ uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5
+ with:
+ distribution: temurin
+ java-version: '25.0.3+9.0.LTS'
+
+ - name: Install pinned Java 8 compilation toolchain
+ uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5
+ with:
+ distribution: temurin
+ java-version: '8.0.502+7'
+
+ - name: Install pinned Java 17 Gradle runtime
+ uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5
+ with:
+ distribution: temurin
+ java-version: '17.0.1+12'
+
+ - name: Pin production JDK
+ run: echo "LOOTABLEBODIES_JAVA8_HOME=$JAVA_HOME_8_X64" >> "$GITHUB_ENV"
+
+ - name: Set up Gradle
+ uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6
+
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4
+ with:
+ languages: java-kotlin
+ build-mode: manual
+
+ - name: Compile production code
+ run: |
+ chmod +x ./gradlew
+ gradle_args=(
+ clean classes --no-build-cache --rerun-tasks
+ --no-daemon --stacktrace --max-workers=2
+ -Dorg.gradle.java.installations.paths="$JAVA_HOME,$JAVA_HOME_8_X64,$JAVA_HOME_25_X64"
+ -Dorg.gradle.java.installations.auto-detect=false
+ -Dorg.gradle.java.installations.auto-download=false
+ )
+ ./gradlew "${gradle_args[@]}"
+
+ - name: Analyze
+ uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4
diff --git a/.github/workflows/deploy-release.yml b/.github/workflows/deploy-release.yml
new file mode 100644
index 0000000..1d7e23c
--- /dev/null
+++ b/.github/workflows/deploy-release.yml
@@ -0,0 +1,335 @@
+name: Release Lootable Bodies
+run-name: Release Lootable Bodies ${{ inputs.release_version }} (${{ inputs.curseforge_release_level }})
+
+on:
+ workflow_dispatch:
+ inputs:
+ release_version:
+ description: Complete Minecraft 1.10.2 version from gradle.properties, ending in 110021.
+ required: true
+ type: string
+ curseforge_release_level:
+ description: CurseForge release level.
+ required: true
+ default: release
+ type: choice
+ options: [release, beta, alpha]
+ confirm_live_publication:
+ description: Publish the audited files to Maven, CurseForge and GitHub after all checks pass.
+ required: true
+ default: false
+ type: boolean
+
+permissions:
+ actions: read
+ checks: read
+ contents: read
+
+concurrency:
+ group: lootablebodies-release-${{ inputs.release_version }}
+ cancel-in-progress: false
+
+jobs:
+ preflight:
+ name: Validate release request
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ outputs:
+ release_sha: ${{ steps.validate.outputs.release_sha }}
+ release_version: ${{ steps.validate.outputs.release_version }}
+ steps:
+ - name: Require the MMD dispatcher and a supported target
+ env:
+ RELEASE_VERSION: ${{ inputs.release_version }}
+ run: |
+ set -euo pipefail
+ [[ "$GITHUB_REPOSITORY" == 'MinecraftModDevelopmentMods/LootableBodies' ]]
+ [[ "$GITHUB_REF" == 'refs/heads/master-1.12' ]]
+ [[ "$RELEASE_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.110021$ ]]
+ - name: Check out the maintained Minecraft 1.10.2 branch
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ with:
+ ref: '1.10.2'
+ fetch-depth: 0
+ - name: Validate identity, exact commit and successful checks
+ id: validate
+ env:
+ GH_TOKEN: ${{ github.token }}
+ RELEASE_VERSION: ${{ inputs.release_version }}
+ run: |
+ set -euo pipefail
+ python3 - <<'PY'
+ import json, os, re, subprocess
+ from pathlib import Path
+ values = dict(line.split('=', 1) for line in Path('gradle.properties').read_text().splitlines()
+ if line.strip() and not line.lstrip().startswith('#') and '=' in line)
+ expected = {'minecraft_version': '1.10.2', 'forge_version': '12.18.3.2511',
+ 'loader_name': 'forge', 'loader_code': '1', 'java_version': '8',
+ 'java_toolchain_version': '8.0.502+7', 'gradle_java_version': '17',
+ 'mod_id': 'lootablebodies', 'mod_artifact': 'LootableBodies',
+ 'mod_group': 'zone.moddev.mc.lootablebodies', 'mod_license': 'LGPL-2.1-only',
+ 'curseforge_project_id': '240968'}
+ if any(values.get(key) != value for key, value in expected.items()):
+ raise SystemExit('Release identity differs from the maintained Minecraft 1.10.2 build')
+ version = os.environ['RELEASE_VERSION']
+ if version != values['mod_version'] or not re.fullmatch(r'(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.110021', version):
+ raise SystemExit('Requested version does not match the selected branch')
+ sha = subprocess.check_output(['git', 'rev-parse', 'HEAD'], text=True).strip()
+ repo = os.environ['GITHUB_REPOSITORY']
+ checks = json.loads(subprocess.check_output(['gh', 'api', f'repos/{repo}/commits/{sha}/check-runs?per_page=100'], text=True))['check_runs']
+ required = {'Cold Forge bootstrap', 'Build, test, and audit', 'Analyze Java', 'Validation'}
+ for name in required:
+ matching = [check for check in checks if check['name'] == name]
+ if not matching or max(matching, key=lambda c: c['id'])['conclusion'] != 'success':
+ raise SystemExit(f'The selected commit has no successful {name} check')
+ release = subprocess.run(['gh', 'api', f'repos/{repo}/releases/tags/{version}'], capture_output=True, text=True)
+ if release.returncode == 0:
+ raise SystemExit('This GitHub release already exists; inspect the completed publication before retrying')
+ if '404' not in release.stderr:
+ raise SystemExit('Could not establish whether this release already exists')
+ tag = subprocess.run(['gh', 'api', f'repos/{repo}/git/ref/tags/{version}'], capture_output=True, text=True)
+ if tag.returncode == 0 and json.loads(tag.stdout)['object']['sha'] != sha:
+ raise SystemExit('Existing release tag points to another commit')
+ if tag.returncode != 0 and '404' not in tag.stderr:
+ raise SystemExit('Could not verify the release tag')
+ with open(os.environ['GITHUB_OUTPUT'], 'a') as out:
+ out.write(f'release_sha={sha}\nrelease_version={version}\n')
+ PY
+
+ build:
+ name: Build immutable release artifact
+ needs: preflight
+ runs-on: ubuntu-latest
+ timeout-minutes: 60
+ outputs:
+ artifact_name: ${{ steps.bundle.outputs.artifact_name }}
+ steps:
+ - name: Check out the verified commit
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ with:
+ ref: ${{ needs.preflight.outputs.release_sha }}
+ - name: Install Mavenizer Java
+ uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5
+ with:
+ distribution: temurin
+ java-version: '25.0.3+9.0.LTS'
+ - name: Install production Java
+ uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5
+ with:
+ distribution: temurin
+ java-version: '8.0.502+7'
+ - name: Install Gradle Java
+ uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5
+ with:
+ distribution: temurin
+ java-version: '17.0.1+12'
+ - name: Pin the production toolchain
+ run: echo "LOOTABLEBODIES_JAVA8_HOME=$JAVA_HOME_8_X64" >> "$GITHUB_ENV"
+ - name: Set up Gradle
+ uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6
+ - name: Build, test and audit the release
+ run: |
+ chmod +x ./gradlew
+ ./gradlew clean build javadoc verifyEclipseProductionClasspath prepareReleaseBundle \
+ --no-daemon --stacktrace \
+ -Dorg.gradle.java.installations.paths="$JAVA_HOME,$JAVA_HOME_8_X64,$JAVA_HOME_25_X64" \
+ -Dorg.gradle.java.installations.auto-detect=false \
+ -Dorg.gradle.java.installations.auto-download=false
+ - name: Stage the exact audited bundle
+ id: bundle
+ env:
+ RELEASE_VERSION: ${{ needs.preflight.outputs.release_version }}
+ RELEASE_SHA: ${{ needs.preflight.outputs.release_sha }}
+ run: |
+ set -euo pipefail
+ mkdir -p "$RUNNER_TEMP/release-bundle"
+ cp build/release/bundle/* "$RUNNER_TEMP/release-bundle/"
+ python3 - <<'PY'
+ import os
+ from pathlib import Path
+ prefix = 'LootableBodies-' + os.environ['RELEASE_VERSION']
+ expected = {prefix + suffix for suffix in ['.jar', '-sources.jar', '-javadoc.jar', '.pom']}
+ expected.update({'SHA256SUMS', 'CHANGELOG.txt'})
+ actual = {p.name for p in (Path(os.environ['RUNNER_TEMP']) / 'release-bundle').iterdir()}
+ if actual != expected:
+ raise SystemExit('Release bundle contains missing or unexpected files')
+ PY
+ (cd "$RUNNER_TEMP/release-bundle" && sha256sum --check SHA256SUMS)
+ echo "artifact_name=LootableBodies-$RELEASE_VERSION-$RELEASE_SHA-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" >> "$GITHUB_OUTPUT"
+ {
+ echo "Prepared Lootable Bodies $RELEASE_VERSION from commit $RELEASE_SHA."
+ echo
+ cat build/release/SHA256SUMS
+ } >> "$GITHUB_STEP_SUMMARY"
+ - name: Upload the immutable release bundle
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: ${{ steps.bundle.outputs.artifact_name }}
+ if-no-files-found: error
+ retention-days: 90
+ path: ${{ runner.temp }}/release-bundle/*
+
+ release_confirmation:
+ name: Confirm live publication
+ needs: [preflight, build]
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - name: Require confirmation and release-secret access
+ env:
+ CONFIRM_LIVE_PUBLICATION: ${{ inputs.confirm_live_publication }}
+ CURSEFORGE_TOKEN: ${{ secrets.CURSEFORGE_TOKEN }}
+ MAVEN_UPLOAD_URL: ${{ secrets.MAVEN_UPLOAD_URL }}
+ MAVEN_UPLOAD_USERNAME: ${{ secrets.MAVEN_UPLOAD_USERNAME }}
+ MAVEN_UPLOAD_PASSWORD: ${{ secrets.MAVEN_UPLOAD_PASSWORD }}
+ run: |
+ set -euo pipefail
+ [[ "$CONFIRM_LIVE_PUBLICATION" == true ]] || { echo 'Live publication was not confirmed'; exit 1; }
+ for name in CURSEFORGE_TOKEN MAVEN_UPLOAD_URL MAVEN_UPLOAD_USERNAME MAVEN_UPLOAD_PASSWORD; do
+ [[ -n "${!name:-}" ]] || { echo "Required release secret is unavailable: $name"; exit 1; }
+ done
+ echo 'Publication confirmed; all required release secrets are available.'
+
+ create_release_tag:
+ name: Create validated release tag
+ needs: [preflight, build, release_confirmation]
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ permissions:
+ contents: write
+ steps:
+ - name: Create or verify the immutable version tag
+ env:
+ GH_TOKEN: ${{ github.token }}
+ RELEASE_SHA: ${{ needs.preflight.outputs.release_sha }}
+ RELEASE_VERSION: ${{ needs.preflight.outputs.release_version }}
+ run: |
+ set -euo pipefail
+ if tag="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_VERSION" 2>/dev/null)"; then
+ [[ "$(jq -r '.object.sha' <<<"$tag")" == "$RELEASE_SHA" ]]
+ else
+ gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \
+ -f ref="refs/tags/$RELEASE_VERSION" -f sha="$RELEASE_SHA" >/dev/null
+ fi
+ [[ "$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_VERSION" --jq '.object.sha')" == "$RELEASE_SHA" ]]
+
+ publish_maven:
+ name: Publish Maven
+ needs: [preflight, build, create_release_tag]
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ steps:
+ - name: Check out the verified publication definition
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ with:
+ ref: ${{ needs.preflight.outputs.release_sha }}
+ - name: Install Mavenizer Java
+ uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5
+ with:
+ distribution: temurin
+ java-version: '25.0.3+9.0.LTS'
+ - name: Install production Java
+ uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5
+ with:
+ distribution: temurin
+ java-version: '8.0.502+7'
+ - name: Install Gradle Java
+ uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5
+ with:
+ distribution: temurin
+ java-version: '17.0.1+12'
+ - name: Pin the production toolchain
+ run: echo "LOOTABLEBODIES_JAVA8_HOME=$JAVA_HOME_8_X64" >> "$GITHUB_ENV"
+ - name: Set up Gradle
+ uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6
+ - name: Download the immutable release bundle
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+ with:
+ name: ${{ needs.build.outputs.artifact_name }}
+ path: ${{ runner.temp }}/release-input
+ - name: Verify release checksums
+ run: (cd "$RUNNER_TEMP/release-input" && sha256sum --check SHA256SUMS)
+ - name: Publish the prepared Maven artifacts
+ env:
+ MAVEN_UPLOAD_URL: ${{ secrets.MAVEN_UPLOAD_URL }}
+ MAVEN_UPLOAD_USERNAME: ${{ secrets.MAVEN_UPLOAD_USERNAME }}
+ MAVEN_UPLOAD_PASSWORD: ${{ secrets.MAVEN_UPLOAD_PASSWORD }}
+ LOOTABLEBODIES_RELEASE_SHA: ${{ needs.preflight.outputs.release_sha }}
+ LOOTABLEBODIES_RELEASE_VERSION: ${{ needs.preflight.outputs.release_version }}
+ run: |
+ chmod +x ./gradlew
+ ./gradlew publishMavenJavaPublicationToReleaseRepository \
+ -PpreparedReleaseDir="$RUNNER_TEMP/release-input" --no-daemon --stacktrace \
+ -Dorg.gradle.java.installations.paths="$JAVA_HOME,$JAVA_HOME_8_X64,$JAVA_HOME_25_X64" \
+ -Dorg.gradle.java.installations.auto-detect=false \
+ -Dorg.gradle.java.installations.auto-download=false
+
+ publish_curseforge:
+ name: Publish CurseForge
+ needs: [preflight, build, publish_maven]
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ steps:
+ - name: Download the immutable release bundle
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+ with:
+ name: ${{ needs.build.outputs.artifact_name }}
+ path: ${{ runner.temp }}/release-input
+ - name: Verify release checksums
+ run: (cd "$RUNNER_TEMP/release-input" && sha256sum --check SHA256SUMS)
+ - name: Publish Lootable Bodies to CurseForge
+ uses: Kira-NT/mc-publish@52307b03863581dec6b652b83e597aec02ebb075 # v3.3.1
+ with:
+ curseforge-token: ${{ secrets.CURSEFORGE_TOKEN }}
+ curseforge-id: '240968'
+ name: Lootable Bodies ${{ needs.preflight.outputs.release_version }} for Minecraft 1.10.2 (Forge)
+ version: ${{ needs.preflight.outputs.release_version }}
+ version-type: ${{ inputs.curseforge_release_level }}
+ changelog-file: ${{ runner.temp }}/release-input/CHANGELOG.txt
+ loaders: forge
+ game-versions: '1.10.2'
+ game-version-filter: none
+ environment: client | server
+ java: Java 8
+ fail-mode: fail
+ files: |
+ ${{ runner.temp }}/release-input/LootableBodies-${{ needs.preflight.outputs.release_version }}.jar
+ ${{ runner.temp }}/release-input/LootableBodies-${{ needs.preflight.outputs.release_version }}-sources.jar
+ ${{ runner.temp }}/release-input/LootableBodies-${{ needs.preflight.outputs.release_version }}-javadoc.jar
+
+ publish_github:
+ name: Publish GitHub Release
+ needs: [preflight, build, publish_curseforge]
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ permissions:
+ contents: write
+ actions: read
+ steps:
+ - name: Download the immutable release bundle
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+ with:
+ name: ${{ needs.build.outputs.artifact_name }}
+ path: ${{ runner.temp }}/release-input
+ - name: Verify release checksums
+ run: (cd "$RUNNER_TEMP/release-input" && sha256sum --check SHA256SUMS)
+ - name: Publish the GitHub release last
+ uses: Kira-NT/mc-publish@52307b03863581dec6b652b83e597aec02ebb075 # v3.3.1
+ with:
+ github-token: ${{ github.token }}
+ github-tag: ${{ needs.preflight.outputs.release_version }}
+ github-commitish: ${{ needs.preflight.outputs.release_sha }}
+ github-draft: false
+ github-prerelease: false
+ name: Lootable Bodies ${{ needs.preflight.outputs.release_version }} for Minecraft 1.10.2 (Forge)
+ version: ${{ needs.preflight.outputs.release_version }}
+ version-type: release
+ changelog-file: ${{ runner.temp }}/release-input/CHANGELOG.txt
+ fail-mode: fail
+ files: |
+ ${{ runner.temp }}/release-input/LootableBodies-${{ needs.preflight.outputs.release_version }}.jar
+ ${{ runner.temp }}/release-input/LootableBodies-${{ needs.preflight.outputs.release_version }}-sources.jar
+ ${{ runner.temp }}/release-input/LootableBodies-${{ needs.preflight.outputs.release_version }}-javadoc.jar
+ ${{ runner.temp }}/release-input/LootableBodies-${{ needs.preflight.outputs.release_version }}.pom
+ ${{ runner.temp }}/release-input/SHA256SUMS
+ ${{ runner.temp }}/release-input/CHANGELOG.txt
diff --git a/.github/workflows/release-on-tag.yml b/.github/workflows/release-on-tag.yml
new file mode 100644
index 0000000..25b380d
--- /dev/null
+++ b/.github/workflows/release-on-tag.yml
@@ -0,0 +1,60 @@
+name: Validate Lootable Bodies release tag
+
+on:
+ push:
+ tags:
+ - '*.*.*.*'
+
+permissions:
+ actions: read
+ contents: read
+ checks: read
+
+concurrency:
+ group: lootablebodies-release-tag-${{ github.ref_name }}
+ cancel-in-progress: false
+
+jobs:
+ validate-release-tag:
+ name: Validate release identity and prior CI
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - name: Check out tagged source
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+
+ - name: Validate target-qualified tag
+ run: |
+ python3 - <<'PY'
+ import os
+ from pathlib import Path
+ import re
+ values = dict(line.split('=', 1) for line in Path('gradle.properties').read_text().splitlines()
+ if line.strip() and not line.lstrip().startswith('#') and '=' in line)
+ expected = {'minecraft_version': '1.10.2', 'loader_name': 'forge', 'loader_code': '1',
+ 'mod_id': 'lootablebodies', 'mod_artifact': 'LootableBodies',
+ 'mod_group': 'zone.moddev.mc.lootablebodies', 'curseforge_project_id': '240968',
+ 'mod_license': 'LGPL-2.1-only'}
+ if any(values.get(key) != value for key, value in expected.items()):
+ raise SystemExit('Wrong Lootable Bodies release identity')
+ version = values['mod_version']
+ if not re.fullmatch(r'(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.110021', version):
+ raise SystemExit('Release version must use Major.Minor.Bug.110021')
+ if os.environ['GITHUB_REF_NAME'] != version:
+ raise SystemExit('Release tag must exactly match mod_version')
+ PY
+
+ - name: Require successful build checks on this commit
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ set -euo pipefail
+ count="$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/check-runs?per_page=100" \
+ --jq '[.check_runs[] | select(.name == "Build, test, and audit" and .conclusion == "success")] | length')"
+ if [[ "$count" -lt 1 ]]; then
+ echo 'The tagged commit has no successful Build, test, and audit check' >&2
+ exit 1
+ fi
+
+ - name: Record the release workflow link
+ run: echo 'Tag validated. Publish only through the confirmed Release Lootable Bodies workflow on the default branch.' >> "$GITHUB_STEP_SUMMARY"
diff --git a/.github/workflows/validate-gradle-build.yml b/.github/workflows/validate-gradle-build.yml
new file mode 100644
index 0000000..5189337
--- /dev/null
+++ b/.github/workflows/validate-gradle-build.yml
@@ -0,0 +1,25 @@
+name: Validate Gradle Wrapper
+
+on:
+ push:
+ branches:
+ - master-1.10.2
+ - 1.10.2
+ - 'feature/**'
+ pull_request:
+ branches:
+ - master-1.10.2
+ - 1.10.2
+
+permissions:
+ contents: read
+
+jobs:
+ validation:
+ name: Validation
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check out source
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ - name: Validate wrapper integrity
+ uses: gradle/actions/wrapper-validation@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6
diff --git a/.gitignore b/.gitignore
index 372b4f6..66da4f4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,4 +15,18 @@
/.idea/*
temp/*
/run/*
+/.project
+/.settings/
+/*.launch
+/.eclipse/
+/build/
+/bin/
+__pycache__/
+*.pyc
+
+/gradle-local.properties
+/logs/
+/crash-reports/
+/runs/
+/out/
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index a430138..0000000
--- a/.travis.yml
+++ /dev/null
@@ -1,12 +0,0 @@
-language: java
-cache:
- directories:
- - $HOME/.gradle
-notifications:
- email: false
-jdk:
- - oraclejdk8
-install: ./gradlew setupCIWorkspace -S
-script: ./gradlew build -S
-before_install:
- - chmod a+x gradlew
diff --git a/CHANGELOG.txt b/CHANGELOG.txt
new file mode 100644
index 0000000..0a5f148
--- /dev/null
+++ b/CHANGELOG.txt
@@ -0,0 +1,22 @@
+2.4.1.110021 - Minecraft 1.10.2, Forge
+
+Updated the Minecraft 1.10.2 build and release setup.
+
+- Fixed item loss and duplication during corpse creation, inventory transfers,
+ hotbar swaps, dragging and cancelled drops.
+- Improved recovery of damaged corpse records while keeping recoverable items.
+- Corpses wait for nearby unloaded terrain instead of falling through it.
+- Failed player skin lookups use a default skin without blocking the game.
+- Added a Config button on the Mods list. Appearance changes apply locally;
+ world and server rules take effect after restarting Minecraft.
+- Kept translations intact when building jars on Windows.
+
+Unreadable item and damaged field records stay with the corpse for recovery.
+Keep backups and keep using this maintained reader for affected saves; older
+builds discard its additional recovery records when saving. Missing mods must
+be restored before their items can be recovered. This release does not qualify
+arbitrary modpacks or upgrades to a different Minecraft version.
+
+Existing corpse inventories, saved identities and configuration defaults are
+preserved. Created by Cyanobacterium; maintained by SkyBlade1978 and Minecraft
+Mod Development. This release uses LGPLv2.1 and retains the original CC0 notice.
diff --git a/LICENSE b/LICENSE
index 670154e..f6683e7 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,116 +1,501 @@
-CC0 1.0 Universal
-
-Statement of Purpose
-
-The laws of most jurisdictions throughout the world automatically confer
-exclusive Copyright and Related Rights (defined below) upon the creator and
-subsequent owner(s) (each and all, an "owner") of an original work of
-authorship and/or a database (each, a "Work").
-
-Certain owners wish to permanently relinquish those rights to a Work for the
-purpose of contributing to a commons of creative, cultural and scientific
-works ("Commons") that the public can reliably and without fear of later
-claims of infringement build upon, modify, incorporate in other works, reuse
-and redistribute as freely as possible in any form whatsoever and for any
-purposes, including without limitation commercial purposes. These owners may
-contribute to the Commons to promote the ideal of a free culture and the
-further production of creative, cultural and scientific works, or to gain
-reputation or greater distribution for their Work in part through the use and
-efforts of others.
-
-For these and/or other purposes and motivations, and without any expectation
-of additional consideration or compensation, the person associating CC0 with a
-Work (the "Affirmer"), to the extent that he or she is an owner of Copyright
-and Related Rights in the Work, voluntarily elects to apply CC0 to the Work
-and publicly distribute the Work under its terms, with knowledge of his or her
-Copyright and Related Rights in the Work and the meaning and intended legal
-effect of CC0 on those rights.
-
-1. Copyright and Related Rights. A Work made available under CC0 may be
-protected by copyright and related or neighboring rights ("Copyright and
-Related Rights"). Copyright and Related Rights include, but are not limited
-to, the following:
-
- i. the right to reproduce, adapt, distribute, perform, display, communicate,
- and translate a Work;
-
- ii. moral rights retained by the original author(s) and/or performer(s);
-
- iii. publicity and privacy rights pertaining to a person's image or likeness
- depicted in a Work;
-
- iv. rights protecting against unfair competition in regards to a Work,
- subject to the limitations in paragraph 4(a), below;
-
- v. rights protecting the extraction, dissemination, use and reuse of data in
- a Work;
-
- vi. database rights (such as those arising under Directive 96/9/EC of the
- European Parliament and of the Council of 11 March 1996 on the legal
- protection of databases, and under any national implementation thereof,
- including any amended or successor version of such directive); and
-
- vii. other similar, equivalent or corresponding rights throughout the world
- based on applicable law or treaty, and any national implementations thereof.
-
-2. Waiver. To the greatest extent permitted by, but not in contravention of,
-applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and
-unconditionally waives, abandons, and surrenders all of Affirmer's Copyright
-and Related Rights and associated claims and causes of action, whether now
-known or unknown (including existing as well as future claims and causes of
-action), in the Work (i) in all territories worldwide, (ii) for the maximum
-duration provided by applicable law or treaty (including future time
-extensions), (iii) in any current or future medium and for any number of
-copies, and (iv) for any purpose whatsoever, including without limitation
-commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes
-the Waiver for the benefit of each member of the public at large and to the
-detriment of Affirmer's heirs and successors, fully intending that such Waiver
-shall not be subject to revocation, rescission, cancellation, termination, or
-any other legal or equitable action to disrupt the quiet enjoyment of the Work
-by the public as contemplated by Affirmer's express Statement of Purpose.
-
-3. Public License Fallback. Should any part of the Waiver for any reason be
-judged legally invalid or ineffective under applicable law, then the Waiver
-shall be preserved to the maximum extent permitted taking into account
-Affirmer's express Statement of Purpose. In addition, to the extent the Waiver
-is so judged Affirmer hereby grants to each affected person a royalty-free,
-non transferable, non sublicensable, non exclusive, irrevocable and
-unconditional license to exercise Affirmer's Copyright and Related Rights in
-the Work (i) in all territories worldwide, (ii) for the maximum duration
-provided by applicable law or treaty (including future time extensions), (iii)
-in any current or future medium and for any number of copies, and (iv) for any
-purpose whatsoever, including without limitation commercial, advertising or
-promotional purposes (the "License"). The License shall be deemed effective as
-of the date CC0 was applied by Affirmer to the Work. Should any part of the
-License for any reason be judged legally invalid or ineffective under
-applicable law, such partial invalidity or ineffectiveness shall not
-invalidate the remainder of the License, and in such case Affirmer hereby
-affirms that he or she will not (i) exercise any of his or her remaining
-Copyright and Related Rights in the Work or (ii) assert any associated claims
-and causes of action with respect to the Work, in either case contrary to
-Affirmer's express Statement of Purpose.
-
-4. Limitations and Disclaimers.
-
- a. No trademark or patent rights held by Affirmer are waived, abandoned,
- surrendered, licensed or otherwise affected by this document.
-
- b. Affirmer offers the Work as-is and makes no representations or warranties
- of any kind concerning the Work, express, implied, statutory or otherwise,
- including without limitation warranties of title, merchantability, fitness
- for a particular purpose, non infringement, or the absence of latent or
- other defects, accuracy, or the present or absence of errors, whether or not
- discoverable, all to the greatest extent permissible under applicable law.
-
- c. Affirmer disclaims responsibility for clearing rights of other persons
- that may apply to the Work or any use thereof, including without limitation
- any person's Copyright and Related Rights in the Work. Further, Affirmer
- disclaims responsibility for obtaining any necessary consents, permissions
- or other rights required for any use of the Work.
-
- d. Affirmer understands and acknowledges that Creative Commons is not a
- party to this document and has no duty or obligation with respect to this
- CC0 or use of the Work.
-
-For more information, please see
-
+ GNU LESSER GENERAL PUBLIC LICENSE
+ Version 2.1, February 1999
+
+ Copyright (C) 1991, 1999 Free Software Foundation, Inc.
+
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+[This is the first released version of the Lesser GPL. It also counts
+ as the successor of the GNU Library Public License, version 2, hence
+ the version number 2.1.]
+
+ Preamble
+
+ The licenses for most software are designed to take away your
+freedom to share and change it. By contrast, the GNU General Public
+Licenses are intended to guarantee your freedom to share and change
+free software--to make sure the software is free for all its users.
+
+ This license, the Lesser General Public License, applies to some
+specially designated software packages--typically libraries--of the
+Free Software Foundation and other authors who decide to use it. You
+can use it too, but we suggest you first think carefully about whether
+this license or the ordinary General Public License is the better
+strategy to use in any particular case, based on the explanations below.
+
+ When we speak of free software, we are referring to freedom of use,
+not price. Our General Public Licenses are designed to make sure that
+you have the freedom to distribute copies of free software (and charge
+for this service if you wish); that you receive source code or can get
+it if you want it; that you can change the software and use pieces of
+it in new free programs; and that you are informed that you can do
+these things.
+
+ To protect your rights, we need to make restrictions that forbid
+distributors to deny you these rights or to ask you to surrender these
+rights. These restrictions translate to certain responsibilities for
+you if you distribute copies of the library or if you modify it.
+
+ For example, if you distribute copies of the library, whether gratis
+or for a fee, you must give the recipients all the rights that we gave
+you. You must make sure that they, too, receive or can get the source
+code. If you link other code with the library, you must provide
+complete object files to the recipients, so that they can relink them
+with the library after making changes to the library and recompiling
+it. And you must show them these terms so they know their rights.
+
+ We protect your rights with a two-step method: (1) we copyright the
+library, and (2) we offer you this license, which gives you legal
+permission to copy, distribute and/or modify the library.
+
+ To protect each distributor, we want to make it very clear that
+there is no warranty for the free library. Also, if the library is
+modified by someone else and passed on, the recipients should know
+that what they have is not the original version, so that the original
+author's reputation will not be affected by problems that might be
+introduced by others.
+
+ Finally, software patents pose a constant threat to the existence of
+any free program. We wish to make sure that a company cannot
+effectively restrict the users of a free program by obtaining a
+restrictive license from a patent holder. Therefore, we insist that
+any patent license obtained for a version of the library must be
+consistent with the full freedom of use specified in this license.
+
+ Most GNU software, including some libraries, is covered by the
+ordinary GNU General Public License. This license, the GNU Lesser
+General Public License, applies to certain designated libraries, and
+is quite different from the ordinary General Public License. We use
+this license for certain libraries in order to permit linking those
+libraries into non-free programs.
+
+ When a program is linked with a library, whether statically or using
+a shared library, the combination of the two is legally speaking a
+combined work, a derivative of the original library. The ordinary
+General Public License therefore permits such linking only if the
+entire combination fits its criteria of freedom. The Lesser General
+Public License permits more lax criteria for linking other code with
+the library.
+
+ We call this license the "Lesser" General Public License because it
+does Less to protect the user's freedom than the ordinary General
+Public License. It also provides other free software developers Less
+of an advantage over competing non-free programs. These disadvantages
+are the reason we use the ordinary General Public License for many
+libraries. However, the Lesser license provides advantages in certain
+special circumstances.
+
+ For example, on rare occasions, there may be a special need to
+encourage the widest possible use of a certain library, so that it becomes
+a de-facto standard. To achieve this, non-free programs must be
+allowed to use the library. A more frequent case is that a free
+library does the same job as widely used non-free libraries. In this
+case, there is little to gain by limiting the free library to free
+software only, so we use the Lesser General Public License.
+
+ In other cases, permission to use a particular library in non-free
+programs enables a greater number of people to use a large body of
+free software. For example, permission to use the GNU C Library in
+non-free programs enables many more people to use the whole GNU
+operating system, as well as its variant, the GNU/Linux operating
+system.
+
+ Although the Lesser General Public License is Less protective of the
+users' freedom, it does ensure that the user of a program that is
+linked with the Library has the freedom and the wherewithal to run
+that program using a modified version of the Library.
+
+ The precise terms and conditions for copying, distribution and
+modification follow. Pay close attention to the difference between a
+"work based on the library" and a "work that uses the library". The
+former contains code derived from the library, whereas the latter must
+be combined with the library in order to run.
+
+ GNU LESSER GENERAL PUBLIC LICENSE
+ TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+ 0. This License Agreement applies to any software library or other
+program which contains a notice placed by the copyright holder or
+other authorized party saying it may be distributed under the terms of
+this Lesser General Public License (also called "this License").
+Each licensee is addressed as "you".
+
+ A "library" means a collection of software functions and/or data
+prepared so as to be conveniently linked with application programs
+(which use some of those functions and data) to form executables.
+
+ The "Library", below, refers to any such software library or work
+which has been distributed under these terms. A "work based on the
+Library" means either the Library or any derivative work under
+copyright law: that is to say, a work containing the Library or a
+portion of it, either verbatim or with modifications and/or translated
+straightforwardly into another language. (Hereinafter, translation is
+included without limitation in the term "modification".)
+
+ "Source code" for a work means the preferred form of the work for
+making modifications to it. For a library, complete source code means
+all the source code for all modules it contains, plus any associated
+interface definition files, plus the scripts used to control compilation
+and installation of the library.
+
+ Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope. The act of
+running a program using the Library is not restricted, and output from
+such a program is covered only if its contents constitute a work based
+on the Library (independent of the use of the Library in a tool for
+writing it). Whether that is true depends on what the Library does
+and what the program that uses the Library does.
+
+ 1. You may copy and distribute verbatim copies of the Library's
+complete source code as you receive it, in any medium, provided that
+you conspicuously and appropriately publish on each copy an
+appropriate copyright notice and disclaimer of warranty; keep intact
+all the notices that refer to this License and to the absence of any
+warranty; and distribute a copy of this License along with the
+Library.
+
+ You may charge a fee for the physical act of transferring a copy,
+and you may at your option offer warranty protection in exchange for a
+fee.
+
+ 2. You may modify your copy or copies of the Library or any portion
+of it, thus forming a work based on the Library, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+ a) The modified work must itself be a software library.
+
+ b) You must cause the files modified to carry prominent notices
+ stating that you changed the files and the date of any change.
+
+ c) You must cause the whole of the work to be licensed at no
+ charge to all third parties under the terms of this License.
+
+ d) If a facility in the modified Library refers to a function or a
+ table of data to be supplied by an application program that uses
+ the facility, other than as an argument passed when the facility
+ is invoked, then you must make a good faith effort to ensure that,
+ in the event an application does not supply such function or
+ table, the facility still operates, and performs whatever part of
+ its purpose remains meaningful.
+
+ (For example, a function in a library to compute square roots has
+ a purpose that is entirely well-defined independent of the
+ application. Therefore, Subsection 2d requires that any
+ application-supplied function or table used by this function must
+ be optional: if the application does not supply it, the square
+ root function must still compute square roots.)
+
+These requirements apply to the modified work as a whole. If
+identifiable sections of that work are not derived from the Library,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works. But when you
+distribute the same sections as part of a whole which is a work based
+on the Library, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote
+it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Library.
+
+In addition, mere aggregation of another work not based on the Library
+with the Library (or with a work based on the Library) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+ 3. You may opt to apply the terms of the ordinary GNU General Public
+License instead of this License to a given copy of the Library. To do
+this, you must alter all the notices that refer to this License, so
+that they refer to the ordinary GNU General Public License, version 2,
+instead of to this License. (If a newer version than version 2 of the
+ordinary GNU General Public License has appeared, then you can specify
+that version instead if you wish.) Do not make any other change in
+these notices.
+
+ Once this change is made in a given copy, it is irreversible for
+that copy, so the ordinary GNU General Public License applies to all
+subsequent copies and derivative works made from that copy.
+
+ This option is useful when you wish to copy part of the code of
+the Library into a program that is not a library.
+
+ 4. You may copy and distribute the Library (or a portion or
+derivative of it, under Section 2) in object code or executable form
+under the terms of Sections 1 and 2 above provided that you accompany
+it with the complete corresponding machine-readable source code, which
+must be distributed under the terms of Sections 1 and 2 above on a
+medium customarily used for software interchange.
+
+ If distribution of object code is made by offering access to copy
+from a designated place, then offering equivalent access to copy the
+source code from the same place satisfies the requirement to
+distribute the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+ 5. A program that contains no derivative of any portion of the
+Library, but is designed to work with the Library by being compiled or
+linked with it, is called a "work that uses the Library". Such a
+work, in isolation, is not a derivative work of the Library, and
+therefore falls outside the scope of this License.
+
+ However, linking a "work that uses the Library" with the Library
+creates an executable that is a derivative of the Library (because it
+contains portions of the Library), rather than a "work that uses the
+library". The executable is therefore covered by this License.
+Section 6 states terms for distribution of such executables.
+
+ When a "work that uses the Library" uses material from a header file
+that is part of the Library, the object code for the work may be a
+derivative work of the Library even though the source code is not.
+Whether this is true is especially significant if the work can be
+linked without the Library, or if the work is itself a library. The
+threshold for this to be true is not precisely defined by law.
+
+ If such an object file uses only numerical parameters, data
+structure layouts and accessors, and small macros and small inline
+functions (ten lines or less in length), then the use of the object
+file is unrestricted, regardless of whether it is legally a derivative
+work. (Executables containing this object code plus portions of the
+Library will still fall under Section 6.)
+
+ Otherwise, if the work is a derivative of the Library, you may
+distribute the object code for the work under the terms of Section 6.
+Any executables containing that work also fall under Section 6,
+whether or not they are linked directly with the Library itself.
+
+ 6. As an exception to the Sections above, you may also combine or
+link a "work that uses the Library" with the Library to produce a
+work containing portions of the Library, and distribute that work
+under terms of your choice, provided that the terms permit
+modification of the work for the customer's own use and reverse
+engineering for debugging such modifications.
+
+ You must give prominent notice with each copy of the work that the
+Library is used in it and that the Library and its use are covered by
+this License. You must supply a copy of this License. If the work
+during execution displays copyright notices, you must include the
+copyright notice for the Library among them, as well as a reference
+directing the user to the copy of this License. Also, you must do one
+of these things:
+
+ a) Accompany the work with the complete corresponding
+ machine-readable source code for the Library including whatever
+ changes were used in the work (which must be distributed under
+ Sections 1 and 2 above); and, if the work is an executable linked
+ with the Library, with the complete machine-readable "work that
+ uses the Library", as object code and/or source code, so that the
+ user can modify the Library and then relink to produce a modified
+ executable containing the modified Library. (It is understood
+ that the user who changes the contents of definitions files in the
+ Library will not necessarily be able to recompile the application
+ to use the modified definitions.)
+
+ b) Use a suitable shared library mechanism for linking with the
+ Library. A suitable mechanism is one that (1) uses at run time a
+ copy of the library already present on the user's computer system,
+ rather than copying library functions into the executable, and (2)
+ will operate properly with a modified version of the library, if
+ the user installs one, as long as the modified version is
+ interface-compatible with the version that the work was made with.
+
+ c) Accompany the work with a written offer, valid for at
+ least three years, to give the same user the materials
+ specified in Subsection 6a, above, for a charge no more
+ than the cost of performing this distribution.
+
+ d) If distribution of the work is made by offering access to copy
+ from a designated place, offer equivalent access to copy the above
+ specified materials from the same place.
+
+ e) Verify that the user has already received a copy of these
+ materials or that you have already sent this user a copy.
+
+ For an executable, the required form of the "work that uses the
+Library" must include any data and utility programs needed for
+reproducing the executable from it. However, as a special exception,
+the materials to be distributed need not include anything that is
+normally distributed (in either source or binary form) with the major
+components (compiler, kernel, and so on) of the operating system on
+which the executable runs, unless that component itself accompanies
+the executable.
+
+ It may happen that this requirement contradicts the license
+restrictions of other proprietary libraries that do not normally
+accompany the operating system. Such a contradiction means you cannot
+use both them and the Library together in an executable that you
+distribute.
+
+ 7. You may place library facilities that are a work based on the
+Library side-by-side in a single library together with other library
+facilities not covered by this License, and distribute such a combined
+library, provided that the separate distribution of the work based on
+the Library and of the other library facilities is otherwise
+permitted, and provided that you do these two things:
+
+ a) Accompany the combined library with a copy of the same work
+ based on the Library, uncombined with any other library
+ facilities. This must be distributed under the terms of the
+ Sections above.
+
+ b) Give prominent notice with the combined library of the fact
+ that part of it is a work based on the Library, and explaining
+ where to find the accompanying uncombined form of the same work.
+
+ 8. You may not copy, modify, sublicense, link with, or distribute
+the Library except as expressly provided under this License. Any
+attempt otherwise to copy, modify, sublicense, link with, or
+distribute the Library is void, and will automatically terminate your
+rights under this License. However, parties who have received copies,
+or rights, from you under this License will not have their licenses
+terminated so long as such parties remain in full compliance.
+
+ 9. You are not required to accept this License, since you have not
+signed it. However, nothing else grants you permission to modify or
+distribute the Library or its derivative works. These actions are
+prohibited by law if you do not accept this License. Therefore, by
+modifying or distributing the Library (or any work based on the
+Library), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Library or works based on it.
+
+ 10. Each time you redistribute the Library (or any work based on the
+Library), the recipient automatically receives a license from the
+original licensor to copy, distribute, link with or modify the Library
+subject to these terms and conditions. You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties with
+this License.
+
+ 11. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Library at all. For example, if a patent
+license would not permit royalty-free redistribution of the Library by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Library.
+
+If any portion of this section is held invalid or unenforceable under any
+particular circumstance, the balance of the section is intended to apply,
+and the section as a whole is intended to apply in other circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system which is
+implemented by public license practices. Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+ 12. If the distribution and/or use of the Library is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Library under this License may add
+an explicit geographical distribution limitation excluding those countries,
+so that distribution is permitted only in or among countries not thus
+excluded. In such case, this License incorporates the limitation as if
+written in the body of this License.
+
+ 13. The Free Software Foundation may publish revised and/or new
+versions of the Lesser General Public License from time to time.
+Such new versions will be similar in spirit to the present version,
+but may differ in detail to address new problems or concerns.
+
+Each version is given a distinguishing version number. If the Library
+specifies a version number of this License which applies to it and
+"any later version", you have the option of following the terms and
+conditions either of that version or of any later version published by
+the Free Software Foundation. If the Library does not specify a
+license version number, you may choose any version ever published by
+the Free Software Foundation.
+
+ 14. If you wish to incorporate parts of the Library into other free
+programs whose distribution conditions are incompatible with these,
+write to the author to ask for permission. For software which is
+copyrighted by the Free Software Foundation, write to the Free
+Software Foundation; we sometimes make exceptions for this. Our
+decision will be guided by the two goals of preserving the free status
+of all derivatives of our free software and of promoting the sharing
+and reuse of software generally.
+
+ NO WARRANTY
+
+ 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO
+WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW.
+EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR
+OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY
+KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE
+LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME
+THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN
+WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY
+AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU
+FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR
+CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE
+LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING
+RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A
+FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF
+SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
+DAMAGES.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Libraries
+
+ If you develop a new library, and you want it to be of the greatest
+possible use to the public, we recommend making it free software that
+everyone can redistribute and change. You can do so by permitting
+redistribution under these terms (or, alternatively, under the terms of the
+ordinary General Public License).
+
+ To apply these terms, attach the following notices to the library. It is
+safest to attach them to the start of each source file to most effectively
+convey the exclusion of warranty; and each file should have at least the
+"copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This library is free software; you can redistribute it and/or
+ modify it under the terms of the GNU Lesser General Public
+ License as published by the Free Software Foundation; either
+ version 2.1 of the License, or (at your option) any later version.
+
+ This library is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ Lesser General Public License for more details.
+
+ You should have received a copy of the GNU Lesser General Public
+ License along with this library; if not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+You should also get your employer (if you work as a programmer) or your
+school, if any, to sign a "copyright disclaimer" for the library, if
+necessary. Here is a sample; alter the names:
+
+ Yoyodyne, Inc., hereby disclaims all copyright interest in the
+ library `Frob' (a library for tweaking knobs) written by James Random Hacker.
+
+ , 1 April 1990
+ Moe Ghoul, President of Vice
+
+That's all there is to it!
diff --git a/LICENSE-CC0 b/LICENSE-CC0
new file mode 100644
index 0000000..670154e
--- /dev/null
+++ b/LICENSE-CC0
@@ -0,0 +1,116 @@
+CC0 1.0 Universal
+
+Statement of Purpose
+
+The laws of most jurisdictions throughout the world automatically confer
+exclusive Copyright and Related Rights (defined below) upon the creator and
+subsequent owner(s) (each and all, an "owner") of an original work of
+authorship and/or a database (each, a "Work").
+
+Certain owners wish to permanently relinquish those rights to a Work for the
+purpose of contributing to a commons of creative, cultural and scientific
+works ("Commons") that the public can reliably and without fear of later
+claims of infringement build upon, modify, incorporate in other works, reuse
+and redistribute as freely as possible in any form whatsoever and for any
+purposes, including without limitation commercial purposes. These owners may
+contribute to the Commons to promote the ideal of a free culture and the
+further production of creative, cultural and scientific works, or to gain
+reputation or greater distribution for their Work in part through the use and
+efforts of others.
+
+For these and/or other purposes and motivations, and without any expectation
+of additional consideration or compensation, the person associating CC0 with a
+Work (the "Affirmer"), to the extent that he or she is an owner of Copyright
+and Related Rights in the Work, voluntarily elects to apply CC0 to the Work
+and publicly distribute the Work under its terms, with knowledge of his or her
+Copyright and Related Rights in the Work and the meaning and intended legal
+effect of CC0 on those rights.
+
+1. Copyright and Related Rights. A Work made available under CC0 may be
+protected by copyright and related or neighboring rights ("Copyright and
+Related Rights"). Copyright and Related Rights include, but are not limited
+to, the following:
+
+ i. the right to reproduce, adapt, distribute, perform, display, communicate,
+ and translate a Work;
+
+ ii. moral rights retained by the original author(s) and/or performer(s);
+
+ iii. publicity and privacy rights pertaining to a person's image or likeness
+ depicted in a Work;
+
+ iv. rights protecting against unfair competition in regards to a Work,
+ subject to the limitations in paragraph 4(a), below;
+
+ v. rights protecting the extraction, dissemination, use and reuse of data in
+ a Work;
+
+ vi. database rights (such as those arising under Directive 96/9/EC of the
+ European Parliament and of the Council of 11 March 1996 on the legal
+ protection of databases, and under any national implementation thereof,
+ including any amended or successor version of such directive); and
+
+ vii. other similar, equivalent or corresponding rights throughout the world
+ based on applicable law or treaty, and any national implementations thereof.
+
+2. Waiver. To the greatest extent permitted by, but not in contravention of,
+applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and
+unconditionally waives, abandons, and surrenders all of Affirmer's Copyright
+and Related Rights and associated claims and causes of action, whether now
+known or unknown (including existing as well as future claims and causes of
+action), in the Work (i) in all territories worldwide, (ii) for the maximum
+duration provided by applicable law or treaty (including future time
+extensions), (iii) in any current or future medium and for any number of
+copies, and (iv) for any purpose whatsoever, including without limitation
+commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes
+the Waiver for the benefit of each member of the public at large and to the
+detriment of Affirmer's heirs and successors, fully intending that such Waiver
+shall not be subject to revocation, rescission, cancellation, termination, or
+any other legal or equitable action to disrupt the quiet enjoyment of the Work
+by the public as contemplated by Affirmer's express Statement of Purpose.
+
+3. Public License Fallback. Should any part of the Waiver for any reason be
+judged legally invalid or ineffective under applicable law, then the Waiver
+shall be preserved to the maximum extent permitted taking into account
+Affirmer's express Statement of Purpose. In addition, to the extent the Waiver
+is so judged Affirmer hereby grants to each affected person a royalty-free,
+non transferable, non sublicensable, non exclusive, irrevocable and
+unconditional license to exercise Affirmer's Copyright and Related Rights in
+the Work (i) in all territories worldwide, (ii) for the maximum duration
+provided by applicable law or treaty (including future time extensions), (iii)
+in any current or future medium and for any number of copies, and (iv) for any
+purpose whatsoever, including without limitation commercial, advertising or
+promotional purposes (the "License"). The License shall be deemed effective as
+of the date CC0 was applied by Affirmer to the Work. Should any part of the
+License for any reason be judged legally invalid or ineffective under
+applicable law, such partial invalidity or ineffectiveness shall not
+invalidate the remainder of the License, and in such case Affirmer hereby
+affirms that he or she will not (i) exercise any of his or her remaining
+Copyright and Related Rights in the Work or (ii) assert any associated claims
+and causes of action with respect to the Work, in either case contrary to
+Affirmer's express Statement of Purpose.
+
+4. Limitations and Disclaimers.
+
+ a. No trademark or patent rights held by Affirmer are waived, abandoned,
+ surrendered, licensed or otherwise affected by this document.
+
+ b. Affirmer offers the Work as-is and makes no representations or warranties
+ of any kind concerning the Work, express, implied, statutory or otherwise,
+ including without limitation warranties of title, merchantability, fitness
+ for a particular purpose, non infringement, or the absence of latent or
+ other defects, accuracy, or the present or absence of errors, whether or not
+ discoverable, all to the greatest extent permissible under applicable law.
+
+ c. Affirmer disclaims responsibility for clearing rights of other persons
+ that may apply to the Work or any use thereof, including without limitation
+ any person's Copyright and Related Rights in the Work. Further, Affirmer
+ disclaims responsibility for obtaining any necessary consents, permissions
+ or other rights required for any use of the Work.
+
+ d. Affirmer understands and acknowledges that Creative Commons is not a
+ party to this document and has no duty or obligation with respect to this
+ CC0 or use of the Work.
+
+For more information, please see
+
diff --git a/NOTICE b/NOTICE
new file mode 100644
index 0000000..560135e
--- /dev/null
+++ b/NOTICE
@@ -0,0 +1,11 @@
+DrCyano's Lootable Bodies
+
+Created by Cyanobacterium (DrCyano, also known as Synechocystis).
+Maintained by SkyBlade1978 and the Minecraft Mod Development community.
+
+This maintained release is distributed under the GNU Lesser General Public
+License, version 2.1. See LICENSE for the complete text.
+
+The original source was supplied with a CC0 1.0 Universal dedication.
+That original notice is preserved in LICENSE-CC0. The maintained release's
+LGPL notice does not remove the original dedication from the original work.
diff --git a/README.md b/README.md
index 8c805c9..067fd91 100644
--- a/README.md
+++ b/README.md
@@ -2,8 +2,44 @@
A death-chest mod for Minecraft
## Requirements
-This mod requires that you install Minecraft Forge version 1.9-12.16.0.1865 or later.
+This version is for Minecraft 1.10.2, Forge 12.18.3.2511 and Java 8.
## Installing
-After you have successfully installed Forge, simply place the file *lootable-bodies-#.#.jar* in your *mods* folder. You can get the lootable-bodies-#.#.jar file from the Releases tab of this repository page.
+Install Forge, then place `LootableBodies-2.4.1.110021.jar` in the
+`mods` folder on the client and server. Keep a backup before changing an
+existing modpack or world.
+
+When a player dies, a corpse holds their dropped items. Right-click the corpse
+to recover them. The configuration file controls corpse damage, decay, skins
+and the extra bones and rotten flesh.
+
+Open **Mods**, select **DrCyano's Lootable Bodies**, then choose **Config**.
+Appearance settings apply locally when you save with Done. World/server rules
+require a full Minecraft restart and cannot be edited while a world is open.
+They change this installation's file, not a remote server's rules. Dedicated
+server owners should edit their server's configuration file and restart it.
+
+If a corpse cannot spawn, the player's ordinary item drops remain available.
+If a corpse cannot release an item, it keeps that item for recovery or a later
+retry. Inventory changes are saved with the corpse's chunk.
+Corpses stay in place while nearby terrain is unloaded.
+If a player skin cannot be loaded, the corpse uses a default skin.
+
+Unreadable item records stay with the corpse. Restore missing mods before
+reloading to retry recovery. An apparently empty corpse can still hold saved
+item data. Keep using this build when saving those corpses; older builds do not
+retain its recovery records.
+
+Damaged corpse records keep recoverable items and the original data for repair.
+Existing corpse slots and configuration defaults are preserved.
+
+## Credits and licence
+
+Created by Cyanobacterium (DrCyano, also known as Synechocystis).
+Maintained by [SkyBlade1978](https://github.com/SkyBlade1978) and Minecraft Mod Development.
+This maintained release uses [LGPLv2.1](LICENSE). The original
+[CC0 dedication](LICENSE-CC0) and [attribution](NOTICE) are retained.
+
+Version `2.4.1.110021` is the maintained Minecraft 1.10.2 Forge release.
+See [changes](CHANGELOG.txt) and the [versioning policy](docs/VERSIONS.md).
diff --git a/build.gradle b/build.gradle
index f1e5e9f..5649597 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,122 +1,207 @@
-buildscript {
- repositories {
- jcenter()
- maven {
- name = "forge"
- url = "http://files.minecraftforge.net/maven"
- }
- maven {
- name = 'gradle'
- url 'https://plugins.gradle.org/m2/'
- }
- maven {
- name = "sonatype"
- url = 'https://oss.sonatype.org/content/groups/public'
- }
- }
- dependencies {
- classpath "net.minecraftforge.gradle:ForgeGradle:2.2-SNAPSHOT"
- classpath "gradle.plugin.com.matthewprenger:CurseGradle:1.0.11"
- }
-}
-
-apply plugin: "net.minecraftforge.gradle.forge"
-apply plugin: "com.matthewprenger.cursegradle"
-
-def mod_file = getModFile()
-version = getVersion("VERSION", mod_file)
-group= "cyano.lootable"
-archivesBaseName = "DrCyanosLootableBodies_1.10.2"
-sourceCompatibility = targetCompatibility = "1.8"
-
-minecraft {
- version = "1.10.2-12.18.3.2185"
- runDir = "run"
- mappings = "stable_29"
-}
+import org.apache.tools.ant.filters.FixCrLfFilter
-dependencies {
- // none
+plugins {
+ id 'java'
+ id 'eclipse'
+ id 'idea'
+ id 'maven-publish'
+ id 'net.minecraftforge.renamer' version '1.1.5'
+ id 'net.minecraftforge.gradle' version '7.0.34'
}
-processResources {
- inputs.property "version", project.version
- inputs.property "mcversion", project.minecraft.version
+group = project.mod_group
+version = project.mod_version
+base.archivesName = project.mod_artifact
- from(sourceSets.main.resources.srcDirs) {
- include 'mcmod.info'
- expand 'version': project.version, 'mcversion': project.minecraft.version
+// Gradle uses Java 17; every compiler, test and game process uses this JDK 8.
+// Eclipse does not inherit the command-line helper's environment. Keep its
+// machine-specific toolchain selection in an ignored local properties file.
+Properties localJavaSetup = new Properties()
+File localJavaSetupFile = file('gradle-local.properties')
+if (localJavaSetupFile.isFile()) localJavaSetupFile.withInputStream { localJavaSetup.load(it) }
+def pinnedJava8 = providers.environmentVariable('LOOTABLEBODIES_JAVA8_HOME')
+ .orElse(providers.gradleProperty('lootablebodies.java8.home'))
+ .orElse(providers.provider { localJavaSetup.getProperty('lootablebodies.java8.home') })
+def exactJava8Spec = pinnedJava8.isPresent() ?
+ org.gradle.jvm.toolchain.internal.SpecificInstallationToolchainSpec.fromJavaHome(
+ project.services.get(org.gradle.api.internal.provider.PropertyFactory), file(pinnedJava8.get())) : null
+java {
+ toolchain {
+ languageVersion = JavaLanguageVersion.of(8)
+ vendor = JvmVendorSpec.ADOPTIUM
}
+ withSourcesJar()
+ withJavadocJar()
+}
+ext.productionLauncher = exactJava8Spec != null ? javaToolchains.launcherFor(exactJava8Spec) :
+ javaToolchains.launcherFor(java.toolchain)
+def productionCompiler = exactJava8Spec != null ? javaToolchains.compilerFor(exactJava8Spec) :
+ javaToolchains.compilerFor(java.toolchain)
+def productionJavadoc = exactJava8Spec != null ? javaToolchains.javadocToolFor(exactJava8Spec) :
+ javaToolchains.javadocToolFor(java.toolchain)
- from(sourceSets.main.resources.srcDirs) {
- exclude 'mcmod.info'
+tasks.register('verifyJavaSetup') {
+ group = 'verification'
+ doLast {
+ if (JavaVersion.current() != JavaVersion.VERSION_17) throw new GradleException('Run Gradle with Java 17')
+ def metadata = productionLauncher.get().metadata
+ Properties release = new Properties()
+ metadata.installationPath.file('release').asFile.withInputStream { release.load(it) }
+ if (release.getProperty('SEMANTIC_VERSION') != '"8.0.502+7"' ||
+ release.getProperty('IMPLEMENTOR') != '"Eclipse Adoptium"') {
+ throw new GradleException("Production requires Temurin 8.0.502+7, found ${metadata.javaRuntimeVersion} ${metadata.vendor}")
+ }
+ logger.lifecycle("Gradle Java: ${System.getProperty('java.runtime.version')}; production: ${metadata}")
}
}
-
-task deobfJar(type: Jar) {
- classifier = "deobf"
- from sourceSets.main.output
+tasks.withType(JavaCompile).configureEach {
+ dependsOn 'verifyJavaSetup'
+ javaCompiler = productionCompiler
+ sourceCompatibility = '1.8'
+ targetCompatibility = '1.8'
+ options.encoding = 'UTF-8'
}
-
-task javadocJar(type: Jar, dependsOn: javadoc) {
- classifier = "javadoc"
- from javadoc.getDestinationDir()
+tasks.named('compileTestJava', JavaCompile) { options.compilerArgs.add('-proc:none') }
+tasks.withType(Test).configureEach {
+ useJUnitPlatform()
+ javaLauncher = productionLauncher
+ workingDir = projectDir
}
-
-artifacts {
- archives deobfJar
- archives sourceJar
- archives javadocJar
+tasks.withType(JavaExec).configureEach { javaLauncher = productionLauncher }
+tasks.withType(Javadoc).configureEach {
+ javadocTool = productionJavadoc
+ failOnError = true
+ options.encoding = 'UTF-8'
+ options.addStringOption('Xdoclint:none', '-quiet')
+ options.addBooleanOption('notimestamp', true)
+}
+tasks.withType(AbstractArchiveTask).configureEach {
+ preserveFileTimestamps = false
+ reproducibleFileOrder = true
+ 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('forge10-development/main')
+tasks.register('syncDevelopmentOutput', Sync) {
+ dependsOn 'classes'
+ from tasks.named('compileJava', JavaCompile).flatMap { it.destinationDirectory }
+ from sourceSets.main.output.resourcesDir
+ into developmentOutput
+}
+tasks.configureEach {
+ if (name in ['runClient', 'runServer']) {
+ dependsOn 'syncDevelopmentOutput'
+ doFirst {
+ Set split = sourceSets.main.output.files
+ classpath = files(developmentOutput, classpath.filter { !split.contains(it) })
+ def forbidden = classpath.files.findAll {
+ it.path.replace('\\', '/') =~ /(?i)(\/test(?:\/|$)|junit|opentest4j|mockito|byte-buddy|integration-test)/
}
+ if (forbidden) throw new GradleException("Ordinary launch contains testing code: ${forbidden}")
}
}
- return path;
}
-String getVersion(String type, String mod_file) {
- String major = "0";
- String revision = "0";
- String patch = "0";
- String prefix = "public static final String $type = \"";
- File file = file(mod_file)
- file.eachLine { String s ->
- s = s.trim();
- if (s.startsWith(prefix)) {
- s = s.substring(prefix.length(), s.length() - 2);
- String[] pts = s.split("\\.");
+repositories {
+ minecraft.mavenizer(it)
+ maven fg.forgeMaven
+ maven fg.minecraftLibsMaven
+ exclusiveContent {
+ forRepository { maven { name = 'Sponge'; url = 'https://repo.spongepowered.org/repository/maven-public' } }
+ filter { includeGroupAndSubgroups('org.spongepowered') }
+ }
+ mavenCentral()
+ maven { url = 'https://libraries.minecraft.net/' }
+}
+dependencies {
+ implementation minecraft.dependency("net.minecraftforge:forge:${minecraft_version}-${forge_version}")
+ compileOnly 'org.lwjgl.lwjgl:lwjgl_util:2.9.4-nightly-20150209'
+ testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.2'
+ testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.10.2'
+ testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.10.2'
+}
- major = pts[0];
- revision = pts[1];
- patch = pts[2];
- }
+tasks.named('processResources', ProcessResources) {
+ filteringCharset = 'UTF-8'
+ inputs.properties(version: project.version, mcversion: project.minecraft_version,
+ mod_url: project.mod_url, mod_license: project.mod_license)
+ filesMatching('mcmod.info') {
+ expand version: project.version, mcversion: project.minecraft_version,
+ mod_url: project.mod_url, mod_license: project.mod_license
+ }
+ filesMatching(archiveTextPatterns, normalizeArchiveText)
+}
+tasks.named('jar', Jar) {
+ archiveClassifier = 'deobf'
+ destinationDirectory = layout.buildDirectory.dir('libs-dev')
+ from(['LICENSE', 'LICENSE-CC0', 'NOTICE']) { into 'META-INF' }
+ manifest.attributes('Implementation-Title': project.mod_name,
+ 'Implementation-Version': project.version, 'Implementation-Vendor': project.mod_vendor,
+ 'Maven-Artifact': "${project.group}:${base.archivesName.get()}:${project.version}",
+ 'Implementation-License': project.mod_license,
+ 'Built-On-Java': '8', 'Built-On': "${minecraft_version}-${forge_version}")
+ filesMatching(archiveTextPatterns, normalizeArchiveText)
+}
+ext.releaseJar = renamer.classes(tasks.named('jar', Jar)) {
+ map.from minecraft.dependency.toSrgFile
+ libraries.from(sourceSets.main.compileClasspath)
+ archiveClassifier = null
+ output = layout.buildDirectory.file("libs/${base.archivesName.get()}-${project.version}.jar")
+}
+['sourcesJar', 'javadocJar'].each { name ->
+ tasks.named(name, Jar) {
+ from(['LICENSE', 'LICENSE-CC0', 'NOTICE']) { into 'META-INF' }
+ manifest.attributes('Implementation-Title': project.mod_name,
+ 'Implementation-Version': project.version, 'Implementation-Vendor': project.mod_vendor,
+ 'Implementation-License': project.mod_license,
+ 'Maven-Artifact': "${project.group}:${base.archivesName.get()}:${project.version}:${name == 'sourcesJar' ? 'sources' : 'javadoc'}")
+ filesMatching(archiveTextPatterns, normalizeArchiveText)
}
- return "$major.$revision.$patch";
}
+['apiElements', 'runtimeElements'].each { name ->
+ configurations.named(name) { artifacts.clear() }
+ artifacts { add(name, releaseJar) }
+}
+tasks.named('assemble') { dependsOn releaseJar, 'sourcesJar', 'javadocJar' }
+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'
+
+tasks.register('verifyProductionRuntimeClasspath') {
+ group = 'verification'
+ dependsOn 'syncDevelopmentOutput'
+ doLast {
+ if (configurations.runtimeClasspath.files.any {
+ it.path.replace('\\','/') =~ /(?i)(\/test(?:\/|$)|junit|opentest4j|mockito|byte-buddy|integration-test|buildtest)/
+ }) throw new GradleException('Testing code leaked into the production runtime')
+ if (!developmentOutput.get().file('mcmod.info').asFile.text.contains("\"version\": \"${project.version}\""))
+ throw new GradleException('Development resources are not processed')
+ }
+}
+tasks.named('check') { dependsOn 'verifyProductionRuntimeClasspath' }
+
+// Publication is restricted to the confirmed MMD release workflow.
diff --git a/docs/RELEASING.md b/docs/RELEASING.md
new file mode 100644
index 0000000..39ee53d
--- /dev/null
+++ b/docs/RELEASING.md
@@ -0,0 +1,19 @@
+# Releasing Lootable Bodies
+
+Changes go to the maintainer's fork first, then to the matching MMD branch by
+pull request. Merge after the build, security and wrapper checks pass.
+
+For Minecraft 1.10.2, open **Release Lootable Bodies** in MMD's Actions tab.
+Run it from the default branch, enter the complete version from
+`gradle.properties`, choose the CurseForge release level and confirm publication.
+The workflow selects the `1.10.2` branch for versions ending in `110021`.
+
+The workflow requires green checks on the selected commit, builds and audits
+the release bundle, then creates the exact version tag. It publishes the same
+files to Maven, CurseForge and GitHub, in that order. Missing files or release
+credentials stop the release. Ordinary local builds cannot publish.
+
+Keep the run ID, commit and checksums when investigating a failed release.
+Inspect completed upload steps before retrying. Do not start another upload
+while a CurseForge file is pending review. An accepted upload does not prove
+that the public download is available yet.
diff --git a/docs/VERSIONS.md b/docs/VERSIONS.md
new file mode 100644
index 0000000..b482567
--- /dev/null
+++ b/docs/VERSIONS.md
@@ -0,0 +1,31 @@
+# Versions
+
+Maintained releases use `Major.Minor.Bug.Target`, matching the MMD convention.
+The first three numbers describe the functional release. Increase Major for a
+breaking new generation, Minor for a substantial feature and Bug for a fix or
+small improvement. Reset the numbers to the right when Major or Minor changes.
+
+Allocate functional numbers across Lootable Bodies as a whole. Equivalent ports
+may share a functional version; unrelated fixes must use a new number. A higher
+version on another Minecraft branch does not imply that all fixes apply there.
+Release notes identify the changes actually included. Historical versions retain
+their original meaning.
+
+Target encodes the Minecraft major number, two-digit minor number, two-digit
+patch number and a loader digit: `1` for Forge, `2` for NeoForge. A missing
+Minecraft patch is zero. Minecraft 1.10.2 Forge therefore has target `110021`.
+This is a four-component MMD version, rather than strict three-component SemVer.
+
+This branch uses `2.4.1.110021`. It follows the historical 2.4.0 allocation and
+contains the maintained 1.10.2 inventory and saved-data fixes described in the
+[changelog](../CHANGELOG.txt). Other Minecraft targets remain separate.
+
+The stable artifact name is `LootableBodies`; main, sources and Javadoc jars
+share the complete version. Maven coordinates are
+`zone.moddev.mc.lootablebodies:LootableBodies:2.4.1.110021`.
+These coordinates do not change the Java package `cyano.lootable`, mod ID
+`lootablebodies`, resource namespace or saved corpse identity.
+
+Ordinary builds prepare and validate the jars and Maven metadata without
+uploading files. Releases use the confirmed MMD workflow described in
+[releasing](RELEASING.md).
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..a0cc8ab
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,34 @@
+org.gradle.jvmargs=-Xmx3G
+org.gradle.daemon=false
+org.gradle.configuration-cache=false
+org.gradle.caching=true
+org.gradle.parallel=false
+org.gradle.workers.max=2
+net.minecraftforge.gradle.merge-source-sets=false
+minecraft_version=1.10.2
+forge_version=12.18.3.2511
+mapping_channel=stable
+mapping_version=29-1.10.2
+
+# Release identity and dispatcher target metadata.
+loader_name=forge
+loader_code=1
+java_version=8
+java_toolchain_version=8.0.502+7
+gradle_java_version=17
+curseforge_project_id=240968
+
+mod_id=lootablebodies
+mod_name=DrCyano's Lootable Bodies
+mod_artifact=LootableBodies
+mod_vendor=Cyanobacterium
+mod_license=LGPL-2.1-only
+mod_license_name=GNU Lesser General Public License, Version 2.1
+mod_license_url=https://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt
+mod_version=2.4.1.110021
+mod_group=zone.moddev.mc.lootablebodies
+mod_authors=Cyanobacterium,SkyBlade1978
+mod_description=Saves your items on a lootable corpse when you die.
+mod_url=https://github.com/MinecraftModDevelopmentMods/LootableBodies
+mod_scm_connection=scm:git:https://github.com/MinecraftModDevelopmentMods/LootableBodies.git
+mod_scm_developer_connection=scm:git:ssh://git@github.com/MinecraftModDevelopmentMods/LootableBodies.git
diff --git a/gradle/audit.gradle b/gradle/audit.gradle
new file mode 100644
index 0000000..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/ide.gradle b/gradle/ide.gradle
new file mode 100644
index 0000000..0d8c227
--- /dev/null
+++ b/gradle/ide.gradle
@@ -0,0 +1,100 @@
+eclipse {
+ jdt.javaRuntimeName = 'JavaSE-1.8'
+ classpath {
+ downloadSources = true
+ downloadJavadoc = true
+ file.whenMerged { model ->
+ model.entries.findAll { it.kind == 'src' && it.path == 'src/main/resources' }.each {
+ // Eclipse must not overwrite expanded metadata with raw templates.
+ it.path = 'build/resources/main'
+ }
+ Set sourcePaths = new HashSet<>()
+ model.entries.removeAll { it.kind == 'src' && !sourcePaths.add(it.path) }
+ }
+ }
+ synchronizationTasks 'genEclipseRuns'
+}
+tasks.register('prepareEclipseResources') {
+ group = 'ide'
+ dependsOn 'processResources'
+ doLast { copy { from layout.buildDirectory.dir('resources/main'); into 'bin/main' } }
+}
+tasks.register('configureEclipseBuildship') {
+ group = 'ide'
+ dependsOn 'verifyJavaSetup'
+ doLast {
+ File target = file('.settings/org.eclipse.buildship.core.prefs')
+ Properties prefs = new Properties()
+ if (target.isFile()) target.withInputStream { prefs.load(it) }
+ ['eclipse.preferences.version': '1', 'connection.gradle.distribution': 'GRADLE_DISTRIBUTION(WRAPPER)',
+ 'connection.gradle.user.home': gradle.gradleUserHomeDir.canonicalPath,
+ 'connection.project.dir': '', 'gradle.user.home': gradle.gradleUserHomeDir.canonicalPath,
+ 'override.workspace.settings': 'true', 'java.home': System.getProperty('java.home')
+ ].each { k, v -> prefs.setProperty(k, v) }
+ target.parentFile.mkdirs()
+ target.withOutputStream { prefs.store(it, 'Local Buildship settings') }
+ File localJavaSetup = file('gradle-local.properties')
+ Properties toolchains = new Properties()
+ if (localJavaSetup.isFile()) localJavaSetup.withInputStream { toolchains.load(it) }
+ toolchains.setProperty('lootablebodies.java8.home',
+ productionLauncher.get().metadata.installationPath.asFile.canonicalPath)
+ localJavaSetup.withOutputStream { toolchains.store(it, 'Local production Java toolchain') }
+ }
+}
+tasks.register('isolateEclipseProductionRuns') {
+ group = 'ide'
+ dependsOn 'prepareEclipseResources', 'configureEclipseBuildship'
+ doLast {
+ ['runClient.launch', 'runServer.launch'].each { name ->
+ File launch = file(name)
+ if (!launch.isFile()) throw new GradleException("Generate ${name} with genEclipseRuns first")
+ String contents = launch.getText('UTF-8')
+ contents = contents.replace('/JavaSE-8', '/JavaSE-1.8')
+ contents = contents.replaceAll(//, '')
+ contents = contents.replace('',
+ ' \n')
+ ['--cache', '--metadata', '--to-srg', '--to-obf'].each { option ->
+ int start = contents.indexOf(option + ' ')
+ if (start >= 0) {
+ start += option.length() + 1
+ int end = contents.indexOf(' --', start)
+ if (end > start) {
+ String value = contents.substring(start, end)
+ if (value.find(/\s/) && !value.startsWith('"'))
+ contents = contents.substring(0, start) + '"' + value + '"' + contents.substring(end)
+ }
+ }
+ }
+ launch.setText(contents.replace('\r\n', '\n'), 'UTF-8')
+ }
+ }
+}
+tasks.named('genEclipseRuns') { finalizedBy 'isolateEclipseProductionRuns' }
+tasks.register('verifyEclipseProductionClasspath') {
+ group = 'verification'
+ dependsOn 'genEclipseRuns', 'eclipse'
+ doLast {
+ String classpathXml = file('.classpath').text
+ if (!classpathXml.contains('path="build/resources/main"') || classpathXml.contains('path="src/main/resources"'))
+ throw new GradleException('Eclipse must use Gradle-processed resources')
+ def classpathModel = new groovy.xml.XmlSlurper().parseText(classpathXml)
+ if (classpathModel.classpathentry.findAll { it.@path.text() == 'build/resources/main' }.size() != 1)
+ throw new GradleException('Duplicate Eclipse production resource roots')
+ classpathModel.classpathentry.findAll { it.@path.text() =~ /(?i)(junit|opentest4j|apiguardian)/ }.each { entry ->
+ if (!entry.attributes.attribute.any { it.@name.text() == 'test' && it.@value.text() == 'true' })
+ throw new GradleException('Eclipse testing library is not marked test-only')
+ }
+ def metadata = new groovy.json.JsonSlurper().parse(file('bin/main/mcmod.info'))[0]
+ if (metadata.modid != project.mod_id || metadata.version != project.version.toString() || metadata.mcversion != project.minecraft_version)
+ throw new GradleException('Eclipse resources are not processed production resources')
+ ['runClient.launch', 'runServer.launch'].each { name ->
+ String xml = file(name).text.replace('\\', '/')
+ if (!xml.contains('ATTR_EXCLUDE_TEST_CODE" value="true"') ||
+ xml =~ /(?i)(src\/test|bin\/test|junit-|integration-test|mockito|byte-buddy)/ ||
+ !xml.contains('PROJECT_ATTR" value="LootableBodies"'))
+ throw new GradleException("${name} has the wrong project or exposes tests")
+ if (name == 'runServer.launch' && (!xml.contains('nogui') || xml.contains('--nogui')))
+ throw new GradleException('Legacy dedicated server needs bare nogui')
+ }
+ }
+}
diff --git a/gradle/log4j2.xml b/gradle/log4j2.xml
new file mode 100644
index 0000000..d15f663
--- /dev/null
+++ b/gradle/log4j2.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/gradle/release.gradle b/gradle/release.gradle
new file mode 100644
index 0000000..6b44f51
--- /dev/null
+++ b/gradle/release.gradle
@@ -0,0 +1,181 @@
+import groovy.xml.XmlSlurper
+import org.gradle.api.publish.maven.tasks.AbstractPublishToMaven
+
+def expectedIdentity = [
+ mod_id: 'lootablebodies', mod_artifact: 'LootableBodies',
+ mod_group: 'zone.moddev.mc.lootablebodies', mod_vendor: 'Cyanobacterium',
+ mod_name: "DrCyano's Lootable Bodies", mod_authors: 'Cyanobacterium,SkyBlade1978',
+ minecraft_version: '1.10.2', forge_version: '12.18.3.2511',
+ mapping_channel: 'stable', mapping_version: '29-1.10.2',
+ loader_name: 'forge', loader_code: '1', curseforge_project_id: '240968',
+ java_version: '8', java_toolchain_version: '8.0.502+7', gradle_java_version: '17',
+ mod_license: 'LGPL-2.1-only', mod_license_name: 'GNU Lesser General Public License, Version 2.1',
+ mod_license_url: 'https://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt',
+ mod_url: 'https://github.com/MinecraftModDevelopmentMods/LootableBodies',
+ mod_scm_connection: 'scm:git:https://github.com/MinecraftModDevelopmentMods/LootableBodies.git',
+ mod_scm_developer_connection: 'scm:git:ssh://git@github.com/MinecraftModDevelopmentMods/LootableBodies.git'
+]
+expectedIdentity.each { key, value ->
+ if (project.findProperty(key)?.toString() != value)
+ throw new GradleException("Release identity mismatch: ${key} must be ${value}")
+}
+def versionParts = project.version.toString().split('\\.', -1)
+def minecraftParts = project.minecraft_version.toString().split('\\.')
+String target = minecraftParts[0] + minecraftParts[1].padLeft(2, '0') +
+ (minecraftParts.length == 3 ? minecraftParts[2] : '0').padLeft(2, '0') + project.loader_code
+if (versionParts.length != 4 || !versionParts.every { it ==~ /(?:0|[1-9][0-9]*)/ } || versionParts[3] != target)
+ throw new GradleException("mod_version must use Major.Minor.Bug.${target} for Minecraft ${minecraft_version} Forge")
+ext.functional_version = versionParts[0..2].join('.')
+ext.release_tag = project.version.toString()
+ext.display_version = project.version.toString()
+
+def preparedReleaseDir = providers.gradleProperty('preparedReleaseDir')
+def generatedPom = layout.buildDirectory.file('publications/mavenJava/pom-default.xml')
+
+tasks.register('verifyReleaseConfiguration') {
+ group = 'verification'
+ doLast {
+ String source = file('src/main/java/cyano/lootable/LootableBodies.java').getText('UTF-8')
+ if (!source.contains("VERSION = \"${project.version}\"") || !source.contains('MODID = "lootablebodies"') ||
+ !source.contains('acceptedMinecraftVersions = "[1.10.2]"') || !source.contains('String idName = "Corpse"'))
+ throw new GradleException('Java release version or persistent identity differs from release metadata')
+ if (!file('README.md').getText('UTF-8').contains(releaseNames[0]) ||
+ !file('CHANGELOG.txt').getText('UTF-8').contains(project.version.toString()))
+ throw new GradleException('Release documentation does not match the declared artifact/version')
+ if (!file('LICENSE').getText('UTF-8').contains('Version 2.1, February 1999') ||
+ !file('LICENSE-CC0').getText('UTF-8').startsWith('CC0 1.0 Universal'))
+ throw new GradleException('Maintained LGPL licence or original CC0 notice is missing')
+ }
+}
+tasks.withType(JavaCompile).configureEach { dependsOn 'verifyReleaseConfiguration' }
+tasks.named('processResources') { dependsOn 'verifyReleaseConfiguration' }
+
+publishing {
+ publications {
+ mavenJava(MavenPublication) {
+ groupId = project.mod_group
+ artifactId = project.mod_artifact
+ version = project.version.toString()
+ if (preparedReleaseDir.isPresent()) {
+ artifact(new File(file(preparedReleaseDir.get()), releaseNames[0]))
+ artifact(new File(file(preparedReleaseDir.get()), releaseNames[1])) { classifier = 'sources' }
+ artifact(new File(file(preparedReleaseDir.get()), releaseNames[2])) { classifier = 'javadoc' }
+ } else {
+ artifact(releaseJar)
+ artifact(tasks.named('sourcesJar'))
+ artifact(tasks.named('javadocJar'))
+ }
+ pom {
+ name = project.mod_name
+ description = project.mod_description
+ url = project.mod_url
+ licenses { license { name = project.mod_license_name; url = project.mod_license_url; distribution = 'repo' } }
+ developers {
+ developer { id = 'cyanobacterium'; name = 'Cyanobacterium (DrCyano / Synechocystis)' }
+ developer { id = 'SkyBlade1978'; name = 'SkyBlade1978'; url = 'https://github.com/SkyBlade1978' }
+ }
+ scm {
+ connection = project.mod_scm_connection
+ developerConnection = project.mod_scm_developer_connection
+ url = project.mod_url
+ }
+ }
+ }
+ }
+ repositories {
+ maven {
+ name = 'release'
+ url = uri(providers.environmentVariable('MAVEN_UPLOAD_URL').getOrElse('https://invalid.invalid/publication-disabled'))
+ credentials {
+ username = providers.environmentVariable('MAVEN_UPLOAD_USERNAME').getOrElse('')
+ password = providers.environmentVariable('MAVEN_UPLOAD_PASSWORD').getOrElse('')
+ }
+ }
+ }
+}
+
+tasks.register('verifyMavenCoordinates') {
+ group = 'verification'
+ dependsOn 'generatePomFileForMavenJavaPublication', 'verifyReleaseConfiguration'
+ doLast {
+ def pom = new XmlSlurper(false, false).parse(generatedPom.get().asFile)
+ if ([pom.groupId.text(), pom.artifactId.text(), pom.version.text()] !=
+ [project.mod_group, project.mod_artifact, project.version.toString()] ||
+ pom.name.text() != project.mod_name || pom.url.text() != project.mod_url ||
+ pom.licenses.license.size() != 1 || pom.licenses.license.name.text() != project.mod_license_name ||
+ pom.licenses.license.url.text() != project.mod_license_url ||
+ pom.scm.connection.text() != project.mod_scm_connection ||
+ pom.scm.developerConnection.text() != project.mod_scm_developer_connection ||
+ pom.scm.url.text() != project.mod_url || pom.dependencies.dependency.size() != 0 ||
+ !pom.developers.developer.name.text().contains('Cyanobacterium'))
+ throw new GradleException('Generated Maven POM does not match the release identity/licence')
+ def declared = publishing.publications.mavenJava.artifacts.collect {
+ [it.extension, it.classifier ?: '', it.file.name]
+ }.sort { it[1] }
+ def expected = [['jar', '', releaseNames[0]], ['jar', 'sources', releaseNames[1]],
+ ['jar', 'javadoc', releaseNames[2]]].sort { it[1] }
+ if (declared != expected) throw new GradleException('Maven must contain exactly runtime, sources and Javadocs')
+ }
+}
+tasks.withType(Test).configureEach { dependsOn 'verifyMavenCoordinates' }
+tasks.named('check') { dependsOn 'verifyMavenCoordinates', 'verifyReleaseConfiguration' }
+
+tasks.register('prepareReleaseBundle', Sync) {
+ group = 'build'
+ description = 'Prepares the audited local jars, POM, checksums and changelog without publishing.'
+ dependsOn 'writeReleaseChecksums', 'verifyMavenCoordinates'
+ from(layout.buildDirectory.dir('libs')) { include releaseNames }
+ from(generatedPom) { rename { releasePomName } }
+ from(layout.buildDirectory.file('release/SHA256SUMS'))
+ from('CHANGELOG.txt')
+ into layout.buildDirectory.dir('release/bundle')
+}
+tasks.register('verifyPreparedReleaseArtifacts') {
+ group = 'verification'
+ description = 'Checks an immutable prepared bundle without recompiling its jars.'
+ dependsOn 'verifyMavenCoordinates'
+ doLast {
+ if (!preparedReleaseDir.isPresent()) throw new GradleException('preparedReleaseDir is required')
+ File prepared = file(preparedReleaseDir.get())
+ Set expected = (releaseNames + [releasePomName, 'SHA256SUMS', 'CHANGELOG.txt']) as Set
+ Set actual = (prepared.listFiles() ?: [] as File[])*.name as Set
+ if (!prepared.isDirectory() || actual != expected)
+ throw new GradleException('Prepared release must contain exactly the declared jars, POM, checksums and changelog')
+ auditReleaseArchives(prepared)
+ List sums = releaseNames.collect { "${releaseDigest(new File(prepared, it))} ${it}".toString() }.sort()
+ if (new File(prepared, 'SHA256SUMS').readLines('UTF-8').sort() != sums)
+ throw new GradleException('Prepared release checksums do not match the immutable jars')
+ if (!Arrays.equals(new File(prepared, releasePomName).bytes, generatedPom.get().asFile.bytes))
+ throw new GradleException('Prepared release POM differs from the configured release metadata')
+ if (!Arrays.equals(new File(prepared, 'CHANGELOG.txt').bytes, file('CHANGELOG.txt').bytes))
+ throw new GradleException('Prepared release changelog differs from the reviewed source')
+ }
+}
+
+// Only the confirmed MMD release workflow may upload an audited prepared bundle.
+gradle.taskGraph.whenReady { graph ->
+ if (graph.allTasks.any { it instanceof AbstractPublishToMaven || it.name in ['publish', 'publishToMavenLocal'] }) {
+ if (graph.allTasks.any { it.name == 'publishToMavenLocal' || it.class.name.contains('PublishToMavenLocal') } ||
+ System.getenv('GITHUB_ACTIONS') != 'true' ||
+ System.getenv('GITHUB_REPOSITORY') != 'MinecraftModDevelopmentMods/LootableBodies' ||
+ System.getenv('LOOTABLEBODIES_RELEASE_VERSION') != project.version.toString() ||
+ !(System.getenv('GITHUB_RUN_ID') ==~ /[0-9]+/) || !preparedReleaseDir.isPresent())
+ throw new GradleException('Publication is disabled outside the confirmed MMD release workflow')
+ }
+}
+tasks.register('validateMavenRelease') {
+ doLast {
+ def expectedSha = System.getenv('LOOTABLEBODIES_RELEASE_SHA') ?: ''
+ def gitHead = providers.exec { commandLine('git', 'rev-parse', 'HEAD') }
+ def actualSha = gitHead.standardOutput.asText.get().trim()
+ if (!expectedSha.matches('[a-f0-9]{40}') || actualSha != expectedSha)
+ throw new GradleException('The publication checkout does not match the confirmed release commit')
+ def uploadUrl = System.getenv('MAVEN_UPLOAD_URL') ?: ''
+ if (!uploadUrl.matches('https://[^/ ]+(/.*)?') || uploadUrl.startsWith('https://invalid.invalid') ||
+ !System.getenv('MAVEN_UPLOAD_USERNAME') || !System.getenv('MAVEN_UPLOAD_PASSWORD'))
+ throw new GradleException('A remote HTTPS Maven repository and release credentials are required')
+ }
+}
+tasks.withType(AbstractPublishToMaven).configureEach {
+ dependsOn 'validateMavenRelease', 'verifyPreparedReleaseArtifacts'
+}
diff --git a/gradle/runtime.gradle b/gradle/runtime.gradle
new file mode 100644
index 0000000..2921812
--- /dev/null
+++ b/gradle/runtime.gradle
@@ -0,0 +1,200 @@
+// Build-only legacy runtime verification, separate from production classpaths.
+def probeClasses = layout.buildDirectory.dir('integration-test-mod/classes')
+tasks.register('compileIntegrationTestMod', JavaCompile) {
+ group = 'verification'
+ dependsOn 'classes'
+ source fileTree('src/integrationTest/java')
+ classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath)
+ destinationDirectory = probeClasses
+ options.compilerArgs.add('-proc:none')
+}
+def probeJar = tasks.register('integrationTestModJar', Jar) {
+ group = 'verification'
+ dependsOn 'compileIntegrationTestMod'
+ archiveFileName = 'lootablebodies-build-tests.jar'
+ destinationDirectory = layout.buildDirectory.dir('integration-test-mod')
+ from probeClasses
+ from 'src/integrationTest/resources'
+}
+def packagedProbe = renamer.classes(probeJar) {
+ map.from minecraft.dependency.toSrgFile
+ libraries.from(tasks.named('jar'), sourceSets.main.compileClasspath)
+ output = layout.buildDirectory.file('integration-test-mod/lootablebodies-build-tests-reobf.jar')
+}
+tasks.register('preparePackagedIntegrationProbe') { dependsOn packagedProbe, releaseJar }
+
+def runDirectory = layout.buildDirectory.dir('integration-run')
+tasks.register('prepareIntegrationTest') {
+ dependsOn probeJar
+ doLast {
+ File root = runDirectory.get().asFile
+ if (root.exists()) {
+ File history = layout.buildDirectory.dir('runtime-history/' + System.currentTimeMillis()).get().asFile
+ history.parentFile.mkdirs()
+ if (!root.renameTo(history)) throw new GradleException('Cannot preserve previous runtime evidence')
+ }
+ new File(root,'mods').mkdirs()
+ copy { from probeJar; into new File(root,'mods') }
+ new File(root,'eula.txt').text = 'eula=true\n'
+ ['ops','whitelist','banned-players','banned-ips'].each { new File(root,"${it}.json").text='[]\n' }
+ new File(root,'server.properties').text = '''level-name=world
+level-seed=4267321
+level-type=FLAT
+online-mode=false
+server-ip=127.0.0.1
+server-port=0
+generate-structures=false
+spawn-animals=false
+spawn-monsters=false
+spawn-protection=0
+max-tick-time=120000
+'''
+ }
+}
+
+// Reuse FG7's actual bootstrap, mappings, natives and pinned Java launcher.
+def phaseTask = { String phase, Object prerequisite ->
+ tasks.register("integration${phase.capitalize()}Process", JavaExec) {
+ group = 'verification'
+ dependsOn prerequisite
+ dependsOn { def run = tasks.getByName('runServer'); run.taskDependencies.getDependencies(run) }
+ actions.clear()
+ doLast {
+ JavaExec run = tasks.getByName('runServer')
+ def options = minecraft.runs.getByName('server')
+ File previousDir = options.workingDir.get().asFile
+ def previousProperties = new LinkedHashMap(run.systemProperties)
+ def previousClasspath = files(run.classpath)
+ try {
+ options.workingDir.set(runDirectory)
+ run.workingDir(runDirectory)
+ run.systemProperty('lootablebodies.phase', phase)
+ run.systemProperty('log4j.configurationFile', file('src/integrationTest/resources/log4j2.xml').toURI())
+ run.executable = productionLauncher.get().executablePath.asFile.path
+ run.setClasspath(files(developmentOutput, run.classpath.filter { !sourceSets.main.output.files.contains(it) }))
+ run.exec()
+ } finally {
+ options.workingDir.set(previousDir)
+ run.workingDir(previousDir)
+ run.setSystemProperties(previousProperties)
+ run.setClasspath(previousClasspath)
+ }
+ File marker = new File(runDirectory.get().asFile,"world/${phase}.pass")
+ if (!marker.isFile()) throw new GradleException("Missing ${phase} corpse preservation marker")
+ File logs = new File(runDirectory.get().asFile,'logs/latest.log')
+ if (!logs.isFile() || logs.text =~ /(?i)(AssertionError|Encountered an unexpected exception|Exception stopping server)/)
+ throw new GradleException("Runtime failure in ${logs}")
+ copy { from logs; into runDirectory; rename { "${phase}.log" } }
+ if (!fileTree(new File(runDirectory.get().asFile,'crash-reports')).isEmpty())
+ throw new GradleException('Runtime crash reports found')
+ }
+ }
+}
+def fresh = phaseTask('fresh','prepareIntegrationTest')
+def inventory = phaseTask('inventory','prepareIntegrationTest')
+tasks.register('inventoryRegressionTest') { group='verification'; dependsOn inventory }
+
+def nbt = phaseTask('nbt','prepareIntegrationTest')
+tasks.register('nbtRecoveryTest') { group='verification'; dependsOn nbt }
+
+def scalars = phaseTask('scalars','prepareIntegrationTest')
+tasks.register('nameLifetimeTest') { group='verification'; dependsOn scalars }
+
+def health = phaseTask('health','prepareIntegrationTest')
+tasks.register('healthAttributeTest') { group='verification'; dependsOn health }
+
+def effects = phaseTask('effects','prepareIntegrationTest')
+
+tasks.register('effectHurtTest') { group='verification'; dependsOn effects }
+
+def geometry = phaseTask('geometry','prepareIntegrationTest')
+
+tasks.register('positionMotionTest') { group='verification'; dependsOn geometry }
+
+def rotation = phaseTask('rotation','prepareIntegrationTest')
+
+tasks.register('rotationFallTest') { group='verification'; dependsOn rotation }
+
+def drops = phaseTask('drops','prepareIntegrationTest')
+tasks.register('dropCreativeInteractionTest') { group='verification'; dependsOn drops }
+
+def interactions = phaseTask('interactions','prepareIntegrationTest')
+tasks.register('containerInteractionTest') { group='verification'; dependsOn interactions }
+def reload = phaseTask('reload',fresh)
+def deferred = phaseTask('deferred',reload)
+tasks.register('integrationTest') { group = 'verification'; dependsOn deferred }
+tasks.named('check') { dependsOn 'integrationTest' }
+
+tasks.register('clientIntegrationTest') {
+ group = 'verification'
+ dependsOn probeJar
+ dependsOn { def run = tasks.getByName('runClient'); run.taskDependencies.getDependencies(run) }
+ doLast {
+ File root = layout.buildDirectory.dir('client-integration-run').get().asFile
+ if(root.exists()) throw new GradleException('Preserve the previous client evidence before rerunning')
+ new File(root,'mods').mkdirs()
+ new File(root,'config').mkdirs()
+ copy { from probeJar; into new File(root,'mods') }
+ new File(root,'config/lootablebodies.cfg').text = 'options {\n B:local_texture=true\n}\n'
+ JavaExec run = tasks.getByName('runClient')
+ def options = minecraft.runs.getByName('client')
+ options.workingDir.set(root)
+ run.workingDir(root)
+ run.systemProperty('lootablebodies.clientCheck','true')
+ run.systemProperty('log4j.configurationFile',file('src/integrationTest/resources/log4j2.xml').toURI())
+ run.executable = productionLauncher.get().executablePath.asFile.path
+ run.setClasspath(files(developmentOutput,run.classpath.filter { !sourceSets.main.output.files.contains(it) }))
+ run.exec()
+ if(!new File(root,'client.pass').isFile()) throw new GradleException('Client did not preserve/reopen/render the corpse GUI')
+ if(!fileTree(new File(root,'crash-reports')).isEmpty()) throw new GradleException('Client crash report')
+ }
+}
+tasks.register('exportRuntimeClasspath') {
+ group='verification'
+ doLast {
+ File output=layout.buildDirectory.file('runtime-classpath.json').get().asFile
+ output.parentFile.mkdirs()
+ output.text=groovy.json.JsonOutput.toJson(configurations.runtimeClasspath.files*.absolutePath)
+ }
+}
+
+def controlClasses = layout.buildDirectory.dir('control-test-mod/classes')
+tasks.register('compileForgeControl',JavaCompile) {
+ source fileTree('src/controlTest/java')
+ classpath=sourceSets.main.compileClasspath
+ destinationDirectory=controlClasses
+ options.compilerArgs.add('-proc:none')
+}
+def controlJar=tasks.register('forgeControlJar',Jar) {
+ dependsOn 'compileForgeControl'
+ from controlClasses
+ archiveFileName='forge-platform-control.jar'
+ destinationDirectory=layout.buildDirectory.dir('control-test-mod')
+}
+def packagedControl=renamer.classes(controlJar) {
+ map.from minecraft.dependency.toSrgFile
+ libraries.from(sourceSets.main.compileClasspath)
+ output=layout.buildDirectory.file('control-test-mod/forge-platform-control-reobf.jar')
+}
+tasks.register('preparePackagedForgeControl') { dependsOn packagedControl }
+tasks.register('forgeControlServer') {
+ group='verification'
+ dependsOn controlJar
+ dependsOn { def run=tasks.getByName('runServer'); run.taskDependencies.getDependencies(run) }
+ doLast {
+ File root=layout.buildDirectory.dir('forge-control-run').get().asFile
+ new File(root,'mods').mkdirs()
+ copy { from controlJar; into new File(root,'mods') }
+ new File(root,'eula.txt').text='eula=true\n'
+ new File(root,'server.properties').text='level-name=world\nlevel-type=FLAT\nserver-ip=127.0.0.1\nserver-port=0\nonline-mode=false\n'
+ ['ops','whitelist','banned-players','banned-ips'].each { new File(root,"${it}.json").text='[]\n' }
+ JavaExec run=tasks.getByName('runServer')
+ minecraft.runs.getByName('server').workingDir.set(root)
+ run.workingDir(root)
+ run.systemProperty('log4j.configurationFile',file('src/integrationTest/resources/log4j2.xml').toURI())
+ run.setClasspath(run.classpath.filter { !sourceSets.main.output.files.contains(it) && it != developmentOutput.get().asFile })
+ run.exec()
+ String text=new File(root,'logs/latest.log').text
+ if(!text.contains('FORGE_PLATFORM_CONTROL_PASS') || text.contains('lootablebodies@')) throw new GradleException('Invalid independent platform control')
+ }
+}
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
index 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
index cccdd3d..249efbb 100644
--- a/gradlew
+++ b/gradlew
@@ -1,78 +1,128 @@
-#!/usr/bin/env sh
+#!/bin/sh
+
+#
+# Copyright © 2015 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
##############################################################################
-##
-## Gradle start up script for UN*X
-##
+#
+# gradlew start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh gradlew
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
##############################################################################
# Attempt to set APP_HOME
+
# Resolve links: $0 may be a link
-PRG="$0"
-# Need this for relative symlinks.
-while [ -h "$PRG" ] ; do
- ls=`ls -ld "$PRG"`
- link=`expr "$ls" : '.*-> \(.*\)$'`
- if expr "$link" : '/.*' > /dev/null; then
- PRG="$link"
- else
- PRG=`dirname "$PRG"`"/$link"
- fi
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
done
-SAVED="`pwd`"
-cd "`dirname \"$PRG\"`/" >/dev/null
-APP_HOME="`pwd -P`"
-cd "$SAVED" >/dev/null
-
-APP_NAME="Gradle"
-APP_BASE_NAME=`basename "$0"`
-# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
-DEFAULT_JVM_OPTS=""
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
-MAX_FD="maximum"
+MAX_FD=maximum
warn () {
echo "$*"
-}
+} >&2
die () {
echo
echo "$*"
echo
exit 1
-}
+} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
-case "`uname`" in
- CYGWIN* )
- cygwin=true
- ;;
- Darwin* )
- darwin=true
- ;;
- MINGW* )
- msys=true
- ;;
- NONSTOP* )
- nonstop=true
- ;;
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
esac
-CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
- JAVACMD="$JAVA_HOME/jre/sh/java"
+ JAVACMD=$JAVA_HOME/jre/sh/java
else
- JAVACMD="$JAVA_HOME/bin/java"
+ JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
@@ -81,92 +131,118 @@ Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
- JAVACMD="java"
- which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
+ fi
fi
# Increase the maximum file descriptors if we can.
-if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
- MAX_FD_LIMIT=`ulimit -H -n`
- if [ $? -eq 0 ] ; then
- if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
- MAX_FD="$MAX_FD_LIMIT"
- fi
- ulimit -n $MAX_FD
- if [ $? -ne 0 ] ; then
- warn "Could not set maximum file descriptor limit: $MAX_FD"
- fi
- else
- warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
- fi
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
fi
-# For Darwin, add options to specify how the application appears in the dock
-if $darwin; then
- GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
-fi
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
-# For Cygwin, switch paths to Windows format before running java
-if $cygwin ; then
- APP_HOME=`cygpath --path --mixed "$APP_HOME"`
- CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
- JAVACMD=`cygpath --unix "$JAVACMD"`
-
- # We build the pattern for arguments to be converted via cygpath
- ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
- SEP=""
- for dir in $ROOTDIRSRAW ; do
- ROOTDIRS="$ROOTDIRS$SEP$dir"
- SEP="|"
- done
- OURCYGPATTERN="(^($ROOTDIRS))"
- # Add a user-defined pattern to the cygpath arguments
- if [ "$GRADLE_CYGPATTERN" != "" ] ; then
- OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
- fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
- i=0
- for arg in "$@" ; do
- CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
- CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
-
- if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
- eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
- else
- eval `echo args$i`="\"$arg\""
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
fi
- i=$((i+1))
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
done
- case $i in
- (0) set -- ;;
- (1) set -- "$args0" ;;
- (2) set -- "$args0" "$args1" ;;
- (3) set -- "$args0" "$args1" "$args2" ;;
- (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
- (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
- (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
- (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
- (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
- (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
- esac
fi
-# Escape application args
-save () {
- for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
- echo " "
-}
-APP_ARGS=$(save "$@")
-
-# Collect all arguments for the java command, following the shell quoting and substitution rules
-eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
-# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
-if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
- cd "$(dirname "$0")"
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
fi
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
index e95643d..a51ec4f 100644
--- a/gradlew.bat
+++ b/gradlew.bat
@@ -1,84 +1,82 @@
-@if "%DEBUG%" == "" @echo off
-@rem ##########################################################################
-@rem
-@rem Gradle startup script for Windows
-@rem
-@rem ##########################################################################
-
-@rem Set local scope for the variables with windows NT shell
-if "%OS%"=="Windows_NT" setlocal
-
-set DIRNAME=%~dp0
-if "%DIRNAME%" == "" set DIRNAME=.
-set APP_BASE_NAME=%~n0
-set APP_HOME=%DIRNAME%
-
-@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
-set DEFAULT_JVM_OPTS=
-
-@rem Find java.exe
-if defined JAVA_HOME goto findJavaFromJavaHome
-
-set JAVA_EXE=java.exe
-%JAVA_EXE% -version >NUL 2>&1
-if "%ERRORLEVEL%" == "0" goto init
-
-echo.
-echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
-echo.
-echo Please set the JAVA_HOME variable in your environment to match the
-echo location of your Java installation.
-
-goto fail
-
-:findJavaFromJavaHome
-set JAVA_HOME=%JAVA_HOME:"=%
-set JAVA_EXE=%JAVA_HOME%/bin/java.exe
-
-if exist "%JAVA_EXE%" goto init
-
-echo.
-echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
-echo.
-echo Please set the JAVA_HOME variable in your environment to match the
-echo location of your Java installation.
-
-goto fail
-
-:init
-@rem Get command-line arguments, handling Windows variants
-
-if not "%OS%" == "Windows_NT" goto win9xME_args
-
-:win9xME_args
-@rem Slurp the command line arguments.
-set CMD_LINE_ARGS=
-set _SKIP=2
-
-:win9xME_args_slurp
-if "x%~1" == "x" goto execute
-
-set CMD_LINE_ARGS=%*
-
-:execute
-@rem Setup the command line
-
-set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
-
-@rem Execute Gradle
-"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
-
-:end
-@rem End local scope for the variables with windows NT shell
-if "%ERRORLEVEL%"=="0" goto mainEnd
-
-:fail
-rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
-rem the _cmd.exe /c_ return code!
-if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
-exit /b 1
-
-:mainEnd
-if "%OS%"=="Windows_NT" endlocal
-
-:omega
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem gradlew startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables, and ensure extensions are enabled
+setlocal EnableExtensions
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+"%COMSPEC%" /c exit 1
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+"%COMSPEC%" /c exit 1
+
+:execute
+@rem Setup the command line
+
+
+
+@rem Execute gradlew
+@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
+@rem which allows us to clear the local environment before executing the java command
+endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
+
+:exitWithErrorLevel
+@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
+"%COMSPEC%" /c exit %ERRORLEVEL%
diff --git a/mcmod.info b/mcmod.info
deleted file mode 100644
index 747c047..0000000
--- a/mcmod.info
+++ /dev/null
@@ -1,16 +0,0 @@
-[
-{
- "modid": "lootable",
- "name": "DrCyano's Lootable Bodies",
- "description": "Saves your items when you die in a container so that you can get your items back when you return.",
- "version": "${version}",
- "mcversion": "${mcversion}",
- "url": "https://github.com/cyanobacterium/DrCyanos_Lootable-Bodies",
- "updateUrl": "",
- "authorList": ["Cyanobacterium"],
- "credits": "The Forge and FML guys, for making the Forge modding API (you guys are awesome), plant-eating zombies, and the mod author Cyanobacterium (aka Synechocystis)",
- "logoFile": "",
- "screenshots": [],
- "dependencies": []
-}
-]
diff --git a/resources/mcmod.info b/resources/mcmod.info
deleted file mode 120000
index 8b9091c..0000000
--- a/resources/mcmod.info
+++ /dev/null
@@ -1 +0,0 @@
-src/main/resources/mcmod.info
\ No newline at end of file
diff --git a/settings.gradle b/settings.gradle
new file mode 100644
index 0000000..d6dc9f9
--- /dev/null
+++ b/settings.gradle
@@ -0,0 +1,4 @@
+plugins {
+ id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0'
+}
+rootProject.name = 'LootableBodies'
diff --git a/src/controlTest/java/buildcontrol/ForgeControl.java b/src/controlTest/java/buildcontrol/ForgeControl.java
new file mode 100644
index 0000000..9532c46
--- /dev/null
+++ b/src/controlTest/java/buildcontrol/ForgeControl.java
@@ -0,0 +1,28 @@
+package buildcontrol;
+import net.minecraftforge.fml.common.*;
+import net.minecraftforge.fml.common.event.*;
+import net.minecraftforge.fml.common.eventhandler.SubscribeEvent;
+import net.minecraftforge.fml.common.gameevent.TickEvent;
+
+/** Independent Forge/platform control; never loads Lootable Bodies. */
+@Mod(modid="forgeplatformcontrol",name="Forge platform control",version="1")
+public final class ForgeControl {
+ @Mod.EventHandler public void started(FMLServerStartedEvent event) {
+ if(FMLCommonHandler.instance().getSide().isServer()) {
+ System.out.println("FORGE_PLATFORM_CONTROL_PASS");
+ FMLCommonHandler.instance().getMinecraftServerInstance().initiateShutdown();
+ }
+ }
+ @Mod.EventHandler public void init(FMLInitializationEvent event) {
+ if(FMLCommonHandler.instance().getSide().isClient()) new ClientControl().register();
+ }
+ public static final class ClientControl {
+ void register() { FMLCommonHandler.instance().bus().register(this); }
+ @SubscribeEvent public void tick(TickEvent.ClientTickEvent event) {
+ if(event.phase==TickEvent.Phase.END && net.minecraft.client.Minecraft.getMinecraft().currentScreen instanceof net.minecraft.client.gui.GuiMainMenu) {
+ System.out.println("FORGE_PLATFORM_CONTROL_PASS");
+ net.minecraft.client.Minecraft.getMinecraft().shutdown();
+ }
+ }
+ }
+}
diff --git a/src/integrationTest/java/cyano/lootable/buildtest/ClientChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/ClientChecks.java
new file mode 100644
index 0000000..7ac00db
--- /dev/null
+++ b/src/integrationTest/java/cyano/lootable/buildtest/ClientChecks.java
@@ -0,0 +1,125 @@
+package cyano.lootable.buildtest;
+
+import cyano.lootable.entities.EntityLootableBody;
+import cyano.lootable.graphics.CorpseGUIContainer;
+import java.io.*;
+import java.util.concurrent.Future;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.gui.GuiMainMenu;
+import net.minecraft.entity.Entity;
+import net.minecraft.util.EnumHand;
+import net.minecraft.util.ScreenShotHelper;
+import net.minecraft.world.*;
+import net.minecraftforge.common.MinecraftForge;
+import net.minecraftforge.fml.common.FMLCommonHandler;
+import net.minecraftforge.fml.common.eventhandler.SubscribeEvent;
+import net.minecraftforge.fml.common.gameevent.TickEvent;
+
+/** Automated real-client world, packet interaction, GUI render and reload check. */
+public final class ClientChecks {
+ private int stage, ticks, frames;
+ private int reportedStage=-1;
+ private Future> pending;
+ private final ConnectedClientChecks connected = new ConnectedClientChecks();
+ private final DropClientChecks drops = new DropClientChecks();
+ private final NbtClientChecks nbt = new NbtClientChecks();
+ private final ReportedIssueClientChecks reportedIssues = new ReportedIssueClientChecks();
+ private final VisibilitySkinClientChecks visibilitySkins = new VisibilitySkinClientChecks();
+ private final ConfigClientChecks configScreen = new ConfigClientChecks();
+ @SubscribeEvent public void mouse(net.minecraftforge.client.event.GuiScreenEvent.MouseInputEvent.Pre event) {
+ event.setCanceled(true);
+ }
+ @SubscribeEvent public void keyboard(net.minecraftforge.client.event.GuiScreenEvent.KeyboardInputEvent.Pre event) {
+ event.setCanceled(true);
+ }
+ public void register() {
+ if (Boolean.getBoolean("lootablebodies.clientCheck")) {
+ FMLCommonHandler.instance().bus().register(this);
+ MinecraftForge.EVENT_BUS.register(this);
+ }
+ }
+ @SubscribeEvent public void tick(TickEvent.ClientTickEvent event) {
+ if(event.phase != TickEvent.Phase.END) return;
+ Minecraft mc = Minecraft.getMinecraft();
+ // The verifier never needs to capture the user's mouse or keyboard.
+ if(mc.inGameHasFocus) mc.setIngameNotInFocus();
+ net.minecraft.client.settings.KeyBinding.unPressAllKeys();
+ if(stage!=reportedStage || ticks%60==0) {
+ System.out.println("GRAPHICAL_STAGE "+stage+" screen="+(mc.currentScreen==null?"world":mc.currentScreen.getClass().getSimpleName())+
+ " position="+(mc.player==null?"none":mc.player.getPosition())+" focus="+mc.inGameHasFocus);
+ reportedStage=stage;
+ }
+ if(ticks>0 && ticks%150==0) ScreenShotHelper.saveScreenshot(mc.mcDataDir,"diagnostic-stage-"+stage+"-tick-"+ticks+".png",mc.displayWidth,mc.displayHeight,mc.getFramebuffer());
+ if(++ticks > 12000) fail(new AssertionError("client timeout stage="+stage));
+ try {
+ RuntimeChecks.require(!new File(mc.mcDataDir,"abort").exists(),"Disposable graphical check aborted");
+ if(Boolean.getBoolean("lootablebodies.configUiCheck")) {configScreen.tick(mc);return;}
+ if(Boolean.getBoolean("lootablebodies.visibilitySkinCheck")) {visibilitySkins.tick(mc);return;}
+ if(Boolean.getBoolean("lootablebodies.issueClientCheck")) {reportedIssues.tick(mc);return;}
+ if(stage==0 && mc.currentScreen instanceof GuiMainMenu) {
+ mc.gameSettings.pauseOnLostFocus=false;
+ mc.gameSettings.limitFramerate=30;
+ mc.gameSettings.renderDistanceChunks=4;
+ stage=1;
+ mc.launchIntegratedServer("build-fixture", "Build fixture",new WorldSettings(4267321,GameType.CREATIVE,false,false,WorldType.FLAT));
+ } else if(stage==1 && mc.world!=null && mc.player!=null && mc.getIntegratedServer()!=null) {
+ stage=2;
+ pending=mc.getIntegratedServer().addScheduledTask(() -> {
+ try { RuntimeChecks.run(mc.getIntegratedServer(),"fresh"); }
+ catch(Exception error) { throw new RuntimeException(error); }
+ net.minecraft.entity.player.EntityPlayerMP player=mc.getIntegratedServer().getPlayerList().getPlayerByUUID(mc.player.getUniqueID());
+ player.capabilities.isFlying=true;player.sendPlayerAbilities();
+ player.connection.setPlayerLocation(8.5,80,7.5,0,0);
+ });
+ } else if(stage==2 && pending.isDone()) {
+ pending.get();
+ for(Entity entity : mc.world.loadedEntityList) if(entity instanceof EntityLootableBody && entity.getUniqueID().equals(RuntimeChecks.NEAR)) {
+ mc.playerController.interactWithEntity(mc.player,entity,null,EnumHand.MAIN_HAND);
+ stage=3; break;
+ }
+ } else if(stage==3 && mc.currentScreen instanceof CorpseGUIContainer && ++frames>20) {
+ RuntimeChecks.require(((CorpseGUIContainer)mc.currentScreen).inventorySlots.inventorySlots.size()==84,"rendered GUI slots");
+ ScreenShotHelper.saveScreenshot(mc.mcDataDir,"corpse-gui-fresh.png",mc.displayWidth,mc.displayHeight,mc.getFramebuffer());
+ mc.player.closeScreen(); stage=4;
+ mc.getIntegratedServer().initiateShutdown();
+ } else if(stage==4 && mc.world==null && !mc.isIntegratedServerRunning()) {
+ stage=5; mc.launchIntegratedServer("build-fixture","Build fixture",null);
+ } else if(stage==5 && mc.player!=null && mc.world!=null && mc.getIntegratedServer()!=null) {
+ stage=6;
+ pending=mc.getIntegratedServer().addScheduledTask(() -> {
+ try { RuntimeChecks.run(mc.getIntegratedServer(),"deferred"); }
+ catch(Exception error) { throw new RuntimeException(error); }
+ net.minecraft.entity.player.EntityPlayerMP player=mc.getIntegratedServer().getPlayerList().getPlayerByUUID(mc.player.getUniqueID());
+ player.capabilities.isFlying=true;player.sendPlayerAbilities();
+ player.connection.setPlayerLocation(8.5,80,7.5,0,0);
+ });
+ } else if(stage==6 && pending.isDone()) {
+ pending.get();
+ for(Entity entity:mc.world.loadedEntityList) if(entity instanceof EntityLootableBody && entity.getUniqueID().equals(RuntimeChecks.NEAR)) {
+ mc.playerController.interactWithEntity(mc.player,entity,null,EnumHand.MAIN_HAND);
+ stage=7; frames=0; break;
+ }
+ } else if(stage==7 && mc.currentScreen instanceof CorpseGUIContainer && ++frames>20) {
+ ScreenShotHelper.saveScreenshot(mc.mcDataDir,"corpse-gui-reload.png",mc.displayWidth,mc.displayHeight,mc.getFramebuffer());
+ mc.player.closeScreen(); stage=20; connected.start(mc);
+ } else if(stage==20 && connected.tick(mc)) {
+ mc.player.closeScreen();drops.start(mc);stage=21;
+ } else if(stage==21 && drops.tick(mc)) {
+ mc.player.closeScreen();nbt.start(mc);stage=22;
+ } else if(stage==22 && nbt.tick(mc)) {
+ try(FileWriter out=new FileWriter(new File(mc.mcDataDir,"client.pass"))) {
+ out.write("fresh/reload GUI; connected death/respawn; network shift/normal recovery; two save/reloads verified\n");
+ }
+ System.out.println("LOOTABLE_BODIES_CLIENT_PASS");
+ stage=8; mc.player.closeScreen(); mc.getIntegratedServer().initiateShutdown();
+ } else if(stage==8 && mc.world==null && !mc.isIntegratedServerRunning()) {
+ stage=9; mc.shutdown();
+ }
+ } catch(Throwable error) { fail(error); }
+ }
+ private void fail(Throwable error) {
+ error.printStackTrace();
+ Minecraft.getMinecraft().shutdown();
+ stage=99;
+ }
+}
diff --git a/src/integrationTest/java/cyano/lootable/buildtest/ConfigClientChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/ConfigClientChecks.java
new file mode 100644
index 0000000..50be35c
--- /dev/null
+++ b/src/integrationTest/java/cyano/lootable/buildtest/ConfigClientChecks.java
@@ -0,0 +1,201 @@
+package cyano.lootable.buildtest;
+
+import cyano.lootable.LootableBodies;
+import cyano.lootable.entities.EntityLootableBody;
+import java.io.*;
+import java.lang.reflect.*;
+import java.util.*;
+import java.util.concurrent.Future;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.gui.*;
+import net.minecraft.entity.Entity;
+import net.minecraft.nbt.NBTTagCompound;
+import net.minecraft.util.ScreenShotHelper;
+import net.minecraft.world.*;
+import net.minecraftforge.common.MinecraftForge;
+import net.minecraftforge.fml.client.*;
+import net.minecraftforge.fml.client.config.*;
+import net.minecraftforge.fml.client.config.GuiConfigEntries.*;
+import net.minecraftforge.fml.client.event.ConfigChangedEvent;
+import net.minecraftforge.fml.common.*;
+
+/** Exercises the actual Forge Config button and widgets without OS input control. */
+final class ConfigClientChecks {
+ private int stage, frames;
+ private GuiModList mods;
+ private GuiConfig root, appearance, rules;
+ private Future> pending;
+ private final UUID bodyId = UUID.fromString("00000000-0000-4000-8000-000000000941");
+ private File report;
+
+ void tick(Minecraft mc) throws Exception {
+ if (++frames < 20) return;
+ frames = 0;
+ if (report == null) report = new File(mc.mcDataDir, "config-checks.txt");
+ if (stage == 0 && mc.currentScreen instanceof GuiMainMenu) {
+ mc.gameSettings.pauseOnLostFocus = false; mc.gameSettings.limitFramerate = 30;
+ mc.gameSettings.renderDistanceChunks = 4;
+ if (Boolean.getBoolean("lootablebodies.configUiReload")) {
+ check(LootableBodies.corpseHP == 31 && LootableBodies.corpseDecayTime == 200
+ && LootableBodies.ticksPerItemDecay == 0, "pending_rules_apply_after_process_restart");
+ check(!LootableBodies.useLocalSkin && LootableBodies.displayNameTag, "appearance_survives_restart");
+ finish(mc); return;
+ }
+ check(LootableBodies.corpseHP == 20 && LootableBodies.ticksPerItemDecay == 6000, "startup_defaults");
+ openMods(mc); stage = 1;
+ } else if (stage == 1) {
+ screenshot(mc, "01-mods-config-button.png");
+ openConfig(mc); stage = 2;
+ } else if (stage == 2) {
+ check(root.configElements.size() == 2, "appearance_and_rules_pages");
+ screenshot(mc, "02-config-root.png");
+ appearance = category(root, 0); mc.displayGuiScreen(appearance); stage = 3;
+ } else if (stage == 3) {
+ screenshot(mc, "03-appearance.png");
+ toggle(appearance, 0); toggle(appearance, 1);
+ click(appearance, 2000); // child Done stages edits; root Escape discards them.
+ mc.displayGuiScreen(mods);
+ check(!LootableBodies.useLocalSkin && LootableBodies.displayNameTag, "leaving_without_done_discards_changes");
+ openConfig(mc); appearance = category(root, 0); mc.displayGuiScreen(appearance);
+ toggle(appearance, 0); toggle(appearance, 1); click(appearance, 2000); click(root, 2000);
+ check(LootableBodies.useLocalSkin && !LootableBodies.displayNameTag, "appearance_done_applies_immediately");
+ openConfig(mc); rules = category(root, 1); mc.displayGuiScreen(rules); stage = 4;
+ } else if (stage == 4) {
+ screenshot(mc, "04-world-rules.png");
+ for (IConfigEntry entry : rules.entryList.listEntries)
+ check(entry.enabled() && entry.getConfigElement().requiresMcRestart(), "rule_restart_policy_" + entry.getConfigElement().getName());
+ IConfigEntry health = rules.entryList.listEntries.get(0);
+ text(health, "31"); health.undoChanges();
+ check(health.getCurrentValue().toString().equals("20.0"), "undo_restores_health");
+ text(health, "31"); health.setToDefault();
+ check(health.getCurrentValue().toString().equals("20.0"), "defaults_restore_health");
+ text(health, "31"); text(rules.entryList.listEntries.get(2), "0");
+ IConfigEntry lifetime = rules.entryList.listEntries.get(8);
+ for (String invalid : Arrays.asList("bad", "1:-2", "29826:9:43", "1:2:3:4")) {
+ text(lifetime, invalid); lifetime.saveConfigElement();
+ check(lifetime.getConfigElement().get().equals("1:00:00"), "invalid_time_not_saved_" + invalid);
+ }
+ text(lifetime, "0:00:10"); click(rules, 2000); click(root, 2000);
+ check(mc.currentScreen instanceof GuiMessageDialog, "restart_dialog_shown");
+ check(LootableBodies.corpseHP == 20 && LootableBodies.corpseDecayTime == 72000
+ && LootableBodies.ticksPerItemDecay == 6000, "gameplay_unchanged_until_restart");
+ stage = 5;
+ } else if (stage == 5) {
+ screenshot(mc, "05-restart-notice.png");
+ mc.displayGuiScreen(null);
+ mc.launchIntegratedServer("config-fixture", "Config fixture",
+ new WorldSettings(4267321, GameType.CREATIVE, false, false, WorldType.FLAT)); stage = 6;
+ } else if (stage == 6 && mc.world != null && mc.player != null) {
+ pending = mc.getIntegratedServer().addScheduledTask(() -> {
+ try {
+ // Freeze only the fixture lifetime while comparing exact saved timer fields.
+ LootableBodies.allowCorpseDecay = false;
+ EntityLootableBody body = RuntimeChecks.fixture(mc.getIntegratedServer().worldServerForDimension(0), bodyId, 8, true);
+ RuntimeChecks.require(body.getAlwaysRenderNameTag(), "Local appearance preference leaked into saved corpse metadata");
+ // Simulate an old corpse whose saved visibility flag was false.
+ body.setAlwaysRenderNameTag(false);
+ RuntimeChecks.saveExpected(mc.getIntegratedServer().worldServerForDimension(0).getSaveHandler().getWorldDirectory(), "config-body", body);
+ net.minecraft.entity.player.EntityPlayerMP player = mc.getIntegratedServer().getPlayerList().getPlayerByUUID(mc.player.getUniqueID());
+ player.capabilities.isFlying = true; player.sendPlayerAbilities();
+ player.connection.setPlayerLocation(8.5, 80, 7.5, 0, 0);
+ } catch (Exception error) { throw new RuntimeException(error); }
+ }); stage = 7;
+ } else if (stage == 7 && pending.isDone()) {
+ pending.get(); openMods(mc); openConfig(mc);
+ rules = category(root, 1); mc.displayGuiScreen(rules); stage = 8;
+ } else if (stage == 8) {
+ screenshot(mc, "06-rules-disabled-in-world.png");
+ for (IConfigEntry entry : rules.entryList.listEntries) check(!entry.enabled(), "rule_disabled_in_world_" + entry.getConfigElement().getName());
+ click(rules, 2000); appearance = category(root, 0); mc.displayGuiScreen(appearance);
+ for (IConfigEntry entry : appearance.entryList.listEntries) check(entry.enabled(), "appearance_enabled_in_world_" + entry.getConfigElement().getName());
+ // Cache a synthetic profile: switching skins needs no external account service.
+ net.minecraft.tileentity.TileEntitySkull.setProfileCache(new net.minecraft.server.management.PlayerProfileCache(null,
+ new File(mc.mcDataDir, "synthetic-cache.json")) {
+ @Override public com.mojang.authlib.GameProfile getGameProfileForUsername(String name) {
+ com.mojang.authlib.GameProfile profile = new com.mojang.authlib.GameProfile(bodyId, name);
+ profile.getProperties().put("textures", new com.mojang.authlib.properties.Property("textures", "eyJ0ZXh0dXJlcyI6e319")); return profile;
+ }
+ });
+ toggle(appearance, 0); toggle(appearance, 1); click(appearance, 2000); click(root, 2000);
+ check(!LootableBodies.useLocalSkin && LootableBodies.displayNameTag, "existing_body_appearance_changes_live");
+ // Another mod's event cannot change our settings.
+ LootableBodies.getConfiguration().getCategory("options").get("local_texture").set(true);
+ MinecraftForge.EVENT_BUS.post(new ConfigChangedEvent.OnConfigChangedEvent("anothermod", null, true, false));
+ check(!LootableBodies.useLocalSkin, "other_mod_config_event_ignored");
+ LootableBodies.getConfiguration().getCategory("options").get("local_texture").set(false);
+ pending = mc.getIntegratedServer().addScheduledTask(() -> {
+ try {
+ WorldServer world = mc.getIntegratedServer().worldServerForDimension(0);
+ EntityLootableBody body = RuntimeChecks.find(world, bodyId);
+ RuntimeChecks.verifyExpected(world.getSaveHandler().getWorldDirectory(), "config-body", body);
+ RuntimeChecks.require(LootableBodies.corpseHP == 20 && LootableBodies.ticksPerItemDecay == 6000, "GUI changed server rules");
+ world.saveAllChunks(true, null);
+ } catch (Exception error) { throw new RuntimeException(error); }
+ }); stage = 9;
+ } else if (stage == 9 && pending.isDone()) {
+ pending.get();
+ EntityLootableBody body = null;
+ for (Entity entity : mc.world.loadedEntityList) if (entity.getUniqueID().equals(bodyId)) body = (EntityLootableBody) entity;
+ check(body != null && body.getGameProfile() != null && body.getGameProfile().getId() != null, "existing_body_skin_refreshes_without_rename");
+ check(!body.getAlwaysRenderNameTag(), "appearance_did_not_rewrite_synced_name_flag");
+ mc.player.closeScreen(); mc.world.sendQuittingDisconnectingPacket(); mc.loadWorld(null);
+ mc.displayGuiScreen(new GuiMainMenu()); stage = 10;
+ } else if (stage == 10 && mc.world == null && !mc.isIntegratedServerRunning()) {
+ mc.launchIntegratedServer("config-fixture", "Config fixture", null); stage = 11;
+ } else if (stage == 11 && mc.world != null && mc.player != null) {
+ pending = mc.getIntegratedServer().addScheduledTask(() -> {
+ try {
+ WorldServer world = mc.getIntegratedServer().worldServerForDimension(0); world.getChunkFromChunkCoords(0, 0);
+ EntityLootableBody body = RuntimeChecks.find(world, bodyId);
+ RuntimeChecks.verifyExpected(world.getSaveHandler().getWorldDirectory(), "config-body", body);
+ RuntimeChecks.require(!body.getAlwaysRenderNameTag(), "Saved legacy visibility flag changed on reload");
+ RuntimeChecks.require(body.getMaxHealth() == 20, "Pending GUI health rewrote existing corpse attributes");
+ } catch (Exception error) { throw new RuntimeException(error); }
+ }); stage = 12;
+ } else if (stage == 12 && pending.isDone()) {
+ pending.get(); check(true, "corpse_inventory_and_nbt_survive_save_reload"); finish(mc);
+ }
+ }
+
+ private void openMods(Minecraft mc) throws Exception {
+ mods = new GuiModList(new GuiMainMenu()); mc.displayGuiScreen(mods);
+ Field list = GuiModList.class.getDeclaredField("mods"); list.setAccessible(true);
+ List> containers = (List>) list.get(mods);
+ for (int i = 0; i < containers.size(); i++) if (((ModContainer) containers.get(i)).getModId().equals(LootableBodies.MODID)) mods.selectModIndex(i);
+ }
+ private void openConfig(Minecraft mc) throws Exception {
+ Field field = GuiModList.class.getDeclaredField("configModButton"); field.setAccessible(true);
+ GuiButton button = (GuiButton) field.get(mods);
+ check(button.enabled && button.visible, "mods_list_config_button_enabled");
+ invokeButton(mods, button); root = (GuiConfig) mc.currentScreen;
+ check(root.getClass().getName().equals("cyano.lootable.graphics.CorpseConfigGui"), "mods_button_opens_our_screen");
+ }
+ private GuiConfig category(GuiConfig screen, int index) throws Exception {
+ Field field = CategoryEntry.class.getDeclaredField("childScreen"); field.setAccessible(true);
+ return (GuiConfig) field.get(screen.entryList.listEntries.get(index));
+ }
+ private void toggle(GuiConfig screen, int index) { ((BooleanEntry) screen.entryList.listEntries.get(index)).valueButtonPressed(index); }
+ private void text(IConfigEntry entry, String value) throws Exception {
+ Field field = StringEntry.class.getDeclaredField("textFieldValue"); field.setAccessible(true);
+ ((GuiTextField) field.get(entry)).setText(value); entry.keyTyped((char) 0, org.lwjgl.input.Keyboard.KEY_HOME);
+ }
+ private void click(GuiConfig screen, int id) throws Exception { invokeButton(screen, new GuiButton(id, 0, 0, "fixture")); }
+ private void invokeButton(GuiScreen screen, GuiButton button) throws Exception {
+ Class> type = screen.getClass();
+ while (type != null) {
+ for (Method method : type.getDeclaredMethods()) if (Arrays.equals(method.getParameterTypes(), new Class>[] {GuiButton.class})) {
+ method.setAccessible(true); method.invoke(screen, button); return;
+ }
+ type = type.getSuperclass();
+ }
+ throw new AssertionError("No button action");
+ }
+ private void screenshot(Minecraft mc, String name) { ScreenShotHelper.saveScreenshot(mc.mcDataDir, name, mc.displayWidth, mc.displayHeight, mc.getFramebuffer()); }
+ private void check(boolean value, String name) throws Exception {
+ RuntimeChecks.require(value, name); try (FileWriter writer = new FileWriter(report, true)) { writer.write("PASS " + name + "\n"); }
+ }
+ private void finish(Minecraft mc) throws Exception {
+ new File(mc.mcDataDir, Boolean.getBoolean("lootablebodies.configUiReload") ? "config-restart.pass" : "client.pass").createNewFile();
+ System.out.println("LOOTABLE_BODIES_CONFIG_UI_PASS restart=" + Boolean.getBoolean("lootablebodies.configUiReload")); mc.shutdown(); stage = 99;
+ }
+}
diff --git a/src/integrationTest/java/cyano/lootable/buildtest/ConnectedClientChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/ConnectedClientChecks.java
new file mode 100644
index 0000000..d353131
--- /dev/null
+++ b/src/integrationTest/java/cyano/lootable/buildtest/ConnectedClientChecks.java
@@ -0,0 +1,205 @@
+package cyano.lootable.buildtest;
+
+import cyano.lootable.entities.EntityLootableBody;
+import cyano.lootable.graphics.CorpseGUIContainer;
+import java.io.*;
+import java.util.*;
+import java.util.concurrent.Future;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.gui.GuiGameOver;
+import net.minecraft.client.gui.GuiMainMenu;
+import net.minecraft.entity.Entity;
+import net.minecraft.entity.player.EntityPlayerMP;
+import net.minecraft.init.Blocks;
+import net.minecraft.inventory.ClickType;
+import net.minecraft.item.ItemStack;
+import net.minecraft.nbt.*;
+import net.minecraft.server.MinecraftServer;
+import net.minecraft.util.EnumHand;
+import net.minecraft.util.ScreenShotHelper;
+import net.minecraft.util.math.BlockPos;
+import net.minecraft.world.GameType;
+
+/** Real connected-player death, container packets and persisted recovery. */
+final class ConnectedClientChecks {
+ private interface Action { void run() throws Exception; }
+ private int stage, frames;
+ private Future> pending;
+ private UUID playerId, corpseId;
+ private NBTTagCompound visibleExpected;
+ private File report;
+
+ void start(Minecraft mc) {
+ playerId=mc.player.getUniqueID();
+ report=new File(mc.mcDataDir,"connected-checks.txt");
+ RuntimeChecks.require(!report.exists(),"Refusing stale connected-client report");
+ }
+ boolean tick(Minecraft mc) throws Exception {
+ frames++;
+ if(stage==0) {
+ schedule(mc,() -> prepareDeath(mc)); next(1);
+ } else if(stage==1 && done() && mc.currentScreen instanceof GuiGameOver && frames>20) {
+ screenshot(mc,"connected-player-death.png");
+ mc.player.respawnPlayer(); next(2);
+ } else if(stage==2 && mc.player!=null && mc.player.isEntityAlive() && !(mc.currentScreen instanceof GuiGameOver)) {
+ schedule(mc,() -> {
+ EntityPlayerMP player=player(mc); EntityLootableBody corpse=corpse(mc);
+ RuntimeChecks.require(player.isEntityAlive(),"Connected respawn failed");
+ player.connection.setPlayerLocation(24.5,80,7.5,0,0);
+ RuntimeChecks.saveExpected(folder(mc),"connected-death-before",corpse);
+ visibleExpected=read(folder(mc),"connected-death-before");
+ mark("connected_player_death_and_respawn");
+ }); next(3);
+ } else if(stage==3 && done()) {
+ if(open(mc)) next(4);
+ } else if(stage==4 && gui(mc) && frames>20) {
+ verifyGui(mc); screenshot(mc,"connected-corpse-before-recovery.png");
+ RuntimeChecks.require(ItemStack.areItemStacksEqual(mc.player.openContainer.getSlot(6).getStack(),RuntimeChecks.stack(19)),"Client recovery slot NBT differs");
+ mc.playerController.windowClick(mc.player.openContainer.windowId,6,0,ClickType.QUICK_MOVE,mc.player);
+ next(5);
+ } else if(stage==5 && frames>30) {
+ schedule(mc,() -> {
+ verifyRecovery(mc,"connected-death-before",6,19,"connected-after-recovery");
+ savePlayer(mc,"connected-player-after-recovery");
+ mark("network_shift_click_exact_nbt");
+ }); next(6);
+ } else if(stage==6 && done()) {
+ verifyGui(mc); checkPlayer(mc.player.inventory,19);
+ screenshot(mc,"connected-corpse-after-shift-recovery.png");
+ quit(mc); next(7);
+ } else if(stage==7 && mc.world==null && !mc.isIntegratedServerRunning()) {
+ mc.launchIntegratedServer("build-fixture","Build fixture",null); next(8);
+ } else if(stage==8 && ready(mc) && frames>30) {
+ schedule(mc,() -> {
+ verifyReload(mc,"connected-after-recovery","connected-player-after-recovery");
+ RuntimeChecks.require(player(mc).inventory.mainInventory[9]==null,"Normal-click destination not empty");
+ mark("first_save_reload_corpse_and_player");
+ }); next(9);
+ } else if(stage==9 && done()) {
+ if(open(mc)) next(10);
+ } else if(stage==10 && gui(mc) && frames>20) {
+ verifyGui(mc); checkPlayer(mc.player.inventory,19);
+ screenshot(mc,"connected-corpse-first-reload.png");
+ RuntimeChecks.require(ItemStack.areItemStacksEqual(mc.player.openContainer.getSlot(7).getStack(),RuntimeChecks.stack(20)),"Client normal-click slot NBT differs");
+ int window=mc.player.openContainer.windowId;
+ mc.playerController.windowClick(window,7,0,ClickType.PICKUP,mc.player);
+ mc.playerController.windowClick(window,48,0,ClickType.PICKUP,mc.player);
+ next(11);
+ } else if(stage==11 && frames>30) {
+ schedule(mc,() -> {
+ verifyRecovery(mc,"connected-after-recovery",7,20,"connected-final");
+ checkPlayer(player(mc).inventory,19);
+ RuntimeChecks.require(player(mc).inventory.getItemStack()==null,"Server cursor retained a duplicate");
+ savePlayer(mc,"connected-player-final");
+ mark("network_normal_click_exact_nbt");
+ }); next(12);
+ } else if(stage==12 && done()) {
+ verifyGui(mc); checkPlayer(mc.player.inventory,19);checkPlayer(mc.player.inventory,20);
+ RuntimeChecks.require(mc.player.inventory.getItemStack()==null,"Client cursor retained a duplicate");
+ screenshot(mc,"connected-corpse-after-normal-recovery.png");
+ quit(mc); next(13);
+ } else if(stage==13 && mc.world==null && !mc.isIntegratedServerRunning()) {
+ mc.launchIntegratedServer("build-fixture","Build fixture",null); next(14);
+ } else if(stage==14 && ready(mc) && frames>30) {
+ schedule(mc,() -> {
+ verifyReload(mc,"connected-final","connected-player-final");
+ checkPlayer(player(mc).inventory,19);checkPlayer(player(mc).inventory,20);
+ mark("second_save_reload_corpse_and_player");
+ }); next(15);
+ } else if(stage==15 && done()) {
+ if(open(mc)) next(16);
+ } else if(stage==16 && gui(mc) && frames>20) {
+ verifyGui(mc); checkPlayer(mc.player.inventory,19);checkPlayer(mc.player.inventory,20);
+ screenshot(mc,"connected-corpse-final-reload.png");
+ System.out.println("LOOTABLE_BODIES_CONNECTED_CLIENT_PASS");
+ return true;
+ }
+ return false;
+ }
+ private void prepareDeath(Minecraft mc) throws Exception {
+ MinecraftServer server=mc.getIntegratedServer(); EntityPlayerMP player=player(mc);
+ for(int x=21;x<=27;x++) for(int z=5;z<=11;z++)
+ player.world.setBlockState(new BlockPos(x,79,z),Blocks.STONE.getDefaultState());
+ player.world.getGameRules().setOrCreateGameRule("keepInventory","false");
+ player.world.getGameRules().setOrCreateGameRule("doMobSpawning","false");
+ player.setGameType(GameType.SURVIVAL); player.inventory.clear();player.inventory.currentItem=0;
+ for(int slot=0;slot<6;slot++)player.setItemStackToSlot(EntityLootableBody.EQUIPMENT_SLOTS[slot],RuntimeChecks.stack(slot));
+ for(int slot=9;slot<=11;slot++)player.inventory.mainInventory[slot]=RuntimeChecks.stack(slot+10);
+ player.connection.setPlayerLocation(24.5,80,8.5,0,0);
+ player.inventoryContainer.detectAndSendChanges(); player.onKillCommand();
+ RuntimeChecks.require(!player.isEntityAlive(),"Connected player did not die");
+ EntityLootableBody corpse=null;
+ for(Entity entity:player.world.loadedEntityList)
+ if(entity instanceof EntityLootableBody && player.getName().equals(entity.getCustomNameTag())) corpse=(EntityLootableBody)entity;
+ RuntimeChecks.require(corpse!=null,"Connected death did not create a corpse");
+ corpseId=corpse.getUniqueID();corpse.setNoGravity(true);corpse.motionX=0;corpse.motionY=0;corpse.motionZ=0;
+ RuntimeChecks.disableVacuum(corpse);
+ for(int slot=0;slot<6;slot++)RuntimeChecks.require(ItemStack.areItemStacksEqual(corpse.getStackInSlot(slot),RuntimeChecks.stack(slot)),"Connected death equipment slot "+slot);
+ for(int slot=6;slot<=8;slot++)RuntimeChecks.require(ItemStack.areItemStacksEqual(corpse.getStackInSlot(slot),RuntimeChecks.stack(slot+13)),"Connected death main slot "+slot);
+ RuntimeChecks.require(player.inventory.writeToNBT(new NBTTagList()).tagCount()==0,"Death retained player inventory alongside corpse");
+ System.out.println("CONNECTED_CORPSE_UUID "+corpseId);
+ }
+ private void verifyRecovery(Minecraft mc,String before,int slot,int item,String after) throws Exception {
+ EntityLootableBody corpse=corpse(mc);
+ RuntimeChecks.require(corpse.getStackInSlot(slot)==null,"Network recovery left source stack");
+ checkPlayer(player(mc).inventory,item);
+ NBTTagCompound expected=read(folder(mc),before),actual=new NBTTagCompound();
+ NBTTagList equipment=expected.getTagList("Equipment",10);
+ for(int i=equipment.tagCount()-1;i>=0;i--)if(equipment.getCompoundTagAt(i).getByte("Slot")==slot)equipment.removeTag(i);
+ corpse.writeToNBTOptional(actual);RuntimeChecks.compare(expected,actual);
+ RuntimeChecks.saveExpected(folder(mc),after,corpse);visibleExpected=actual.copy();
+ }
+ private void verifyReload(Minecraft mc,String corpseName,String playerName) throws Exception {
+ mc.getIntegratedServer().worldServerForDimension(0).getChunkFromChunkCoords(1,0);
+ EntityLootableBody corpse=corpse(mc);RuntimeChecks.require(corpse.isEntityAlive(),"Saved corpse is no longer accessible");
+ corpse.onEntityUpdate();RuntimeChecks.verifyExpected(folder(mc),corpseName,corpse);
+ NBTTagCompound expected=read(folder(mc),playerName);
+ RuntimeChecks.require(expected.getTag("Inventory").equals(player(mc).inventory.writeToNBT(new NBTTagList())),"Recovered player inventory changed on disk reload");
+ player(mc).connection.setPlayerLocation(24.5,80,7.5,0,0);
+ visibleExpected=read(folder(mc),corpseName);
+ }
+ private void savePlayer(Minecraft mc,String name) throws Exception {
+ NBTTagCompound expected=new NBTTagCompound();expected.setTag("Inventory",player(mc).inventory.writeToNBT(new NBTTagList()));
+ try(FileOutputStream out=new FileOutputStream(new File(folder(mc),name+".nbt"))){CompressedStreamTools.writeCompressed(expected,out);}
+ mc.getIntegratedServer().getPlayerList().saveAllPlayerData();mc.getIntegratedServer().saveAllWorlds(false);
+ }
+ private void verifyGui(Minecraft mc) {
+ RuntimeChecks.require(gui(mc) && mc.player.openContainer.inventorySlots.size()==84,"Connected GUI missing slots");
+ ItemStack[] expected=new ItemStack[48];NBTTagList list=visibleExpected.getTagList("Equipment",10);
+ for(int i=0;i {try{action.run();}catch(Exception error){throw new RuntimeException(error);}});}
+ private boolean done() throws Exception {if(!pending.isDone())return false;pending.get();return true;}
+ private void next(int value){stage=value;frames=0;System.out.println("CONNECTED_CLIENT_STAGE "+stage);}
+ private static boolean ready(Minecraft mc){return mc.world!=null && mc.player!=null && mc.getIntegratedServer()!=null;}
+ private static boolean gui(Minecraft mc){return mc.currentScreen instanceof CorpseGUIContainer;}
+ private boolean open(Minecraft mc){
+ for(Entity entity:mc.world.loadedEntityList)if(entity instanceof EntityLootableBody && entity.getUniqueID().equals(corpseId)){
+ mc.playerController.interactWithEntity(mc.player,entity,null,EnumHand.MAIN_HAND);return true;
+ }
+ return false;
+ }
+ private static void screenshot(Minecraft mc,String name){ScreenShotHelper.saveScreenshot(mc.mcDataDir,name,mc.displayWidth,mc.displayHeight,mc.getFramebuffer());}
+ private static void quit(Minecraft mc){mc.player.closeScreen();mc.getIntegratedServer().initiateShutdown();}
+ private void mark(String name) throws Exception {
+ try(FileWriter out=new FileWriter(report,true)){out.write("PASS "+name+"\n");}
+ System.out.println("CLIENT_CHECK_PASS "+name);
+ }
+}
diff --git a/src/integrationTest/java/cyano/lootable/buildtest/ContainerInteractionChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/ContainerInteractionChecks.java
new file mode 100644
index 0000000..7a49d79
--- /dev/null
+++ b/src/integrationTest/java/cyano/lootable/buildtest/ContainerInteractionChecks.java
@@ -0,0 +1,403 @@
+package cyano.lootable.buildtest;
+
+import com.mojang.authlib.GameProfile;
+import cyano.lootable.entities.EntityLootableBody;
+import cyano.lootable.graphics.CorpseContainer;
+import io.netty.buffer.Unpooled;
+import java.io.*;
+import java.util.*;
+import net.minecraft.entity.Entity;
+import net.minecraft.entity.item.EntityItem;
+import net.minecraft.entity.player.EntityPlayerMP;
+import net.minecraft.init.Blocks;
+import net.minecraft.inventory.*;
+import net.minecraft.item.ItemStack;
+import net.minecraft.nbt.*;
+import net.minecraft.network.*;
+import net.minecraft.network.play.client.*;
+import net.minecraft.network.play.server.*;
+import net.minecraft.server.MinecraftServer;
+import net.minecraft.world.*;
+import net.minecraftforge.common.MinecraftForge;
+import net.minecraftforge.event.entity.EntityJoinWorldEvent;
+import net.minecraftforge.event.entity.item.ItemTossEvent;
+import net.minecraftforge.fml.common.eventhandler.SubscribeEvent;
+
+/** Server packet/transaction tests; packets are decoded from their actual wire layout. */
+final class ContainerInteractionChecks {
+ private interface Check { void run() throws Exception; }
+ private static MinecraftServer server;
+ private static WorldServer world;
+ private static int sequence, failures;
+ private static short transaction;
+ private static final List results=new ArrayList<>();
+
+ static void run(MinecraftServer instance,WorldServer target,File folder) throws Exception {
+ server=instance;world=target;failures=0;results.clear();
+ check("hotbar_all_nine_empty_and_occupied",ContainerInteractionChecks::hotbar);
+ check("hotbar_armor_validation_and_limits",ContainerInteractionChecks::armorSwap);
+ check("shift_wearable_stack_obeys_slot_limit",ContainerInteractionChecks::wearableShift);
+ check("left_drag_even_split_and_cursor_remainder",()->dragDistribution(0));
+ check("right_drag_one_per_slot_and_cursor_remainder",()->dragDistribution(1));
+ check("drag_rejects_invalid_full_and_different_nbt",ContainerInteractionChecks::dragMixed);
+ check("drag_armor_respects_capacity",ContainerInteractionChecks::dragArmor);
+ check("drag_cancel_and_out_of_order_do_not_move_items",ContainerInteractionChecks::dragCancel);
+ check("full_player_shift_is_unchanged",ContainerInteractionChecks::fullPlayer);
+ check("full_player_partial_merge_conserves_remainder",ContainerInteractionChecks::partialPlayer);
+ check("full_corpse_keeps_existing_aux",ContainerInteractionChecks::fullCorpse);
+ check("full_hotbar_displacement_success_conserves_items",()->displacement(false,false));
+ check("full_hotbar_rejected_drop_keeps_displaced_item",()->displacement(true,false));
+ check("full_hotbar_cancelled_toss_keeps_displaced_item",()->displacement(false,true));
+ check("cursor_close_success_keeps_vanilla_drop",()->closeCursor(false,false));
+ check("cursor_close_rejected_drop_with_player_space",()->closeCursor(true,false));
+ check("cursor_close_rejected_drop_with_full_inventories",()->closeCursor(true,true));
+ check("cursor_close_preserves_existing_death_capture",ContainerInteractionChecks::capturedCursor);
+ check("cursor_close_after_release_keeps_live_inventory_owner",ContainerInteractionChecks::dyingCursor);
+ check("two_player_same_slot_both_orders_and_resync",ContainerInteractionChecks::twoPlayerShift);
+ check("two_player_drag_vs_pickup_preserves_both_tags",()->interleavedDrag(false));
+ check("two_player_drag_vs_hotbar_preserves_both_tags",()->interleavedDrag(true));
+ check("two_player_cursor_close_and_stale_click",ContainerInteractionChecks::twoPlayerClose);
+ check("distant_packet_cannot_extract_before_tick_close",()->stale(false));
+ check("removed_corpse_packet_cannot_extract",()->stale(true));
+ check("drag_departure_resets_pending_distribution",ContainerInteractionChecks::staleDrag);
+ try(PrintWriter out=new PrintWriter(new File(folder,"container-checks.txt"),"UTF-8")) {
+ for(String line:results)out.println(line);
+ out.println("checks="+results.size()+" failures="+failures);
+ }
+ RuntimeChecks.require(failures==0,"Container regressions: "+failures+" / "+results.size());
+ System.out.println("LOOTABLE_BODIES_CONTAINER_PASS checks="+results.size());
+ }
+
+ static void runDrops(MinecraftServer instance,WorldServer target,File folder) throws Exception {
+ server=instance;world=target;failures=0;results.clear();
+ for(boolean creative:new boolean[]{false,true}) for(int source=0;source<3;source++)
+ for(int button=0;button<2;button++) for(int rejection=0;rejection<3;rejection++) {
+ final boolean c=creative;final int a=source,b=button,d=rejection;
+ check((c?"creative":"survival")+"_"+new String[]{"throw_corpse","throw_player","outside"}[a]+"_"+b+"_"+new String[]{"accepted","cancelled_toss","rejected_spawn"}[d],()->throwing(c,a,b,d,false));
+ }
+ for(boolean creative:new boolean[]{false,true}) for(int source:new int[]{0,2}) {
+ final boolean c=creative;final int a=source;
+ check((c?"creative":"survival")+"_capture_"+a,()->throwing(c,a,0,0,true));
+ }
+ for(boolean toss:new boolean[]{false,true}) {
+ final boolean t=toss;
+ check("creative_full_close_"+(t?"cancelled_toss":"rejected_spawn"),()->creativeFallback(t,false));
+ check("creative_full_displacement_"+(t?"cancelled_toss":"rejected_spawn"),()->creativeFallback(t,true));
+ }
+ for(int mode=0;mode<6;mode++) { final int m=mode;check("double_click_"+m,()->collect(m)); }
+ check("cancelled_close_preserves_hotbar_insertion_order",()->fallbackOrder(false));
+ check("cancelled_close_preserves_offhand_merge",()->fallbackOrder(true));
+ check("creative_clone_copies_complete_nbt",()->cloneStack(true,false));
+ check("survival_cannot_clone",()->cloneStack(false,false));
+ check("creative_clone_occupied_cursor_unchanged",()->cloneStack(true,true));
+ check("creative_fill_drag_respects_limits_and_nbt",()->creativeDrag(true));
+ check("survival_cannot_use_creative_fill_drag",()->creativeDrag(false));
+ check("creative_inventory_packet_targets_player_not_corpse",()->creativePacket(true,false));
+ check("survival_rejects_creative_inventory_packet",()->creativePacket(false,false));
+ check("creative_cancelled_minted_drop_leaves_corpse_unchanged",()->creativePacket(true,true));
+ for(int rejection=0;rejection<3;rejection++) {final int r=rejection;check("damaged_equipment_throw_"+r,()->toolThrow(r));}
+ check("throw_interrupts_pending_drag_without_dropping",()->interruptDrag(false));
+ check("outside_click_interrupts_pending_drag_without_dropping",()->interruptDrag(true));
+ try(PrintWriter out=new PrintWriter(new File(folder,"drop-creative-checks.txt"),"UTF-8")) {
+ for(String line:results)out.println(line);out.println("checks="+results.size()+" failures="+failures);
+ }
+ RuntimeChecks.require(failures==0,"Drop/creative regressions: "+failures+" / "+results.size());
+ System.out.println("LOOTABLE_BODIES_DROP_CREATIVE_PASS checks="+results.size());
+ }
+ private static void toolThrow(int rejection) throws Exception {
+ EntityLootableBody b=body();EntityPlayerMP p=player();open(p,b);b.setInventorySlotContents(4,item(4,1));Map before=total(b,p);
+ RejectedDrops reject=new RejectedDrops();reject.toss=rejection==1;reject.join=rejection==2;MinecraftForge.EVENT_BUS.register(reject);
+ try{packet(p,4,0,ClickType.THROW,null);}finally{MinecraftForge.EVENT_BUS.unregister(reject);}
+ conserved(before,b,p);equal(b.getStackInSlot(4),rejection==0?null:item(4,1),"Equipment throw changed damage/enchantment/custom data");
+ }
+ private static void interruptDrag(boolean outside) throws Exception {
+ EntityLootableBody b=body();EntityPlayerMP p=player();open(p,b);b.setInventorySlotContents(6,item(120,17));p.inventory.setItemStack(item(121,10));Map before=total(b,p);
+ packet(p,-999,0,ClickType.QUICK_CRAFT,null);packet(p,7,1,ClickType.QUICK_CRAFT,null);
+ packet(p,outside?-999:6,0,outside?ClickType.PICKUP:ClickType.THROW,null);
+ equal(p.inventory.getItemStack(),item(121,10),"Interrupted drag dropped cursor");equal(b.getStackInSlot(6),item(120,17),"Interrupted drag threw source");conserved(before,b,p);
+ }
+ private static void throwing(boolean creative,int source,int button,int rejection,boolean capture) throws Exception {
+ EntityLootableBody b=body();EntityPlayerMP p=player();p.setGameType(creative?GameType.CREATIVE:GameType.SURVIVAL);
+ fill(b);fill(p);open(p,b);ItemStack original=item(120,17);
+ if(source==0)b.setInventorySlotContents(6,original.copy());
+ else if(source==1)p.inventory.mainInventory[9]=original.copy();else p.inventory.setItemStack(original.copy());
+ if(capture){p.captureDrops=true;p.capturedDrops.add(new EntityItem(world,96.5,80,96.5,item(121,11)));}
+ Map before=total(b,p);RejectedDrops reject=new RejectedDrops();reject.toss=rejection==1;reject.join=rejection==2;
+ RecordingConnection connection=(RecordingConnection)p.connection;int initialSyncs=connection.resyncs;
+ MinecraftForge.EVENT_BUS.register(reject);
+ try{packet(p,source==2?-999:source==0?6:48,button,source==2?ClickType.PICKUP:ClickType.THROW,null);}
+ finally{MinecraftForge.EVENT_BUS.unregister(reject);}
+ conserved(before,b,p);RuntimeChecks.require(p.captureDrops==capture,"Toss changed capture state");
+ int amount=source==2?(button==0?17:1):(button==0?1:17);
+ ItemStack remaining=rejection==0 && amount==17?null:item(120,rejection==0?17-amount:17);
+ equal(source==2?p.inventory.getItemStack():p.openContainer.getSlot(source==0?6:48).getStack(),remaining,"Drop quantity/source restoration differs");
+ if(rejection!=0)RuntimeChecks.require(connection.resyncs>initialSyncs,"Rejected predicted drop did not resync initiating client");
+ if(capture)RuntimeChecks.require(p.capturedDrops.size()==2,"Toss discarded pre-existing captured item");
+ if(rejection==0 && !capture){
+ int dropped=0;for(Entity e:world.loadedEntityList)if(e instanceof EntityItem && !e.isDead){equal(((EntityItem)e).getEntityItem(),item(120,amount),"Accepted drop NBT differs");dropped++;}
+ RuntimeChecks.require(dropped==1,"Accepted toss did not create exactly one entity");
+ }
+ }
+ private static void creativeFallback(boolean toss,boolean swap) throws Exception {
+ EntityLootableBody b=body();EntityPlayerMP p=player();fill(b);fill(p);p.setGameType(GameType.CREATIVE);open(p,b);
+ if(swap){b.setInventorySlotContents(0,item(0,1));p.inventory.mainInventory[0]=pumpkins(64);}
+ else p.inventory.setItemStack(item(120,17));
+ Map before=total(b,p);RejectedDrops reject=new RejectedDrops();reject.toss=toss;reject.join=!toss;MinecraftForge.EVENT_BUS.register(reject);
+ try{if(swap)packet(p,0,0,ClickType.SWAP,null);else close(p);}finally{MinecraftForge.EVENT_BUS.unregister(reject);}
+ conserved(before,b,p);RuntimeChecks.require(b.isEntityAlive(),"Creative fallback owner no longer alive");
+ }
+ private static void collect(int mode) throws Exception {
+ EntityLootableBody b=body();EntityPlayerMP p=player(),q=player();open(p,b);open(q,b);
+ b.setInventorySlotContents(6,item(120,20));b.setInventorySlotContents(7,item(120,30));b.setInventorySlotContents(8,item(121,14));
+ ItemStack differentDamage=item(120,12);differentDamage.setItemDamage((differentDamage.getItemDamage()+1)%16);b.setInventorySlotContents(9,differentDamage);
+ p.inventory.mainInventory[9]=item(120,10);p.inventory.setItemStack(mode==3?null:item(120,mode==2?64:5));
+ if(mode==4)p.setGameType(GameType.CREATIVE);
+ if(mode==5){q.inventory.setItemStack(item(120,4));packet(q,10,0,ClickType.PICKUP_ALL,null);}
+ Map before=total(b,p,q);int wanted=mode==3?0:mode==2?64:mode==5?15:64;
+ packet(p,10,mode==1?1:0,ClickType.PICKUP_ALL,null);
+ RuntimeChecks.require((p.inventory.getItemStack()==null?0:p.inventory.getItemStack().stackSize)==wanted,"Double click count/capacity differs");
+ equal(b.getStackInSlot(8),item(121,14),"Double click collected different custom NBT");equal(b.getStackInSlot(9),differentDamage,"Double click collected different damage");
+ if(mode==0)equal(p.inventory.mainInventory[9],item(120,1),"Forward collection order differs");
+ if(mode==1)equal(b.getStackInSlot(6),item(120,1),"Reverse collection order differs");
+ conserved(before,b,p,q);
+ }
+ private static void fallbackOrder(boolean offhand) throws Exception {
+ EntityLootableBody b=body();EntityPlayerMP p=player();open(p,b);
+ if(offhand){fill(p);p.inventory.offHandInventory[0]=item(120,60);}
+ p.inventory.setItemStack(item(120,10));Map before=total(b,p);RejectedDrops reject=new RejectedDrops();reject.toss=true;MinecraftForge.EVENT_BUS.register(reject);
+ try{close(p);}finally{MinecraftForge.EVENT_BUS.unregister(reject);}
+ equal(p.inventory.getStackInSlot(offhand?40:0),item(120,offhand?64:10),"Rejected-drop fallback changed vanilla player insertion priority");conserved(before,b,p);
+ }
+ private static void cloneStack(boolean creative,boolean occupied) throws Exception {
+ EntityLootableBody b=body();EntityPlayerMP p=player();p.setGameType(creative?GameType.CREATIVE:GameType.SURVIVAL);open(p,b);b.setInventorySlotContents(6,item(120,17));
+ if(occupied)p.inventory.setItemStack(item(121,4));Map expected=total(b,p);
+ if(creative && !occupied)add(expected,item(120,64));packet(p,6,2,ClickType.CLONE,null);
+ equal(b.getStackInSlot(6),item(120,17),"Creative clone removed source");equal(p.inventory.getItemStack(),occupied?item(121,4):creative?item(120,64):null,"Clone cursor/NBT differs");conserved(expected,b,p);
+ }
+ private static void creativeDrag(boolean creative) throws Exception {
+ EntityLootableBody b=body();EntityPlayerMP p=player();p.setGameType(creative?GameType.CREATIVE:GameType.SURVIVAL);open(p,b);p.inventory.setItemStack(pumpkins(64));Map expected=total(b,p);
+ if(creative){add(expected,pumpkins(65));} // 1 armor + two main stacks, with original cursor consumed by vanilla 1.10.
+ drag(p,2,0,6,7);equal(b.getStackInSlot(0),creative?pumpkins(1):null,"Creative fill armor capacity differs");
+ equal(b.getStackInSlot(6),creative?pumpkins(64):null,"Creative fill main capacity differs");equal(b.getStackInSlot(7),creative?pumpkins(64):null,"Creative fill second main differs");conserved(expected,b,p);
+ }
+ private static void creativePacket(boolean creative,boolean cancelled) throws Exception {
+ EntityLootableBody b=body();EntityPlayerMP p=player();p.setGameType(creative?GameType.CREATIVE:GameType.SURVIVAL);open(p,b);b.setInventorySlotContents(6,item(120,17));NBTTagCompound unchanged=data(b);
+ RejectedDrops reject=new RejectedDrops();reject.toss=cancelled;MinecraftForge.EVENT_BUS.register(reject);PacketBuffer wire=new PacketBuffer(Unpooled.buffer());
+ try{wire.writeShort(cancelled?-1:9);wire.writeItemStack(item(121,23));CPacketCreativeInventoryAction packet=new CPacketCreativeInventoryAction();packet.readPacketData(wire);p.connection.processCreativeInventoryAction(packet);}
+ finally{wire.release();MinecraftForge.EVENT_BUS.unregister(reject);}
+ RuntimeChecks.require(unchanged.equals(data(b)),"Creative inventory packet changed corpse instead of player");equal(p.inventory.mainInventory[9],creative&&!cancelled?item(121,23):null,"Creative player packet permission/target differs");
+ if(cancelled)RuntimeChecks.require(world.loadedEntityList.stream().noneMatch(e->e instanceof EntityItem && !e.isDead),"Cancelled creative minted drop spawned anyway");
+ }
+
+ private static void check(String name,Check test) {
+ Set before=new HashSet<>(world.loadedEntityList);
+ try {test.run();results.add("PASS "+name);System.out.println("CONTAINER_CHECK_PASS "+name);}
+ catch(Throwable error){failures++;results.add("FAIL "+name+": "+error);System.out.println("CONTAINER_CHECK_FAIL "+name+": "+error);error.printStackTrace();}
+ finally{for(Entity entity:new ArrayList(world.loadedEntityList))if(!before.contains(entity))world.removeEntityDangerously(entity);}
+ }
+ private static EntityPlayerMP player() {
+ EntityPlayerMP p=new EntityPlayerMP(server,world,
+ new GameProfile(UUID.nameUUIDFromBytes(("container-player-"+sequence++).getBytes(java.nio.charset.StandardCharsets.UTF_8)),"ContainerFixture"),
+ new net.minecraft.server.management.PlayerInteractionManager(world));
+ p.connection=new RecordingConnection(p);p.setGameType(GameType.SURVIVAL);
+ p.setPosition(96.5,80,96.5);world.getChunkFromChunkCoords(6,6);return p;
+ }
+ private static EntityLootableBody body(){
+ EntityLootableBody b=new EntityLootableBody(world);b.setPosition(96.5,80,96.5);b.setNoGravity(true);RuntimeChecks.disableVacuum(b);
+ world.getChunkFromChunkCoords(6,6);RuntimeChecks.require(world.spawnEntity(b),"Container corpse spawn");return b;
+ }
+ private static CorpseContainer open(EntityPlayerMP p,EntityLootableBody b){
+ CorpseContainer c=new CorpseContainer(p.inventory,b);c.windowId=7;p.openContainer=c;c.addListener(p);return c;
+ }
+ private static ItemStack item(int id,int count){ItemStack s=RuntimeChecks.stack(id);s.stackSize=count;return s;}
+ private static ItemStack pumpkins(int count){ItemStack s=new ItemStack(Blocks.PUMPKIN,count);s.setTagCompound(item(120,1).getTagCompound().copy());return s;}
+ private static void fill(EntityPlayerMP p){for(int i=0;i<36;i++)p.inventory.mainInventory[i]=item(100+i,64);}
+ private static void fill(EntityLootableBody b){for(int i=0;i<48;i++)b.setInventorySlotContents(i,item(i,i<6?1:64));b.addItem(item(70,31));}
+ private static void packet(EntityPlayerMP p,int slot,int button,ClickType type,ItemStack predicted) throws Exception {
+ PacketBuffer wire=new PacketBuffer(Unpooled.buffer());
+ try{
+ wire.writeByte(p.openContainer.windowId);wire.writeShort(slot);wire.writeByte(button);wire.writeShort(++transaction);
+ wire.writeEnumValue(type);wire.writeItemStack(predicted);
+ CPacketClickWindow packet=new CPacketClickWindow();packet.readPacketData(wire);p.connection.processClickWindow(packet);
+ } finally{wire.release();}
+ }
+ private static void click(EntityPlayerMP p,int slot,int button,ClickType type) throws Exception {
+ ItemStack predicted=null;
+ if((type==ClickType.PICKUP || type==ClickType.QUICK_MOVE) && slot>=0){
+ ItemStack s=p.openContainer.getSlot(slot).getStack();predicted=s==null?null:s.copy();
+ }
+ packet(p,slot,button,type,predicted);
+ }
+ private static void close(EntityPlayerMP p) throws Exception {
+ PacketBuffer wire=new PacketBuffer(Unpooled.buffer());
+ try{wire.writeByte(p.openContainer.windowId);CPacketCloseWindow packet=new CPacketCloseWindow();packet.readPacketData(wire);p.connection.processCloseWindow(packet);}
+ finally{wire.release();}
+ }
+ private static void drag(EntityPlayerMP p,int mode,int... slots) throws Exception {
+ packet(p,-999,mode<<2,ClickType.QUICK_CRAFT,null);
+ for(int slot:slots)packet(p,slot,1|(mode<<2),ClickType.QUICK_CRAFT,null);
+ packet(p,-999,2|(mode<<2),ClickType.QUICK_CRAFT,null);
+ }
+ private static void equal(ItemStack actual,ItemStack expected,String reason){RuntimeChecks.require(ItemStack.areItemStacksEqual(actual,expected),reason);}
+ private static NBTTagCompound data(EntityLootableBody b){NBTTagCompound n=new NBTTagCompound();b.writeEntityToNBT(n);return n;}
+ private static void add(Map all,ItemStack s){
+ if(s==null || s.stackSize<=0)return;NBTTagCompound n=s.writeToNBT(new NBTTagCompound());n.removeTag("Count");
+ String key=n.toString();all.put(key,all.getOrDefault(key,0)+s.stackSize);
+ }
+ private static Map total(EntityLootableBody b,EntityPlayerMP... players){
+ Map all=new TreeMap<>();
+ for(int i=0;i<48;i++)add(all,b.getStackInSlot(i));NBTTagList aux=data(b).getTagList("Aux",10);
+ for(int i=0;i expected,EntityLootableBody b,EntityPlayerMP... players){
+ RuntimeChecks.require(expected.equals(total(b,players)),"Item count/type/damage/custom NBT lost or duplicated");
+ NBTTagCompound before=data(b),immutable=before.copy();EntityLootableBody copy=new EntityLootableBody(world);copy.readEntityFromNBT(before);
+ RuntimeChecks.require(Objects.equals(before.getTag("Equipment"),data(copy).getTag("Equipment")) && Objects.equals(before.getTag("Aux"),data(copy).getTag("Aux")),"Post-interaction NBT round trip changed inventory");
+ RuntimeChecks.require(before.equals(immutable),"Post-interaction reader mutated saved input");
+ }
+ private static void hotbar() throws Exception {
+ EntityLootableBody b=body();EntityPlayerMP p=player();open(p,b);
+ for(int key=0;key<9;key++){
+ p.inventory.mainInventory[key]=item(30+key,13);Map before=total(b,p);
+ click(p,6,key,ClickType.SWAP);equal(b.getStackInSlot(6),item(30+key,13),"Empty hotbar swap failed");equal(p.inventory.mainInventory[key],null,"Hotbar source remained");
+ click(p,6,key,ClickType.SWAP);equal(b.getStackInSlot(6),null,"Hotbar return left source");
+ b.setInventorySlotContents(6,item(19,23));before=total(b,p);
+ click(p,6,key,ClickType.SWAP);equal(b.getStackInSlot(6),item(30+key,13),"Occupied hotbar incoming differs");equal(p.inventory.mainInventory[key],item(19,23),"Occupied hotbar outgoing differs");
+ click(p,6,key,ClickType.SWAP);conserved(before,b,p);b.setInventorySlotContents(6,null);p.inventory.mainInventory[key]=null;
+ }
+ }
+ private static void armorSwap() throws Exception {
+ EntityLootableBody b=body();EntityPlayerMP p=player();open(p,b);p.inventory.mainInventory[0]=item(4,1);
+ click(p,0,0,ClickType.SWAP);equal(b.getStackInSlot(0),null,"Sword inserted into armor slot");
+ p.inventory.mainInventory[0]=pumpkins(64);Map before=total(b,p);click(p,0,0,ClickType.SWAP);
+ equal(b.getStackInSlot(0),pumpkins(1),"Hotbar armor limit ignored");equal(p.inventory.mainInventory[0],pumpkins(63),"Hotbar armor remainder lost");conserved(before,b,p);
+ }
+ private static void wearableShift() throws Exception {
+ EntityLootableBody b=body();EntityPlayerMP p=player();p.inventory.mainInventory[9]=pumpkins(64);open(p,b);
+ Map before=total(b,p);click(p,48,0,ClickType.QUICK_MOVE);
+ RuntimeChecks.require(b.getStackInSlot(0)!=null && b.getStackInSlot(0).stackSize==1,"Shift-click put a stack into the one-item head slot");
+ conserved(before,b,p);
+ }
+ private static void dragDistribution(int mode) throws Exception {
+ EntityLootableBody b=body();EntityPlayerMP p=player();open(p,b);p.inventory.setItemStack(item(19,10));Map before=total(b,p);
+ drag(p,mode,6,7,8);int each=mode==0?3:1;
+ for(int i=6;i<=8;i++)equal(b.getStackInSlot(i),item(19,each),"Drag distribution differs");
+ equal(p.inventory.getItemStack(),item(19,10-3*each),"Drag cursor remainder differs");conserved(before,b,p);
+ }
+ private static void dragMixed() throws Exception {
+ EntityLootableBody b=body();b.setInventorySlotContents(6,item(19,64));b.setInventorySlotContents(7,item(19,63));b.setInventorySlotContents(8,item(20,11));
+ EntityPlayerMP p=player();open(p,b);p.inventory.setItemStack(item(19,12));Map before=total(b,p);
+ drag(p,0,0,6,7,8,9,48);equal(b.getStackInSlot(0),null,"Drag bypassed armor validity");equal(b.getStackInSlot(6),item(19,64),"Drag changed saturated slot");equal(b.getStackInSlot(8),item(20,11),"Drag merged different NBT");
+ RuntimeChecks.require(b.getStackInSlot(7).stackSize<=64,"Drag overfilled partial slot");conserved(before,b,p);
+ }
+ private static void dragArmor() throws Exception {
+ EntityLootableBody b=body();EntityPlayerMP p=player();open(p,b);p.inventory.setItemStack(pumpkins(64));Map before=total(b,p);
+ drag(p,0,0,6);equal(b.getStackInSlot(0),pumpkins(1),"Drag head limit ignored");equal(b.getStackInSlot(6),pumpkins(32),"Drag main allocation differs");equal(p.inventory.getItemStack(),pumpkins(31),"Drag head remainder lost");conserved(before,b,p);
+ }
+ private static void dragCancel() throws Exception {
+ EntityLootableBody b=body();EntityPlayerMP p=player();open(p,b);p.inventory.setItemStack(item(19,10));Map before=total(b,p);
+ packet(p,-999,2,ClickType.QUICK_CRAFT,null);packet(p,-999,0,ClickType.QUICK_CRAFT,null);packet(p,6,1,ClickType.QUICK_CRAFT,null);
+ packet(p,48,0,ClickType.PICKUP,null);packet(p,-999,2,ClickType.QUICK_CRAFT,null);
+ equal(b.getStackInSlot(6),null,"Cancelled drag wrote selected slot");equal(p.inventory.getItemStack(),item(19,10),"Cancelled drag consumed cursor");conserved(before,b,p);
+ }
+ private static void fullPlayer() throws Exception {
+ EntityLootableBody b=body();b.setInventorySlotContents(6,item(19,23));EntityPlayerMP p=player();fill(p);open(p,b);
+ Map before=total(b,p);packet(p,6,0,ClickType.QUICK_MOVE,null);equal(b.getStackInSlot(6),item(19,23),"Full player inventory consumed corpse stack");conserved(before,b,p);
+ }
+ private static void partialPlayer() throws Exception {
+ EntityLootableBody b=body();b.setInventorySlotContents(6,item(19,23));EntityPlayerMP p=player();fill(p);p.inventory.mainInventory[35]=item(19,63);open(p,b);
+ Map before=total(b,p);click(p,6,0,ClickType.QUICK_MOVE);equal(p.inventory.mainInventory[35],item(19,64),"Partial merge did not fill one space");equal(b.getStackInSlot(6),item(19,22),"Partial merge discarded remainder");conserved(before,b,p);
+ }
+ private static void fullCorpse() throws Exception {
+ EntityLootableBody b=body();fill(b);EntityPlayerMP p=player();p.inventory.mainInventory[9]=item(110,23);open(p,b);
+ NBTTagCompound unchanged=data(b);Map before=total(b,p);packet(p,48,0,ClickType.QUICK_MOVE,null);b.onEntityUpdate();
+ RuntimeChecks.require(unchanged.getTag("Equipment").equals(data(b).getTag("Equipment")) && unchanged.getTag("Aux").equals(data(b).getTag("Aux")),"Full corpse overwrote indexed/Aux contents");conserved(before,b,p);
+ }
+ public static final class RejectedDrops {
+ boolean join,toss;
+ @SubscribeEvent public void join(EntityJoinWorldEvent e){if(join && e.getEntity() instanceof EntityItem)e.setCanceled(true);}
+ @SubscribeEvent public void toss(ItemTossEvent e){if(toss)e.setCanceled(true);}
+ }
+ private static void displacement(boolean rejectJoin,boolean rejectToss) throws Exception {
+ EntityLootableBody b=body();b.setInventorySlotContents(0,item(0,1));EntityPlayerMP p=player();fill(p);p.inventory.mainInventory[0]=pumpkins(64);open(p,b);Map before=total(b,p);
+ RejectedDrops reject=new RejectedDrops();reject.join=rejectJoin;reject.toss=rejectToss;MinecraftForge.EVENT_BUS.register(reject);
+ try{click(p,0,0,ClickType.SWAP);}finally{MinecraftForge.EVENT_BUS.unregister(reject);}
+ equal(b.getStackInSlot(0),pumpkins(1),"Displacement changed normal incoming swap");equal(p.inventory.mainInventory[0],pumpkins(63),"Displacement changed hotbar remainder");conserved(before,b,p);
+ if(!rejectJoin && !rejectToss)RuntimeChecks.require(world.loadedEntityList.stream().anyMatch(e->e instanceof EntityItem && !e.isDead),"Successful displacement no longer drops normally");
+ }
+ private static void closeCursor(boolean rejected,boolean full) throws Exception {
+ EntityLootableBody b=body();EntityPlayerMP p=player();if(full){fill(b);fill(p);}open(p,b);p.inventory.setItemStack(item(120,17));Map before=total(b,p);
+ RejectedDrops reject=new RejectedDrops();reject.join=rejected;MinecraftForge.EVENT_BUS.register(reject);
+ try{close(p);}finally{MinecraftForge.EVENT_BUS.unregister(reject);}
+ equal(p.inventory.getItemStack(),null,"Closing left a nonsaved cursor stack");conserved(before,b,p);
+ if(!rejected)RuntimeChecks.require(world.loadedEntityList.stream().anyMatch(e->e instanceof EntityItem && !e.isDead),"Cursor close no longer performs normal accepted drop");
+ }
+ private static void capturedCursor() throws Exception {
+ EntityLootableBody b=body();EntityPlayerMP p=player();open(p,b);p.inventory.setItemStack(item(120,17));
+ p.captureDrops=true;p.capturedDrops.add(new EntityItem(world,96.5,80,96.5,item(121,11)));Map before=total(b,p);
+ close(p);RuntimeChecks.require(p.captureDrops,"Container close changed caller's drop capture state");conserved(before,b,p);
+ }
+ private static void dyingCursor() throws Exception {
+ EntityLootableBody b=body();EntityPlayerMP p=player();fill(p);open(p,b);p.inventory.setItemStack(item(120,17));
+ b.onKillCommand();b.onEntityUpdate();Map before=total(b,p);
+ RejectedDrops reject=new RejectedDrops();reject.join=true;MinecraftForge.EVENT_BUS.register(reject);
+ try{close(p);}finally{MinecraftForge.EVENT_BUS.unregister(reject);}
+ for(int i=0;i<15;i++)b.onEntityUpdate();
+ RuntimeChecks.require(b.isEntityAlive() || b.isEmpty(),"Cursor fallback was put into an owner still scheduled for removal");
+ conserved(before,b,p);
+ }
+ private static void twoPlayerShift() throws Exception {
+ for(int order=0;order<2;order++){
+ EntityLootableBody b=body();b.setInventorySlotContents(6,item(19,23));EntityPlayerMP a=player(),c=player();open(a,b);open(c,b);
+ EntityPlayerMP first=order==0?a:c,second=order==0?c:a;Map before=total(b,a,c);
+ click(first,6,0,ClickType.QUICK_MOVE);packet(second,6,0,ClickType.QUICK_MOVE,item(19,23));
+ a.openContainer.detectAndSendChanges();c.openContainer.detectAndSendChanges();
+ RuntimeChecks.require(!second.openContainer.getCanCraft(second),"Stale transaction was not rejected");
+ RuntimeChecks.require(((RecordingConnection)second.connection).resyncs>0 && ((RecordingConnection)second.connection).rejections>0,"Stale viewer did not receive inventory resync");
+ // Accepted clicks rely on the initiating client's prediction; only the
+ // rejected stale viewer requires an authoritative window resync.
+ equal(first.openContainer.getSlot(6).getStack(),null,"First server container remained stale");
+ equal(((RecordingConnection)second.connection).slots.get(6),null,"Rejected viewer source remained stale");conserved(before,b,a,c);
+ }
+ }
+ private static void interleavedDrag(boolean swap) throws Exception {
+ EntityLootableBody b=body();EntityPlayerMP a=player(),c=player();open(a,b);open(c,b);a.inventory.setItemStack(item(19,10));
+ if(swap)c.inventory.mainInventory[0]=item(20,7);else c.inventory.setItemStack(item(20,7));Map before=total(b,a,c);
+ packet(a,-999,0,ClickType.QUICK_CRAFT,null);packet(a,6,1,ClickType.QUICK_CRAFT,null);packet(a,7,1,ClickType.QUICK_CRAFT,null);
+ click(c,6,0,swap?ClickType.SWAP:ClickType.PICKUP);packet(a,-999,2,ClickType.QUICK_CRAFT,null);
+ equal(b.getStackInSlot(6),item(20,7),"Other player's stack was overwritten by drag");equal(b.getStackInSlot(7),item(19,5),"Surviving drag slot differs");equal(a.inventory.getItemStack(),item(19,5),"Interrupted drag lost remainder");conserved(before,b,a,c);
+ }
+ private static void twoPlayerClose() throws Exception {
+ EntityLootableBody b=body();b.setInventorySlotContents(6,item(19,23));EntityPlayerMP a=player(),c=player();fill(a);open(a,b);open(c,b);Map before=total(b,a,c);
+ click(a,6,0,ClickType.PICKUP);packet(c,6,0,ClickType.QUICK_MOVE,item(19,23));RejectedDrops reject=new RejectedDrops();reject.join=true;MinecraftForge.EVENT_BUS.register(reject);
+ try{close(a);}finally{MinecraftForge.EVENT_BUS.unregister(reject);}
+ c.openContainer.detectAndSendChanges();conserved(before,b,a,c);
+ }
+ private static void stale(boolean removed) throws Exception {
+ EntityLootableBody b=body();b.setInventorySlotContents(6,item(19,23));EntityPlayerMP p=player();open(p,b);Map before=total(b,p);
+ if(removed)b.setDead();else p.setPosition(200,80,200);
+ packet(p,6,0,ClickType.QUICK_MOVE,null);equal(b.getStackInSlot(6),item(19,23),"Stale packet extracted before tick closed GUI");conserved(before,b,p);
+ }
+ private static void staleDrag() throws Exception {
+ EntityLootableBody b=body();EntityPlayerMP p=player();open(p,b);p.inventory.setItemStack(item(19,10));Map before=total(b,p);
+ packet(p,-999,0,ClickType.QUICK_CRAFT,null);packet(p,6,1,ClickType.QUICK_CRAFT,null);p.setPosition(200,80,200);
+ packet(p,-999,2,ClickType.QUICK_CRAFT,null);equal(b.getStackInSlot(6),null,"Distant drag still distributed items");
+ p.setPosition(96.5,80,96.5);drag(p,0,7);equal(b.getStackInSlot(7),item(19,10),"New drag reused invalid selection");conserved(before,b,p);
+ }
+ private static final class RecordingConnection extends NetHandlerPlayServer {
+ final Map slots=new HashMap<>();int resyncs,rejections;
+ RecordingConnection(EntityPlayerMP p){super(server,new NetworkManager(EnumPacketDirection.SERVERBOUND),p);}
+ @Override public void sendPacket(Packet> packet){
+ if(!(packet instanceof SPacketWindowItems || packet instanceof SPacketSetSlot || packet instanceof SPacketConfirmTransaction))return;
+ PacketBuffer wire=new PacketBuffer(Unpooled.buffer());
+ try{packet.writePacketData(wire);int window=wire.readByte();
+ if(packet instanceof SPacketWindowItems){int n=wire.readShort();for(int i=0;i=0)slots.put(slot,s);}
+ else{wire.readShort();if(!wire.readBoolean())rejections++;}
+ }catch(IOException error){throw new RuntimeException(error);}finally{wire.release();}
+ }
+ }
+}
diff --git a/src/integrationTest/java/cyano/lootable/buildtest/DropClientChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/DropClientChecks.java
new file mode 100644
index 0000000..4611967
--- /dev/null
+++ b/src/integrationTest/java/cyano/lootable/buildtest/DropClientChecks.java
@@ -0,0 +1,118 @@
+package cyano.lootable.buildtest;
+
+import cyano.lootable.entities.EntityLootableBody;
+import cyano.lootable.graphics.CorpseGUIContainer;
+import java.io.*;
+import java.util.*;
+import java.util.concurrent.Future;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.gui.GuiMainMenu;
+import net.minecraft.entity.Entity;
+import net.minecraft.entity.item.EntityItem;
+import net.minecraft.entity.player.EntityPlayerMP;
+import net.minecraft.init.Blocks;
+import net.minecraft.inventory.ClickType;
+import net.minecraft.item.ItemStack;
+import net.minecraft.nbt.*;
+import net.minecraft.util.EnumHand;
+import net.minecraft.util.ScreenShotHelper;
+import net.minecraft.util.math.BlockPos;
+import net.minecraft.world.GameType;
+import net.minecraftforge.common.MinecraftForge;
+
+/** Actual connected client predictions, authoritative corrections and disk reload. */
+final class DropClientChecks {
+ private interface Action {void run() throws Exception;}
+ private final UUID bodyId=UUID.fromString("00000000-0000-4000-8000-000000000109");
+ private final ContainerInteractionChecks.RejectedDrops reject=new ContainerInteractionChecks.RejectedDrops();
+ private int stage,frames;
+ private Future> pending;
+ private UUID playerId;
+ private NBTTagCompound savedBody;
+ private NBTTagList savedPlayer;
+ void start(Minecraft mc){playerId=mc.player.getUniqueID();}
+ boolean tick(Minecraft mc) throws Exception {
+ frames++;
+ if(stage==0){schedule(mc,()->prepare(mc));next(1);}
+ else if(stage==1 && done()){if(open(mc))next(2);}
+ else if(stage==2 && gui(mc) && frames>20){
+ equal(slot(mc,6),item(120,17),"Initial client source");click(mc,6,0,ClickType.THROW);next(3);
+ } else if(stage==3 && frames>20){
+ equal(slot(mc,6),item(120,16),"Accepted client single throw");
+ schedule(mc,()->{equal(body(mc).getStackInSlot(6),item(120,16),"Accepted server single throw");drop(mc,item(120,1));mark(mc,"network_accepted_throw");reject.toss=true;MinecraftForge.EVENT_BUS.register(reject);});next(4);
+ } else if(stage==4 && done()) {click(mc,6,1,ClickType.THROW);next(5);}
+ else if(stage==5 && frames>20){
+ equal(slot(mc,6),item(120,16),"Cancelled throw did not correct client source");screenshot(mc,"drop-cancelled-source-resync.png");
+ schedule(mc,()->{equal(body(mc).getStackInSlot(6),item(120,16),"Cancelled server throw");mark(mc,"network_cancelled_throw_source_resync");reject.toss=false;reject.join=true;});next(6);
+ } else if(stage==6 && done()){click(mc,6,0,ClickType.PICKUP);next(7);}
+ else if(stage==7 && frames>20){equal(mc.player.inventory.getItemStack(),item(120,16),"Pickup cursor");click(mc,-999,1,ClickType.PICKUP);next(8);}
+ else if(stage==8 && frames>20){
+ equal(mc.player.inventory.getItemStack(),item(120,16),"Rejected outside drop did not correct client cursor");equal(slot(mc,6),null,"Outside source reappeared");screenshot(mc,"drop-rejected-cursor-resync.png");
+ schedule(mc,()->{equal(player(mc).inventory.getItemStack(),item(120,16),"Rejected server outside cursor");mark(mc,"network_rejected_outside_cursor_resync");reject.join=false;});next(9);
+ } else if(stage==9 && done()){click(mc,-999,0,ClickType.PICKUP);next(10);}
+ else if(stage==10 && frames>20){
+ equal(mc.player.inventory.getItemStack(),null,"Accepted outside client cursor");
+ schedule(mc,()->{drop(mc,item(120,16));equal(player(mc).inventory.getItemStack(),null,"Accepted outside server cursor");mark(mc,"network_accepted_outside_drop");
+ body(mc).setInventorySlotContents(6,item(120,17));player(mc).inventory.mainInventory[9]=item(120,5);refresh(mc);});next(11);
+ } else if(stage==11 && done() && frames>20){click(mc,48,0,ClickType.PICKUP);click(mc,10,0,ClickType.PICKUP_ALL);next(12);}
+ else if(stage==12 && frames>20){
+ equal(mc.player.inventory.getItemStack(),item(120,42),"Double-click client cursor");equal(slot(mc,8),item(121,10),"Double-click changed unlike NBT");
+ schedule(mc,()->{equal(player(mc).inventory.getItemStack(),item(120,42),"Double-click server cursor");equal(body(mc).getStackInSlot(6),null,"Double-click source 6");equal(body(mc).getStackInSlot(7),null,"Double-click source 7");mark(mc,"network_double_click_exact_nbt");});next(13);
+ } else if(stage==13 && done()){click(mc,48,0,ClickType.PICKUP);next(14);}
+ else if(stage==14 && frames>20){schedule(mc,()->{player(mc).setGameType(GameType.CREATIVE);body(mc).setInventorySlotContents(6,item(120,17));refresh(mc);});next(15);}
+ else if(stage==15 && done() && frames>20){RuntimeChecks.require(mc.player.capabilities.isCreativeMode,"Client not creative");click(mc,6,2,ClickType.CLONE);next(16);}
+ else if(stage==16 && frames>20){
+ equal(mc.player.inventory.getItemStack(),item(120,64),"Creative clone client NBT");equal(slot(mc,6),item(120,17),"Creative clone changed source");
+ click(mc,-999,8,ClickType.QUICK_CRAFT);click(mc,7,9,ClickType.QUICK_CRAFT);click(mc,9,9,ClickType.QUICK_CRAFT);click(mc,-999,10,ClickType.QUICK_CRAFT);next(17);
+ } else if(stage==17 && frames>20){
+ equal(slot(mc,7),item(120,64),"Creative client fill first");equal(slot(mc,9),item(120,64),"Creative client fill second");equal(mc.player.inventory.getItemStack(),null,"Creative fill vanilla cursor");screenshot(mc,"drop-creative-filled.png");
+ schedule(mc,()->{equal(body(mc).getStackInSlot(7),item(120,64),"Creative server fill first");equal(body(mc).getStackInSlot(9),item(120,64),"Creative server fill second");equal(player(mc).inventory.mainInventory[9],item(120,42),"Creative operation changed recovered inventory");mark(mc,"network_creative_clone_and_fill");
+ for(int i=0;i<48;i++)body(mc).setInventorySlotContents(i,item(i,i<6?1:64));body(mc).addItem(item(70,31));
+ for(int i=0;i<36;i++)player(mc).inventory.mainInventory[i]=item(100+i,64);player(mc).inventory.setItemStack(item(122,17));reject.toss=true;refresh(mc);});next(18);
+ } else if(stage==18 && done() && frames>20){equal(mc.player.inventory.getItemStack(),item(122,17),"Creative close cursor fixture");mc.player.closeScreen();next(19);}
+ else if(stage==19 && frames>20){
+ equal(mc.player.inventory.getItemStack(),null,"Closed creative client cursor");
+ schedule(mc,()->{equal(player(mc).inventory.getItemStack(),null,"Closed creative server cursor");NBTTagCompound data=new NBTTagCompound();body(mc).writeEntityToNBT(data);NBTTagList aux=data.getTagList("Aux",10);int count=0;
+ for(int i=0;i20){schedule(mc,()->{
+ player(mc).world.getChunkFromChunkCoords(2,0);body(mc).onEntityUpdate();RuntimeChecks.verifyExpected(folder(mc),"drop-creative-final",body(mc));
+ RuntimeChecks.require(savedPlayer.equals(player(mc).inventory.writeToNBT(new NBTTagList())),"Creative player inventory changed on disk reload");player(mc).connection.setPlayerLocation(40.5,80,7.5,0,0);
+ mark(mc,"creative_cancelled_close_full_inventory_disk_reload");});next(23);
+ } else if(stage==23 && done()){if(open(mc))next(24);}
+ else if(stage==24 && gui(mc) && frames>20){
+ ItemStack[] expected=new ItemStack[48];NBTTagList list=savedBody.getTagList("Equipment",10);for(int i=0;i(player(mc).world.loadedEntityList))if(e instanceof EntityItem && !e.isDead){equal(((EntityItem)e).getEntityItem(),expected,"Accepted world drop NBT/count");count++;player(mc).world.removeEntityDangerously(e);}RuntimeChecks.require(count==1,"Expected exactly one accepted drop");}
+ private static ItemStack slot(Minecraft mc,int index){return mc.player.openContainer.getSlot(index).getStack();}
+ private static void click(Minecraft mc,int slot,int button,ClickType type){mc.playerController.windowClick(mc.player.openContainer.windowId,slot,button,type,mc.player);}
+ private static boolean gui(Minecraft mc){return mc.currentScreen instanceof CorpseGUIContainer;}
+ private static boolean ready(Minecraft mc){return mc.world!=null && mc.player!=null && mc.getIntegratedServer()!=null;}
+ private boolean open(Minecraft mc){for(Entity e:mc.world.loadedEntityList)if(e.getUniqueID().equals(bodyId)){mc.playerController.interactWithEntity(mc.player,e,null,EnumHand.MAIN_HAND);return true;}return false;}
+ private EntityPlayerMP player(Minecraft mc){return mc.getIntegratedServer().getPlayerList().getPlayerByUUID(playerId);}
+ private EntityLootableBody body(Minecraft mc){return RuntimeChecks.find(mc.getIntegratedServer().worldServerForDimension(0),bodyId);}
+ private File folder(Minecraft mc){return player(mc).world.getSaveHandler().getWorldDirectory();}
+ private void refresh(Minecraft mc){player(mc).openContainer.detectAndSendChanges();player(mc).sendContainerToPlayer(player(mc).openContainer);}
+ private void schedule(Minecraft mc,Action action){pending=mc.getIntegratedServer().addScheduledTask(()->{try{action.run();}catch(Exception e){throw new RuntimeException(e);}});}
+ private boolean done() throws Exception {if(!pending.isDone())return false;pending.get();return true;}
+ private void next(int value){stage=value;frames=0;System.out.println("DROP_CLIENT_STAGE "+stage);}
+ private static void screenshot(Minecraft mc,String name){ScreenShotHelper.saveScreenshot(mc.mcDataDir,name,mc.displayWidth,mc.displayHeight,mc.getFramebuffer());}
+ private void mark(Minecraft mc,String name) throws Exception {try(FileWriter out=new FileWriter(new File(mc.mcDataDir,"drop-client-checks.txt"),true)){out.write("PASS "+name+"\n");}System.out.println("DROP_CLIENT_CHECK_PASS "+name);}
+ private static void quit(Minecraft mc){mc.player.closeScreen();mc.getIntegratedServer().initiateShutdown();}
+}
diff --git a/src/integrationTest/java/cyano/lootable/buildtest/EffectHurtChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/EffectHurtChecks.java
new file mode 100644
index 0000000..793b32d
--- /dev/null
+++ b/src/integrationTest/java/cyano/lootable/buildtest/EffectHurtChecks.java
@@ -0,0 +1,95 @@
+package cyano.lootable.buildtest;
+
+import cyano.lootable.entities.EntityLootableBody;
+import java.io.*;
+import java.util.*;
+import net.minecraft.entity.SharedMonsterAttributes;
+import net.minecraft.init.MobEffects;
+import net.minecraft.item.ItemStack;
+import net.minecraft.nbt.*;
+import net.minecraft.potion.*;
+import net.minecraft.server.MinecraftServer;
+import net.minecraft.world.WorldServer;
+
+/** Real legacy readers, ticking and round trips; no production test dependency. */
+final class EffectHurtChecks {
+ private interface Check {void run() throws Exception;}
+ private static WorldServer world;
+ private static int failures;
+ private static final List results=new ArrayList<>();
+ private static Potion extended;
+ static void registerPotion(){
+ RuntimeChecks.require(Potion.getPotionById(200)==null,"Fixture potion ID already occupied");
+ extended=new Potion(false,0x123456){};
+ Potion.REGISTRY.register(200,new net.minecraft.util.ResourceLocation("lootablebodiesbuildtest:extended_effect"),extended);
+ }
+ static void run(MinecraftServer server,WorldServer target,File folder) throws Exception {
+ world=target;failures=0;results.clear();
+ NBTBase[] wrong={new NBTTagString("opaque"),new NBTTagCompound(),new NBTTagByteArray(new byte[]{1}),new NBTTagIntArray(new int[]{1}),new NBTTagList()};
+ for(int i=0;ibadHurt(raw));}
+ for(NBTBase raw:new NBTBase[]{new NBTTagShort((short)-1),new NBTTagInt(32768),new NBTTagLong(65537),new NBTTagLong(Long.MAX_VALUE),new NBTTagFloat(2),new NBTTagDouble(2.5),new NBTTagDouble(Double.NaN),new NBTTagDouble(Double.POSITIVE_INFINITY)})check("HurtTime_invalid_"+raw,()->badHurt(raw));
+ for(NBTBase raw:new NBTBase[]{new NBTTagByte((byte)7),new NBTTagShort((short)10),new NBTTagInt(11),new NBTTagLong(32767)})check("HurtTime_lossless_"+raw,()->validHurt(raw));
+ check("missing_hurt_time_defaults_zero",()->{NBTTagCompound n=base();n.removeTag("HurtTime");EntityLootableBody b=read(n);require(b.hurtTime==0,"Missing hurt time not zero");stable(b);});
+ for(int i=0;ibadList(raw));}
+ check("ActiveEffects_wrong_element_type",()->{NBTTagList l=new NBTTagList();l.appendTag(new NBTTagString("opaque"));badList(l);});
+ check("empty_and_missing_effects_keep_defaults",()->{for(boolean empty:new boolean[]{false,true}){NBTTagCompound n=base();if(empty)n.setTag("ActiveEffects",new NBTTagList());EntityLootableBody b=read(n);require(b.getActivePotionEffects().isEmpty(),"Unexpected default effect");require(!saved(b).hasKey(NameLifetimeChecks.RECOVERY),"Healthy empty effects quarantined");stable(b);}});
+ for(String key:new String[]{"Id","Duration","Amplifier"})for(int i=0;ibadEffect(k,raw));}
+ for(NBTBase raw:new NBTBase[]{new NBTTagInt(-1),new NBTTagInt(256),new NBTTagLong(4294967297L),new NBTTagDouble(1),new NBTTagByte((byte)201)})check("Id_invalid_or_absent_registry_"+raw,()->badEffect("Id",raw));
+ check("Id_missing_retains_record",()->badEffect("Id",null));
+ for(NBTBase raw:new NBTBase[]{new NBTTagInt(-1),new NBTTagLong(2147483648L),new NBTTagLong(4294967496L),new NBTTagFloat(100),new NBTTagDouble(Double.NaN),new NBTTagDouble(Double.NEGATIVE_INFINITY)})check("Duration_invalid_"+raw,()->badEffect("Duration",raw));
+ for(NBTBase raw:new NBTBase[]{new NBTTagShort((short)-1),new NBTTagInt(128),new NBTTagInt(256),new NBTTagLong(4294967296L),new NBTTagFloat(1),new NBTTagDouble(Double.POSITIVE_INFINITY)})check("Amplifier_invalid_"+raw,()->badEffect("Amplifier",raw));
+ for(String key:new String[]{"Ambient","ShowParticles"}){
+ for(int i=0;ibadFlag(k,raw));}
+ for(NBTBase raw:new NBTBase[]{new NBTTagByte((byte)-1),new NBTTagInt(2),new NBTTagLong(256),new NBTTagFloat(1),new NBTTagDouble(Double.NaN)})check(key+"_invalid_"+raw,()->badFlag(key,raw));
+ }
+ check("lossless_integral_effect_fields",EffectHurtChecks::numeric);
+ check("missing_optional_effect_fields_keep_legacy_defaults",EffectHurtChecks::missingOptional);
+ check("zero_duration_expires_normally",()->validDuration(0));
+ check("maximum_duration_does_not_overflow",()->validDuration(Integer.MAX_VALUE));
+ check("signed_byte_amplifier_encodings_keep_legacy_meaning",EffectHurtChecks::signedAmplifiers);
+ check("registered_unsigned_byte_effect_id_survives_reload",EffectHurtChecks::extendedId);
+ check("duplicate_id_numeric_alias_keeps_first",EffectHurtChecks::alias);
+ check("invalid_record_does_not_hide_valid_tail",EffectHurtChecks::validTail);
+ check("duplicate_effect_keeps_first_valid_record",EffectHurtChecks::duplicate);
+ check("unknown_effect_payload_retained_with_known_fields",EffectHurtChecks::unknown);
+ check("repeated_read_replaces_effects_and_metadata",EffectHurtChecks::repeated);
+ check("valid_potion_attributes_health_absorption_survive_reload",EffectHurtChecks::attributes);
+ check("repeated_read_removes_obsolete_potion_modifiers",EffectHurtChecks::repeatedAttributes);
+ check("reused_output_does_not_keep_previous_effect_list",EffectHurtChecks::reusedOutput);
+ check("invalid_effect_and_hurt_fields_keep_complete_unresolved_inventory",EffectHurtChecks::inventory);
+ check("metadata_only_does_not_prevent_empty_decay",EffectHurtChecks::empty);
+ try(PrintWriter out=new PrintWriter(new File(folder,"effect-hurt-checks.txt"),"UTF-8")){for(String s:results)out.println(s);out.println("checks="+results.size()+" failures="+failures);}
+ require(failures==0,"Effect/hurt regressions: "+failures+" / "+results.size());System.out.println("LOOTABLE_BODIES_EFFECT_HURT_PASS checks="+results.size());
+ }
+ private static void check(String name,Check test){try{test.run();results.add("PASS "+name);System.out.println("EFFECT_HURT_PASS "+name);}catch(Throwable e){failures++;results.add("FAIL "+name+": "+e);System.out.println("EFFECT_HURT_FAIL "+name+": "+e);e.printStackTrace();}}
+ private static void require(boolean c,String why){RuntimeChecks.require(c,why);}
+ private static NBTTagCompound saved(EntityLootableBody b){return NameLifetimeChecks.saved(b);}
+ private static NBTTagCompound base(){EntityLootableBody b=new EntityLootableBody(world);b.setPosition(160.5,80,160.5);b.setNoGravity(true);b.setInventorySlotContents(6,NbtRecoveryChecks.item(120,17));RuntimeChecks.disableVacuum(b);return saved(b);}
+ private static EntityLootableBody read(NBTTagCompound n){NBTTagCompound before=n.copy();EntityLootableBody b=new EntityLootableBody(world);b.readFromNBT(n);require(NameLifetimeChecks.same(before,n),"Input mutated");return b;}
+ static NBTTagCompound effect(Potion p,int duration,int amplifier){return new PotionEffect(p,duration,amplifier,false,true).writeCustomPotionEffectToNBT(new NBTTagCompound());}
+ private static NBTTagCompound effect(){return effect(MobEffects.NIGHT_VISION,200,1);}
+ private static NBTTagCompound with(NBTTagCompound e){NBTTagCompound n=base();n.setTag("ActiveEffects",NbtRecoveryChecks.list(e));return n;}
+ private static void stable(EntityLootableBody b){NBTTagCompound a=saved(b),z=saved(read(a));for(String k:new String[]{"Equipment","Aux",NbtRecoveryChecks.RECOVERY,NameLifetimeChecks.RECOVERY,"ActiveEffects","HurtTime","Health","AbsorptionAmount","Attributes"})require(NameLifetimeChecks.same(a.getTag(k),z.getTag(k)),"Round trip changed "+k);require(ItemStack.areItemStacksEqual(b.getStackInSlot(6),NbtRecoveryChecks.item(120,17)),"Complete known inventory changed");}
+ private static void badHurt(NBTBase raw){NBTTagCompound n=base();n.setTag("HurtTime",raw.copy());EntityLootableBody b=read(n);require(b.hurtTime==0 && b.isEntityAlive(),"Bad hurt time activated");NameLifetimeChecks.retained(b,"HurtTime",raw);stable(b);}
+ private static void validHurt(NBTBase raw){NBTTagCompound n=base();n.setTag("HurtTime",raw);EntityLootableBody b=read(n);int v=((NBTPrimitive)raw).getInt();require(b.hurtTime==v,"Valid countdown narrowed");stable(b);b.onEntityUpdate();require(b.hurtTime==v-1,"Countdown no longer ticks");}
+ private static void badList(NBTBase raw){NBTTagCompound n=base();n.setTag("ActiveEffects",raw.copy());EntityLootableBody b=read(n);require(b.getActivePotionEffects().isEmpty(),"Bad effect list activated");NameLifetimeChecks.retained(b,"ActiveEffects",raw);stable(b);}
+ private static void badEffect(String key,NBTBase raw){NBTTagCompound e=effect();if(raw==null)e.removeTag(key);else e.setTag(key,raw.copy());EntityLootableBody b=read(with(e));require(b.getActivePotionEffects().isEmpty(),"Malformed effect activated");NameLifetimeChecks.retained(b,"ActiveEffect",e);stable(b);}
+ private static void badFlag(String key,NBTBase raw){NBTTagCompound e=effect();e.setTag(key,raw.copy());EntityLootableBody b=read(with(e));PotionEffect p=b.getActivePotionEffect(MobEffects.NIGHT_VISION);require(p!=null && p.getDuration()==200 && p.getAmplifier()==1,"Bad optional flag lost valid effect");require(!p.getIsAmbient() && p.doesShowParticles(),"Bad optional flag did not use defaults");NameLifetimeChecks.retained(b,"ActiveEffect",e);stable(b);}
+ private static void numeric(){for(int type=0;type<4;type++){NBTTagCompound e=effect();NBTBase[] ids={new NBTTagByte((byte)16),new NBTTagShort((short)16),new NBTTagInt(16),new NBTTagLong(16)};NBTBase[] one={new NBTTagByte((byte)1),new NBTTagShort((short)1),new NBTTagInt(1),new NBTTagLong(1)};e.setTag("Id",ids[type]);e.setTag("Duration",one[type]);e.setTag("Amplifier",one[type].copy());e.setTag("Ambient",one[type].copy());e.setTag("ShowParticles",one[type].copy());EntityLootableBody b=read(with(e));PotionEffect p=b.getActivePotionEffect(MobEffects.NIGHT_VISION);require(p!=null && p.getDuration()==1 && p.getAmplifier()==1 && p.getIsAmbient() && p.doesShowParticles(),"Lossless integral effect changed");require(!saved(b).hasKey(NameLifetimeChecks.RECOVERY),"Valid numbers quarantined");stable(b);}}
+ private static void missingOptional(){NBTTagCompound e=effect();for(String k:new String[]{"Duration","Amplifier","Ambient","ShowParticles"})e.removeTag(k);EntityLootableBody b=read(with(e));PotionEffect p=b.getActivePotionEffect(MobEffects.NIGHT_VISION);require(p!=null && p.getDuration()==0 && p.getAmplifier()==0 && !p.getIsAmbient() && p.doesShowParticles(),"Legacy defaults changed");require(!saved(b).hasKey(NameLifetimeChecks.RECOVERY),"Missing optional fields quarantined");stable(b);}
+ private static void validDuration(int d){EntityLootableBody b=read(with(effect(MobEffects.NIGHT_VISION,d,0)));b.onEntityUpdate();PotionEffect p=b.getActivePotionEffect(MobEffects.NIGHT_VISION);require(d==0?p==null:p!=null && p.getDuration()==d-1,"Valid duration tick/expiry changed");stable(b);}
+ private static void signedAmplifiers(){for(int a:new int[]{-128,-1,0,127}){EntityLootableBody b=read(with(effect(MobEffects.NIGHT_VISION,200,a)));require(b.getActivePotionEffect(MobEffects.NIGHT_VISION).getAmplifier()==a,"Existing byte amplifier interpretation changed");require(!saved(b).hasKey(NameLifetimeChecks.RECOVERY),"Canonical signed byte quarantined");stable(b);}}
+ private static void extendedId(){for(boolean wide:new boolean[]{false,true}){NBTTagCompound e=effect(extended,200,1);if(wide)e.setInteger("Id",200);EntityLootableBody b=read(with(e));require(b.getActivePotionEffect(extended)!=null && b.getActivePotionEffect(extended).getDuration()==200,"Registered unsigned ID changed");require(!saved(b).hasKey(NameLifetimeChecks.RECOVERY),"Valid extended ID quarantined");stable(b);}}
+ private static void alias(){NBTTagCompound first=effect(extended,200,1),second=effect(extended,900,3),n=base();second.setLong("Id",200);n.setTag("ActiveEffects",NbtRecoveryChecks.list(first,second));EntityLootableBody b=read(n);require(b.getActivePotionEffect(extended).getDuration()==200,"Numeric alias bypassed first valid effect");NameLifetimeChecks.retained(b,"ActiveEffect",second);stable(b);}
+ private static void validTail(){NBTTagCompound bad=effect();bad.setString("Duration","opaque");NBTTagCompound n=base();n.setTag("ActiveEffects",NbtRecoveryChecks.list(bad,effect()));EntityLootableBody b=read(n);require(b.getActivePotionEffect(MobEffects.NIGHT_VISION).getDuration()==200,"Bad first effect hid valid tail");NameLifetimeChecks.retained(b,"ActiveEffect",bad);stable(b);}
+ private static void duplicate(){NBTTagCompound first=effect(),second=effect(MobEffects.NIGHT_VISION,900,3),n=base();n.setTag("ActiveEffects",NbtRecoveryChecks.list(first,second));EntityLootableBody b=read(n);require(b.getActivePotionEffect(MobEffects.NIGHT_VISION).getDuration()==200,"Duplicate overwrote first effect");NameLifetimeChecks.retained(b,"ActiveEffect",second);stable(b);}
+ private static void unknown(){NBTTagCompound e=effect();e.setString("Opaque","retain effect extension");EntityLootableBody b=read(with(e));require(b.getActivePotionEffects().size()==1,"Unknown sibling lost understood effect");NameLifetimeChecks.retained(b,"ActiveEffect",e);stable(b);}
+ private static void repeated(){EntityLootableBody b=read(with(effect(MobEffects.INVISIBILITY,200,0)));b.onEntityUpdate();require(b.isInvisible(),"Fixture not invisible");b.readFromNBT(base());require(b.getActivePotionEffects().isEmpty() && !b.isInvisible(),"Previous effects or render state survived read");stable(b);}
+ private static void attributes(){EntityLootableBody original=new EntityLootableBody(world);original.setInventorySlotContents(6,NbtRecoveryChecks.item(120,17));RuntimeChecks.disableVacuum(original);original.addPotionEffect(new PotionEffect(MobEffects.HEALTH_BOOST,200,1));original.addPotionEffect(new PotionEffect(MobEffects.SPEED,200,0));original.addPotionEffect(new PotionEffect(MobEffects.ABSORPTION,200,0));original.setHealth(original.getMaxHealth());NBTTagCompound n=saved(original);EntityLootableBody b=read(n);require(b.getMaxHealth()==28 && b.getHealth()==28 && b.getAbsorptionAmount()==4 && b.getActivePotionEffects().size()==3,"Valid potion health/attributes/absorption changed");require(!saved(b).hasKey(NameLifetimeChecks.RECOVERY),"Healthy potion fields quarantined");stable(b);for(int i=0;i<200;i++)b.onEntityUpdate();require(b.getActivePotionEffects().isEmpty() && b.getMaxHealth()==20 && b.getAbsorptionAmount()==0 && b.getHealth()<=20,"Expiry did not clean up normal modifiers");}
+ private static void repeatedAttributes(){EntityLootableBody original=new EntityLootableBody(world);original.addPotionEffect(new PotionEffect(MobEffects.HEALTH_BOOST,200,1));EntityLootableBody b=read(saved(original));b.readFromNBT(base());require(b.getActivePotionEffects().isEmpty() && b.getMaxHealth()==20,"Obsolete effects/modifiers survived read");stable(b);}
+ private static void reusedOutput(){EntityLootableBody b=read(base());NBTTagCompound n=with(effect());b.writeEntityToNBT(n);require(!n.hasKey("ActiveEffects"),"Writer retained obsolete ActiveEffects");stable(b);}
+ private static void inventory(){NBTTagCompound n=base(),missing=NbtRecoveryChecks.entry(121,11,7);missing.setString("id","absentfixture:missing");n.getTagList("Equipment",10).appendTag(missing);NBTTagCompound raw=malformedFields();for(String k:raw.getKeySet())n.setTag(k,raw.getTag(k).copy());EntityLootableBody b=read(n);require(b.isEntityAlive(),"Bad fields made corpse inaccessible");NbtRecoveryChecks.retained(b,missing);verifyWorldFields(b);stable(b);stable(read(saved(b)));}
+ private static void empty(){NBTTagCompound n=base();n.setTag("Equipment",new NBTTagList());n.setString("HurtTime","opaque");EntityLootableBody b=read(n);require(b.isEmpty(),"Metadata counts as inventory");NameLifetimeChecks.retained(b,"HurtTime",n.getTag("HurtTime"));}
+ static NBTTagCompound malformedFields(){NBTTagCompound n=new NBTTagCompound();n.setLong("HurtTime",65537);NBTTagCompound bad=effect();bad.setString("Duration","opaque saved duration");NBTTagCompound unknown=effect();unknown.setInteger("Id",250);unknown.setString("Opaque","missing effect data");n.setTag("ActiveEffects",NbtRecoveryChecks.list(bad,unknown));return n;}
+ static void verifyWorldFields(EntityLootableBody b){require(b.hurtTime==0 && b.getActivePotionEffects().isEmpty(),"Malformed disk effect/hurt state activated");NBTTagCompound raw=malformedFields();NameLifetimeChecks.retained(b,"HurtTime",raw.getTag("HurtTime"));NBTTagList list=raw.getTagList("ActiveEffects",10);for(int i=0;i results=new ArrayList<>();
+ private static int failures;
+ private static final UUID MOD=UUID.fromString("00000000-0000-4000-8000-000000000141");
+ static void run(MinecraftServer server,WorldServer target,File folder) throws Exception {
+ world=target;results.clear();failures=0;
+ NBTBase[] wrong={new NBTTagString("opaque"),new NBTTagCompound(),new NBTTagList(),new NBTTagByteArray(new byte[]{1}),new NBTTagIntArray(new int[]{1})};
+ for(String key:new String[]{"Health","AbsorptionAmount"}) {
+ for(int i=0;ibadScalar(key,raw));}
+ for(double value:new double[]{Double.NaN,Double.NEGATIVE_INFINITY,Double.POSITIVE_INFINITY,-1,Double.MAX_VALUE,1e-300})check(key+"_invalid_value_"+value,()->badScalar(key,new NBTTagDouble(value)));
+ }
+ check("health_above_effective_max_retained",()->badScalar("Health",new NBTTagFloat(21)));
+ for(int type=0;type<6;type++){final int t=type;check("valid_numeric_scalar_type_"+type,()->numeric(t));}
+ check("valid_zero_health_keeps_death_semantics",HealthAttributeChecks::zero);
+ check("missing_health_defaults_to_loaded_max",HealthAttributeChecks::missingHealth);
+ check("configured_health_and_follow_range_defaults",HealthAttributeChecks::configured);
+ check("configured_health_above_engine_cap_keeps_existing_clamp",HealthAttributeChecks::configuredCap);
+ check("previous_configured_health_survives_later_config_change",HealthAttributeChecks::configuredHistory);
+ for(int i=0;ibadList(raw));}
+ check("empty_attribute_list_keeps_constructor_defaults",()->{NBTTagCompound n=base();n.setTag("Attributes",new NBTTagList());EntityLootableBody b=read(n);value(b.getMaxHealth(),20,"Empty list changed max");RuntimeChecks.require(!NameLifetimeChecks.saved(b).hasKey(NameLifetimeChecks.RECOVERY),"Valid empty list quarantined");stable(b);});
+ check("Attributes_wrong_element_type",()->{NBTTagList raw=new NBTTagList();raw.appendTag(new NBTTagString("opaque"));badList(raw);});
+ check("attribute_missing_name",()->badAttribute("Name",null));
+ check("attribute_wrong_name_type",()->badAttribute("Name",new NBTTagInt(1)));
+ check("attribute_unknown_name_retained",()->badAttribute("Name",new NBTTagString("absentfixture:attribute")));
+ check("attribute_missing_base",()->badAttribute("Base",null));
+ for(int i=0;ibadAttribute("Base",raw));}
+ for(double value:new double[]{Double.NaN,Double.POSITIVE_INFINITY,Double.NEGATIVE_INFINITY,-1,0,Double.MAX_VALUE})check("Base_invalid_value_"+value,()->badAttribute("Base",new NBTTagDouble(value)));
+ check("invalid_base_does_not_hide_later_valid_attribute",HealthAttributeChecks::validTail);
+ check("duplicate_attribute_keeps_first_valid_record",HealthAttributeChecks::duplicateAttribute);
+ check("unknown_attribute_payload_preserved_with_valid_fields",HealthAttributeChecks::unknownAttributeData);
+ check("Modifiers_wrong_list_type_keeps_base",()->badModifierList(new NBTTagString("opaque")));
+ check("Modifiers_wrong_element_type_keeps_base",()->{NBTTagList l=new NBTTagList();l.appendTag(new NBTTagInt(3));badModifierList(l);});
+ for(String field:new String[]{"Name","Amount","Operation","UUIDMost","UUIDLeast"}) {
+ check("modifier_missing_"+field,()->badModifier(field,null));
+ check("modifier_wrong_type_"+field,()->badModifier(field,field.equals("Name")?new NBTTagInt(1):new NBTTagString("opaque")));
+ }
+ check("modifier_blank_name",()->badModifier("Name",new NBTTagString("")));
+ for(double value:new double[]{Double.NaN,Double.POSITIVE_INFINITY,Double.NEGATIVE_INFINITY})check("modifier_nonfinite_amount_"+value,()->badModifier("Amount",new NBTTagDouble(value)));
+ for(NBTBase value:new NBTBase[]{new NBTTagInt(-1),new NBTTagInt(3),new NBTTagLong(4294967296L),new NBTTagFloat(1F)})check("modifier_invalid_operation_"+value,()->badModifier("Operation",value));
+ check("modifier_fractional_UUID_not_coerced",()->badModifier("UUIDMost",new NBTTagDouble(1.5)));
+ check("duplicate_modifier_UUID_keeps_first",HealthAttributeChecks::duplicateModifier);
+ check("bad_modifier_keeps_valid_base_and_tail",HealthAttributeChecks::modifierTail);
+ check("unknown_modifier_payload_retained",HealthAttributeChecks::unknownModifierData);
+ check("finite_modifier_overflow_not_activated",HealthAttributeChecks::overflow);
+ check("valid_signed_modifiers_all_three_operations",HealthAttributeChecks::validModifiers);
+ check("valid_zero_UUID_and_integral_modifier_numbers",HealthAttributeChecks::zeroUuid);
+ check("valid_base_numeric_types_and_fractional_health",HealthAttributeChecks::validBases);
+ check("repeated_load_restores_constructor_defaults",HealthAttributeChecks::repeated);
+ check("repeated_load_clears_saved_modifiers",HealthAttributeChecks::repeatedModifiers);
+ check("repaired_health_does_not_keep_previous_removal_countdown",HealthAttributeChecks::pendingRemoval);
+ check("invalid_health_attribute_and_unresolved_inventory_reload",HealthAttributeChecks::inventory);
+ check("invalid_metadata_alone_does_not_count_as_inventory",HealthAttributeChecks::empty);
+ check("healthy_save_layout_unchanged",HealthAttributeChecks::healthy);
+ try(PrintWriter out=new PrintWriter(new File(folder,"health-attribute-checks.txt"),"UTF-8")){for(String s:results)out.println(s);out.println("checks="+results.size()+" failures="+failures);}
+ RuntimeChecks.require(failures==0,"Health/attribute regressions: "+failures+" / "+results.size());System.out.println("LOOTABLE_BODIES_HEALTH_ATTRIBUTE_PASS checks="+results.size());
+ }
+ private static void check(String name,Check test){
+ float hp=LootableBodies.corpseHP;Set before=new HashSet<>(world.loadedEntityList);
+ try{test.run();results.add("PASS "+name);System.out.println("HEALTH_ATTRIBUTE_PASS "+name);}
+ catch(Throwable e){failures++;results.add("FAIL "+name+": "+e);System.out.println("HEALTH_ATTRIBUTE_FAIL "+name+": "+e);e.printStackTrace();}
+ finally{LootableBodies.corpseHP=hp;for(Entity e:new ArrayList(world.loadedEntityList))if(!before.contains(e))world.removeEntityDangerously(e);}
+ }
+ static NBTTagCompound base(){EntityLootableBody b=new EntityLootableBody(world);b.setPosition(160.5,80,160.5);b.setNoGravity(true);b.setUserName("HealthFixture");b.setInventorySlotContents(6,NbtRecoveryChecks.item(120,17));RuntimeChecks.disableVacuum(b);return NameLifetimeChecks.saved(b);}
+ static EntityLootableBody read(NBTTagCompound n){NBTTagCompound before=n.copy();EntityLootableBody b=new EntityLootableBody(world);b.readFromNBT(n);RuntimeChecks.require(NameLifetimeChecks.same(before,n),"Input NBT mutated");return b;}
+ static NBTTagCompound attribute(String name,double base){NBTTagCompound n=new NBTTagCompound();n.setString("Name",name);n.setDouble("Base",base);return n;}
+ static NBTTagCompound modifier(double amount,int operation){NBTTagCompound n=new NBTTagCompound();n.setString("Name","Fixture modifier");n.setDouble("Amount",amount);n.setInteger("Operation",operation);n.setUniqueId("UUID",MOD);return n;}
+ static NBTTagCompound with(NBTTagCompound a){NBTTagCompound n=base();n.setTag("Attributes",NbtRecoveryChecks.list(a));return n;}
+ private static void value(double a,double b,String why){RuntimeChecks.require(Double.isFinite(a) && Math.abs(a-b)<1e-6,why+" expected="+b+" actual="+a);}
+ private static void inventoryKept(EntityLootableBody b){RuntimeChecks.require(ItemStack.areItemStacksEqual(b.getStackInSlot(6),NbtRecoveryChecks.item(120,17)),"Complete recoverable inventory changed");}
+ private static void stable(EntityLootableBody b){NBTTagCompound a=NameLifetimeChecks.saved(b),z=NameLifetimeChecks.saved(read(a));for(String key:new String[]{"Health","AbsorptionAmount","Attributes","Equipment","Aux",NameLifetimeChecks.RECOVERY,NbtRecoveryChecks.RECOVERY})RuntimeChecks.require(NameLifetimeChecks.same(a.getTag(key),z.getTag(key)),"Round trip changed "+key);inventoryKept(b);}
+ private static void retained(EntityLootableBody b,String key,NBTBase raw){NameLifetimeChecks.retained(b,key,raw);}
+ private static void badScalar(String key,NBTBase raw){NBTTagCompound n=base();n.setTag(key,raw.copy());EntityLootableBody b=read(n);value(key.equals("Health")?b.getHealth():b.getAbsorptionAmount(),key.equals("Health")?20:0,"Bad scalar did not use safe default");RuntimeChecks.require(b.isEntityAlive(),"Malformed scalar made corpse inaccessible");retained(b,key,raw);stable(b);}
+ private static void numeric(int type){NBTBase[] values={new NBTTagByte((byte)1),new NBTTagShort((short)1),new NBTTagInt(1),new NBTTagLong(1),new NBTTagFloat(1),new NBTTagDouble(1)};NBTTagCompound n=base();n.setTag("Health",values[type]);n.setTag("AbsorptionAmount",values[type].copy());EntityLootableBody b=read(n);value(b.getHealth(),1,"Valid Health changed");value(b.getAbsorptionAmount(),1,"Valid absorption changed");stable(b);}
+ private static void zero(){NBTTagCompound n=base();n.setFloat("Health",0);EntityLootableBody b=read(n);value(b.getHealth(),0,"Legitimate zero revived");RuntimeChecks.require(!b.isEntityAlive(),"Zero health no longer starts death handling");inventoryKept(b);}
+ private static void missingHealth(){NBTTagCompound n=with(attribute("generic.maxHealth",30));n.removeTag("Health");EntityLootableBody b=read(n);value(b.getHealth(),30,"Missing Health did not use loaded max");stable(b);}
+ private static void configured(){LootableBodies.corpseHP=37;NBTTagCompound n=base();n.removeTag("Attributes");n.removeTag("Health");EntityLootableBody b=read(n);value(b.getMaxHealth(),37,"Config health changed");value(b.getEntityAttribute(SharedMonsterAttributes.FOLLOW_RANGE).getBaseValue(),16,"EntityLiving follow-range default changed");stable(b);}
+ private static void configuredCap(){LootableBodies.corpseHP=Short.MAX_VALUE;EntityLootableBody original=new EntityLootableBody(world);NBTTagCompound n=NameLifetimeChecks.saved(original);EntityLootableBody b=read(n);value(b.getMaxHealth(),original.getMaxHealth(),"Existing configured health clamp changed");RuntimeChecks.require(!NameLifetimeChecks.saved(b).hasKey(NameLifetimeChecks.RECOVERY),"Healthy configured base quarantined");}
+ private static void configuredHistory(){LootableBodies.corpseHP=Short.MAX_VALUE;EntityLootableBody old=new EntityLootableBody(world);old.setInventorySlotContents(6,NbtRecoveryChecks.item(120,17));NBTTagCompound n=NameLifetimeChecks.saved(old);LootableBodies.corpseHP=20;EntityLootableBody b=read(n);value(b.getMaxHealth(),old.getMaxHealth(),"Valid old configured base was replaced by today's default");value(b.getHealth(),old.getHealth(),"Valid old configured health changed");RuntimeChecks.require(!NameLifetimeChecks.saved(b).hasKey(NameLifetimeChecks.RECOVERY),"Healthy historical config quarantined");stable(b);}
+ private static void badList(NBTBase raw){NBTTagCompound n=base();n.setTag("Attributes",raw.copy());EntityLootableBody b=read(n);value(b.getMaxHealth(),20,"Bad list changed max health");retained(b,"Attributes",raw);stable(b);}
+ private static void badAttribute(String field,NBTBase raw){NBTTagCompound a=attribute("generic.maxHealth",30);if(raw==null)a.removeTag(field);else a.setTag(field,raw.copy());EntityLootableBody b=read(with(a));value(b.getMaxHealth(),20,"Bad attribute changed default");retained(b,"Attribute",a);stable(b);}
+ private static void validTail(){NBTTagCompound a=attribute("generic.maxHealth",Double.NaN),n=base();n.setTag("Attributes",NbtRecoveryChecks.list(a,attribute("generic.maxHealth",30),attribute("generic.followRange",24)));EntityLootableBody b=read(n);value(b.getMaxHealth(),30,"Invalid first entry hid valid max health");value(b.getEntityAttribute(SharedMonsterAttributes.FOLLOW_RANGE).getBaseValue(),24,"Valid tail lost");retained(b,"Attribute",a);stable(b);}
+ private static void duplicateAttribute(){NBTTagCompound duplicate=attribute("generic.maxHealth",40),n=base();n.setTag("Attributes",NbtRecoveryChecks.list(attribute("generic.maxHealth",30),duplicate));EntityLootableBody b=read(n);value(b.getMaxHealth(),30,"Duplicate overwrote first max health");retained(b,"Attribute",duplicate);stable(b);}
+ private static void unknownAttributeData(){NBTTagCompound a=attribute("generic.maxHealth",30);a.setString("Opaque","retain");EntityLootableBody b=read(with(a));value(b.getMaxHealth(),30,"Known fields lost with unknown sibling");retained(b,"Attribute",a);stable(b);}
+ private static void badModifierList(NBTBase raw){NBTTagCompound a=attribute("generic.maxHealth",30);a.setTag("Modifiers",raw.copy());EntityLootableBody b=read(with(a));value(b.getMaxHealth(),30,"Invalid modifiers lost valid base");retained(b,"Attribute",a);stable(b);}
+ private static void badModifier(String field,NBTBase raw){NBTTagCompound m=modifier(5,0);if(raw==null)m.removeTag(field);else m.setTag(field,raw.copy());NBTTagCompound a=attribute("generic.maxHealth",30);a.setTag("Modifiers",NbtRecoveryChecks.list(m));EntityLootableBody b=read(with(a));value(b.getMaxHealth(),30,"Malformed modifier activated");retained(b,"Attribute",a);stable(b);}
+ private static void duplicateModifier(){NBTTagCompound a=attribute("generic.maxHealth",20);a.setTag("Modifiers",NbtRecoveryChecks.list(modifier(2,0),modifier(7,0)));EntityLootableBody b=read(with(a));value(b.getMaxHealth(),22,"Duplicate modifier replaced first");retained(b,"Attribute",a);stable(b);}
+ private static void modifierTail(){NBTTagCompound bad=modifier(Double.NaN,0),good=modifier(5,0),a=attribute("generic.maxHealth",30);good.setUniqueId("UUID",new UUID(0,142));a.setTag("Modifiers",NbtRecoveryChecks.list(bad,good));EntityLootableBody b=read(with(a));value(b.getMaxHealth(),35,"Invalid modifier hid valid tail or base");retained(b,"Attribute",a);stable(b);}
+ private static void unknownModifierData(){NBTTagCompound m=modifier(5,0),a=attribute("generic.maxHealth",30);m.setString("Opaque","retain");a.setTag("Modifiers",NbtRecoveryChecks.list(m));EntityLootableBody b=read(with(a));value(b.getMaxHealth(),35,"Known modifier fields lost");retained(b,"Attribute",a);stable(b);}
+ private static void overflow(){NBTTagCompound a=attribute("generic.maxHealth",30);a.setTag("Modifiers",NbtRecoveryChecks.list(modifier(Double.MAX_VALUE,2)));EntityLootableBody b=read(with(a));value(b.getMaxHealth(),30,"Overflowing modifier activated");retained(b,"Attribute",a);stable(b);}
+ private static void validModifiers(){NBTTagCompound a=attribute("generic.maxHealth",30);NBTTagCompound m0=modifier(-2,0),m1=modifier(.5,1),m2=modifier(.25,2);m1.setUniqueId("UUID",new UUID(0,142));m2.setUniqueId("UUID",new UUID(0,143));a.setTag("Modifiers",NbtRecoveryChecks.list(m0,m1,m2));EntityLootableBody b=read(with(a));value(b.getMaxHealth(),52.5,"Valid signed/three-operation modifiers changed");RuntimeChecks.require(!NameLifetimeChecks.saved(b).hasKey(NameLifetimeChecks.RECOVERY),"Valid modifiers quarantined");stable(b);}
+ private static void zeroUuid(){NBTTagCompound a=attribute("generic.maxHealth",30),m=modifier(5,0);m.setLong("UUIDMost",0);m.setLong("UUIDLeast",0);m.setByte("Operation",(byte)0);m.setInteger("Amount",5);a.setTag("Modifiers",NbtRecoveryChecks.list(m));EntityLootableBody b=read(with(a));value(b.getMaxHealth(),35,"Zero UUID or lossless numbers rejected");stable(b);}
+ private static void validBases(){for(NBTBase raw:new NBTBase[]{new NBTTagByte((byte)30),new NBTTagShort((short)30),new NBTTagInt(30),new NBTTagLong(30),new NBTTagFloat(30.5F),new NBTTagDouble(30.5)}){NBTTagCompound a=attribute("generic.maxHealth",30);a.setTag("Base",raw);NBTTagCompound n=with(a);n.setDouble("Health",12.5);EntityLootableBody b=read(n);value(b.getMaxHealth(),((NBTPrimitive)raw).getDouble(),"Valid base changed");value(b.getHealth(),12.5,"Fractional Health changed");stable(b);}}
+ private static void repeated(){EntityLootableBody b=read(with(attribute("generic.maxHealth",30)));NBTTagCompound n=base();n.removeTag("Attributes");n.removeTag("Health");n.removeTag("AbsorptionAmount");b.setAbsorptionAmount(5);b.readFromNBT(n);value(b.getMaxHealth(),20,"Missing Attributes retained previous max");value(b.getHealth(),20,"Missing Health retained previous value");value(b.getAbsorptionAmount(),0,"Missing absorption retained previous value");stable(b);}
+ private static void repeatedModifiers(){NBTTagCompound a=attribute("generic.maxHealth",30);a.setTag("Modifiers",NbtRecoveryChecks.list(modifier(5,0)));EntityLootableBody b=read(with(a));b.readFromNBT(with(attribute("generic.maxHealth",20)));value(b.getMaxHealth(),20,"Repeated read kept obsolete modifier");stable(b);}
+ private static void pendingRemoval(){NBTTagCompound n=base();n.setFloat("Health",0);EntityLootableBody b=read(n);b.onEntityUpdate();n=base();n.setDouble("Health",Double.NaN);b.readFromNBT(n);b.onEntityUpdate();RuntimeChecks.require(b.isEntityAlive(),"Repaired owner still dead");inventoryKept(b);stable(b);n=base();n.setFloat("Health",3.5F);b.isDead=true;b.readFromNBT(n);RuntimeChecks.require(b.isEntityAlive(),"Reused entity kept stale dead flag");value(b.getHealth(),3.5,"Reset healed valid wounded corpse");}
+ private static void inventory(){NBTTagCompound n=base(),raw=NbtRecoveryChecks.entry(121,11,7);raw.setString("id","absentfixture:missing");n.getTagList("Equipment",10).appendTag(raw);n.setDouble("Health",Double.NaN);NBTTagCompound a=attribute("generic.maxHealth",Double.NaN);n.setTag("Attributes",NbtRecoveryChecks.list(a,attribute("generic.followRange",24)));EntityLootableBody b=read(n);RuntimeChecks.require(b.isEntityAlive(),"Invalid health made recoverable inventory inaccessible");NbtRecoveryChecks.retained(b,raw);retained(b,"Attribute",a);retained(b,"Health",n.getTag("Health"));stable(b);stable(read(NameLifetimeChecks.saved(b)));}
+ private static void empty(){NBTTagCompound n=base();n.setTag("Equipment",new NBTTagList());n.setDouble("Health",Double.NaN);EntityLootableBody b=read(n);RuntimeChecks.require(b.isEmpty(),"Health evidence counted as inventory");retained(b,"Health",n.getTag("Health"));}
+ private static void healthy(){NBTTagCompound n=base();EntityLootableBody b=read(n);RuntimeChecks.require(!NameLifetimeChecks.saved(b).hasKey(NameLifetimeChecks.RECOVERY),"Healthy save gained recovery records");stable(b);}
+
+ static NBTTagCompound malformedFields(){
+ NBTTagCompound n=new NBTTagCompound();n.setDouble("Health",Double.NaN);n.setString("AbsorptionAmount","opaque saved absorption");
+ NBTTagCompound max=attribute("generic.maxHealth",Double.NaN),follow=attribute("generic.followRange",24),unknown=attribute("absentfixture:attribute",7);
+ NBTTagCompound bad=modifier(Double.NaN,0),good=modifier(2,0);good.setUniqueId("UUID",new UUID(0,144));
+ follow.setTag("Modifiers",NbtRecoveryChecks.list(bad,good));unknown.setString("Opaque","retain original attribute");
+ n.setTag("Attributes",NbtRecoveryChecks.list(max,follow,unknown));return n;
+ }
+ static void verifyWorldFields(EntityLootableBody b){
+ value(b.getHealth(),20,"Disk health unsafe");value(b.getMaxHealth(),20,"Disk max health unsafe");value(b.getAbsorptionAmount(),0,"Disk absorption unsafe");
+ value(b.getEntityAttribute(SharedMonsterAttributes.FOLLOW_RANGE).getAttributeValue(),26,"Valid attribute/modifier tail lost on disk");
+ NBTTagCompound raw=malformedFields();retained(b,"Health",raw.getTag("Health"));retained(b,"AbsorptionAmount",raw.getTag("AbsorptionAmount"));
+ NBTTagList entries=raw.getTagList("Attributes",10);for(int i=0;i results = new ArrayList<>();
+ private static int failures;
+ private static int sequence;
+ private static WorldServer world;
+ private static MinecraftServer server;
+
+ public static void run(MinecraftServer instance, WorldServer target, File folder) throws Exception {
+ server=instance; world=target; results.clear(); failures=0;
+ check("failed_corpse_spawn_keeps_original_drops",InventorySafetyChecks::failedSpawn);
+ check("cancelled_corpse_spawn_leaves_vanilla_drops",InventorySafetyChecks::cancelledSpawn);
+ check("stale_equipment_cannot_overwrite_current_helmet",InventorySafetyChecks::staleEquipment);
+ check("equipment_cache_consumed_and_ignored_deaths",InventorySafetyChecks::cacheLifecycle);
+ check("failed_release_retains_inventory_and_body",InventorySafetyChecks::failedRelease);
+ check("partial_release_retries_without_duplicates",InventorySafetyChecks::partialRelease);
+ check("vanilla_equipment_drop_does_not_duplicate",InventorySafetyChecks::vanillaEquipment);
+ check("vacuum_claims_each_entity_once",InventorySafetyChecks::vacuumOnce);
+ check("empty_and_negative_removal_preserve_slots",InventorySafetyChecks::emptyRemoval);
+ check("repeated_nbt_read_replaces_without_mutating_source",InventorySafetyChecks::repeatedRead);
+ check("container_rejects_stale_access",InventorySafetyChecks::staleAccess);
+ check("overflow_never_overwrites_and_respects_limits",InventorySafetyChecks::overflow);
+ check("two_viewers_shift_click_conserve_custom_stacks",InventorySafetyChecks::twoViewers);
+ check("empty_slot_validation_is_safe",InventorySafetyChecks::emptyValidation);
+ check("dirty_inventory_schedules_chunk_save",InventorySafetyChecks::dirtyInventory);
+ check("direct_and_overflow_mutations_schedule_save",InventorySafetyChecks::dirtyMutations);
+ try(PrintWriter out=new PrintWriter(new File(folder,"inventory-checks.txt"),"UTF-8")) {
+ for(String line:results) out.println(line);
+ out.println("checks="+results.size()+" failures="+failures);
+ }
+ RuntimeChecks.require(failures==0,"Inventory regressions: "+failures+" / "+results.size());
+ System.out.println("LOOTABLE_BODIES_INVENTORY_PASS checks="+results.size());
+ }
+ private static void check(String name,Check check) {
+ List before=new ArrayList<>(world.loadedEntityList);
+ String keep=world.getGameRules().getString("keepInventory");
+ String mobLoot=world.getGameRules().getString("doMobLoot");
+ try {
+ world.getGameRules().setOrCreateGameRule("keepInventory","false");
+ world.getGameRules().setOrCreateGameRule("doMobLoot","false");
+ check.run(); results.add("PASS "+name);
+ System.out.println("INVENTORY_CHECK_PASS "+name);
+ } catch(Throwable error) {
+ failures++; results.add("FAIL "+name+": "+error);
+ System.out.println("INVENTORY_CHECK_FAIL "+name+": "+error);
+ error.printStackTrace();
+ } finally {
+ world.getGameRules().setOrCreateGameRule("keepInventory",keep);
+ world.getGameRules().setOrCreateGameRule("doMobLoot",mobLoot);
+ for(Entity entity:new ArrayList(world.loadedEntityList))
+ if(!before.contains(entity)) world.removeEntityDangerously(entity);
+ }
+ }
+ private static EntityPlayerMP player() {
+ EntityPlayerMP player=new EntityPlayerMP(server,world,
+ new GameProfile(UUID.nameUUIDFromBytes(("inventory-fixture-"+(sequence++)).getBytes(java.nio.charset.StandardCharsets.UTF_8)),"InventoryFixture"),
+ new net.minecraft.server.management.PlayerInteractionManager(world));
+ player.connection=new net.minecraft.network.NetHandlerPlayServer(server,
+ new net.minecraft.network.NetworkManager(net.minecraft.network.EnumPacketDirection.SERVERBOUND),player);
+ player.setPosition(64.5,80,64.5); world.getChunkFromChunkCoords(4,4);
+ return player;
+ }
+ private static EntityLootableBody body() {
+ EntityLootableBody body=new EntityLootableBody(world);
+ body.setPosition(64.5,80,64.5); body.setNoGravity(true);
+ world.getChunkFromChunkCoords(4,4); RuntimeChecks.disableVacuum(body);
+ return body;
+ }
+ private static ItemStack item(int id,int count) {
+ ItemStack stack=RuntimeChecks.stack(id); stack.stackSize=count; return stack;
+ }
+ private static List drops(EntityPlayerMP player,ItemStack... stacks) {
+ List result=new ArrayList<>();
+ for(ItemStack stack:stacks) result.add(new EntityItem(player.getEntityWorld(),player.posX,player.posY,player.posZ,stack));
+ return result;
+ }
+ private static void handle(PlayerDeathEventHandler handler,EntityPlayerMP player,List drops) {
+ handler.entityDropEvent(new LivingDropsEvent(player,DamageSource.outOfWorld,drops,0,false));
+ }
+ private static NBTTagCompound data(EntityLootableBody body) {
+ NBTTagCompound result=new NBTTagCompound(); body.writeEntityToNBT(result); return result;
+ }
+ private static NBTTagCompound encoded(ItemStack stack) { return stack.writeToNBT(new NBTTagCompound()); }
+ private static Map inventory(EntityLootableBody body) {
+ Map result=new TreeMap<>();
+ for(int i=0;i result,ItemStack stack) {
+ if(stack==null || stack.stackSize<=0) return;
+ NBTTagCompound tag=encoded(stack); tag.removeTag("Count");
+ String key=tag.toString(); result.put(key,result.getOrDefault(key,0)+stack.stackSize);
+ }
+ private static Map dropped() {
+ Map result=new TreeMap<>();
+ for(Entity entity:world.loadedEntityList) if(entity instanceof EntityItem && !entity.isDead) add(result,((EntityItem)entity).getEntityItem());
+ return result;
+ }
+ private static EntityLootableBody newest(Set before) {
+ for(Entity entity:world.loadedEntityList) if(entity instanceof EntityLootableBody && !before.contains(entity)) return (EntityLootableBody)entity;
+ throw new AssertionError("No corpse spawned");
+ }
+ private static void failedSpawn() {
+ EntityPlayerMP player=player(); RejectingWorld rejected=new RejectingWorld(world);
+ player.world=rejected;
+ ItemStack bones=new ItemStack(Items.BONE,9); bones.setStackDisplayName("Saved bones");
+ List drops=drops(player,bones,item(19,23));
+ List before=new ArrayList<>(); for(EntityItem e:drops) before.add(encoded(e.getEntityItem()));
+ PlayerDeathEventHandler handler=new PlayerDeathEventHandler();
+ handler.entityDeathEvent(new LivingDeathEvent(player,DamageSource.outOfWorld));
+ handle(handler,player,drops);
+ RuntimeChecks.require(rejected.attempts==1,"Failed spawn branch was not reached");
+ RuntimeChecks.require(drops.size()==before.size(),"Rejected corpse consumed vanilla death drops");
+ for(int i=0;i expected=new TreeMap<>(); add(expected,player.inventory.mainInventory[0]);add(expected,player.inventory.mainInventory[9]);
+ Set before=new HashSet<>(world.loadedEntityList);
+ RejectCorpse reject=new RejectCorpse(); MinecraftForge.EVENT_BUS.register(reject);
+ try { player.onDeath(DamageSource.outOfWorld); } finally { MinecraftForge.EVENT_BUS.unregister(reject); }
+ RuntimeChecks.require(reject.attempts==1,"Corpse cancellation not exercised");
+ for(Entity entity:world.loadedEntityList) RuntimeChecks.require(before.contains(entity) || !(entity instanceof EntityLootableBody),"Cancelled corpse spawn bypassed Forge cancellation");
+ RuntimeChecks.require(expected.equals(dropped()),"Cancelled corpse did not fall back to exact vanilla drops");
+ }
+ private static void staleEquipment() {
+ EntityPlayerMP player=player(); PlayerDeathEventHandler handler=new PlayerDeathEventHandler();
+ ItemStack previous=item(0,1); player.setItemStackToSlot(EntityEquipmentSlot.HEAD,previous);
+ handler.entityDeathEvent(new LivingDeathEvent(player,DamageSource.outOfWorld));
+ handle(handler,player,drops(player,previous));
+ ItemStack current=item(1,1); player.setItemStackToSlot(EntityEquipmentSlot.HEAD,current);
+ handler.entityDeathEvent(new LivingDeathEvent(player,DamageSource.outOfWorld));
+ Map expected=new TreeMap<>(); add(expected,current);add(expected,previous);
+ Set before=new HashSet<>(world.loadedEntityList);
+ handle(handler,player,drops(player,current,previous));
+ EntityLootableBody corpse=newest(before);
+ Map actual=inventory(corpse);
+ // Ignore only the explicitly configured extra bones/flesh.
+ actual.entrySet().removeIf(e->e.getKey().contains("minecraft:bone") || e.getKey().contains("minecraft:rotten_flesh"));
+ RuntimeChecks.require(expected.equals(actual),"Stale equipment overwrote a current stack");
+ }
+ @SuppressWarnings("unchecked") private static Map