From dcd6494e0b7d7e04534a559577bbf4abb887663b Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Thu, 22 Oct 2026 22:30:36 +0100 Subject: [PATCH 1/8] Update Lootable Bodies for Minecraft 1.12.2 Bring the maintained recovery fixes and Config screen to 1.12.2. Preserve older corpses and their inventories when upgrading, including chunks visited later. --- .gitattributes | 17 + .github/scripts/test_release_workflow.py | 29 +- .github/workflows/artifact-audit-1.11.yml | 2 +- .github/workflows/artifact-audit-1.12.yml | 64 ++ .github/workflows/build.yml | 348 +++++++- .github/workflows/codeql-analysis.yml | 76 ++ .github/workflows/deploy-release.yml | 23 +- .github/workflows/migration-matrix.yml | 98 +++ .github/workflows/release-workflow-checks.yml | 4 +- .gitignore | 16 +- .travis.yml | 30 - CHANGELOG.txt | 8 + LICENSE | 617 ++++++++++--- LICENSE-CC0 | 116 +++ NOTICE | 11 + README.md | 34 +- build.gradle | 298 ++++--- gradle.properties | 35 +- gradle/audit.gradle | 110 +++ gradle/baseline.gradle | 23 + gradle/ide.gradle | 111 +++ gradle/log4j2.xml | 8 + gradle/release.gradle | 181 ++++ gradle/runtime.gradle | 273 ++++++ gradle/wrapper/gradle-wrapper.jar | Bin 54413 -> 48462 bytes gradle/wrapper/gradle-wrapper.properties | 7 +- gradlew | 302 ++++--- gradlew.bat | 166 ++-- mcmod.info | 16 - resources/mcmod.info | 1 - settings.gradle | 6 +- .../buildtest/NameLifetimeChecks.java | 3 + .../lootable/buildtest/Original112Checks.java | 42 + .../lootable/buildtest/RuntimeChecks.java | 20 + .../java/buildcontrol/ForgeControl.java | 28 + .../lootable/buildtest/ClientChecks.java | 128 +++ .../buildtest/ConfigClientChecks.java | 201 +++++ .../buildtest/ConnectedClientChecks.java | 205 +++++ .../buildtest/ContainerInteractionChecks.java | 403 +++++++++ .../lootable/buildtest/DropClientChecks.java | 118 +++ .../lootable/buildtest/EffectHurtChecks.java | 104 +++ .../lootable/buildtest/FixtureItems.java | 54 ++ .../buildtest/HealthAttributeChecks.java | 130 +++ .../buildtest/InventorySafetyChecks.java | 367 ++++++++ .../lootable/buildtest/MultiplayerChecks.java | 120 +++ .../buildtest/MultiplayerClientChecks.java | 79 ++ .../buildtest/NameLifetimeChecks.java | 117 +++ .../buildtest/NamelessCorpseClientChecks.java | 155 ++++ .../lootable/buildtest/NbtClientChecks.java | 94 ++ .../lootable/buildtest/NbtRecoveryChecks.java | 120 +++ .../lootable/buildtest/NbtWorldChecks.java | 116 +++ .../buildtest/PortBaselineChecks.java | 82 ++ .../buildtest/PositionMotionChecks.java | 82 ++ .../lootable/buildtest/Recovery112Checks.java | 53 ++ .../buildtest/ReportedIssueChecks.java | 273 ++++++ .../buildtest/ReportedIssueClientChecks.java | 157 ++++ .../buildtest/RespawnPacketChecks.java | 150 ++++ .../buildtest/RespawnPacketClientChecks.java | 133 +++ .../buildtest/RespawnPacketTrace.java | 66 ++ .../buildtest/RotationFallChecks.java | 80 ++ .../lootable/buildtest/RuntimeChecks.java | 268 ++++++ .../lootable/buildtest/TestRegistries.java | 12 + .../cyano/lootable/buildtest/TypedNbt.java | 26 + .../lootable/buildtest/UpgradeChecks.java | 237 +++++ .../lootable/buildtest/VisibilityChecks.java | 158 ++++ .../buildtest/VisibilityClientChecks.java | 106 +++ .../buildtest/VisibilitySkinChecks.java | 81 ++ .../buildtest/VisibilitySkinClientChecks.java | 53 ++ .../lootable/buildtest/WallEscapeChecks.java | 245 ++++++ .../buildtest/WorldUpgradeChecks.java | 166 ++++ .../models/item/keepsake.json | 1 + src/integrationTest/resources/log4j2.xml | 8 + src/integrationTest/resources/mcmod.info | 1 + .../lootable/fixture/LegacyWorldSeeder.java | 120 +++ src/main/java/cyano/lootable/ClientProxy.java | 1 + src/main/java/cyano/lootable/ItemStacks.java | 7 + .../java/cyano/lootable/LootableBodies.java | 114 ++- .../lootable/entities/EntityLootableBody.java | 820 +++++++++++++++--- .../events/PlayerDeathEventHandler.java | 75 +- .../graphics/ClientConfigChanges.java | 24 + .../lootable/graphics/CorpseConfigGui.java | 80 ++ .../graphics/CorpseConfigGuiFactory.java | 18 + .../lootable/graphics/CorpseContainer.java | 196 ++++- .../lootable/graphics/CorpseRenderer.java | 17 +- .../lootable/upgrade/ChunkVersionContext.java | 51 ++ .../cyano/lootable/upgrade/ItemMigration.java | 123 +++ .../upgrade/LegacyRegistryMappings.java | 21 + .../lootable/upgrade/UpgradeJournal.java | 347 ++++++++ .../lootable/upgrade/UpgradePreservation.java | 173 ++++ .../assets/lootablebodies/lang/de_DE.lang | 1 - .../assets/lootablebodies/lang/de_de.lang | 12 + .../assets/lootablebodies/lang/en_AU.lang | 1 - .../assets/lootablebodies/lang/en_CA.lang | 1 - .../assets/lootablebodies/lang/en_EN.lang | 1 - .../assets/lootablebodies/lang/en_GB.lang | 1 - .../assets/lootablebodies/lang/en_PT.lang | 1 - .../assets/lootablebodies/lang/en_US.lang | 1 - .../assets/lootablebodies/lang/en_au.lang | 12 + .../assets/lootablebodies/lang/en_ca.lang | 12 + .../assets/lootablebodies/lang/en_en.lang | 12 + .../assets/lootablebodies/lang/en_gb.lang | 12 + .../assets/lootablebodies/lang/en_pt.lang | 12 + .../assets/lootablebodies/lang/en_us.lang | 40 + .../assets/lootablebodies/lang/es_ES.lang | 1 - .../assets/lootablebodies/lang/es_MX.lang | 1 - .../assets/lootablebodies/lang/es_es.lang | 12 + .../assets/lootablebodies/lang/es_mx.lang | 12 + .../assets/lootablebodies/lang/fr_CA.lang | 1 - .../assets/lootablebodies/lang/fr_FR.lang | 1 - .../assets/lootablebodies/lang/fr_ca.lang | 12 + .../assets/lootablebodies/lang/fr_fr.lang | 12 + .../assets/lootablebodies/lang/it_IT.lang | 1 - .../assets/lootablebodies/lang/it_it.lang | 12 + .../assets/lootablebodies/lang/nl_NL.lang | 1 - .../assets/lootablebodies/lang/nl_nl.lang | 12 + .../assets/lootablebodies/lang/zh_CN.lang | 1 - .../assets/lootablebodies/lang/zh_cn.lang | 12 + src/main/resources/mcmod.info | 9 +- src/main/resources/pack.mcmeta | 7 +- .../lootable/contracts/BuildContractTest.java | 90 ++ .../lootable/contracts/ConfigScreenTest.java | 120 +++ .../contracts/CorpseTranslationTest.java | 31 + .../contracts/ReleaseContractTest.java | 86 ++ .../contracts/UpgradeJournalTest.java | 137 +++ .../lootable/fixture/TransitionFixture.java | 90 ++ tools/audit_runtime_logs.py | 97 +++ tools/client_runtime.py | 36 + .../forge14-bootstrap-diagnostics.txt | 65 ++ tools/nbt_regions.py | 152 ++++ tools/prepare_migration_inputs.py | 106 +++ tools/release_identity.py | 9 + tools/test_audit_runtime_logs.py | 88 ++ tools/test_client_runtime.py | 19 + tools/verify_forge_upgrade_control.py | 59 ++ tools/verify_multiplayer.py | 111 +++ tools/verify_nbt_recovery.py | 25 + tools/verify_normal_server.py | 59 ++ tools/verify_packaged.py | 136 +++ tools/verify_packaged_client.py | 141 +++ tools/verify_release_setup.py | 130 +++ tools/verify_upgrade_abort.py | 64 ++ tools/verify_visibility.py | 104 +++ tools/verify_world_upgrades.py | 299 +++++++ 143 files changed, 11990 insertions(+), 749 deletions(-) create mode 100644 .gitattributes create mode 100644 .github/workflows/artifact-audit-1.12.yml create mode 100644 .github/workflows/codeql-analysis.yml create mode 100644 .github/workflows/migration-matrix.yml delete mode 100644 .travis.yml create mode 100644 CHANGELOG.txt create mode 100644 LICENSE-CC0 create mode 100644 NOTICE create mode 100644 gradle/audit.gradle create mode 100644 gradle/baseline.gradle create mode 100644 gradle/ide.gradle create mode 100644 gradle/log4j2.xml create mode 100644 gradle/release.gradle create mode 100644 gradle/runtime.gradle mode change 100644 => 100755 gradlew delete mode 100644 mcmod.info delete mode 120000 resources/mcmod.info create mode 100644 src/baselineTest/java/cyano/lootable/buildtest/NameLifetimeChecks.java create mode 100644 src/baselineTest/java/cyano/lootable/buildtest/Original112Checks.java create mode 100644 src/baselineTest/java/cyano/lootable/buildtest/RuntimeChecks.java create mode 100644 src/controlTest/java/buildcontrol/ForgeControl.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/ClientChecks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/ConfigClientChecks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/ConnectedClientChecks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/ContainerInteractionChecks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/DropClientChecks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/EffectHurtChecks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/FixtureItems.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/HealthAttributeChecks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/InventorySafetyChecks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/MultiplayerChecks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/MultiplayerClientChecks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/NameLifetimeChecks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/NamelessCorpseClientChecks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/NbtClientChecks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/NbtRecoveryChecks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/NbtWorldChecks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/PortBaselineChecks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/PositionMotionChecks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/Recovery112Checks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/ReportedIssueChecks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/ReportedIssueClientChecks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/RespawnPacketChecks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/RespawnPacketClientChecks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/RespawnPacketTrace.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/RotationFallChecks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/RuntimeChecks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/TestRegistries.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/TypedNbt.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/UpgradeChecks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/VisibilityChecks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/VisibilityClientChecks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/VisibilitySkinChecks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/VisibilitySkinClientChecks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/WallEscapeChecks.java create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/WorldUpgradeChecks.java create mode 100644 src/integrationTest/resources/assets/lootablebodiesbuildtest/models/item/keepsake.json create mode 100644 src/integrationTest/resources/log4j2.xml create mode 100644 src/integrationTest/resources/mcmod.info create mode 100644 src/legacySeed/java/cyano/lootable/fixture/LegacyWorldSeeder.java create mode 100644 src/main/java/cyano/lootable/ItemStacks.java create mode 100644 src/main/java/cyano/lootable/graphics/ClientConfigChanges.java create mode 100644 src/main/java/cyano/lootable/graphics/CorpseConfigGui.java create mode 100644 src/main/java/cyano/lootable/graphics/CorpseConfigGuiFactory.java create mode 100644 src/main/java/cyano/lootable/upgrade/ChunkVersionContext.java create mode 100644 src/main/java/cyano/lootable/upgrade/ItemMigration.java create mode 100644 src/main/java/cyano/lootable/upgrade/LegacyRegistryMappings.java create mode 100644 src/main/java/cyano/lootable/upgrade/UpgradeJournal.java create mode 100644 src/main/java/cyano/lootable/upgrade/UpgradePreservation.java delete mode 100644 src/main/resources/assets/lootablebodies/lang/de_DE.lang create mode 100644 src/main/resources/assets/lootablebodies/lang/de_de.lang delete mode 100644 src/main/resources/assets/lootablebodies/lang/en_AU.lang delete mode 100644 src/main/resources/assets/lootablebodies/lang/en_CA.lang delete mode 100644 src/main/resources/assets/lootablebodies/lang/en_EN.lang delete mode 100644 src/main/resources/assets/lootablebodies/lang/en_GB.lang delete mode 100644 src/main/resources/assets/lootablebodies/lang/en_PT.lang delete mode 100644 src/main/resources/assets/lootablebodies/lang/en_US.lang create mode 100644 src/main/resources/assets/lootablebodies/lang/en_au.lang create mode 100644 src/main/resources/assets/lootablebodies/lang/en_ca.lang create mode 100644 src/main/resources/assets/lootablebodies/lang/en_en.lang create mode 100644 src/main/resources/assets/lootablebodies/lang/en_gb.lang create mode 100644 src/main/resources/assets/lootablebodies/lang/en_pt.lang create mode 100644 src/main/resources/assets/lootablebodies/lang/en_us.lang delete mode 100644 src/main/resources/assets/lootablebodies/lang/es_ES.lang delete mode 100644 src/main/resources/assets/lootablebodies/lang/es_MX.lang create mode 100644 src/main/resources/assets/lootablebodies/lang/es_es.lang create mode 100644 src/main/resources/assets/lootablebodies/lang/es_mx.lang delete mode 100644 src/main/resources/assets/lootablebodies/lang/fr_CA.lang delete mode 100644 src/main/resources/assets/lootablebodies/lang/fr_FR.lang create mode 100644 src/main/resources/assets/lootablebodies/lang/fr_ca.lang create mode 100644 src/main/resources/assets/lootablebodies/lang/fr_fr.lang delete mode 100644 src/main/resources/assets/lootablebodies/lang/it_IT.lang create mode 100644 src/main/resources/assets/lootablebodies/lang/it_it.lang delete mode 100644 src/main/resources/assets/lootablebodies/lang/nl_NL.lang create mode 100644 src/main/resources/assets/lootablebodies/lang/nl_nl.lang delete mode 100644 src/main/resources/assets/lootablebodies/lang/zh_CN.lang create mode 100644 src/main/resources/assets/lootablebodies/lang/zh_cn.lang create mode 100644 src/test/java/cyano/lootable/contracts/BuildContractTest.java create mode 100644 src/test/java/cyano/lootable/contracts/ConfigScreenTest.java create mode 100644 src/test/java/cyano/lootable/contracts/CorpseTranslationTest.java create mode 100644 src/test/java/cyano/lootable/contracts/ReleaseContractTest.java create mode 100644 src/test/java/cyano/lootable/contracts/UpgradeJournalTest.java create mode 100644 src/transitionFixture/java/cyano/lootable/fixture/TransitionFixture.java create mode 100644 tools/audit_runtime_logs.py create mode 100644 tools/client_runtime.py create mode 100644 tools/fixtures/forge14-bootstrap-diagnostics.txt create mode 100644 tools/nbt_regions.py create mode 100644 tools/prepare_migration_inputs.py create mode 100644 tools/release_identity.py create mode 100644 tools/test_audit_runtime_logs.py create mode 100644 tools/test_client_runtime.py create mode 100644 tools/verify_forge_upgrade_control.py create mode 100644 tools/verify_multiplayer.py create mode 100644 tools/verify_nbt_recovery.py create mode 100644 tools/verify_normal_server.py create mode 100644 tools/verify_packaged.py create mode 100644 tools/verify_packaged_client.py create mode 100644 tools/verify_release_setup.py create mode 100644 tools/verify_upgrade_abort.py create mode 100644 tools/verify_visibility.py create mode 100644 tools/verify_world_upgrades.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..998e11e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,17 @@ +* text=auto +*.java text eol=lf +*.gradle text eol=lf +*.properties text eol=lf +*.json text eol=lf +*.lang text eol=lf +*.mcmeta text eol=lf +*.info text eol=lf +*.md text eol=lf +*.txt text eol=lf +LICENSE* text eol=lf +*.yml text eol=lf +*.py text eol=lf +gradlew text eol=lf +*.bat text eol=crlf +*.jar binary +*.png binary diff --git a/.github/scripts/test_release_workflow.py b/.github/scripts/test_release_workflow.py index 1012756..e932114 100644 --- a/.github/scripts/test_release_workflow.py +++ b/.github/scripts/test_release_workflow.py @@ -13,6 +13,10 @@ REPOSITORY = 'MinecraftModDevelopmentMods/LootableBodies' SHA = 'a' * 40 CORE = ['Cold Forge bootstrap', 'Build, test, and audit', 'Analyze Java', 'Validation'] +MATRIX112 = [origin + ' / ' + stamps for origin in + ['original-110', 'released-110', 'original-111', 'recovery-110', + 'released-111', 'original-112', 'recovery-111', 'via111-110'] + for stamps in ['normal', 'stamps']] MATRIX = [origin + ' / ' + stamps for origin in ['original-110', 'released-110', 'original-111', 'recovery-110'] for stamps in ['normal', 'stamps']] @@ -42,7 +46,10 @@ def select(self, version, repository=REPOSITORY, ref='refs/heads/master-1.12'): return result.returncode, values def validate(self, minecraft, names=None, mutation=None, release_exists=False, wrong_tag=False, audit_success=True): - suffix, branch, forge = ('110021', '1.10.2', '12.18.3.2511') if minecraft == '1.10.2' else ('111021', '1.11.2-dev', '13.20.1.2588') + suffix, branch, forge = { + '1.10.2': ('110021', '1.10.2', '12.18.3.2511'), + '1.11.2': ('111021', '1.11.2-dev', '13.20.1.2588'), + '1.12.2': ('112021', 'master-1.12', '14.23.5.2859')}[minecraft] version = '2.4.2.' + suffix values = dict(minecraft_version=minecraft, forge_version=forge, loader_name='forge', loader_code='1', java_version='8', java_toolchain_version='8.0.502+7', gradle_java_version='17', mod_id='lootablebodies', @@ -50,12 +57,12 @@ def validate(self, minecraft, names=None, mutation=None, release_exists=False, w curseforge_project_id='240968', mod_version=version) if mutation: values.update(mutation) - checks = CORE + (MATRIX if minecraft == '1.11.2' else []) if names is None else names + checks = CORE + {'1.11.2': MATRIX, '1.12.2': MATRIX112}.get(minecraft, []) if names is None else names def command(args, **options): if args[0] == 'git': return SHA + '\n' if '/actions/workflows/' in args[-1]: - return json.dumps({'workflow_runs': [{'id': 1, 'display_title': 'Audit 1.11.2 artifacts from ' + SHA, + return json.dumps({'workflow_runs': [{'id': 1, 'display_title': 'Audit ' + minecraft + ' artifacts from ' + SHA, 'conclusion': 'success' if audit_success else 'failure'}]}) return json.dumps({'check_runs': [dict(name=name, id=i, conclusion='success') for i, name in enumerate(checks)]}) def existing(args, **options): @@ -73,8 +80,8 @@ def existing(args, **options): patch('subprocess.check_output', side_effect=command), patch('subprocess.run', side_effect=existing): exec(compile(code, 'release-preflight', 'exec'), {}) - def test_routes_both_supported_versions(self): - for suffix, minecraft, branch in [('110021', '1.10.2', '1.10.2'), ('111021', '1.11.2', '1.11.2-dev')]: + def test_routes_all_supported_versions(self): + for suffix, minecraft, branch in [('110021', '1.10.2', '1.10.2'), ('111021', '1.11.2', '1.11.2-dev'), ('112021', '1.12.2', 'master-1.12')]: with self.subTest(minecraft=minecraft): status, values = self.select('2.4.2.' + suffix) self.assertEqual(0, status) @@ -82,7 +89,7 @@ def test_routes_both_supported_versions(self): self.assertEqual(branch, values['source_branch']) def test_invalid_versions_are_rejected(self): - for version in ['2.4.2', '2.4.2.112021', '02.4.2.111021', '2.4.2.111021;exit 0']: + for version in ['2.4.2', '2.4.2.113021', '02.4.2.111021', '2.4.2.111021;exit 0']: with self.subTest(version=version): self.assertNotEqual(0, self.select(version)[0]) @@ -103,6 +110,16 @@ def test_111_requires_an_independent_audit_of_its_exact_artifact(self): with self.assertRaises(SystemExit): self.validate('1.11.2', audit_success=False) + def test_112_requires_every_upgrade_case(self): + self.validate('1.12.2') + for missing in MATRIX112: + with self.subTest(missing=missing), self.assertRaises(SystemExit): + self.validate('1.12.2', names=CORE + [name for name in MATRIX112 if name != missing]) + + def test_112_requires_an_independent_audit_of_its_exact_artifact(self): + with self.assertRaises(SystemExit): + self.validate('1.12.2', audit_success=False) + def test_identity_changes_are_rejected(self): for mutation in [{'mod_id': 'other'}, {'minecraft_version': '1.10.2'}, {'mod_license': 'CC0-1.0'}]: with self.subTest(mutation=mutation), self.assertRaises(SystemExit): diff --git a/.github/workflows/artifact-audit-1.11.yml b/.github/workflows/artifact-audit-1.11.yml index 38e9cc7..5b58860 100644 --- a/.github/workflows/artifact-audit-1.11.yml +++ b/.github/workflows/artifact-audit-1.11.yml @@ -3,7 +3,7 @@ run-name: Audit 1.11.2 artifacts from ${{ github.event.workflow_run.head_sha }} on: workflow_run: - workflows: [LootableBodies 1.10 CI, LootableBodies 1.11 CI] + workflows: [LootableBodies 1.10 CI, LootableBodies 1.11 CI, LootableBodies 1.12 CI] types: [completed] permissions: diff --git a/.github/workflows/artifact-audit-1.12.yml b/.github/workflows/artifact-audit-1.12.yml new file mode 100644 index 0000000..3f812f9 --- /dev/null +++ b/.github/workflows/artifact-audit-1.12.yml @@ -0,0 +1,64 @@ +name: Audit LootableBodies 1.12 artifacts +run-name: Audit 1.12.2 artifacts from ${{ github.event.workflow_run.head_sha }} + +on: + workflow_run: + workflows: [LootableBodies 1.10 CI, LootableBodies 1.11 CI, LootableBodies 1.12 CI] + types: [completed] + +permissions: + contents: read + actions: read + +jobs: + audit: + # Pull-request merge trees are audited inside CI. Push artifacts use the + # workflow run's exact head SHA, which is also their declared artifact name. + if: >- + github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && + (github.event.workflow_run.head_branch == 'master-1.12.2' || github.event.workflow_run.head_branch == 'master-1.12') + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.event.workflow_run.head_sha }} + - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '25.0.3+9.0.LTS' + - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '8.0.502+7' + - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '17.0.1+12' + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: LootableBodies-1.12.2-${{ github.event.workflow_run.head_sha }} + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + path: downloaded + - name: Require exact inputs and assemble an immutable audit bundle + run: | + set -euo pipefail + mkdir audited + for name in LootableBodies-2.4.3.112021.jar LootableBodies-2.4.3.112021-sources.jar LootableBodies-2.4.3.112021-javadoc.jar; do + test -s "downloaded/build/libs/$name" + cp "downloaded/build/libs/$name" audited/ + done + test -s downloaded/build/release/SHA256SUMS + test -s downloaded/build/publications/mavenJava/pom-default.xml + test -s downloaded/CHANGELOG.txt + cp downloaded/build/release/SHA256SUMS audited/ + cp downloaded/build/publications/mavenJava/pom-default.xml audited/LootableBodies-2.4.3.112021.pom + cp downloaded/CHANGELOG.txt audited/ + - name: Audit original bytes without recompiling the jars + env: + LOOTABLEBODIES_JAVA8_HOME: ${{ env.JAVA_HOME_8_X64 }} + LOOTABLEBODIES_MAVENIZER_JAVA_HOME: ${{ env.JAVA_HOME_25_X64 }} + run: | + chmod +x ./gradlew + ./gradlew verifyPreparedReleaseArtifacts -PpreparedReleaseDir=audited --no-daemon diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ddfe637..a6df05f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,36 +1,336 @@ -name: "Validate Build" +name: LootableBodies 1.12 CI + on: push: - paths-ignore: - - '.github/workflows/**' - - '.github/scripts/**' - - '**/*.md' + branches: + - master-1.12.2 + - master-1.12 + - 'feature/**' pull_request: - paths-ignore: - - '.github/workflows/**' - - '.github/scripts/**' - - '**/*.md' + branches: + - master-1.12.2 + - master-1.12 + +permissions: + contents: read + +concurrency: + group: lootablebodies-1.12-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: - validate: + cold-forge-bootstrap: + name: Cold Forge bootstrap runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Install pinned Java 25 ForgeGradle Mavenizer runtime + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '25.0.3+9.0.LTS' + + - name: Install pinned Java 8 compilation toolchain + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '8.0.502+7' + + - name: Install pinned Java 17 Gradle runtime + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '17.0.1+12' + + - name: Bootstrap Forge from an empty cache + env: + GRADLE_USER_HOME: ${{ runner.temp }}/lootablebodies-cold-gradle + LOOTABLEBODIES_JAVA8_HOME: ${{ env.JAVA_HOME_8_X64 }} + LOOTABLEBODIES_MAVENIZER_JAVA_HOME: ${{ env.JAVA_HOME_25_X64 }} + run: | + set -euo pipefail + test ! -e .gradle + test ! -e "$GRADLE_USER_HOME" + mkdir -p "$GRADLE_USER_HOME" + chmod +x ./gradlew + ./gradlew classes --no-daemon --no-build-cache --stacktrace \ + -Dorg.gradle.java.installations.paths="$JAVA_HOME,$JAVA_HOME_8_X64,$JAVA_HOME_25_X64" \ + -Dorg.gradle.java.installations.auto-detect=false \ + -Dorg.gradle.java.installations.auto-download=false + + - name: Verify the same cache offline + env: + GRADLE_USER_HOME: ${{ runner.temp }}/lootablebodies-cold-gradle + LOOTABLEBODIES_JAVA8_HOME: ${{ env.JAVA_HOME_8_X64 }} + LOOTABLEBODIES_MAVENIZER_JAVA_HOME: ${{ env.JAVA_HOME_25_X64 }} + run: | + ./gradlew classes --offline --rerun-tasks --no-daemon --no-build-cache --stacktrace \ + -Dorg.gradle.java.installations.paths="$JAVA_HOME,$JAVA_HOME_8_X64,$JAVA_HOME_25_X64" \ + -Dorg.gradle.java.installations.auto-detect=false \ + -Dorg.gradle.java.installations.auto-download=false + + build: + name: Build, test, and audit + runs-on: ubuntu-latest + timeout-minutes: 60 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Install pinned Java 25 ForgeGradle Mavenizer runtime + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '25.0.3+9.0.LTS' + + - name: Install pinned Java 8 compilation toolchain + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '8.0.502+7' + + - name: Install pinned Java 17 Gradle runtime + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '17.0.1+12' + + - name: Pin production JDK + run: | + echo "LOOTABLEBODIES_JAVA8_HOME=$JAVA_HOME_8_X64" >> "$GITHUB_ENV" + echo "LOOTABLEBODIES_MAVENIZER_JAVA_HOME=$JAVA_HOME_25_X64" >> "$GITHUB_ENV" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6 + with: + cache-disabled: true + + - name: Make the wrapper executable + run: chmod +x ./gradlew + + - name: Test runtime log contracts + run: python3 -m unittest discover -s tools -p 'test_*.py' -v + + - name: Build, test, and audit release artifacts + run: >- + ./gradlew clean check build javadoc forgeControlServer verifyReleaseArtifacts writeReleaseChecksums + upgradeContractTest inventoryRegressionTest containerInteractionTest dropCreativeInteractionTest nbtRecoveryTest + nameLifetimeTest healthAttributeTest effectHurtTest positionMotionTest rotationFallTest + verifyEclipseProductionClasspath --no-daemon --stacktrace + -Dorg.gradle.java.installations.paths="$JAVA_HOME,$JAVA_HOME_8_X64,$JAVA_HOME_25_X64" + -Dorg.gradle.java.installations.auto-detect=false + -Dorg.gradle.java.installations.auto-download=false + + - name: Install legacy client display and audio support + timeout-minutes: 10 + run: | + sudo apt-get update -o Acquire::Retries=2 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 + sudo apt-get install --yes --no-install-recommends -o Acquire::Retries=2 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 x11-xserver-utils libgl1-mesa-dri libglu1-mesa libopenal1 pulseaudio pulseaudio-utils + command -v xrandr + pulseaudio --start --exit-idle-time=-1 + pactl load-module module-null-sink sink_name=minecraft + echo "PULSE_SINK=minecraft" >> "$GITHUB_ENV" + echo "ALSOFT_DRIVERS=pulse" >> "$GITHUB_ENV" + + - name: Verify real graphical client and corpse GUI + env: + LIBGL_ALWAYS_SOFTWARE: 'true' + run: >- + xvfb-run -a -s '-screen 0 1280x720x24 +extension GLX +render -noreset' + sh -c 'xrandr --query && ./gradlew clientIntegrationTest preparePackagedIntegrationProbe exportRuntimeClasspath --no-daemon' + + - name: Verify exact packaged dedicated servers + run: >- + python3 tools/verify_packaged.py --java "$JAVA_HOME_8_X64/bin/java" + --output build/packaged-runtime + + - name: Verify malformed corpse NBT and returning items across JVMs + run: >- + python3 tools/verify_nbt_recovery.py --java "$JAVA_HOME_8_X64/bin/java" + --forge-home build/packaged-runtime/forge-distribution --output build/packaged-nbt + + - name: Verify ordinary production servers exclude the test mod + run: | + python3 tools/verify_normal_server.py --java "$JAVA_HOME_8_X64/bin/java" --forge-home build/packaged-runtime/forge-distribution --output build/ordinary-development --development + python3 tools/verify_normal_server.py --java "$JAVA_HOME_8_X64/bin/java" --forge-home build/packaged-runtime/forge-distribution --output build/ordinary-packaged + + - name: Audit complete development logs + run: >- + python3 tools/audit_runtime_logs.py --development + build/integration-run/fresh.log build/integration-run/reload.log build/integration-run/deferred.log + build/client-integration-run/logs/latest.log build/forge-control-run/logs/latest.log + + - name: Verify exact packaged client + env: + LIBGL_ALWAYS_SOFTWARE: 'true' + run: >- + xvfb-run -a -s '-screen 0 1280x720x24 +extension GLX +render -noreset' python3 tools/verify_packaged_client.py --java "$JAVA_HOME_8_X64/bin/java" + --forge-jar build/packaged-runtime/forge-distribution/forge-1.12.2-14.23.5.2859-universal.jar + --output build/packaged-client --gradle-home "$HOME/.gradle" + + - name: Verify configuration, skins and the reported death sequence + env: + LIBGL_ALWAYS_SOFTWARE: 'true' + run: | + for mode in config skin issues names; do + xvfb-run -a -s '-screen 0 1280x720x24 +extension GLX +render -noreset' python3 tools/verify_packaged_client.py --java "$JAVA_HOME_8_X64/bin/java" --forge-jar build/packaged-runtime/forge-distribution/forge-1.12.2-14.23.5.2859-universal.jar --output "build/packaged-client-$mode" --gradle-home "$HOME/.gradle" --mode "$mode" + done + + - name: Verify two real loopback clients and persisted shared inventory + env: + LIBGL_ALWAYS_SOFTWARE: 'true' + run: >- + xvfb-run -a -s '-screen 0 1280x720x24 +extension GLX +render -noreset' python3 tools/verify_multiplayer.py --java "$JAVA_HOME_8_X64/bin/java" + --forge-home build/packaged-runtime/forge-distribution --gradle-home "$HOME/.gradle" + --output build/packaged-multiplayer + + - name: Verify respawn packets with a connected observer + env: + LIBGL_ALWAYS_SOFTWARE: 'true' + run: >- + xvfb-run -a -s '-screen 0 1280x720x24 +extension GLX +render -noreset' python3 tools/verify_visibility.py + --java "$JAVA_HOME_8_X64/bin/java" --forge-home build/packaged-runtime/forge-distribution + --gradle-home "$HOME/.gradle" --output build/packaged-respawn --mode respawn + + - name: Verify simultaneous respawns after restart + env: + LIBGL_ALWAYS_SOFTWARE: 'true' + run: >- + xvfb-run -a -s '-screen 0 1280x720x24 +extension GLX +render -noreset' python3 tools/verify_visibility.py + --java "$JAVA_HOME_8_X64/bin/java" --forge-home build/packaged-runtime/forge-distribution + --gradle-home "$HOME/.gradle" --output build/packaged-respawn-restart --mode respawn + --source-server build/packaged-respawn/server --simultaneous --counter-collision + + - name: Verify default timing, TNT death and connected chunk re-entry + env: + LIBGL_ALWAYS_SOFTWARE: 'true' + run: | + xvfb-run -a -s '-screen 0 1280x720x24 +extension GLX +render -noreset' python3 tools/verify_visibility.py --java "$JAVA_HOME_8_X64/bin/java" --forge-home build/packaged-runtime/forge-distribution --gradle-home "$HOME/.gradle" --output build/packaged-visibility --mode visibility + xvfb-run -a -s '-screen 0 1280x720x24 +extension GLX +render -noreset' python3 tools/verify_visibility.py --java "$JAVA_HOME_8_X64/bin/java" --forge-home build/packaged-runtime/forge-distribution --gradle-home "$HOME/.gradle" --output build/packaged-visibility-restart --mode visibility --source-server build/packaged-visibility/server + + - name: Audit complete runtime file logs and crash directories + run: >- + python3 tools/audit_runtime_logs.py --recursive + build/packaged-runtime build/packaged-nbt build/ordinary-packaged + build/packaged-client build/packaged-client-config build/packaged-client-skin build/packaged-client-issues build/packaged-client-names + build/packaged-multiplayer build/packaged-respawn build/packaged-respawn-restart + build/packaged-visibility build/packaged-visibility-restart + + - name: Compare a second forced build + run: | + mkdir -p "$RUNNER_TEMP/first-build" + cp build/libs/LootableBodies-2.4.3.112021{,-sources,-javadoc}.jar build/release/SHA256SUMS build/publications/mavenJava/pom-default.xml "$RUNNER_TEMP/first-build/" + ./gradlew assemble writeReleaseChecksums verifyMavenCoordinates --rerun-tasks --offline --no-daemon --no-build-cache + for name in LootableBodies-2.4.3.112021{,-sources,-javadoc}.jar; do + cmp "$RUNNER_TEMP/first-build/$name" "build/libs/$name" + done + cmp "$RUNNER_TEMP/first-build/SHA256SUMS" build/release/SHA256SUMS + cmp "$RUNNER_TEMP/first-build/pom-default.xml" build/publications/mavenJava/pom-default.xml - - name: Set up JDK 1.8 - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 - with: - distribution: temurin - java-version: '8.0.502+7' - java-package: jdk # (jre, jdk, or jdk+fx) - defaults to jdk - architecture: x64 # (x64 or x86) - defaults to x64 + - name: Prepare and test the local release contract + run: | + ./gradlew prepareReleaseBundle --offline --no-daemon + python3 tools/verify_release_setup.py --output build/release-contracts - - name: Make Wrapper Executable - run: chmod a+x gradlew + - name: Require all six declared artifacts + run: | + for path in build/libs/LootableBodies-2.4.3.112021.jar build/libs/LootableBodies-2.4.3.112021-sources.jar build/libs/LootableBodies-2.4.3.112021-javadoc.jar build/release/SHA256SUMS build/publications/mavenJava/pom-default.xml CHANGELOG.txt; do + test -s "$path" + done - - name: Setup CI Workspace - run: ./gradlew clean setupciworkspace + - name: Upload audited release candidate + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: LootableBodies-1.12.2-${{ github.sha }} + if-no-files-found: error + retention-days: 30 + path: | + build/libs/LootableBodies-2.4.3.112021.jar + build/libs/LootableBodies-2.4.3.112021-sources.jar + build/libs/LootableBodies-2.4.3.112021-javadoc.jar + build/release/SHA256SUMS + build/publications/mavenJava/pom-default.xml + CHANGELOG.txt - - name: Build with Gradle - run: ./gradlew build + - name: Upload diagnostics on failure + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: LootableBodies-1.12.2-diagnostics-${{ github.sha }} + if-no-files-found: error + retention-days: 14 + path: | + build/test-results/** + build/reports/** + build/release-contracts/** + build/*-run/logs/** + build/*-run/crash-reports/** + build/integration-run/world/inventory-checks.txt + build/integration-run/world/container-checks.txt + build/integration-run/world/drop-creative-checks.txt + build/integration-run/world/nbt-checks.txt + build/integration-run/world/name-lifetime-checks.txt + build/integration-run/world/health-attribute-checks.txt + build/integration-run/world/effect-hurt-checks.txt + build/integration-run/world/position-motion-checks.txt + build/integration-run/world/rotation-fall-checks.txt + build/packaged-runtime/**/*.log + build/packaged-runtime/**/crash-reports/** + build/packaged-runtime/candidate-fresh/world/inventory-checks.txt + build/packaged-runtime/candidate-fresh/world/container-checks.txt + build/packaged-runtime/candidate-fresh/world/drop-creative-checks.txt + build/packaged-runtime/candidate-fresh/world/nbt-checks.txt + build/packaged-runtime/candidate-fresh/world/name-lifetime-checks.txt + build/packaged-runtime/candidate-fresh/world/health-attribute-checks.txt + build/packaged-runtime/candidate-fresh/world/effect-hurt-checks.txt + build/packaged-runtime/candidate-fresh/world/position-motion-checks.txt + build/packaged-runtime/candidate-fresh/world/rotation-fall-checks.txt + build/packaged-nbt/**/*.log + build/packaged-nbt/**/crash-reports/** + build/packaged-nbt/candidate/world/nbt-world-checks.txt + build/client-integration-run/screenshots/*.png + build/client-integration-run/connected-checks.txt + build/client-integration-run/drop-client-checks.txt + build/client-integration-run/nbt-client-checks.txt + build/packaged-client/client.log + build/packaged-client/drop-client-checks.txt + build/packaged-client/nbt-client-checks.txt + build/packaged-client/connected-checks.txt + build/packaged-client/screenshots/*.png + build/packaged-multiplayer/**/*.log + build/packaged-multiplayer/**/crash-reports/** + build/packaged-multiplayer/clientA/screenshots/*.png + build/packaged-multiplayer/clientB/screenshots/*.png + build/packaged-multiplayer/sync/checks.txt + build/packaged-respawn/**/*.log + build/packaged-respawn/**/crash-reports/** + build/packaged-respawn/sync/*.txt + build/packaged-client-config/**/*.log + build/packaged-client-config/*.txt + build/packaged-client-config/screenshots/*.png + build/packaged-client-skin/**/*.log + build/packaged-client-skin/*.txt + build/packaged-client-skin/screenshots/*.png + build/packaged-client-issues/**/*.log + build/packaged-client-issues/*.txt + build/packaged-client-issues/screenshots/*.png + build/packaged-client-names/**/*.log + build/packaged-client-names/*.txt + build/packaged-client-names/screenshots/*.png + build/ordinary-development/**/*.log + build/ordinary-packaged/**/*.log + build/packaged-respawn-restart/**/*.log + build/packaged-respawn-restart/sync/*.txt + build/packaged-visibility/**/*.log + build/packaged-visibility/sync/*.txt + build/packaged-visibility/clientA/screenshots/*.png + build/packaged-visibility/clientB/screenshots/*.png + build/packaged-visibility-restart/**/*.log + build/packaged-visibility-restart/sync/*.txt + build/packaged-visibility-restart/clientA/screenshots/*.png + build/packaged-visibility-restart/clientB/screenshots/*.png + build/problems/** diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 0000000..78e3313 --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,76 @@ +name: CodeQL + +on: + push: + branches: + - master-1.12.2 + - master-1.12 + - 'feature/**' + pull_request: + branches: + - master-1.12.2 + - master-1.12 + schedule: + - cron: '43 7 * * 4' + +permissions: + actions: read + contents: read + security-events: write + +jobs: + analyze: + name: Analyze Java + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Install pinned Java 25 ForgeGradle Mavenizer runtime + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '25.0.3+9.0.LTS' + + - name: Install pinned Java 8 compilation toolchain + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '8.0.502+7' + + - name: Install pinned Java 17 Gradle runtime + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '17.0.1+12' + + - name: Pin production JDK + run: | + echo "LOOTABLEBODIES_JAVA8_HOME=$JAVA_HOME_8_X64" >> "$GITHUB_ENV" + echo "LOOTABLEBODIES_MAVENIZER_JAVA_HOME=$JAVA_HOME_25_X64" >> "$GITHUB_ENV" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6 + + - name: Initialize CodeQL + uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4 + with: + languages: java-kotlin + build-mode: manual + + - name: Compile production code + run: | + chmod +x ./gradlew + gradle_args=( + clean classes --no-build-cache --rerun-tasks + --no-daemon --stacktrace --max-workers=2 + -Dorg.gradle.java.installations.paths="$JAVA_HOME,$JAVA_HOME_8_X64,$JAVA_HOME_25_X64" + -Dorg.gradle.java.installations.auto-detect=false + -Dorg.gradle.java.installations.auto-download=false + ) + ./gradlew "${gradle_args[@]}" + + - name: Analyze + uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4 diff --git a/.github/workflows/deploy-release.yml b/.github/workflows/deploy-release.yml index e0f1a97..b6aae57 100644 --- a/.github/workflows/deploy-release.yml +++ b/.github/workflows/deploy-release.yml @@ -5,7 +5,7 @@ on: workflow_dispatch: inputs: release_version: - description: Complete version from gradle.properties, ending in 110021 or 111021. + description: Complete version from gradle.properties, ending in 110021, 111021 or 112021. required: true type: string curseforge_release_level: @@ -48,10 +48,11 @@ jobs: set -euo pipefail [[ "$GITHUB_REPOSITORY" == 'MinecraftModDevelopmentMods/LootableBodies' ]] [[ "$GITHUB_REF" == 'refs/heads/master-1.12' ]] - [[ "$RELEASE_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(110021|111021)$ ]] + [[ "$RELEASE_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(110021|111021|112021)$ ]] case "$RELEASE_VERSION" in *.110021) minecraft_version=1.10.2; source_branch=1.10.2 ;; *.111021) minecraft_version=1.11.2; source_branch=1.11.2-dev ;; + *.112021) minecraft_version=1.12.2; source_branch=master-1.12 ;; *) exit 1 ;; esac echo "minecraft_version=$minecraft_version" >> "$GITHUB_OUTPUT" @@ -77,7 +78,8 @@ jobs: if line.strip() and not line.lstrip().startswith('#') and '=' in line) minecraft = os.environ['MINECRAFT_VERSION'] targets = {'1.10.2': ('1.10.2', '12.18.3.2511', '110021'), - '1.11.2': ('1.11.2-dev', '13.20.1.2588', '111021')} + '1.11.2': ('1.11.2-dev', '13.20.1.2588', '111021'), + '1.12.2': ('master-1.12', '14.23.5.2859', '112021')} branch, forge, suffix = targets[minecraft] if os.environ['SOURCE_BRANCH'] != branch: raise SystemExit('The selected branch does not match its Minecraft version') @@ -96,18 +98,21 @@ jobs: repo = os.environ['GITHUB_REPOSITORY'] checks = json.loads(subprocess.check_output(['gh', 'api', f'repos/{repo}/commits/{sha}/check-runs?per_page=100'], text=True))['check_runs'] required = {'Cold Forge bootstrap', 'Build, test, and audit', 'Analyze Java', 'Validation'} - if minecraft == '1.11.2': - required.update(origin + ' / ' + stamps - for origin in ['original-110', 'released-110', 'original-111', 'recovery-110'] + origins = ['original-110', 'released-110', 'original-111', 'recovery-110'] + if minecraft == '1.12.2': + origins += ['released-111', 'original-112', 'recovery-111', 'via111-110'] + if minecraft in ('1.11.2', '1.12.2'): + required.update(origin + ' / ' + stamps for origin in origins for stamps in ['normal', 'stamps']) for name in required: matching = [check for check in checks if check['name'] == name] if not matching or max(matching, key=lambda c: c['id'])['conclusion'] != 'success': raise SystemExit(f'The selected commit has no successful {name} check') - if minecraft == '1.11.2': + if minecraft in ('1.11.2', '1.12.2'): + target = '1.11' if minecraft == '1.11.2' else '1.12' audits = json.loads(subprocess.check_output(['gh', 'api', - f'repos/{repo}/actions/workflows/artifact-audit-1.11.yml/runs?event=workflow_run&per_page=100'], text=True))['workflow_runs'] - matching = [run for run in audits if run['display_title'] == f'Audit 1.11.2 artifacts from {sha}'] + f'repos/{repo}/actions/workflows/artifact-audit-{target}.yml/runs?event=workflow_run&per_page=100'], text=True))['workflow_runs'] + matching = [run for run in audits if run['display_title'] == f'Audit {minecraft} artifacts from {sha}'] if not matching or max(matching, key=lambda run: run['id'])['conclusion'] != 'success': raise SystemExit('The selected commit has no successful independent artifact audit') release = subprocess.run(['gh', 'api', f'repos/{repo}/releases/tags/{version}'], capture_output=True, text=True) diff --git a/.github/workflows/migration-matrix.yml b/.github/workflows/migration-matrix.yml new file mode 100644 index 0000000..4df1aee --- /dev/null +++ b/.github/workflows/migration-matrix.yml @@ -0,0 +1,98 @@ +name: LootableBodies 1.12 world upgrades + +on: + push: + branches: [master-1.12.2, master-1.12] + pull_request: + branches: [master-1.12.2, master-1.12] + workflow_dispatch: + +permissions: + contents: read + +jobs: + upgrade: + name: ${{ matrix.origin }} / ${{ matrix.stamps }} + runs-on: ubuntu-latest + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + origin: [original-110, released-110, original-111, recovery-110, released-111, original-112, recovery-111, via111-110] + stamps: [normal, stamps] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '25.0.3+9.0.LTS' + - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '8.0.502+7' + - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '17.0.1+12' + - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6 + with: + cache-disabled: true + - name: Pin separate toolchains + run: | + echo "LOOTABLEBODIES_JAVA8_HOME=$JAVA_HOME_8_X64" >> "$GITHUB_ENV" + echo "LOOTABLEBODIES_MAVENIZER_JAVA_HOME=$JAVA_HOME_25_X64" >> "$GITHUB_ENV" + chmod +x ./gradlew + - name: Build candidate and isolated fixture writers + run: ./gradlew build upgradeContractTest prepareLegacyWorldFixtures preparePackagedIntegrationProbe preparePackagedForgeControl --no-daemon + - name: Download and verify historical artifacts + run: python3 tools/prepare_migration_inputs.py --java "$JAVA_HOME_8_X64/bin/java" --output build/migration-inputs + - name: Create worlds with historical mod writers + run: >- + python3 tools/verify_world_upgrades.py --java "$JAVA_HOME_8_X64/bin/java" + --forge110-home build/migration-inputs/forge110 --forge111-home build/migration-inputs/forge111 --forge112-home build/migration-inputs/forge112 + --original-110 build/migration-inputs/original-110.jar --released-110 build/migration-inputs/released-110.jar + --original-111 build/migration-inputs/original-111.jar --released-111 build/migration-inputs/released-111.jar --original-112 build/migration-inputs/original-112.jar --origins ${{ matrix.origin }} + --seed-only --output build/migration-seeds + - name: Reproduce the Forge diagnostic without Lootable Bodies + run: >- + python3 tools/verify_forge_upgrade_control.py --java "$JAVA_HOME_8_X64/bin/java" + --forge110-home build/migration-inputs/forge110 --forge112-home build/migration-inputs/forge112 + --output build/migration-control + - name: Verify first load, recovery, deferred dimensions and restarts + run: >- + python3 tools/verify_world_upgrades.py --java "$JAVA_HOME_8_X64/bin/java" + --forge110-home build/migration-inputs/forge110 --forge111-home build/migration-inputs/forge111 --forge112-home build/migration-inputs/forge112 + --original-110 build/migration-inputs/original-110.jar --released-110 build/migration-inputs/released-110.jar + --original-111 build/migration-inputs/original-111.jar --released-111 build/migration-inputs/released-111.jar --original-112 build/migration-inputs/original-112.jar --origins ${{ matrix.origin }} + --seeds build/migration-seeds --variants ${{ matrix.stamps }} + --control-report build/migration-control/result.json --output build/migration-results + - name: Verify unsafe preservation stops world loading + run: >- + python3 tools/verify_upgrade_abort.py --java "$JAVA_HOME_8_X64/bin/java" + --forge-home build/migration-inputs/forge112 + --source-world build/migration-seeds/${{ matrix.origin }}/world --output build/migration-abort + - name: Require every report + run: | + for path in build/migration-inputs/inputs.json build/migration-seeds/seed-report.json build/migration-control/result.json build/migration-results/result.json build/migration-abort/result.json; do + test -s "$path" + done + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: LootableBodies-1.12.2-upgrade-${{ matrix.origin }}-${{ matrix.stamps }}-${{ github.sha }} + if-no-files-found: error + path: | + build/migration-inputs/inputs.json + build/migration-seeds/seed-report.json + build/migration-control/result.json + build/migration-results/result.json + build/migration-abort/result.json + - name: Upload failed run diagnostics + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: LootableBodies-1.12.2-upgrade-failure-${{ matrix.origin }}-${{ matrix.stamps }}-${{ github.sha }} + if-no-files-found: error + path: | + build/reports/** + build/migration-*/**/*.log + build/migration-*/**/crash-reports/** diff --git a/.github/workflows/release-workflow-checks.yml b/.github/workflows/release-workflow-checks.yml index f37fc32..1ca556c 100644 --- a/.github/workflows/release-workflow-checks.yml +++ b/.github/workflows/release-workflow-checks.yml @@ -1,7 +1,7 @@ name: Validate release workflow on: push: - branches: ['feature/**', 'master-1.12'] + branches: ['feature/**', 'master-1.12', 'master-1.12.2'] paths: ['.github/workflows/**', '.github/scripts/**'] pull_request: branches: ['master-1.12'] @@ -26,7 +26,7 @@ jobs: - name: Check release workflow syntax run: >- "$RUNNER_TEMP/actionlint/actionlint" -shellcheck= -pyflakes= - .github/workflows/deploy-release.yml .github/workflows/release-workflow-checks.yml .github/workflows/artifact-audit-1.11.yml + .github/workflows/deploy-release.yml .github/workflows/release-workflow-checks.yml .github/workflows/artifact-audit-1.11.yml .github/workflows/artifact-audit-1.12.yml - name: Test version selection and publication checks run: python3 .github/scripts/test_release_workflow.py -v diff --git a/.gitignore b/.gitignore index 9171f8e..66da4f4 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,18 @@ /.idea/* temp/* /run/* -/out/* +/.project +/.settings/ +/*.launch +/.eclipse/ +/build/ +/bin/ +__pycache__/ +*.pyc + + +/gradle-local.properties +/logs/ +/crash-reports/ +/runs/ +/out/ diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 61e88ec..0000000 --- a/.travis.yml +++ /dev/null @@ -1,30 +0,0 @@ -language: java -os: linux -dist: trusty -addons: - apt: - update: true - packages: -# - oracle-java8-installer -git: - quiet: true -arch: amd64 -before_cache: - - rm -f $HOME/.gradle/caches/modules-2/modules-2.lock - - rm -fr $HOME/.gradle/caches/*/plugin-resolution/ -cache: - directories: - - $HOME/.gradle/caches/ - - $HOME/.gradle/wrapper/ -notifications: - email: false -jdk: - - oraclejdk8 -before_install: skip -install: skip -before_script: - - chmod a+x gradlew -script: - - ./gradlew setupCIWorkspace -S - - ./gradlew clean build -S - diff --git a/CHANGELOG.txt b/CHANGELOG.txt new file mode 100644 index 0000000..d9c1ff2 --- /dev/null +++ b/CHANGELOG.txt @@ -0,0 +1,8 @@ +2.4.3.112021 - Minecraft 1.12.2, Forge + +- Brings the maintained inventory and corpse recovery fixes to 1.12.2. +- Adds the Config button on the Mods screen. +- Protects older corpses and their items when upgrading from 1.10.2, 1.11.2 or an earlier 1.12.2 build, including chunks visited later. +- Keeps the existing buoyancy and void-platform options and their defaults. + +Back up the whole world before upgrading, including its data folder. diff --git a/LICENSE b/LICENSE index 670154e..f6683e7 100644 --- a/LICENSE +++ b/LICENSE @@ -1,116 +1,501 @@ -CC0 1.0 Universal - -Statement of Purpose - -The laws of most jurisdictions throughout the world automatically confer -exclusive Copyright and Related Rights (defined below) upon the creator and -subsequent owner(s) (each and all, an "owner") of an original work of -authorship and/or a database (each, a "Work"). - -Certain owners wish to permanently relinquish those rights to a Work for the -purpose of contributing to a commons of creative, cultural and scientific -works ("Commons") that the public can reliably and without fear of later -claims of infringement build upon, modify, incorporate in other works, reuse -and redistribute as freely as possible in any form whatsoever and for any -purposes, including without limitation commercial purposes. These owners may -contribute to the Commons to promote the ideal of a free culture and the -further production of creative, cultural and scientific works, or to gain -reputation or greater distribution for their Work in part through the use and -efforts of others. - -For these and/or other purposes and motivations, and without any expectation -of additional consideration or compensation, the person associating CC0 with a -Work (the "Affirmer"), to the extent that he or she is an owner of Copyright -and Related Rights in the Work, voluntarily elects to apply CC0 to the Work -and publicly distribute the Work under its terms, with knowledge of his or her -Copyright and Related Rights in the Work and the meaning and intended legal -effect of CC0 on those rights. - -1. Copyright and Related Rights. A Work made available under CC0 may be -protected by copyright and related or neighboring rights ("Copyright and -Related Rights"). Copyright and Related Rights include, but are not limited -to, the following: - - i. the right to reproduce, adapt, distribute, perform, display, communicate, - and translate a Work; - - ii. moral rights retained by the original author(s) and/or performer(s); - - iii. publicity and privacy rights pertaining to a person's image or likeness - depicted in a Work; - - iv. rights protecting against unfair competition in regards to a Work, - subject to the limitations in paragraph 4(a), below; - - v. rights protecting the extraction, dissemination, use and reuse of data in - a Work; - - vi. database rights (such as those arising under Directive 96/9/EC of the - European Parliament and of the Council of 11 March 1996 on the legal - protection of databases, and under any national implementation thereof, - including any amended or successor version of such directive); and - - vii. other similar, equivalent or corresponding rights throughout the world - based on applicable law or treaty, and any national implementations thereof. - -2. Waiver. To the greatest extent permitted by, but not in contravention of, -applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and -unconditionally waives, abandons, and surrenders all of Affirmer's Copyright -and Related Rights and associated claims and causes of action, whether now -known or unknown (including existing as well as future claims and causes of -action), in the Work (i) in all territories worldwide, (ii) for the maximum -duration provided by applicable law or treaty (including future time -extensions), (iii) in any current or future medium and for any number of -copies, and (iv) for any purpose whatsoever, including without limitation -commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes -the Waiver for the benefit of each member of the public at large and to the -detriment of Affirmer's heirs and successors, fully intending that such Waiver -shall not be subject to revocation, rescission, cancellation, termination, or -any other legal or equitable action to disrupt the quiet enjoyment of the Work -by the public as contemplated by Affirmer's express Statement of Purpose. - -3. Public License Fallback. Should any part of the Waiver for any reason be -judged legally invalid or ineffective under applicable law, then the Waiver -shall be preserved to the maximum extent permitted taking into account -Affirmer's express Statement of Purpose. In addition, to the extent the Waiver -is so judged Affirmer hereby grants to each affected person a royalty-free, -non transferable, non sublicensable, non exclusive, irrevocable and -unconditional license to exercise Affirmer's Copyright and Related Rights in -the Work (i) in all territories worldwide, (ii) for the maximum duration -provided by applicable law or treaty (including future time extensions), (iii) -in any current or future medium and for any number of copies, and (iv) for any -purpose whatsoever, including without limitation commercial, advertising or -promotional purposes (the "License"). The License shall be deemed effective as -of the date CC0 was applied by Affirmer to the Work. Should any part of the -License for any reason be judged legally invalid or ineffective under -applicable law, such partial invalidity or ineffectiveness shall not -invalidate the remainder of the License, and in such case Affirmer hereby -affirms that he or she will not (i) exercise any of his or her remaining -Copyright and Related Rights in the Work or (ii) assert any associated claims -and causes of action with respect to the Work, in either case contrary to -Affirmer's express Statement of Purpose. - -4. Limitations and Disclaimers. - - a. No trademark or patent rights held by Affirmer are waived, abandoned, - surrendered, licensed or otherwise affected by this document. - - b. Affirmer offers the Work as-is and makes no representations or warranties - of any kind concerning the Work, express, implied, statutory or otherwise, - including without limitation warranties of title, merchantability, fitness - for a particular purpose, non infringement, or the absence of latent or - other defects, accuracy, or the present or absence of errors, whether or not - discoverable, all to the greatest extent permissible under applicable law. - - c. Affirmer disclaims responsibility for clearing rights of other persons - that may apply to the Work or any use thereof, including without limitation - any person's Copyright and Related Rights in the Work. Further, Affirmer - disclaims responsibility for obtaining any necessary consents, permissions - or other rights required for any use of the Work. - - d. Affirmer understands and acknowledges that Creative Commons is not a - party to this document and has no duty or obligation with respect to this - CC0 or use of the Work. - -For more information, please see - + GNU LESSER GENERAL PUBLIC LICENSE + Version 2.1, February 1999 + + Copyright (C) 1991, 1999 Free Software Foundation, Inc. + + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts + as the successor of the GNU Library Public License, version 2, hence + the version number 2.1.] + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +Licenses are intended to guarantee your freedom to share and change +free software--to make sure the software is free for all its users. + + This license, the Lesser General Public License, applies to some +specially designated software packages--typically libraries--of the +Free Software Foundation and other authors who decide to use it. You +can use it too, but we suggest you first think carefully about whether +this license or the ordinary General Public License is the better +strategy to use in any particular case, based on the explanations below. + + When we speak of free software, we are referring to freedom of use, +not price. Our General Public Licenses are designed to make sure that +you have the freedom to distribute copies of free software (and charge +for this service if you wish); that you receive source code or can get +it if you want it; that you can change the software and use pieces of +it in new free programs; and that you are informed that you can do +these things. + + To protect your rights, we need to make restrictions that forbid +distributors to deny you these rights or to ask you to surrender these +rights. These restrictions translate to certain responsibilities for +you if you distribute copies of the library or if you modify it. + + For example, if you distribute copies of the library, whether gratis +or for a fee, you must give the recipients all the rights that we gave +you. You must make sure that they, too, receive or can get the source +code. If you link other code with the library, you must provide +complete object files to the recipients, so that they can relink them +with the library after making changes to the library and recompiling +it. And you must show them these terms so they know their rights. + + We protect your rights with a two-step method: (1) we copyright the +library, and (2) we offer you this license, which gives you legal +permission to copy, distribute and/or modify the library. + + To protect each distributor, we want to make it very clear that +there is no warranty for the free library. Also, if the library is +modified by someone else and passed on, the recipients should know +that what they have is not the original version, so that the original +author's reputation will not be affected by problems that might be +introduced by others. + + Finally, software patents pose a constant threat to the existence of +any free program. We wish to make sure that a company cannot +effectively restrict the users of a free program by obtaining a +restrictive license from a patent holder. Therefore, we insist that +any patent license obtained for a version of the library must be +consistent with the full freedom of use specified in this license. + + Most GNU software, including some libraries, is covered by the +ordinary GNU General Public License. This license, the GNU Lesser +General Public License, applies to certain designated libraries, and +is quite different from the ordinary General Public License. We use +this license for certain libraries in order to permit linking those +libraries into non-free programs. + + When a program is linked with a library, whether statically or using +a shared library, the combination of the two is legally speaking a +combined work, a derivative of the original library. The ordinary +General Public License therefore permits such linking only if the +entire combination fits its criteria of freedom. The Lesser General +Public License permits more lax criteria for linking other code with +the library. + + We call this license the "Lesser" General Public License because it +does Less to protect the user's freedom than the ordinary General +Public License. It also provides other free software developers Less +of an advantage over competing non-free programs. These disadvantages +are the reason we use the ordinary General Public License for many +libraries. However, the Lesser license provides advantages in certain +special circumstances. + + For example, on rare occasions, there may be a special need to +encourage the widest possible use of a certain library, so that it becomes +a de-facto standard. To achieve this, non-free programs must be +allowed to use the library. A more frequent case is that a free +library does the same job as widely used non-free libraries. In this +case, there is little to gain by limiting the free library to free +software only, so we use the Lesser General Public License. + + In other cases, permission to use a particular library in non-free +programs enables a greater number of people to use a large body of +free software. For example, permission to use the GNU C Library in +non-free programs enables many more people to use the whole GNU +operating system, as well as its variant, the GNU/Linux operating +system. + + Although the Lesser General Public License is Less protective of the +users' freedom, it does ensure that the user of a program that is +linked with the Library has the freedom and the wherewithal to run +that program using a modified version of the Library. + + The precise terms and conditions for copying, distribution and +modification follow. Pay close attention to the difference between a +"work based on the library" and a "work that uses the library". The +former contains code derived from the library, whereas the latter must +be combined with the library in order to run. + + GNU LESSER GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License Agreement applies to any software library or other +program which contains a notice placed by the copyright holder or +other authorized party saying it may be distributed under the terms of +this Lesser General Public License (also called "this License"). +Each licensee is addressed as "you". + + A "library" means a collection of software functions and/or data +prepared so as to be conveniently linked with application programs +(which use some of those functions and data) to form executables. + + The "Library", below, refers to any such software library or work +which has been distributed under these terms. A "work based on the +Library" means either the Library or any derivative work under +copyright law: that is to say, a work containing the Library or a +portion of it, either verbatim or with modifications and/or translated +straightforwardly into another language. (Hereinafter, translation is +included without limitation in the term "modification".) + + "Source code" for a work means the preferred form of the work for +making modifications to it. For a library, complete source code means +all the source code for all modules it contains, plus any associated +interface definition files, plus the scripts used to control compilation +and installation of the library. + + Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running a program using the Library is not restricted, and output from +such a program is covered only if its contents constitute a work based +on the Library (independent of the use of the Library in a tool for +writing it). Whether that is true depends on what the Library does +and what the program that uses the Library does. + + 1. You may copy and distribute verbatim copies of the Library's +complete source code as you receive it, in any medium, provided that +you conspicuously and appropriately publish on each copy an +appropriate copyright notice and disclaimer of warranty; keep intact +all the notices that refer to this License and to the absence of any +warranty; and distribute a copy of this License along with the +Library. + + You may charge a fee for the physical act of transferring a copy, +and you may at your option offer warranty protection in exchange for a +fee. + + 2. You may modify your copy or copies of the Library or any portion +of it, thus forming a work based on the Library, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices + stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no + charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a + table of data to be supplied by an application program that uses + the facility, other than as an argument passed when the facility + is invoked, then you must make a good faith effort to ensure that, + in the event an application does not supply such function or + table, the facility still operates, and performs whatever part of + its purpose remains meaningful. + + (For example, a function in a library to compute square roots has + a purpose that is entirely well-defined independent of the + application. Therefore, Subsection 2d requires that any + application-supplied function or table used by this function must + be optional: if the application does not supply it, the square + root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Library, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Library, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote +it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library +with the Library (or with a work based on the Library) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may opt to apply the terms of the ordinary GNU General Public +License instead of this License to a given copy of the Library. To do +this, you must alter all the notices that refer to this License, so +that they refer to the ordinary GNU General Public License, version 2, +instead of to this License. (If a newer version than version 2 of the +ordinary GNU General Public License has appeared, then you can specify +that version instead if you wish.) Do not make any other change in +these notices. + + Once this change is made in a given copy, it is irreversible for +that copy, so the ordinary GNU General Public License applies to all +subsequent copies and derivative works made from that copy. + + This option is useful when you wish to copy part of the code of +the Library into a program that is not a library. + + 4. You may copy and distribute the Library (or a portion or +derivative of it, under Section 2) in object code or executable form +under the terms of Sections 1 and 2 above provided that you accompany +it with the complete corresponding machine-readable source code, which +must be distributed under the terms of Sections 1 and 2 above on a +medium customarily used for software interchange. + + If distribution of object code is made by offering access to copy +from a designated place, then offering equivalent access to copy the +source code from the same place satisfies the requirement to +distribute the source code, even though third parties are not +compelled to copy the source along with the object code. + + 5. A program that contains no derivative of any portion of the +Library, but is designed to work with the Library by being compiled or +linked with it, is called a "work that uses the Library". Such a +work, in isolation, is not a derivative work of the Library, and +therefore falls outside the scope of this License. + + However, linking a "work that uses the Library" with the Library +creates an executable that is a derivative of the Library (because it +contains portions of the Library), rather than a "work that uses the +library". The executable is therefore covered by this License. +Section 6 states terms for distribution of such executables. + + When a "work that uses the Library" uses material from a header file +that is part of the Library, the object code for the work may be a +derivative work of the Library even though the source code is not. +Whether this is true is especially significant if the work can be +linked without the Library, or if the work is itself a library. The +threshold for this to be true is not precisely defined by law. + + If such an object file uses only numerical parameters, data +structure layouts and accessors, and small macros and small inline +functions (ten lines or less in length), then the use of the object +file is unrestricted, regardless of whether it is legally a derivative +work. (Executables containing this object code plus portions of the +Library will still fall under Section 6.) + + Otherwise, if the work is a derivative of the Library, you may +distribute the object code for the work under the terms of Section 6. +Any executables containing that work also fall under Section 6, +whether or not they are linked directly with the Library itself. + + 6. As an exception to the Sections above, you may also combine or +link a "work that uses the Library" with the Library to produce a +work containing portions of the Library, and distribute that work +under terms of your choice, provided that the terms permit +modification of the work for the customer's own use and reverse +engineering for debugging such modifications. + + You must give prominent notice with each copy of the work that the +Library is used in it and that the Library and its use are covered by +this License. You must supply a copy of this License. If the work +during execution displays copyright notices, you must include the +copyright notice for the Library among them, as well as a reference +directing the user to the copy of this License. Also, you must do one +of these things: + + a) Accompany the work with the complete corresponding + machine-readable source code for the Library including whatever + changes were used in the work (which must be distributed under + Sections 1 and 2 above); and, if the work is an executable linked + with the Library, with the complete machine-readable "work that + uses the Library", as object code and/or source code, so that the + user can modify the Library and then relink to produce a modified + executable containing the modified Library. (It is understood + that the user who changes the contents of definitions files in the + Library will not necessarily be able to recompile the application + to use the modified definitions.) + + b) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (1) uses at run time a + copy of the library already present on the user's computer system, + rather than copying library functions into the executable, and (2) + will operate properly with a modified version of the library, if + the user installs one, as long as the modified version is + interface-compatible with the version that the work was made with. + + c) Accompany the work with a written offer, valid for at + least three years, to give the same user the materials + specified in Subsection 6a, above, for a charge no more + than the cost of performing this distribution. + + d) If distribution of the work is made by offering access to copy + from a designated place, offer equivalent access to copy the above + specified materials from the same place. + + e) Verify that the user has already received a copy of these + materials or that you have already sent this user a copy. + + For an executable, the required form of the "work that uses the +Library" must include any data and utility programs needed for +reproducing the executable from it. However, as a special exception, +the materials to be distributed need not include anything that is +normally distributed (in either source or binary form) with the major +components (compiler, kernel, and so on) of the operating system on +which the executable runs, unless that component itself accompanies +the executable. + + It may happen that this requirement contradicts the license +restrictions of other proprietary libraries that do not normally +accompany the operating system. Such a contradiction means you cannot +use both them and the Library together in an executable that you +distribute. + + 7. You may place library facilities that are a work based on the +Library side-by-side in a single library together with other library +facilities not covered by this License, and distribute such a combined +library, provided that the separate distribution of the work based on +the Library and of the other library facilities is otherwise +permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work + based on the Library, uncombined with any other library + facilities. This must be distributed under the terms of the + Sections above. + + b) Give prominent notice with the combined library of the fact + that part of it is a work based on the Library, and explaining + where to find the accompanying uncombined form of the same work. + + 8. You may not copy, modify, sublicense, link with, or distribute +the Library except as expressly provided under this License. Any +attempt otherwise to copy, modify, sublicense, link with, or +distribute the Library is void, and will automatically terminate your +rights under this License. However, parties who have received copies, +or rights, from you under this License will not have their licenses +terminated so long as such parties remain in full compliance. + + 9. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Library or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Library (or any work based on the +Library), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Library or works based on it. + + 10. Each time you redistribute the Library (or any work based on the +Library), the recipient automatically receives a license from the +original licensor to copy, distribute, link with or modify the Library +subject to these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties with +this License. + + 11. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Library at all. For example, if a patent +license would not permit royalty-free redistribution of the Library by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any +particular circumstance, the balance of the section is intended to apply, +and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 12. If the distribution and/or use of the Library is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Library under this License may add +an explicit geographical distribution limitation excluding those countries, +so that distribution is permitted only in or among countries not thus +excluded. In such case, this License incorporates the limitation as if +written in the body of this License. + + 13. The Free Software Foundation may publish revised and/or new +versions of the Lesser General Public License from time to time. +Such new versions will be similar in spirit to the present version, +but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library +specifies a version number of this License which applies to it and +"any later version", you have the option of following the terms and +conditions either of that version or of any later version published by +the Free Software Foundation. If the Library does not specify a +license version number, you may choose any version ever published by +the Free Software Foundation. + + 14. If you wish to incorporate parts of the Library into other free +programs whose distribution conditions are incompatible with these, +write to the author to ask for permission. For software which is +copyrighted by the Free Software Foundation, write to the Free +Software Foundation; we sometimes make exceptions for this. Our +decision will be guided by the two goals of preserving the free status +of all derivatives of our free software and of promoting the sharing +and reuse of software generally. + + NO WARRANTY + + 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO +WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. +EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY +KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE +LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME +THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN +WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY +AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU +FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR +CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE +LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING +RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A +FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF +SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Libraries + + If you develop a new library, and you want it to be of the greatest +possible use to the public, we recommend making it free software that +everyone can redistribute and change. You can do so by permitting +redistribution under these terms (or, alternatively, under the terms of the +ordinary General Public License). + + To apply these terms, attach the following notices to the library. It is +safest to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least the +"copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, see . + +Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the library, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the + library `Frob' (a library for tweaking knobs) written by James Random Hacker. + + , 1 April 1990 + Moe Ghoul, President of Vice + +That's all there is to it! diff --git a/LICENSE-CC0 b/LICENSE-CC0 new file mode 100644 index 0000000..670154e --- /dev/null +++ b/LICENSE-CC0 @@ -0,0 +1,116 @@ +CC0 1.0 Universal + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator and +subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for the +purpose of contributing to a commons of creative, cultural and scientific +works ("Commons") that the public can reliably and without fear of later +claims of infringement build upon, modify, incorporate in other works, reuse +and redistribute as freely as possible in any form whatsoever and for any +purposes, including without limitation commercial purposes. These owners may +contribute to the Commons to promote the ideal of a free culture and the +further production of creative, cultural and scientific works, or to gain +reputation or greater distribution for their Work in part through the use and +efforts of others. + +For these and/or other purposes and motivations, and without any expectation +of additional consideration or compensation, the person associating CC0 with a +Work (the "Affirmer"), to the extent that he or she is an owner of Copyright +and Related Rights in the Work, voluntarily elects to apply CC0 to the Work +and publicly distribute the Work under its terms, with knowledge of his or her +Copyright and Related Rights in the Work and the meaning and intended legal +effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not limited +to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, communicate, + and translate a Work; + + ii. moral rights retained by the original author(s) and/or performer(s); + + iii. publicity and privacy rights pertaining to a person's image or likeness + depicted in a Work; + + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + + v. rights protecting the extraction, dissemination, use and reuse of data in + a Work; + + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation thereof, + including any amended or successor version of such directive); and + + vii. other similar, equivalent or corresponding rights throughout the world + based on applicable law or treaty, and any national implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention of, +applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and +unconditionally waives, abandons, and surrenders all of Affirmer's Copyright +and Related Rights and associated claims and causes of action, whether now +known or unknown (including existing as well as future claims and causes of +action), in the Work (i) in all territories worldwide, (ii) for the maximum +duration provided by applicable law or treaty (including future time +extensions), (iii) in any current or future medium and for any number of +copies, and (iv) for any purpose whatsoever, including without limitation +commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes +the Waiver for the benefit of each member of the public at large and to the +detriment of Affirmer's heirs and successors, fully intending that such Waiver +shall not be subject to revocation, rescission, cancellation, termination, or +any other legal or equitable action to disrupt the quiet enjoyment of the Work +by the public as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason be +judged legally invalid or ineffective under applicable law, then the Waiver +shall be preserved to the maximum extent permitted taking into account +Affirmer's express Statement of Purpose. In addition, to the extent the Waiver +is so judged Affirmer hereby grants to each affected person a royalty-free, +non transferable, non sublicensable, non exclusive, irrevocable and +unconditional license to exercise Affirmer's Copyright and Related Rights in +the Work (i) in all territories worldwide, (ii) for the maximum duration +provided by applicable law or treaty (including future time extensions), (iii) +in any current or future medium and for any number of copies, and (iv) for any +purpose whatsoever, including without limitation commercial, advertising or +promotional purposes (the "License"). The License shall be deemed effective as +of the date CC0 was applied by Affirmer to the Work. Should any part of the +License for any reason be judged legally invalid or ineffective under +applicable law, such partial invalidity or ineffectiveness shall not +invalidate the remainder of the License, and in such case Affirmer hereby +affirms that he or she will not (i) exercise any of his or her remaining +Copyright and Related Rights in the Work or (ii) assert any associated claims +and causes of action with respect to the Work, in either case contrary to +Affirmer's express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + + b. Affirmer offers the Work as-is and makes no representations or warranties + of any kind concerning the Work, express, implied, statutory or otherwise, + including without limitation warranties of title, merchantability, fitness + for a particular purpose, non infringement, or the absence of latent or + other defects, accuracy, or the present or absence of errors, whether or not + discoverable, all to the greatest extent permissible under applicable law. + + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without limitation + any person's Copyright and Related Rights in the Work. Further, Affirmer + disclaims responsibility for obtaining any necessary consents, permissions + or other rights required for any use of the Work. + + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to this + CC0 or use of the Work. + +For more information, please see + diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..560135e --- /dev/null +++ b/NOTICE @@ -0,0 +1,11 @@ +DrCyano's Lootable Bodies + +Created by Cyanobacterium (DrCyano, also known as Synechocystis). +Maintained by SkyBlade1978 and the Minecraft Mod Development community. + +This maintained release is distributed under the GNU Lesser General Public +License, version 2.1. See LICENSE for the complete text. + +The original source was supplied with a CC0 1.0 Universal dedication. +That original notice is preserved in LICENSE-CC0. The maintained release's +LGPL notice does not remove the original dedication from the original work. diff --git a/README.md b/README.md index 7293ed4..d193e43 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,25 @@ -# DrCyano's Lootable Bodies +# Lootable Bodies for Minecraft 1.12.2 -When a player dies, a corpse holds their dropped items. Right-click the corpse -to recover them. +Your items stay on a corpse when you die. Right-click the body to recover them. -The maintained versions are: +Use Forge 14.23.5.2859 and Java 8. Install `LootableBodies-2.4.3.112021.jar` +in the client and server mods folders. Back up your world before upgrading. -- [Minecraft 1.10.2](https://github.com/MinecraftModDevelopmentMods/LootableBodies/tree/1.10.2) -- [Minecraft 1.11.2](https://github.com/MinecraftModDevelopmentMods/LootableBodies/tree/1.11.2-dev) +Created by Cyanobacterium (DrCyano), maintained by SkyBlade1978. +Licensed under LGPLv2.1; the original CC0 dedication is included in LICENSE-CC0. -Each version's README covers installation, settings and saved-item recovery. +Open Mods, select Lootable Bodies and click Config to change its settings. +Appearance changes apply straight away on your client. World and server rules +take effect after restarting Minecraft or the server. -This default branch contains the older Minecraft 1.12.2 source. It has not -received the maintained build and inventory updates. It requires Forge -1.12.2-14.23.4.2705 or later. +This version adds upgrade protection for existing 1.10.2, 1.11.2 and 1.12.2 corpses, +including bodies in chunks you visit later. Keep the whole world backup, +including its `data` folder. If preservation cannot finish safely, world loading +stops with an explanation in the log. Items from missing mods are retained for +recovery when those mods return. -Download the file for your Minecraft version from -[Releases](https://github.com/MinecraftModDevelopmentMods/LootableBodies/releases) -and place it in the client and server `mods` folders after installing Forge. -Keep a backup before changing an existing modpack or world. +It cannot recover items an older build already lost. Downgrading an upgraded +world is unsupported. -Created by Cyanobacterium (DrCyano, also known as Synechocystis). -Maintained by [SkyBlade1978](https://github.com/SkyBlade1978) and Minecraft Mod Development. +The existing float-in-fluids and void-platform settings are also available under +Config. Their defaults are unchanged. diff --git a/build.gradle b/build.gradle index 0a83576..ff3a2cd 100644 --- a/build.gradle +++ b/build.gradle @@ -1,121 +1,225 @@ -buildscript { - repositories { - jcenter() - maven { - name = "forge" - url = "http://files.minecraftforge.net/maven" - } - maven { - name = 'gradle' - url 'https://plugins.gradle.org/m2/' - } - maven { - name = "sonatype" - url = 'https://oss.sonatype.org/content/groups/public' - } - } - dependencies { - classpath "net.minecraftforge.gradle:ForgeGradle:2.3-SNAPSHOT" - classpath 'gradle.plugin.com.matthewprenger:CurseGradle:1.0.11' - } -} - -apply plugin: "net.minecraftforge.gradle.forge" -apply plugin: "com.matthewprenger.cursegradle" - -def mod_file = getModFile() -version = getVersion("VERSION", mod_file) -group= "cyano.lootable" -sourceCompatibility = targetCompatibility = "1.8" - -minecraft { - version = "1.12.2-14.23.4.2705" - runDir = "run" - mappings = "stable_39" -} +import org.apache.tools.ant.filters.FixCrLfFilter -dependencies { - // none +plugins { + id 'java' + id 'eclipse' + id 'idea' + id 'maven-publish' + id 'net.minecraftforge.renamer' version '1.1.5' + id 'net.minecraftforge.gradle' version '7.0.34' } -processResources { - inputs.property "version", project.version - inputs.property "mcversion", project.minecraft.version +group = project.mod_group +version = project.mod_version +base.archivesName = project.mod_artifact - from(sourceSets.main.resources.srcDirs) { - include 'mcmod.info' - expand 'version': project.version, 'mcversion': project.minecraft.version +// Gradle uses Java 17; every compiler, test and game process uses this JDK 8. +// Eclipse does not inherit the command-line helper's environment. Keep its +// machine-specific toolchain selection in an ignored local properties file. +Properties localJavaSetup = new Properties() +File localJavaSetupFile = file('gradle-local.properties') +if (localJavaSetupFile.isFile()) localJavaSetupFile.withInputStream { localJavaSetup.load(it) } +def pinnedJava8 = providers.environmentVariable('LOOTABLEBODIES_JAVA8_HOME') + .orElse(providers.gradleProperty('lootablebodies.java8.home')) + .orElse(providers.provider { localJavaSetup.getProperty('lootablebodies.java8.home') }) +def exactJava8Spec = pinnedJava8.isPresent() ? + org.gradle.jvm.toolchain.internal.SpecificInstallationToolchainSpec.fromJavaHome( + project.services.get(org.gradle.api.internal.provider.PropertyFactory), file(pinnedJava8.get())) : null +def pinnedJava25 = providers.environmentVariable('LOOTABLEBODIES_MAVENIZER_JAVA_HOME') + .orElse(providers.gradleProperty('lootablebodies.mavenizer.java.home')) + .orElse(providers.provider { localJavaSetup.getProperty('lootablebodies.mavenizer.java.home') }) +def exactJava25Spec = pinnedJava25.isPresent() ? + org.gradle.jvm.toolchain.internal.SpecificInstallationToolchainSpec.fromJavaHome( + project.services.get(org.gradle.api.internal.provider.PropertyFactory), file(pinnedJava25.get())) : null +ext.mavenizerLauncher = exactJava25Spec != null ? javaToolchains.launcherFor(exactJava25Spec) : + javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(25); vendor = JvmVendorSpec.ADOPTIUM } +fgtools.configure('mavenizer') { javaLauncher.set(mavenizerLauncher) } +java { + toolchain { + languageVersion = JavaLanguageVersion.of(8) + vendor = JvmVendorSpec.ADOPTIUM } + withSourcesJar() + withJavadocJar() +} +ext.productionLauncher = exactJava8Spec != null ? javaToolchains.launcherFor(exactJava8Spec) : + javaToolchains.launcherFor(java.toolchain) +fgtools.configure('slimelauncher') { javaLauncher.set(productionLauncher) } +def productionCompiler = exactJava8Spec != null ? javaToolchains.compilerFor(exactJava8Spec) : + javaToolchains.compilerFor(java.toolchain) +def productionJavadoc = exactJava8Spec != null ? javaToolchains.javadocToolFor(exactJava8Spec) : + javaToolchains.javadocToolFor(java.toolchain) - from(sourceSets.main.resources.srcDirs) { - exclude 'mcmod.info' +tasks.register('verifyJavaSetup') { + group = 'verification' + doLast { + if (JavaVersion.current() != JavaVersion.VERSION_17) throw new GradleException('Run Gradle with Java 17') + def metadata = productionLauncher.get().metadata + Properties release = new Properties() + metadata.installationPath.file('release').asFile.withInputStream { release.load(it) } + if (release.getProperty('SEMANTIC_VERSION') != '"8.0.502+7"' || + release.getProperty('IMPLEMENTOR') != '"Eclipse Adoptium"') { + throw new GradleException("Production requires Temurin 8.0.502+7, found ${metadata.javaRuntimeVersion} ${metadata.vendor}") + } + logger.lifecycle("Gradle Java: ${System.getProperty('java.runtime.version')}; production: ${metadata}") + def worker = mavenizerLauncher.get().metadata + Properties workerRelease = new Properties() + worker.installationPath.file('release').asFile.withInputStream { workerRelease.load(it) } + if (workerRelease.getProperty('JAVA_RUNTIME_VERSION') != '"25.0.3+9-LTS"' || + workerRelease.getProperty('IMPLEMENTOR') != '"Eclipse Adoptium"') + throw new GradleException('The separate Mavenizer worker requires Temurin 25.0.3+9-LTS') + logger.lifecycle("Mavenizer worker: ${worker}; game/compilation remain on Java 8") } } - -task deobfJar(type: Jar) { - classifier = "deobf" - from sourceSets.main.output +tasks.withType(JavaCompile).configureEach { + dependsOn 'verifyJavaSetup' + javaCompiler = productionCompiler + sourceCompatibility = '1.8' + targetCompatibility = '1.8' + options.encoding = 'UTF-8' } - -task javadocJar(type: Jar, dependsOn: javadoc) { - classifier = "javadoc" - from javadoc.getDestinationDir() +tasks.named('compileTestJava', JavaCompile) { options.compilerArgs.add('-proc:none') } +tasks.withType(Test).configureEach { + useJUnitPlatform() + javaLauncher = productionLauncher + workingDir = projectDir } - -artifacts { - archives deobfJar - archives sourceJar - archives javadocJar +tasks.withType(JavaExec).configureEach { javaLauncher = productionLauncher } +tasks.withType(Javadoc).configureEach { + javadocTool = productionJavadoc + failOnError = true + options.encoding = 'UTF-8' + options.addStringOption('Xdoclint:none', '-quiet') + options.addBooleanOption('notimestamp', true) +} +tasks.withType(AbstractArchiveTask).configureEach { + preserveFileTimestamps = false + reproducibleFileOrder = true + filteringCharset = 'UTF-8' +} +ext.archiveTextPatterns = ['**/*.info', '**/*.java', '**/*.lang', '**/*.mcmeta', '**/*.html', + '**/*.css', '**/*.js', '**/*.md', '**/*.txt', '**/package-list', '**/LICENSE', '**/LICENSE-CC0', '**/NOTICE'] +ext.normalizeArchiveText = { details -> + details.filter(FixCrLfFilter, eol: FixCrLfFilter.CrLf.newInstance('lf'), + eof: FixCrLfFilter.AddAsisRemove.newInstance('asis')) } -curseforge { - apiKey = "" - project { - id = "240968" - changelog = file("CHANGELOG.txt") - releaseType = "release" - mainArtifact(jar) { - displayName = "Dr Cyano's Lootable Bodies $project.version" +minecraft { + mappings channel: project.mapping_channel, version: project.mapping_version + runs { + configureEach { + workingDir.convention layout.projectDirectory.dir( + providers.gradleProperty('gameRunDirectory').getOrElse('run')) + systemProperty 'log4j.configurationFile', file('gradle/log4j2.xml').toURI().toString() } - addArtifact sourceJar - addArtifact deobfJar - addArtifact javadocJar + register('client') + register('server') { args 'nogui' } } } +tasks.matching { it.name == 'runServer' }.configureEach { standardInput = System.in } -String getModFile() { - String path = ""; - FileTree tree = fileTree(dir: "src/main/java") - tree.include '**/*.java' - tree.visit { element -> - if (element.file.isFile()) { - element.file.eachLine { String s -> - s = s.trim(); - if (s.startsWith("@Mod")) { - path = "src/main/java/$element.relativePath" - } +// Legacy FML discovers classes and processed resources from a single root. +ext.developmentOutput = layout.buildDirectory.dir('forge12-development/main') +tasks.register('syncDevelopmentOutput', Sync) { + dependsOn 'classes' + from tasks.named('compileJava', JavaCompile).flatMap { it.destinationDirectory } + from sourceSets.main.output.resourcesDir + into developmentOutput +} +tasks.configureEach { + if (name in ['runClient', 'runServer']) { + dependsOn 'syncDevelopmentOutput' + doFirst { + Set split = sourceSets.main.output.files + classpath = files(developmentOutput, classpath.filter { !split.contains(it) }) + def forbidden = classpath.files.findAll { + it.path.replace('\\', '/') =~ /(?i)(\/test(?:\/|$)|junit|opentest4j|mockito|byte-buddy|integration-test)/ } + if (forbidden) throw new GradleException("Ordinary launch contains testing code: ${forbidden}") } } - return path; } -String getVersion(String type, String mod_file) { - String major = "0"; - String revision = "0"; - String patch = "0"; - String prefix = "public static final String $type = \""; - File file = file(mod_file) - file.eachLine { String s -> - s = s.trim(); - if (s.startsWith(prefix)) { - s = s.substring(prefix.length(), s.length() - 2); - String[] pts = s.split("\\."); +repositories { + minecraft.mavenizer(it) + maven fg.forgeMaven + maven fg.minecraftLibsMaven + exclusiveContent { + forRepository { maven { name = 'Sponge'; url = 'https://repo.spongepowered.org/repository/maven-public' } } + filter { includeGroupAndSubgroups('org.spongepowered') } + } + mavenCentral() + maven { url = 'https://libraries.minecraft.net/' } +} +dependencies { + implementation minecraft.dependency("net.minecraftforge:forge:${minecraft_version}-${forge_version}") + compileOnly 'org.lwjgl.lwjgl:lwjgl_util:2.9.4-nightly-20150209' + testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.2' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.10.2' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.12.2' +} - major = pts[0]; - revision = pts[1]; - patch = pts[2]; - } +tasks.named('processResources', ProcessResources) { + filteringCharset = 'UTF-8' + inputs.properties(version: project.version, mcversion: project.minecraft_version, + mod_url: project.mod_url, mod_license: project.mod_license) + filesMatching('mcmod.info') { + expand version: project.version, mcversion: project.minecraft_version, + mod_url: project.mod_url, mod_license: project.mod_license + } + filesMatching(archiveTextPatterns, normalizeArchiveText) +} +tasks.named('jar', Jar) { + archiveClassifier = 'deobf' + destinationDirectory = layout.buildDirectory.dir('libs-dev') + from(['LICENSE', 'LICENSE-CC0', 'NOTICE']) { into 'META-INF' } + manifest.attributes('Implementation-Title': project.mod_name, + 'Implementation-Version': project.version, 'Implementation-Vendor': project.mod_vendor, + 'Maven-Artifact': "${project.group}:${base.archivesName.get()}:${project.version}", + 'Implementation-License': project.mod_license, + 'Built-On-Java': '8', 'Built-On': "${minecraft_version}-${forge_version}") + filesMatching(archiveTextPatterns, normalizeArchiveText) +} +ext.releaseJar = renamer.classes(tasks.named('jar', Jar)) { + map.from minecraft.dependency.toSrgFile + libraries.from(sourceSets.main.compileClasspath) + archiveClassifier = null + output = layout.buildDirectory.file("libs/${base.archivesName.get()}-${project.version}.jar") +} +['sourcesJar', 'javadocJar'].each { name -> + tasks.named(name, Jar) { + from(['LICENSE', 'LICENSE-CC0', 'NOTICE']) { into 'META-INF' } + manifest.attributes('Implementation-Title': project.mod_name, + 'Implementation-Version': project.version, 'Implementation-Vendor': project.mod_vendor, + 'Implementation-License': project.mod_license, + 'Maven-Artifact': "${project.group}:${base.archivesName.get()}:${project.version}:${name == 'sourcesJar' ? 'sources' : 'javadoc'}") + filesMatching(archiveTextPatterns, normalizeArchiveText) } - return "$major.$revision.$patch"; } +['apiElements', 'runtimeElements'].each { name -> + configurations.named(name) { artifacts.clear() } + artifacts { add(name, releaseJar) } +} +tasks.named('assemble') { dependsOn releaseJar, 'sourcesJar', 'javadocJar' } +tasks.named('test') { dependsOn 'jar', 'sourcesJar' } + +apply from: 'gradle/ide.gradle' +apply from: 'gradle/audit.gradle' +apply from: 'gradle/release.gradle' +apply from: 'gradle/runtime.gradle' +apply from: 'gradle/baseline.gradle' + +tasks.register('verifyProductionRuntimeClasspath') { + group = 'verification' + dependsOn 'syncDevelopmentOutput' + doLast { + if (configurations.runtimeClasspath.files.any { + it.path.replace('\\','/') =~ /(?i)(\/test(?:\/|$)|junit|opentest4j|mockito|byte-buddy|integration-test|buildtest)/ + }) throw new GradleException('Testing code leaked into the production runtime') + if (!developmentOutput.get().file('mcmod.info').asFile.text.contains("\"version\": \"${project.version}\"")) + throw new GradleException('Development resources are not processed') + } +} +tasks.named('check') { dependsOn 'verifyProductionRuntimeClasspath' } + +// Publication is restricted to the confirmed MMD release workflow. diff --git a/gradle.properties b/gradle.properties index 96e1903..26ce7d3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1,34 @@ -archivesBaseName = DrCyanosLootableBodies_1.12 \ No newline at end of file +org.gradle.jvmargs=-Xmx3G +org.gradle.daemon=false +org.gradle.configuration-cache=false +org.gradle.caching=true +org.gradle.parallel=false +org.gradle.workers.max=2 +net.minecraftforge.gradle.merge-source-sets=false +minecraft_version=1.12.2 +forge_version=14.23.5.2859 +mapping_channel=stable +mapping_version=39-1.12 + +# Release identity and dispatcher target metadata. +loader_name=forge +loader_code=1 +java_version=8 +java_toolchain_version=8.0.502+7 +gradle_java_version=17 +curseforge_project_id=240968 + +mod_id=lootablebodies +mod_name=DrCyano's Lootable Bodies +mod_artifact=LootableBodies +mod_vendor=Cyanobacterium +mod_license=LGPL-2.1-only +mod_license_name=GNU Lesser General Public License, Version 2.1 +mod_license_url=https://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt +mod_version=2.4.3.112021 +mod_group=zone.moddev.mc.lootablebodies +mod_authors=Cyanobacterium,SkyBlade1978 +mod_description=Saves your items on a lootable corpse when you die. +mod_url=https://github.com/MinecraftModDevelopmentMods/LootableBodies +mod_scm_connection=scm:git:https://github.com/MinecraftModDevelopmentMods/LootableBodies.git +mod_scm_developer_connection=scm:git:ssh://git@github.com/MinecraftModDevelopmentMods/LootableBodies.git diff --git a/gradle/audit.gradle b/gradle/audit.gradle new file mode 100644 index 0000000..4a3794d --- /dev/null +++ b/gradle/audit.gradle @@ -0,0 +1,110 @@ +import groovy.json.JsonSlurper +import java.security.MessageDigest +import java.util.jar.Manifest +import java.util.zip.ZipFile + +ext.releaseNames = ['', '-sources', '-javadoc'].collect { "${base.archivesName.get()}-${version}${it}.jar".toString() } +ext.releasePomName = "${base.archivesName.get()}-${version}.pom".toString() +ext.releaseDigest = { File candidate -> + MessageDigest.getInstance('SHA-256').digest(candidate.bytes).encodeHex().toString().toUpperCase() +} + +// Apply identical archive checks to newly built jars and immutable input bundles. +ext.auditReleaseArchives = { File directory -> + Set actual = (directory.listFiles() ?: [] as File[]).findAll { it.name.endsWith('.jar') }*.name as Set + if (actual != releaseNames as Set) throw new GradleException("Unexpected/missing artifacts: ${actual}") + releaseNames.eachWithIndex { name, index -> + File candidate = new File(directory, name) + if (!candidate.isFile() || candidate.length() == 0L) throw new GradleException("Empty artifact ${name}") + new ZipFile(candidate).withCloseable { zip -> + Set names = zip.entries().collect { it.name } as Set + if (names.size() != zip.size()) throw new GradleException("Duplicate zip entries in ${name}") + ['LICENSE', 'LICENSE-CC0', 'NOTICE'].each { noticeName -> + def entry = zip.getEntry("META-INF/${noticeName}") + if (!entry || zip.getInputStream(entry).getText('UTF-8').replace('\r\n','\n') != + file(noticeName).getText('UTF-8').replace('\r\n','\n')) + throw new GradleException("Missing/changed licence or attribution ${noticeName} in ${name}") + } + zip.entries().each { entry -> + String path = entry.name + if (path =~ /(?i)(agent|\.codex|\.git\/|test|probe|junit|mockito|bytebuddy|credentials|\.launch$)/) + throw new GradleException("Forbidden archive entry ${path}") + if (path.endsWith('.class')) { + if (!path.startsWith('cyano/lootable/')) throw new GradleException("Foreign production class ${path}") + byte[] bytes = zip.getInputStream(entry).bytes + int major = ((bytes[6] & 255) << 8) | (bytes[7] & 255) + if (major != 52) throw new GradleException("${path} is class major ${major}") + } + if (path.endsWith('.lang')) { + java.nio.charset.StandardCharsets.UTF_8.newDecoder().decode( + java.nio.ByteBuffer.wrap(zip.getInputStream(entry).bytes)) + } + String content = new String(zip.getInputStream(entry).bytes, 'ISO-8859-1') + if (content =~ /(?i)([a-z]:[\\\/]users[\\\/]|[a-z]:[\\\/]LootableBodies|\/home\/runner|\/Users\/|gh[pousr]_[A-Za-z0-9]{20}|-----BEGIN .*PRIVATE KEY-----)/) + throw new GradleException("Private path/credential in ${name}: ${path}") + } + def manifestEntry = zip.getEntry('META-INF/MANIFEST.MF') + def manifest = manifestEntry ? new Manifest(zip.getInputStream(manifestEntry)).mainAttributes : null + String classifier = index == 0 ? '' : index == 1 ? ':sources' : ':javadoc' + if (manifest == null || manifest.getValue('Implementation-Version') != project.version.toString() || + manifest.getValue('Implementation-Title') != project.mod_name || + manifest.getValue('Implementation-Vendor') != project.mod_vendor || + manifest.getValue('Implementation-License') != project.mod_license || + manifest.getValue('Maven-Artifact') != "${project.group}:${base.archivesName.get()}:${project.version}${classifier}" || + manifest.getValue('Implementation-Timestamp') != null || manifest.getValue('Timestamp') != null) + throw new GradleException("Incorrect release manifest in ${name}") + if (index == 0) { + ['mcmod.info', 'pack.mcmeta', 'cyano/lootable/LootableBodies.class', + 'assets/lootablebodies/textures/gui/corpse.png', + 'assets/lootablebodies/textures/entity/corpse/corpse.png'].each { + if (!names.contains(it)) throw new GradleException("Missing production entry ${it}") + } + def info = new JsonSlurper().parseText(zip.getInputStream(zip.getEntry('mcmod.info')).getText('UTF-8'))[0] + if (info.modid != project.mod_id || info.version != project.version.toString() || + info.mcversion != project.minecraft_version || info.name != project.mod_name || + info.url != project.mod_url || info.license != project.mod_license || + info.authorList != ['Cyanobacterium', 'SkyBlade1978'] || info.dependencies != []) + throw new GradleException('Incorrect production metadata/attribution') + if (names.any { it.endsWith('.java') || it.startsWith('net/') || it.startsWith('org/') }) + throw new GradleException('Bundled sources or dependencies in main jar') + String entity = new String(zip.getInputStream(zip.getEntry( + 'cyano/lootable/entities/EntityLootableBody.class')).bytes, 'ISO-8859-1') + if (!entity.contains('func_70037_a') || !entity.contains('func_70014_b')) + throw new GradleException('Production jar does not have runtime SRG mappings') + } else if (index == 1 && !names.contains('cyano/lootable/LootableBodies.java')) { + throw new GradleException('Sources jar does not contain production sources') + } else if (index == 2 && !names.contains('index.html')) { + throw new GradleException('Javadoc jar has no index') + } + } + } +} + +tasks.register('verifyReleaseArtifacts') { + group = 'verification' + dependsOn 'assemble', 'verifyJavaSetup', 'verifyReleaseConfiguration' + doLast { + File libs = layout.buildDirectory.dir('libs').get().asFile + Set actual = (libs.listFiles() ?: [] as File[])*.name as Set + if (actual != releaseNames as Set) throw new GradleException('Release output must contain exactly the three declared jars') + auditReleaseArchives(libs) + new ZipFile(new File(libs, releaseNames[0])).withCloseable { zip -> + new ZipFile(tasks.jar.archiveFile.get().asFile).withCloseable { dev -> + String cls = 'cyano/lootable/entities/EntityLootableBody.class' + if (Arrays.equals(zip.getInputStream(zip.getEntry(cls)).bytes, dev.getInputStream(dev.getEntry(cls)).bytes)) + throw new GradleException('Production jar was not reobfuscated') + } + } + } +} +tasks.register('writeReleaseChecksums') { + group = 'verification' + dependsOn 'verifyReleaseArtifacts' + doLast { + File output = layout.buildDirectory.file('release/SHA256SUMS').get().asFile + output.parentFile.mkdirs() + output.setText(releaseNames.sort(false).collect { name -> + "${releaseDigest(layout.buildDirectory.file("libs/${name}").get().asFile)} ${name}" + }.join('\n') + '\n', 'UTF-8') + } +} diff --git a/gradle/baseline.gradle b/gradle/baseline.gradle new file mode 100644 index 0000000..f738acc --- /dev/null +++ b/gradle/baseline.gradle @@ -0,0 +1,23 @@ +// A separate probe can exercise the historical jar without compiling the port. +def baselineClasses=layout.buildDirectory.dir('baseline-test/classes') +tasks.register('compileOriginal112Checks',JavaCompile) { + source fileTree('src/baselineTest/java'), + file('src/integrationTest/java/cyano/lootable/buildtest/UpgradeChecks.java'), + file('src/integrationTest/java/cyano/lootable/buildtest/TypedNbt.java') + classpath=files(sourceSets.main.output,sourceSets.main.compileClasspath) + destinationDirectory=baselineClasses + dependsOn 'classes' + options.compilerArgs.add('-proc:none') +} +def baselineJar=tasks.register('original112ChecksJar',Jar) { + dependsOn 'compileOriginal112Checks' + from baselineClasses + archiveFileName='lootablebodies-original-112-checks.jar' + destinationDirectory=layout.buildDirectory.dir('baseline-test') +} +def baselineRuntime=renamer.classes(baselineJar) { + map.from minecraft.dependency.toSrgFile + libraries.from(sourceSets.main.output,sourceSets.main.compileClasspath) + output=layout.buildDirectory.file('baseline-test/lootablebodies-original-112-checks-reobf.jar') +} +tasks.register('prepareOriginal112Checks') {dependsOn baselineRuntime} diff --git a/gradle/ide.gradle b/gradle/ide.gradle new file mode 100644 index 0000000..47747c5 --- /dev/null +++ b/gradle/ide.gradle @@ -0,0 +1,111 @@ +eclipse { + jdt.javaRuntimeName = 'JavaSE-1.8' + classpath { + downloadSources = true + downloadJavadoc = true + file.whenMerged { model -> + model.entries.findAll { it.kind == 'src' && it.path == 'src/main/resources' }.each { + // Eclipse must not overwrite expanded metadata with raw templates. + it.path = 'build/resources/main' + } + Set sourcePaths = new HashSet<>() + model.entries.removeAll { it.kind == 'src' && !sourcePaths.add(it.path) } + } + } + synchronizationTasks 'genEclipseRuns' +} +tasks.register('prepareEclipseResources') { + group = 'ide' + dependsOn 'processResources' + doLast { copy { from layout.buildDirectory.dir('resources/main'); into 'bin/main' } } +} +tasks.register('configureEclipseBuildship') { + group = 'ide' + dependsOn 'verifyJavaSetup' + doLast { + File target = file('.settings/org.eclipse.buildship.core.prefs') + Properties prefs = new Properties() + if (target.isFile()) target.withInputStream { prefs.load(it) } + ['eclipse.preferences.version': '1', 'connection.gradle.distribution': 'GRADLE_DISTRIBUTION(WRAPPER)', + 'connection.gradle.user.home': gradle.gradleUserHomeDir.canonicalPath, + 'connection.project.dir': '', 'gradle.user.home': gradle.gradleUserHomeDir.canonicalPath, + 'override.workspace.settings': 'true', 'java.home': System.getProperty('java.home') + ].each { k, v -> prefs.setProperty(k, v) } + target.parentFile.mkdirs() + target.withOutputStream { prefs.store(it, 'Local Buildship settings') } + File localJavaSetup = file('gradle-local.properties') + Properties toolchains = new Properties() + if (localJavaSetup.isFile()) localJavaSetup.withInputStream { toolchains.load(it) } + toolchains.setProperty('lootablebodies.java8.home', + productionLauncher.get().metadata.installationPath.asFile.canonicalPath) + toolchains.setProperty('lootablebodies.mavenizer.java.home', + mavenizerLauncher.get().metadata.installationPath.asFile.canonicalPath) + localJavaSetup.withOutputStream { toolchains.store(it, 'Local production Java toolchain') } + } +} +tasks.register('isolateEclipseProductionRuns') { + group = 'ide' + dependsOn 'prepareEclipseResources', 'configureEclipseBuildship' + doLast { + ['runClient.launch', 'runServer.launch'].each { name -> + File launch = file(name) + if (!launch.isFile()) throw new GradleException("Generate ${name} with genEclipseRuns first") + String contents = launch.getText('UTF-8') + contents = contents.replace('/JavaSE-8', '/JavaSE-1.8') + contents = contents.replace('key="MC_VERSION" value="${MC_VERSION}"', 'key="MC_VERSION" value="1.12.2"') + contents = contents.replaceAll(//, '') + contents = contents.replace('', + ' \n') + ['--cache', '--metadata', '--to-srg', '--to-obf'].each { option -> + int start = contents.indexOf(option + ' ') + if (start >= 0) { + start += option.length() + 1 + int end = contents.indexOf(' --', start) + if (end > start) { + String value = contents.substring(start, end) + if (value.find(/\s/) && !value.startsWith('"')) + contents = contents.substring(0, start) + '"' + value + '"' + contents.substring(end) + } + } + } + launch.setText(contents.replace('\r\n', '\n'), 'UTF-8') + } + } +} +tasks.named('genEclipseRuns') { finalizedBy 'isolateEclipseProductionRuns' } +tasks.register('verifyEclipseProductionClasspath') { + group = 'verification' + dependsOn 'genEclipseRuns', 'eclipse' + doLast { + String classpathXml = file('.classpath').text + if (!classpathXml.contains('path="build/resources/main"') || classpathXml.contains('path="src/main/resources"')) + throw new GradleException('Eclipse must use Gradle-processed resources') + def classpathModel = new groovy.xml.XmlSlurper().parseText(classpathXml) + if (classpathModel.classpathentry.findAll { it.@path.text() == 'build/resources/main' }.size() != 1) + throw new GradleException('Duplicate Eclipse production resource roots') + classpathModel.classpathentry.findAll { it.@path.text() =~ /(?i)(junit|opentest4j|apiguardian)/ }.each { entry -> + if (!entry.attributes.attribute.any { it.@name.text() == 'test' && it.@value.text() == 'true' }) + throw new GradleException('Eclipse testing library is not marked test-only') + } + def metadata = new groovy.json.JsonSlurper().parse(file('bin/main/mcmod.info'))[0] + if (metadata.modid != project.mod_id || metadata.version != project.version.toString() || metadata.mcversion != project.minecraft_version) + throw new GradleException('Eclipse resources are not processed production resources') + ['runClient.launch', 'runServer.launch'].each { name -> + String xml = file(name).text.replace('\\', '/') + if (!xml.contains('ATTR_EXCLUDE_TEST_CODE" value="true"') || + xml =~ /(?i)(src\/test|bin\/test|junit-|integration-test|mockito|byte-buddy)/ || + !xml.contains('PROJECT_ATTR" value="LootableBodies"')) + throw new GradleException("${name} has the wrong project or exposes tests") + if (!xml.contains('key="MC_VERSION" value="1.12.2"')) + throw new GradleException('Eclipse Minecraft version is unresolved') + def launchModel = new groovy.xml.XmlSlurper().parseText(xml) + String directory = launchModel.stringAttribute.find { + it.@key.text() == 'org.eclipse.jdt.launching.WORKING_DIRECTORY' + }.@value.text() + if (file(directory).canonicalFile != file(providers.gradleProperty('gameRunDirectory').getOrElse('run')).canonicalFile) + throw new GradleException("${name} must use the production run directory, found ${directory}") + if (name == 'runServer.launch' && (!xml.contains('nogui') || xml.contains('--nogui'))) + throw new GradleException('Legacy dedicated server needs bare nogui') + } + } +} diff --git a/gradle/log4j2.xml b/gradle/log4j2.xml new file mode 100644 index 0000000..d15f663 --- /dev/null +++ b/gradle/log4j2.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/gradle/release.gradle b/gradle/release.gradle new file mode 100644 index 0000000..4d4346d --- /dev/null +++ b/gradle/release.gradle @@ -0,0 +1,181 @@ +import groovy.xml.XmlSlurper +import org.gradle.api.publish.maven.tasks.AbstractPublishToMaven + +def expectedIdentity = [ + mod_id: 'lootablebodies', mod_artifact: 'LootableBodies', + mod_group: 'zone.moddev.mc.lootablebodies', mod_vendor: 'Cyanobacterium', + mod_name: "DrCyano's Lootable Bodies", mod_authors: 'Cyanobacterium,SkyBlade1978', + minecraft_version: '1.12.2', forge_version: '14.23.5.2859', + mapping_channel: 'stable', mapping_version: '39-1.12', + loader_name: 'forge', loader_code: '1', curseforge_project_id: '240968', + java_version: '8', java_toolchain_version: '8.0.502+7', gradle_java_version: '17', + mod_license: 'LGPL-2.1-only', mod_license_name: 'GNU Lesser General Public License, Version 2.1', + mod_license_url: 'https://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt', + mod_url: 'https://github.com/MinecraftModDevelopmentMods/LootableBodies', + mod_scm_connection: 'scm:git:https://github.com/MinecraftModDevelopmentMods/LootableBodies.git', + mod_scm_developer_connection: 'scm:git:ssh://git@github.com/MinecraftModDevelopmentMods/LootableBodies.git' +] +expectedIdentity.each { key, value -> + if (project.findProperty(key)?.toString() != value) + throw new GradleException("Release identity mismatch: ${key} must be ${value}") +} +def versionParts = project.version.toString().split('\\.', -1) +def minecraftParts = project.minecraft_version.toString().split('\\.') +String target = minecraftParts[0] + minecraftParts[1].padLeft(2, '0') + + (minecraftParts.length == 3 ? minecraftParts[2] : '0').padLeft(2, '0') + project.loader_code +if (versionParts.length != 4 || !versionParts.every { it ==~ /(?:0|[1-9][0-9]*)/ } || versionParts[3] != target) + throw new GradleException("mod_version must use Major.Minor.Bug.${target} for Minecraft ${minecraft_version} Forge") +ext.functional_version = versionParts[0..2].join('.') +ext.release_tag = project.version.toString() +ext.display_version = project.version.toString() + +def preparedReleaseDir = providers.gradleProperty('preparedReleaseDir') +def generatedPom = layout.buildDirectory.file('publications/mavenJava/pom-default.xml') + +tasks.register('verifyReleaseConfiguration') { + group = 'verification' + doLast { + String source = file('src/main/java/cyano/lootable/LootableBodies.java').getText('UTF-8') + if (!source.contains("VERSION = \"${project.version}\"") || !source.contains('MODID = "lootablebodies"') || + !source.contains('acceptedMinecraftVersions = "[1.12.2]"') || !source.contains('String idName = "Corpse"')) + throw new GradleException('Java release version or persistent identity differs from release metadata') + if (!file('README.md').getText('UTF-8').contains(releaseNames[0]) || + !file('CHANGELOG.txt').getText('UTF-8').contains(project.version.toString())) + throw new GradleException('Release documentation does not match the declared artifact/version') + if (!file('LICENSE').getText('UTF-8').contains('Version 2.1, February 1999') || + !file('LICENSE-CC0').getText('UTF-8').startsWith('CC0 1.0 Universal')) + throw new GradleException('Maintained LGPL licence or original CC0 notice is missing') + } +} +tasks.withType(JavaCompile).configureEach { dependsOn 'verifyReleaseConfiguration' } +tasks.named('processResources') { dependsOn 'verifyReleaseConfiguration' } + +publishing { + publications { + mavenJava(MavenPublication) { + groupId = project.mod_group + artifactId = project.mod_artifact + version = project.version.toString() + if (preparedReleaseDir.isPresent()) { + artifact(new File(file(preparedReleaseDir.get()), releaseNames[0])) + artifact(new File(file(preparedReleaseDir.get()), releaseNames[1])) { classifier = 'sources' } + artifact(new File(file(preparedReleaseDir.get()), releaseNames[2])) { classifier = 'javadoc' } + } else { + artifact(releaseJar) + artifact(tasks.named('sourcesJar')) + artifact(tasks.named('javadocJar')) + } + pom { + name = project.mod_name + description = project.mod_description + url = project.mod_url + licenses { license { name = project.mod_license_name; url = project.mod_license_url; distribution = 'repo' } } + developers { + developer { id = 'cyanobacterium'; name = 'Cyanobacterium (DrCyano / Synechocystis)' } + developer { id = 'SkyBlade1978'; name = 'SkyBlade1978'; url = 'https://github.com/SkyBlade1978' } + } + scm { + connection = project.mod_scm_connection + developerConnection = project.mod_scm_developer_connection + url = project.mod_url + } + } + } + } + repositories { + maven { + name = 'release' + url = uri(providers.environmentVariable('MAVEN_UPLOAD_URL').getOrElse('https://invalid.invalid/publication-disabled')) + credentials { + username = providers.environmentVariable('MAVEN_UPLOAD_USERNAME').getOrElse('') + password = providers.environmentVariable('MAVEN_UPLOAD_PASSWORD').getOrElse('') + } + } + } +} + +tasks.register('verifyMavenCoordinates') { + group = 'verification' + dependsOn 'generatePomFileForMavenJavaPublication', 'verifyReleaseConfiguration' + doLast { + def pom = new XmlSlurper(false, false).parse(generatedPom.get().asFile) + if ([pom.groupId.text(), pom.artifactId.text(), pom.version.text()] != + [project.mod_group, project.mod_artifact, project.version.toString()] || + pom.name.text() != project.mod_name || pom.url.text() != project.mod_url || + pom.licenses.license.size() != 1 || pom.licenses.license.name.text() != project.mod_license_name || + pom.licenses.license.url.text() != project.mod_license_url || + pom.scm.connection.text() != project.mod_scm_connection || + pom.scm.developerConnection.text() != project.mod_scm_developer_connection || + pom.scm.url.text() != project.mod_url || pom.dependencies.dependency.size() != 0 || + !pom.developers.developer.name.text().contains('Cyanobacterium')) + throw new GradleException('Generated Maven POM does not match the release identity/licence') + def declared = publishing.publications.mavenJava.artifacts.collect { + [it.extension, it.classifier ?: '', it.file.name] + }.sort { it[1] } + def expected = [['jar', '', releaseNames[0]], ['jar', 'sources', releaseNames[1]], + ['jar', 'javadoc', releaseNames[2]]].sort { it[1] } + if (declared != expected) throw new GradleException('Maven must contain exactly runtime, sources and Javadocs') + } +} +tasks.withType(Test).configureEach { dependsOn 'verifyMavenCoordinates' } +tasks.named('check') { dependsOn 'verifyMavenCoordinates', 'verifyReleaseConfiguration' } + +tasks.register('prepareReleaseBundle', Sync) { + group = 'build' + description = 'Prepares the audited local jars, POM, checksums and changelog without publishing.' + dependsOn 'writeReleaseChecksums', 'verifyMavenCoordinates' + from(layout.buildDirectory.dir('libs')) { include releaseNames } + from(generatedPom) { rename { releasePomName } } + from(layout.buildDirectory.file('release/SHA256SUMS')) + from('CHANGELOG.txt') + into layout.buildDirectory.dir('release/bundle') +} +tasks.register('verifyPreparedReleaseArtifacts') { + group = 'verification' + description = 'Checks an immutable prepared bundle without recompiling its jars.' + dependsOn 'verifyMavenCoordinates' + doLast { + if (!preparedReleaseDir.isPresent()) throw new GradleException('preparedReleaseDir is required') + File prepared = file(preparedReleaseDir.get()) + Set expected = (releaseNames + [releasePomName, 'SHA256SUMS', 'CHANGELOG.txt']) as Set + Set actual = (prepared.listFiles() ?: [] as File[])*.name as Set + if (!prepared.isDirectory() || actual != expected) + throw new GradleException('Prepared release must contain exactly the declared jars, POM, checksums and changelog') + auditReleaseArchives(prepared) + List sums = releaseNames.collect { "${releaseDigest(new File(prepared, it))} ${it}".toString() }.sort() + if (new File(prepared, 'SHA256SUMS').readLines('UTF-8').sort() != sums) + throw new GradleException('Prepared release checksums do not match the immutable jars') + if (!Arrays.equals(new File(prepared, releasePomName).bytes, generatedPom.get().asFile.bytes)) + throw new GradleException('Prepared release POM differs from the configured release metadata') + if (!Arrays.equals(new File(prepared, 'CHANGELOG.txt').bytes, file('CHANGELOG.txt').bytes)) + throw new GradleException('Prepared release changelog differs from the reviewed source') + } +} + +// Only the confirmed MMD release workflow may upload an audited prepared bundle. +gradle.taskGraph.whenReady { graph -> + if (graph.allTasks.any { it instanceof AbstractPublishToMaven || it.name in ['publish', 'publishToMavenLocal'] }) { + if (graph.allTasks.any { it.name == 'publishToMavenLocal' || it.class.name.contains('PublishToMavenLocal') } || + System.getenv('GITHUB_ACTIONS') != 'true' || + System.getenv('GITHUB_REPOSITORY') != 'MinecraftModDevelopmentMods/LootableBodies' || + System.getenv('LOOTABLEBODIES_RELEASE_VERSION') != project.version.toString() || + !(System.getenv('GITHUB_RUN_ID') ==~ /[0-9]+/) || !preparedReleaseDir.isPresent()) + throw new GradleException('Publication is disabled outside the confirmed MMD release workflow') + } +} +tasks.register('validateMavenRelease') { + doLast { + def expectedSha = System.getenv('LOOTABLEBODIES_RELEASE_SHA') ?: '' + def gitHead = providers.exec { commandLine('git', 'rev-parse', 'HEAD') } + def actualSha = gitHead.standardOutput.asText.get().trim() + if (!expectedSha.matches('[a-f0-9]{40}') || actualSha != expectedSha) + throw new GradleException('The publication checkout does not match the confirmed release commit') + def uploadUrl = System.getenv('MAVEN_UPLOAD_URL') ?: '' + if (!uploadUrl.matches('https://[^/ ]+(/.*)?') || uploadUrl.startsWith('https://invalid.invalid') || + !System.getenv('MAVEN_UPLOAD_USERNAME') || !System.getenv('MAVEN_UPLOAD_PASSWORD')) + throw new GradleException('A remote HTTPS Maven repository and release credentials are required') + } +} +tasks.withType(AbstractPublishToMaven).configureEach { + dependsOn 'validateMavenRelease', 'verifyPreparedReleaseArtifacts' +} diff --git a/gradle/runtime.gradle b/gradle/runtime.gradle new file mode 100644 index 0000000..1e81369 --- /dev/null +++ b/gradle/runtime.gradle @@ -0,0 +1,273 @@ +// Build-only legacy runtime verification, separate from production classpaths. +def probeClasses = layout.buildDirectory.dir('integration-test-mod/classes') +tasks.register('compileIntegrationTestMod', JavaCompile) { + group = 'verification' + dependsOn 'classes' + source fileTree('src/integrationTest/java') + classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) + destinationDirectory = probeClasses + options.compilerArgs.add('-proc:none') +} +def probeJar = tasks.register('integrationTestModJar', Jar) { + group = 'verification' + dependsOn 'compileIntegrationTestMod' + archiveFileName = 'lootablebodies-build-tests.jar' + destinationDirectory = layout.buildDirectory.dir('integration-test-mod') + from probeClasses + from 'src/integrationTest/resources' +} +def packagedProbe = renamer.classes(probeJar) { + map.from minecraft.dependency.toSrgFile + libraries.from(tasks.named('jar'), sourceSets.main.compileClasspath) + output = layout.buildDirectory.file('integration-test-mod/lootablebodies-build-tests-reobf.jar') +} +tasks.register('preparePackagedIntegrationProbe') { dependsOn packagedProbe, releaseJar } + +def legacySeedClasses=layout.buildDirectory.dir('legacy-seed/classes') +tasks.register('compileLegacySeed',JavaCompile) { + source fileTree('src/legacySeed/java'), file('src/integrationTest/java/cyano/lootable/buildtest/FixtureItems.java') + classpath=files(sourceSets.main.output,sourceSets.main.compileClasspath) + destinationDirectory=legacySeedClasses + options.compilerArgs.add('-proc:none') +} +def legacySeedJar=tasks.register('legacySeedJar',Jar) { + dependsOn 'compileLegacySeed' + from legacySeedClasses + archiveFileName='lootablebodies-world-fixtures.jar' + destinationDirectory=layout.buildDirectory.dir('legacy-seed') +} +def legacySeedRuntime=renamer.classes(legacySeedJar) { + map.from minecraft.dependency.toSrgFile + libraries.from(tasks.named('jar'),sourceSets.main.compileClasspath) + output=layout.buildDirectory.file('legacy-seed/lootablebodies-world-fixtures-reobf.jar') +} +tasks.register('prepareLegacyWorldFixtures') {dependsOn legacySeedRuntime} + +def runDirectory = layout.buildDirectory.dir('integration-run') +tasks.register('prepareIntegrationTest') { + dependsOn probeJar + doLast { + File root = runDirectory.get().asFile + if (root.exists()) { + File history = layout.buildDirectory.dir('runtime-history/' + System.currentTimeMillis()).get().asFile + history.parentFile.mkdirs() + if (!root.renameTo(history)) throw new GradleException('Cannot preserve previous runtime evidence') + } + new File(root,'mods').mkdirs() + copy { from probeJar; into new File(root,'mods') } + new File(root,'eula.txt').text = 'eula=true\n' + ['ops','whitelist','banned-players','banned-ips'].each { new File(root,"${it}.json").text='[]\n' } + new File(root,'server.properties').text = '''level-name=world +level-seed=4267321 +level-type=FLAT +online-mode=false +server-ip=127.0.0.1 +server-port=0 +generate-structures=false +spawn-animals=false +spawn-monsters=false +spawn-protection=0 +max-tick-time=120000 +''' + } +} + +// Reuse FG7's actual bootstrap, mappings, natives and pinned Java launcher. +def phaseTask = { String phase, Object prerequisite -> + tasks.register("integration${phase.capitalize()}Process", JavaExec) { + group = 'verification' + dependsOn prerequisite + dependsOn { def run = tasks.getByName('runServer'); run.taskDependencies.getDependencies(run) } + actions.clear() + doLast { + JavaExec run = tasks.getByName('runServer') + def options = minecraft.runs.getByName('server') + File previousDir = options.workingDir.get().asFile + def previousProperties = new LinkedHashMap(run.systemProperties) + def previousClasspath = files(run.classpath) + try { + options.workingDir.set(runDirectory) + run.workingDir(runDirectory) + run.systemProperty('lootablebodies.phase', phase) + run.systemProperty('log4j.configurationFile', file('src/integrationTest/resources/log4j2.xml').toURI()) + run.executable = productionLauncher.get().executablePath.asFile.path + run.setClasspath(files(developmentOutput, run.classpath.filter { !sourceSets.main.output.files.contains(it) })) + run.exec() + } finally { + options.workingDir.set(previousDir) + run.workingDir(previousDir) + run.setSystemProperties(previousProperties) + run.setClasspath(previousClasspath) + } + File marker = new File(runDirectory.get().asFile,"world/${phase}.pass") + if (!marker.isFile()) throw new GradleException("Missing ${phase} corpse preservation marker") + File logs = new File(runDirectory.get().asFile,'logs/latest.log') + if (!logs.isFile() || logs.text =~ /(?i)(AssertionError|Encountered an unexpected exception|Exception stopping server)/) + throw new GradleException("Runtime failure in ${logs}") + copy { from logs; into runDirectory; rename { "${phase}.log" } } + if (!fileTree(new File(runDirectory.get().asFile,'crash-reports')).isEmpty()) + throw new GradleException('Runtime crash reports found') + } + } +} +def fresh = phaseTask('fresh','prepareIntegrationTest') +def upgrades = phaseTask('upgrade-contracts','prepareIntegrationTest') +tasks.register('upgradeContractTest') { group='verification'; dependsOn upgrades } +def inventory = phaseTask('inventory','prepareIntegrationTest') +tasks.register('inventoryRegressionTest') { group='verification'; dependsOn inventory } + +def nbt = phaseTask('nbt','prepareIntegrationTest') +tasks.register('nbtRecoveryTest') { group='verification'; dependsOn nbt } + +def scalars = phaseTask('scalars','prepareIntegrationTest') +tasks.register('nameLifetimeTest') { group='verification'; dependsOn scalars } + +def health = phaseTask('health','prepareIntegrationTest') +tasks.register('healthAttributeTest') { group='verification'; dependsOn health } + +def effects = phaseTask('effects','prepareIntegrationTest') + +tasks.register('effectHurtTest') { group='verification'; dependsOn effects } + +def geometry = phaseTask('geometry','prepareIntegrationTest') + +tasks.register('positionMotionTest') { group='verification'; dependsOn geometry } + +def rotation = phaseTask('rotation','prepareIntegrationTest') + +tasks.register('rotationFallTest') { group='verification'; dependsOn rotation } + +def wallEscape = phaseTask('wall-escape','prepareIntegrationTest') +def wallEscapeReload = phaseTask('wall-escape-reload',wallEscape) +tasks.register('wallEscapeTest') { group='verification'; dependsOn wallEscapeReload } + +def drops = phaseTask('drops','prepareIntegrationTest') +tasks.register('dropCreativeInteractionTest') { group='verification'; dependsOn drops } + +def interactions = phaseTask('interactions','prepareIntegrationTest') +tasks.register('containerInteractionTest') { group='verification'; dependsOn interactions } +def reload = phaseTask('reload',fresh) +def deferred = phaseTask('deferred',reload) +tasks.register('integrationTest') { group = 'verification'; dependsOn deferred } +tasks.named('check') { dependsOn 'integrationTest' } + +tasks.register('clientIntegrationTest') { + group = 'verification' + dependsOn probeJar + dependsOn { def run = tasks.getByName('runClient'); run.taskDependencies.getDependencies(run) } + doLast { + File root = file(providers.gradleProperty('clientIntegrationDirectory').getOrElse('build/client-integration-run')) + if(root.exists()) throw new GradleException('Preserve the previous client evidence before rerunning') + new File(root,'mods').mkdirs() + new File(root,'config').mkdirs() + new File(root,'options.txt').text='realmsNotifications:false\n' + copy { from probeJar; into new File(root,'mods') } + new File(root,'config/lootablebodies.cfg').text = 'options {\n B:local_texture=true\n}\n' + JavaExec run = tasks.getByName('runClient') + def options = minecraft.runs.getByName('client') + File previousDir = options.workingDir.get().asFile + File previousRunDir = run.workingDir + def previousProperties = new LinkedHashMap(run.systemProperties) + def previousClasspath = files(run.classpath) + String previousExecutable = run.executable + try { + options.workingDir.set(root) + run.workingDir(root) + run.systemProperty('lootablebodies.clientCheck','true') + run.systemProperty('log4j.configurationFile',file('src/integrationTest/resources/log4j2.xml').toURI()) + run.executable = productionLauncher.get().executablePath.asFile.path + run.setClasspath(files(developmentOutput,run.classpath.filter { !sourceSets.main.output.files.contains(it) })) + run.exec() + } finally { + options.workingDir.set(previousDir) + run.workingDir(previousRunDir) + run.setSystemProperties(previousProperties) + run.setClasspath(previousClasspath) + run.executable = previousExecutable + } + if(!new File(root,'client.pass').isFile()) throw new GradleException('Client did not preserve/reopen/render the corpse GUI') + if(!fileTree(new File(root,'crash-reports')).isEmpty()) throw new GradleException('Client crash report') + } +} +tasks.register('exportRuntimeClasspath') { + group='verification' + doLast { + File output=layout.buildDirectory.file('runtime-classpath.json').get().asFile + output.parentFile.mkdirs() + output.text=groovy.json.JsonOutput.toJson(configurations.runtimeClasspath.files*.absolutePath) + } +} + +def controlClasses = layout.buildDirectory.dir('control-test-mod/classes') +tasks.register('compileForgeControl',JavaCompile) { + source fileTree('src/controlTest/java') + classpath=sourceSets.main.compileClasspath + destinationDirectory=controlClasses + options.compilerArgs.add('-proc:none') +} +def controlJar=tasks.register('forgeControlJar',Jar) { + dependsOn 'compileForgeControl' + from controlClasses + archiveFileName='forge-platform-control.jar' + destinationDirectory=layout.buildDirectory.dir('control-test-mod') +} +def packagedControl=renamer.classes(controlJar) { + map.from minecraft.dependency.toSrgFile + libraries.from(sourceSets.main.compileClasspath) + output=layout.buildDirectory.file('control-test-mod/forge-platform-control-reobf.jar') +} +tasks.register('preparePackagedForgeControl') { dependsOn packagedControl } +tasks.register('forgeControlServer') { + group='verification' + dependsOn controlJar + dependsOn { def run=tasks.getByName('runServer'); run.taskDependencies.getDependencies(run) } + doLast { + File root=layout.buildDirectory.dir('forge-control-run').get().asFile + new File(root,'mods').mkdirs() + copy { from controlJar; into new File(root,'mods') } + new File(root,'eula.txt').text='eula=true\n' + new File(root,'server.properties').text='level-name=world\nlevel-type=FLAT\nserver-ip=127.0.0.1\nserver-port=0\nonline-mode=false\n' + ['ops','whitelist','banned-players','banned-ips'].each { new File(root,"${it}.json").text='[]\n' } + JavaExec run=tasks.getByName('runServer') + def options = minecraft.runs.getByName('server') + File previousDir = options.workingDir.get().asFile + File previousRunDir = run.workingDir + def previousProperties = new LinkedHashMap(run.systemProperties) + def previousClasspath = files(run.classpath) + try { + options.workingDir.set(root) + run.workingDir(root) + run.systemProperty('log4j.configurationFile',file('src/integrationTest/resources/log4j2.xml').toURI()) + run.setClasspath(run.classpath.filter { !sourceSets.main.output.files.contains(it) && it != developmentOutput.get().asFile }) + run.exec() + } finally { + options.workingDir.set(previousDir) + run.workingDir(previousRunDir) + run.setSystemProperties(previousProperties) + run.setClasspath(previousClasspath) + } + String text=new File(root,'logs/latest.log').text + if(!text.contains('FORGE_PLATFORM_CONTROL_PASS') || text.contains('lootablebodies@')) throw new GradleException('Invalid independent platform control') + } +} + +// The intermediate writer runs with the checksum-locked released 1.11 jar. +def transitionClasses=layout.buildDirectory.dir('transition-fixture/classes') +tasks.register('compileTransitionFixture',JavaCompile) { + source fileTree('src/transitionFixture/java'),file('src/integrationTest/java/cyano/lootable/buildtest/FixtureItems.java') + classpath=files(sourceSets.main.output,sourceSets.main.compileClasspath) + destinationDirectory=transitionClasses + options.compilerArgs.add('-proc:none') +} +def transitionJar=tasks.register('transitionFixtureJar',Jar) { + dependsOn 'compileTransitionFixture' + from transitionClasses + archiveFileName='lootablebodies-111-transition.jar' + destinationDirectory=layout.buildDirectory.dir('transition-fixture') +} +def transitionRuntime=renamer.classes(transitionJar) { + map.from minecraft.dependency.toSrgFile + libraries.from(tasks.named('jar'),sourceSets.main.compileClasspath) + output=layout.buildDirectory.file('transition-fixture/lootablebodies-111-transition-reobf.jar') +} +tasks.named('prepareLegacyWorldFixtures') {dependsOn transitionRuntime} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 0d4a9516871afd710a9d84d89e31ba77745607bd..b1b8ef56b44f16b14dc800fa8103a6d89abb526f 100644 GIT binary patch literal 48462 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 R2np~f1o-$>Q}y+){|A@R9n$~+ literal 54413 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 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index a95009c..dbe66e1 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,10 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-4.9-bin.zip +distributionSha256Sum=9c0f7faeeb306cb14e4279a3e084ca6b596894089a0638e68a07c945a32c9e14 +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 index cccdd3d..249efbb --- a/gradlew +++ b/gradlew @@ -1,78 +1,128 @@ -#!/usr/bin/env sh +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## -## -## Gradle start up script for UN*X -## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # Attempt to set APP_HOME + # Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null - -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS="" +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -81,92 +131,118 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac fi -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) -# For Cygwin, switch paths to Windows format before running java -if $cygwin ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) fi - i=$((i+1)) + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg done - case $i in - (0) set -- ;; - (1) set -- "$args0" ;; - (2) set -- "$args0" "$args1" ;; - (3) set -- "$args0" "$args1" "$args2" ;; - (4) set -- "$args0" "$args1" "$args2" "$args3" ;; - (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=$(save "$@") - -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" -# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong -if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then - cd "$(dirname "$0")" +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" fi +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index e95643d..a51ec4f 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -1,84 +1,82 @@ -@if "%DEBUG%" == "" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS= - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto init - -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto init - -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:init -@rem Get command-line arguments, handling Windows variants - -if not "%OS%" == "Windows_NT" goto win9xME_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% - -:end -@rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/mcmod.info b/mcmod.info deleted file mode 100644 index 747c047..0000000 --- a/mcmod.info +++ /dev/null @@ -1,16 +0,0 @@ -[ -{ - "modid": "lootable", - "name": "DrCyano's Lootable Bodies", - "description": "Saves your items when you die in a container so that you can get your items back when you return.", - "version": "${version}", - "mcversion": "${mcversion}", - "url": "https://github.com/cyanobacterium/DrCyanos_Lootable-Bodies", - "updateUrl": "", - "authorList": ["Cyanobacterium"], - "credits": "The Forge and FML guys, for making the Forge modding API (you guys are awesome), plant-eating zombies, and the mod author Cyanobacterium (aka Synechocystis)", - "logoFile": "", - "screenshots": [], - "dependencies": [] -} -] diff --git a/resources/mcmod.info b/resources/mcmod.info deleted file mode 120000 index 8b9091c..0000000 --- a/resources/mcmod.info +++ /dev/null @@ -1 +0,0 @@ -src/main/resources/mcmod.info \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 0e64f4a..d6dc9f9 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,2 +1,4 @@ -rootProject.name = archivesBaseName -enableFeaturePreview('STABLE_PUBLISHING') +plugins { + id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0' +} +rootProject.name = 'LootableBodies' diff --git a/src/baselineTest/java/cyano/lootable/buildtest/NameLifetimeChecks.java b/src/baselineTest/java/cyano/lootable/buildtest/NameLifetimeChecks.java new file mode 100644 index 0000000..aa4e81e --- /dev/null +++ b/src/baselineTest/java/cyano/lootable/buildtest/NameLifetimeChecks.java @@ -0,0 +1,3 @@ +package cyano.lootable.buildtest; +import net.minecraft.nbt.NBTBase; +final class NameLifetimeChecks { static boolean same(NBTBase a,NBTBase b){return TypedNbt.canonical(a).equals(TypedNbt.canonical(b));}} diff --git a/src/baselineTest/java/cyano/lootable/buildtest/Original112Checks.java b/src/baselineTest/java/cyano/lootable/buildtest/Original112Checks.java new file mode 100644 index 0000000..7f9874a --- /dev/null +++ b/src/baselineTest/java/cyano/lootable/buildtest/Original112Checks.java @@ -0,0 +1,42 @@ +package cyano.lootable.buildtest; + +import cyano.lootable.LootableBodies; +import cyano.lootable.entities.EntityLootableBody; +import java.io.*; +import java.util.*; +import net.minecraft.init.Items; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.*; +import net.minecraft.world.WorldServer; +import net.minecraftforge.fml.common.*; +import net.minecraftforge.fml.common.event.*; + +/** Independent assertions run against the checksum-locked original 1.12 jar. */ +@Mod(modid="lootablebodiesbuildtest",name="Lootable Bodies Original Checks",version="1",dependencies="required-after:lootablebodies") +public final class Original112Checks { + @Mod.EventHandler public void preInit(FMLPreInitializationEvent event){UpgradeChecks.registerOwnFixer();} + @Mod.EventHandler public void started(FMLServerStartedEvent event) { + net.minecraft.server.MinecraftServer server=FMLCommonHandler.instance().getMinecraftServerInstance(); + int failed=0; + try { + try {UpgradeChecks.run(server);} catch(Throwable expected){failed++;expected.printStackTrace();} + WorldServer world=server.getWorld(0); + LootableBodies.allowCorpseDecay=false;LootableBodies.ticksPerItemDecay=-1; + EntityLootableBody body=new EntityLootableBody(world);body.setPosition(.5,80,.5);body.setNoGravity(true); + body.setInventorySlotContents(6,new ItemStack(Items.DIAMOND,44)); + NBTTagCompound saved=new NBTTagCompound();body.writeToNBT(saved);saved.setByte("Vac",(byte)20); + NBTTagList overflow=new NBTTagList();overflow.appendTag(new ItemStack(Items.PAPER,17).writeToNBT(new NBTTagCompound()));saved.setTag("Aux",overflow);body.readFromNBT(saved); + try { + body.onEntityUpdate();NBTTagCompound after=new NBTTagCompound();body.writeToNBT(after); + RuntimeChecks.require(body.getStackInSlot(6).getItem()==Items.DIAMOND && body.getStackInSlot(6).getCount()==44,"Aux refill overwrote an occupied slot"); + int paper=0;for(int slot=6;slot<48;slot++)if(body.getStackInSlot(slot).getItem()==Items.PAPER)paper+=body.getStackInSlot(slot).getCount(); + for(int i=0;i pending; + private final ConnectedClientChecks connected = new ConnectedClientChecks(); + private final DropClientChecks drops = new DropClientChecks(); + private final NbtClientChecks nbt = new NbtClientChecks(); + private final ReportedIssueClientChecks reportedIssues = new ReportedIssueClientChecks(); + private final VisibilitySkinClientChecks visibilitySkins = new VisibilitySkinClientChecks(); + private final ConfigClientChecks configScreen = new ConfigClientChecks(); + private final NamelessCorpseClientChecks namelessNames = new NamelessCorpseClientChecks(); + @SubscribeEvent public void mouse(net.minecraftforge.client.event.GuiScreenEvent.MouseInputEvent.Pre event) { + event.setCanceled(true); + } + @SubscribeEvent public void keyboard(net.minecraftforge.client.event.GuiScreenEvent.KeyboardInputEvent.Pre event) { + event.setCanceled(true); + } + public void register() { + if (Boolean.getBoolean("lootablebodies.clientCheck")) { + FMLCommonHandler.instance().bus().register(this); + MinecraftForge.EVENT_BUS.register(this); + } + } + @SubscribeEvent public void tick(TickEvent.ClientTickEvent event) { + if(event.phase != TickEvent.Phase.END) return; + Minecraft mc = Minecraft.getMinecraft(); + // The verifier never needs to capture the user's mouse or keyboard. + if(mc.inGameHasFocus) mc.setIngameNotInFocus(); + net.minecraft.client.settings.KeyBinding.unPressAllKeys(); + if(stage!=reportedStage || ticks%60==0) { + System.out.println("GRAPHICAL_STAGE "+stage+" screen="+(mc.currentScreen==null?"world":mc.currentScreen.getClass().getSimpleName())+ + " position="+(mc.player==null?"none":mc.player.getPosition())+" focus="+mc.inGameHasFocus); + reportedStage=stage; + } + if(ticks>0 && ticks%150==0) ScreenShotHelper.saveScreenshot(mc.gameDir,"diagnostic-stage-"+stage+"-tick-"+ticks+".png",mc.displayWidth,mc.displayHeight,mc.getFramebuffer()); + if(++ticks > 12000) fail(new AssertionError("client timeout stage="+stage)); + try { + RuntimeChecks.require(!new File(mc.gameDir,"abort").exists(),"Disposable graphical check aborted"); + if(Boolean.getBoolean("lootablebodies.configUiCheck")) {configScreen.tick(mc);return;} + if(Boolean.getBoolean("lootablebodies.namelessClientCheck")) {namelessNames.tick(mc);return;} + if(Boolean.getBoolean("lootablebodies.visibilitySkinCheck")) {visibilitySkins.tick(mc);return;} + if(Boolean.getBoolean("lootablebodies.issueClientCheck")) {reportedIssues.tick(mc);return;} + if(stage==0 && mc.currentScreen instanceof GuiMainMenu) { + mc.gameSettings.pauseOnLostFocus=false; + mc.gameSettings.limitFramerate=30; + mc.gameSettings.renderDistanceChunks=4; + stage=1; + mc.launchIntegratedServer("build-fixture", "Build fixture",new WorldSettings(4267321,GameType.CREATIVE,false,false,WorldType.FLAT)); + } else if(stage==1 && mc.world!=null && mc.player!=null && mc.getIntegratedServer()!=null) { + stage=2; + pending=mc.getIntegratedServer().addScheduledTask(() -> { + try { RuntimeChecks.run(mc.getIntegratedServer(),"fresh"); } + catch(Exception error) { throw new RuntimeException(error); } + net.minecraft.entity.player.EntityPlayerMP player=mc.getIntegratedServer().getPlayerList().getPlayerByUUID(mc.player.getUniqueID()); + player.capabilities.isFlying=true;player.sendPlayerAbilities(); + player.connection.setPlayerLocation(8.5,80,7.5,0,0); + }); + } else if(stage==2 && pending.isDone()) { + pending.get(); + NamelessCorpseClientChecks.englishContract(mc); + for(Entity entity : mc.world.loadedEntityList) if(entity instanceof EntityLootableBody && entity.getUniqueID().equals(RuntimeChecks.NEAR)) { + mc.playerController.interactWithEntity(mc.player,entity,EnumHand.MAIN_HAND); + stage=3; break; + } + } else if(stage==3 && mc.currentScreen instanceof CorpseGUIContainer && ++frames>20) { + RuntimeChecks.require(((CorpseGUIContainer)mc.currentScreen).inventorySlots.inventorySlots.size()==84,"rendered GUI slots"); + ScreenShotHelper.saveScreenshot(mc.gameDir,"corpse-gui-fresh.png",mc.displayWidth,mc.displayHeight,mc.getFramebuffer()); + mc.player.closeScreen(); stage=4; + mc.getIntegratedServer().initiateShutdown(); + } else if(stage==4 && mc.world==null && !mc.isIntegratedServerRunning()) { + stage=5; mc.launchIntegratedServer("build-fixture","Build fixture",null); + } else if(stage==5 && mc.player!=null && mc.world!=null && mc.getIntegratedServer()!=null) { + stage=6; + pending=mc.getIntegratedServer().addScheduledTask(() -> { + try { RuntimeChecks.run(mc.getIntegratedServer(),"deferred"); } + catch(Exception error) { throw new RuntimeException(error); } + net.minecraft.entity.player.EntityPlayerMP player=mc.getIntegratedServer().getPlayerList().getPlayerByUUID(mc.player.getUniqueID()); + player.capabilities.isFlying=true;player.sendPlayerAbilities(); + player.connection.setPlayerLocation(8.5,80,7.5,0,0); + }); + } else if(stage==6 && pending.isDone()) { + pending.get(); + for(Entity entity:mc.world.loadedEntityList) if(entity instanceof EntityLootableBody && entity.getUniqueID().equals(RuntimeChecks.NEAR)) { + mc.playerController.interactWithEntity(mc.player,entity,EnumHand.MAIN_HAND); + stage=7; frames=0; break; + } + } else if(stage==7 && mc.currentScreen instanceof CorpseGUIContainer && ++frames>20) { + ScreenShotHelper.saveScreenshot(mc.gameDir,"corpse-gui-reload.png",mc.displayWidth,mc.displayHeight,mc.getFramebuffer()); + mc.player.closeScreen(); stage=20; connected.start(mc); + } else if(stage==20 && connected.tick(mc)) { + mc.player.closeScreen();drops.start(mc);stage=21; + } else if(stage==21 && drops.tick(mc)) { + mc.player.closeScreen();nbt.start(mc);stage=22; + } else if(stage==22 && nbt.tick(mc)) { + try(FileWriter out=new FileWriter(new File(mc.gameDir,"client.pass"))) { + out.write("fresh/reload GUI; connected death/respawn; network shift/normal recovery; two save/reloads verified\n"); + } + System.out.println("LOOTABLE_BODIES_CLIENT_PASS"); + stage=8; mc.player.closeScreen(); mc.getIntegratedServer().initiateShutdown(); + } else if(stage==8 && mc.world==null && !mc.isIntegratedServerRunning()) { + stage=9; mc.shutdown(); + } + } catch(Throwable error) { fail(error); } + } + private void fail(Throwable error) { + error.printStackTrace(); + Minecraft.getMinecraft().shutdown(); + stage=99; + } +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/ConfigClientChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/ConfigClientChecks.java new file mode 100644 index 0000000..1d51b2c --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/ConfigClientChecks.java @@ -0,0 +1,201 @@ +package cyano.lootable.buildtest; + +import cyano.lootable.LootableBodies; +import cyano.lootable.entities.EntityLootableBody; +import java.io.*; +import java.lang.reflect.*; +import java.util.*; +import java.util.concurrent.Future; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.*; +import net.minecraft.entity.Entity; +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.util.ScreenShotHelper; +import net.minecraft.world.*; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.fml.client.*; +import net.minecraftforge.fml.client.config.*; +import net.minecraftforge.fml.client.config.GuiConfigEntries.*; +import net.minecraftforge.fml.client.event.ConfigChangedEvent; +import net.minecraftforge.fml.common.*; + +/** Exercises the actual Forge Config button and widgets without OS input control. */ +final class ConfigClientChecks { + private int stage, frames; + private GuiModList mods; + private GuiConfig root, appearance, rules; + private Future pending; + private final UUID bodyId = UUID.fromString("00000000-0000-4000-8000-000000000941"); + private File report; + + void tick(Minecraft mc) throws Exception { + if (++frames < 20) return; + frames = 0; + if (report == null) report = new File(mc.gameDir, "config-checks.txt"); + if (stage == 0 && mc.currentScreen instanceof GuiMainMenu) { + mc.gameSettings.pauseOnLostFocus = false; mc.gameSettings.limitFramerate = 30; + mc.gameSettings.renderDistanceChunks = 4; + if (Boolean.getBoolean("lootablebodies.configUiReload")) { + check(LootableBodies.corpseHP == 31 && LootableBodies.corpseDecayTime == 200 + && LootableBodies.ticksPerItemDecay == 0, "pending_rules_apply_after_process_restart"); + check(!LootableBodies.useLocalSkin && LootableBodies.displayNameTag, "appearance_survives_restart"); + finish(mc); return; + } + check(LootableBodies.corpseHP == 20 && LootableBodies.ticksPerItemDecay == 6000, "startup_defaults"); + openMods(mc); stage = 1; + } else if (stage == 1) { + screenshot(mc, "01-mods-config-button.png"); + openConfig(mc); stage = 2; + } else if (stage == 2) { + check(root.configElements.size() == 2, "appearance_and_rules_pages"); + screenshot(mc, "02-config-root.png"); + appearance = category(root, 0); mc.displayGuiScreen(appearance); stage = 3; + } else if (stage == 3) { + screenshot(mc, "03-appearance.png"); + toggle(appearance, 0); toggle(appearance, 1); + click(appearance, 2000); // child Done stages edits; root Escape discards them. + mc.displayGuiScreen(mods); + check(!LootableBodies.useLocalSkin && LootableBodies.displayNameTag, "leaving_without_done_discards_changes"); + openConfig(mc); appearance = category(root, 0); mc.displayGuiScreen(appearance); + toggle(appearance, 0); toggle(appearance, 1); click(appearance, 2000); click(root, 2000); + check(LootableBodies.useLocalSkin && !LootableBodies.displayNameTag, "appearance_done_applies_immediately"); + openConfig(mc); rules = category(root, 1); mc.displayGuiScreen(rules); stage = 4; + } else if (stage == 4) { + screenshot(mc, "04-world-rules.png"); + for (IConfigEntry entry : rules.entryList.listEntries) + check(entry.enabled() && entry.getConfigElement().requiresMcRestart(), "rule_restart_policy_" + entry.getConfigElement().getName()); + IConfigEntry health = rules.entryList.listEntries.get(0); + text(health, "31"); health.undoChanges(); + check(health.getCurrentValue().toString().equals("20.0"), "undo_restores_health"); + text(health, "31"); health.setToDefault(); + check(health.getCurrentValue().toString().equals("20.0"), "defaults_restore_health"); + text(health, "31"); text(rules.entryList.listEntries.get(2), "0"); + IConfigEntry lifetime = rules.entryList.listEntries.get(8); + for (String invalid : Arrays.asList("bad", "1:-2", "29826:9:43", "1:2:3:4")) { + text(lifetime, invalid); lifetime.saveConfigElement(); + check(lifetime.getConfigElement().get().equals("1:00:00"), "invalid_time_not_saved_" + invalid); + } + text(lifetime, "0:00:10"); click(rules, 2000); click(root, 2000); + check(mc.currentScreen instanceof GuiMessageDialog, "restart_dialog_shown"); + check(LootableBodies.corpseHP == 20 && LootableBodies.corpseDecayTime == 72000 + && LootableBodies.ticksPerItemDecay == 6000, "gameplay_unchanged_until_restart"); + stage = 5; + } else if (stage == 5) { + screenshot(mc, "05-restart-notice.png"); + mc.displayGuiScreen(null); + mc.launchIntegratedServer("config-fixture", "Config fixture", + new WorldSettings(4267321, GameType.CREATIVE, false, false, WorldType.FLAT)); stage = 6; + } else if (stage == 6 && mc.world != null && mc.player != null) { + pending = mc.getIntegratedServer().addScheduledTask(() -> { + try { + // Freeze only the fixture lifetime while comparing exact saved timer fields. + LootableBodies.allowCorpseDecay = false; + EntityLootableBody body = RuntimeChecks.fixture(mc.getIntegratedServer().getWorld(0), bodyId, 8, true); + RuntimeChecks.require(body.getAlwaysRenderNameTag(), "Local appearance preference leaked into saved corpse metadata"); + // Simulate an old corpse whose saved visibility flag was false. + body.setAlwaysRenderNameTag(false); + RuntimeChecks.saveExpected(mc.getIntegratedServer().getWorld(0).getSaveHandler().getWorldDirectory(), "config-body", body); + net.minecraft.entity.player.EntityPlayerMP player = mc.getIntegratedServer().getPlayerList().getPlayerByUUID(mc.player.getUniqueID()); + player.capabilities.isFlying = true; player.sendPlayerAbilities(); + player.connection.setPlayerLocation(8.5, 80, 7.5, 0, 0); + } catch (Exception error) { throw new RuntimeException(error); } + }); stage = 7; + } else if (stage == 7 && pending.isDone()) { + pending.get(); openMods(mc); openConfig(mc); + rules = category(root, 1); mc.displayGuiScreen(rules); stage = 8; + } else if (stage == 8) { + screenshot(mc, "06-rules-disabled-in-world.png"); + for (IConfigEntry entry : rules.entryList.listEntries) check(!entry.enabled(), "rule_disabled_in_world_" + entry.getConfigElement().getName()); + click(rules, 2000); appearance = category(root, 0); mc.displayGuiScreen(appearance); + for (IConfigEntry entry : appearance.entryList.listEntries) check(entry.enabled(), "appearance_enabled_in_world_" + entry.getConfigElement().getName()); + // Cache a synthetic profile: switching skins needs no external account service. + net.minecraft.tileentity.TileEntitySkull.setProfileCache(new net.minecraft.server.management.PlayerProfileCache(null, + new File(mc.gameDir, "synthetic-cache.json")) { + @Override public com.mojang.authlib.GameProfile getGameProfileForUsername(String name) { + com.mojang.authlib.GameProfile profile = new com.mojang.authlib.GameProfile(bodyId, name); + profile.getProperties().put("textures", new com.mojang.authlib.properties.Property("textures", "eyJ0ZXh0dXJlcyI6e319")); return profile; + } + }); + toggle(appearance, 0); toggle(appearance, 1); click(appearance, 2000); click(root, 2000); + check(!LootableBodies.useLocalSkin && LootableBodies.displayNameTag, "existing_body_appearance_changes_live"); + // Another mod's event cannot change our settings. + LootableBodies.getConfiguration().getCategory("options").get("local_texture").set(true); + MinecraftForge.EVENT_BUS.post(new ConfigChangedEvent.OnConfigChangedEvent("anothermod", null, true, false)); + check(!LootableBodies.useLocalSkin, "other_mod_config_event_ignored"); + LootableBodies.getConfiguration().getCategory("options").get("local_texture").set(false); + pending = mc.getIntegratedServer().addScheduledTask(() -> { + try { + WorldServer world = mc.getIntegratedServer().getWorld(0); + EntityLootableBody body = RuntimeChecks.find(world, bodyId); + RuntimeChecks.verifyExpected(world.getSaveHandler().getWorldDirectory(), "config-body", body); + RuntimeChecks.require(LootableBodies.corpseHP == 20 && LootableBodies.ticksPerItemDecay == 6000, "GUI changed server rules"); + world.saveAllChunks(true, null); + } catch (Exception error) { throw new RuntimeException(error); } + }); stage = 9; + } else if (stage == 9 && pending.isDone()) { + pending.get(); + EntityLootableBody body = null; + for (Entity entity : mc.world.loadedEntityList) if (entity.getUniqueID().equals(bodyId)) body = (EntityLootableBody) entity; + check(body != null && body.getGameProfile() != null && body.getGameProfile().getId() != null, "existing_body_skin_refreshes_without_rename"); + check(!body.getAlwaysRenderNameTag(), "appearance_did_not_rewrite_synced_name_flag"); + mc.player.closeScreen(); mc.world.sendQuittingDisconnectingPacket(); mc.loadWorld(null); + mc.displayGuiScreen(new GuiMainMenu()); stage = 10; + } else if (stage == 10 && mc.world == null && !mc.isIntegratedServerRunning()) { + mc.launchIntegratedServer("config-fixture", "Config fixture", null); stage = 11; + } else if (stage == 11 && mc.world != null && mc.player != null) { + pending = mc.getIntegratedServer().addScheduledTask(() -> { + try { + WorldServer world = mc.getIntegratedServer().getWorld(0); world.getChunk(0, 0); + EntityLootableBody body = RuntimeChecks.find(world, bodyId); + RuntimeChecks.verifyExpected(world.getSaveHandler().getWorldDirectory(), "config-body", body); + RuntimeChecks.require(!body.getAlwaysRenderNameTag(), "Saved legacy visibility flag changed on reload"); + RuntimeChecks.require(body.getMaxHealth() == 20, "Pending GUI health rewrote existing corpse attributes"); + } catch (Exception error) { throw new RuntimeException(error); } + }); stage = 12; + } else if (stage == 12 && pending.isDone()) { + pending.get(); check(true, "corpse_inventory_and_nbt_survive_save_reload"); finish(mc); + } + } + + private void openMods(Minecraft mc) throws Exception { + mods = new GuiModList(new GuiMainMenu()); mc.displayGuiScreen(mods); + Field list = GuiModList.class.getDeclaredField("mods"); list.setAccessible(true); + List containers = (List) list.get(mods); + for (int i = 0; i < containers.size(); i++) if (((ModContainer) containers.get(i)).getModId().equals(LootableBodies.MODID)) mods.selectModIndex(i); + } + private void openConfig(Minecraft mc) throws Exception { + Field field = GuiModList.class.getDeclaredField("configModButton"); field.setAccessible(true); + GuiButton button = (GuiButton) field.get(mods); + check(button.enabled && button.visible, "mods_list_config_button_enabled"); + invokeButton(mods, button); root = (GuiConfig) mc.currentScreen; + check(root.getClass().getName().equals("cyano.lootable.graphics.CorpseConfigGui"), "mods_button_opens_our_screen"); + } + private GuiConfig category(GuiConfig screen, int index) throws Exception { + Field field = CategoryEntry.class.getDeclaredField("childScreen"); field.setAccessible(true); + return (GuiConfig) field.get(screen.entryList.listEntries.get(index)); + } + private void toggle(GuiConfig screen, int index) { ((BooleanEntry) screen.entryList.listEntries.get(index)).valueButtonPressed(index); } + private void text(IConfigEntry entry, String value) throws Exception { + Field field = StringEntry.class.getDeclaredField("textFieldValue"); field.setAccessible(true); + ((GuiTextField) field.get(entry)).setText(value); entry.keyTyped((char) 0, org.lwjgl.input.Keyboard.KEY_HOME); + } + private void click(GuiConfig screen, int id) throws Exception { invokeButton(screen, new GuiButton(id, 0, 0, "fixture")); } + private void invokeButton(GuiScreen screen, GuiButton button) throws Exception { + Class type = screen.getClass(); + while (type != null) { + for (Method method : type.getDeclaredMethods()) if (Arrays.equals(method.getParameterTypes(), new Class[] {GuiButton.class})) { + method.setAccessible(true); method.invoke(screen, button); return; + } + type = type.getSuperclass(); + } + throw new AssertionError("No button action"); + } + private void screenshot(Minecraft mc, String name) { ScreenShotHelper.saveScreenshot(mc.gameDir, name, mc.displayWidth, mc.displayHeight, mc.getFramebuffer()); } + private void check(boolean value, String name) throws Exception { + RuntimeChecks.require(value, name); try (FileWriter writer = new FileWriter(report, true)) { writer.write("PASS " + name + "\n"); } + } + private void finish(Minecraft mc) throws Exception { + new File(mc.gameDir, Boolean.getBoolean("lootablebodies.configUiReload") ? "config-restart.pass" : "client.pass").createNewFile(); + System.out.println("LOOTABLE_BODIES_CONFIG_UI_PASS restart=" + Boolean.getBoolean("lootablebodies.configUiReload")); mc.shutdown(); stage = 99; + } +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/ConnectedClientChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/ConnectedClientChecks.java new file mode 100644 index 0000000..75be221 --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/ConnectedClientChecks.java @@ -0,0 +1,205 @@ +package cyano.lootable.buildtest; + +import cyano.lootable.entities.EntityLootableBody; +import cyano.lootable.graphics.CorpseGUIContainer; +import java.io.*; +import java.util.*; +import java.util.concurrent.Future; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGameOver; +import net.minecraft.client.gui.GuiMainMenu; +import net.minecraft.entity.Entity; +import net.minecraft.entity.player.EntityPlayerMP; +import net.minecraft.init.Blocks; +import net.minecraft.inventory.ClickType; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.*; +import net.minecraft.server.MinecraftServer; +import net.minecraft.util.EnumHand; +import net.minecraft.util.ScreenShotHelper; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.GameType; + +/** Real connected-player death, container packets and persisted recovery. */ +final class ConnectedClientChecks { + private interface Action { void run() throws Exception; } + private int stage, frames; + private Future pending; + private UUID playerId, corpseId; + private NBTTagCompound visibleExpected; + private File report; + + void start(Minecraft mc) { + playerId=mc.player.getUniqueID(); + report=new File(mc.gameDir,"connected-checks.txt"); + RuntimeChecks.require(!report.exists(),"Refusing stale connected-client report"); + } + boolean tick(Minecraft mc) throws Exception { + frames++; + if(stage==0) { + schedule(mc,() -> prepareDeath(mc)); next(1); + } else if(stage==1 && done() && mc.currentScreen instanceof GuiGameOver && frames>20) { + screenshot(mc,"connected-player-death.png"); + mc.player.respawnPlayer(); next(2); + } else if(stage==2 && mc.player!=null && mc.player.isEntityAlive() && !(mc.currentScreen instanceof GuiGameOver)) { + schedule(mc,() -> { + EntityPlayerMP player=player(mc); EntityLootableBody corpse=corpse(mc); + RuntimeChecks.require(player.isEntityAlive(),"Connected respawn failed"); + player.connection.setPlayerLocation(24.5,80,7.5,0,0); + RuntimeChecks.saveExpected(folder(mc),"connected-death-before",corpse); + visibleExpected=read(folder(mc),"connected-death-before"); + mark("connected_player_death_and_respawn"); + }); next(3); + } else if(stage==3 && done()) { + if(open(mc)) next(4); + } else if(stage==4 && gui(mc) && frames>20) { + verifyGui(mc); screenshot(mc,"connected-corpse-before-recovery.png"); + RuntimeChecks.require(RuntimeChecks.sameStack(mc.player.openContainer.getSlot(6).getStack(),RuntimeChecks.stack(19)),"Client recovery slot NBT differs"); + mc.playerController.windowClick(mc.player.openContainer.windowId,6,0,ClickType.QUICK_MOVE,mc.player); + next(5); + } else if(stage==5 && frames>30) { + schedule(mc,() -> { + verifyRecovery(mc,"connected-death-before",6,19,"connected-after-recovery"); + savePlayer(mc,"connected-player-after-recovery"); + mark("network_shift_click_exact_nbt"); + }); next(6); + } else if(stage==6 && done()) { + verifyGui(mc); checkPlayer(mc.player.inventory,19); + screenshot(mc,"connected-corpse-after-shift-recovery.png"); + quit(mc); next(7); + } else if(stage==7 && mc.world==null && !mc.isIntegratedServerRunning()) { + mc.launchIntegratedServer("build-fixture","Build fixture",null); next(8); + } else if(stage==8 && ready(mc) && frames>30) { + schedule(mc,() -> { + verifyReload(mc,"connected-after-recovery","connected-player-after-recovery"); + RuntimeChecks.require(player(mc).inventory.mainInventory.get(9).isEmpty(),"Normal-click destination not empty"); + mark("first_save_reload_corpse_and_player"); + }); next(9); + } else if(stage==9 && done()) { + if(open(mc)) next(10); + } else if(stage==10 && gui(mc) && frames>20) { + verifyGui(mc); checkPlayer(mc.player.inventory,19); + screenshot(mc,"connected-corpse-first-reload.png"); + RuntimeChecks.require(RuntimeChecks.sameStack(mc.player.openContainer.getSlot(7).getStack(),RuntimeChecks.stack(20)),"Client normal-click slot NBT differs"); + int window=mc.player.openContainer.windowId; + mc.playerController.windowClick(window,7,0,ClickType.PICKUP,mc.player); + mc.playerController.windowClick(window,48,0,ClickType.PICKUP,mc.player); + next(11); + } else if(stage==11 && frames>30) { + schedule(mc,() -> { + verifyRecovery(mc,"connected-after-recovery",7,20,"connected-final"); + checkPlayer(player(mc).inventory,19); + RuntimeChecks.require((player(mc).inventory.getItemStack()).isEmpty(),"Server cursor retained a duplicate"); + savePlayer(mc,"connected-player-final"); + mark("network_normal_click_exact_nbt"); + }); next(12); + } else if(stage==12 && done()) { + verifyGui(mc); checkPlayer(mc.player.inventory,19);checkPlayer(mc.player.inventory,20); + RuntimeChecks.require((mc.player.inventory.getItemStack()).isEmpty(),"Client cursor retained a duplicate"); + screenshot(mc,"connected-corpse-after-normal-recovery.png"); + quit(mc); next(13); + } else if(stage==13 && mc.world==null && !mc.isIntegratedServerRunning()) { + mc.launchIntegratedServer("build-fixture","Build fixture",null); next(14); + } else if(stage==14 && ready(mc) && frames>30) { + schedule(mc,() -> { + verifyReload(mc,"connected-final","connected-player-final"); + checkPlayer(player(mc).inventory,19);checkPlayer(player(mc).inventory,20); + mark("second_save_reload_corpse_and_player"); + }); next(15); + } else if(stage==15 && done()) { + if(open(mc)) next(16); + } else if(stage==16 && gui(mc) && frames>20) { + verifyGui(mc); checkPlayer(mc.player.inventory,19);checkPlayer(mc.player.inventory,20); + screenshot(mc,"connected-corpse-final-reload.png"); + System.out.println("LOOTABLE_BODIES_CONNECTED_CLIENT_PASS"); + return true; + } + return false; + } + private void prepareDeath(Minecraft mc) throws Exception { + MinecraftServer server=mc.getIntegratedServer(); EntityPlayerMP player=player(mc); + for(int x=21;x<=27;x++) for(int z=5;z<=11;z++) + player.world.setBlockState(new BlockPos(x,79,z),Blocks.STONE.getDefaultState()); + player.world.getGameRules().setOrCreateGameRule("keepInventory","false"); + player.world.getGameRules().setOrCreateGameRule("doMobSpawning","false"); + player.setGameType(GameType.SURVIVAL); player.inventory.clear();player.inventory.currentItem=0; + for(int slot=0;slot<6;slot++)player.setItemStackToSlot(EntityLootableBody.EQUIPMENT_SLOTS[slot],RuntimeChecks.stack(slot)); + for(int slot=9;slot<=11;slot++)player.inventory.mainInventory.set(slot,RuntimeChecks.stack(slot+10)); + player.connection.setPlayerLocation(24.5,80,8.5,0,0); + player.inventoryContainer.detectAndSendChanges(); player.onKillCommand(); + RuntimeChecks.require(!player.isEntityAlive(),"Connected player did not die"); + EntityLootableBody corpse=null; + for(Entity entity:player.world.loadedEntityList) + if(entity instanceof EntityLootableBody && player.getName().equals(entity.getCustomNameTag())) corpse=(EntityLootableBody)entity; + RuntimeChecks.require(corpse!=null,"Connected death did not create a corpse"); + corpseId=corpse.getUniqueID();corpse.setNoGravity(true);corpse.motionX=0;corpse.motionY=0;corpse.motionZ=0; + RuntimeChecks.disableVacuum(corpse); + for(int slot=0;slot<6;slot++)RuntimeChecks.require(RuntimeChecks.sameStack(corpse.getStackInSlot(slot),RuntimeChecks.stack(slot)),"Connected death equipment slot "+slot); + for(int slot=6;slot<=8;slot++)RuntimeChecks.require(RuntimeChecks.sameStack(corpse.getStackInSlot(slot),RuntimeChecks.stack(slot+13)),"Connected death main slot "+slot); + RuntimeChecks.require(player.inventory.writeToNBT(new NBTTagList()).tagCount()==0,"Death retained player inventory alongside corpse"); + System.out.println("CONNECTED_CORPSE_UUID "+corpseId); + } + private void verifyRecovery(Minecraft mc,String before,int slot,int item,String after) throws Exception { + EntityLootableBody corpse=corpse(mc); + RuntimeChecks.require((corpse.getStackInSlot(slot)).isEmpty(),"Network recovery left source stack"); + checkPlayer(player(mc).inventory,item); + NBTTagCompound expected=read(folder(mc),before),actual=new NBTTagCompound(); + NBTTagList equipment=expected.getTagList("Equipment",10); + for(int i=equipment.tagCount()-1;i>=0;i--)if(equipment.getCompoundTagAt(i).getByte("Slot")==slot)equipment.removeTag(i); + corpse.writeToNBTOptional(actual);RuntimeChecks.compare(expected,actual); + RuntimeChecks.saveExpected(folder(mc),after,corpse);visibleExpected=actual.copy(); + } + private void verifyReload(Minecraft mc,String corpseName,String playerName) throws Exception { + mc.getIntegratedServer().getWorld(0).getChunk(1,0); + EntityLootableBody corpse=corpse(mc);RuntimeChecks.require(corpse.isEntityAlive(),"Saved corpse is no longer accessible"); + corpse.onEntityUpdate();RuntimeChecks.verifyExpected(folder(mc),corpseName,corpse); + NBTTagCompound expected=read(folder(mc),playerName); + RuntimeChecks.require(expected.getTag("Inventory").equals(player(mc).inventory.writeToNBT(new NBTTagList())),"Recovered player inventory changed on disk reload"); + player(mc).connection.setPlayerLocation(24.5,80,7.5,0,0); + visibleExpected=read(folder(mc),corpseName); + } + private void savePlayer(Minecraft mc,String name) throws Exception { + NBTTagCompound expected=new NBTTagCompound();expected.setTag("Inventory",player(mc).inventory.writeToNBT(new NBTTagList())); + try(FileOutputStream out=new FileOutputStream(new File(folder(mc),name+".nbt"))){CompressedStreamTools.writeCompressed(expected,out);} + mc.getIntegratedServer().getPlayerList().saveAllPlayerData();mc.getIntegratedServer().saveAllWorlds(false); + } + private void verifyGui(Minecraft mc) { + RuntimeChecks.require(gui(mc) && mc.player.openContainer.inventorySlots.size()==84,"Connected GUI missing slots"); + ItemStack[] expected=new ItemStack[48];NBTTagList list=visibleExpected.getTagList("Equipment",10); + for(int i=0;i {try{action.run();}catch(Exception error){throw new RuntimeException(error);}});} + private boolean done() throws Exception {if(!pending.isDone())return false;pending.get();return true;} + private void next(int value){stage=value;frames=0;System.out.println("CONNECTED_CLIENT_STAGE "+stage);} + private static boolean ready(Minecraft mc){return mc.world!=null && mc.player!=null && mc.getIntegratedServer()!=null;} + private static boolean gui(Minecraft mc){return mc.currentScreen instanceof CorpseGUIContainer;} + private boolean open(Minecraft mc){ + for(Entity entity:mc.world.loadedEntityList)if(entity instanceof EntityLootableBody && entity.getUniqueID().equals(corpseId)){ + mc.playerController.interactWithEntity(mc.player,entity,EnumHand.MAIN_HAND);return true; + } + return false; + } + private static void screenshot(Minecraft mc,String name){ScreenShotHelper.saveScreenshot(mc.gameDir,name,mc.displayWidth,mc.displayHeight,mc.getFramebuffer());} + private static void quit(Minecraft mc){mc.player.closeScreen();mc.getIntegratedServer().initiateShutdown();} + private void mark(String name) throws Exception { + try(FileWriter out=new FileWriter(report,true)){out.write("PASS "+name+"\n");} + System.out.println("CLIENT_CHECK_PASS "+name); + } +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/ContainerInteractionChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/ContainerInteractionChecks.java new file mode 100644 index 0000000..39cd65f --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/ContainerInteractionChecks.java @@ -0,0 +1,403 @@ +package cyano.lootable.buildtest; + +import com.mojang.authlib.GameProfile; +import cyano.lootable.entities.EntityLootableBody; +import cyano.lootable.graphics.CorpseContainer; +import io.netty.buffer.Unpooled; +import java.io.*; +import java.util.*; +import net.minecraft.entity.Entity; +import net.minecraft.entity.item.EntityItem; +import net.minecraft.entity.player.EntityPlayerMP; +import net.minecraft.init.Blocks; +import net.minecraft.inventory.*; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.*; +import net.minecraft.network.*; +import net.minecraft.network.play.client.*; +import net.minecraft.network.play.server.*; +import net.minecraft.server.MinecraftServer; +import net.minecraft.world.*; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.event.entity.EntityJoinWorldEvent; +import net.minecraftforge.event.entity.item.ItemTossEvent; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; + +/** Server packet/transaction tests; packets are decoded from their actual wire layout. */ +final class ContainerInteractionChecks { + private interface Check { void run() throws Exception; } + private static MinecraftServer server; + private static WorldServer world; + private static int sequence, failures; + private static short transaction; + private static final List results=new ArrayList<>(); + + static void run(MinecraftServer instance,WorldServer target,File folder) throws Exception { + server=instance;world=target;failures=0;results.clear(); + check("hotbar_all_nine_empty_and_occupied",ContainerInteractionChecks::hotbar); + check("hotbar_armor_validation_and_limits",ContainerInteractionChecks::armorSwap); + check("shift_wearable_stack_obeys_slot_limit",ContainerInteractionChecks::wearableShift); + check("left_drag_even_split_and_cursor_remainder",()->dragDistribution(0)); + check("right_drag_one_per_slot_and_cursor_remainder",()->dragDistribution(1)); + check("drag_rejects_invalid_full_and_different_nbt",ContainerInteractionChecks::dragMixed); + check("drag_armor_respects_capacity",ContainerInteractionChecks::dragArmor); + check("drag_cancel_and_out_of_order_do_not_move_items",ContainerInteractionChecks::dragCancel); + check("full_player_shift_is_unchanged",ContainerInteractionChecks::fullPlayer); + check("full_player_partial_merge_conserves_remainder",ContainerInteractionChecks::partialPlayer); + check("full_corpse_keeps_existing_aux",ContainerInteractionChecks::fullCorpse); + check("full_hotbar_displacement_success_conserves_items",()->displacement(false,false)); + check("full_hotbar_rejected_drop_keeps_displaced_item",()->displacement(true,false)); + check("full_hotbar_cancelled_toss_keeps_displaced_item",()->displacement(false,true)); + check("cursor_close_success_keeps_vanilla_drop",()->closeCursor(false,false)); + check("cursor_close_rejected_drop_with_player_space",()->closeCursor(true,false)); + check("cursor_close_rejected_drop_with_full_inventories",()->closeCursor(true,true)); + check("cursor_close_preserves_existing_death_capture",ContainerInteractionChecks::capturedCursor); + check("cursor_close_after_release_keeps_live_inventory_owner",ContainerInteractionChecks::dyingCursor); + check("two_player_same_slot_both_orders_and_resync",ContainerInteractionChecks::twoPlayerShift); + check("two_player_drag_vs_pickup_preserves_both_tags",()->interleavedDrag(false)); + check("two_player_drag_vs_hotbar_preserves_both_tags",()->interleavedDrag(true)); + check("two_player_cursor_close_and_stale_click",ContainerInteractionChecks::twoPlayerClose); + check("distant_packet_cannot_extract_before_tick_close",()->stale(false)); + check("removed_corpse_packet_cannot_extract",()->stale(true)); + check("drag_departure_resets_pending_distribution",ContainerInteractionChecks::staleDrag); + try(PrintWriter out=new PrintWriter(new File(folder,"container-checks.txt"),"UTF-8")) { + for(String line:results)out.println(line); + out.println("checks="+results.size()+" failures="+failures); + } + RuntimeChecks.require(failures==0,"Container regressions: "+failures+" / "+results.size()); + System.out.println("LOOTABLE_BODIES_CONTAINER_PASS checks="+results.size()); + } + + static void runDrops(MinecraftServer instance,WorldServer target,File folder) throws Exception { + server=instance;world=target;failures=0;results.clear(); + for(boolean creative:new boolean[]{false,true}) for(int source=0;source<3;source++) + for(int button=0;button<2;button++) for(int rejection=0;rejection<3;rejection++) { + final boolean c=creative;final int a=source,b=button,d=rejection; + check((c?"creative":"survival")+"_"+new String[]{"throw_corpse","throw_player","outside"}[a]+"_"+b+"_"+new String[]{"accepted","cancelled_toss","rejected_spawn"}[d],()->throwing(c,a,b,d,false)); + } + for(boolean creative:new boolean[]{false,true}) for(int source:new int[]{0,2}) { + final boolean c=creative;final int a=source; + check((c?"creative":"survival")+"_capture_"+a,()->throwing(c,a,0,0,true)); + } + for(boolean toss:new boolean[]{false,true}) { + final boolean t=toss; + check("creative_full_close_"+(t?"cancelled_toss":"rejected_spawn"),()->creativeFallback(t,false)); + check("creative_full_displacement_"+(t?"cancelled_toss":"rejected_spawn"),()->creativeFallback(t,true)); + } + for(int mode=0;mode<6;mode++) { final int m=mode;check("double_click_"+m,()->collect(m)); } + check("cancelled_close_preserves_hotbar_insertion_order",()->fallbackOrder(false)); + check("cancelled_close_preserves_offhand_merge",()->fallbackOrder(true)); + check("creative_clone_copies_complete_nbt",()->cloneStack(true,false)); + check("survival_cannot_clone",()->cloneStack(false,false)); + check("creative_clone_occupied_cursor_unchanged",()->cloneStack(true,true)); + check("creative_fill_drag_respects_limits_and_nbt",()->creativeDrag(true)); + check("survival_cannot_use_creative_fill_drag",()->creativeDrag(false)); + check("creative_inventory_packet_targets_player_not_corpse",()->creativePacket(true,false)); + check("survival_rejects_creative_inventory_packet",()->creativePacket(false,false)); + check("creative_cancelled_minted_drop_leaves_corpse_unchanged",()->creativePacket(true,true)); + for(int rejection=0;rejection<3;rejection++) {final int r=rejection;check("damaged_equipment_throw_"+r,()->toolThrow(r));} + check("throw_interrupts_pending_drag_without_dropping",()->interruptDrag(false)); + check("outside_click_interrupts_pending_drag_without_dropping",()->interruptDrag(true)); + try(PrintWriter out=new PrintWriter(new File(folder,"drop-creative-checks.txt"),"UTF-8")) { + for(String line:results)out.println(line);out.println("checks="+results.size()+" failures="+failures); + } + RuntimeChecks.require(failures==0,"Drop/creative regressions: "+failures+" / "+results.size()); + System.out.println("LOOTABLE_BODIES_DROP_CREATIVE_PASS checks="+results.size()); + } + private static void toolThrow(int rejection) throws Exception { + EntityLootableBody b=body();EntityPlayerMP p=player();open(p,b);b.setInventorySlotContents(4,item(4,1));Map before=total(b,p); + RejectedDrops reject=new RejectedDrops();reject.toss=rejection==1;reject.join=rejection==2;MinecraftForge.EVENT_BUS.register(reject); + try{packet(p,4,0,ClickType.THROW,null);}finally{MinecraftForge.EVENT_BUS.unregister(reject);} + conserved(before,b,p);equal(b.getStackInSlot(4),rejection==0?null:item(4,1),"Equipment throw changed damage/enchantment/custom data"); + } + private static void interruptDrag(boolean outside) throws Exception { + EntityLootableBody b=body();EntityPlayerMP p=player();open(p,b);b.setInventorySlotContents(6,item(120,17));p.inventory.setItemStack(item(121,10));Map before=total(b,p); + packet(p,-999,0,ClickType.QUICK_CRAFT,null);packet(p,7,1,ClickType.QUICK_CRAFT,null); + packet(p,outside?-999:6,0,outside?ClickType.PICKUP:ClickType.THROW,null); + equal(p.inventory.getItemStack(),item(121,10),"Interrupted drag dropped cursor");equal(b.getStackInSlot(6),item(120,17),"Interrupted drag threw source");conserved(before,b,p); + } + private static void throwing(boolean creative,int source,int button,int rejection,boolean capture) throws Exception { + EntityLootableBody b=body();EntityPlayerMP p=player();p.setGameType(creative?GameType.CREATIVE:GameType.SURVIVAL); + fill(b);fill(p);open(p,b);ItemStack original=item(120,17); + if(source==0)b.setInventorySlotContents(6,original.copy()); + else if(source==1)p.inventory.mainInventory.set(9,original.copy());else p.inventory.setItemStack(original.copy()); + if(capture){p.captureDrops=true;p.capturedDrops.add(new EntityItem(world,96.5,80,96.5,item(121,11)));} + Map before=total(b,p);RejectedDrops reject=new RejectedDrops();reject.toss=rejection==1;reject.join=rejection==2; + RecordingConnection connection=(RecordingConnection)p.connection;int initialSyncs=connection.resyncs; + MinecraftForge.EVENT_BUS.register(reject); + try{packet(p,source==2?-999:source==0?6:48,button,source==2?ClickType.PICKUP:ClickType.THROW,null);} + finally{MinecraftForge.EVENT_BUS.unregister(reject);} + conserved(before,b,p);RuntimeChecks.require(p.captureDrops==capture,"Toss changed capture state"); + int amount=source==2?(button==0?17:1):(button==0?1:17); + ItemStack remaining=rejection==0 && amount==17?null:item(120,rejection==0?17-amount:17); + equal(source==2?p.inventory.getItemStack():p.openContainer.getSlot(source==0?6:48).getStack(),remaining,"Drop quantity/source restoration differs"); + if(rejection!=0)RuntimeChecks.require(connection.resyncs>initialSyncs,"Rejected predicted drop did not resync initiating client"); + if(capture)RuntimeChecks.require(p.capturedDrops.size()==2,"Toss discarded pre-existing captured item"); + if(rejection==0 && !capture){ + int dropped=0;for(Entity e:world.loadedEntityList)if(e instanceof EntityItem && !e.isDead){equal(((EntityItem)e).getItem(),item(120,amount),"Accepted drop NBT differs");dropped++;} + RuntimeChecks.require(dropped==1,"Accepted toss did not create exactly one entity"); + } + } + private static void creativeFallback(boolean toss,boolean swap) throws Exception { + EntityLootableBody b=body();EntityPlayerMP p=player();fill(b);fill(p);p.setGameType(GameType.CREATIVE);open(p,b); + if(swap){b.setInventorySlotContents(0,item(0,1));p.inventory.mainInventory.set(0,pumpkins(64));} + else p.inventory.setItemStack(item(120,17)); + Map before=total(b,p);RejectedDrops reject=new RejectedDrops();reject.toss=toss;reject.join=!toss;MinecraftForge.EVENT_BUS.register(reject); + try{if(swap)packet(p,0,0,ClickType.SWAP,null);else close(p);}finally{MinecraftForge.EVENT_BUS.unregister(reject);} + conserved(before,b,p);RuntimeChecks.require(b.isEntityAlive(),"Creative fallback owner no longer alive"); + } + private static void collect(int mode) throws Exception { + EntityLootableBody b=body();EntityPlayerMP p=player(),q=player();open(p,b);open(q,b); + b.setInventorySlotContents(6,item(120,20));b.setInventorySlotContents(7,item(120,30));b.setInventorySlotContents(8,item(121,14)); + ItemStack differentDamage=item(120,12);differentDamage.setItemDamage((differentDamage.getItemDamage()+1)%16);b.setInventorySlotContents(9,differentDamage); + p.inventory.mainInventory.set(9,item(120,10));p.inventory.setItemStack(mode==3?ItemStack.EMPTY:item(120,mode==2?64:5)); + if(mode==4)p.setGameType(GameType.CREATIVE); + if(mode==5){q.inventory.setItemStack(item(120,4));packet(q,10,0,ClickType.PICKUP_ALL,null);} + Map before=total(b,p,q);int wanted=mode==3?0:mode==2?64:mode==5?15:64; + packet(p,10,mode==1?1:0,ClickType.PICKUP_ALL,null); + RuntimeChecks.require(((p.inventory.getItemStack()).isEmpty()?0:p.inventory.getItemStack().getCount())==wanted,"Double click count/capacity differs"); + equal(b.getStackInSlot(8),item(121,14),"Double click collected different custom NBT");equal(b.getStackInSlot(9),differentDamage,"Double click collected different damage"); + if(mode==0)equal(p.inventory.mainInventory.get(9),item(120,1),"Forward collection order differs"); + if(mode==1)equal(b.getStackInSlot(6),item(120,1),"Reverse collection order differs"); + conserved(before,b,p,q); + } + private static void fallbackOrder(boolean offhand) throws Exception { + EntityLootableBody b=body();EntityPlayerMP p=player();open(p,b); + if(offhand){fill(p);p.inventory.offHandInventory.set(0,item(120,60));} + p.inventory.setItemStack(item(120,10));Map before=total(b,p);RejectedDrops reject=new RejectedDrops();reject.toss=true;MinecraftForge.EVENT_BUS.register(reject); + try{close(p);}finally{MinecraftForge.EVENT_BUS.unregister(reject);} + equal(p.inventory.getStackInSlot(offhand?40:0),item(120,offhand?64:10),"Rejected-drop fallback changed vanilla player insertion priority");conserved(before,b,p); + } + private static void cloneStack(boolean creative,boolean occupied) throws Exception { + EntityLootableBody b=body();EntityPlayerMP p=player();p.setGameType(creative?GameType.CREATIVE:GameType.SURVIVAL);open(p,b);b.setInventorySlotContents(6,item(120,17)); + if(occupied)p.inventory.setItemStack(item(121,4));Map expected=total(b,p); + if(creative && !occupied)add(expected,item(120,64));packet(p,6,2,ClickType.CLONE,null); + equal(b.getStackInSlot(6),item(120,17),"Creative clone removed source");equal(p.inventory.getItemStack(),occupied?item(121,4):creative?item(120,64):null,"Clone cursor/NBT differs");conserved(expected,b,p); + } + private static void creativeDrag(boolean creative) throws Exception { + EntityLootableBody b=body();EntityPlayerMP p=player();p.setGameType(creative?GameType.CREATIVE:GameType.SURVIVAL);open(p,b);p.inventory.setItemStack(pumpkins(64));Map expected=total(b,p); + if(creative){add(expected,pumpkins(65));} // 1 armor + two main stacks, with original cursor consumed by vanilla 1.10. + drag(p,2,0,6,7);equal(b.getStackInSlot(0),creative?pumpkins(1):null,"Creative fill armor capacity differs"); + equal(b.getStackInSlot(6),creative?pumpkins(64):null,"Creative fill main capacity differs");equal(b.getStackInSlot(7),creative?pumpkins(64):null,"Creative fill second main differs");conserved(expected,b,p); + } + private static void creativePacket(boolean creative,boolean cancelled) throws Exception { + EntityLootableBody b=body();EntityPlayerMP p=player();p.setGameType(creative?GameType.CREATIVE:GameType.SURVIVAL);open(p,b);b.setInventorySlotContents(6,item(120,17));NBTTagCompound unchanged=data(b); + RejectedDrops reject=new RejectedDrops();reject.toss=cancelled;MinecraftForge.EVENT_BUS.register(reject);PacketBuffer wire=new PacketBuffer(Unpooled.buffer()); + try{wire.writeShort(cancelled?-1:9);wire.writeItemStack(item(121,23));CPacketCreativeInventoryAction packet=new CPacketCreativeInventoryAction();packet.readPacketData(wire);p.connection.processCreativeInventoryAction(packet);} + finally{wire.release();MinecraftForge.EVENT_BUS.unregister(reject);} + RuntimeChecks.require(unchanged.equals(data(b)),"Creative inventory packet changed corpse instead of player");equal(p.inventory.mainInventory.get(9),creative&&!cancelled?item(121,23):null,"Creative player packet permission/target differs"); + if(cancelled)RuntimeChecks.require(world.loadedEntityList.stream().noneMatch(e->e instanceof EntityItem && !e.isDead),"Cancelled creative minted drop spawned anyway"); + } + + private static void check(String name,Check test) { + Set before=new HashSet<>(world.loadedEntityList); + try {test.run();results.add("PASS "+name);System.out.println("CONTAINER_CHECK_PASS "+name);} + catch(Throwable error){failures++;results.add("FAIL "+name+": "+error);System.out.println("CONTAINER_CHECK_FAIL "+name+": "+error);error.printStackTrace();} + finally{for(Entity entity:new ArrayList(world.loadedEntityList))if(!before.contains(entity))world.removeEntityDangerously(entity);} + } + private static EntityPlayerMP player() { + EntityPlayerMP p=new EntityPlayerMP(server,world, + new GameProfile(UUID.nameUUIDFromBytes(("container-player-"+sequence++).getBytes(java.nio.charset.StandardCharsets.UTF_8)),"ContainerFixture"), + new net.minecraft.server.management.PlayerInteractionManager(world)); + p.connection=new RecordingConnection(p);p.setGameType(GameType.SURVIVAL); + p.setPosition(96.5,80,96.5);world.getChunk(6,6);return p; + } + private static EntityLootableBody body(){ + EntityLootableBody b=new EntityLootableBody(world);b.setPosition(96.5,80,96.5);b.setNoGravity(true);RuntimeChecks.disableVacuum(b); + world.getChunk(6,6);RuntimeChecks.require(world.spawnEntity(b),"Container corpse spawn");return b; + } + private static CorpseContainer open(EntityPlayerMP p,EntityLootableBody b){ + CorpseContainer c=new CorpseContainer(p.inventory,b);c.windowId=7;p.openContainer=c;c.addListener(p);return c; + } + private static ItemStack item(int id,int count){ItemStack s=RuntimeChecks.stack(id);s.setCount(count);return s;} + private static ItemStack pumpkins(int count){ItemStack s=new ItemStack(Blocks.PUMPKIN,count);s.setTagCompound(item(120,1).getTagCompound().copy());return s;} + private static void fill(EntityPlayerMP p){for(int i=0;i<36;i++)p.inventory.mainInventory.set(i,item(100+i,64));} + private static void fill(EntityLootableBody b){for(int i=0;i<48;i++)b.setInventorySlotContents(i,item(i,i<6?1:64));b.addItem(item(70,31));} + private static void packet(EntityPlayerMP p,int slot,int button,ClickType type,ItemStack predicted) throws Exception { + PacketBuffer wire=new PacketBuffer(Unpooled.buffer()); + try{ + wire.writeByte(p.openContainer.windowId);wire.writeShort(slot);wire.writeByte(button);wire.writeShort(++transaction); + wire.writeEnumValue(type);wire.writeItemStack(predicted == null ? ItemStack.EMPTY : predicted); + CPacketClickWindow packet=new CPacketClickWindow();packet.readPacketData(wire);p.connection.processClickWindow(packet); + } finally{wire.release();} + } + private static void click(EntityPlayerMP p,int slot,int button,ClickType type) throws Exception { + ItemStack predicted=null; + if((type==ClickType.PICKUP || type==ClickType.QUICK_MOVE) && slot>=0){ + ItemStack s=p.openContainer.getSlot(slot).getStack();predicted=s.copy(); + } + packet(p,slot,button,type,predicted); + } + private static void close(EntityPlayerMP p) throws Exception { + PacketBuffer wire=new PacketBuffer(Unpooled.buffer()); + try{wire.writeByte(p.openContainer.windowId);CPacketCloseWindow packet=new CPacketCloseWindow();packet.readPacketData(wire);p.connection.processCloseWindow(packet);} + finally{wire.release();} + } + private static void drag(EntityPlayerMP p,int mode,int... slots) throws Exception { + packet(p,-999,mode<<2,ClickType.QUICK_CRAFT,null); + for(int slot:slots)packet(p,slot,1|(mode<<2),ClickType.QUICK_CRAFT,null); + packet(p,-999,2|(mode<<2),ClickType.QUICK_CRAFT,null); + } + private static void equal(ItemStack actual,ItemStack expected,String reason){RuntimeChecks.require(RuntimeChecks.sameStack(actual == null ? ItemStack.EMPTY : actual,expected == null ? ItemStack.EMPTY : expected),reason);} + private static NBTTagCompound data(EntityLootableBody b){NBTTagCompound n=new NBTTagCompound();b.writeEntityToNBT(n);return n;} + private static void add(Map all,ItemStack s){ + if((s == null || s.isEmpty()) || s.getCount()<=0)return;NBTTagCompound n=s.writeToNBT(new NBTTagCompound());n.removeTag("Count"); + String key=n.toString();all.put(key,all.getOrDefault(key,0)+s.getCount()); + } + private static Map total(EntityLootableBody b,EntityPlayerMP... players){ + Map all=new TreeMap<>(); + for(int i=0;i<48;i++)add(all,b.getStackInSlot(i));NBTTagList aux=data(b).getTagList("Aux",10); + for(int i=0;i expected,EntityLootableBody b,EntityPlayerMP... players){ + RuntimeChecks.require(expected.equals(total(b,players)),"Item count/type/damage/custom NBT lost or duplicated"); + NBTTagCompound before=data(b),immutable=before.copy();EntityLootableBody copy=new EntityLootableBody(world);copy.readEntityFromNBT(before); + RuntimeChecks.require(Objects.equals(before.getTag("Equipment"),data(copy).getTag("Equipment")) && Objects.equals(before.getTag("Aux"),data(copy).getTag("Aux")),"Post-interaction NBT round trip changed inventory"); + RuntimeChecks.require(before.equals(immutable),"Post-interaction reader mutated saved input"); + } + private static void hotbar() throws Exception { + EntityLootableBody b=body();EntityPlayerMP p=player();open(p,b); + for(int key=0;key<9;key++){ + p.inventory.mainInventory.set(key,item(30+key,13));Map before=total(b,p); + click(p,6,key,ClickType.SWAP);equal(b.getStackInSlot(6),item(30+key,13),"Empty hotbar swap failed");equal(p.inventory.mainInventory.get(key),null,"Hotbar source remained"); + click(p,6,key,ClickType.SWAP);equal(b.getStackInSlot(6),null,"Hotbar return left source"); + b.setInventorySlotContents(6,item(19,23));before=total(b,p); + click(p,6,key,ClickType.SWAP);equal(b.getStackInSlot(6),item(30+key,13),"Occupied hotbar incoming differs");equal(p.inventory.mainInventory.get(key),item(19,23),"Occupied hotbar outgoing differs"); + click(p,6,key,ClickType.SWAP);conserved(before,b,p);b.setInventorySlotContents(6,null);p.inventory.mainInventory.set(key,ItemStack.EMPTY); + } + } + private static void armorSwap() throws Exception { + EntityLootableBody b=body();EntityPlayerMP p=player();open(p,b);p.inventory.mainInventory.set(0,item(4,1)); + click(p,0,0,ClickType.SWAP);equal(b.getStackInSlot(0),null,"Sword inserted into armor slot"); + p.inventory.mainInventory.set(0,pumpkins(64));Map before=total(b,p);click(p,0,0,ClickType.SWAP); + equal(b.getStackInSlot(0),pumpkins(1),"Hotbar armor limit ignored");equal(p.inventory.mainInventory.get(0),pumpkins(63),"Hotbar armor remainder lost");conserved(before,b,p); + } + private static void wearableShift() throws Exception { + EntityLootableBody b=body();EntityPlayerMP p=player();p.inventory.mainInventory.set(9,pumpkins(64));open(p,b); + Map before=total(b,p);click(p,48,0,ClickType.QUICK_MOVE); + RuntimeChecks.require(!(b.getStackInSlot(0)).isEmpty() && b.getStackInSlot(0).getCount()==1,"Shift-click put a stack into the one-item head slot"); + conserved(before,b,p); + } + private static void dragDistribution(int mode) throws Exception { + EntityLootableBody b=body();EntityPlayerMP p=player();open(p,b);p.inventory.setItemStack(item(19,10));Map before=total(b,p); + drag(p,mode,6,7,8);int each=mode==0?3:1; + for(int i=6;i<=8;i++)equal(b.getStackInSlot(i),item(19,each),"Drag distribution differs"); + equal(p.inventory.getItemStack(),item(19,10-3*each),"Drag cursor remainder differs");conserved(before,b,p); + } + private static void dragMixed() throws Exception { + EntityLootableBody b=body();b.setInventorySlotContents(6,item(19,64));b.setInventorySlotContents(7,item(19,63));b.setInventorySlotContents(8,item(20,11)); + EntityPlayerMP p=player();open(p,b);p.inventory.setItemStack(item(19,12));Map before=total(b,p); + drag(p,0,0,6,7,8,9,48);equal(b.getStackInSlot(0),null,"Drag bypassed armor validity");equal(b.getStackInSlot(6),item(19,64),"Drag changed saturated slot");equal(b.getStackInSlot(8),item(20,11),"Drag merged different NBT"); + RuntimeChecks.require(b.getStackInSlot(7).getCount()<=64,"Drag overfilled partial slot");conserved(before,b,p); + } + private static void dragArmor() throws Exception { + EntityLootableBody b=body();EntityPlayerMP p=player();open(p,b);p.inventory.setItemStack(pumpkins(64));Map before=total(b,p); + drag(p,0,0,6);equal(b.getStackInSlot(0),pumpkins(1),"Drag head limit ignored");equal(b.getStackInSlot(6),pumpkins(32),"Drag main allocation differs");equal(p.inventory.getItemStack(),pumpkins(31),"Drag head remainder lost");conserved(before,b,p); + } + private static void dragCancel() throws Exception { + EntityLootableBody b=body();EntityPlayerMP p=player();open(p,b);p.inventory.setItemStack(item(19,10));Map before=total(b,p); + packet(p,-999,2,ClickType.QUICK_CRAFT,null);packet(p,-999,0,ClickType.QUICK_CRAFT,null);packet(p,6,1,ClickType.QUICK_CRAFT,null); + packet(p,48,0,ClickType.PICKUP,null);packet(p,-999,2,ClickType.QUICK_CRAFT,null); + equal(b.getStackInSlot(6),null,"Cancelled drag wrote selected slot");equal(p.inventory.getItemStack(),item(19,10),"Cancelled drag consumed cursor");conserved(before,b,p); + } + private static void fullPlayer() throws Exception { + EntityLootableBody b=body();b.setInventorySlotContents(6,item(19,23));EntityPlayerMP p=player();fill(p);open(p,b); + Map before=total(b,p);packet(p,6,0,ClickType.QUICK_MOVE,null);equal(b.getStackInSlot(6),item(19,23),"Full player inventory consumed corpse stack");conserved(before,b,p); + } + private static void partialPlayer() throws Exception { + EntityLootableBody b=body();b.setInventorySlotContents(6,item(19,23));EntityPlayerMP p=player();fill(p);p.inventory.mainInventory.set(35,item(19,63));open(p,b); + Map before=total(b,p);click(p,6,0,ClickType.QUICK_MOVE);equal(p.inventory.mainInventory.get(35),item(19,64),"Partial merge did not fill one space");equal(b.getStackInSlot(6),item(19,22),"Partial merge discarded remainder");conserved(before,b,p); + } + private static void fullCorpse() throws Exception { + EntityLootableBody b=body();fill(b);EntityPlayerMP p=player();p.inventory.mainInventory.set(9,item(110,23));open(p,b); + NBTTagCompound unchanged=data(b);Map before=total(b,p);packet(p,48,0,ClickType.QUICK_MOVE,null);b.onEntityUpdate(); + RuntimeChecks.require(unchanged.getTag("Equipment").equals(data(b).getTag("Equipment")) && unchanged.getTag("Aux").equals(data(b).getTag("Aux")),"Full corpse overwrote indexed/Aux contents");conserved(before,b,p); + } + public static final class RejectedDrops { + boolean join,toss; + @SubscribeEvent public void join(EntityJoinWorldEvent e){if(join && e.getEntity() instanceof EntityItem)e.setCanceled(true);} + @SubscribeEvent public void toss(ItemTossEvent e){if(toss)e.setCanceled(true);} + } + private static void displacement(boolean rejectJoin,boolean rejectToss) throws Exception { + EntityLootableBody b=body();b.setInventorySlotContents(0,item(0,1));EntityPlayerMP p=player();fill(p);p.inventory.mainInventory.set(0,pumpkins(64));open(p,b);Map before=total(b,p); + RejectedDrops reject=new RejectedDrops();reject.join=rejectJoin;reject.toss=rejectToss;MinecraftForge.EVENT_BUS.register(reject); + try{click(p,0,0,ClickType.SWAP);}finally{MinecraftForge.EVENT_BUS.unregister(reject);} + equal(b.getStackInSlot(0),pumpkins(1),"Displacement changed normal incoming swap");equal(p.inventory.mainInventory.get(0),pumpkins(63),"Displacement changed hotbar remainder");conserved(before,b,p); + if(!rejectJoin && !rejectToss)RuntimeChecks.require(world.loadedEntityList.stream().anyMatch(e->e instanceof EntityItem && !e.isDead),"Successful displacement no longer drops normally"); + } + private static void closeCursor(boolean rejected,boolean full) throws Exception { + EntityLootableBody b=body();EntityPlayerMP p=player();if(full){fill(b);fill(p);}open(p,b);p.inventory.setItemStack(item(120,17));Map before=total(b,p); + RejectedDrops reject=new RejectedDrops();reject.join=rejected;MinecraftForge.EVENT_BUS.register(reject); + try{close(p);}finally{MinecraftForge.EVENT_BUS.unregister(reject);} + equal(p.inventory.getItemStack(),null,"Closing left a nonsaved cursor stack");conserved(before,b,p); + if(!rejected)RuntimeChecks.require(world.loadedEntityList.stream().anyMatch(e->e instanceof EntityItem && !e.isDead),"Cursor close no longer performs normal accepted drop"); + } + private static void capturedCursor() throws Exception { + EntityLootableBody b=body();EntityPlayerMP p=player();open(p,b);p.inventory.setItemStack(item(120,17)); + p.captureDrops=true;p.capturedDrops.add(new EntityItem(world,96.5,80,96.5,item(121,11)));Map before=total(b,p); + close(p);RuntimeChecks.require(p.captureDrops,"Container close changed caller's drop capture state");conserved(before,b,p); + } + private static void dyingCursor() throws Exception { + EntityLootableBody b=body();EntityPlayerMP p=player();fill(p);open(p,b);p.inventory.setItemStack(item(120,17)); + b.onKillCommand();b.onEntityUpdate();Map before=total(b,p); + RejectedDrops reject=new RejectedDrops();reject.join=true;MinecraftForge.EVENT_BUS.register(reject); + try{close(p);}finally{MinecraftForge.EVENT_BUS.unregister(reject);} + for(int i=0;i<15;i++)b.onEntityUpdate(); + RuntimeChecks.require(b.isEntityAlive() || b.isEmpty(),"Cursor fallback was put into an owner still scheduled for removal"); + conserved(before,b,p); + } + private static void twoPlayerShift() throws Exception { + for(int order=0;order<2;order++){ + EntityLootableBody b=body();b.setInventorySlotContents(6,item(19,23));EntityPlayerMP a=player(),c=player();open(a,b);open(c,b); + EntityPlayerMP first=order==0?a:c,second=order==0?c:a;Map before=total(b,a,c); + click(first,6,0,ClickType.QUICK_MOVE);packet(second,6,0,ClickType.QUICK_MOVE,item(19,23)); + a.openContainer.detectAndSendChanges();c.openContainer.detectAndSendChanges(); + RuntimeChecks.require(!second.openContainer.getCanCraft(second),"Stale transaction was not rejected"); + RuntimeChecks.require(((RecordingConnection)second.connection).resyncs>0 && ((RecordingConnection)second.connection).rejections>0,"Stale viewer did not receive inventory resync"); + // Accepted clicks rely on the initiating client's prediction; only the + // rejected stale viewer requires an authoritative window resync. + equal(first.openContainer.getSlot(6).getStack(),null,"First server container remained stale"); + equal(((RecordingConnection)second.connection).slots.get(6),null,"Rejected viewer source remained stale");conserved(before,b,a,c); + } + } + private static void interleavedDrag(boolean swap) throws Exception { + EntityLootableBody b=body();EntityPlayerMP a=player(),c=player();open(a,b);open(c,b);a.inventory.setItemStack(item(19,10)); + if(swap)c.inventory.mainInventory.set(0,item(20,7));else c.inventory.setItemStack(item(20,7));Map before=total(b,a,c); + packet(a,-999,0,ClickType.QUICK_CRAFT,null);packet(a,6,1,ClickType.QUICK_CRAFT,null);packet(a,7,1,ClickType.QUICK_CRAFT,null); + click(c,6,0,swap?ClickType.SWAP:ClickType.PICKUP);packet(a,-999,2,ClickType.QUICK_CRAFT,null); + equal(b.getStackInSlot(6),item(20,7),"Other player's stack was overwritten by drag");equal(b.getStackInSlot(7),item(19,5),"Surviving drag slot differs");equal(a.inventory.getItemStack(),item(19,5),"Interrupted drag lost remainder");conserved(before,b,a,c); + } + private static void twoPlayerClose() throws Exception { + EntityLootableBody b=body();b.setInventorySlotContents(6,item(19,23));EntityPlayerMP a=player(),c=player();fill(a);open(a,b);open(c,b);Map before=total(b,a,c); + click(a,6,0,ClickType.PICKUP);packet(c,6,0,ClickType.QUICK_MOVE,item(19,23));RejectedDrops reject=new RejectedDrops();reject.join=true;MinecraftForge.EVENT_BUS.register(reject); + try{close(a);}finally{MinecraftForge.EVENT_BUS.unregister(reject);} + c.openContainer.detectAndSendChanges();conserved(before,b,a,c); + } + private static void stale(boolean removed) throws Exception { + EntityLootableBody b=body();b.setInventorySlotContents(6,item(19,23));EntityPlayerMP p=player();open(p,b);Map before=total(b,p); + if(removed)b.setDead();else p.setPosition(200,80,200); + packet(p,6,0,ClickType.QUICK_MOVE,null);equal(b.getStackInSlot(6),item(19,23),"Stale packet extracted before tick closed GUI");conserved(before,b,p); + } + private static void staleDrag() throws Exception { + EntityLootableBody b=body();EntityPlayerMP p=player();open(p,b);p.inventory.setItemStack(item(19,10));Map before=total(b,p); + packet(p,-999,0,ClickType.QUICK_CRAFT,null);packet(p,6,1,ClickType.QUICK_CRAFT,null);p.setPosition(200,80,200); + packet(p,-999,2,ClickType.QUICK_CRAFT,null);equal(b.getStackInSlot(6),null,"Distant drag still distributed items"); + p.setPosition(96.5,80,96.5);drag(p,0,7);equal(b.getStackInSlot(7),item(19,10),"New drag reused invalid selection");conserved(before,b,p); + } + private static final class RecordingConnection extends NetHandlerPlayServer { + final Map slots=new HashMap<>();int resyncs,rejections; + RecordingConnection(EntityPlayerMP p){super(server,new NetworkManager(EnumPacketDirection.SERVERBOUND),p);} + @Override public void sendPacket(Packet packet){ + if(!(packet instanceof SPacketWindowItems || packet instanceof SPacketSetSlot || packet instanceof SPacketConfirmTransaction))return; + PacketBuffer wire=new PacketBuffer(Unpooled.buffer()); + try{packet.writePacketData(wire);int window=wire.readByte(); + if(packet instanceof SPacketWindowItems){int n=wire.readShort();for(int i=0;i=0)slots.put(slot,s);} + else{wire.readShort();if(!wire.readBoolean())rejections++;} + }catch(IOException error){throw new RuntimeException(error);}finally{wire.release();} + } + } +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/DropClientChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/DropClientChecks.java new file mode 100644 index 0000000..4b900a6 --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/DropClientChecks.java @@ -0,0 +1,118 @@ +package cyano.lootable.buildtest; + +import cyano.lootable.entities.EntityLootableBody; +import cyano.lootable.graphics.CorpseGUIContainer; +import java.io.*; +import java.util.*; +import java.util.concurrent.Future; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiMainMenu; +import net.minecraft.entity.Entity; +import net.minecraft.entity.item.EntityItem; +import net.minecraft.entity.player.EntityPlayerMP; +import net.minecraft.init.Blocks; +import net.minecraft.inventory.ClickType; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.*; +import net.minecraft.util.EnumHand; +import net.minecraft.util.ScreenShotHelper; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.GameType; +import net.minecraftforge.common.MinecraftForge; + +/** Actual connected client predictions, authoritative corrections and disk reload. */ +final class DropClientChecks { + private interface Action {void run() throws Exception;} + private final UUID bodyId=UUID.fromString("00000000-0000-4000-8000-000000000109"); + private final ContainerInteractionChecks.RejectedDrops reject=new ContainerInteractionChecks.RejectedDrops(); + private int stage,frames; + private Future pending; + private UUID playerId; + private NBTTagCompound savedBody; + private NBTTagList savedPlayer; + void start(Minecraft mc){playerId=mc.player.getUniqueID();} + boolean tick(Minecraft mc) throws Exception { + frames++; + if(stage==0){schedule(mc,()->prepare(mc));next(1);} + else if(stage==1 && done()){if(open(mc))next(2);} + else if(stage==2 && gui(mc) && frames>20){ + equal(slot(mc,6),item(120,17),"Initial client source");click(mc,6,0,ClickType.THROW);next(3); + } else if(stage==3 && frames>20){ + equal(slot(mc,6),item(120,16),"Accepted client single throw"); + schedule(mc,()->{equal(body(mc).getStackInSlot(6),item(120,16),"Accepted server single throw");drop(mc,item(120,1));mark(mc,"network_accepted_throw");reject.toss=true;MinecraftForge.EVENT_BUS.register(reject);});next(4); + } else if(stage==4 && done()) {click(mc,6,1,ClickType.THROW);next(5);} + else if(stage==5 && frames>20){ + equal(slot(mc,6),item(120,16),"Cancelled throw did not correct client source");screenshot(mc,"drop-cancelled-source-resync.png"); + schedule(mc,()->{equal(body(mc).getStackInSlot(6),item(120,16),"Cancelled server throw");mark(mc,"network_cancelled_throw_source_resync");reject.toss=false;reject.join=true;});next(6); + } else if(stage==6 && done()){click(mc,6,0,ClickType.PICKUP);next(7);} + else if(stage==7 && frames>20){equal(mc.player.inventory.getItemStack(),item(120,16),"Pickup cursor");click(mc,-999,1,ClickType.PICKUP);next(8);} + else if(stage==8 && frames>20){ + equal(mc.player.inventory.getItemStack(),item(120,16),"Rejected outside drop did not correct client cursor");equal(slot(mc,6),null,"Outside source reappeared");screenshot(mc,"drop-rejected-cursor-resync.png"); + schedule(mc,()->{equal(player(mc).inventory.getItemStack(),item(120,16),"Rejected server outside cursor");mark(mc,"network_rejected_outside_cursor_resync");reject.join=false;});next(9); + } else if(stage==9 && done()){click(mc,-999,0,ClickType.PICKUP);next(10);} + else if(stage==10 && frames>20){ + equal(mc.player.inventory.getItemStack(),null,"Accepted outside client cursor"); + schedule(mc,()->{drop(mc,item(120,16));equal(player(mc).inventory.getItemStack(),null,"Accepted outside server cursor");mark(mc,"network_accepted_outside_drop"); + body(mc).setInventorySlotContents(6,item(120,17));player(mc).inventory.mainInventory.set(9,item(120,5));refresh(mc);});next(11); + } else if(stage==11 && done() && frames>20){click(mc,48,0,ClickType.PICKUP);click(mc,10,0,ClickType.PICKUP_ALL);next(12);} + else if(stage==12 && frames>20){ + equal(mc.player.inventory.getItemStack(),item(120,42),"Double-click client cursor");equal(slot(mc,8),item(121,10),"Double-click changed unlike NBT"); + schedule(mc,()->{equal(player(mc).inventory.getItemStack(),item(120,42),"Double-click server cursor");equal(body(mc).getStackInSlot(6),null,"Double-click source 6");equal(body(mc).getStackInSlot(7),null,"Double-click source 7");mark(mc,"network_double_click_exact_nbt");});next(13); + } else if(stage==13 && done()){click(mc,48,0,ClickType.PICKUP);next(14);} + else if(stage==14 && frames>20){schedule(mc,()->{player(mc).setGameType(GameType.CREATIVE);body(mc).setInventorySlotContents(6,item(120,17));refresh(mc);});next(15);} + else if(stage==15 && done() && frames>20){RuntimeChecks.require(mc.player.capabilities.isCreativeMode,"Client not creative");click(mc,6,2,ClickType.CLONE);next(16);} + else if(stage==16 && frames>20){ + equal(mc.player.inventory.getItemStack(),item(120,64),"Creative clone client NBT");equal(slot(mc,6),item(120,17),"Creative clone changed source"); + click(mc,-999,8,ClickType.QUICK_CRAFT);click(mc,7,9,ClickType.QUICK_CRAFT);click(mc,9,9,ClickType.QUICK_CRAFT);click(mc,-999,10,ClickType.QUICK_CRAFT);next(17); + } else if(stage==17 && frames>20){ + equal(slot(mc,7),item(120,64),"Creative client fill first");equal(slot(mc,9),item(120,64),"Creative client fill second");equal(mc.player.inventory.getItemStack(),null,"Creative fill vanilla cursor");screenshot(mc,"drop-creative-filled.png"); + schedule(mc,()->{equal(body(mc).getStackInSlot(7),item(120,64),"Creative server fill first");equal(body(mc).getStackInSlot(9),item(120,64),"Creative server fill second");equal(player(mc).inventory.mainInventory.get(9),item(120,42),"Creative operation changed recovered inventory");mark(mc,"network_creative_clone_and_fill"); + for(int i=0;i<48;i++)body(mc).setInventorySlotContents(i,item(i,i<6?1:64));body(mc).addItem(item(70,31)); + for(int i=0;i<36;i++)player(mc).inventory.mainInventory.set(i,item(100+i,64));player(mc).inventory.setItemStack(item(122,17));reject.toss=true;refresh(mc);});next(18); + } else if(stage==18 && done() && frames>20){equal(mc.player.inventory.getItemStack(),item(122,17),"Creative close cursor fixture");mc.player.closeScreen();next(19);} + else if(stage==19 && frames>20){ + equal(mc.player.inventory.getItemStack(),null,"Closed creative client cursor"); + schedule(mc,()->{equal(player(mc).inventory.getItemStack(),null,"Closed creative server cursor");NBTTagCompound data=new NBTTagCompound();body(mc).writeEntityToNBT(data);NBTTagList aux=data.getTagList("Aux",10);int count=0; + for(int i=0;i20){schedule(mc,()->{ + player(mc).world.getChunk(2,0);body(mc).onEntityUpdate();RuntimeChecks.verifyExpected(folder(mc),"drop-creative-final",body(mc)); + RuntimeChecks.require(savedPlayer.equals(player(mc).inventory.writeToNBT(new NBTTagList())),"Creative player inventory changed on disk reload");player(mc).connection.setPlayerLocation(40.5,80,7.5,0,0); + mark(mc,"creative_cancelled_close_full_inventory_disk_reload");});next(23); + } else if(stage==23 && done()){if(open(mc))next(24);} + else if(stage==24 && gui(mc) && frames>20){ + ItemStack[] expected=new ItemStack[48];NBTTagList list=savedBody.getTagList("Equipment",10);for(int i=0;i(player(mc).world.loadedEntityList))if(e instanceof EntityItem && !e.isDead){equal(((EntityItem)e).getItem(),expected,"Accepted world drop NBT/count");count++;player(mc).world.removeEntityDangerously(e);}RuntimeChecks.require(count==1,"Expected exactly one accepted drop");} + private static ItemStack slot(Minecraft mc,int index){return mc.player.openContainer.getSlot(index).getStack();} + private static void click(Minecraft mc,int slot,int button,ClickType type){mc.playerController.windowClick(mc.player.openContainer.windowId,slot,button,type,mc.player);} + private static boolean gui(Minecraft mc){return mc.currentScreen instanceof CorpseGUIContainer;} + private static boolean ready(Minecraft mc){return mc.world!=null && mc.player!=null && mc.getIntegratedServer()!=null;} + private boolean open(Minecraft mc){for(Entity e:mc.world.loadedEntityList)if(e.getUniqueID().equals(bodyId)){mc.playerController.interactWithEntity(mc.player,e,EnumHand.MAIN_HAND);return true;}return false;} + private EntityPlayerMP player(Minecraft mc){return mc.getIntegratedServer().getPlayerList().getPlayerByUUID(playerId);} + private EntityLootableBody body(Minecraft mc){return RuntimeChecks.find(mc.getIntegratedServer().getWorld(0),bodyId);} + private File folder(Minecraft mc){return player(mc).world.getSaveHandler().getWorldDirectory();} + private void refresh(Minecraft mc){player(mc).openContainer.detectAndSendChanges();player(mc).sendContainerToPlayer(player(mc).openContainer);} + private void schedule(Minecraft mc,Action action){pending=mc.getIntegratedServer().addScheduledTask(()->{try{action.run();}catch(Exception e){throw new RuntimeException(e);}});} + private boolean done() throws Exception {if(!pending.isDone())return false;pending.get();return true;} + private void next(int value){stage=value;frames=0;System.out.println("DROP_CLIENT_STAGE "+stage);} + private static void screenshot(Minecraft mc,String name){ScreenShotHelper.saveScreenshot(mc.gameDir,name,mc.displayWidth,mc.displayHeight,mc.getFramebuffer());} + private void mark(Minecraft mc,String name) throws Exception {try(FileWriter out=new FileWriter(new File(mc.gameDir,"drop-client-checks.txt"),true)){out.write("PASS "+name+"\n");}System.out.println("DROP_CLIENT_CHECK_PASS "+name);} + private static void quit(Minecraft mc){mc.player.closeScreen();mc.getIntegratedServer().initiateShutdown();} +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/EffectHurtChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/EffectHurtChecks.java new file mode 100644 index 0000000..52748c6 --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/EffectHurtChecks.java @@ -0,0 +1,104 @@ +package cyano.lootable.buildtest; + +import cyano.lootable.entities.EntityLootableBody; +import java.io.*; +import java.util.*; +import net.minecraft.entity.SharedMonsterAttributes; +import net.minecraft.init.MobEffects; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.*; +import net.minecraft.potion.*; +import net.minecraft.server.MinecraftServer; +import net.minecraft.world.WorldServer; + +/** Real legacy readers, ticking and round trips; no production test dependency. */ +final class EffectHurtChecks { + private interface Check {void run() throws Exception;} + private static WorldServer world; + private static int failures; + private static final List results=new ArrayList<>(); + private static Potion extended; + static void registerPotion(){ + RuntimeChecks.require(Potion.getPotionById(200)==null,"Fixture potion ID already occupied"); + extended=new Potion(false,0x123456){}; + // Forge 1.12 assigns IDs through registry events. Fill the isolated test + // registry through its public API so unsigned-byte coverage still uses 200. + for(int i=0;Potion.getPotionById(199)==null && i<200;i++) + net.minecraftforge.fml.common.registry.ForgeRegistries.POTIONS.register(new Potion(false,0){}.setRegistryName("lootablebodiesbuildtest:padding_"+i)); + net.minecraftforge.fml.common.registry.ForgeRegistries.POTIONS.register(extended.setRegistryName("lootablebodiesbuildtest:extended_effect")); + RuntimeChecks.require(Potion.getIdFromPotion(extended)==200,"Fixture potion must exercise unsigned byte ID 200"); + } + static void run(MinecraftServer server,WorldServer target,File folder) throws Exception { + world=target;failures=0;results.clear(); + NBTBase[] wrong={new NBTTagString("opaque"),new NBTTagCompound(),new NBTTagByteArray(new byte[]{1}),new NBTTagIntArray(new int[]{1}),new NBTTagList()}; + for(int i=0;ibadHurt(raw));} + for(NBTBase raw:new NBTBase[]{new NBTTagShort((short)-1),new NBTTagInt(32768),new NBTTagLong(65537),new NBTTagLong(Long.MAX_VALUE),new NBTTagFloat(2),new NBTTagDouble(2.5),new NBTTagDouble(Double.NaN),new NBTTagDouble(Double.POSITIVE_INFINITY)})check("HurtTime_invalid_"+raw,()->badHurt(raw)); + for(NBTBase raw:new NBTBase[]{new NBTTagByte((byte)7),new NBTTagShort((short)10),new NBTTagInt(11),new NBTTagLong(32767)})check("HurtTime_lossless_"+raw,()->validHurt(raw)); + check("missing_hurt_time_defaults_zero",()->{NBTTagCompound n=base();n.removeTag("HurtTime");EntityLootableBody b=read(n);require(b.hurtTime==0,"Missing hurt time not zero");stable(b);}); + for(int i=0;ibadList(raw));} + check("ActiveEffects_wrong_element_type",()->{NBTTagList l=new NBTTagList();l.appendTag(new NBTTagString("opaque"));badList(l);}); + check("empty_and_missing_effects_keep_defaults",()->{for(boolean empty:new boolean[]{false,true}){NBTTagCompound n=base();if(empty)n.setTag("ActiveEffects",new NBTTagList());EntityLootableBody b=read(n);require(b.getActivePotionEffects().isEmpty(),"Unexpected default effect");require(!saved(b).hasKey(NameLifetimeChecks.RECOVERY),"Healthy empty effects quarantined");stable(b);}}); + for(String key:new String[]{"Id","Duration","Amplifier"})for(int i=0;ibadEffect(k,raw));} + for(NBTBase raw:new NBTBase[]{new NBTTagInt(-1),new NBTTagInt(256),new NBTTagLong(4294967297L),new NBTTagDouble(1),new NBTTagByte((byte)201)})check("Id_invalid_or_absent_registry_"+raw,()->badEffect("Id",raw)); + check("Id_missing_retains_record",()->badEffect("Id",null)); + for(NBTBase raw:new NBTBase[]{new NBTTagInt(-1),new NBTTagLong(2147483648L),new NBTTagLong(4294967496L),new NBTTagFloat(100),new NBTTagDouble(Double.NaN),new NBTTagDouble(Double.NEGATIVE_INFINITY)})check("Duration_invalid_"+raw,()->badEffect("Duration",raw)); + for(NBTBase raw:new NBTBase[]{new NBTTagShort((short)-1),new NBTTagInt(128),new NBTTagInt(256),new NBTTagLong(4294967296L),new NBTTagFloat(1),new NBTTagDouble(Double.POSITIVE_INFINITY)})check("Amplifier_invalid_"+raw,()->badEffect("Amplifier",raw)); + for(String key:new String[]{"Ambient","ShowParticles"}){ + for(int i=0;ibadFlag(k,raw));} + for(NBTBase raw:new NBTBase[]{new NBTTagByte((byte)-1),new NBTTagInt(2),new NBTTagLong(256),new NBTTagFloat(1),new NBTTagDouble(Double.NaN)})check(key+"_invalid_"+raw,()->badFlag(key,raw)); + } + check("lossless_integral_effect_fields",EffectHurtChecks::numeric); + check("missing_optional_effect_fields_keep_legacy_defaults",EffectHurtChecks::missingOptional); + check("zero_duration_expires_normally",()->validDuration(0)); + check("maximum_duration_does_not_overflow",()->validDuration(Integer.MAX_VALUE)); + check("signed_byte_amplifier_encodings_keep_native_111_clamping",EffectHurtChecks::signedAmplifiers); + check("forge_curative_items_keep_complete_item_data",EffectHurtChecks::curativeItems); + check("malformed_curative_items_retained_with_safe_defaults",EffectHurtChecks::badCurativeItems); + check("registered_unsigned_byte_effect_id_survives_reload",EffectHurtChecks::extendedId); + check("duplicate_id_numeric_alias_keeps_first",EffectHurtChecks::alias); + check("invalid_record_does_not_hide_valid_tail",EffectHurtChecks::validTail); + check("duplicate_effect_keeps_first_valid_record",EffectHurtChecks::duplicate); + check("unknown_effect_payload_retained_with_known_fields",EffectHurtChecks::unknown); + check("repeated_read_replaces_effects_and_metadata",EffectHurtChecks::repeated); + check("valid_potion_attributes_health_absorption_survive_reload",EffectHurtChecks::attributes); + check("repeated_read_removes_obsolete_potion_modifiers",EffectHurtChecks::repeatedAttributes); + check("reused_output_does_not_keep_previous_effect_list",EffectHurtChecks::reusedOutput); + check("invalid_effect_and_hurt_fields_keep_complete_unresolved_inventory",EffectHurtChecks::inventory); + check("metadata_only_does_not_prevent_empty_decay",EffectHurtChecks::empty); + try(PrintWriter out=new PrintWriter(new File(folder,"effect-hurt-checks.txt"),"UTF-8")){for(String s:results)out.println(s);out.println("checks="+results.size()+" failures="+failures);} + require(failures==0,"Effect/hurt regressions: "+failures+" / "+results.size());System.out.println("LOOTABLE_BODIES_EFFECT_HURT_PASS checks="+results.size()); + } + private static void check(String name,Check test){try{test.run();results.add("PASS "+name);System.out.println("EFFECT_HURT_PASS "+name);}catch(Throwable e){failures++;results.add("FAIL "+name+": "+e);System.out.println("EFFECT_HURT_FAIL "+name+": "+e);e.printStackTrace();}} + private static void require(boolean c,String why){RuntimeChecks.require(c,why);} + private static NBTTagCompound saved(EntityLootableBody b){return NameLifetimeChecks.saved(b);} + private static NBTTagCompound base(){EntityLootableBody b=new EntityLootableBody(world);b.setPosition(160.5,80,160.5);b.setNoGravity(true);b.setInventorySlotContents(6,NbtRecoveryChecks.item(120,17));RuntimeChecks.disableVacuum(b);return saved(b);} + private static EntityLootableBody read(NBTTagCompound n){NBTTagCompound before=n.copy();EntityLootableBody b=new EntityLootableBody(world);b.readFromNBT(n);require(NameLifetimeChecks.same(before,n),"Input mutated");return b;} + static NBTTagCompound effect(Potion p,int duration,int amplifier){return new PotionEffect(p,duration,amplifier,false,true).writeCustomPotionEffectToNBT(new NBTTagCompound());} + private static NBTTagCompound effect(){return effect(MobEffects.NIGHT_VISION,200,1);} + private static NBTTagCompound with(NBTTagCompound e){NBTTagCompound n=base();n.setTag("ActiveEffects",NbtRecoveryChecks.list(e));return n;} + private static void stable(EntityLootableBody b){NBTTagCompound a=saved(b),z=saved(read(a));for(String k:new String[]{"Equipment","Aux",NbtRecoveryChecks.RECOVERY,NameLifetimeChecks.RECOVERY,"ActiveEffects","HurtTime","Health","AbsorptionAmount","Attributes"})require(NameLifetimeChecks.same(a.getTag(k),z.getTag(k)),"Round trip changed "+k);require(RuntimeChecks.sameStack(b.getStackInSlot(6),NbtRecoveryChecks.item(120,17)),"Complete known inventory changed");} + private static void badHurt(NBTBase raw){NBTTagCompound n=base();n.setTag("HurtTime",raw.copy());EntityLootableBody b=read(n);require(b.hurtTime==0 && b.isEntityAlive(),"Bad hurt time activated");NameLifetimeChecks.retained(b,"HurtTime",raw);stable(b);} + private static void validHurt(NBTBase raw){NBTTagCompound n=base();n.setTag("HurtTime",raw);EntityLootableBody b=read(n);int v=((NBTPrimitive)raw).getInt();require(b.hurtTime==v,"Valid countdown narrowed");stable(b);b.onEntityUpdate();require(b.hurtTime==v-1,"Countdown no longer ticks");} + private static void badList(NBTBase raw){NBTTagCompound n=base();n.setTag("ActiveEffects",raw.copy());EntityLootableBody b=read(n);require(b.getActivePotionEffects().isEmpty(),"Bad effect list activated");NameLifetimeChecks.retained(b,"ActiveEffects",raw);stable(b);} + private static void badEffect(String key,NBTBase raw){NBTTagCompound e=effect();if(raw==null)e.removeTag(key);else e.setTag(key,raw.copy());EntityLootableBody b=read(with(e));require(b.getActivePotionEffects().isEmpty(),"Malformed effect activated");NameLifetimeChecks.retained(b,"ActiveEffect",e);stable(b);} + private static void badFlag(String key,NBTBase raw){NBTTagCompound e=effect();e.setTag(key,raw.copy());EntityLootableBody b=read(with(e));PotionEffect p=b.getActivePotionEffect(MobEffects.NIGHT_VISION);require(p!=null && p.getDuration()==200 && p.getAmplifier()==1,"Bad optional flag lost valid effect");require(!p.getIsAmbient() && p.doesShowParticles(),"Bad optional flag did not use defaults");NameLifetimeChecks.retained(b,"ActiveEffect",e);stable(b);} + private static void numeric(){for(int type=0;type<4;type++){NBTTagCompound e=effect();NBTBase[] ids={new NBTTagByte((byte)16),new NBTTagShort((short)16),new NBTTagInt(16),new NBTTagLong(16)};NBTBase[] one={new NBTTagByte((byte)1),new NBTTagShort((short)1),new NBTTagInt(1),new NBTTagLong(1)};e.setTag("Id",ids[type]);e.setTag("Duration",one[type]);e.setTag("Amplifier",one[type].copy());e.setTag("Ambient",one[type].copy());e.setTag("ShowParticles",one[type].copy());EntityLootableBody b=read(with(e));PotionEffect p=b.getActivePotionEffect(MobEffects.NIGHT_VISION);require(p!=null && p.getDuration()==1 && p.getAmplifier()==1 && p.getIsAmbient() && p.doesShowParticles(),"Lossless integral effect changed");require(!saved(b).hasKey(NameLifetimeChecks.RECOVERY),"Valid numbers quarantined");stable(b);}} + private static void missingOptional(){NBTTagCompound e=effect();for(String k:new String[]{"Duration","Amplifier","Ambient","ShowParticles"})e.removeTag(k);EntityLootableBody b=read(with(e));PotionEffect p=b.getActivePotionEffect(MobEffects.NIGHT_VISION);require(p!=null && p.getDuration()==0 && p.getAmplifier()==0 && !p.getIsAmbient() && p.doesShowParticles(),"Legacy defaults changed");require(!saved(b).hasKey(NameLifetimeChecks.RECOVERY),"Missing optional fields quarantined");stable(b);} + private static void validDuration(int d){EntityLootableBody b=read(with(effect(MobEffects.NIGHT_VISION,d,0)));b.onEntityUpdate();PotionEffect p=b.getActivePotionEffect(MobEffects.NIGHT_VISION);require(d==0?p==null:p!=null && p.getDuration()==d-1,"Valid duration tick/expiry changed");stable(b);} + private static void signedAmplifiers(){for(int a:new int[]{-128,-1,0,127}){EntityLootableBody b=read(with(effect(MobEffects.NIGHT_VISION,200,a)));require(b.getActivePotionEffect(MobEffects.NIGHT_VISION).getAmplifier()==Math.max(0,a),"Native 1.11 byte amplifier clamping changed");require(!saved(b).hasKey(NameLifetimeChecks.RECOVERY),"Canonical signed byte quarantined");stable(b);}} + private static void curativeItems(){NBTTagCompound e=effect();NBTTagCompound item=NbtRecoveryChecks.item(120,7).writeToNBT(new NBTTagCompound());NBTTagList list=new NBTTagList();list.appendTag(item);e.setTag("CurativeItems",list);EntityLootableBody b=read(with(e));require(b.getActivePotionEffect(MobEffects.NIGHT_VISION).getCurativeItems().size()==1 && RuntimeChecks.sameStack(b.getActivePotionEffect(MobEffects.NIGHT_VISION).getCurativeItems().get(0),new ItemStack(item)),"Forge curative item NBT changed");require(!saved(b).hasKey(NameLifetimeChecks.RECOVERY),"Healthy Forge curative items quarantined");stable(b);} + private static void badCurativeItems(){NBTTagCompound e=effect();e.setString("CurativeItems","opaque");EntityLootableBody b=read(with(e));NameLifetimeChecks.retained(b,"ActiveEffect",e);require(!b.getActivePotionEffect(MobEffects.NIGHT_VISION).getCurativeItems().isEmpty(),"Malformed curative list did not use native defaults");stable(b);} + private static void extendedId(){for(boolean wide:new boolean[]{false,true}){NBTTagCompound e=effect(extended,200,1);if(wide)e.setInteger("Id",200);EntityLootableBody b=read(with(e));require(b.getActivePotionEffect(extended)!=null && b.getActivePotionEffect(extended).getDuration()==200,"Registered unsigned ID changed");require(!saved(b).hasKey(NameLifetimeChecks.RECOVERY),"Valid extended ID quarantined");stable(b);}} + private static void alias(){NBTTagCompound first=effect(extended,200,1),second=effect(extended,900,3),n=base();second.setLong("Id",200);n.setTag("ActiveEffects",NbtRecoveryChecks.list(first,second));EntityLootableBody b=read(n);require(b.getActivePotionEffect(extended).getDuration()==200,"Numeric alias bypassed first valid effect");NameLifetimeChecks.retained(b,"ActiveEffect",second);stable(b);} + private static void validTail(){NBTTagCompound bad=effect();bad.setString("Duration","opaque");NBTTagCompound n=base();n.setTag("ActiveEffects",NbtRecoveryChecks.list(bad,effect()));EntityLootableBody b=read(n);require(b.getActivePotionEffect(MobEffects.NIGHT_VISION).getDuration()==200,"Bad first effect hid valid tail");NameLifetimeChecks.retained(b,"ActiveEffect",bad);stable(b);} + private static void duplicate(){NBTTagCompound first=effect(),second=effect(MobEffects.NIGHT_VISION,900,3),n=base();n.setTag("ActiveEffects",NbtRecoveryChecks.list(first,second));EntityLootableBody b=read(n);require(b.getActivePotionEffect(MobEffects.NIGHT_VISION).getDuration()==200,"Duplicate overwrote first effect");NameLifetimeChecks.retained(b,"ActiveEffect",second);stable(b);} + private static void unknown(){NBTTagCompound e=effect();e.setString("Opaque","retain effect extension");EntityLootableBody b=read(with(e));require(b.getActivePotionEffects().size()==1,"Unknown sibling lost understood effect");NameLifetimeChecks.retained(b,"ActiveEffect",e);stable(b);} + private static void repeated(){EntityLootableBody b=read(with(effect(MobEffects.INVISIBILITY,200,0)));b.onEntityUpdate();require(b.isInvisible(),"Fixture not invisible");b.readFromNBT(base());require(b.getActivePotionEffects().isEmpty() && !b.isInvisible(),"Previous effects or render state survived read");stable(b);} + private static void attributes(){EntityLootableBody original=new EntityLootableBody(world);original.setInventorySlotContents(6,NbtRecoveryChecks.item(120,17));RuntimeChecks.disableVacuum(original);original.addPotionEffect(new PotionEffect(MobEffects.HEALTH_BOOST,200,1));original.addPotionEffect(new PotionEffect(MobEffects.SPEED,200,0));original.addPotionEffect(new PotionEffect(MobEffects.ABSORPTION,200,0));original.setHealth(original.getMaxHealth());NBTTagCompound n=saved(original);EntityLootableBody b=read(n);require(b.getMaxHealth()==28 && b.getHealth()==28 && b.getAbsorptionAmount()==4 && b.getActivePotionEffects().size()==3,"Valid potion health/attributes/absorption changed");require(!saved(b).hasKey(NameLifetimeChecks.RECOVERY),"Healthy potion fields quarantined");stable(b);for(int i=0;i<200;i++)b.onEntityUpdate();require(b.getActivePotionEffects().isEmpty() && b.getMaxHealth()==20 && b.getAbsorptionAmount()==0 && b.getHealth()<=20,"Expiry did not clean up normal modifiers");} + private static void repeatedAttributes(){EntityLootableBody original=new EntityLootableBody(world);original.addPotionEffect(new PotionEffect(MobEffects.HEALTH_BOOST,200,1));EntityLootableBody b=read(saved(original));b.readFromNBT(base());require(b.getActivePotionEffects().isEmpty() && b.getMaxHealth()==20,"Obsolete effects/modifiers survived read");stable(b);} + private static void reusedOutput(){EntityLootableBody b=read(base());NBTTagCompound n=with(effect());b.writeEntityToNBT(n);require(!n.hasKey("ActiveEffects"),"Writer retained obsolete ActiveEffects");stable(b);} + private static void inventory(){NBTTagCompound n=base(),missing=NbtRecoveryChecks.entry(121,11,7);missing.setString("id","absentfixture:missing");n.getTagList("Equipment",10).appendTag(missing);NBTTagCompound raw=malformedFields();for(String k:raw.getKeySet())n.setTag(k,raw.getTag(k).copy());EntityLootableBody b=read(n);require(b.isEntityAlive(),"Bad fields made corpse inaccessible");NbtRecoveryChecks.retained(b,missing);verifyWorldFields(b);stable(b);stable(read(saved(b)));} + private static void empty(){NBTTagCompound n=base();n.setTag("Equipment",new NBTTagList());n.setString("HurtTime","opaque");EntityLootableBody b=read(n);require(b.isEmpty(),"Metadata counts as inventory");NameLifetimeChecks.retained(b,"HurtTime",n.getTag("HurtTime"));} + static NBTTagCompound malformedFields(){NBTTagCompound n=new NBTTagCompound();n.setLong("HurtTime",65537);NBTTagCompound bad=effect();bad.setString("Duration","opaque saved duration");NBTTagCompound unknown=effect();unknown.setInteger("Id",250);unknown.setString("Opaque","missing effect data");n.setTag("ActiveEffects",NbtRecoveryChecks.list(bad,unknown));return n;} + static void verifyWorldFields(EntityLootableBody b){require(b.hurtTime==0 && b.getActivePotionEffects().isEmpty(),"Malformed disk effect/hurt state activated");NBTTagCompound raw=malformedFields();NameLifetimeChecks.retained(b,"HurtTime",raw.getTag("HurtTime"));NBTTagList list=raw.getTagList("ActiveEffects",10);for(int i=0;i event) {event.getRegistry().register(keepsake);} + } + private static final class Stored implements ICapabilitySerializable { + private NBTTagCompound data=new NBTTagCompound(); + Stored(){data.setString("owner","Disposable fixture");data.setLong("number",9876543210123L);data.setByteArray("opaque",new byte[]{0,1,-1,17});} + public boolean hasCapability(Capability capability,EnumFacing side){return false;} + public T getCapability(Capability capability,EnumFacing side){return null;} + public NBTTagCompound serializeNBT(){return data.copy();} + public void deserializeNBT(NBTTagCompound nbt){data=nbt.copy();} + } +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/HealthAttributeChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/HealthAttributeChecks.java new file mode 100644 index 0000000..c93c4df --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/HealthAttributeChecks.java @@ -0,0 +1,130 @@ +package cyano.lootable.buildtest; + +import cyano.lootable.LootableBodies; +import cyano.lootable.entities.EntityLootableBody; +import java.io.*; +import java.util.*; +import net.minecraft.entity.Entity; +import net.minecraft.entity.SharedMonsterAttributes; +import net.minecraft.entity.ai.attributes.*; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.*; +import net.minecraft.server.MinecraftServer; +import net.minecraft.world.WorldServer; + +/** Load real entities, compare complete item data and exercise the legacy attribute engine. */ +final class HealthAttributeChecks { + private interface Check {void run() throws Exception;} + private static WorldServer world; + private static final List results=new ArrayList<>(); + private static int failures; + private static final UUID MOD=UUID.fromString("00000000-0000-4000-8000-000000000141"); + static void run(MinecraftServer server,WorldServer target,File folder) throws Exception { + world=target;results.clear();failures=0; + NBTBase[] wrong={new NBTTagString("opaque"),new NBTTagCompound(),new NBTTagList(),new NBTTagByteArray(new byte[]{1}),new NBTTagIntArray(new int[]{1})}; + for(String key:new String[]{"Health","AbsorptionAmount"}) { + for(int i=0;ibadScalar(key,raw));} + for(double value:new double[]{Double.NaN,Double.NEGATIVE_INFINITY,Double.POSITIVE_INFINITY,-1,Double.MAX_VALUE,1e-300})check(key+"_invalid_value_"+value,()->badScalar(key,new NBTTagDouble(value))); + } + check("health_above_effective_max_retained",()->badScalar("Health",new NBTTagFloat(21))); + for(int type=0;type<6;type++){final int t=type;check("valid_numeric_scalar_type_"+type,()->numeric(t));} + check("valid_zero_health_keeps_death_semantics",HealthAttributeChecks::zero); + check("missing_health_defaults_to_loaded_max",HealthAttributeChecks::missingHealth); + check("configured_health_and_follow_range_defaults",HealthAttributeChecks::configured); + check("configured_health_above_engine_cap_keeps_existing_clamp",HealthAttributeChecks::configuredCap); + check("previous_configured_health_survives_later_config_change",HealthAttributeChecks::configuredHistory); + for(int i=0;ibadList(raw));} + check("empty_attribute_list_keeps_constructor_defaults",()->{NBTTagCompound n=base();n.setTag("Attributes",new NBTTagList());EntityLootableBody b=read(n);value(b.getMaxHealth(),20,"Empty list changed max");RuntimeChecks.require(!NameLifetimeChecks.saved(b).hasKey(NameLifetimeChecks.RECOVERY),"Valid empty list quarantined");stable(b);}); + check("Attributes_wrong_element_type",()->{NBTTagList raw=new NBTTagList();raw.appendTag(new NBTTagString("opaque"));badList(raw);}); + check("attribute_missing_name",()->badAttribute("Name",null)); + check("attribute_wrong_name_type",()->badAttribute("Name",new NBTTagInt(1))); + check("attribute_unknown_name_retained",()->badAttribute("Name",new NBTTagString("absentfixture:attribute"))); + check("attribute_missing_base",()->badAttribute("Base",null)); + for(int i=0;ibadAttribute("Base",raw));} + for(double value:new double[]{Double.NaN,Double.POSITIVE_INFINITY,Double.NEGATIVE_INFINITY,-1,0,Double.MAX_VALUE})check("Base_invalid_value_"+value,()->badAttribute("Base",new NBTTagDouble(value))); + check("invalid_base_does_not_hide_later_valid_attribute",HealthAttributeChecks::validTail); + check("duplicate_attribute_keeps_first_valid_record",HealthAttributeChecks::duplicateAttribute); + check("unknown_attribute_payload_preserved_with_valid_fields",HealthAttributeChecks::unknownAttributeData); + check("Modifiers_wrong_list_type_keeps_base",()->badModifierList(new NBTTagString("opaque"))); + check("Modifiers_wrong_element_type_keeps_base",()->{NBTTagList l=new NBTTagList();l.appendTag(new NBTTagInt(3));badModifierList(l);}); + for(String field:new String[]{"Name","Amount","Operation","UUIDMost","UUIDLeast"}) { + check("modifier_missing_"+field,()->badModifier(field,null)); + check("modifier_wrong_type_"+field,()->badModifier(field,field.equals("Name")?new NBTTagInt(1):new NBTTagString("opaque"))); + } + check("modifier_blank_name",()->badModifier("Name",new NBTTagString(""))); + for(double value:new double[]{Double.NaN,Double.POSITIVE_INFINITY,Double.NEGATIVE_INFINITY})check("modifier_nonfinite_amount_"+value,()->badModifier("Amount",new NBTTagDouble(value))); + for(NBTBase value:new NBTBase[]{new NBTTagInt(-1),new NBTTagInt(3),new NBTTagLong(4294967296L),new NBTTagFloat(1F)})check("modifier_invalid_operation_"+value,()->badModifier("Operation",value)); + check("modifier_fractional_UUID_not_coerced",()->badModifier("UUIDMost",new NBTTagDouble(1.5))); + check("duplicate_modifier_UUID_keeps_first",HealthAttributeChecks::duplicateModifier); + check("bad_modifier_keeps_valid_base_and_tail",HealthAttributeChecks::modifierTail); + check("unknown_modifier_payload_retained",HealthAttributeChecks::unknownModifierData); + check("finite_modifier_overflow_not_activated",HealthAttributeChecks::overflow); + check("valid_signed_modifiers_all_three_operations",HealthAttributeChecks::validModifiers); + check("valid_zero_UUID_and_integral_modifier_numbers",HealthAttributeChecks::zeroUuid); + check("valid_base_numeric_types_and_fractional_health",HealthAttributeChecks::validBases); + check("repeated_load_restores_constructor_defaults",HealthAttributeChecks::repeated); + check("repeated_load_clears_saved_modifiers",HealthAttributeChecks::repeatedModifiers); + check("repaired_health_does_not_keep_previous_removal_countdown",HealthAttributeChecks::pendingRemoval); + check("invalid_health_attribute_and_unresolved_inventory_reload",HealthAttributeChecks::inventory); + check("invalid_metadata_alone_does_not_count_as_inventory",HealthAttributeChecks::empty); + check("healthy_save_layout_unchanged",HealthAttributeChecks::healthy); + try(PrintWriter out=new PrintWriter(new File(folder,"health-attribute-checks.txt"),"UTF-8")){for(String s:results)out.println(s);out.println("checks="+results.size()+" failures="+failures);} + RuntimeChecks.require(failures==0,"Health/attribute regressions: "+failures+" / "+results.size());System.out.println("LOOTABLE_BODIES_HEALTH_ATTRIBUTE_PASS checks="+results.size()); + } + private static void check(String name,Check test){ + float hp=LootableBodies.corpseHP;Set before=new HashSet<>(world.loadedEntityList); + try{test.run();results.add("PASS "+name);System.out.println("HEALTH_ATTRIBUTE_PASS "+name);} + catch(Throwable e){failures++;results.add("FAIL "+name+": "+e);System.out.println("HEALTH_ATTRIBUTE_FAIL "+name+": "+e);e.printStackTrace();} + finally{LootableBodies.corpseHP=hp;for(Entity e:new ArrayList(world.loadedEntityList))if(!before.contains(e))world.removeEntityDangerously(e);} + } + static NBTTagCompound base(){EntityLootableBody b=new EntityLootableBody(world);b.setPosition(160.5,80,160.5);b.setNoGravity(true);b.setUserName("HealthFixture");b.setInventorySlotContents(6,NbtRecoveryChecks.item(120,17));RuntimeChecks.disableVacuum(b);return NameLifetimeChecks.saved(b);} + static EntityLootableBody read(NBTTagCompound n){NBTTagCompound before=n.copy();EntityLootableBody b=new EntityLootableBody(world);b.readFromNBT(n);RuntimeChecks.require(NameLifetimeChecks.same(before,n),"Input NBT mutated");return b;} + static NBTTagCompound attribute(String name,double base){NBTTagCompound n=new NBTTagCompound();n.setString("Name",name);n.setDouble("Base",base);return n;} + static NBTTagCompound modifier(double amount,int operation){NBTTagCompound n=new NBTTagCompound();n.setString("Name","Fixture modifier");n.setDouble("Amount",amount);n.setInteger("Operation",operation);n.setUniqueId("UUID",MOD);return n;} + static NBTTagCompound with(NBTTagCompound a){NBTTagCompound n=base();n.setTag("Attributes",NbtRecoveryChecks.list(a));return n;} + private static void value(double a,double b,String why){RuntimeChecks.require(Double.isFinite(a) && Math.abs(a-b)<1e-6,why+" expected="+b+" actual="+a);} + private static void inventoryKept(EntityLootableBody b){RuntimeChecks.require(RuntimeChecks.sameStack(b.getStackInSlot(6),NbtRecoveryChecks.item(120,17)),"Complete recoverable inventory changed");} + private static void stable(EntityLootableBody b){NBTTagCompound a=NameLifetimeChecks.saved(b),z=NameLifetimeChecks.saved(read(a));for(String key:new String[]{"Health","AbsorptionAmount","Attributes","Equipment","Aux",NameLifetimeChecks.RECOVERY,NbtRecoveryChecks.RECOVERY})RuntimeChecks.require(NameLifetimeChecks.same(a.getTag(key),z.getTag(key)),"Round trip changed "+key);inventoryKept(b);} + private static void retained(EntityLootableBody b,String key,NBTBase raw){NameLifetimeChecks.retained(b,key,raw);} + private static void badScalar(String key,NBTBase raw){NBTTagCompound n=base();n.setTag(key,raw.copy());EntityLootableBody b=read(n);value(key.equals("Health")?b.getHealth():b.getAbsorptionAmount(),key.equals("Health")?20:0,"Bad scalar did not use safe default");RuntimeChecks.require(b.isEntityAlive(),"Malformed scalar made corpse inaccessible");retained(b,key,raw);stable(b);} + private static void numeric(int type){NBTBase[] values={new NBTTagByte((byte)1),new NBTTagShort((short)1),new NBTTagInt(1),new NBTTagLong(1),new NBTTagFloat(1),new NBTTagDouble(1)};NBTTagCompound n=base();n.setTag("Health",values[type]);n.setTag("AbsorptionAmount",values[type].copy());EntityLootableBody b=read(n);value(b.getHealth(),1,"Valid Health changed");value(b.getAbsorptionAmount(),1,"Valid absorption changed");stable(b);} + private static void zero(){NBTTagCompound n=base();n.setFloat("Health",0);EntityLootableBody b=read(n);value(b.getHealth(),0,"Legitimate zero revived");RuntimeChecks.require(!b.isEntityAlive(),"Zero health no longer starts death handling");inventoryKept(b);} + private static void missingHealth(){NBTTagCompound n=with(attribute("generic.maxHealth",30));n.removeTag("Health");EntityLootableBody b=read(n);value(b.getHealth(),30,"Missing Health did not use loaded max");stable(b);} + private static void configured(){LootableBodies.corpseHP=37;NBTTagCompound n=base();n.removeTag("Attributes");n.removeTag("Health");EntityLootableBody b=read(n);value(b.getMaxHealth(),37,"Config health changed");value(b.getEntityAttribute(SharedMonsterAttributes.FOLLOW_RANGE).getBaseValue(),16,"EntityLiving follow-range default changed");stable(b);} + private static void configuredCap(){LootableBodies.corpseHP=Short.MAX_VALUE;EntityLootableBody original=new EntityLootableBody(world);NBTTagCompound n=NameLifetimeChecks.saved(original);EntityLootableBody b=read(n);value(b.getMaxHealth(),original.getMaxHealth(),"Existing configured health clamp changed");RuntimeChecks.require(!NameLifetimeChecks.saved(b).hasKey(NameLifetimeChecks.RECOVERY),"Healthy configured base quarantined");} + private static void configuredHistory(){LootableBodies.corpseHP=Short.MAX_VALUE;EntityLootableBody old=new EntityLootableBody(world);old.setInventorySlotContents(6,NbtRecoveryChecks.item(120,17));NBTTagCompound n=NameLifetimeChecks.saved(old);LootableBodies.corpseHP=20;EntityLootableBody b=read(n);value(b.getMaxHealth(),old.getMaxHealth(),"Valid old configured base was replaced by today's default");value(b.getHealth(),old.getHealth(),"Valid old configured health changed");RuntimeChecks.require(!NameLifetimeChecks.saved(b).hasKey(NameLifetimeChecks.RECOVERY),"Healthy historical config quarantined");stable(b);} + private static void badList(NBTBase raw){NBTTagCompound n=base();n.setTag("Attributes",raw.copy());EntityLootableBody b=read(n);value(b.getMaxHealth(),20,"Bad list changed max health");retained(b,"Attributes",raw);stable(b);} + private static void badAttribute(String field,NBTBase raw){NBTTagCompound a=attribute("generic.maxHealth",30);if(raw==null)a.removeTag(field);else a.setTag(field,raw.copy());EntityLootableBody b=read(with(a));value(b.getMaxHealth(),20,"Bad attribute changed default");retained(b,"Attribute",a);stable(b);} + private static void validTail(){NBTTagCompound a=attribute("generic.maxHealth",Double.NaN),n=base();n.setTag("Attributes",NbtRecoveryChecks.list(a,attribute("generic.maxHealth",30),attribute("generic.followRange",24)));EntityLootableBody b=read(n);value(b.getMaxHealth(),30,"Invalid first entry hid valid max health");value(b.getEntityAttribute(SharedMonsterAttributes.FOLLOW_RANGE).getBaseValue(),24,"Valid tail lost");retained(b,"Attribute",a);stable(b);} + private static void duplicateAttribute(){NBTTagCompound duplicate=attribute("generic.maxHealth",40),n=base();n.setTag("Attributes",NbtRecoveryChecks.list(attribute("generic.maxHealth",30),duplicate));EntityLootableBody b=read(n);value(b.getMaxHealth(),30,"Duplicate overwrote first max health");retained(b,"Attribute",duplicate);stable(b);} + private static void unknownAttributeData(){NBTTagCompound a=attribute("generic.maxHealth",30);a.setString("Opaque","retain");EntityLootableBody b=read(with(a));value(b.getMaxHealth(),30,"Known fields lost with unknown sibling");retained(b,"Attribute",a);stable(b);} + private static void badModifierList(NBTBase raw){NBTTagCompound a=attribute("generic.maxHealth",30);a.setTag("Modifiers",raw.copy());EntityLootableBody b=read(with(a));value(b.getMaxHealth(),30,"Invalid modifiers lost valid base");retained(b,"Attribute",a);stable(b);} + private static void badModifier(String field,NBTBase raw){NBTTagCompound m=modifier(5,0);if(raw==null)m.removeTag(field);else m.setTag(field,raw.copy());NBTTagCompound a=attribute("generic.maxHealth",30);a.setTag("Modifiers",NbtRecoveryChecks.list(m));EntityLootableBody b=read(with(a));value(b.getMaxHealth(),30,"Malformed modifier activated");retained(b,"Attribute",a);stable(b);} + private static void duplicateModifier(){NBTTagCompound a=attribute("generic.maxHealth",20);a.setTag("Modifiers",NbtRecoveryChecks.list(modifier(2,0),modifier(7,0)));EntityLootableBody b=read(with(a));value(b.getMaxHealth(),22,"Duplicate modifier replaced first");retained(b,"Attribute",a);stable(b);} + private static void modifierTail(){NBTTagCompound bad=modifier(Double.NaN,0),good=modifier(5,0),a=attribute("generic.maxHealth",30);good.setUniqueId("UUID",new UUID(0,142));a.setTag("Modifiers",NbtRecoveryChecks.list(bad,good));EntityLootableBody b=read(with(a));value(b.getMaxHealth(),35,"Invalid modifier hid valid tail or base");retained(b,"Attribute",a);stable(b);} + private static void unknownModifierData(){NBTTagCompound m=modifier(5,0),a=attribute("generic.maxHealth",30);m.setString("Opaque","retain");a.setTag("Modifiers",NbtRecoveryChecks.list(m));EntityLootableBody b=read(with(a));value(b.getMaxHealth(),35,"Known modifier fields lost");retained(b,"Attribute",a);stable(b);} + private static void overflow(){NBTTagCompound a=attribute("generic.maxHealth",30);a.setTag("Modifiers",NbtRecoveryChecks.list(modifier(Double.MAX_VALUE,2)));EntityLootableBody b=read(with(a));value(b.getMaxHealth(),30,"Overflowing modifier activated");retained(b,"Attribute",a);stable(b);} + private static void validModifiers(){NBTTagCompound a=attribute("generic.maxHealth",30);NBTTagCompound m0=modifier(-2,0),m1=modifier(.5,1),m2=modifier(.25,2);m1.setUniqueId("UUID",new UUID(0,142));m2.setUniqueId("UUID",new UUID(0,143));a.setTag("Modifiers",NbtRecoveryChecks.list(m0,m1,m2));EntityLootableBody b=read(with(a));value(b.getMaxHealth(),52.5,"Valid signed/three-operation modifiers changed");RuntimeChecks.require(!NameLifetimeChecks.saved(b).hasKey(NameLifetimeChecks.RECOVERY),"Valid modifiers quarantined");stable(b);} + private static void zeroUuid(){NBTTagCompound a=attribute("generic.maxHealth",30),m=modifier(5,0);m.setLong("UUIDMost",0);m.setLong("UUIDLeast",0);m.setByte("Operation",(byte)0);m.setInteger("Amount",5);a.setTag("Modifiers",NbtRecoveryChecks.list(m));EntityLootableBody b=read(with(a));value(b.getMaxHealth(),35,"Zero UUID or lossless numbers rejected");stable(b);} + private static void validBases(){for(NBTBase raw:new NBTBase[]{new NBTTagByte((byte)30),new NBTTagShort((short)30),new NBTTagInt(30),new NBTTagLong(30),new NBTTagFloat(30.5F),new NBTTagDouble(30.5)}){NBTTagCompound a=attribute("generic.maxHealth",30);a.setTag("Base",raw);NBTTagCompound n=with(a);n.setDouble("Health",12.5);EntityLootableBody b=read(n);value(b.getMaxHealth(),((NBTPrimitive)raw).getDouble(),"Valid base changed");value(b.getHealth(),12.5,"Fractional Health changed");stable(b);}} + private static void repeated(){EntityLootableBody b=read(with(attribute("generic.maxHealth",30)));NBTTagCompound n=base();n.removeTag("Attributes");n.removeTag("Health");n.removeTag("AbsorptionAmount");b.setAbsorptionAmount(5);b.readFromNBT(n);value(b.getMaxHealth(),20,"Missing Attributes retained previous max");value(b.getHealth(),20,"Missing Health retained previous value");value(b.getAbsorptionAmount(),0,"Missing absorption retained previous value");stable(b);} + private static void repeatedModifiers(){NBTTagCompound a=attribute("generic.maxHealth",30);a.setTag("Modifiers",NbtRecoveryChecks.list(modifier(5,0)));EntityLootableBody b=read(with(a));b.readFromNBT(with(attribute("generic.maxHealth",20)));value(b.getMaxHealth(),20,"Repeated read kept obsolete modifier");stable(b);} + private static void pendingRemoval(){NBTTagCompound n=base();n.setFloat("Health",0);EntityLootableBody b=read(n);b.onEntityUpdate();n=base();n.setDouble("Health",Double.NaN);b.readFromNBT(n);b.onEntityUpdate();RuntimeChecks.require(b.isEntityAlive(),"Repaired owner still dead");inventoryKept(b);stable(b);n=base();n.setFloat("Health",3.5F);b.isDead=true;b.readFromNBT(n);RuntimeChecks.require(b.isEntityAlive(),"Reused entity kept stale dead flag");value(b.getHealth(),3.5,"Reset healed valid wounded corpse");} + private static void inventory(){NBTTagCompound n=base(),raw=NbtRecoveryChecks.entry(121,11,7);raw.setString("id","absentfixture:missing");n.getTagList("Equipment",10).appendTag(raw);n.setDouble("Health",Double.NaN);NBTTagCompound a=attribute("generic.maxHealth",Double.NaN);n.setTag("Attributes",NbtRecoveryChecks.list(a,attribute("generic.followRange",24)));EntityLootableBody b=read(n);RuntimeChecks.require(b.isEntityAlive(),"Invalid health made recoverable inventory inaccessible");NbtRecoveryChecks.retained(b,raw);retained(b,"Attribute",a);retained(b,"Health",n.getTag("Health"));stable(b);stable(read(NameLifetimeChecks.saved(b)));} + private static void empty(){NBTTagCompound n=base();n.setTag("Equipment",new NBTTagList());n.setDouble("Health",Double.NaN);EntityLootableBody b=read(n);RuntimeChecks.require(b.isEmpty(),"Health evidence counted as inventory");retained(b,"Health",n.getTag("Health"));} + private static void healthy(){NBTTagCompound n=base();EntityLootableBody b=read(n);RuntimeChecks.require(!NameLifetimeChecks.saved(b).hasKey(NameLifetimeChecks.RECOVERY),"Healthy save gained recovery records");stable(b);} + + static NBTTagCompound malformedFields(){ + NBTTagCompound n=new NBTTagCompound();n.setDouble("Health",Double.NaN);n.setString("AbsorptionAmount","opaque saved absorption"); + NBTTagCompound max=attribute("generic.maxHealth",Double.NaN),follow=attribute("generic.followRange",24),unknown=attribute("absentfixture:attribute",7); + NBTTagCompound bad=modifier(Double.NaN,0),good=modifier(2,0);good.setUniqueId("UUID",new UUID(0,144)); + follow.setTag("Modifiers",NbtRecoveryChecks.list(bad,good));unknown.setString("Opaque","retain original attribute"); + n.setTag("Attributes",NbtRecoveryChecks.list(max,follow,unknown));return n; + } + static void verifyWorldFields(EntityLootableBody b){ + value(b.getHealth(),20,"Disk health unsafe");value(b.getMaxHealth(),20,"Disk max health unsafe");value(b.getAbsorptionAmount(),0,"Disk absorption unsafe"); + value(b.getEntityAttribute(SharedMonsterAttributes.FOLLOW_RANGE).getAttributeValue(),26,"Valid attribute/modifier tail lost on disk"); + NBTTagCompound raw=malformedFields();retained(b,"Health",raw.getTag("Health"));retained(b,"AbsorptionAmount",raw.getTag("AbsorptionAmount")); + NBTTagList entries=raw.getTagList("Attributes",10);for(int i=0;i results = new ArrayList<>(); + private static int failures; + private static int sequence; + private static WorldServer world; + private static MinecraftServer server; + + public static void run(MinecraftServer instance, WorldServer target, File folder) throws Exception { + server=instance; world=target; results.clear(); failures=0; + check("failed_corpse_spawn_keeps_original_drops",InventorySafetyChecks::failedSpawn); + check("cancelled_corpse_spawn_leaves_vanilla_drops",InventorySafetyChecks::cancelledSpawn); + check("stale_equipment_cannot_overwrite_current_helmet",InventorySafetyChecks::staleEquipment); + check("equipment_cache_consumed_and_ignored_deaths",InventorySafetyChecks::cacheLifecycle); + check("failed_release_retains_inventory_and_body",InventorySafetyChecks::failedRelease); + check("partial_release_retries_without_duplicates",InventorySafetyChecks::partialRelease); + check("vanilla_equipment_drop_does_not_duplicate",InventorySafetyChecks::vanillaEquipment); + check("vacuum_claims_each_entity_once",InventorySafetyChecks::vacuumOnce); + check("empty_and_negative_removal_preserve_slots",InventorySafetyChecks::emptyRemoval); + check("repeated_nbt_read_replaces_without_mutating_source",InventorySafetyChecks::repeatedRead); + check("container_rejects_stale_access",InventorySafetyChecks::staleAccess); + check("overflow_never_overwrites_and_respects_limits",InventorySafetyChecks::overflow); + check("two_viewers_shift_click_conserve_custom_stacks",InventorySafetyChecks::twoViewers); + check("empty_slot_validation_is_safe",InventorySafetyChecks::emptyValidation); + check("dirty_inventory_schedules_chunk_save",InventorySafetyChecks::dirtyInventory); + check("direct_and_overflow_mutations_schedule_save",InventorySafetyChecks::dirtyMutations); + try(PrintWriter out=new PrintWriter(new File(folder,"inventory-checks.txt"),"UTF-8")) { + for(String line:results) out.println(line); + out.println("checks="+results.size()+" failures="+failures); + } + RuntimeChecks.require(failures==0,"Inventory regressions: "+failures+" / "+results.size()); + System.out.println("LOOTABLE_BODIES_INVENTORY_PASS checks="+results.size()); + } + private static void check(String name,Check check) { + List before=new ArrayList<>(world.loadedEntityList); + String keep=world.getGameRules().getString("keepInventory"); + String mobLoot=world.getGameRules().getString("doMobLoot"); + try { + world.getGameRules().setOrCreateGameRule("keepInventory","false"); + world.getGameRules().setOrCreateGameRule("doMobLoot","false"); + check.run(); results.add("PASS "+name); + System.out.println("INVENTORY_CHECK_PASS "+name); + } catch(Throwable error) { + failures++; results.add("FAIL "+name+": "+error); + System.out.println("INVENTORY_CHECK_FAIL "+name+": "+error); + error.printStackTrace(); + } finally { + world.getGameRules().setOrCreateGameRule("keepInventory",keep); + world.getGameRules().setOrCreateGameRule("doMobLoot",mobLoot); + for(Entity entity:new ArrayList(world.loadedEntityList)) + if(!before.contains(entity)) world.removeEntityDangerously(entity); + } + } + private static EntityPlayerMP player() { + EntityPlayerMP player=new EntityPlayerMP(server,world, + new GameProfile(UUID.nameUUIDFromBytes(("inventory-fixture-"+(sequence++)).getBytes(java.nio.charset.StandardCharsets.UTF_8)),"InventoryFixture"), + new net.minecraft.server.management.PlayerInteractionManager(world)); + player.connection=new net.minecraft.network.NetHandlerPlayServer(server, + new net.minecraft.network.NetworkManager(net.minecraft.network.EnumPacketDirection.SERVERBOUND),player); + player.setPosition(64.5,80,64.5); world.getChunk(4,4); + return player; + } + private static EntityLootableBody body() { + EntityLootableBody body=new EntityLootableBody(world); + body.setPosition(64.5,80,64.5); body.setNoGravity(true); + world.getChunk(4,4); RuntimeChecks.disableVacuum(body); + return body; + } + private static ItemStack item(int id,int count) { + ItemStack stack=RuntimeChecks.stack(id); stack.setCount(count); return stack; + } + private static List drops(EntityPlayerMP player,ItemStack... stacks) { + List result=new ArrayList<>(); + for(ItemStack stack:stacks) result.add(new EntityItem(player.getEntityWorld(),player.posX,player.posY,player.posZ,stack)); + return result; + } + private static void handle(PlayerDeathEventHandler handler,EntityPlayerMP player,List drops) { + handler.entityDropEvent(new LivingDropsEvent(player,DamageSource.OUT_OF_WORLD,drops,0,false)); + } + private static NBTTagCompound data(EntityLootableBody body) { + NBTTagCompound result=new NBTTagCompound(); body.writeEntityToNBT(result); return result; + } + private static NBTTagCompound encoded(ItemStack stack) { return stack.writeToNBT(new NBTTagCompound()); } + private static Map inventory(EntityLootableBody body) { + Map result=new TreeMap<>(); + for(int i=0;i result,ItemStack stack) { + if((stack == null || stack.isEmpty()) || stack.getCount()<=0) return; + NBTTagCompound tag=encoded(stack); tag.removeTag("Count"); + String key=tag.toString(); result.put(key,result.getOrDefault(key,0)+stack.getCount()); + } + private static Map dropped() { + Map result=new TreeMap<>(); + for(Entity entity:world.loadedEntityList) if(entity instanceof EntityItem && !entity.isDead) add(result,((EntityItem)entity).getItem()); + return result; + } + private static EntityLootableBody newest(Set before) { + for(Entity entity:world.loadedEntityList) if(entity instanceof EntityLootableBody && !before.contains(entity)) return (EntityLootableBody)entity; + throw new AssertionError("No corpse spawned"); + } + private static void failedSpawn() { + EntityPlayerMP player=player(); RejectingWorld rejected=new RejectingWorld(world); + player.world=rejected; + ItemStack bones=new ItemStack(Items.BONE,9); bones.setStackDisplayName("Saved bones"); + List drops=drops(player,bones,item(19,23)); + List before=new ArrayList<>(); for(EntityItem e:drops) before.add(encoded(e.getItem())); + PlayerDeathEventHandler handler=new PlayerDeathEventHandler(); + handler.entityDeathEvent(new LivingDeathEvent(player,DamageSource.OUT_OF_WORLD)); + handle(handler,player,drops); + RuntimeChecks.require(rejected.attempts==1,"Failed spawn branch was not reached"); + RuntimeChecks.require(drops.size()==before.size(),"Rejected corpse consumed vanilla death drops"); + for(int i=0;i expected=new TreeMap<>(); add(expected,player.inventory.mainInventory.get(0));add(expected,player.inventory.mainInventory.get(9)); + Set before=new HashSet<>(world.loadedEntityList); + RejectCorpse reject=new RejectCorpse(); MinecraftForge.EVENT_BUS.register(reject); + try { player.onDeath(DamageSource.OUT_OF_WORLD); } finally { MinecraftForge.EVENT_BUS.unregister(reject); } + RuntimeChecks.require(reject.attempts==1,"Corpse cancellation not exercised"); + for(Entity entity:world.loadedEntityList) RuntimeChecks.require(before.contains(entity) || !(entity instanceof EntityLootableBody),"Cancelled corpse spawn bypassed Forge cancellation"); + RuntimeChecks.require(expected.equals(dropped()),"Cancelled corpse did not fall back to exact vanilla drops"); + } + private static void staleEquipment() { + EntityPlayerMP player=player(); PlayerDeathEventHandler handler=new PlayerDeathEventHandler(); + ItemStack previous=item(0,1); player.setItemStackToSlot(EntityEquipmentSlot.HEAD,previous); + handler.entityDeathEvent(new LivingDeathEvent(player,DamageSource.OUT_OF_WORLD)); + handle(handler,player,drops(player,previous)); + ItemStack current=item(1,1); player.setItemStackToSlot(EntityEquipmentSlot.HEAD,current); + handler.entityDeathEvent(new LivingDeathEvent(player,DamageSource.OUT_OF_WORLD)); + Map expected=new TreeMap<>(); add(expected,current);add(expected,previous); + Set before=new HashSet<>(world.loadedEntityList); + handle(handler,player,drops(player,current,previous)); + EntityLootableBody corpse=newest(before); + Map actual=inventory(corpse); + // Ignore only the explicitly configured extra bones/flesh. + actual.entrySet().removeIf(e->e.getKey().contains("minecraft:bone") || e.getKey().contains("minecraft:rotten_flesh")); + RuntimeChecks.require(expected.equals(actual),"Stale equipment overwrote a current stack"); + } + @SuppressWarnings("unchecked") private static Map cache(PlayerDeathEventHandler handler) throws Exception { + Field field=PlayerDeathEventHandler.class.getDeclaredField("equipmentCache"); field.setAccessible(true); + return (Map)field.get(handler); + } + private static void cacheLifecycle() throws Exception { + EntityPlayerMP player=player(); PlayerDeathEventHandler handler=new PlayerDeathEventHandler(); + player.setItemStackToSlot(EntityEquipmentSlot.HEAD,item(0,1)); + handler.entityDeathEvent(new LivingDeathEvent(player,DamageSource.OUT_OF_WORLD)); + handle(handler,player,drops(player,player.getItemStackFromSlot(EntityEquipmentSlot.HEAD))); + RuntimeChecks.require(!cache(handler).containsKey(player),"Equipment cache retains completed death"); + world.getGameRules().setOrCreateGameRule("keepInventory","true"); + handler.entityDeathEvent(new LivingDeathEvent(player,DamageSource.OUT_OF_WORLD)); + RuntimeChecks.require(!cache(handler).containsKey(player),"keepInventory death cached equipment"); + world.getGameRules().setOrCreateGameRule("keepInventory","false"); + LivingDeathEvent canceled=new LivingDeathEvent(player,DamageSource.OUT_OF_WORLD); canceled.setCanceled(true); + handler.entityDeathEvent(canceled); + RuntimeChecks.require(!cache(handler).containsKey(player),"Cancelled death cached equipment"); + handler.entityDeathEvent(new LivingDeathEvent(player,DamageSource.OUT_OF_WORLD)); + List original=drops(player,item(19,23)); + LivingDropsEvent canceledDrops=new LivingDropsEvent(player,DamageSource.OUT_OF_WORLD,original,0,false); + canceledDrops.setCanceled(true);handler.entityDropEvent(canceledDrops); + RuntimeChecks.require(!cache(handler).containsKey(player) && original.size()==1 + && RuntimeChecks.sameStack(original.get(0).getItem(),item(19,23)),"Cancelled drops retained stale cache or changed inventory"); + } + public static final class RejectItems { + boolean rejectAll; + String rejectFixture; + @SubscribeEvent public void join(EntityJoinWorldEvent event) { + if(event.getEntity() instanceof EntityItem) { + ItemStack stack=((EntityItem)event.getEntity()).getItem(); + if(rejectAll || stack.hasTagCompound() && rejectFixture.equals(stack.getTagCompound().getString("fixture"))) event.setCanceled(true); + } + } + } + private static void failedRelease() throws Exception { + EntityLootableBody corpse=body(); corpse.setInventorySlotContents(6,item(19,23)); + RuntimeChecks.require(world.spawnEntity(corpse),"Rejected-release owner spawn"); + Map expected=inventory(corpse); + RejectItems reject=new RejectItems(); reject.rejectAll=true; MinecraftForge.EVENT_BUS.register(reject); + try { corpse.onKillCommand(); for(int i=0;i<25;i++) corpse.onEntityUpdate(); } + finally { MinecraftForge.EVENT_BUS.unregister(reject); } + RuntimeChecks.require(expected.equals(inventory(corpse)),"Rejected item spawn erased corpse inventory"); + RuntimeChecks.require(corpse.isEntityAlive() && world.loadedEntityList.contains(corpse),"Rejected release removed the only inventory owner"); + NBTTagCompound saved=data(corpse); EntityLootableBody loaded=body();loaded.readEntityFromNBT(saved); + RuntimeChecks.require(expected.equals(inventory(loaded)),"Rejected items do not survive NBT reload"); + corpse.onKillCommand();for(int i=0;i<12;i++)corpse.onEntityUpdate(); + RuntimeChecks.require(expected.equals(dropped()),"Retry did not drop exact saved stacks once"); + } + private static void partialRelease() { + EntityLootableBody corpse=body();corpse.setInventorySlotContents(6,item(19,23));corpse.setInventorySlotContents(7,item(20,11)); + RuntimeChecks.require(world.spawnEntity(corpse),"Partial-release owner spawn"); + Map expected=inventory(corpse); + RejectItems reject=new RejectItems();reject.rejectFixture="slot-20";MinecraftForge.EVENT_BUS.register(reject); + try { corpse.onKillCommand();for(int i=0;i<12;i++)corpse.onEntityUpdate(); } + finally { MinecraftForge.EVENT_BUS.unregister(reject); } + Map combined=new TreeMap<>(dropped());inventory(corpse).forEach((k,v)->combined.put(k,combined.getOrDefault(k,0)+v)); + RuntimeChecks.require(expected.equals(combined),"Partial release lost/duplicated a stack"); + RuntimeChecks.require((corpse.getStackInSlot(6)).isEmpty() && RuntimeChecks.sameStack(corpse.getStackInSlot(7),item(20,11)),"Partial release did not retain only rejected slot"); + corpse.onKillCommand();for(int i=0;i<12;i++)corpse.onEntityUpdate(); + RuntimeChecks.require(expected.equals(dropped()),"Partial retry duplicated previously accepted drops"); + } + private static void vanillaEquipment() { + world.getGameRules().setOrCreateGameRule("doMobLoot","true"); + EntityLootableBody corpse=body();corpse.setInventorySlotContents(0,item(0,1));corpse.setDropChance(EntityEquipmentSlot.HEAD,2F); + RuntimeChecks.require(world.spawnEntity(corpse),"Equipment-release owner spawn"); + Map expected=inventory(corpse); + corpse.onKillCommand();for(int i=0;i<12;i++)corpse.onEntityUpdate(); + RuntimeChecks.require(expected.equals(dropped()),"Vanilla mob equipment path duplicates corpse inventory"); + } + private static void vacuumOnce() { + EntityLootableBody first=body(),second=body(); + try { Field f=EntityLootableBody.class.getDeclaredField("vacuumTime");f.setAccessible(true);f.setByte(first,(byte)0);f.setByte(second,(byte)0); } + catch(Exception error){throw new AssertionError(error);} + EntityItem entity=new EntityItem(world,64.5,80,64.5,item(19,23));RuntimeChecks.require(world.spawnEntity(entity),"Vacuum input spawn"); + Map expected=new TreeMap<>();add(expected,entity.getItem().copy()); + first.onEntityUpdate();second.onEntityUpdate(); + Map all=inventory(first);inventory(second).forEach((k,v)->all.put(k,all.getOrDefault(k,0)+v)); + RuntimeChecks.require(expected.equals(all),"Two corpses claimed the same dying item entity"); + } + private static void emptyRemoval() { + EntityLootableBody corpse=body(); + RuntimeChecks.require((corpse.decrStackSize(6,1)).isEmpty(),"Empty removal must return EMPTY"); + corpse.setInventorySlotContents(6,item(19,23)); NBTTagCompound expected=encoded(corpse.getStackInSlot(6)); + RuntimeChecks.require((corpse.decrStackSize(6,-1)).isEmpty(),"Negative removal created a stack"); + RuntimeChecks.require(expected.equals(encoded(corpse.getStackInSlot(6))),"Negative removal changed stack count"); + RuntimeChecks.require((corpse.decrStackSize(6,0)).isEmpty(),"Zero removal created a stack"); + ItemStack removed=corpse.decrStackSize(6,40);RuntimeChecks.require(RuntimeChecks.sameStack(removed,item(19,23)) && (corpse.getStackInSlot(6)).isEmpty(),"Full extraction did not clear slot"); + } + private static void repeatedRead() { + EntityLootableBody source=body();for(int i=6;i<48;i++)source.setInventorySlotContents(i,item(i,1));source.addItem(item(70,7)); + NBTTagCompound input=data(source),immutable=input.copy(); + EntityLootableBody target=body(); target.readEntityFromNBT(input); target.readEntityFromNBT(input); + RuntimeChecks.require(data(target).getTag("Equipment").equals(input.getTag("Equipment")) && data(target).getTag("Aux").equals(input.getTag("Aux")),"Repeated read appended overflow or changed slots"); + NBTTagCompound sparse=new NBTTagCompound();NBTTagList list=new NBTTagList();NBTTagCompound slot=encoded(item(19,23));slot.setByte("Slot",(byte)47);list.appendTag(slot);sparse.setTag("Equipment",list); + target.readEntityFromNBT(sparse); + RuntimeChecks.require((target.getStackInSlot(6)).isEmpty() && RuntimeChecks.sameStack(target.getStackInSlot(47),item(19,23)) && !data(target).hasKey("Aux"),"Sparse reread kept stale inventory/overflow"); + RuntimeChecks.require(immutable.equals(input),"Reader mutated source NBT"); + } + private static void staleAccess() { + EntityPlayerMP player=player();EntityLootableBody corpse=body();CorpseContainer container=new CorpseContainer(player.inventory,corpse); + RuntimeChecks.require(container.canInteractWith(player),"Near living access failed"); + player.setPosition(100,80,100);RuntimeChecks.require(!container.canInteractWith(player),"Distant container remained accessible"); + player.setPosition(64.5,80,64.5);corpse.setDead();RuntimeChecks.require(!container.canInteractWith(player),"Removed corpse remained accessible"); + corpse=body();container=new CorpseContainer(player.inventory,corpse);player.world=new RejectingWorld(world); + RuntimeChecks.require(!container.canInteractWith(player),"Cross-world container remained accessible"); + player.world=world;player.setHealth(0); + RuntimeChecks.require(!container.canInteractWith(player),"Dead player retained container access"); + player.setHealth(player.getMaxHealth());corpse.onKillCommand();corpse.onEntityUpdate(); + RuntimeChecks.require(!container.canInteractWith(player),"Dying corpse remained accessible"); + } + private static void overflow() { + EntityLootableBody corpse=body();for(int i=6;i<48;i++)corpse.setInventorySlotContents(i,item(i,1));corpse.addItem(item(70,7)); + NBTTagCompound before=data(corpse);corpse.onEntityUpdate(); + RuntimeChecks.require(before.getTag("Equipment").equals(data(corpse).getTag("Equipment")),"Full inventory overwritten by overflow"); + corpse.removeStackFromSlot(47);corpse.onEntityUpdate();RuntimeChecks.require(RuntimeChecks.sameStack(corpse.getStackInSlot(47),item(70,7)),"Overflow did not fill free slot"); + EntityLootableBody large=body();large.addItem(item(19,100)); + Map expected=new TreeMap<>();add(expected,item(19,100)); + RuntimeChecks.require(expected.equals(inventory(large)),"Oversized incoming stack lost items"); + for(int i=6;i<48;i++) RuntimeChecks.require((large.getStackInSlot(i)).isEmpty() || large.getStackInSlot(i).getCount()<=64,"Oversized input bypassed slot capacity"); + EntityLootableBody queued=body();for(int i=6;i<48;i++)queued.setInventorySlotContents(i,item(i,1)); + queued.addItem(item(70,100));queued.removeStackFromSlot(47);expected=inventory(queued); + queued.onEntityUpdate(); + RuntimeChecks.require(expected.equals(inventory(queued)) && queued.getStackInSlot(47).getCount()==64,"Overflow transfer lost items or exceeded slot capacity"); + queued.removeStackFromSlot(46);queued.onEntityUpdate(); + RuntimeChecks.require(RuntimeChecks.sameStack(queued.getStackInSlot(46),item(70,36)) && !data(queued).hasKey("Aux"),"Overflow remainder changed count or custom NBT"); + } + private static void twoViewers() { + EntityLootableBody corpse=body();corpse.setInventorySlotContents(6,item(19,23));EntityPlayerMP a=player(),b=player(); + CorpseContainer ca=new CorpseContainer(a.inventory,corpse),cb=new CorpseContainer(b.inventory,corpse); + RuntimeChecks.require(RuntimeChecks.sameStack(ca.transferStackInSlot(a,6),item(19,23)),"First recovery failed"); + RuntimeChecks.require((cb.transferStackInSlot(b,6)).isEmpty(),"Second viewer recovered the same stack"); + Map all=inventory(corpse);for(ItemStack s:a.inventory.mainInventory)add(all,s);for(ItemStack s:b.inventory.mainInventory)add(all,s); + Map expected=new TreeMap<>();add(expected,item(19,23));RuntimeChecks.require(expected.equals(all),"Two viewer recovery changed inventory/NBT"); + } + private static void emptyValidation() { + EntityLootableBody corpse=body();EntityPlayerMP player=player();CorpseContainer container=new CorpseContainer(player.inventory,corpse); + RuntimeChecks.require(!corpse.isItemValidForSlot(0,null),"Empty armor input accepted"); + RuntimeChecks.require(!container.getSlot(0).isItemValid(null),"Container empty armor input accepted"); + } + private static net.minecraft.world.chunk.Chunk cleanChunk(EntityLootableBody corpse) { + net.minecraft.world.chunk.Chunk chunk=world.getChunk(corpse.getPosition()); + chunk.setLastSaveTime(world.getTotalWorldTime());chunk.setModified(false); + RuntimeChecks.require(!chunk.needsSaving(false),"Dirty-chunk test precondition"); + return chunk; + } + private static void dirtyInventory() { + EntityLootableBody corpse=body();corpse.setInventorySlotContents(6,item(19,23)); + RuntimeChecks.require(world.spawnEntity(corpse),"Dirty-chunk corpse spawn"); + net.minecraft.world.chunk.Chunk chunk=cleanChunk(corpse);corpse.markDirty(); + RuntimeChecks.require(chunk.needsSaving(false),"Inventory markDirty does not schedule a chunk save"); + } + private static void dirtyMutations() { + EntityLootableBody corpse=body();RuntimeChecks.require(world.spawnEntity(corpse),"Mutation corpse spawn"); + net.minecraft.world.chunk.Chunk chunk=cleanChunk(corpse);corpse.setInventorySlotContents(6,item(19,23)); + RuntimeChecks.require(chunk.needsSaving(false),"Slot insertion not scheduled for save"); + cleanChunk(corpse);corpse.decrStackSize(6,1);RuntimeChecks.require(chunk.needsSaving(false),"Extraction not scheduled for save"); + cleanChunk(corpse);corpse.removeStackFromSlot(6);RuntimeChecks.require(chunk.needsSaving(false),"Removal not scheduled for save"); + cleanChunk(corpse);corpse.initializeItems(new ItemStack[]{item(20,11)});RuntimeChecks.require(chunk.needsSaving(false),"Initial inventory not scheduled for save"); + for(int i=6;i<48;i++)corpse.setInventorySlotContents(i,item(i,1)); + cleanChunk(corpse);corpse.addItem(item(70,7));RuntimeChecks.require(chunk.needsSaving(false),"Overflow insertion not scheduled for save"); + } + static void seedBlockedRelease(WorldServer target,File folder) throws Exception { + EntityLootableBody corpse=new EntityLootableBody(target); + corpse.setUniqueId(UUID.fromString("00000000-0000-4000-8000-000000000105")); + corpse.setPosition(48.5,80,8.5);corpse.setNoGravity(true);corpse.setUserName("BlockedRelease"); + target.getChunk(3,0); + for(int i=0;i<48;i++)corpse.setInventorySlotContents(i,RuntimeChecks.stack(i)); + corpse.addItem(RuntimeChecks.stack(70));corpse.addItem(RuntimeChecks.stack(71));RuntimeChecks.disableVacuum(corpse); + RuntimeChecks.require(target.spawnEntity(corpse),"Blocked-release fixture spawn"); + Map expected=inventory(corpse); + RejectItems reject=new RejectItems();reject.rejectAll=true;MinecraftForge.EVENT_BUS.register(reject); + try {corpse.onKillCommand();for(int i=0;i<25;i++)corpse.onEntityUpdate();} + finally {MinecraftForge.EVENT_BUS.unregister(reject);} + RuntimeChecks.require(corpse.isEntityAlive() && target.loadedEntityList.contains(corpse) && expected.equals(inventory(corpse)),"Blocked-release fixture lost inventory/owner"); + RuntimeChecks.saveExpected(folder,"rejected-release",corpse); + } + /** Controlled false-return seam; no dimension registration, disk writes or chunks of its own. */ + private static final class RejectingWorld extends World { + private final WorldServer delegate; int attempts; + RejectingWorld(WorldServer target) { + super(target.getSaveHandler(),new WorldInfo(target.getWorldInfo()),new WorldProviderSurface(),new Profiler(),false); + delegate=target; + } + @Override protected IChunkProvider createChunkProvider(){return delegate.getChunkProvider();} + @Override protected boolean isChunkLoaded(int x,int z,boolean empty){return true;} + @Override public long getTotalWorldTime(){return delegate==null?0:delegate.getTotalWorldTime();} + @Override public boolean spawnEntity(Entity entity){attempts++;return false;} + } +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/MultiplayerChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/MultiplayerChecks.java new file mode 100644 index 0000000..57b16a7 --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/MultiplayerChecks.java @@ -0,0 +1,120 @@ +package cyano.lootable.buildtest; + +import cyano.lootable.LootableBodies; +import cyano.lootable.entities.EntityLootableBody; +import io.netty.buffer.ByteBuf; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.UUID; +import net.minecraft.entity.player.EntityPlayerMP; +import net.minecraft.init.Blocks; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.*; +import net.minecraft.server.MinecraftServer; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.*; +import net.minecraftforge.fml.common.FMLCommonHandler; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; +import net.minecraftforge.fml.common.gameevent.TickEvent; +import net.minecraftforge.fml.common.network.NetworkRegistry; +import net.minecraftforge.fml.common.network.simpleimpl.*; +import net.minecraftforge.fml.relauncher.Side; + +/** Disposable two-client coordinator. Step messages acknowledge prior TCP clicks on the server thread. */ +public final class MultiplayerChecks { + public static final UUID BODY=UUID.fromString("00000000-0000-4000-8000-000000000108"); + public static final SimpleNetworkWrapper CHANNEL=NetworkRegistry.INSTANCE.newSimpleChannel("lootabletest"); + private final MinecraftServer server; + private final File sync; + private EntityLootableBody body; + private int phase,ticks; + private boolean finishing; + public static void registerChannel(){CHANNEL.registerMessage(StepHandler.class,Step.class,0,Side.SERVER);} + public MultiplayerChecks(MinecraftServer server) throws Exception { + this.server=server;sync=sync(); + LootableBodies.allowCorpseDecay=false;LootableBodies.ticksPerItemDecay=-1; + setPhase(0);Files.write(new File(sync,"server.ready").toPath(),"ready\n".getBytes(StandardCharsets.UTF_8)); + FMLCommonHandler.instance().bus().register(this); + } + public static File sync(){return new File(System.getProperty("lootablebodies.multiplayer.sync"));} + private EntityPlayerMP player(String role){return server.getPlayerList().getPlayerByUsername("Container"+role);} + private boolean ack(String role,int step){return new File(sync,"Container"+role+".step-"+step).isFile();} + @SubscribeEvent public void tick(TickEvent.ServerTickEvent e){ + if(e.phase!=TickEvent.Phase.END || finishing)return; + try{ + RuntimeChecks.require(++ticks<2400,"Two-client timeout phase="+phase); + if(new File(sync,"abort").exists())throw new AssertionError("Runner aborted multiplayer fixture"); + EntityPlayerMP a=player("A"),b=player("B"); + if(phase==0 && a!=null && b!=null){ + WorldServer w=server.getWorld(0); + for(int x=93;x<=100;x++)for(int z=93;z<=100;z++)w.setBlockState(new BlockPos(x,79,z),Blocks.STONE.getDefaultState()); + w.getGameRules().setOrCreateGameRule("doMobSpawning","false"); + body=new EntityLootableBody(w);body.setUniqueId(BODY);body.setPosition(96.5,80.5,96.5);body.setNoGravity(true);body.setUserName("SharedFixture");RuntimeChecks.disableVacuum(body); + body.setInventorySlotContents(6,stack(19,23));RuntimeChecks.require(w.spawnEntity(body),"Shared corpse spawn"); + a.setGameType(GameType.SURVIVAL);b.setGameType(GameType.SURVIVAL);a.inventory.clear();b.inventory.clear(); + a.connection.setPlayerLocation(96.5,80,94.5,0,0);b.connection.setPlayerLocation(98.5,80,96.5,0,0); + setPhase(1); + }else if(phase==1 && ack("A",1) && ack("B",1))setPhase(2); + else if(phase==2 && ack("A",2) && ack("B",2)){ + RuntimeChecks.require((body.getStackInSlot(6)).isEmpty() && count(a,19)+count(b,19)==23,"Two TCP clients duplicated/lost contested stack"); + RuntimeChecks.require(count(a,19)==0 || count(b,19)==0,"Both clients recovered the same stack"); + mark("two_tcp_clients_contested_shift_click"); + a.inventory.clear();b.inventory.clear();body.clear(); + a.inventory.setItemStack(stack(19,10));b.inventory.mainInventory.set(0,stack(20,7));refresh(a);refresh(b);setPhase(3); + }else if(phase==3 && ack("A",3))setPhase(4); + else if(phase==4 && ack("B",4)){ + equal(body.getStackInSlot(6),stack(20,7),"TCP hotbar swap failed");setPhase(5); + }else if(phase==5 && ack("A",5)){ + equal(body.getStackInSlot(6),stack(20,7),"TCP drag overwrote other player's NBT");equal(body.getStackInSlot(7),stack(19,5),"TCP drag allocation");equal(a.inventory.getItemStack(),stack(19,5),"TCP cursor remainder"); + mark("two_tcp_clients_hotbar_interleaved_with_drag");setPhase(6); + }else if(phase==6 && ack("A",6)){ + equal(a.inventory.getItemStack(),null,"TCP deposit cursor not empty");RuntimeChecks.require(count(a,19)==5,"TCP drag remainder deposit");setPhase(7); + }else if(phase==7 && ack("A",7) && ack("B",7)){ + File folder=server.getWorld(0).getSaveHandler().getWorldDirectory(); + RuntimeChecks.saveExpected(folder,"multiplayer-body",body);savePlayer(folder,a,"A");savePlayer(folder,b,"B"); + server.getPlayerList().saveAllPlayerData();server.saveAllWorlds(false);mark("two_tcp_clients_final_gui_and_saved_nbt"); + setPhase(8); + }else if(phase==8 && server.getPlayerList().getCurrentPlayerCount()==0){ + Files.write(new File(sync,"multiplayer.pass").toPath(),"two TCP clients passed\n".getBytes(StandardCharsets.UTF_8)); + System.out.println("LOOTABLE_BODIES_MULTIPLAYER_PASS");finishing=true;server.initiateShutdown(); + } + }catch(Throwable error){error.printStackTrace();finishing=true;server.initiateShutdown();} + } + private void setPhase(int value) throws Exception { + phase=value;File temporary=new File(sync,"phase.tmp");Files.write(temporary.toPath(),Integer.toString(value).getBytes(StandardCharsets.UTF_8)); + Files.move(temporary.toPath(),new File(sync,"phase").toPath(),StandardCopyOption.REPLACE_EXISTING,StandardCopyOption.ATOMIC_MOVE); + System.out.println("MULTIPLAYER_PHASE "+value); + } + private void mark(String name) throws Exception{try(FileWriter out=new FileWriter(new File(sync,"checks.txt"),true)){out.write("PASS "+name+"\n");}System.out.println("MULTIPLAYER_CHECK_PASS "+name);} + public static ItemStack stack(int i,int n){ItemStack s=RuntimeChecks.stack(i);s.setCount(n);return s;} + public static void equal(ItemStack a,ItemStack b,String reason){RuntimeChecks.require(RuntimeChecks.sameStack(a == null ? ItemStack.EMPTY : a,b == null ? ItemStack.EMPTY : b),reason);} + private static int count(EntityPlayerMP p,int index){int n=0;ItemStack expected=RuntimeChecks.stack(index);for(int i=0;i { + public IMessage onMessage(Step message,MessageContext context){ + EntityPlayerMP player=context.getServerHandler().player; + player.getServerWorld().addScheduledTask(()->{ + try{RuntimeChecks.require(player.getName().matches("Container[AB]"),"Unexpected fixture player");Files.write(new File(sync(),player.getName()+".step-"+message.number).toPath(),"ack\n".getBytes(StandardCharsets.UTF_8));} + catch(Exception error){throw new RuntimeException(error);} + });return null; + } + } +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/MultiplayerClientChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/MultiplayerClientChecks.java new file mode 100644 index 0000000..60555c4 --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/MultiplayerClientChecks.java @@ -0,0 +1,79 @@ +package cyano.lootable.buildtest; + +import cyano.lootable.entities.EntityLootableBody; +import cyano.lootable.graphics.CorpseGUIContainer; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.HashSet; +import java.util.Set; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiMainMenu; +import net.minecraft.client.multiplayer.GuiConnecting; +import net.minecraft.entity.Entity; +import net.minecraft.inventory.ClickType; +import net.minecraft.util.EnumHand; +import net.minecraft.util.ScreenShotHelper; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.fml.common.FMLCommonHandler; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; +import net.minecraftforge.fml.common.gameevent.TickEvent; + +/** Two independent real Forge clients; no native desktop input or control. */ +final class MultiplayerClientChecks { + private final String role=System.getProperty("lootablebodies.multiplayer.role"); + private final Set completed=new HashSet<>(); + private boolean connecting; + private boolean finished; + private int lastPhase=-1,frames,ticks; + void register(){if(role!=null&&!Boolean.getBoolean("lootablebodies.visibility")&&!Boolean.getBoolean("lootablebodies.respawn")){FMLCommonHandler.instance().bus().register(this);MinecraftForge.EVENT_BUS.register(this);}} + @SubscribeEvent public void mouse(net.minecraftforge.client.event.GuiScreenEvent.MouseInputEvent.Pre e){e.setCanceled(true);} + @SubscribeEvent public void key(net.minecraftforge.client.event.GuiScreenEvent.KeyboardInputEvent.Pre e){e.setCanceled(true);} + @SubscribeEvent public void tick(TickEvent.ClientTickEvent e){ + if(e.phase!=TickEvent.Phase.END)return;Minecraft mc=Minecraft.getMinecraft(); + if(mc.inGameHasFocus)mc.setIngameNotInFocus();net.minecraft.client.settings.KeyBinding.unPressAllKeys(); + try{ + if(finished){mc.shutdown();return;} + RuntimeChecks.require(++ticks<6000,"Multiplayer client timeout role="+role+" phase="+lastPhase); + if(new File(MultiplayerChecks.sync(),"abort").exists())throw new AssertionError("Multiplayer runner abort"); + if(!connecting && mc.currentScreen instanceof GuiMainMenu){ + connecting=true;mc.gameSettings.pauseOnLostFocus=false;mc.gameSettings.limitFramerate=30;mc.gameSettings.renderDistanceChunks=4; + mc.displayGuiScreen(new GuiConnecting(new GuiMainMenu(),mc,"127.0.0.1",Integer.parseInt(System.getProperty("lootablebodies.multiplayer.port")))); + } + if(mc.world==null || mc.player==null)return; + int phase;try{phase=Integer.parseInt(new String(Files.readAllBytes(new File(MultiplayerChecks.sync(),"phase").toPath()),StandardCharsets.UTF_8).trim());}catch(IOException busy){return;} + if(phase!=lastPhase){lastPhase=phase;frames=0;System.out.println("TCP_CLIENT_PHASE "+role+" "+phase);}frames++; + if(phase==8){ + try(FileWriter out=new FileWriter(new File(mc.gameDir,"multiplayer-client.pass"))){out.write("two-client packet/GUI checks passed\n");} + System.out.println("LOOTABLE_BODIES_MULTIPLAYER_CLIENT_PASS role="+role); + finished=true;mc.player.closeScreen();mc.world.sendQuittingDisconnectingPacket();mc.loadWorld(null);mc.displayGuiScreen(new GuiMainMenu());return; + } + if(frames%100==0)System.out.println("TCP_CLIENT_STATE role="+role+" phase="+phase+" screen="+(mc.currentScreen==null?"world":mc.currentScreen.getClass().getSimpleName())+" container="+mc.player.openContainer.getClass().getSimpleName()+" window="+mc.player.openContainer.windowId+" position="+mc.player.getPosition()+" completed="+completed); + if(!(mc.currentScreen instanceof CorpseGUIContainer)){ + if(frames%10==0)for(Entity entity:mc.world.loadedEntityList)if(entity instanceof EntityLootableBody && entity.getUniqueID().equals(MultiplayerChecks.BODY))mc.playerController.interactWithEntity(mc.player,entity,EnumHand.MAIN_HAND); + return; + } + if(completed.contains(phase) || frames<15)return;int window=mc.player.openContainer.windowId; + if(phase==1){ + if(!RuntimeChecks.sameStack(mc.player.openContainer.getSlot(6).getStack(),MultiplayerChecks.stack(19,23)))return; + shot(mc,"shared-ready.png");ack(1); + }else if(phase==2){mc.playerController.windowClick(window,6,0,ClickType.QUICK_MOVE,mc.player);ack(2);} + else if(phase==3 && role.equals("A")){ + if(!RuntimeChecks.sameStack(mc.player.inventory.getItemStack(),MultiplayerChecks.stack(19,10)))return; + mc.playerController.windowClick(window,-999,0,ClickType.QUICK_CRAFT,mc.player);mc.playerController.windowClick(window,6,1,ClickType.QUICK_CRAFT,mc.player);mc.playerController.windowClick(window,7,1,ClickType.QUICK_CRAFT,mc.player);ack(3); + }else if(phase==4 && role.equals("B")){ + if(!RuntimeChecks.sameStack(mc.player.inventory.mainInventory.get(0),MultiplayerChecks.stack(20,7)))return; + mc.playerController.windowClick(window,6,0,ClickType.SWAP,mc.player);ack(4); + }else if(phase==5 && role.equals("A")){mc.playerController.windowClick(window,-999,2,ClickType.QUICK_CRAFT,mc.player);ack(5);} + else if(phase==6 && role.equals("A")){ + MultiplayerChecks.equal(mc.player.inventory.getItemStack(),MultiplayerChecks.stack(19,5),"TCP client cursor prediction differs"); + mc.playerController.windowClick(window,48,0,ClickType.PICKUP,mc.player);ack(6); + }else if(phase==7){ + MultiplayerChecks.equal(mc.player.openContainer.getSlot(6).getStack(),MultiplayerChecks.stack(20,7),"Shared GUI slot 6 differs");MultiplayerChecks.equal(mc.player.openContainer.getSlot(7).getStack(),MultiplayerChecks.stack(19,5),"Shared GUI slot 7 differs"); + MultiplayerChecks.equal(mc.player.inventory.getItemStack(),null,"Shared GUI cursor differs");shot(mc,"shared-final.png");ack(7); + } + }catch(Throwable error){error.printStackTrace();mc.shutdown();} + } + private void ack(int phase){completed.add(phase);MultiplayerChecks.CHANNEL.sendToServer(new MultiplayerChecks.Step(phase));} + private void shot(Minecraft mc,String name){ScreenShotHelper.saveScreenshot(mc.gameDir,name,mc.displayWidth,mc.displayHeight,mc.getFramebuffer());} +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/NameLifetimeChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/NameLifetimeChecks.java new file mode 100644 index 0000000..278854c --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/NameLifetimeChecks.java @@ -0,0 +1,117 @@ +package cyano.lootable.buildtest; + +import com.mojang.authlib.GameProfile; +import cyano.lootable.LootableBodies; +import cyano.lootable.entities.EntityLootableBody; +import java.io.*; +import java.lang.reflect.Field; +import java.util.*; +import net.minecraft.entity.Entity; +import net.minecraft.init.Items; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.*; +import net.minecraft.server.MinecraftServer; +import net.minecraft.world.WorldServer; + +/** Real legacy-engine contracts for owned scalar fields; no production dependency. */ +final class NameLifetimeChecks { + static final String RECOVERY="LootableBodiesInvalidFields"; + private interface Check {void run() throws Exception;} + private static WorldServer world; + private static final List results=new ArrayList<>(); + private static int failures; + static void run(MinecraftServer server,WorldServer target,File folder) throws Exception { + world=target;results.clear();failures=0; + NBTBase[] wrong={new NBTTagString("not a timer"),new NBTTagFloat(1.5F),new NBTTagDouble(Double.NaN),new NBTTagDouble(Double.POSITIVE_INFINITY),new NBTTagCompound(),new NBTTagList(),new NBTTagByteArray(new byte[]{1}),new NBTTagIntArray(new int[]{1})}; + for(String key:new String[]{"Vac","DeathTime","DecayTime"}) { + for(int i=0;iinvalid(key,value));} + for(long value:new long[]{Long.MIN_VALUE,-1,Long.MAX_VALUE,10001})check(key+"_invalid_value_"+value,()->invalid(key,new NBTTagLong(value))); + } + for(int i=0;ibadName("Name",value));} + check("Name_over_packet_string_limit",()->badName("Name",new NBTTagString(repeat('n',32768)))); + check("Name_utf8_bytes_over_packet_limit",()->badName("Name",new NBTTagString(repeat('\u20ac',10923)))); + check("CustomName_utf8_bytes_over_packet_limit",()->badName("CustomName",new NBTTagString(repeat('\u20ac',10923)))); + check("name_exact_packet_byte_limit_preserved",NameLifetimeChecks::packetBoundary); + check("CustomName_wrong_type_retained",()->badName("CustomName",new NBTTagInt(27))); + check("CustomName_over_packet_string_limit",()->badName("CustomName",new NBTTagString(repeat('c',32768)))); + check("custom_name_fallback_and_source_immutable",NameLifetimeChecks::fallback); + check("valid_name_authoritative_without_username_regex",NameLifetimeChecks::namePriority); + check("save_before_first_tick_keeps_name",NameLifetimeChecks::earlySave); + check("missing_name_clears_reused_profile_and_name",NameLifetimeChecks::missingName); + check("reused_writer_clears_name_and_completed_vacuum",NameLifetimeChecks::reusedWriter); + check("reused_full_writer_does_not_restore_cleared_name",NameLifetimeChecks::clearedCustomName); + check("blank_name_keeps_legacy_unnamed_behavior",NameLifetimeChecks::blankName); + check("missing_timers_reset_reused_instance_to_now",NameLifetimeChecks::missingTimers); + check("custom_timestamp_not_vanilla_death_animation",NameLifetimeChecks::animation); + for(int i=0;i<4;i++){final int type=i;check("integral_scalar_type_"+i,()->integral(type));} + check("zero_timestamps_and_vacuum_are_valid",NameLifetimeChecks::zero); + check("vacuum_upper_bound_and_invalid_21",NameLifetimeChecks::vacuumLimit); + check("future_timestamp_does_not_delay_forever",NameLifetimeChecks::future); + check("clock_rollback_rebases_both_timers",NameLifetimeChecks::rollback); + check("negative_world_clock_cannot_overflow_age",NameLifetimeChecks::negativeClock); + check("large_catchup_does_not_wrap_damage_or_repeat",NameLifetimeChecks::largeCatchup); + check("long_max_elapsed_time_and_remainder",NameLifetimeChecks::maxCatchup); + check("long_interval_catchup_does_not_overflow",NameLifetimeChecks::longInterval); + check("strict_decay_boundary_preserved",NameLifetimeChecks::boundary); + check("nonempty_default_pauses_corpse_decay",NameLifetimeChecks::occupied); + check("disabled_decay_preserves_items_and_timestamps",NameLifetimeChecks::disabled); + check("invalid_metadata_does_not_count_as_inventory",NameLifetimeChecks::metadataOnly); + check("scalar_and_unresolved_inventory_conserved_on_reload",NameLifetimeChecks::inventory); + check("unknown_scalar_recovery_record_preserved",NameLifetimeChecks::unknownRecovery); + check("wrong_scalar_recovery_tag_preserved_idempotently",NameLifetimeChecks::wrongRecovery); + check("healthy_save_has_no_scalar_recovery_tag",NameLifetimeChecks::healthy); + try(PrintWriter out=new PrintWriter(new File(folder,"name-lifetime-checks.txt"),"UTF-8")){for(String s:results)out.println(s);out.println("checks="+results.size()+" failures="+failures);} + RuntimeChecks.require(failures==0,"Name/lifetime regressions: "+failures+" / "+results.size()); + System.out.println("LOOTABLE_BODIES_NAME_LIFETIME_PASS checks="+results.size()); + } + private static void check(String name,Check test) { + long clock=world.getTotalWorldTime(),rate=LootableBodies.ticksPerItemDecay,life=LootableBodies.corpseDecayTime; + boolean decay=LootableBodies.allowCorpseDecay,empty=LootableBodies.decayOnlyWhenEmpty; + Set before=new HashSet<>(world.loadedEntityList); + try{world.getWorldInfo().setWorldTotalTime(10000);LootableBodies.allowCorpseDecay=false;LootableBodies.ticksPerItemDecay=-1;test.run();results.add("PASS "+name);System.out.println("NAME_LIFETIME_PASS "+name);} + catch(Throwable e){failures++;results.add("FAIL "+name+": "+e);System.out.println("NAME_LIFETIME_FAIL "+name+": "+e);e.printStackTrace();} + finally{world.getWorldInfo().setWorldTotalTime(clock);LootableBodies.ticksPerItemDecay=rate;LootableBodies.corpseDecayTime=life;LootableBodies.allowCorpseDecay=decay;LootableBodies.decayOnlyWhenEmpty=empty;for(Entity e:new ArrayList(world.loadedEntityList))if(!before.contains(e))world.removeEntityDangerously(e);} + } + static NBTTagCompound base(){EntityLootableBody b=new EntityLootableBody(world);b.setPosition(160.5,80,160.5);b.setNoGravity(true);b.setCustomNameTag("Saved Owner");b.setInventorySlotContents(6,NbtRecoveryChecks.item(120,17));NBTTagCompound n=new NBTTagCompound();b.writeToNBTOptional(n);n.setString("Name","Saved Owner");n.setByte("Vac",(byte)20);return n;} + static EntityLootableBody read(NBTTagCompound n){NBTTagCompound copy=n.copy();EntityLootableBody b=new EntityLootableBody(world);b.readFromNBT(n);RuntimeChecks.require(same(n,copy),"Input NBT mutated");return b;} + static NBTTagCompound saved(EntityLootableBody b){NBTTagCompound n=new NBTTagCompound();b.writeToNBTOptional(n);return n;} + static void retained(EntityLootableBody b,String key,NBTBase raw){NBTTagList l=saved(b).getTagList(RECOVERY,10);boolean found=false;for(int i=0;i=0 && saved(b).getLong("DecayTime")>=0 && termination(b)<0,"Negative world clock caused invalid lifetime");} + private static EntityLootableBody tool(long now,long interval){world.getWorldInfo().setWorldTotalTime(now);EntityLootableBody b=timed(0);b.clear();ItemStack tool=new ItemStack(Items.DIAMOND_PICKAXE);tool.setItemDamage(17);b.setInventorySlotContents(6,tool);LootableBodies.ticksPerItemDecay=interval;return b;} + private static void catchup(long now,long interval){EntityLootableBody b=tool(now,interval);b.onEntityUpdate();ItemStack s=b.getStackInSlot(6);RuntimeChecks.require((s != null && !s.isEmpty()) && s.getCount()==1 && s.getItemDamage()==s.getMaxDamage()-1,"Catchup overflow repaired, erased or under-decayed tool");long stamp=now-now%interval;RuntimeChecks.require(saved(b).getLong("DecayTime")==stamp,"Catchup timestamp wrapped or lost remainder");b.onEntityUpdate();RuntimeChecks.require(saved(b).getLong("DecayTime")==stamp && s.getItemDamage()==s.getMaxDamage()-1,"Catchup repeated");} + private static void largeCatchup(){catchup((1L<<31)+1,1);} + private static void maxCatchup(){catchup(Long.MAX_VALUE,6000);} + private static void longInterval(){EntityLootableBody b=tool(Long.MAX_VALUE,Long.MAX_VALUE/2);b.onEntityUpdate();RuntimeChecks.require(saved(b).getLong("DecayTime")==Long.MAX_VALUE-1 && b.getStackInSlot(6).getItemDamage()==19,"Wide interval arithmetic changed");} + private static void boundary(){EntityLootableBody b=tool(10,10);LootableBodies.allowCorpseDecay=true;LootableBodies.decayOnlyWhenEmpty=false;LootableBodies.corpseDecayTime=10;b.onEntityUpdate();RuntimeChecks.require(b.getStackInSlot(6).getItemDamage()==17 && termination(b)<0,"Strict > boundary changed");world.getWorldInfo().setWorldTotalTime(11);b.onEntityUpdate();RuntimeChecks.require(b.getStackInSlot(6).getItemDamage()==18 && termination(b)>=0,"Elapsed threshold ignored");} + private static void occupied(){EntityLootableBody b=timed(0);LootableBodies.allowCorpseDecay=true;LootableBodies.decayOnlyWhenEmpty=true;LootableBodies.corpseDecayTime=40;b.onEntityUpdate();RuntimeChecks.require(saved(b).getLong("DeathTime")==10000 && termination(b)<0,"Occupied default no longer pauses expiration");} + private static void disabled(){EntityLootableBody b=tool(Long.MAX_VALUE,-1);b.onEntityUpdate();RuntimeChecks.require(b.getStackInSlot(6).getItemDamage()==17 && saved(b).getLong("DecayTime")==0 && saved(b).getLong("DeathTime")==0,"Disabled timer changed state");} + private static int termination(EntityLootableBody b){try{Field f=EntityLootableBody.class.getDeclaredField("terminate");f.setAccessible(true);return f.getInt(b);}catch(Exception e){throw new AssertionError(e);}} + private static void metadataOnly(){NBTTagCompound n=base();n.setTag("Equipment",new NBTTagList());n.setString("DeathTime","opaque");EntityLootableBody b=read(n);RuntimeChecks.require(b.isEmpty(),"Scalar evidence counted as inventory");LootableBodies.allowCorpseDecay=true;LootableBodies.decayOnlyWhenEmpty=true;LootableBodies.corpseDecayTime=40;world.getWorldInfo().setWorldTotalTime(10041);b.onEntityUpdate();RuntimeChecks.require(termination(b)>=0,"Scalar evidence made empty corpse immortal");} + private static void inventory(){NBTTagCompound n=base();NBTTagCompound missing=NbtRecoveryChecks.entry(121,11,7);missing.setString("id","absentfixture:missing");n.getTagList("Equipment",10).appendTag(missing);n.setString("DeathTime","bad");n.setTag("Name",new NBTTagCompound());EntityLootableBody b=read(n);NbtRecoveryChecks.retained(b,missing);stable(b);b.readFromNBT(saved(b));stable(b);RuntimeChecks.require(saved(b).getTagList(RECOVERY,10).tagCount()==2 && saved(b).getTagList(NbtRecoveryChecks.RECOVERY,10).tagCount()==1,"Raw scalar/item evidence accumulated or disappeared");} + private static void unknownRecovery(){NBTTagCompound n=base(),record=new NBTTagCompound();record.setString("Future","opaque");n.setTag(RECOVERY,NbtRecoveryChecks.list(record));EntityLootableBody b=read(n);RuntimeChecks.require(saved(b).getTagList(RECOVERY,10).getCompoundTagAt(0).equals(record),"Unknown metadata evidence changed");stable(b);} + private static void wrongRecovery(){NBTTagCompound n=base();NBTTagString raw=new NBTTagString("opaque");n.setTag(RECOVERY,raw);EntityLootableBody b=read(n);retained(b,"Recovery",raw);stable(b);} + private static void healthy(){EntityLootableBody b=read(base());b.onEntityUpdate();stable(b);RuntimeChecks.require(!saved(b).hasKey(RECOVERY),"Healthy save gained invalid-field data");} + // NBTTagDouble.equals uses ==, so NaN copies are unequal without any mutation. + static boolean same(NBTBase a,NBTBase b){if(a==null || b==null)return a==b;return Arrays.equals(encoded(a),encoded(b));} + private static byte[] encoded(NBTBase value){try{NBTTagCompound n=new NBTTagCompound();n.setTag("Value",value);ByteArrayOutputStream out=new ByteArrayOutputStream();CompressedStreamTools.write(n,new DataOutputStream(out));return out.toByteArray();}catch(IOException e){throw new AssertionError(e);}} + private static String repeat(char c,int size){char[] a=new char[size];Arrays.fill(a,c);return new String(a);} +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/NamelessCorpseClientChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/NamelessCorpseClientChecks.java new file mode 100644 index 0000000..b0f7674 --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/NamelessCorpseClientChecks.java @@ -0,0 +1,155 @@ +package cyano.lootable.buildtest; + +import cyano.lootable.LootableBodies; +import cyano.lootable.entities.EntityLootableBody; +import cyano.lootable.graphics.CorpseGUIContainer; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.concurrent.Future; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiMainMenu; +import net.minecraft.client.resources.Language; +import net.minecraft.entity.Entity; +import net.minecraft.entity.EntityList; +import net.minecraft.init.Blocks; +import net.minecraft.init.Items; +import net.minecraft.item.ItemMonsterPlacer; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.util.EnumHand; +import net.minecraft.util.ResourceLocation; +import net.minecraft.util.ScreenShotHelper; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.*; +import net.minecraftforge.client.event.RenderLivingEvent; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; + +/** Exercise the actual client locale, entity label, spawn egg and saved name. */ +final class NamelessCorpseClientChecks { + private static final String[] LOCALES = {"en_us","de_de","en_au","en_ca","en_en","en_gb","en_pt", + "es_es","es_mx","fr_ca","fr_fr","it_it","nl_nl","zh_cn"}; + private static final UUID BODY = UUID.fromString("00000000-0000-4000-8000-000000000119"); + private int stage, frames, locale; + private Future pending; + private Language originalLanguage; + private boolean originalUnicode, originalBidi; + private boolean renderedBody; + + @SubscribeEvent public void rendered(RenderLivingEvent.Post event) { + if(event.getEntity().getUniqueID().equals(BODY)) renderedBody=true; + } + + static void englishContract(Minecraft mc) throws Exception { + assertLocale(mc, "en_us"); + } + + private static String translation(Minecraft mc, String code) throws Exception { + Properties values = new Properties(); + try (Reader in = new InputStreamReader(mc.getResourceManager().getResource( + new ResourceLocation("lootablebodies", "lang/"+code+".lang")).getInputStream(), StandardCharsets.UTF_8)) { + values.load(in); + } + String expected = values.getProperty("entity.lootablebodies.Corpse.name"); + RuntimeChecks.require(expected != null && !expected.isEmpty(), "Missing original locale "+code); + return expected; + } + + private static void assertLocale(Minecraft mc, String code) throws Exception { + String expected = translation(mc, code); + EntityLootableBody body = new EntityLootableBody(mc.world); + body.setPosition(18.5,80,18.5); + RuntimeChecks.disableVacuum(body); + for (int slot=0;slot<48;slot++) body.setInventorySlotContents(slot,RuntimeChecks.stack(slot)); + body.addItem(RuntimeChecks.stack(70)); + NBTTagCompound data = NameLifetimeChecks.saved(body); + NBTTagCompound retained = new NBTTagCompound(); + retained.setString("Source","FutureFixture"); retained.setString("Data","retain verbatim"); + data.setTag("LootableBodiesUnresolved",NbtRecoveryChecks.list(retained)); + body = (EntityLootableBody)EntityList.createEntityFromNBT(data,mc.world); + RuntimeChecks.require(body != null && "Corpse".equals(EntityList.getEntityString(body)) && + new ResourceLocation("lootablebodies:corpse").equals(EntityList.getKey(body)), "Registry/display identity changed"); + NBTTagCompound before = NameLifetimeChecks.saved(body); + RuntimeChecks.require(expected.equals(body.getName()), "Untranslated nameless corpse in "+code+": "+body.getName()); + RuntimeChecks.require(expected.equals(body.getDisplayName().getUnformattedText()), "Wrong rendered name in "+code); + RuntimeChecks.require(!body.hasCustomName() && !before.hasKey("Name") && !before.hasKey("CustomName"), "Localized fallback became an owner name"); + RuntimeChecks.require(NameLifetimeChecks.same(before,NameLifetimeChecks.saved(body)), "Reading fallback mutated complete NBT"); + EntityLootableBody loaded = (EntityLootableBody)EntityList.createEntityFromNBT(before.copy(),mc.world); + RuntimeChecks.require(expected.equals(loaded.getName()) && NameLifetimeChecks.same(before,NameLifetimeChecks.saved(loaded)), + "Fallback reload changed UUID, inventory, recovery or other NBT"); + for (String owner : new String[]{"Owner Fixture", "entity.Corpse.name", "Owner \u00e9\u540d"}) { + body.setUserName(owner); NBTTagCompound named = NameLifetimeChecks.saved(body); + RuntimeChecks.require(owner.equals(body.getName()) && owner.equals(body.getDisplayName().getUnformattedText()), "Owner name was translated/renamed"); + RuntimeChecks.require(NameLifetimeChecks.same(named,NameLifetimeChecks.saved(body)), "Owner lookup mutated NBT"); + } + body.setUserName(""); NBTTagCompound cleared = NameLifetimeChecks.saved(body); + RuntimeChecks.require(expected.equals(body.getName()) && !body.hasCustomName() && + NameLifetimeChecks.same(cleared,NameLifetimeChecks.saved(body)), "Cleared owner did not regain transient fallback"); + ItemStack egg = new ItemStack(Items.SPAWN_EGG); + ItemMonsterPlacer.applyEntityIdToItemStack(egg,new ResourceLocation("lootablebodies:corpse")); + NBTTagCompound eggBefore = egg.writeToNBT(new NBTTagCompound()); + RuntimeChecks.require(egg.getDisplayName().endsWith(" "+expected) && !egg.getDisplayName().contains("entity.Corpse.name"), "Untranslated spawn egg in "+code); + RuntimeChecks.require(NameLifetimeChecks.same(eggBefore,egg.writeToNBT(new NBTTagCompound())), "Egg name lookup mutated its native ID/NBT"); + System.out.println("NAME_TRANSLATION_PASS "+code); + } + + void tick(Minecraft mc) throws Exception { + if(stage==0 && mc.currentScreen instanceof GuiMainMenu) { + mc.gameSettings.pauseOnLostFocus=false;mc.gameSettings.limitFramerate=30;mc.gameSettings.renderDistanceChunks=4; + originalLanguage=mc.getLanguageManager().getCurrentLanguage(); + originalUnicode=mc.fontRenderer.getUnicodeFlag(); originalBidi=mc.fontRenderer.getBidiFlag(); + MinecraftForge.EVENT_BUS.register(this); + mc.launchIntegratedServer("name-fixture","Name fixture",new WorldSettings(4267321,GameType.CREATIVE,false,false,WorldType.FLAT));stage=1; + } else if(stage==1 && mc.world!=null && mc.player!=null && mc.getIntegratedServer()!=null) { + pending=mc.getIntegratedServer().addScheduledTask(() -> { + WorldServer world=mc.getIntegratedServer().getWorld(0); + world.getGameRules().setOrCreateGameRule("doMobSpawning","false"); + for(int x=12;x<=24;x++)for(int z=12;z<=24;z++)world.setBlockState(new BlockPos(x,79,z),Blocks.STONE.getDefaultState()); + EntityLootableBody body=new EntityLootableBody(world);body.setUniqueId(BODY);body.setPosition(18.5,80,18.5);body.setNoGravity(true);RuntimeChecks.disableVacuum(body); + for(int i=0;i<48;i++)body.setInventorySlotContents(i,RuntimeChecks.stack(i)); + RuntimeChecks.require(world.spawnEntity(body),"Spawn nameless graphical fixture"); + EntityLootableBody named=new EntityLootableBody(world);named.setPosition(20.5,80,18.5);named.setNoGravity(true);named.setUserName("Owner Fixture");RuntimeChecks.disableVacuum(named);world.spawnEntity(named); + net.minecraft.entity.player.EntityPlayerMP player=mc.getIntegratedServer().getPlayerList().getPlayerByUUID(mc.player.getUniqueID()); + player.capabilities.isFlying=true;player.sendPlayerAbilities();player.connection.setPlayerLocation(18.5,80,14.5,0,12); + });stage=2;frames=0; + } else if(stage==2 && pending.isDone() && ++frames>30) { + pending.get();stage=3;frames=0; + } else if(stage==3) { + // Keep the disposable view attached to the current network player. + // The integrated-server join may replace its initial camera entity. + mc.setRenderViewEntity(mc.player);mc.gameSettings.thirdPersonView=0; + mc.player.rotationYaw=mc.player.prevRotationYaw=0; + mc.player.rotationPitch=mc.player.prevRotationPitch=20; + String code=LOCALES[locale]; + if(frames++==0) { + renderedBody=false; + mc.getLanguageManager().setCurrentLanguage(new Language(code,code,code,false)); + mc.getLanguageManager().onResourceManagerReload(mc.getResourceManager()); + mc.fontRenderer.setUnicodeFlag(mc.getLanguageManager().isCurrentLocaleUnicode()); + mc.fontRenderer.setBidiFlag(mc.getLanguageManager().isCurrentLanguageBidirectional()); + assertLocale(mc,code); + try(PrintWriter out=new PrintWriter(new OutputStreamWriter(new FileOutputStream(new File(mc.gameDir,"name-checks.txt"),true),StandardCharsets.UTF_8))) {out.println("PASS locale_"+code+"_fallback_owner_egg_complete_nbt");} + } + if(frames==15 && Arrays.asList("en_us","de_de","en_pt","es_es","zh_cn").contains(code)) { + System.out.println("NAME_CAMERA player="+mc.player.getPosition()+" view="+mc.getRenderViewEntity().getPosition()+" yaw="+mc.player.rotationYaw+" pitch="+mc.player.rotationPitch+" rendered="+renderedBody); + RuntimeChecks.require(renderedBody,"No corpse rendered for label screenshot "+code); + ScreenShotHelper.saveScreenshot(mc.gameDir,"name-"+code+".png",mc.displayWidth,mc.displayHeight,mc.getFramebuffer()); + } + if(frames>20) {frames=0;locale++;if(locale==LOCALES.length) { + mc.getLanguageManager().setCurrentLanguage(originalLanguage);mc.getLanguageManager().onResourceManagerReload(mc.getResourceManager()); + mc.fontRenderer.setUnicodeFlag(originalUnicode);mc.fontRenderer.setBidiFlag(originalBidi);stage=4; + }} + } else if(stage==4) { + for(Entity entity:mc.world.loadedEntityList)if(entity.getUniqueID().equals(BODY)) { + RuntimeChecks.require("Corpse".equals(entity.getName()) && !entity.hasCustomName(),"Network corpse has wrong English fallback/custom name"); + mc.playerController.interactWithEntity(mc.player,entity,EnumHand.MAIN_HAND);stage=5;frames=0;break; + } + } else if(stage==5 && mc.currentScreen instanceof CorpseGUIContainer && ++frames>20) { + for(int slot=0;slot<48;slot++)RuntimeChecks.require(RuntimeChecks.sameStack(mc.player.openContainer.getSlot(slot).getStack(),RuntimeChecks.stack(slot)),"Name fix changed network inventory slot "+slot); + ScreenShotHelper.saveScreenshot(mc.gameDir,"name-english-inventory.png",mc.displayWidth,mc.displayHeight,mc.getFramebuffer()); + new File(mc.gameDir,"client.pass").createNewFile();System.out.println("LOOTABLE_BODIES_NAME_TRANSLATION_PASS locales="+LOCALES.length); + mc.player.closeScreen();mc.getIntegratedServer().initiateShutdown();stage=6; + } else if(stage==6 && mc.world==null && !mc.isIntegratedServerRunning()) {mc.shutdown();stage=7;} + } +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/NbtClientChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/NbtClientChecks.java new file mode 100644 index 0000000..c403581 --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/NbtClientChecks.java @@ -0,0 +1,94 @@ +package cyano.lootable.buildtest; + +import cyano.lootable.entities.EntityLootableBody; +import cyano.lootable.graphics.CorpseGUIContainer; +import java.io.*; +import java.util.*; +import java.util.concurrent.Future; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiMainMenu; +import net.minecraft.entity.Entity; +import net.minecraft.entity.player.EntityPlayerMP; +import net.minecraft.init.Blocks; +import net.minecraft.inventory.ClickType; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.*; +import net.minecraft.util.EnumHand; +import net.minecraft.util.ScreenShotHelper; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.GameType; + +/** Real GUI recovery after loading malformed NBT, including unresolved data across another save. */ +final class NbtClientChecks { + private interface Action {void run() throws Exception;} + private final UUID bodyId=UUID.fromString("00000000-0000-4000-8000-000000000121"); + private UUID playerId; + private int stage,frames; + private Future pending; + private NBTTagCompound visible; + private Map before; + private NBTTagList expectedPlayer; + void start(Minecraft mc){playerId=mc.player.getUniqueID();} + boolean tick(Minecraft mc) throws Exception { + frames++;RuntimeChecks.require(frames<300,"NBT graphical stage timeout "+stage); + if(stage==0){schedule(mc,()->{ + EntityPlayerMP p=player(mc);p.inventory.clear();p.inventory.setItemStack(ItemStack.EMPTY);p.setGameType(GameType.SURVIVAL); + for(int x=157;x<=171;x++)for(int z=157;z<=163;z++)p.world.setBlockState(new BlockPos(x,79,z),Blocks.STONE.getDefaultState()); + NbtWorldChecks.seed(p.getServerWorld(),bodyId,160,folder(mc),"graphical"); + });next(1);} + else if(stage==1 && done() && frames>20){quit(mc);next(2);} + else if(stage==2 && mc.world==null && !mc.isIntegratedServerRunning()){mc.launchIntegratedServer("build-fixture","Build fixture",null);next(3);} + else if(stage==3 && ready(mc) && frames>20){schedule(mc,()->player(mc).connection.setPlayerLocation(168.5,80,159.5,0,0));next(30);} + else if(stage==30 && done() && frames>20){schedule(mc,()->{ + EntityLootableBody b=body(mc);for(int i=0;i<8;i++)b.onEntityUpdate();NbtWorldChecks.verify(b,false); + before=NbtRecoveryChecks.items(b);visible=full(b);RuntimeChecks.saveExpected(folder(mc),"nbt-client-before",b); + });next(4);} + else if(stage==4 && done() && frames>20){if(open(mc))next(5);} + else if(stage==5 && gui(mc) && frames>20){verifyGui(mc);equal(mc.player.openContainer.getSlot(6).getStack(),NbtRecoveryChecks.item(120,17),"Malformed GUI recovery source differs");screenshot(mc,"nbt-recovered-corpse.png");mark(mc,"malformed_disk_load_gui_exact_inventory");mc.playerController.windowClick(mc.player.openContainer.windowId,6,0,ClickType.QUICK_MOVE,mc.player);next(6);} + else if(stage==6 && frames>25){schedule(mc,()->{ + Map after=NbtRecoveryChecks.items(body(mc));for(int i=0;i20){schedule(mc,()->player(mc).connection.setPlayerLocation(168.5,80,159.5,0,0));next(90);} + else if(stage==90 && done() && frames>20){schedule(mc,()->{ + body(mc).onEntityUpdate();RuntimeChecks.verifyExpected(folder(mc),"nbt-client-after",body(mc)); + RuntimeChecks.require(Objects.equals(visible.getTag(NbtRecoveryChecks.RECOVERY),NbtRecoveryChecks.saved(body(mc)).getTag(NbtRecoveryChecks.RECOVERY)),"Unresolved raw data changed on graphical disk reload"); + NbtWorldChecks.verifyMetadata(body(mc));RuntimeChecks.require(NameLifetimeChecks.same(visible.getTag(NameLifetimeChecks.RECOVERY),full(body(mc)).getTag(NameLifetimeChecks.RECOVERY)),"Raw scalar evidence changed on graphical reload"); + RuntimeChecks.require(expectedPlayer.equals(player(mc).inventory.writeToNBT(new NBTTagList())),"Recovered player NBT changed on graphical disk reload");checkPlayer(player(mc).inventory);mark(mc,"malformed_corpse_and_player_disk_reload"); + });next(10);} + else if(stage==10 && done() && frames>20){if(open(mc))next(11);} + else if(stage==11 && gui(mc) && frames>20){verifyGui(mc);checkPlayer(mc.player.inventory);screenshot(mc,"nbt-after-recovery-reload.png");System.out.println("LOOTABLE_BODIES_NBT_CLIENT_PASS");return true;} + if((stage==4 || stage==5 || stage==10 || stage==11) && !gui(mc) && frames%10==0) { + open(mc); + if(frames==60) { + System.out.println("NBT_GUI_CLIENT_STATE position="+mc.player.getPosition()+" container="+mc.player.openContainer.getClass().getSimpleName()); + for(Entity entity:mc.world.loadedEntityList)if(entity instanceof EntityLootableBody)System.out.println("NBT_GUI_CLIENT_BODY uuid="+entity.getUniqueID()+" position="+entity.getPosition()+" id="+entity.getEntityId()); + schedule(mc,()->System.out.println("NBT_GUI_SERVER_STATE player="+player(mc).getPosition()+" body="+body(mc).getPosition()+" usable="+body(mc).isUsableByPlayer(player(mc))+" alive="+body(mc).isEntityAlive()+" health="+body(mc).getHealth()+" container="+player(mc).openContainer.getClass().getSimpleName())); + } + } + return false; + } + private void verifyGui(Minecraft mc){ItemStack[] expected=new ItemStack[48];NBTTagList list=visible.getTagList("Equipment",10);for(int i=0;i actual=new TreeMap<>();for(int i=0;i expected=new TreeMap<>();add(expected,NbtRecoveryChecks.item(120,17));RuntimeChecks.require(actual.equals(expected),"Malformed GUI recovered player stack changed");} + private static void add(Map map,ItemStack s){if((s == null || s.isEmpty()) || s.getCount()<=0)return;NBTTagCompound n=s.writeToNBT(new NBTTagCompound());n.removeTag("Count");String key=n.toString();map.put(key,map.getOrDefault(key,0)+s.getCount());} + private static void equal(ItemStack a,ItemStack b,String why){RuntimeChecks.require(RuntimeChecks.sameStack(a == null ? ItemStack.EMPTY : a,b == null ? ItemStack.EMPTY : b),why);} + private static NBTTagCompound full(EntityLootableBody b){NBTTagCompound n=new NBTTagCompound();b.writeToNBTOptional(n);return n;} + private EntityPlayerMP player(Minecraft mc){return mc.getIntegratedServer().getPlayerList().getPlayerByUUID(playerId);} + private EntityLootableBody body(Minecraft mc){return RuntimeChecks.find(mc.getIntegratedServer().getWorld(0),bodyId);} + private File folder(Minecraft mc){return player(mc).world.getSaveHandler().getWorldDirectory();} + private static boolean ready(Minecraft mc){return mc.world!=null && mc.player!=null && mc.getIntegratedServer()!=null;} + private static boolean gui(Minecraft mc){return mc.currentScreen instanceof CorpseGUIContainer;} + private boolean open(Minecraft mc){for(Entity e:mc.world.loadedEntityList)if(e.getUniqueID().equals(bodyId)){RuntimeChecks.require(e.getCustomNameTag().equals("NbtFixture"),"Malformed name reached tracked client entity");RuntimeChecks.require(e instanceof EntityLootableBody && Float.isFinite(((EntityLootableBody)e).getHealth()) && ((EntityLootableBody)e).getHealth()>0,"Malformed health reached tracked client entity");RuntimeChecks.require(((EntityLootableBody)e).getActivePotionEffects().isEmpty() && ((EntityLootableBody)e).hurtTime==0,"Malformed effect/hurt state reached tracked client entity");PositionMotionChecks.finite((EntityLootableBody)e);RotationFallChecks.finite((EntityLootableBody)e);mc.playerController.interactWithEntity(mc.player,e,EnumHand.MAIN_HAND);return true;}return false;} + private void schedule(Minecraft mc,Action action){pending=mc.getIntegratedServer().addScheduledTask(()->{try{action.run();}catch(Exception e){throw new RuntimeException(e);}});} + private boolean done() throws Exception{if(!pending.isDone())return false;pending.get();return true;} + private void next(int value){stage=value;frames=0;System.out.println("NBT_CLIENT_STAGE "+stage);} + private static void screenshot(Minecraft mc,String name){ScreenShotHelper.saveScreenshot(mc.gameDir,name,mc.displayWidth,mc.displayHeight,mc.getFramebuffer());} + private static void quit(Minecraft mc){mc.player.closeScreen();mc.getIntegratedServer().initiateShutdown();} + private static void mark(Minecraft mc,String name) throws Exception{try(FileWriter out=new FileWriter(new File(mc.gameDir,"nbt-client-checks.txt"),true)){out.write("PASS "+name+"\n");}System.out.println("NBT_CLIENT_CHECK_PASS "+name);} +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/NbtRecoveryChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/NbtRecoveryChecks.java new file mode 100644 index 0000000..e285ba7 --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/NbtRecoveryChecks.java @@ -0,0 +1,120 @@ +package cyano.lootable.buildtest; + +import cyano.lootable.LootableBodies; +import cyano.lootable.entities.EntityLootableBody; +import java.io.*; +import java.util.*; +import net.minecraft.entity.Entity; +import net.minecraft.entity.item.EntityItem; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.*; +import net.minecraft.server.MinecraftServer; +import net.minecraft.world.WorldServer; + +/** Malformed data is never permission to erase the surrounding inventory. */ +final class NbtRecoveryChecks { + static final String RECOVERY="LootableBodiesUnresolved"; + private interface Check {void run() throws Exception;} + private static WorldServer world; + private static int failures; + private static final List results=new ArrayList<>(); + static void run(MinecraftServer server,WorldServer target,File folder) throws Exception { + world=target;failures=0;results.clear(); + check("duplicate_distinct_stacks_keep_first_and_retain_second",()->duplicate(false)); + check("duplicate_identical_stacks_are_not_deduplicated",()->duplicate(true)); + check("missing_duplicate_does_not_erase_known_stack",NbtRecoveryChecks::missingDuplicate); + for(int slot:new int[]{-1,48,127,256}) {final int s=slot;check("invalid_slot_"+s,()->invalidSlot(new NBTTagInt(s)));} + check("missing_slot_does_not_default_to_helmet",()->invalidSlot(null)); + check("string_slot_is_not_coerced_to_zero",()->invalidSlot(new NBTTagString("7"))); + check("fractional_slot_is_not_truncated",()->invalidSlot(new NBTTagFloat(6.5F))); + check("long_slot_is_not_narrowed",()->invalidSlot(new NBTTagLong(4294967302L))); + check("valid_wide_slot_is_read_without_byte_wrap",NbtRecoveryChecks::validWideSlot); + check("missing_equipment_item_raw_retained",()->unreadable("Equipment","id",new NBTTagString("absentfixture:missing"))); + check("missing_aux_item_raw_retained",()->unreadable("Aux","id",new NBTTagString("absentfixture:missing"))); + check("air_item_is_not_a_replacement",()->unreadable("Equipment","id",new NBTTagString("minecraft:air"))); + check("numeric_item_id_not_guessed",()->unreadable("Equipment","id",new NBTTagShort((short)264))); + check("missing_count_retained_raw",()->unreadable("Equipment","Count",null)); + check("zero_count_retained_raw",()->unreadable("Equipment","Count",new NBTTagByte((byte)0))); + check("negative_count_retained_raw",()->unreadable("Equipment","Count",new NBTTagByte((byte)-1))); + check("wide_count_does_not_wrap_to_zero",()->unreadable("Equipment","Count",new NBTTagInt(256))); + check("string_count_retained_raw",()->unreadable("Equipment","Count",new NBTTagString("17"))); + check("fractional_count_retained_raw",()->unreadable("Equipment","Count",new NBTTagFloat(17.5F))); + check("valid_integer_count_preserved",NbtRecoveryChecks::integerCount); + check("wrong_damage_type_retained_raw",()->unreadable("Equipment","Damage",new NBTTagString("17"))); + check("negative_damage_not_reset",()->unreadable("Equipment","Damage",new NBTTagShort((short)-1))); + check("wide_damage_not_narrowed",()->unreadable("Equipment","Damage",new NBTTagInt(32768))); + check("wrong_custom_tag_type_retained_raw",()->unreadable("Equipment","tag",new NBTTagString("opaque"))); + check("unknown_capability_payload_retained_raw",NbtRecoveryChecks::unknownCaps); + check("unknown_stack_root_data_retained_raw",()->unreadable("Equipment","UnknownPayload",new NBTTagString("retain verbatim"))); + check("wrong_equipment_list_type_retained",()->wrongList("Equipment")); + check("wrong_aux_list_type_retained",()->wrongList("Aux")); + check("empty_aux_record_retained_without_null_queue",NbtRecoveryChecks::emptyAux); + check("repeated_read_replaces_unresolved_without_growth",NbtRecoveryChecks::repeated); + check("clear_and_reused_writer_do_not_resurrect_old_data",NbtRecoveryChecks::clear); + check("normal_save_layout_round_trip_unchanged",NbtRecoveryChecks::normal); + check("oversized_stack_split_without_count_loss",NbtRecoveryChecks::oversized); + check("invalid_armor_item_recoverable_in_overflow",NbtRecoveryChecks::invalidArmor); + check("raw_only_body_not_empty_or_removed",NbtRecoveryChecks::rawOwner); + check("partial_release_keeps_raw_owner_without_duplicate_drops",NbtRecoveryChecks::partialRelease); + check("read_is_detached_from_saved_input",NbtRecoveryChecks::detached); + check("retry_returned_item_at_original_free_slot",()->retry(false)); + check("retry_returned_item_never_overwrites_occupied_slot",()->retry(true)); + check("unknown_recovery_record_preserved_verbatim",NbtRecoveryChecks::futureRecord); + check("wrong_recovery_tag_retained_idempotently",NbtRecoveryChecks::wrongRecovery); + check("malformed_first_record_does_not_hide_valid_tail",NbtRecoveryChecks::tail); + check("valid_aux_queue_order_preserved",NbtRecoveryChecks::auxOrder); + check("entity_factory_keeps_malformed_corpse_and_identity",NbtRecoveryChecks::factory); + check("custom_equipment_authoritative_over_vanilla_mirrors",NbtRecoveryChecks::mirrors); + check("opaque_equipment_keeps_recoverable_vanilla_mirror",NbtRecoveryChecks::mirrorFallback); + check("missing_damage_keeps_vanilla_zero_default",NbtRecoveryChecks::missingDamage); + check("oversized_aux_split_without_loss",NbtRecoveryChecks::oversizedAux); + try(PrintWriter out=new PrintWriter(new File(folder,"nbt-checks.txt"),"UTF-8")) {for(String s:results)out.println(s);out.println("checks="+results.size()+" failures="+failures);} + RuntimeChecks.require(failures==0,"NBT recovery regressions: "+failures+" / "+results.size());System.out.println("LOOTABLE_BODIES_NBT_PASS checks="+results.size()); + } + private static void check(String name,Check test){ + Set before=new HashSet<>(world.loadedEntityList); + try{test.run();results.add("PASS "+name);System.out.println("NBT_CHECK_PASS "+name);} + catch(Throwable e){failures++;results.add("FAIL "+name+": "+e);System.out.println("NBT_CHECK_FAIL "+name+": "+e);e.printStackTrace();} + finally{for(Entity e:new ArrayList(world.loadedEntityList))if(!before.contains(e))world.removeEntityDangerously(e);} + } + static ItemStack item(int index,int count){ItemStack s=RuntimeChecks.stack(index);s.setCount(count);return s;} + static NBTTagCompound entry(int index,int count,int slot){NBTTagCompound n=item(index,count).writeToNBT(new NBTTagCompound());n.setByte("Slot",(byte)slot);return n;} + static NBTTagList list(NBTTagCompound... entries){NBTTagList l=new NBTTagList();for(NBTTagCompound n:entries)l.appendTag(n.copy());return l;} + static NBTTagCompound root(NBTTagCompound... entries){NBTTagCompound n=new NBTTagCompound();n.setTag("Equipment",list(entries));n.setByte("Vac",(byte)20);return n;} + static NBTTagCompound saved(EntityLootableBody b){NBTTagCompound n=new NBTTagCompound();b.writeEntityToNBT(n);return n;} + static EntityLootableBody read(NBTTagCompound n){NBTTagCompound copy=n.copy();EntityLootableBody b=new EntityLootableBody(world);b.readEntityFromNBT(n);RuntimeChecks.require(n.equals(copy),"Reader modified source NBT");return b;} + private static void equal(ItemStack a,ItemStack b,String why){RuntimeChecks.require(RuntimeChecks.sameStack(a == null ? ItemStack.EMPTY : a,b == null ? ItemStack.EMPTY : b),why);} + private static void add(Map sums,ItemStack s){if((s == null || s.isEmpty()) || s.getCount()<=0)return;NBTTagCompound n=s.writeToNBT(new NBTTagCompound());n.removeTag("Count");String k=n.toString();sums.put(k,sums.getOrDefault(k,0)+s.getCount());} + static Map items(EntityLootableBody b){Map sums=new TreeMap<>();for(int i=0;i<48;i++)add(sums,b.getStackInSlot(i));NBTTagList aux=saved(b).getTagList("Aux",10);for(int i=0;i sums=new TreeMap<>();for(ItemStack s:expected)add(sums,s);RuntimeChecks.require(items(b).equals(sums),"Recoverable item/count/damage/full NBT changed");} + static void retained(EntityLootableBody b,NBTBase raw){NBTTagList records=saved(b).getTagList(RECOVERY,10);boolean found=false;for(int i=0;i>4,coordinate>>4);RawBody b=new RawBody(w);b.setUniqueId(id);b.setPosition(coordinate+.5,80,coordinate+.5);b.setNoGravity(true);b.setUserName("NbtFixture");b.setRotation(37);RuntimeChecks.disableVacuum(b); + for(int i=0;i<48;i++)b.setInventorySlotContents(i,known(i));RuntimeChecks.require(w.spawnEntity(b),"Raw fixture spawn"); + NBTTagCompound n=new NBTTagCompound();RuntimeChecks.require(b.writeToNBTOptional(n),"Raw fixture serialization"); + try(FileOutputStream out=new FileOutputStream(new File(folder,"raw-"+name+".nbt"))){CompressedStreamTools.writeCompressed(n,out);} + } + private static ItemStack known(int i){return i==6?NbtRecoveryChecks.item(120,17):NbtRecoveryChecks.item(i,i<6?1:64);} + private static NBTTagCompound rawEquipment(){ + NBTTagCompound n=new NBTTagCompound();NBTTagList equipment=new NBTTagList(); + for(int i=0;i<48;i++) { + NBTTagCompound entry=known(i).writeToNBT(new NBTTagCompound());entry.setByte("Slot",(byte)i); + if(i==7){entry=NbtRecoveryChecks.entry(123,9,7);entry.setString("id",RETURNING);} + if(i==8)entry=missing(124,5,8); + if(i==9){entry=NbtRecoveryChecks.entry(125,1,9);entry.setByte("Count",(byte)0);} + if(i==10){entry=NbtRecoveryChecks.entry(129,6,10);NBTTagCompound caps=new NBTTagCompound();caps.setString("absentfixture:inventory","opaque capabilities");entry.setTag("ForgeCaps",caps);} + equipment.appendTag(entry); + } + equipment.appendTag(NbtRecoveryChecks.entry(121,11,6));NBTTagCompound bad=NbtRecoveryChecks.entry(122,13,6);bad.setInteger("Slot",999);equipment.appendTag(bad);n.setTag("Equipment",equipment); + n.setTag("Aux",NbtRecoveryChecks.list(NbtRecoveryChecks.entry(126,7,0),missing(127,3,0),NbtRecoveryChecks.entry(128,2,0),NbtRecoveryChecks.entry(130,19,0),NbtRecoveryChecks.entry(131,23,0))); + n.setInteger("Vac",256); + n.setLong("DeathTime",Long.MIN_VALUE); + n.setDouble("DecayTime",Double.NaN); + NBTTagCompound name=new NBTTagCompound();name.setString("Opaque","saved malformed attribution");n.setTag("Name",name); + NBTTagCompound health=HealthAttributeChecks.malformedFields();for(String key:health.getKeySet())n.setTag(key,health.getTag(key).copy()); + NBTTagCompound effects=EffectHurtChecks.malformedFields();for(String key:effects.getKeySet())n.setTag(key,effects.getTag(key).copy()); + NBTTagCompound rotation=RotationFallChecks.malformedFields();for(String key:rotation.getKeySet())n.setTag(key,rotation.getTag(key).copy()); + return n; + } + private static NBTTagCompound missing(int index,int count,int slot){NBTTagCompound n=NbtRecoveryChecks.entry(index,count,slot);n.setString("id","absentfixture:missing");return n;} + private static final class RawBody extends EntityLootableBody { + RawBody(World w){super(w);} + @Override public boolean writeToNBTOptional(NBTTagCompound n){if(isDead)return false;n.setString("id","lootablebodies:corpse");writeToNBT(n);return true;} + @Override public void writeEntityToNBT(NBTTagCompound n){super.writeEntityToNBT(n);NBTTagCompound raw=rawEquipment();for(String key:raw.getKeySet())n.setTag(key,raw.getTag(key).copy());n.setTag("Pos",PositionMotionChecks.vector(Double.NaN,80,posZ));n.setTag("Motion",PositionMotionChecks.vector(Double.NaN,Double.POSITIVE_INFINITY,Double.NEGATIVE_INFINITY));} + } + private static void settle(EntityLootableBody b){for(int i=0;i<8;i++)b.onEntityUpdate();} + private static void identity(EntityLootableBody b,UUID id,int coordinate){RuntimeChecks.require(b.isEntityAlive() && b.getUniqueID().equals(id) && b.posX==coordinate+8.5 && b.posY==80 && b.posZ==coordinate+.5 && b.rotationYaw==37 && b.getCustomNameTag().equals("NbtFixture"),"Recovered corpse identity/position/rotation/name/health changed");} + private static void add(Map totals,ItemStack s){NBTTagCompound n=s.writeToNBT(new NBTTagCompound());n.removeTag("Count");String key=n.toString();totals.put(key,totals.getOrDefault(key,0)+s.getCount());} + static void verify(EntityLootableBody b,boolean restored){ + verifyMetadata(b); + Map expected=new TreeMap<>();for(int i=0;i<48;i++)if(i<7 || i>10)add(expected,known(i)); + for(int[] spec:new int[][]{{121,11},{122,13},{126,7},{128,2},{130,19},{131,23}})add(expected,NbtRecoveryChecks.item(spec[0],spec[1])); + if(restored){NBTTagCompound raw=NbtRecoveryChecks.entry(123,9,7);raw.setString("id",RETURNING);ItemStack s=new ItemStack(raw);RuntimeChecks.require((s != null && !s.isEmpty()) && s.getItem()==returning,"Returning fixture item not registered");add(expected,s);} + RuntimeChecks.require(expected.equals(NbtRecoveryChecks.items(b)),"Disk recovery item/count/damage/custom NBT lost or duplicated"); + NBTTagCompound source=rawEquipment();int rawCount=0; + for(String list:new String[]{"Equipment","Aux"}){NBTTagList entries=source.getTagList(list,10);for(int i=0;i=0 && saved.getLong("DeathTime")<=b.world.getTotalWorldTime() && saved.getLong("DecayTime")>=0 && saved.getLong("DecayTime")<=b.world.getTotalWorldTime(),"Malformed disk timers entered active state"); + for(String key:new String[]{"Name","Vac","DeathTime","DecayTime"})NameLifetimeChecks.retained(b,key,raw.getTag(key)); + RuntimeChecks.require(saved.getTagList(NameLifetimeChecks.RECOVERY,10).tagCount()==16,"Disk metadata evidence grew or disappeared"); + } + private static void snapshot(File folder,String name,EntityLootableBody b) throws Exception {RuntimeChecks.saveExpected(folder,"nbt-"+name,b);} + private static void verifySnapshot(File folder,String name,EntityLootableBody b) throws Exception { + b.onEntityUpdate();RuntimeChecks.verifyExpected(folder,"nbt-"+name,b);NBTTagCompound expected;try(FileInputStream in=new FileInputStream(new File(folder,"nbt-"+name+".nbt"))){expected=CompressedStreamTools.readCompressed(in);} + RuntimeChecks.require(Objects.equals(expected.getTag(NbtRecoveryChecks.RECOVERY),NbtRecoveryChecks.saved(b).getTag(NbtRecoveryChecks.RECOVERY)),"Raw recovery NBT changed on actual disk reload"); + RuntimeChecks.require(NameLifetimeChecks.same(expected.getTag(NameLifetimeChecks.RECOVERY),NbtRecoveryChecks.saved(b).getTag(NameLifetimeChecks.RECOVERY)),"Raw scalar data changed on actual disk reload"); + } +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/PortBaselineChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/PortBaselineChecks.java new file mode 100644 index 0000000..23ff857 --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/PortBaselineChecks.java @@ -0,0 +1,82 @@ +package cyano.lootable.buildtest; + +import cyano.lootable.entities.EntityLootableBody; +import cyano.lootable.events.PlayerDeathEventHandler; +import java.util.*; +import net.minecraft.entity.item.EntityItem; +import net.minecraft.entity.player.EntityPlayerMP; +import net.minecraft.init.Items; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.*; +import net.minecraft.server.MinecraftServer; +import net.minecraft.util.DamageSource; +import net.minecraft.world.WorldServer; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.common.util.FakePlayerFactory; +import com.mojang.authlib.GameProfile; +import net.minecraftforge.event.entity.EntityJoinWorldEvent; +import net.minecraftforge.event.entity.living.LivingDropsEvent; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; + +/** Small behavioral red/green gates, runnable against the unmodified 1.11 source. */ +public final class PortBaselineChecks { + private interface Check { void run() throws Exception; } + private static int failures; + private static void check(String name, Check action) { + try { action.run(); System.out.println("PORT_BASELINE_PASS " + name); } + catch (Throwable error) { failures++; System.out.println("PORT_BASELINE_FAIL " + name + " " + error); } + } + private static void require(boolean ok, String message) { if (!ok) throw new AssertionError(message); } + public static void run(MinecraftServer server) throws Exception { + failures=0; + WorldServer world=server.getWorld(0); + check("rejected_spawn_keeps_death_inventory", () -> { + EntityPlayerMP player=FakePlayerFactory.get(world,new GameProfile(UUID.randomUUID(),"PortFixture")); + ArrayList drops=new ArrayList<>(); + drops.add(new EntityItem(world,8,80,8,new ItemStack(Items.DIAMOND,7))); + Object veto=new Object() { @SubscribeEvent public void join(EntityJoinWorldEvent e) { + if(e.getEntity() instanceof EntityLootableBody) e.setCanceled(true); + }}; + MinecraftForge.EVENT_BUS.register(veto); + try { new PlayerDeathEventHandler().entityDropEvent(new LivingDropsEvent(player,DamageSource.GENERIC,drops,0,false)); } + finally { MinecraftForge.EVENT_BUS.unregister(veto); } + require(drops.size()==1 && drops.get(0).getItem().getCount()==7,"original drops were discarded"); + }); + check("duplicate_slots_preserve_both_records", () -> { + EntityLootableBody body=new EntityLootableBody(world); + NBTTagCompound nbt=new NBTTagCompound(); body.writeEntityToNBT(nbt); + NBTTagList equipment=new NBTTagList(); + for(int i=0;i<2;i++) { NBTTagCompound item=new ItemStack(Items.DIAMOND,i+2).writeToNBT(new NBTTagCompound()); item.setByte("Slot",(byte)10); equipment.appendTag(item); } + nbt.setTag("Equipment",equipment); body.readEntityFromNBT(nbt); + NBTTagCompound saved=new NBTTagCompound();body.writeEntityToNBT(saved); + int count=0; for(String key:Arrays.asList("Equipment","Aux")) { + NBTTagList list=saved.getTagList(key,10); + for(int i=0;i { + EntityLootableBody body=new EntityLootableBody(world); NBTTagCompound nbt=new NBTTagCompound();body.writeEntityToNBT(nbt); + NBTTagCompound item=new ItemStack(Items.DIAMOND,7).writeToNBT(new NBTTagCompound());item.setString("id","missingfixture:keepsake");item.setByte("Slot",(byte)10); + NBTTagList list=new NBTTagList();list.appendTag(item);nbt.setTag("Equipment",list);body.readEntityFromNBT(nbt); + NBTTagCompound saved=new NBTTagCompound();body.writeEntityToNBT(saved); + require(saved.getTagList("LootableBodiesUnresolved",10).tagCount()==1,"missing item was erased"); + require(!body.isEmpty(),"recoverable item allowed empty-body decay"); + }); + check("invalid_slot_retains_inventory", () -> { + EntityLootableBody body=new EntityLootableBody(world); NBTTagCompound nbt=new NBTTagCompound();body.writeEntityToNBT(nbt); + NBTTagCompound item=new ItemStack(Items.EMERALD,9).writeToNBT(new NBTTagCompound());item.setInteger("Slot",12345); + NBTTagList list=new NBTTagList();list.appendTag(item);nbt.setTag("Equipment",list);body.readEntityFromNBT(nbt); + NBTTagCompound saved=new NBTTagCompound();body.writeEntityToNBT(saved); + require(!body.isEmpty(),"invalid slot discarded inventory"); + }); + check("malformed_name_and_timer_are_retained", () -> { + EntityLootableBody body=new EntityLootableBody(world); NBTTagCompound nbt=new NBTTagCompound();body.writeEntityToNBT(nbt); + nbt.setTag("Name",new NBTTagInt(17));nbt.setDouble("DeathTime",Double.NaN);body.readEntityFromNBT(nbt); + NBTTagCompound saved=new NBTTagCompound();body.writeEntityToNBT(saved); + require(saved.getTagList("LootableBodiesInvalidFields",10).tagCount()==2,"malformed fields lost without recovery"); + }); + System.out.println("PORT_BASELINE_RESULT failures="+failures); + require(failures==0,"Port baseline regressions: "+failures); + } +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/PositionMotionChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/PositionMotionChecks.java new file mode 100644 index 0000000..e847e99 --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/PositionMotionChecks.java @@ -0,0 +1,82 @@ +package cyano.lootable.buildtest; + +import cyano.lootable.entities.EntityLootableBody; +import java.io.*; +import java.util.*; +import net.minecraft.entity.*; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.*; +import net.minecraft.server.MinecraftServer; +import net.minecraft.util.math.AxisAlignedBB; +import net.minecraft.world.WorldServer; +import net.minecraft.world.chunk.Chunk; +import net.minecraft.world.chunk.storage.AnvilChunkLoader; + +/** Validate before vanilla builds geometry, then exercise actual chunk admission. */ +final class PositionMotionChecks { + private interface Check {void run() throws Exception;} + private static WorldServer world; + private static int failures; + private static final List results=new ArrayList<>(); + static void run(MinecraftServer server,WorldServer target,File folder) throws Exception { + world=target;failures=0;results.clear(); + NBTBase[] wrong={new NBTTagString("opaque"),new NBTTagCompound(),new NBTTagByteArray(new byte[]{1,2,3}),new NBTTagIntArray(new int[]{1,2,3}),new NBTTagDouble(Double.NaN)}; + for(String key:new String[]{"Pos","Motion"}) { + for(int i=0;ibadVector(key,raw));} + for(NBTBase raw:new NBTBase[]{new NBTTagString("opaque"),new NBTTagCompound(),new NBTTagByteArray(new byte[]{1})})check(key+"_wrong_element_type_"+raw.getId(),()->{NBTTagList list=new NBTTagList();for(int i=0;i<3;i++)list.appendTag(raw.copy());badVector(key,list);}); + for(int count:new int[]{0,1,2,4})check(key+"_wrong_length_"+count,()->length(key,count)); + for(int axis=0;axis<3;axis++)for(double v:new double[]{Double.NaN,Double.NEGATIVE_INFINITY,Double.POSITIVE_INFINITY}){final int a=axis;check(key+"_nonfinite_axis_"+axis+"_"+v,()->component(key,a,v));} + for(int type=0;type<6;type++){final int t=type;check(key+"_valid_numeric_type_"+type,()->numeric(key,t));} + check(key+"_missing_uses_safe_defaults",()->missing(key)); + } + for(int axis=0;axis<3;axis++)for(double v:new double[]{-10.01,10.01,Double.MAX_VALUE,-Double.MAX_VALUE}){final int a=axis;check("Motion_outside_engine_limit_axis_"+axis+"_"+v,()->component("Motion",a,v));} + for(int axis=0;axis<3;axis++)for(double v:new double[]{-30000000.01,30000000.01,Double.MAX_VALUE,-Double.MAX_VALUE}){final int a=axis;check("Pos_outside_engine_limit_axis_"+axis+"_"+v,()->component("Pos",a,v));} + check("valid_negative_coordinates_and_high_altitude",PositionMotionChecks::signed); + check("valid_boundary_values_preserved",PositionMotionChecks::boundaries); + check("valid_void_position_keeps_saved_value",PositionMotionChecks::voidPosition); + check("repeated_read_does_not_reuse_previous_geometry",PositionMotionChecks::repeated); + check("chunk_admission_repairs_missing_horizontal_coordinates",PositionMotionChecks::chunkAdmission); + check("chunk_admission_repairs_all_invalid_position",PositionMotionChecks::allInvalidChunk); + check("finite_saved_X_in_wrong_containing_chunk_keeps_inventory",()->finiteMismatch(true)); + check("finite_saved_Z_in_wrong_containing_chunk_keeps_inventory",()->finiteMismatch(false)); + check("healthy_chunk_admission_and_later_movement_unchanged",PositionMotionChecks::healthyChunk); + check("healthy_removed_entity_is_not_revived_on_chunk_entry",()->notRevived(false)); + check("valid_repeated_read_clears_previous_pending_position_repair",()->notRevived(true)); + check("position_repair_does_not_heal_saved_zero_health",PositionMotionChecks::deadHealth); + check("geometry_metadata_alone_is_not_inventory",PositionMotionChecks::empty); + check("unknown_items_and_complete_inventory_survive_geometry_repair",PositionMotionChecks::inventory); + try(PrintWriter out=new PrintWriter(new File(folder,"position-motion-checks.txt"),"UTF-8")){for(String s:results)out.println(s);out.println("checks="+results.size()+" failures="+failures);} + require(failures==0,"Position/motion regressions: "+failures+" / "+results.size());System.out.println("LOOTABLE_BODIES_POSITION_MOTION_PASS checks="+results.size()); + } + private static void check(String name,Check test){try{test.run();results.add("PASS "+name);System.out.println("POSITION_MOTION_PASS "+name);}catch(Throwable e){failures++;results.add("FAIL "+name+": "+e);System.out.println("POSITION_MOTION_FAIL "+name+": "+e);e.printStackTrace();}} + private static void require(boolean c,String why){RuntimeChecks.require(c,why);} + static NBTTagList vector(double... values){NBTTagList l=new NBTTagList();for(double v:values)l.appendTag(new NBTTagDouble(v));return l;} + private static NBTTagCompound saved(EntityLootableBody b){return NameLifetimeChecks.saved(b);} + private static NBTTagCompound base(){EntityLootableBody b=new EntityLootableBody(world);b.setPosition(160.5,80,160.5);b.setNoGravity(true);b.setUserName("PositionFixture");for(int i=0;i<48;i++)b.setInventorySlotContents(i,RuntimeChecks.stack(i));b.addItem(RuntimeChecks.stack(70));b.addItem(RuntimeChecks.stack(71));RuntimeChecks.disableVacuum(b);b.motionX=.25;b.motionY=-.5;b.motionZ=.75;return saved(b);} + private static EntityLootableBody read(NBTTagCompound n){NBTTagCompound before=n.copy();Entity e=EntityList.createEntityFromNBT(n,world);require(e instanceof EntityLootableBody,"Factory discarded corpse");require(NameLifetimeChecks.same(before,n),"Input NBT mutated");return (EntityLootableBody)e;} + static void finite(EntityLootableBody b){for(double v:new double[]{b.posX,b.posY,b.posZ,b.motionX,b.motionY,b.motionZ,b.prevPosX,b.prevPosY,b.prevPosZ,b.lastTickPosX,b.lastTickPosY,b.lastTickPosZ})require(Double.isFinite(v),"Nonfinite geometry/derived position");AxisAlignedBB a=b.getEntityBoundingBox();for(double v:new double[]{a.minX,a.minY,a.minZ,a.maxX,a.maxY,a.maxZ})require(Double.isFinite(v),"Nonfinite bounding box");require(Math.abs(a.minX-(b.posX-b.width/2))<1e-6 && a.minY==b.posY && Math.abs(a.maxZ-(b.posZ+b.width/2))<1e-6,"Bounding box differs from sanitized position");} + private static double[] values(EntityLootableBody b,String key){return key.equals("Pos")?new double[]{b.posX,b.posY,b.posZ}:new double[]{b.motionX,b.motionY,b.motionZ};} + private static double fallback(String key,int axis){return key.equals("Pos") && axis==1?64:0;} + private static void sameValue(double a,double z,String why){require(Double.isFinite(a) && a==z,why+" expected="+z+" actual="+a);} + private static void kept(EntityLootableBody b){NBTTagCompound n=base(),a=saved(b);for(String k:new String[]{"Equipment","Aux"})require(NameLifetimeChecks.same(n.getTag(k),a.getTag(k)),"Complete inventory changed "+k);require(b.getSizeInventory()==48 && b.getCustomNameTag().equals("PositionFixture"),"Identity/default changed");} + private static void stable(EntityLootableBody b){finite(b);kept(b);NBTTagCompound a=saved(b),z=saved(read(a));for(String k:new String[]{"Pos","Motion","Equipment","Aux",NbtRecoveryChecks.RECOVERY,NameLifetimeChecks.RECOVERY})require(NameLifetimeChecks.same(a.getTag(k),z.getTag(k)),"Round trip changed "+k);} + private static void badVector(String key,NBTBase raw){NBTTagCompound n=base();n.setTag(key,raw.copy());EntityLootableBody b=read(n);for(int i=0;i<3;i++)sameValue(values(b,key)[i],fallback(key,i),"Bad vector component");NameLifetimeChecks.retained(b,key,raw);stable(b);} + private static void component(String key,int axis,double value){double[] v=key.equals("Pos")?new double[]{160.5,80,160.5}:new double[]{.25,-.5,.75};v[axis]=value;NBTTagCompound n=base();NBTTagList raw=vector(v);n.setTag(key,raw);EntityLootableBody b=read(n);for(int i=0;i<3;i++)sameValue(values(b,key)[i],i==axis?fallback(key,i):v[i],"Lost valid sibling or activated invalid component");NameLifetimeChecks.retained(b,key,raw);stable(b);} + private static void length(String key,int count){double[] v=new double[count];for(int i=0;i>4,coordinate>>4);BlockPos center=new BlockPos(coordinate,0,coordinate); + for(int x=-1;x<=1;x++)for(int z=-1;z<=1;z++)world.setBlockToAir(center.add(x,0,z)); + world.setBlockState(center,Blocks.STONE.getDefaultState()); + EntityLootableBody body=body(world,coordinate,-1); + LootableBodies.voidPlatform=false;body.onEntityUpdate(); + RuntimeChecks.require(body.posY==-1 && world.isAirBlock(center.add(1,0,0)),"Disabled void platform changed behaviour"); + LootableBodies.voidPlatform=true;LootableBodies.dimensionSpecificPlatform=dimensionBlocks;body.onEntityUpdate(); + Block expected=!dimensionBlocks || dimension==0?Blocks.COBBLESTONE:dimension==-1?Blocks.NETHERRACK:Blocks.END_STONE; + RuntimeChecks.require(body.posY==1.5 && body.isEntityAlive(),"Enabled void platform did not rescue corpse"); + for(int x=-1;x<=1;x++)for(int z=-1;z<=1;z++) + RuntimeChecks.require(world.getBlockState(center.add(x,0,z)).getBlock()==(x==0 && z==0?Blocks.STONE:expected),"Void platform changed existing terrain or dimension block"); + conserved(body);checks++; + } + WorldServer world=server.getWorld(0);world.getChunk(30,30); + for(int x=487;x<=489;x++)for(int z=487;z<=489;z++)for(int y=79;y<=82;y++)world.setBlockState(new BlockPos(x,y,z),Blocks.WATER.getDefaultState()); + EntityLootableBody body=body(world,488,80); + RuntimeChecks.require(body.handleWaterMovement(),"Water fixture was not submerged"); + LootableBodies.corpseBuoyancy=false;body.motionY=0;body.onEntityUpdate(); + RuntimeChecks.require(body.motionY==0,"Disabled buoyancy accelerated the corpse"); + LootableBodies.corpseBuoyancy=true;body.motionY=0;body.onEntityUpdate(); + RuntimeChecks.require(body.motionY>0,"Enabled buoyancy did not lift the corpse");conserved(body);checks++; + System.out.println("LOOTABLE_BODIES_112_RECOVERY_PASS checks="+checks); + } finally {LootableBodies.corpseBuoyancy=buoyancy;LootableBodies.voidPlatform=platform;LootableBodies.dimensionSpecificPlatform=specific;} + } + private static EntityLootableBody body(WorldServer world,int coordinate,double y) { + EntityLootableBody body=new EntityLootableBody(world);body.setPosition(coordinate+.5,y,coordinate+.5);body.setNoGravity(true); + body.setInventorySlotContents(6,RuntimeChecks.stack(420));RuntimeChecks.disableVacuum(body);return body; + } + private static void conserved(EntityLootableBody body) { + RuntimeChecks.require(RuntimeChecks.sameStack(body.getStackInSlot(6),RuntimeChecks.stack(420)),"Recovery option changed complete item NBT"); + NBTTagCompound saved=new NBTTagCompound();body.writeToNBTOptional(saved);EntityLootableBody reloaded=new EntityLootableBody(body.world);reloaded.readFromNBT(saved); + RuntimeChecks.require(RuntimeChecks.sameStack(reloaded.getStackInSlot(6),RuntimeChecks.stack(420)),"Recovery option inventory changed on reload"); + } +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/ReportedIssueChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/ReportedIssueChecks.java new file mode 100644 index 0000000..2f76178 --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/ReportedIssueChecks.java @@ -0,0 +1,273 @@ +package cyano.lootable.buildtest; + +import com.mojang.authlib.GameProfile; +import cyano.lootable.entities.EntityLootableBody; +import cyano.lootable.graphics.CorpseContainer; +import java.io.*; +import java.util.*; +import net.minecraft.entity.Entity; +import net.minecraft.entity.player.EntityPlayerMP; +import net.minecraft.init.Blocks; +import net.minecraft.init.Items; +import net.minecraft.inventory.ClickType; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.*; +import net.minecraft.network.*; +import net.minecraft.network.play.client.*; +import io.netty.buffer.Unpooled; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.management.PlayerInteractionManager; +import net.minecraft.util.math.*; +import net.minecraft.world.*; +import net.minecraft.world.chunk.Chunk; +import net.minecraftforge.common.ForgeChunkManager; + +/** Public issue reproductions in real Forge; excluded from production artifacts. */ +final class ReportedIssueChecks { + private interface Check { void run() throws Exception; } + private static final List results = new ArrayList<>(); + private static MinecraftServer server; + private static WorldServer world; + private static int failures, sequence, transaction; + private static File folder; + + static void run(MinecraftServer instance, WorldServer target, File directory) throws Exception { + server=instance; world=target; folder=directory; results.clear(); failures=0; + check("issue5_exact_two_deaths_hotbar_one_then_switch_without_inventory_sync", ReportedIssueChecks::deathSequence); + check("issue17_loaded_platform_keeps_gravity_and_inventory", ReportedIssueChecks::loadedPlatform); + check("issue17_normal_missing_neighbours_do_not_tick_or_load", ReportedIssueChecks::normalTickGate); + check("issue17_normal_chunk_unload_reload_preserves_complete_inventory", ReportedIssueChecks::unloadReload); + check("issue17_unloaded_east_support_preserves_inventory", ()->forcedSupport(0,1)); + check("issue17_unloaded_west_support_preserves_inventory", ()->forcedSupport(0,-1)); + check("issue17_unloaded_south_support_preserves_inventory", ()->forcedSupport(1,1)); + check("issue17_unloaded_north_support_preserves_inventory", ()->forcedSupport(1,-1)); + check("issue17_loaded_real_void_keeps_existing_fatal_behavior", ReportedIssueChecks::realVoid); + try(PrintWriter out=new PrintWriter(new File(folder,"reported-issue-checks.txt"),"UTF-8")) { + for(String line:results) out.println(line); + out.println("checks="+results.size()+" failures="+failures); + } + RuntimeChecks.require(failures==0,"Reported issue regressions: "+failures+" / "+results.size()); + System.out.println("LOOTABLE_BODIES_REPORTED_ISSUES_PASS checks="+results.size()); + } + private static void check(String name, Check action) { + Set before=new HashSet<>(world.loadedEntityList); + try {action.run(); results.add("PASS "+name); System.out.println("REPORTED_ISSUE_PASS "+name);} + catch(Throwable error) {failures++;results.add("FAIL "+name+": "+error);System.out.println("REPORTED_ISSUE_FAIL "+name+": "+error);error.printStackTrace();} + finally { + for(Entity entity:new ArrayList(world.loadedEntityList)) if(!before.contains(entity)) { + if(entity instanceof EntityPlayerMP) server.getPlayerList().playerLoggedOut((EntityPlayerMP)entity); + else world.removeEntityDangerously(entity); + } + } + } + private static ItemStack shovel() { + ItemStack stack=new ItemStack(Items.DIAMOND_SHOVEL,1,17); + stack.setTagCompound(RuntimeChecks.stack(300).getTagCompound().copy()); + stack.setStackDisplayName("Issue 5 original shovel"); return stack; + } + static Map inventory(EntityLootableBody body) { + Map total=new TreeMap<>(); + for(int i=0;i<48;i++) add(total,body.getStackInSlot(i)); + NBTTagCompound n=new NBTTagCompound();body.writeEntityToNBT(n); + NBTTagList aux=n.getTagList("Aux",10); + for(int i=0;i total,ItemStack stack) { + if((stack == null || stack.isEmpty()) || stack.getCount()<=0)return; + NBTTagCompound n=stack.writeToNBT(new NBTTagCompound());n.removeTag("Count"); + total.put(n.toString(),total.getOrDefault(n.toString(),0)+stack.getCount()); + } + private static Map tagged(EntityLootableBody body) { + Map n=inventory(body); n.keySet().removeIf(k->!k.contains("fixture"));return n; + } + private static Map tagged(EntityPlayerMP player) { + Map n=new TreeMap<>(); + for(int i=0;i before=new HashSet<>(world.loadedEntityList);player.onKillCommand(); + for(Entity e:world.loadedEntityList)if(e instanceof EntityLootableBody && !before.contains(e))return (EntityLootableBody)e; + throw new AssertionError("Real player death did not create corpse"); + } + private static EntityPlayerMP respawn(EntityPlayerMP player) { + NetHandlerPlayServer connection=player.connection; + connection.processClientStatus(new CPacketClientStatus(CPacketClientStatus.State.PERFORM_RESPAWN)); + EntityPlayerMP replacement=connection.player; + RuntimeChecks.require(replacement!=player && replacement.isEntityAlive(),"Actual player recreation did not occur"); + replacement.setPosition(32.5,80,0.5);return replacement; + } + private static void click(EntityPlayerMP p,int slot,int button,ClickType type) throws IOException { + // 1.12 QUICK_MOVE returns EMPTY after completely draining its source slot. + ItemStack predicted=ItemStack.EMPTY; + PacketBuffer wire=new PacketBuffer(Unpooled.buffer()); + try { + wire.writeByte(p.openContainer.windowId);wire.writeShort(slot);wire.writeByte(button);wire.writeShort(++transaction); + wire.writeEnumValue(type);wire.writeItemStack(predicted == null ? ItemStack.EMPTY : predicted); + CPacketClickWindow packet=new CPacketClickWindow();packet.readPacketData(wire);p.connection.processClickWindow(packet); + RuntimeChecks.require(p.openContainer.getCanCraft(p),"Packet prediction rejected the click transaction"); + } finally {wire.release();} + } + private static void deathSequence() throws Exception { + world.getGameRules().setOrCreateGameRule("keepInventory","false");world.getChunk(2,0); + EntityPlayerMP p=new EntityPlayerMP(server,world,new GameProfile(UUID.randomUUID(),"IssueFive"),new PlayerInteractionManager(world)); + p.connection=new QuietConnection(p);p.setGameType(GameType.SURVIVAL);p.setPosition(32.5,80,0.5); + // Vanilla clients need player-list information before a tracked player + // spawn, including the brief spawn-point visit during real respawn. + server.getPlayerList().sendPacketToAllPlayers(new net.minecraft.network.play.server.SPacketPlayerListItem( + net.minecraft.network.play.server.SPacketPlayerListItem.Action.ADD_PLAYER,p)); + RuntimeChecks.require(world.spawnEntity(p),"Fixture player spawn");server.getPlayerList().getPlayers().add(p); + p.inventory.currentItem=4;p.inventory.mainInventory.set(4,shovel());p.inventory.mainInventory.set(9,RuntimeChecks.stack(301)); + Map expected=tagged(p); + EntityLootableBody first=death(p);RuntimeChecks.require(expected.equals(tagged(first)),"First death lost inventory"); + p=respawn(p);CorpseContainer gui=new CorpseContainer(p.inventory,first);gui.windowId=17;p.openContainer=gui;gui.addListener(p); + click(p,4,0,ClickType.SWAP); + RuntimeChecks.require(RuntimeChecks.sameStack(p.inventory.mainInventory.get(0),shovel()),"Key1 did not recover original main hand"); + for(int slot=0;slot<48;slot++)if(gui.getSlot(slot).getHasStack())click(p,slot,0,ClickType.QUICK_MOVE); + RuntimeChecks.require(first.isEmpty() && expected.equals(tagged(p)),"First recovery lost or duplicated tagged inventory: remaining="+inventory(first)+" expected="+expected+" player="+tagged(p)+" canCraft="+gui.getCanCraft(p)); + PacketBuffer close=new PacketBuffer(Unpooled.buffer()); + try {close.writeByte(gui.windowId);CPacketCloseWindow packet=new CPacketCloseWindow();packet.readPacketData(close);p.connection.processCloseWindow(packet);} + finally {close.release();} + int active=-1;for(int slot=1;slot<9;slot++)if(!(p.inventory.mainInventory.get(slot)).isEmpty() && p.inventory.mainInventory.get(slot).hasTagCompound() && p.inventory.mainInventory.get(slot).getTagCompound().hasKey("fixture"))active=slot; + RuntimeChecks.require(active>0,"Shift-click did not leave a different occupied hotbar slot"); + PacketBuffer held=new PacketBuffer(Unpooled.buffer()); + try {held.writeShort(active);CPacketHeldItemChange packet=new CPacketHeldItemChange();packet.readPacketData(held);p.connection.processHeldItemChange(packet);} + finally {held.release();} + // Deliberately do not tick/sync inventoryContainer or reopen inventory here. + EntityLootableBody second=death(p); + RuntimeChecks.require(expected.equals(tagged(second)),"Second death lost original key1 shovel or another exact stack"); + RuntimeChecks.require(RuntimeChecks.sameStack(second.getStackInSlot(4),RuntimeChecks.stack(301)),"Second death active hand differs"); + System.out.println("ISSUE5_REAL_RESPAWN_AND_HELD_ITEM_PACKET_PASS"); + p=respawn(p);server.getPlayerList().playerLoggedOut(p); + NBTTagCompound n=new NBTTagCompound();second.writeToNBTOptional(n); + EntityLootableBody loaded=new EntityLootableBody(world);loaded.readFromNBT(n); + RuntimeChecks.require(expected.equals(tagged(loaded)),"Second corpse NBT reload lost recovery"); + } + private static EntityLootableBody fullBody(int cx,int cz,double offset) { + world.getChunk(cx,cz); + EntityLootableBody body=new EntityLootableBody(world);body.setUserName("OfflineIssue17"); + body.setPosition(cx*16+offset,80,cz*16+8.5); + for(int i=0;i<48;i++)body.setInventorySlotContents(i,RuntimeChecks.stack(i)); + body.addItem(RuntimeChecks.stack(70));body.addItem(RuntimeChecks.stack(71));RuntimeChecks.disableVacuum(body); + RuntimeChecks.require(!body.hasNoGravity(),"Issue17 fixture must use real gravity"); + RuntimeChecks.require(world.spawnEntity(body),"Physics fixture spawn");return body; + } + private static void loadedPlatform() { + int c=96;for(int x=c-2;x<=c+2;x++)for(int z=c-2;z<=c+2;z++)world.getChunk(x,z); + world.setBlockState(new BlockPos(c*16+8,79,c*16+8),Blocks.STONE.getDefaultState()); + EntityLootableBody b=fullBody(c,c,8.5);Map expected=inventory(b); + for(int i=0;i<40;i++)world.updateEntity(b); + RuntimeChecks.require(b.onGround && b.posY==80 && b.isEntityAlive() && expected.equals(inventory(b)),"Loaded platform/normal gravity lost body or inventory"); + } + private static void normalTickGate() { + EntityLootableBody b=fullBody(112,112,8.5);int ticks=b.ticksExisted; + for(int i=0;i<40;i++)world.updateEntity(b); + RuntimeChecks.require(b.ticksExisted==ticks && b.posY==80 && !world.getChunkProvider().chunkExists(113,112),"Vanilla unloaded-neighbour tick guard did not hold"); + } + private static void unloadReload() throws Exception { + EntityLootableBody b=fullBody(128,128,8.5);UUID uuid=b.getUniqueID();Map expected=inventory(b); + unloadChunk(128,128);world.updateEntities(); + RuntimeChecks.require(!world.getChunkProvider().chunkExists(128,128),"Fixture chunk did not actually unload"); + RuntimeChecks.require(!world.loadedEntityList.contains(b),"Unloaded corpse still receives normal world ticks"); + world.getChunk(128,128);EntityLootableBody loaded=RuntimeChecks.find(world,uuid); + RuntimeChecks.require(loaded.isEntityAlive() && expected.equals(inventory(loaded)),"Normal chunk unload/load lost inventory"); + } + private static final class Support implements AutoCloseable { + EntityLootableBody body; int cx,cz,nx,nz; BlockPos platform; + ForgeChunkManager.Ticket ticket; + Map expected; + void unload(){unloadChunk(nx,nz);RuntimeChecks.require(!world.getChunkProvider().chunkExists(nx,nz),"Support chunk did not unload");} + void frozen(){for(int i=0;i<100;i++)world.updateEntity(body);System.out.println("ISSUE17_UNLOADED_SUPPORT y="+body.posY+" alive="+body.isEntityAlive()+" inventory="+inventory(body).size()+" supportLoaded="+world.getChunkProvider().chunkExists(nx,nz));RuntimeChecks.require(body.isEntityAlive() && body.posY==80 && expected.equals(inventory(body)),"Corpse fell through unloaded neighbour support and lost recoverable inventory");} + void resume(){world.getChunk(nx,nz);for(int i=0;i<10;i++)world.updateEntity(body);RuntimeChecks.require(body.onGround && body.posY==80 && body.addedToChunk && expected.equals(inventory(body)),"Corpse failed to resume safely after support terrain loaded");} + @Override public void close(){ForgeChunkManager.releaseTicket(ticket);} + } + private static Support support(int axis,int direction) { + Support f=new Support();f.cx=144+(sequence++)*8;f.cz=f.cx; + f.nx=f.cx+(axis==0?direction:0);f.nz=f.cz+(axis==1?direction:0); + world.getChunk(f.cx,f.cz);world.getChunk(f.nx,f.nz); + double x=f.cx*16+(axis==0?(direction>0?15.6:.4):8.5); + double z=f.cz*16+(axis==1?(direction>0?15.6:.4):8.5); + int floorX=MathHelper.floor(x),floorZ=MathHelper.floor(z); + f.platform=new BlockPos(floorX+(axis==0?direction:0),79,floorZ+(axis==1?direction:0)); + // A body can straddle a chunk boundary while its centre remains in the ticketed chunk. + // Remove only the two fixture columns' ground, leaving one thin supporting platform. + for(int y=0;y<5;y++){world.setBlockToAir(new BlockPos(floorX,y,floorZ));world.setBlockToAir(new BlockPos(f.platform.getX(),y,f.platform.getZ()));} + world.setBlockState(f.platform,Blocks.STONE.getDefaultState()); + f.body=fullBody(f.cx,f.cz,8.5);f.body.setPosition(x,80,z);f.expected=inventory(f.body); + Object mod=net.minecraftforge.fml.common.Loader.instance().getIndexedModList().get("lootablebodiesbuildtest").getMod(); + ForgeChunkManager.setForcedChunkLoadingCallback(mod,(tickets,target)->{}); + f.ticket=ForgeChunkManager.requestTicket(mod,world,ForgeChunkManager.Type.NORMAL); + RuntimeChecks.require(f.ticket!=null,"Real Forge chunk-loader ticket unavailable"); + ForgeChunkManager.forceChunk(f.ticket,new ChunkPos(f.cx,f.cz)); + for(int i=0;i<10;i++)world.updateEntity(f.body); + RuntimeChecks.require(f.body.onGround && f.body.posY==80 && f.body.isEntityAlive(),"Loaded neighbour platform control failed"); + return f; + } + private static void forcedSupport(int axis,int direction) { + try(Support f=support(axis,direction)) { + NBTTagCompound before=new NBTTagCompound();f.body.writeToNBTOptional(before); + f.unload();f.frozen(); + NBTTagCompound after=new NBTTagCompound();f.body.writeToNBTOptional(after); + RuntimeChecks.require(before.equals(after),"Waiting for terrain changed healthy saved NBT"); + f.resume(); + } + } + private static void realVoid() { + try(Support f=support(0,1)) { + world.setBlockToAir(f.platform); + // 1.11 applies ordinary 4-HP void hits with hurt-resistance intervals. + // Allow that native sequence to finish; retain the same conservation gate. + for(int i=0;i<250 && f.body.isEntityAlive();i++)world.updateEntity(f.body); + for(int i=0;i<15;i++)f.body.onEntityUpdate(); + System.out.println("VOID_CONTROL y="+f.body.posY+" alive="+f.body.isEntityAlive()+" items="+inventory(f.body).size()+" motion="+f.body.motionY); + RuntimeChecks.require(!f.body.isEntityAlive() && inventory(f.body).isEmpty(),"Loaded real void behavior changed into a rescue feature"); + } + } + private static void unloadChunk(int x,int z) { + net.minecraft.world.gen.ChunkProviderServer provider=world.getChunkProvider(); + provider.queueUnload(world.getChunk(x,z)); + // Each 1.10 provider tick saves/unloads at most 100 chunks. An integrated + // client can leave a larger queue when the test teleports its players. + int attempts=provider.getLoadedChunks().size()/100+2; + for(int i=0;i packet) { } + } +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/ReportedIssueClientChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/ReportedIssueClientChecks.java new file mode 100644 index 0000000..0b542a3 --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/ReportedIssueClientChecks.java @@ -0,0 +1,157 @@ +package cyano.lootable.buildtest; + +import cyano.lootable.entities.EntityLootableBody; +import cyano.lootable.graphics.CorpseGUIContainer; +import java.io.*; +import java.util.*; +import java.util.concurrent.Future; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.*; +import net.minecraft.entity.Entity; +import net.minecraft.entity.player.EntityPlayerMP; +import net.minecraft.init.*; +import net.minecraft.inventory.ClickType; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.*; +import net.minecraft.network.play.client.CPacketHeldItemChange; +import net.minecraft.util.*; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.*; + +/** Exact issue5 sequence over a real connected client and two disk reloads. */ +final class ReportedIssueClientChecks { + private interface Action {void run() throws Exception;} + private int stage,frames,active; + private Future pending; + private UUID firstId,secondId; + private File report; + private NBTTagCompound savedBody,savedPlayer; + static ItemStack original() { + ItemStack s=new ItemStack(Items.DIAMOND_SHOVEL,1,17); + s.setTagCompound(RuntimeChecks.stack(300).getTagCompound().copy()); + s.setStackDisplayName("Issue 5 original shovel");return s; + } + void tick(Minecraft mc) throws Exception { + frames++; + if(stage==0 && mc.currentScreen instanceof GuiMainMenu) { + report=new File(mc.gameDir,"issue5-client-checks.txt");RuntimeChecks.require(!report.exists(),"Stale issue5 report"); + mc.gameSettings.pauseOnLostFocus=false;mc.gameSettings.limitFramerate=30;mc.gameSettings.renderDistanceChunks=4; + mc.launchIntegratedServer("issue-fixture","Issue fixture",new WorldSettings(4267321,GameType.SURVIVAL,false,false,WorldType.FLAT).enableCommands());next(1); + } else if(stage==1 && ready(mc)) { + schedule(mc,()->{ + WorldServer w=player(mc).getServerWorld();w.getGameRules().setOrCreateGameRule("keepInventory","false"); + w.getGameRules().setOrCreateGameRule("doMobSpawning","false"); + for(int x=29;x<=36;x++)for(int z=-3;z<=4;z++)w.setBlockState(new BlockPos(x,79,z),Blocks.STONE.getDefaultState()); + EntityPlayerMP p=player(mc);p.setGameType(GameType.SURVIVAL);p.inventory.clear();p.inventory.currentItem=4; + p.inventory.mainInventory.set(4,original());p.inventory.mainInventory.set(9,RuntimeChecks.stack(301)); + p.connection.setPlayerLocation(32.5,80,.5,0,0);p.inventoryContainer.detectAndSendChanges(); + firstId=die(p,null).getUniqueID(); + });next(2); + } else if(stage==2 && done() && mc.currentScreen instanceof GuiGameOver && frames>10) { + mc.player.respawnPlayer();next(3); + } else if(stage==3 && ready(mc) && mc.player.isEntityAlive() && !(mc.currentScreen instanceof GuiGameOver)) { + schedule(mc,()->player(mc).connection.setPlayerLocation(32.5,80,.5,0,0));next(4); + } else if(stage==4 && done() && open(mc,firstId)) {next(5); + } else if(stage==5 && gui(mc) && frames>15) { + equal(mc.player.openContainer.getSlot(4).getStack(),original(),"First corpse main hand");shot(mc,"issue5-first-main-hand.png"); + mc.playerController.windowClick(mc.player.openContainer.windowId,4,0,ClickType.SWAP,mc.player);next(6); + } else if(stage==6 && frames>15) { + equal(mc.player.inventory.mainInventory.get(0),original(),"Key1 original shovel recovery"); + for(int slot=0;slot<48;slot++)if(mc.player.openContainer.getSlot(slot).getHasStack()) + mc.playerController.windowClick(mc.player.openContainer.windowId,slot,0,ClickType.QUICK_MOVE,mc.player); + next(7); + } else if(stage==7 && frames>20) { + equal(mc.player.inventory.mainInventory.get(0),original(),"Original shovel changed before switch"); + active=-1;for(int slot=1;slot<9;slot++)if(RuntimeChecks.sameStack(mc.player.inventory.mainInventory.get(slot),RuntimeChecks.stack(301)))active=slot; + RuntimeChecks.require(active>0,"No different recovered hotbar item"); + mark("first_death_respawn_key1_and_shift_recovery"); + mc.player.closeScreen();mc.player.inventory.currentItem=active; + mc.getConnection().sendPacket(new CPacketHeldItemChange(active)); + // Same ordered connection: held-item change, then /kill. No inventory open or forced resync. + mc.player.sendChatMessage("/kill");next(8); + } else if(stage==8 && mc.currentScreen instanceof GuiGameOver && frames>10) { + mc.player.respawnPlayer();next(9); + } else if(stage==9 && ready(mc) && mc.player.isEntityAlive() && !(mc.currentScreen instanceof GuiGameOver)) { + schedule(mc,()->{ + EntityPlayerMP p=player(mc);EntityLootableBody second=null; + for(Entity e:p.world.loadedEntityList)if(e instanceof EntityLootableBody && !e.getUniqueID().equals(firstId) && p.getName().equals(e.getCustomNameTag()))second=(EntityLootableBody)e; + RuntimeChecks.require(second!=null,"Second actual death corpse absent");secondId=second.getUniqueID(); + verifyOriginals(second);equal(second.getStackInSlot(4),RuntimeChecks.stack(301),"Second selected main hand"); + RuntimeChecks.require(p.inventory.writeToNBT(new NBTTagList()).tagCount()==0,"Second death duplicated player inventory"); + p.connection.setPlayerLocation(32.5,80,.5,0,0); + mark("second_death_without_inventory_resync_preserves_original_shovel_and_all_tags"); + });next(10); + } else if(stage==10 && done() && open(mc,secondId)) {next(11); + } else if(stage==11 && gui(mc) && frames>20) { + verifyClientOriginals(mc);shot(mc,"issue5-second-corpse.png"); + mc.player.closeScreen();schedule(mc,()->save(mc));next(12); + } else if(stage==12 && done()) {quit(mc);next(13); + } else if(stage==13 && mc.world==null && !mc.isIntegratedServerRunning()) { + mc.launchIntegratedServer("issue-fixture","Issue fixture",null);next(14); + } else if(stage==14 && ready(mc) && frames>25) { + schedule(mc,()->{verifyDisk(mc);verifyOriginals(body(mc));mark("first_disk_reload_complete_corpse_inventory");});next(15); + } else if(stage==15 && done() && open(mc,secondId)) {next(16); + } else if(stage==16 && gui(mc) && frames>20) { + verifyClientOriginals(mc);shot(mc,"issue5-reloaded-corpse.png"); + for(int slot=0;slot<48;slot++)if(mc.player.openContainer.getSlot(slot).getHasStack()) + mc.playerController.windowClick(mc.player.openContainer.windowId,slot,0,ClickType.QUICK_MOVE,mc.player); + next(17); + } else if(stage==17 && frames>20) { + mc.player.closeScreen();schedule(mc,()->{ + RuntimeChecks.require(body(mc).isEmpty(),"Final recovery left duplicate corpse items"); + Map expected=originals(),actual=new TreeMap<>(); + EntityPlayerMP p=player(mc); + for(int i=0;i25) { + schedule(mc,()->{verifyDisk(mc);mark("second_disk_reload_complete_player_and_corpse_inventory");});next(21); + } else if(stage==21 && done()) { + new File(mc.gameDir,"client.pass").createNewFile(); + System.out.println("LOOTABLE_BODIES_ISSUE5_CLIENT_PASS");quit(mc);next(22); + } else if(stage==22 && mc.world==null && !mc.isIntegratedServerRunning()) {mc.shutdown();next(23);} + } + private EntityLootableBody die(EntityPlayerMP p,UUID skip) { + Set before=new HashSet<>(p.world.loadedEntityList);p.onKillCommand(); + for(Entity e:p.world.loadedEntityList)if(e instanceof EntityLootableBody && !before.contains(e))return (EntityLootableBody)e; + throw new AssertionError("Actual connected death absent"); + } + private static void addTagged(Map result,ItemStack stack) { + if((stack == null || stack.isEmpty()) || !stack.hasTagCompound() || !stack.getTagCompound().hasKey("fixture"))return; + NBTTagCompound n=stack.writeToNBT(new NBTTagCompound());n.removeTag("Count");result.put(n.toString(),result.getOrDefault(n.toString(),0)+stack.getCount()); + } + private static Map originals(){Map m=new TreeMap<>();addTagged(m,original());addTagged(m,RuntimeChecks.stack(301));return m;} + private static void verifyOriginals(EntityLootableBody b) { + Map n=new TreeMap<>(ReportedIssueChecks.inventory(b));n.keySet().removeIf(k->!k.contains("fixture")); + RuntimeChecks.require(originals().equals(n),"Second corpse lost/duplicated exact tagged inventory"); + } + private static void verifyClientOriginals(Minecraft mc){Map n=new TreeMap<>();for(int i=0;i<48;i++)addTagged(n,mc.player.openContainer.getSlot(i).getStack());RuntimeChecks.require(originals().equals(n),"Connected GUI differs from exact originals");} + private void save(Minecraft mc) throws Exception { + savedBody=new NBTTagCompound();body(mc).writeToNBTOptional(savedBody);savedPlayer=new NBTTagCompound();savedPlayer.setTag("Inventory",player(mc).inventory.writeToNBT(new NBTTagList())); + File folder=player(mc).getServerWorld().getSaveHandler().getWorldDirectory(); + try(FileOutputStream out=new FileOutputStream(new File(folder,"issue5-corpse-expected.nbt"))){CompressedStreamTools.writeCompressed(savedBody,out);} + try(FileOutputStream out=new FileOutputStream(new File(folder,"issue5-player-expected.nbt"))){CompressedStreamTools.writeCompressed(savedPlayer,out);} + mc.getIntegratedServer().getPlayerList().saveAllPlayerData();mc.getIntegratedServer().saveAllWorlds(false); + } + private void verifyDisk(Minecraft mc) { + player(mc).getServerWorld().getChunk(2,0);NBTTagCompound actual=new NBTTagCompound();body(mc).writeToNBTOptional(actual); + for(String key:Arrays.asList("id","UUIDMost","UUIDLeast","Equipment","Aux","Name"))RuntimeChecks.require(Objects.equals(savedBody.getTag(key),actual.getTag(key)),"Disk corpse differs: "+key); + RuntimeChecks.require(savedPlayer.getTagList("Inventory",10).equals(player(mc).inventory.writeToNBT(new NBTTagList())),"Complete player inventory differs after disk reload"); + } + private EntityPlayerMP player(Minecraft mc){return mc.getIntegratedServer().getPlayerList().getPlayerByUUID(mc.player.getUniqueID());} + private EntityLootableBody body(Minecraft mc){return RuntimeChecks.find(player(mc).getServerWorld(),secondId);} + private static void equal(ItemStack a,ItemStack b,String reason){RuntimeChecks.require(RuntimeChecks.sameStack(a == null ? ItemStack.EMPTY : a,b == null ? ItemStack.EMPTY : b),reason);} + private static boolean ready(Minecraft mc){return mc.world!=null && mc.player!=null && mc.getIntegratedServer()!=null;} + private static boolean gui(Minecraft mc){return mc.currentScreen instanceof CorpseGUIContainer;} + private static boolean open(Minecraft mc,UUID id){for(Entity e:mc.world.loadedEntityList)if(e instanceof EntityLootableBody && e.getUniqueID().equals(id)){mc.playerController.interactWithEntity(mc.player,e,EnumHand.MAIN_HAND);return true;}return false;} + private void schedule(Minecraft mc,Action a){pending=mc.getIntegratedServer().addScheduledTask(()->{try{a.run();}catch(Exception e){throw new RuntimeException(e);}});} + private boolean done() throws Exception {if(!pending.isDone())return false;pending.get();return true;} + private void next(int n){stage=n;frames=0;System.out.println("ISSUE5_CLIENT_STAGE "+n);} + private void mark(String name) throws Exception {try(FileWriter out=new FileWriter(report,true)){out.write("PASS "+name+"\n");}System.out.println("ISSUE5_CLIENT_CHECK_PASS "+name);} + private static void shot(Minecraft mc,String name){ScreenShotHelper.saveScreenshot(mc.gameDir,name,mc.displayWidth,mc.displayHeight,mc.getFramebuffer());} + private static void quit(Minecraft mc){mc.player.closeScreen();mc.getIntegratedServer().initiateShutdown();} +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/RespawnPacketChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/RespawnPacketChecks.java new file mode 100644 index 0000000..3f8f553 --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/RespawnPacketChecks.java @@ -0,0 +1,150 @@ +package cyano.lootable.buildtest; + +import cyano.lootable.entities.EntityLootableBody; +import cyano.lootable.LootableBodies; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.*; +import net.minecraft.entity.Entity; +import net.minecraft.entity.player.EntityPlayerMP; +import net.minecraft.init.Blocks; +import net.minecraft.item.ItemStack; +import net.minecraft.server.MinecraftServer; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.*; +import net.minecraftforge.fml.common.FMLCommonHandler; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; +import net.minecraftforge.fml.common.gameevent.*; + +/** Death/respawn timing matrix, with an independently connected observer. */ +public final class RespawnPacketChecks { + private final MinecraftServer server; + private int index,phase,ticks,phaseTicks,startupTicks; + private EntityPlayerMP dead; + private EntityPlayerMP observerDead; + private EntityLootableBody body; + private EntityLootableBody observerBody; + private final boolean simultaneous=Boolean.getBoolean("lootablebodies.respawn.simultaneous"); + private final int count=Integer.getInteger("lootablebodies.respawn.cases",16); + private final Set traced=new HashSet<>(); + private boolean finished; + RespawnPacketChecks(MinecraftServer server) throws Exception { + RuntimeChecks.require(!LootableBodies.useLocalSkin&&LootableBodies.allowCorpseDecay&&LootableBodies.decayOnlyWhenEmpty + &&LootableBodies.ticksPerItemDecay==6000&&LootableBodies.corpseDecayTime==72000&&LootableBodies.corpseHP==20 + &&!LootableBodies.hurtByEnvironment&&!LootableBodies.hurtByAttacks&&!LootableBodies.hurtByMisc,"Respawn matrix requires production defaults"); + this.server=server;state(0);Files.write(file("server.ready").toPath(),new byte[]{1});FMLCommonHandler.instance().bus().register(this); + } + static void verifySlowClientStartup(MinecraftServer server) throws Exception { + String previous=System.getProperty("lootablebodies.multiplayer.sync"); + File folder=new File(server.getWorld(0).getSaveHandler().getWorldDirectory(),"respawn-startup-regression"); + Files.createDirectories(folder.toPath()); + RespawnPacketChecks fixture=null; + try { + System.setProperty("lootablebodies.multiplayer.sync",folder.getAbsolutePath()); + fixture=new RespawnPacketChecks(server); + // Cold software-rendered clients may need more than 500 server ticks + // to reach their first login. Exercise the actual fixture tick path. + for(int tick=0;tick<1000;tick++)fixture.tick(new TickEvent.ServerTickEvent(TickEvent.Phase.END)); + RuntimeChecks.require(!fixture.finished&&fixture.index==0&&fixture.phase==0&&fixture.phaseTicks==0, + "Client startup consumed the death/respawn case deadline"); + System.out.println("RESPAWN_STARTUP_CLOCK_PASS waiting_ticks=1000 case_ticks=0"); + } finally { + if(fixture!=null)FMLCommonHandler.instance().bus().unregister(fixture); + if(previous==null)System.clearProperty("lootablebodies.multiplayer.sync"); + else System.setProperty("lootablebodies.multiplayer.sync",previous); + } + } + static File file(String name){return new File(System.getProperty("lootablebodies.multiplayer.sync"),name);} + private EntityPlayerMP player(String role){return server.getPlayerList().getPlayerByUsername("Container"+role);} + private boolean ack(String role,int p){return file(role+".case-"+index+"-"+p).isFile();} + private void trace(EntityPlayerMP p){if(p!=null&&traced.add(p.getName()))RespawnPacketTrace.install(p.connection.netManager,file(p.getName()+"-server-packets.txt"),null);} + @SubscribeEvent public void tick(TickEvent.ServerTickEvent e) { + if(e.phase!=TickEvent.Phase.END||finished)return; + try { + RuntimeChecks.require(++ticks<20000,"Respawn matrix total timeout"); + RuntimeChecks.require(!file("abort").exists(),"Respawn matrix aborted"); + EntityPlayerMP a=player("A"),b=player("B");trace(a);trace(b); + if(index==0&&phase==0&&(a==null||b==null||!ack("A",0)||!ack("B",0))) { + RuntimeChecks.require(++startupTicks<2400,"Clients did not finish startup before the respawn matrix"); + return; + } + phaseTicks++; + if(phaseTicks%100==0)System.out.println("RESPAWN_MATRIX_STATE case="+index+" phase="+phase+" A="+(a==null?null:a.getPosition())+" body="+(body==null?null:body.getPosition())+" tracked="+(body==null?null:body.world.getMinecraftServer().getWorld(body.dimension).getEntityTracker().getTrackingPlayers(body))); + RuntimeChecks.require(phaseTicks<500,"Corpse missing after accepted respawn case="+index+" phase="+phase); + if(phase==0 && a!=null&&b!=null&&ack("A",0)&&ack("B",0)) { + int kind=index%8;int dim=kind==7?-1:0;WorldServer w=server.getWorld(dim); + if(a.dimension!=dim)transfer(a,dim); + if(b.dimension!=dim)transfer(b,dim); + double x=kind==6?-.4:96.5,z=kind==6?-.4:96.5; + for(int dx=-6;dx<=6;dx++)for(int dz=-6;dz<=6;dz++) { + BlockPos floor=new BlockPos(x+dx,79,z+dz);w.setBlockState(floor,Blocks.OBSIDIAN.getDefaultState()); + for(int y=80;y<=84;y++)w.setBlockToAir(new BlockPos(x+dx,y,z+dz)); + } + w.getGameRules().setOrCreateGameRule("doMobSpawning","false");w.getGameRules().setOrCreateGameRule("keepInventory","false"); + a.setGameType(GameType.SURVIVAL);b.setGameType(GameType.SURVIVAL);a.inventory.clear();b.inventory.clear(); + a.setSpawnPoint(new BlockPos(kind==5?160:(int)Math.floor(x),80,(int)Math.floor(z)-3),true); + b.setSpawnPoint(new BlockPos((int)Math.floor(x),80,(int)Math.floor(z)-3),true); + if(kind==7)a.setSpawnPoint(new BlockPos(96,80,93),true); + for(int dx=157;dx<=164;dx++)for(int dz=90;dz<=98;dz++)w.setBlockState(new BlockPos(dx,79,dz),Blocks.OBSIDIAN.getDefaultState()); + a.inventory.mainInventory.set(9,RuntimeChecks.stack(520+index));a.inventoryContainer.detectAndSendChanges(); + if(simultaneous){b.inventory.mainInventory.set(9,RuntimeChecks.stack(620+index));b.inventoryContainer.detectAndSendChanges();} + a.connection.setPlayerLocation(x,80,z,0,0);b.connection.setPlayerLocation(kind==4?160.5:x+2,80,z-2.5,0,25); + state(1); + } else if(phase==1&&ack("A",1)&&ack("B",1)&&phaseTicks>10) { + dead=a;Set before=new HashSet<>(a.world.loadedEntityList);a.onKillCommand();body=null; + for(Entity ent:a.world.loadedEntityList)if(ent instanceof EntityLootableBody&&!before.contains(ent))body=(EntityLootableBody)ent; + RuntimeChecks.require(body!=null,"No server death corpse");state(2); + if(simultaneous) { + observerDead=b;before=new HashSet<>(b.world.loadedEntityList);b.onKillCommand();observerBody=null; + for(Entity ent:b.world.loadedEntityList)if(ent instanceof EntityLootableBody&&!before.contains(ent))observerBody=(EntityLootableBody)ent; + RuntimeChecks.require(observerBody!=null,"No observer death corpse"); + } + } else if(phase==2&&a!=null&&a!=dead&&a.isEntityAlive()&&(!simultaneous||(b!=null&&b!=observerDead&&b.isEntityAlive()))) { + RuntimeChecks.require(body.isEntityAlive()&&VisibilityChecks.has(body,520+index),"Server corpse/inventory lost during respawn"); + if(a.dimension!=body.dimension)transfer(a,body.dimension); + a.connection.setPlayerLocation(body.posX,body.posY,body.posZ-2.5,0,25); + if(b.dimension!=body.dimension)transfer(b,body.dimension); + b.connection.setPlayerLocation(body.posX+1.5,body.posY,body.posZ-2.5,0,25); + // With no players left, Forge may unload/reload the Nether between + // death and return. Saved UUIDs survive; runtime entity IDs do not. + body=live(body);if(simultaneous)observerBody=live(observerBody);state(3); + } else if(phase==3) { + if(phaseTicks==10){writeTracked(a,"A");writeTracked(b,"B");} + if(!ack("A",3)||!ack("B",3))return; + RuntimeChecks.require(a.openContainer instanceof cyano.lootable.graphics.CorpseContainer,"Owner GUI not backed by server container"); + RuntimeChecks.require(body.isEntityAlive()&&VisibilityChecks.has(body,520+index),"Corpse lost exact tagged items"); + if(simultaneous)RuntimeChecks.require(observerBody.isEntityAlive()&&VisibilityChecks.has(observerBody,620+index),"Observer corpse lost exact tagged items"); + try(FileWriter out=new FileWriter(file("checks.txt"),true)){out.write("PASS case="+index+" kind="+(index%8)+" owner_and_observer_after_respawn\n");} + a.closeScreen();b.closeScreen();index++; + if(index==count){state(9);}else state(0); + } else if(phase==9&&server.getPlayerList().getCurrentPlayerCount()==0) { + Files.write(file("respawn.pass").toPath(),new byte[]{1});System.out.println("LOOTABLE_BODIES_RESPAWN_MATRIX_PASS");finished=true;server.initiateShutdown(); + } + } catch(Throwable failure){failure.printStackTrace();try{Files.write(file("server.failure").toPath(),failure.toString().getBytes(StandardCharsets.UTF_8));}catch(IOException ignored){}finished=true;server.initiateShutdown();} + } + private void state(int value) throws Exception { + phase=value;phaseTicks=0;String line=index+" "+phase+" "+(body==null?"none":body.getUniqueID())+" "+(body==null?-1:body.getEntityId()); + File tmp=file("state.tmp");Files.write(tmp.toPath(),line.getBytes(StandardCharsets.UTF_8)); + // Windows readers briefly deny replacement while their handle is open. + for(int retry=0;;retry++)try{Files.move(tmp.toPath(),file("state").toPath(),StandardCopyOption.REPLACE_EXISTING,StandardCopyOption.ATOMIC_MOVE);break;} + catch(java.nio.file.AccessDeniedException busy){if(retry==50)throw busy;Thread.sleep(1);} + System.out.println("RESPAWN_MATRIX_PHASE "+line); + } + private void transfer(EntityPlayerMP player,int dimension) { + WorldServer target=server.getWorld(dimension); + server.getPlayerList().transferPlayerToDimension(player,dimension,new Teleporter(target){ + @Override public void placeInPortal(Entity entity,float yaw){entity.setLocationAndAngles(96.5,80,96.5,yaw,0);} + }); + } + private void writeTracked(EntityPlayerMP player,String role) throws Exception { + List expected=new ArrayList<>();WorldServer w=player.getServerWorld(); + for(Entity e:w.loadedEntityList)if(e instanceof EntityLootableBody&&e.isEntityAlive()&&w.getEntityTracker().getTrackingPlayers(e).contains(player))expected.add(e.getEntityId()+" "+e.getUniqueID()); + Collections.sort(expected);Files.write(file("expected-"+index+"-"+role+".txt").toPath(),expected,StandardCharsets.UTF_8); + } + private EntityLootableBody live(EntityLootableBody previous) { + WorldServer w=server.getWorld(previous.dimension); + w.getChunk((int)Math.floor(previous.posX/16),(int)Math.floor(previous.posZ/16)); + return RuntimeChecks.find(w,previous.getUniqueID()); + } +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/RespawnPacketClientChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/RespawnPacketClientChecks.java new file mode 100644 index 0000000..f2c8719 --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/RespawnPacketClientChecks.java @@ -0,0 +1,133 @@ +package cyano.lootable.buildtest; + +import cyano.lootable.entities.EntityLootableBody; +import cyano.lootable.graphics.CorpseGUIContainer; +import com.mojang.authlib.GameProfile; +import com.mojang.authlib.properties.Property; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.*; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.*; +import net.minecraft.client.multiplayer.GuiConnecting; +import net.minecraft.entity.Entity; +import net.minecraft.item.ItemStack; +import net.minecraft.util.*; +import net.minecraft.server.management.PlayerProfileCache; +import net.minecraft.tileentity.TileEntitySkull; +import net.minecraft.world.World; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.fml.common.FMLCommonHandler; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; +import net.minecraftforge.fml.common.gameevent.TickEvent; + +/** Respawns on the death notification, without waiting for a corpse spawn. */ +final class RespawnPacketClientChecks { + private final String role=System.getProperty("lootablebodies.multiplayer.role"); + private int index=-1,phase=-1,frames,ticks,deathTicks,guiFrames; + private boolean connecting,traced,requested,finished; + private UUID uuid; + private int entityId; + private final Set completed=new HashSet<>(); + private File observations; + private World profileWorld; + void register(){if(Boolean.getBoolean("lootablebodies.respawn")&&role!=null){FMLCommonHandler.instance().bus().register(this);MinecraftForge.EVENT_BUS.register(this);}} + @SubscribeEvent public void mouse(net.minecraftforge.client.event.GuiScreenEvent.MouseInputEvent.Pre e){e.setCanceled(true);} + @SubscribeEvent public void key(net.minecraftforge.client.event.GuiScreenEvent.KeyboardInputEvent.Pre e){e.setCanceled(true);} + private void respawn(Minecraft mc) { + if((!role.equals("A")&&!Boolean.getBoolean("lootablebodies.respawn.simultaneous"))||mc.player==null)return; + if(mc.currentScreen instanceof GuiGameOver) { + int kind=index%8,delay=kind==1?1:kind==2?20:kind==3?60:0; + if(!requested&&deathTicks>=delay){ + if(Boolean.getBoolean("lootablebodies.respawn.counterCollision")&&kind==2) { + if(phase!=2)return; + try {java.lang.reflect.Field counter=net.minecraftforge.fml.relauncher.ReflectionHelper.findField(Entity.class,"nextEntityID","field_70152_a");counter.setInt(null,entityId);} + catch(Exception error){throw new RuntimeException(error);} + observe(mc,"SEEDED_CLIENT_COUNTER_COLLISION next_id="+entityId); + } + requested=true;mc.player.respawnPlayer();observe(mc,"REQUEST_RESPAWN without_corpse_barrier delay="+delay); + } + } else if(mc.player.isEntityAlive()){requested=false;deathTicks=0;} + } + @SubscribeEvent public void tick(TickEvent.ClientTickEvent e) { + Minecraft mc=Minecraft.getMinecraft(); + if(e.phase==TickEvent.Phase.START) { + if(mc.world!=null&&mc.world!=profileWorld) { + profileWorld=mc.world; + // Packet tests use cached synthetic profiles, never real accounts or + // live profile-service availability. The local_texture default stays false. + TileEntitySkull.setProfileCache(new PlayerProfileCache(null,new File(mc.gameDir,"fixture-usercache.json")) { + @Override public GameProfile getGameProfileForUsername(String name) { + RuntimeChecks.require(name.matches("Container[AB]"),"Unexpected packet fixture profile"); + UUID id=UUID.nameUUIDFromBytes(("OfflinePlayer:"+name).getBytes(StandardCharsets.UTF_8)); + GameProfile p=new GameProfile(id,name); + String texture="{\"timestamp\":0,\"profileId\":\""+id.toString().replace("-","")+"\",\"profileName\":\""+name+"\",\"textures\":{}}"; + p.getProperties().put("textures",new Property("textures",Base64.getEncoder().encodeToString(texture.getBytes(StandardCharsets.UTF_8))));return p; + } + }); + } + return; + } + if(mc.inGameHasFocus)mc.setIngameNotInFocus();net.minecraft.client.settings.KeyBinding.unPressAllKeys(); + try { + if(finished){mc.shutdown();return;} + RuntimeChecks.require(++ticks<20000,"Respawn client total timeout");RuntimeChecks.require(!RespawnPacketChecks.file("abort").exists(),"Respawn runner aborted"); + String[] state=new String(Files.readAllBytes(RespawnPacketChecks.file("state").toPath()),StandardCharsets.UTF_8).trim().split(" "); + int nextIndex=Integer.parseInt(state[0]),nextPhase=Integer.parseInt(state[1]); + if(nextIndex!=index||nextPhase!=phase){index=nextIndex;phase=nextPhase;frames=0;guiFrames=0;System.out.println("RESPAWN_CLIENT_PHASE "+role+" "+index+" "+phase);} + uuid=state[2].equals("none")?null:UUID.fromString(state[2]);entityId=Integer.parseInt(state[3]);frames++; + if(!connecting&&mc.currentScreen instanceof GuiMainMenu) { + connecting=true;mc.gameSettings.pauseOnLostFocus=false;mc.gameSettings.limitFramerate=30;mc.gameSettings.renderDistanceChunks=4; + mc.displayGuiScreen(new GuiConnecting(new GuiMainMenu(),mc,"127.0.0.1",Integer.parseInt(System.getProperty("lootablebodies.multiplayer.port")))); + } + if(mc.world==null||mc.player==null)return; + if(!traced) { + traced=true;observations=new File(mc.gameDir,"main-packet-observations.txt"); + RespawnPacketTrace.install(mc.getConnection().getNetworkManager(),new File(mc.gameDir,"client-packets.txt"),packet->mc.addScheduledTask(()->{observe(mc,"AFTER "+packet);respawn(mc);})); + } + if(mc.currentScreen instanceof GuiGameOver)deathTicks++; + respawn(mc); + if(frames%100==0)observe(mc,"TICK phase="+phase+" frame="+frames); + RuntimeChecks.require(frames<450,"Client corpse missing case="+index+" phase="+phase+" role="+role); + String key=index+"-"+phase;if(completed.contains(key))return; + if(phase==0&&frames>10){mc.player.closeScreen();ack(key);} + else if(phase==1&&frames>20){ + if(index%4==1)Thread.sleep(250); // Coalesce an ordinary FIFO packet burst; no packet reordering. + ack(key); + } else if(phase==3&&frames>20) { + File expected=RespawnPacketChecks.file("expected-"+index+"-"+role+".txt");if(!expected.isFile())return; + for(String line:Files.readAllLines(expected.toPath(),StandardCharsets.UTF_8)) { + String[] pair=line.split(" ");Entity tracked=mc.world.getEntityByID(Integer.parseInt(pair[0])); + if(!(tracked instanceof EntityLootableBody)||!tracked.getUniqueID().equals(UUID.fromString(pair[1]))||tracked.isDead||!mc.world.loadedEntityList.contains(tracked))return; + } + Entity entity=mc.world.getEntityByID(entityId); + if(!(entity instanceof EntityLootableBody)||!entity.getUniqueID().equals(uuid)||!mc.world.loadedEntityList.contains(entity)||entity.isDead)return; + if(role.equals("A")) { + if(!(mc.currentScreen instanceof CorpseGUIContainer)){mc.playerController.interactWithEntity(mc.player,entity,EnumHand.MAIN_HAND);return;} + boolean found=false;for(int slot=0;slot<48;slot++)if(RuntimeChecks.sameStack(mc.player.openContainer.getSlot(slot).getStack(),RuntimeChecks.stack(520+index)))found=true; + if(!found||++guiFrames<10)return; + } + observe(mc,"VERIFIED owner_or_observer exact_uuid_loaded_after_respawn"); + if(index%8==0||index%8==7)ScreenShotHelper.saveScreenshot(mc.gameDir,"respawn-case-"+index+".png",mc.displayWidth,mc.displayHeight,mc.getFramebuffer()); + ack(key); + } else if(phase==9) { + new File(mc.gameDir,"respawn-client.pass").createNewFile();System.out.println("LOOTABLE_BODIES_RESPAWN_CLIENT_PASS "+role); + mc.player.closeScreen(); + // Close the transport before clearing the world. The next main + // loop drains already queued destroy packets against that world; + // clearing it here races vanilla 1.11's unguarded packet handler. + mc.getConnection().getNetworkManager().closeChannel(new net.minecraft.util.text.TextComponentString("Disposable check complete")); + finished=true; + } + } catch(Throwable failure){failure.printStackTrace();try{Files.write(RespawnPacketChecks.file(role+".failure").toPath(),failure.toString().getBytes(StandardCharsets.UTF_8));}catch(IOException ignored){}finished=true;mc.shutdown();} + } + private void ack(String key) throws Exception {completed.add(key);Files.write(RespawnPacketChecks.file(role+".case-"+key).toPath(),new byte[]{1});} + private synchronized void observe(Minecraft mc,String event) { + try(FileWriter out=new FileWriter(observations,true)) { + StringBuilder bodies=new StringBuilder();if(mc.world!=null)for(Entity ent:mc.world.loadedEntityList)if(ent instanceof EntityLootableBody)bodies.append(ent.getEntityId()).append(':').append(ent.getUniqueID()).append(':').append(ent.isDead).append(','); + Entity mapped=mc.world==null?null:mc.world.getEntityByID(entityId); + out.write(System.nanoTime()+" case="+index+" phase="+phase+" "+event+" world="+(mc.world==null?"none":mc.world.provider.getDimension())+" player="+(mc.player==null?"none":mc.player.getEntityId())+" mapped="+(mapped==null?null:mapped.getUniqueID())+" loaded="+bodies+"\n"); + } catch(IOException error){throw new RuntimeException(error);} + } +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/RespawnPacketTrace.java b/src/integrationTest/java/cyano/lootable/buildtest/RespawnPacketTrace.java new file mode 100644 index 0000000..6a22977 --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/RespawnPacketTrace.java @@ -0,0 +1,66 @@ +package cyano.lootable.buildtest; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.channel.*; +import java.io.*; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import net.minecraft.network.NetworkManager; +import net.minecraft.network.Packet; +import net.minecraft.network.PacketBuffer; +import net.minecraft.network.play.server.*; +import net.minecraft.network.play.client.CPacketClientStatus; +import net.minecraftforge.fml.common.network.ByteBufUtils; +import net.minecraftforge.fml.common.network.internal.FMLProxyPacket; + +/** Observes decoded packets without dropping, delaying, mutating or reordering them. */ +final class RespawnPacketTrace extends ChannelDuplexHandler { + interface After {void received(String packet);} + private final File log; + private final After after; + private long sequence; + private RespawnPacketTrace(File log,After after){this.log=log;this.after=after;} + static void install(NetworkManager manager,File log,After after) { + Channel channel=manager.channel(); + channel.eventLoop().submit(()->channel.pipeline().addBefore("packet_handler","corpse_packet_trace",new RespawnPacketTrace(log,after))).syncUninterruptibly(); + } + @Override public void channelRead(ChannelHandlerContext context,Object packet) throws Exception { + String description=describe(packet);if(description!=null)record("IN "+description); + if(Boolean.getBoolean("lootablebodies.respawn.dropSpawn")&&log.getName().equals("client-packets.txt") + &&description!=null&&description.startsWith("MOD_SPAWN ")&&description.contains("mod=lootablebodies ")) { + record("FAULT_DROPPED "+description);((FMLProxyPacket)packet).payload().release();return; + } + super.channelRead(context,packet); + if(description!=null&&after!=null)after.received(description); + } + @Override public void write(ChannelHandlerContext context,Object packet,ChannelPromise promise) throws Exception { + String description=describe(packet);if(description!=null)record("OUT "+description); + super.write(context,packet,promise); + } + private synchronized void record(String line) throws IOException {try(FileWriter out=new FileWriter(log,true)){out.write((++sequence)+" "+System.nanoTime()+" "+line+"\n");}} + static String describe(Object packet) { + if(packet instanceof FMLProxyPacket) { + FMLProxyPacket f=(FMLProxyPacket)packet;if(!f.channel().equals("FML"))return null; + ByteBuf b=f.payload().duplicate();if(b.readableBytes()<6||b.readUnsignedByte()!=2)return null; + int id=b.readInt();String mod=ByteBufUtils.readUTF8String(b);int type=b.readInt(); + return "MOD_SPAWN id="+id+" mod="+mod+" type="+type+" uuid="+new UUID(b.readLong(),b.readLong()); + } + if(!(packet instanceof SPacketRespawn||packet instanceof SPacketDestroyEntities||packet instanceof SPacketUnloadChunk + ||packet instanceof SPacketChunkData||packet instanceof SPacketCombatEvent||packet instanceof SPacketEntityMetadata||packet instanceof CPacketClientStatus))return null; + // Vanilla strips receiving-side accessors from a dedicated distribution. + // Read an independent serialization, never its live reader index/fields. + PacketBuffer b=new PacketBuffer(Unpooled.buffer()); + try { + ((Packet)packet).writePacketData(b); + if(packet instanceof SPacketRespawn)return "RESPAWN dimension="+b.readInt(); + if(packet instanceof SPacketDestroyEntities){int[] ids=new int[b.readVarInt()];for(int i=0;i results=new ArrayList<>(); + static void run(MinecraftServer server,WorldServer target,File folder) throws Exception { + world=target;failures=0;results.clear(); + NBTBase[] wrong={new NBTTagString("opaque"),new NBTTagCompound(),new NBTTagByteArray(new byte[]{1,2}),new NBTTagIntArray(new int[]{1,2}),new NBTTagList()}; + for(int i=0;ibadFall(raw));} + for(int i=0;ibadRotation(raw));} + for(NBTBase raw:new NBTBase[]{new NBTTagString("opaque"),new NBTTagCompound(),new NBTTagByteArray(new byte[]{1})})check("Rotation_wrong_element_type_"+raw.getId(),()->{NBTTagList l=new NBTTagList();l.appendTag(raw.copy());l.appendTag(raw.copy());badRotation(l);}); + for(int count:new int[]{0,1,3})check("Rotation_wrong_length_"+count,()->length(count)); + for(int axis=0;axis<2;axis++)for(double value:new double[]{Double.NaN,Double.NEGATIVE_INFINITY,Double.POSITIVE_INFINITY,Double.MAX_VALUE,-Double.MAX_VALUE,1e-300,-1e-300}){final int a=axis;check("Rotation_bad_component_"+axis+"_"+value,()->component(a,value));} + for(NBTBase raw:new NBTBase[]{new NBTTagFloat(Float.NaN),new NBTTagFloat(Float.NEGATIVE_INFINITY),new NBTTagFloat(Float.POSITIVE_INFINITY),new NBTTagDouble(Double.NaN),new NBTTagDouble(Double.NEGATIVE_INFINITY),new NBTTagDouble(Double.POSITIVE_INFINITY),new NBTTagInt(-1),new NBTTagFloat(-.25F),new NBTTagDouble(-Double.MAX_VALUE),new NBTTagDouble(Double.MAX_VALUE),new NBTTagDouble(1e-300)})check("FallDistance_invalid_"+raw,()->badFall(raw)); + for(int type=0;type<6;type++){final int t=type;check("valid_rotation_numeric_type_"+type,()->numericRotation(t));check("valid_fall_numeric_type_"+type,()->numericFall(t));} + for(float yaw:new float[]{0F,-0F,37.25F,-37.25F,359.75F,-359.75F,360F,-360F,720.25F,-720.25F,Float.MAX_VALUE,-Float.MAX_VALUE})check("valid_yaw_orientation_and_derived_angles_"+Float.floatToIntBits(yaw),()->validYaw(yaw)); + for(float pitch:new float[]{-90,90,720,-720,Float.MAX_VALUE,-Float.MAX_VALUE})check("valid_pitch_keeps_existing_flat_corpse_behavior_"+pitch,()->validPitch(pitch)); + for(float distance:new float[]{0F,-0F,Float.MIN_VALUE,12.5F,Float.MAX_VALUE})check("valid_representable_fall_distance_"+Float.floatToIntBits(distance),()->validFall(new NBTTagFloat(distance),distance)); + check("missing_rotation_and_fall_default_to_zero",RotationFallChecks::missing); + check("repeated_read_resets_previous_head_body_and_fall_state",RotationFallChecks::repeated); + check("healthy_saved_layout_and_defaults_unchanged",RotationFallChecks::healthy); + check("malformed_angles_and_position_keep_containing_chunk_inventory",RotationFallChecks::chunkAdmission); + check("malformed_fields_do_not_heal_valid_zero_health",RotationFallChecks::zeroHealth); + check("malformed_fields_keep_complete_and_unresolved_inventory",RotationFallChecks::inventory); + check("metadata_only_does_not_count_as_items",RotationFallChecks::empty); + check("landing_keeps_default_environmental_damage_disabled",()->landing(false,false)); + check("landing_keeps_enabled_environmental_damage",()->landing(true,false)); + check("repaired_fall_distance_does_not_invent_landing_damage",()->landing(true,true)); + try(PrintWriter out=new PrintWriter(new File(folder,"rotation-fall-checks.txt"),"UTF-8")){for(String s:results)out.println(s);out.println("checks="+results.size()+" failures="+failures);} + require(failures==0,"Rotation/fall regressions: "+failures+" / "+results.size());System.out.println("LOOTABLE_BODIES_ROTATION_FALL_PASS checks="+results.size()); + } + private static void check(String name,Check test){boolean env=LootableBodies.hurtByEnvironment,inv=LootableBodies.completelyInvulnerable;try{test.run();results.add("PASS "+name);System.out.println("ROTATION_FALL_PASS "+name);}catch(Throwable e){failures++;results.add("FAIL "+name+": "+e);System.out.println("ROTATION_FALL_FAIL "+name+": "+e);e.printStackTrace();}finally{LootableBodies.hurtByEnvironment=env;LootableBodies.completelyInvulnerable=inv;}} + private static void require(boolean c,String why){RuntimeChecks.require(c,why);} + private static NBTTagCompound saved(EntityLootableBody b){return NameLifetimeChecks.saved(b);} + private static NBTTagCompound base(){EntityLootableBody b=new EntityLootableBody(world);b.setPosition(160.5,80,160.5);b.setNoGravity(true);b.setUserName("RotationFixture");b.setRotation(37.25F);b.fallDistance=12.5F;for(int i=0;i<48;i++)b.setInventorySlotContents(i,RuntimeChecks.stack(i));b.addItem(RuntimeChecks.stack(70));b.addItem(RuntimeChecks.stack(71));RuntimeChecks.disableVacuum(b);return saved(b);} + private static EntityLootableBody read(NBTTagCompound n){NBTTagCompound before=n.copy();Entity e=EntityList.createEntityFromNBT(n,world);require(e instanceof EntityLootableBody,"Factory discarded corpse");require(NameLifetimeChecks.same(before,n),"Source NBT mutated");return (EntityLootableBody)e;} + static NBTTagList rotation(float... angles){NBTTagList l=new NBTTagList();for(float a:angles)l.appendTag(new NBTTagFloat(a));return l;} + static NBTTagList wideRotation(double... angles){NBTTagList l=new NBTTagList();for(double a:angles)l.appendTag(new NBTTagDouble(a));return l;} + static void finite(EntityLootableBody b){for(float v:new float[]{b.rotationYaw,b.rotationPitch,b.prevRotationYaw,b.prevRotationPitch,b.rotationYawHead,b.prevRotationYawHead,b.renderYawOffset,b.prevRenderYawOffset,b.fallDistance})require(Float.isFinite(v),"Nonfinite rotation/derived angle/fall distance");require(b.fallDistance>=0,"Negative fall distance");} + private static void value(float a,float z,String why){require(Float.isFinite(a) && a==z,why+" expected="+z+" actual="+a);} + private static void angles(EntityLootableBody b,float yaw){finite(b);for(float v:new float[]{b.rotationYaw,b.prevRotationYaw,b.rotationYawHead,b.prevRotationYawHead,b.renderYawOffset,b.prevRenderYawOffset})value(v,yaw,"Yaw/derived orientation differs");value(b.rotationPitch,0,"Corpse pitch no longer flat");value(b.prevRotationPitch,0,"Previous pitch differs");} + private static void kept(EntityLootableBody b){NBTTagCompound n=base(),a=saved(b);for(String k:new String[]{"Equipment","Aux"})require(NameLifetimeChecks.same(n.getTag(k),a.getTag(k)),"Complete inventory changed "+k);require(b.getSizeInventory()==48 && b.getCustomNameTag().equals("RotationFixture"),"Corpse identity/slots changed");} + private static void stable(EntityLootableBody b){finite(b);kept(b);NBTTagCompound a=saved(b),z=saved(read(a));for(String k:new String[]{"Rotation","FallDistance","Pos","Motion","Equipment","Aux",NbtRecoveryChecks.RECOVERY,NameLifetimeChecks.RECOVERY})require(NameLifetimeChecks.same(a.getTag(k),z.getTag(k)),"Round trip changed "+k);} + private static void badFall(NBTBase raw){NBTTagCompound n=base();n.setTag("FallDistance",raw.copy());EntityLootableBody b=read(n);value(b.fallDistance,0,"Bad fall distance activated");NameLifetimeChecks.retained(b,"FallDistance",raw);stable(b);} + private static void badRotation(NBTBase raw){NBTTagCompound n=base();n.setTag("Rotation",raw.copy());EntityLootableBody b=read(n);angles(b,0);NameLifetimeChecks.retained(b,"Rotation",raw);stable(b);} + private static void component(int axis,double raw){NBTTagCompound n=base();NBTTagList r=axis==0?wideRotation(raw,17):wideRotation(37.25,raw);n.setTag("Rotation",r);EntityLootableBody b=read(n);angles(b,axis==0?0:37.25F);NameLifetimeChecks.retained(b,"Rotation",r);stable(b);} + private static void length(int count){float[] a=new float[count];for(int i=0;i0 && b.getHealth()<20:b.getHealth()==20,"Landing changed configured damage behavior");kept(b);} + static NBTTagCompound malformedFields(){NBTTagCompound n=new NBTTagCompound();n.setTag("Rotation",wideRotation(37,Double.NaN));n.setDouble("FallDistance",Double.NaN);return n;} + static void verifyWorldFields(EntityLootableBody b){finite(b);value(b.rotationYaw,37,"Valid disk yaw lost");value(b.rotationPitch,0,"Disk corpse pitch changed");value(b.fallDistance,0,"Disk fall distance unsafe");NBTTagCompound raw=malformedFields();NameLifetimeChecks.retained(b,"Rotation",raw.getTag("Rotation"));NameLifetimeChecks.retained(b,"FallDistance",raw.getTag("FallDistance"));} +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/RuntimeChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/RuntimeChecks.java new file mode 100644 index 0000000..d980e4c --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/RuntimeChecks.java @@ -0,0 +1,268 @@ +package cyano.lootable.buildtest; + +import com.mojang.authlib.GameProfile; +import cyano.lootable.LootableBodies; +import cyano.lootable.entities.EntityLootableBody; +import cyano.lootable.graphics.CorpseContainer; +import cyano.lootable.graphics.GUIHandler; +import java.io.*; +import java.util.*; +import net.minecraft.entity.Entity; +import net.minecraft.entity.player.EntityPlayerMP; +import net.minecraft.init.Items; +import net.minecraft.inventory.EntityEquipmentSlot; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.*; +import net.minecraft.server.MinecraftServer; +import net.minecraft.util.DamageSource; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.WorldServer; +import net.minecraftforge.common.util.FakePlayer; +import net.minecraftforge.common.util.FakePlayerFactory; +import net.minecraftforge.fml.common.*; +import net.minecraftforge.fml.common.event.*; + +/** Build-only verifier. Never included in the production source set or jars. */ +@Mod(modid="lootablebodiesbuildtest", name="Lootable Bodies Build Tests", version="1", dependencies="required-after:lootablebodies") +public final class RuntimeChecks { + static final UUID NEAR = UUID.fromString("00000000-0000-4000-8000-000000000101"); + static final UUID FAR = UUID.fromString("00000000-0000-4000-8000-000000000102"); + static final UUID DEATH = UUID.fromString("00000000-0000-4000-8000-000000000103"); + public static volatile Throwable failure; + private static boolean defaultsChecked; + @Mod.EventHandler public void preInit(FMLPreInitializationEvent event){MultiplayerChecks.registerChannel();net.minecraftforge.common.MinecraftForge.EVENT_BUS.register(new TestRegistries());UpgradeChecks.registerOwnFixer();FixtureItems.register();} + @Mod.EventHandler public void clientInit(FMLInitializationEvent event) { + if (FMLCommonHandler.instance().getSide().isClient()) { + new ClientChecks().register();new MultiplayerClientChecks().register();new VisibilityClientChecks().register();new RespawnPacketClientChecks().register(); + } + } + @Mod.EventHandler public void started(FMLServerStartedEvent event) { + if (FMLCommonHandler.instance().getSide().isClient()) return; + MinecraftServer server = FMLCommonHandler.instance().getMinecraftServerInstance(); + if(Boolean.getBoolean("lootablebodies.respawn")) { + try{new RespawnPacketChecks(server);}catch(Exception error){throw new RuntimeException(error);}return; + } + if(Boolean.getBoolean("lootablebodies.visibility")) { + try{new VisibilityChecks(server);}catch(Exception error){throw new RuntimeException(error);}return; + } + if(Boolean.getBoolean("lootablebodies.multiplayer.server")) { + try{new MultiplayerChecks(server);}catch(Exception error){throw new RuntimeException(error);}return; + } + try { + run(server, System.getProperty("lootablebodies.phase", "fresh")); + } catch (Throwable error) { + failure = error; + error.printStackTrace(); + } finally { server.initiateShutdown(); } + } + static void require(boolean condition, String message) { + if (!condition) throw new AssertionError(message); + } + static boolean sameStack(ItemStack first,ItemStack second) { + return ItemStack.areItemStacksEqual(first==null?ItemStack.EMPTY:first,second==null?ItemStack.EMPTY:second); + } + public static void run(MinecraftServer server, String phase) throws Exception { + WorldServer world = server.getWorld(0); + File folder = world.getSaveHandler().getWorldDirectory(); + require(LootableBodies.MODID.equals("lootablebodies") && LootableBodies.VERSION.equals("2.4.3.112021"), "identity/version"); + require(net.minecraftforge.fml.common.Loader.instance().getIndexedModList().get("lootablebodies") + .getVersion().equals(phase.equals("seed") ? "2.4.0" : phase.equals("issues") ? + System.getProperty("lootablebodies.expectedIssueVersion","2.4.3.112021") : "2.4.3.112021"), "loaded release version"); + if (!defaultsChecked) require(LootableBodies.addBonesToCorpse && LootableBodies.displayNameTag && !LootableBodies.hurtByEnvironment && + !LootableBodies.hurtByAttacks && !LootableBodies.hurtByMisc && LootableBodies.corpseHP == 20 && + LootableBodies.ticksPerItemDecay == 6000 && LootableBodies.corpseDecayTime == 72000 && + LootableBodies.allowCorpseDecay && LootableBodies.decayOnlyWhenEmpty && + (phase.equals("seed") || !LootableBodies.corpseBuoyancy && !LootableBodies.voidPlatform && LootableBodies.dimensionSpecificPlatform), "default configuration"); + defaultsChecked=true; + if(phase.equals("fresh")&&FMLCommonHandler.instance().getSide().isServer()) + RespawnPacketChecks.verifySlowClientStartup(server); + // Freeze timers only in this verifier while comparing exact inventories. + LootableBodies.allowCorpseDecay = false; + LootableBodies.ticksPerItemDecay = -1; + if(phase.equals("upgrade-contracts")) { + UpgradeChecks.run(server); + } else if(phase.startsWith("upgrade-")) { + WorldUpgradeChecks.run(server,phase); + } else if(phase.equals("visibility-reload")) { + VisibilityChecks.reload(server); + } else if(phase.equals("issues")) { + ReportedIssueChecks.run(server,world,folder); + ReportedIssueChecks.seedPaused(); + } else if(phase.equals("issues-reload")) { + ReportedIssueChecks.coldReload(server,world,folder); + } else if(phase.equals("multiplayer-reload")) { + MultiplayerChecks.verifyReload(server); + } else if (phase.equals("wall-escape")) { + WallEscapeChecks.run(server,world,folder); + } else if (phase.equals("wall-escape-reload")) { + WallEscapeChecks.reload(world,folder); + } else if (phase.startsWith("wall-historical")) { + WallEscapeChecks.historical(server,folder,phase.endsWith("reload")); + } else if (phase.equals("rotation")) { + RotationFallChecks.run(server,world,folder); + } else if (phase.equals("geometry")) { + PositionMotionChecks.run(server,world,folder); + } else if (phase.equals("effects")) { + EffectHurtChecks.run(server,world,folder); + } else if (phase.equals("health")) { + HealthAttributeChecks.run(server,world,folder); + } else if (phase.equals("scalars")) { + NameLifetimeChecks.run(server,world,folder); + } else if (phase.startsWith("nbt-")) { + NbtWorldChecks.run(server,phase); + } else if (phase.equals("nbt")) { + NbtRecoveryChecks.run(server,world,folder); + } else if (phase.equals("drops")) { + ContainerInteractionChecks.runDrops(server,world,folder); + } else if (phase.equals("interactions")) { + ContainerInteractionChecks.run(server,world,folder); + } else if (phase.equals("inventory")) { + InventorySafetyChecks.run(server,world,folder); + } else if (phase.equals("fresh") || phase.equals("seed")) { + if (phase.equals("fresh")) PortBaselineChecks.run(server); + if (phase.equals("fresh")) Recovery112Checks.run(server); + if (phase.equals("fresh")) ReportedIssueChecks.run(server,world,folder); + if (phase.equals("fresh")) InventorySafetyChecks.run(server,world,folder); + if (phase.equals("fresh")) ContainerInteractionChecks.run(server,world,folder); + if (phase.equals("fresh")) ContainerInteractionChecks.runDrops(server,world,folder); + if (phase.equals("fresh")) NbtRecoveryChecks.run(server,world,folder); + if (phase.equals("fresh")) NameLifetimeChecks.run(server,world,folder); + if (phase.equals("fresh")) HealthAttributeChecks.run(server,world,folder); + if (phase.equals("fresh")) EffectHurtChecks.run(server,world,folder); + if (phase.equals("fresh")) PositionMotionChecks.run(server,world,folder); + if (phase.equals("fresh")) RotationFallChecks.run(server,world,folder); + if (phase.equals("fresh")) WallEscapeChecks.run(server,world,folder); + require(!new File(folder, "near.nbt").exists(), "fresh fixture already exists"); + EntityLootableBody near = fixture(world, NEAR, 8, true); + EntityLootableBody far = fixture(world, FAR, 4096, false); + saveExpected(folder, "near", near); + saveExpected(folder, "far", far); + verifyRoundTrip(world, near); + verifyRoundTrip(world, far); + death(server, world, folder); + if (phase.equals("fresh")) InventorySafetyChecks.seedBlockedRelease(world,folder); + if (phase.equals("fresh")) ReportedIssueChecks.seedPaused(); + } else if (phase.equals("reload") || phase.equals("deferred")) { + if (phase.equals("reload") && new File(folder,"wall-escape-checks.txt").isFile()) WallEscapeChecks.reload(world,folder); + if(new File(folder,"issue17-chunks.txt").isFile()) ReportedIssueChecks.coldReload(server,world,folder); + world.getChunk(0, 0); + EntityLootableBody near = find(world, NEAR); + verifyExpected(folder, "near", near); + world.getChunk(2, 0); + verifyExpected(folder, "death", find(world, DEATH)); + if(new File(folder,"rejected-release.nbt").isFile()) { + world.getChunk(3,0); + EntityLootableBody retained=find(world,UUID.fromString("00000000-0000-4000-8000-000000000105")); + require(retained.isEntityAlive(),"Blocked-release owner did not survive reload"); + // The saved profile is recreated lazily on the first entity tick. + retained.onEntityUpdate(); + verifyExpected(folder,"rejected-release",retained); + } + if (phase.equals("deferred")) { + world.getChunk(256, 256); + verifyExpected(folder, "far", find(world, FAR)); + } else { + require(!world.getChunkProvider().chunkExists(256, 256), "deferred chunk opened early"); + } + } else throw new AssertionError("Unknown phase " + phase); + server.saveAllWorlds(false); + File marker = new File(folder, phase + ".pass"); + try (FileWriter output = new FileWriter(marker)) { output.write("corpse-inventory-preserved\n"); } + System.out.println("LOOTABLE_BODIES_RUNTIME_PASS phase=" + phase); + } + static ItemStack stack(int index) { + ItemStack stack = index < 4 ? new ItemStack(new net.minecraft.item.Item[]{Items.DIAMOND_HELMET, Items.DIAMOND_CHESTPLATE, Items.DIAMOND_LEGGINGS, Items.DIAMOND_BOOTS}[index],1,index+2) : + index == 4 ? new ItemStack(Items.DIAMOND_SWORD,1,17) : index == 5 ? new ItemStack(Items.SHIELD,1,9) : + new ItemStack(Items.DYE, 1 + index % 32, index % 16); + NBTTagCompound custom = new NBTTagCompound(); + custom.setString("fixture", "slot-" + index); + custom.setTag("typed_longs",new NBTTagLongArray(new long[]{Long.MIN_VALUE,index,Long.MAX_VALUE})); + NBTTagCompound nested = new NBTTagCompound(); nested.setInteger("unchanged", index * 17); + NBTTagList contents = new NBTTagList(); + NBTTagCompound item = new NBTTagCompound(); new ItemStack(Items.DIAMOND,3).writeToNBT(item); contents.appendTag(item); + nested.setTag("contents", contents); custom.setTag("nested", nested); + stack.setTagCompound(custom); stack.setStackDisplayName("Fixture " + index); + if (index == 4) stack.addEnchantment(net.minecraft.init.Enchantments.UNBREAKING, 2); + return stack; + } + static EntityLootableBody fixture(WorldServer world, UUID uuid, int coordinate, boolean full) { + world.getChunk(coordinate >> 4, coordinate >> 4); + EntityLootableBody body = new EntityLootableBody(world); + body.setUniqueId(uuid); body.setPosition(coordinate+0.5, 80, coordinate+0.5); + body.setNoGravity(true); body.setUserName("BuildFixture"); body.setRotation(37); + require(body.getSizeInventory() == 48, "48 inventory slots"); + for (int i=0; i<48; i++) if (full || i % 7 == 0 || i == 47) body.setInventorySlotContents(i,stack(i)); + if (full) { body.addItem(stack(70)); body.addItem(stack(71)); } + disableVacuum(body); + require(world.spawnEntity(body), "fixture spawn"); + return body; + } + static void verifyRoundTrip(WorldServer world, EntityLootableBody body) { + NBTTagCompound before = new NBTTagCompound(); body.writeToNBTOptional(before); + require(before.getString("id").equals("lootablebodies:corpse"), "persistent entity name: " + before.getString("id")); + Entity copy = net.minecraft.entity.EntityList.createEntityFromNBT(before.copy(), world); + require(copy instanceof EntityLootableBody, "NBT factory registration"); + NBTTagCompound after = new NBTTagCompound(); copy.writeToNBTOptional(after); + compare(before,after); + if (body.getUniqueID().equals(NEAR)) { + require(before.getTagList("Equipment",10).tagCount()==48, "all indexed stacks"); + require(before.getTagList("Aux",10).tagCount()==2, "ordered overflow"); + } + } + static void death(MinecraftServer server, WorldServer world, File folder) throws Exception { + EntityPlayerMP player = new EntityPlayerMP(server, world, + new GameProfile(UUID.fromString("00000000-0000-4000-8000-000000000104"),"BuildDeath"), + new net.minecraft.server.management.PlayerInteractionManager(world)); + player.connection = new net.minecraft.network.NetHandlerPlayServer(server, + new net.minecraft.network.NetworkManager(net.minecraft.network.EnumPacketDirection.SERVERBOUND), player); + player.setPosition(32.5,80,0.5); world.getChunk(2,0); + world.getGameRules().setOrCreateGameRule("keepInventory","false"); + player.inventory.mainInventory.set(0,stack(4)); + player.inventory.mainInventory.set(9,stack(19)); + player.setItemStackToSlot(EntityEquipmentSlot.HEAD,stack(0)); + player.setItemStackToSlot(EntityEquipmentSlot.OFFHAND,stack(5)); + player.onDeath(DamageSource.OUT_OF_WORLD); + EntityLootableBody corpse = null; + for (Entity entity : new ArrayList(world.loadedEntityList)) + if (entity instanceof EntityLootableBody && "BuildDeath".equals(entity.getCustomNameTag())) corpse=(EntityLootableBody)entity; + require(corpse != null, "actual player death created corpse"); + require(ItemStack.areItemStacksEqual(corpse.getStackInSlot(0),stack(0)),"death helmet"); + require(ItemStack.areItemStacksEqual(corpse.getStackInSlot(4),stack(4)),"death main hand"); + require(ItemStack.areItemStacksEqual(corpse.getStackInSlot(5),stack(5)),"death offhand"); + require(ItemStack.areItemStacksEqual(corpse.getStackInSlot(6),stack(19)),"death inventory custom NBT"); + Object gui = GUIHandler.getInstance().getServerGuiElement(0,player,world,corpse.getEntityId(),0,0); + require(gui instanceof CorpseContainer,"server inventory GUI"); + CorpseContainer container = (CorpseContainer)gui; + require(container.inventorySlots.size()==84 && container.canInteractWith(player),"container slots/access"); + ItemStack recovered = container.transferStackInSlot(player,6); + require(ItemStack.areItemStacksEqual(recovered,stack(19)),"shift-click recovery"); + require((corpse.getStackInSlot(6)).isEmpty(),"no duplicate in corpse"); + corpse.setUniqueId(DEATH); corpse.setNoGravity(true); + corpse.motionX=0; corpse.motionY=0; corpse.motionZ=0; + disableVacuum(corpse); + saveExpected(folder,"death",corpse); + } + static EntityLootableBody find(WorldServer world, UUID uuid) { + for(Entity entity : world.loadedEntityList) if(entity.getUniqueID().equals(uuid)) return (EntityLootableBody)entity; + throw new AssertionError("Saved corpse missing " + uuid); + } + static void disableVacuum(EntityLootableBody body) { + try { + java.lang.reflect.Field field = EntityLootableBody.class.getDeclaredField("vacuumTime"); + field.setAccessible(true); field.setByte(body,(byte)20); + } catch (Exception error) { throw new AssertionError(error); } + } + static void saveExpected(File folder,String name,EntityLootableBody body) throws Exception { + NBTTagCompound data = new NBTTagCompound(); body.writeToNBTOptional(data); + CompressedStreamTools.writeCompressed(data,new FileOutputStream(new File(folder,name+".nbt"))); + } + static void verifyExpected(File folder,String name,EntityLootableBody body) throws Exception { + NBTTagCompound expected = CompressedStreamTools.readCompressed(new FileInputStream(new File(folder,name+".nbt"))); + NBTTagCompound actual = new NBTTagCompound(); body.writeToNBTOptional(actual); compare(expected,actual); + } + static void compare(NBTTagCompound expected,NBTTagCompound actual) { + for(String key : Arrays.asList("id","UUIDMost","UUIDLeast","Pos","Rotation","CustomName","Equipment","Aux","DeathTime","DecayTime")) + require(Objects.equals(expected.getTag(key),actual.getTag(key)),"Saved field differs " + key + ": " + expected.getTag(key) + " != " + actual.getTag(key)); + if(expected.hasKey("Name")) require(expected.getString("Name").equals(actual.getString("Name")),"profile name"); + } +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/TestRegistries.java b/src/integrationTest/java/cyano/lootable/buildtest/TestRegistries.java new file mode 100644 index 0000000..0f4da20 --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/TestRegistries.java @@ -0,0 +1,12 @@ +package cyano.lootable.buildtest; + +import net.minecraft.item.Item; +import net.minecraft.potion.Potion; +import net.minecraftforge.event.RegistryEvent; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; + +/** Forge 1.12 registers controlled dependencies during their registry events. */ +public final class TestRegistries { + @SubscribeEvent public void items(RegistryEvent.Register event) {NbtWorldChecks.registerItem();} + @SubscribeEvent public void potions(RegistryEvent.Register event) {EffectHurtChecks.registerPotion();} +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/TypedNbt.java b/src/integrationTest/java/cyano/lootable/buildtest/TypedNbt.java new file mode 100644 index 0000000..b621b68 --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/TypedNbt.java @@ -0,0 +1,26 @@ +package cyano.lootable.buildtest; + +import java.util.TreeSet; +import net.minecraft.nbt.*; + +/** Compound order is immaterial; NBT types, list order and every array byte matter. */ +final class TypedNbt { + static String canonical(NBTBase tag) { + if(tag==null)return "null"; + StringBuilder text=new StringBuilder().append(tag.getId()).append(':'); + if(tag instanceof NBTTagCompound) { + NBTTagCompound compound=(NBTTagCompound)tag; + for(String key:new TreeSet<>(compound.getKeySet()))text.append(key.length()).append(':').append(key).append('=').append(canonical(compound.getTag(key))).append(';'); + } else if(tag instanceof NBTTagList) { + NBTTagList list=(NBTTagList)tag;text.append(list.getTagType()).append('/').append(list.tagCount()).append('['); + for(int i=0;iconversion("lootablebodies.Corpse",new NBTTagInt(512))); + check("native_111_identity_survives",()->conversion("lootablebodies:corpse",new NBTTagInt(922))); + check("missing_chunk_version_preserves_48_slots",()->conversion("lootablebodies.Corpse",null)); + check("wrong_type_chunk_version_preserves_48_slots",()->conversion("lootablebodies.Corpse",new NBTTagString("damaged"))); + check("nonfinite_chunk_version_preserves_48_slots",()->conversion("lootablebodies.Corpse",new NBTTagDouble(Double.NaN))); + check("negative_chunk_version_preserves_48_slots",()->conversion("lootablebodies.Corpse",new NBTTagInt(-1))); + check("incorrect_new_stamp_does_not_skip_legacy_items",()->conversion("lootablebodies.Corpse",new NBTTagInt(922))); + check("unknown_items_keep_source_context",UpgradeChecks::missingItem); + check("repeated_fixing_and_loading_never_duplicates",UpgradeChecks::repeated); + check("old_recovery_payload_is_not_mutated",UpgradeChecks::recovery); + check("full_save_does_not_reapply_original_item_versions",UpgradeChecks::savedCounter); + check("damaged_stamp_on_migrated_corpse_preserves_inventory",UpgradeChecks::migratedDamagedStamp); + check("forged_mod_stamp_does_not_bypass_preservation",UpgradeChecks::forgedModStamp); + check("reused_output_compound_cannot_resurrect_old_payload",UpgradeChecks::reusedOutput); + check("all_current_stamps_do_not_hide_legacy_identity",UpgradeChecks::allCurrentStamps); + check("falsely_current_stamps_still_convert_vanilla_container_items",UpgradeChecks::vanillaFalseStamp); + check("native112_bed_colour_is_preserved",()->bed(1343,0,0)); + check("maintained111_still_receives112_item_fixers",()->bed(922,1,14)); + check("current112_marker_never_reapplies_older_item_fixers",()->bed(1343,2,0)); + check("legacy_xp_sound_remaps_without_ignoring_unrelated_entries",UpgradeChecks::soundMapping); + check("unstamped_native112_chunk_keeps_white_bed_in_vanilla_chest",()->nativeChest(false)); + check("legacy_corpse_does_not_downgrade_native112_chunk_items",()->nativeChest(true)); + check("bed_with_no_source_evidence_stays_raw_and_retriable",UpgradeChecks::ambiguousBed); + System.out.println("LOOTABLE_BODIES_UPGRADE_CONTRACT_RESULT checks=23 failures="+failures); + RuntimeChecks.require(failures==0,"Upgrade contracts: "+failures); + } + private static void check(String name,Check check) { + try {check.run();System.out.println("UPGRADE_CONTRACT_PASS "+name);} + catch(Throwable error){failures++;System.out.println("UPGRADE_CONTRACT_FAIL "+name+": "+error);error.printStackTrace();} + } + private static void nativeChest(boolean legacy) throws Exception { + NBTTagCompound entity=body(legacy?"lootablebodies.Corpse":"lootablebodies:corpse"); + if(!legacy)entity.getTagList("Equipment",10).getCompoundTagAt(40).getCompoundTag("tag").getCompoundTag("EntityTag").setString("id","minecraft:pig"); + NBTTagCompound input=chunk(entity,legacy?new NBTTagInt(1343):null); + NBTTagCompound chest=new NBTTagCompound();chest.setString("id","minecraft:chest");chest.setInteger("x",2);chest.setInteger("y",80);chest.setInteger("z",2); + NBTTagCompound bed=new NBTTagCompound();bed.setString("id","minecraft:bed");bed.setByte("Count",(byte)1);bed.setShort("Damage",(short)0);bed.setByte("Slot",(byte)0); + NBTTagList items=new NBTTagList();items.appendTag(bed);chest.setTag("Items",items);NBTTagList tiles=new NBTTagList();tiles.appendTag(chest);input.getCompoundTag("Level").setTag("TileEntities",tiles); + NBTTagCompound fixed=processChunk(input,"1.12.2"); + NBTTagCompound actual=fixed.getCompoundTag("Level").getTagList("TileEntities",10).getCompoundTagAt(0).getTagList("Items",10).getCompoundTagAt(0); + RuntimeChecks.require(NameLifetimeChecks.same(bed,actual),"Native white bed changed under chunk conversion: "+actual); + NBTTagCompound converted=saved(fixed.getCompoundTag("Level").getTagList("Entities",10).getCompoundTagAt(0)); + RuntimeChecks.require(inventory(converted).size()==48,"Legacy/native corpse inventory changed while correcting vanilla context"); + } + private static void ambiguousBed() throws Exception { + NBTTagCompound input=body("lootablebodies:corpse"),item=new NBTTagCompound();item.setString("id","minecraft:bed");item.setByte("Count",(byte)1);item.setShort("Damage",(short)0);item.setByte("Slot",(byte)6); + NBTTagList items=new NBTTagList();items.appendTag(item);input.setTag("Equipment",items); + NBTTagCompound once=saved(process(chunk(input,null))),record=once.getTagList("LootableBodiesUnresolved",10).getCompoundTagAt(0); + RuntimeChecks.require(once.getTagList("LootableBodiesUnresolved",10).tagCount()==1 && NameLifetimeChecks.same(item,record.getTag("Data")) && record.getCompoundTag("SourceVersions").getBoolean("Ambiguous"),"Ambiguous bed was guessed or lost"); + EntityLootableBody again=new EntityLootableBody(world);again.readEntityFromNBT(once); + RuntimeChecks.require(!again.isEmpty(),"Raw recoverable bed allowed empty-corpse decay"); + NBTTagCompound twice=new NBTTagCompound();again.writeEntityToNBT(twice); + RuntimeChecks.require(NameLifetimeChecks.same(once.getTag("LootableBodiesUnresolved"),twice.getTag("LootableBodiesUnresolved")),"Ambiguous bed duplicated or changed during reload"); + } + private static void soundMapping() throws Exception { + net.minecraftforge.registries.IForgeRegistry registry=net.minecraftforge.fml.common.registry.ForgeRegistries.SOUND_EVENTS; + net.minecraftforge.event.RegistryEvent.MissingMappings.Mapping old=new net.minecraftforge.event.RegistryEvent.MissingMappings.Mapping<>(registry,registry,new net.minecraft.util.ResourceLocation("minecraft:entity.experience_orb.touch"),196); + net.minecraftforge.event.RegistryEvent.MissingMappings.Mapping other=new net.minecraftforge.event.RegistryEvent.MissingMappings.Mapping<>(registry,registry,new net.minecraft.util.ResourceLocation("absentfixture:other_sound"),197); + net.minecraftforge.event.RegistryEvent.MissingMappings event=new net.minecraftforge.event.RegistryEvent.MissingMappings<>(new net.minecraft.util.ResourceLocation("minecraft:soundevents"),registry,Arrays.asList(old,other)); + Class.forName("cyano.lootable.upgrade.LegacyRegistryMappings").getMethod("sounds",net.minecraftforge.event.RegistryEvent.MissingMappings.class).invoke(null,event); + RuntimeChecks.require(old.getAction()==net.minecraftforge.event.RegistryEvent.MissingMappings.Action.REMAP && old.getTarget()==registry.getValue(new net.minecraft.util.ResourceLocation("minecraft:entity.experience_orb.pickup")),"Known vanilla sound rename not remapped"); + RuntimeChecks.require(other.getAction()==net.minecraftforge.event.RegistryEvent.MissingMappings.Action.DEFAULT,"Unrelated missing mappings must retain Forge's normal handling"); + } + private static void bed(int source,int marker,int damage) throws Exception { + NBTTagCompound input=body("lootablebodies:corpse"); + input.getTagList("Equipment",10).getCompoundTagAt(40).getCompoundTag("tag").getCompoundTag("EntityTag").setString("id","minecraft:pig"); + NBTTagCompound item=new NBTTagCompound();item.setString("id","minecraft:bed");item.setByte("Count",(byte)2);item.setShort("Damage",(short)0);item.setByte("Slot",(byte)41);input.getTagList("Equipment",10).set(41,item); + if(marker>0)input.setInteger("LootableBodiesDataVersion",marker); + NBTTagCompound result=saved(process(chunk(input,new NBTTagInt(source)))); + NBTTagList equipment=result.getTagList("Equipment",10);NBTTagCompound found=null; + for(int i=0;i preservation=null;Object previous=null;Field active=null; + try { + // The red run exercises the real unprotected Forge pipeline. Once + // implemented, activate the same preflight used before world loading. + try {preservation=Class.forName("cyano.lootable.upgrade.UpgradePreservation");active=preservation.getDeclaredField("journal");active.setAccessible(true);previous=active.get(null);preservation.getMethod("prepare",Path.class).invoke(null,path);} + catch(ClassNotFoundException beforeImplementation) { } + NBTTagCompound input=source.copy(); + if(preservation!=null)try{input=(NBTTagCompound)preservation.getMethod("chunkContext",NBTTagCompound.class).invoke(null,input);}catch(NoSuchMethodException beforeBoundaryFix){} + NBTTagCompound result=FMLCommonHandler.instance().getDataFixer().process(FixTypes.CHUNK,input); + if(preservation!=null)try{preservation.getMethod("clearChunkContext",NBTTagCompound.class).invoke(null,result);}catch(NoSuchMethodException beforeBoundaryFix){} + return result; + } finally {if(active!=null)active.set(null,previous);} + } + private static NBTTagCompound saved(NBTTagCompound fixed) { + RuntimeChecks.require(fixed.getString("id").equals("lootablebodies:corpse"),"legacy identity was not converted before construction"); + Entity entity=net.minecraft.entity.EntityList.createEntityFromNBT(fixed.copy(),world); + RuntimeChecks.require(entity instanceof EntityLootableBody,"corpse factory returned no body"); + NBTTagCompound saved=new NBTTagCompound();((EntityLootableBody)entity).writeEntityToNBT(saved);return saved; + } + private static Map inventory(NBTTagCompound body) { + Map counts=new TreeMap<>();for(String key:Arrays.asList("Equipment","Aux")) { + NBTTagList list=body.getTagList(key,10);for(int i=0;i0,"malformed original fields were lost in vanilla fixing"); + } + private static void missingItem() throws Exception { + NBTTagCompound body=body("lootablebodies.Corpse"),raw=body.getTagList("Equipment",10).getCompoundTagAt(20);raw.setString("id","absentfixture:keepsake"); + NBTTagCompound result=saved(process(chunk(body,new NBTTagInt(512))));NBTTagList recovery=result.getTagList("LootableBodiesUnresolved",10); + RuntimeChecks.require(recovery.tagCount()==1,"unavailable item not retained exactly once");NBTTagCompound record=recovery.getCompoundTagAt(0); + RuntimeChecks.require(NameLifetimeChecks.same(raw,record.getTag("Data")) && record.getCompoundTag("SourceVersions").getInteger("DataVersion")==512,"unavailable item lost its original payload or source context"); + } + private static void repeated() throws Exception { + NBTTagCompound raw=body("lootablebodies.Corpse"),fixed=process(chunk(raw,new NBTTagInt(512))),once=saved(fixed); + NBTTagCompound again=FMLCommonHandler.instance().getDataFixer().process(FixTypes.CHUNK,chunk(fixed.copy(),new NBTTagInt(512))).getCompoundTag("Level").getTagList("Entities",10).getCompoundTagAt(0); + NBTTagCompound twice=saved(again);RuntimeChecks.require(inventory(once).equals(inventory(twice)),"repeated data fixing duplicated or lost items"); + } + private static void recovery() throws Exception { + NBTTagCompound body=body("lootablebodies.Corpse"),raw=body.getTagList("Equipment",10).getCompoundTagAt(20).copy();raw.setString("id","absentfixture:keepsake"); + NBTTagCompound record=new NBTTagCompound();record.setString("Source","Aux");record.setTag("Data",raw.copy());NBTTagList records=new NBTTagList();records.appendTag(record);body.setTag("LootableBodiesUnresolved",records); + NBTTagCompound output=saved(process(chunk(body,new NBTTagInt(512))));NBTTagCompound kept=output.getTagList("LootableBodiesUnresolved",10).getCompoundTagAt(0); + RuntimeChecks.require(kept.getString("Source").equals("Aux") && NameLifetimeChecks.same(raw,kept.getTag("Data")),"existing Source/Data payload changed"); + RuntimeChecks.require(kept.getCompoundTag("SourceVersions").getInteger("DataVersion")==512,"existing recovery extension lost source version context"); + } + private static NBTTagCompound fullSave(NBTTagCompound fixed) { + Entity entity=net.minecraft.entity.EntityList.createEntityFromNBT(fixed.copy(),world);RuntimeChecks.require(entity instanceof EntityLootableBody,"Missing body for full save"); + NBTTagCompound result=new NBTTagCompound();entity.writeToNBTOptional(result);return result; + } + private static void savedCounter() throws Exception { + NBTTagCompound source=body("lootablebodies.Corpse");source.getTagList("Equipment",10).getCompoundTagAt(20).getCompoundTag("tag").setInteger("upgrade_counter",0); + NBTTagCompound once=fullSave(process(chunk(source,new NBTTagInt(512)))); + RuntimeChecks.require(once.getTagList("Equipment",10).getCompoundTagAt(20).getCompoundTag("tag").getInteger("upgrade_counter")==1,"Item fixer did not run exactly once on first load"); + NBTTagCompound twice=fullSave(process(chunk(once,new NBTTagInt(922))));RuntimeChecks.require(inventory(once).equals(inventory(twice)),"Old item version was applied again after saving migrated items"); + } + private static NBTTagCompound currentStamps(NBTTagCompound chunk) {FMLCommonHandler.instance().getDataFixer().writeVersionData(chunk);chunk.getCompoundTag("ForgeDataVersion").setInteger("minecraft",-1);return chunk;} + private static void migratedDamagedStamp() throws Exception { + NBTTagCompound source=body("lootablebodies:corpse");source.setInteger("LootableBodiesDataVersion",1);source.getTagList("Equipment",10).getCompoundTagAt(40).getCompoundTag("tag").getCompoundTag("EntityTag").setString("id","minecraft:pig"); + NBTTagCompound result=saved(process(currentStamps(chunk(source,null))));RuntimeChecks.require(inventory(source).equals(inventory(result)),"Damaged version stamp truncated an already migrated Equipment list"); + } + private static void forgedModStamp() throws Exception { + NBTTagCompound source=body("lootablebodies.Corpse"),expected=source.copy();expected.getTagList("Equipment",10).getCompoundTagAt(40).getCompoundTag("tag").getCompoundTag("EntityTag").setString("id","minecraft:pig"); + NBTTagCompound input=currentStamps(chunk(source,new NBTTagInt(512)));input.getCompoundTag("ForgeDataVersion").setInteger("minecraft",512); + RuntimeChecks.require(inventory(expected).equals(inventory(saved(process(input)))),"Incorrect mod stamp bypassed custom inventory preservation"); + } + private static void reusedOutput() throws Exception { + NBTTagCompound fixed=process(chunk(body("lootablebodies.Corpse"),new NBTTagInt(512))); + EntityLootableBody entity=(EntityLootableBody)net.minecraft.entity.EntityList.createEntityFromNBT(fixed.copy(),world);entity.clear();entity.writeEntityToNBT(fixed); + EntityLootableBody reloaded=(EntityLootableBody)net.minecraft.entity.EntityList.createEntityFromNBT(fixed.copy(),world); + RuntimeChecks.require(reloaded.isEmpty(),"Reused output compound resurrected a consumed upgrade payload"); + } + private static void allCurrentStamps() throws Exception { + NBTTagCompound input=chunk(body("lootablebodies.Corpse"),new NBTTagInt(922)),versions=new NBTTagCompound(); + versions.setInteger("minecraft",922);versions.setInteger("lootablebodies",1);versions.setInteger("lootablebodiesbuildtest",1);input.setTag("ForgeDataVersion",versions); + saved(process(input)); + } + private static void vanillaFalseStamp() throws Exception { + NBTTagCompound input=chunk(body("lootablebodies.Corpse"),new NBTTagInt(922)),versions=new NBTTagCompound(); + versions.setInteger("minecraft",922);versions.setInteger("lootablebodies",1);versions.setInteger("lootablebodiesbuildtest",1);input.setTag("ForgeDataVersion",versions); + NBTTagCompound chest=new NBTTagCompound();chest.setString("id","Chest");chest.setInteger("x",2);chest.setInteger("y",80);chest.setInteger("z",2); + NBTTagCompound banner=new NBTTagCompound();banner.setString("id","minecraft:banner");banner.setByte("Count",(byte)2);banner.setShort("Damage",(short)0);banner.setByte("Slot",(byte)0); + NBTTagCompound tag=new NBTTagCompound(),block=new NBTTagCompound();block.setInteger("Base",5);tag.setTag("BlockEntityTag",block);banner.setTag("tag",tag);NBTTagList items=new NBTTagList();items.appendTag(banner);chest.setTag("Items",items);NBTTagList tiles=new NBTTagList();tiles.appendTag(chest);input.getCompoundTag("Level").setTag("TileEntities",tiles); + NBTTagCompound fixed=processChunk(input).getCompoundTag("Level").getTagList("TileEntities",10).getCompoundTagAt(0).getTagList("Items",10).getCompoundTagAt(0); + RuntimeChecks.require(fixed.getShort("Damage")==5 && fixed.getByte("Count")==2 && !fixed.getCompoundTag("tag").getCompoundTag("BlockEntityTag").hasKey("Base"),"False version stamps skipped the vanilla banner conversion"); + } +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/VisibilityChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/VisibilityChecks.java new file mode 100644 index 0000000..6632e15 --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/VisibilityChecks.java @@ -0,0 +1,158 @@ +package cyano.lootable.buildtest; + +import cyano.lootable.LootableBodies; +import cyano.lootable.entities.EntityLootableBody; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.*; +import net.minecraft.entity.*; +import net.minecraft.entity.item.EntityTNTPrimed; +import net.minecraft.entity.player.EntityPlayerMP; +import net.minecraft.init.Blocks; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.*; +import net.minecraft.server.MinecraftServer; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.*; +import net.minecraftforge.fml.common.FMLCommonHandler; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; +import net.minecraftforge.fml.common.gameevent.TickEvent; + +/** Default-config real deaths and tracking, coordinated with two independent TCP clients. */ +public final class VisibilityChecks { + private final MinecraftServer server; + private int phase,ticks,phaseTicks; + private long waitStart; + private EntityLootableBody first,second; + private Set before; + private boolean finished; + private final boolean resumed=Boolean.getBoolean("lootablebodies.visibility.reload"); + public VisibilityChecks(MinecraftServer server) throws Exception { + this.server=server; + RuntimeChecks.require(!LootableBodies.useLocalSkin && LootableBodies.allowCorpseDecay && LootableBodies.decayOnlyWhenEmpty + && LootableBodies.ticksPerItemDecay==6000 && LootableBodies.corpseDecayTime==72000 + && !LootableBodies.hurtByEnvironment && !LootableBodies.hurtByAttacks && !LootableBodies.hurtByMisc,"Visibility defaults changed"); + phase(0);Files.write(file("server.ready").toPath(),new byte[]{1});FMLCommonHandler.instance().bus().register(this); + } + static File file(String name){return new File(System.getProperty("lootablebodies.multiplayer.sync"),name);} + private EntityPlayerMP player(String role){return server.getPlayerList().getPlayerByUsername("Container"+role);} + private boolean ack(String role,int n){return file(role+".visibility-"+n).isFile();} + private boolean both(int n){return ack("A",n)&&ack("B",n);} + private WorldServer world(){return server.getWorld(0);} + @SubscribeEvent public void tick(TickEvent.ServerTickEvent e) { + if(e.phase!=TickEvent.Phase.END || finished)return; + try { + RuntimeChecks.require(++ticks<14000,"Visibility server timeout phase="+phase);phaseTicks++; + RuntimeChecks.require(!file("abort").exists(),"Visibility runner aborted"); + EntityPlayerMP a=player("A"),b=player("B"); + if(phaseTicks%100==0 && phase!=12)System.out.println("VISIBILITY_SERVER_STATE phase="+phase+" A="+(a==null?null:a.getPosition())+" B="+(b==null?null:b.getPosition())+" corpse="+(second==null?null:second.getPosition())+" usableA="+(second!=null&&second.isUsableByPlayer(a))+" usableB="+(second!=null&&second.isUsableByPlayer(b))); + if(phase==0 && resumed && a!=null && b!=null && both(0)) { + // Only this disposable tracking fixture moves the world spawn. + // The corpse chunk must be eligible to unload when both clients + // leave; keeping it inside the spawn area would test no unload. + world().setSpawnPoint(new BlockPos(8192,80,8192)); + NBTTagCompound n;try(FileInputStream in=new FileInputStream(new File(world().getSaveHandler().getWorldDirectory(),"visibility-state.nbt"))){n=CompressedStreamTools.readCompressed(in);} + first=loadExpected(world(),"first");second=loadExpected(world(),"second"); + saveId("first",first);saveId("second",second);verifyInventories(); + a.connection.setPlayerLocation(first.posX,first.posY,first.posZ-2.5,0,25);b.connection.setPlayerLocation(first.posX+1.5,first.posY,first.posZ-2.5,0,25);phase(19); + } else if(phase==0 && a!=null && b!=null && both(0)) { + WorldServer w=world();w.getGameRules().setOrCreateGameRule("doMobSpawning","false");w.getGameRules().setOrCreateGameRule("keepInventory","false"); + w.setWorldTime(6000); + for(int x=88;x<=170;x++)for(int z=89;z<=103;z++)w.setBlockState(new BlockPos(x,79,z),Blocks.OBSIDIAN.getDefaultState()); + for(EntityPlayerMP p:new EntityPlayerMP[]{a,b}) {p.setGameType(GameType.SURVIVAL);p.inventory.clear();p.setSpawnPoint(new BlockPos(96,80,93),true);} + a.connection.setPlayerLocation(96.5,80,96.5,0,0);b.connection.setPlayerLocation(98.5,80,93.5,0,25); + a.inventory.mainInventory.set(9,RuntimeChecks.stack(410));a.inventoryContainer.detectAndSendChanges();phase(1); + } else if(phase==1 && both(1)) { + before=new HashSet<>(world().loadedEntityList);a.onKillCommand();first=newCorpse();saveId("first",first);phase(2); + } else if(phase==2 && both(2)) {mark("both_clients_receive_actual_death_before_respawn");phase(3); + } else if(phase==3 && both(3) && a!=null && a.isEntityAlive()) { + RuntimeChecks.require(first.isEntityAlive(),"Server lost corpse after respawn"); + a.connection.setPlayerLocation(96.5,80,93.5,0,25);phase(4); + } else if(phase==4 && both(4)) { + RuntimeChecks.require(a.openContainer instanceof cyano.lootable.graphics.CorpseContainer && b.openContainer instanceof cyano.lootable.graphics.CorpseContainer,"GUI ack without server access"); + mark("both_clients_render_and_open_corpse_after_respawn");phase(5); + } else if(phase==5 && both(5)) { + a.closeScreen();b.closeScreen();a.connection.setPlayerLocation(160.5,80,93.5,0,25);phase(6); + } else if(phase==6 && both(6)) { + RuntimeChecks.require(world().getEntityTracker().getTrackingPlayers(first).contains(b) && !world().getEntityTracker().getTrackingPlayers(first).contains(a),"Range assertions differ from tracker"); + mark("range_exit_removes_only_distant_client");a.connection.setPlayerLocation(96.5,80,93.5,0,25);phase(7); + } else if(phase==7 && both(7)) {mark("range_return_respawns_same_uuid");phase(8); + } else if(phase==8 && a==null) {phase(9); + } else if(phase==9 && a!=null && both(9)) { + mark("reconnect_owner_with_observer_still_online");phase(10); + } else if(phase==10 && b==null) {phase(11); + } else if(phase==11 && b!=null && both(11)) { + mark("reconnect_observer_with_owner_still_online");waitStart=System.nanoTime();phase(12); + } else if(phase==12 && phaseTicks>Integer.getInteger("lootablebodies.visibility.waitTicks",6200) && (System.nanoTime()-waitStart)>Long.getLong("lootablebodies.visibility.waitMillis",310000L)*1000000L && both(12)) { + RuntimeChecks.require(first.isEntityAlive() && has(first,410),"Default corpse lost before five-minute TNT test"); + phase(120); + } else if(phase==120 && both(120)) { + mark(Integer.getInteger("lootablebodies.visibility.waitTicks",6200)==6200?"default_corpse_visible_after_6200_ticks_and_310_seconds":"short_fixture_control_default_corpse_visible"); + a.connection.setPlayerLocation(100.5,80,96.5,0,0);b.connection.setPlayerLocation(102.5,80,93.5,0,25); + a.inventory.mainInventory.set(9,RuntimeChecks.stack(411));a.inventoryContainer.detectAndSendChanges();phase(13); + } else if(phase==13 && both(13)) { + before=new HashSet<>(world().loadedEntityList);EntityTNTPrimed tnt=new EntityTNTPrimed(world(),a.posX,a.posY,a.posZ,null);tnt.setFuse(0); + RuntimeChecks.require(world().spawnEntity(tnt),"TNT fixture spawn");phase(14); + } else if(phase==14) { + if(second==null && a!=null && !a.isEntityAlive()) {second=newCorpse();saveId("second",second);} + if(second!=null && both(14)) {RuntimeChecks.require(second.isEntityAlive() && has(second,411),"TNT destroyed default corpse/inventory");mark("tnt_death_after_five_minutes_both_clients_receive_corpse");phase(15);} + } else if(phase==15 && both(15) && a!=null && a.isEntityAlive()) { + // TNT can displace both the observer and the death position. + a.connection.setPlayerLocation(second.posX,second.posY,second.posZ-2.5,0,25); + b.connection.setPlayerLocation(second.posX+1.5,second.posY,second.posZ-2.5,0,25);phase(16); + } else if(phase==16 && both(16)) {mark("both_clients_render_and_open_tnt_corpse_after_respawn");phase(17); + } else if(phase==17 && both(17) && phaseTicks>20) { + RuntimeChecks.require(!has(second,411) && playerHas(a,411),"TNT corpse recovery lost or duplicated tagged stack"); + RuntimeChecks.require(has(first,410),"Earlier corpse inventory changed during TNT/reconnect checks"); + a.closeScreen();b.closeScreen();File folder=world().getSaveHandler().getWorldDirectory(); + RuntimeChecks.saveExpected(folder,"visibility-first",first);RuntimeChecks.saveExpected(folder,"visibility-second",second); + NBTTagCompound state=new NBTTagCompound();state.setUniqueId("First",first.getUniqueID());state.setUniqueId("Second",second.getUniqueID());state.setUniqueId("Player",a.getUniqueID());state.setTag("Inventory",a.inventory.writeToNBT(new NBTTagList())); + try(FileOutputStream out=new FileOutputStream(new File(folder,"visibility-state.nbt"))){CompressedStreamTools.writeCompressed(state,out);} + server.getPlayerList().saveAllPlayerData();server.saveAllWorlds(false);mark("tagged_inventory_recovered_and_complete_nbt_saved");phase(18); + } else if(phase==18 && server.getPlayerList().getCurrentPlayerCount()==0) { + Files.write(file("visibility.pass").toPath(),new byte[]{1});System.out.println("LOOTABLE_BODIES_VISIBILITY_PASS");finished=true;server.initiateShutdown(); + } else if(phase==19 && both(19)) { + mark("cold_server_restart_both_clients_render_and_open_saved_corpse");a.closeScreen();b.closeScreen(); + for(int x=397;x<=404;x++)for(int z=397;z<=404;z++)world().setBlockState(new BlockPos(x,79,z),Blocks.OBSIDIAN.getDefaultState()); + a.connection.setPlayerLocation(400.5,80,400.5,0,25);b.connection.setPlayerLocation(402.5,80,400.5,0,25);phase(20); + } else if(phase==20 && both(20) && phaseTicks>240 && !world().getChunkProvider().chunkExists(first.chunkCoordX,first.chunkCoordZ) && !world().getChunkProvider().chunkExists(second.chunkCoordX,second.chunkCoordZ)) { + mark("both_clients_leave_and_corpse_chunk_really_unloads"); + net.minecraft.world.storage.ThreadedFileIOBase.getThreadedIOInstance().waitForFinish(); + a.connection.setPlayerLocation(first.posX,first.posY,first.posZ-2.5,0,25);b.connection.setPlayerLocation(first.posX+1.5,first.posY,first.posZ-2.5,0,25);phase(21); + } else if(phase==21 && both(21)) { + first=RuntimeChecks.find(world(),first.getUniqueID());second=RuntimeChecks.find(world(),second.getUniqueID());verifyInventories(); + mark("chunk_reentry_both_clients_render_same_uuids_and_inventory");server.getPlayerList().saveAllPlayerData();server.saveAllWorlds(false);phase(18); + } + } catch(Throwable failure) {failure.printStackTrace();finished=true;server.initiateShutdown();} + } + private EntityLootableBody newCorpse(){for(Entity e:world().loadedEntityList)if(e instanceof EntityLootableBody&&!before.contains(e))return (EntityLootableBody)e;throw new AssertionError("No server corpse for real death");} + private void saveId(String name,EntityLootableBody b) throws Exception {Files.write(file(name+".uuid").toPath(),b.getUniqueID().toString().getBytes(StandardCharsets.UTF_8));} + private void phase(int n) throws Exception {phase=n;phaseTicks=0;File tmp=file("phase.tmp");Files.write(tmp.toPath(),Integer.toString(n).getBytes(StandardCharsets.UTF_8));Files.move(tmp.toPath(),file("phase").toPath(),StandardCopyOption.REPLACE_EXISTING,StandardCopyOption.ATOMIC_MOVE);System.out.println("VISIBILITY_PHASE "+n);} + private void mark(String n) throws Exception {try(FileWriter out=new FileWriter(file("checks.txt"),true)){out.write("PASS "+n+"\n");}System.out.println("VISIBILITY_CHECK_PASS "+n);} + static boolean has(EntityLootableBody b,int i){for(int s=0;s<48;s++)if(RuntimeChecks.sameStack(b.getStackInSlot(s),RuntimeChecks.stack(i)))return true;return false;} + private static boolean playerHas(EntityPlayerMP p,int i){int count=0;for(int s=0;s=expected.getLong(key)&&actual.getLong(key)<=w.getTotalWorldTime(),"Reloaded live timer out of range: "+key); + } +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/VisibilityClientChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/VisibilityClientChecks.java new file mode 100644 index 0000000..aee1b2a --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/VisibilityClientChecks.java @@ -0,0 +1,106 @@ +package cyano.lootable.buildtest; + +import cyano.lootable.entities.EntityLootableBody; +import cyano.lootable.graphics.CorpseGUIContainer; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.*; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.*; +import net.minecraft.client.multiplayer.GuiConnecting; +import net.minecraft.entity.Entity; +import net.minecraft.inventory.ClickType; +import net.minecraft.item.ItemStack; +import net.minecraft.util.*; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.client.event.RenderLivingEvent; +import net.minecraftforge.fml.common.FMLCommonHandler; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; +import net.minecraftforge.fml.common.gameevent.TickEvent; + +/** Receives real Forge spawn/destroy/respawn packets; never takes native input focus. */ +final class VisibilityClientChecks { + private final String role=System.getProperty("lootablebodies.multiplayer.role"); + private int phase=-1,frames,ticks,guiFrames; + private boolean connecting,respawnSent,finished; + private final Set done=new HashSet<>(); + private final Set rendered=new HashSet<>(); + void register(){if(Boolean.getBoolean("lootablebodies.visibility")&&role!=null){FMLCommonHandler.instance().bus().register(this);MinecraftForge.EVENT_BUS.register(this);}} + @SubscribeEvent public void mouse(net.minecraftforge.client.event.GuiScreenEvent.MouseInputEvent.Pre e){e.setCanceled(true);} + @SubscribeEvent public void key(net.minecraftforge.client.event.GuiScreenEvent.KeyboardInputEvent.Pre e){e.setCanceled(true);} + @SubscribeEvent public void rendered(RenderLivingEvent.Post e){if(e.getEntity() instanceof EntityLootableBody)rendered.add(e.getEntity().getUniqueID());} + @SubscribeEvent public void tick(TickEvent.ClientTickEvent e) { + if(e.phase!=TickEvent.Phase.END)return;Minecraft mc=Minecraft.getMinecraft(); + if(mc.inGameHasFocus)mc.setIngameNotInFocus();net.minecraft.client.settings.KeyBinding.unPressAllKeys(); + try { + if(finished){mc.shutdown();return;} + RuntimeChecks.require(++ticks<15000,"Visibility client timeout role="+role+" phase="+phase); + RuntimeChecks.require(!VisibilityChecks.file("abort").exists(),"Visibility runner aborted"); + int n=Integer.parseInt(new String(Files.readAllBytes(VisibilityChecks.file("phase").toPath()),StandardCharsets.UTF_8).trim()); + if(n!=phase){phase=n;frames=0;guiFrames=0;respawnSent=false;rendered.clear();System.out.println("VISIBILITY_CLIENT_PHASE "+role+" "+phase);}frames++; + if(!connecting && mc.world==null && mc.currentScreen instanceof GuiMainMenu && (phase==0 || phase==9 || phase==11)) { + connecting=true;mc.gameSettings.pauseOnLostFocus=false;mc.gameSettings.limitFramerate=30;mc.gameSettings.renderDistanceChunks=4; + mc.displayGuiScreen(new GuiConnecting(new GuiMainMenu(),mc,"127.0.0.1",Integer.parseInt(System.getProperty("lootablebodies.multiplayer.port")))); + } + if(mc.world==null || mc.player==null)return; + if(frames%200==0)System.out.println("VISIBILITY_CLIENT_STATE "+role+" phase="+phase+" position="+mc.player.getPosition()+" rendered="+rendered+" screen="+(mc.currentScreen==null?null:mc.currentScreen.getClass().getSimpleName())+" container="+mc.player.openContainer.getClass().getSimpleName()); + if(phase==12 && frames%200==0) { + EntityLootableBody b=body(mc,"first");RuntimeChecks.require(b!=null&&!b.isDead&&!b.isInvisible(),"Corpse disappeared during five-minute wait"); + } + if(done.contains(phase) || frames<20)return; + if(phase==0 || phase==1 || phase==13){ack();return;} + if(phase==2 || phase==14) { + EntityLootableBody b=body(mc,phase==2?"first":"second");if(b==null)return; + if(role.equals("A") && !(mc.currentScreen instanceof GuiGameOver))return; + RuntimeChecks.require(!b.isDead&&!b.isInvisible(),"Received corpse is dead/invisible");shot(mc,"death-"+phase+".png");ack(); + } else if(phase==3 || phase==15) { + if(role.equals("A")) { + if(mc.currentScreen instanceof GuiGameOver){if(!respawnSent){mc.player.respawnPlayer();respawnSent=true;}return;} + if(!mc.player.isEntityAlive())return; + } + if(body(mc,phase==3?"first":"second")!=null)ack(); + } else if(phase==4 || phase==16 || phase==19) { + EntityLootableBody b=body(mc,phase==16?"second":"first");if(b==null)return; + if(!(mc.currentScreen instanceof CorpseGUIContainer)) { + if(!rendered.contains(b.getUniqueID()))return; + mc.playerController.interactWithEntity(mc.player,b,EnumHand.MAIN_HAND);return; + } + int index=phase==16?411:410;boolean found=false; + for(int slot=0;slot<48;slot++)if(RuntimeChecks.sameStack(mc.player.openContainer.getSlot(slot).getStack(),RuntimeChecks.stack(index)))found=true; + if(!found || ++guiFrames<10)return;shot(mc,"corpse-gui-"+phase+".png");ack(); + } else if(phase==5) {mc.player.closeScreen();ack(); + } else if(phase==6) { + boolean present=body(mc,"first")!=null;if(present==role.equals("B"))ack(); + } else if(phase==7 || phase==9 || phase==11) { + EntityLootableBody b=body(mc,"first");if(b!=null&&!b.isDead&&!b.isInvisible()&&rendered.contains(b.getUniqueID())){shot(mc,"tracked-"+phase+".png");ack();} + } else if(phase==8 || phase==10) { + if(role.equals(phase==8?"A":"B")){mc.world.sendQuittingDisconnectingPacket();mc.loadWorld(null);mc.displayGuiScreen(new GuiMainMenu());connecting=false;} + ack(); + } else if(phase==12 || phase==120) { + EntityLootableBody b=body(mc,"first");if(b!=null&&!b.isDead&&rendered.contains(b.getUniqueID()))ack(); + } else if(phase==17) { + if(role.equals("A"))for(int slot=0;slot<48;slot++)if(RuntimeChecks.sameStack(mc.player.openContainer.getSlot(slot).getStack(),RuntimeChecks.stack(411)))mc.playerController.windowClick(mc.player.openContainer.windowId,slot,0,ClickType.QUICK_MOVE,mc.player); + // The following acknowledgement is not inventory proof; the server checks the resulting packet state. + ack(); + } else if(phase==18) { + new File(mc.gameDir,"visibility-client.pass").createNewFile();System.out.println("LOOTABLE_BODIES_VISIBILITY_CLIENT_PASS role="+role); + mc.player.closeScreen(); + mc.getConnection().getNetworkManager().closeChannel(new net.minecraft.util.text.TextComponentString("Disposable check complete")); + finished=true; + } else if(phase==20) { + if(body(mc,"first")==null&&body(mc,"second")==null)ack(); + } else if(phase==21) { + mc.player.rotationYaw=frames*3;mc.player.rotationPitch=25; + EntityLootableBody a=body(mc,"first"),b=body(mc,"second"); + if(a!=null&&b!=null&&!a.isDead&&!b.isDead&&rendered.contains(a.getUniqueID())&&rendered.contains(b.getUniqueID())) {shot(mc,"chunk-reentry.png");ack();} + } + } catch(Throwable error){error.printStackTrace();finished=true;mc.shutdown();} + } + private EntityLootableBody body(Minecraft mc,String name) throws Exception { + File file=VisibilityChecks.file(name+".uuid");if(!file.isFile())return null;UUID id=UUID.fromString(new String(Files.readAllBytes(file.toPath()),StandardCharsets.UTF_8)); + for(Entity e:mc.world.loadedEntityList)if(e instanceof EntityLootableBody&&e.getUniqueID().equals(id))return (EntityLootableBody)e;return null; + } + private void ack() throws Exception {done.add(phase);Files.write(VisibilityChecks.file(role+".visibility-"+phase).toPath(),new byte[]{1});} + private static void shot(Minecraft mc,String name){ScreenShotHelper.saveScreenshot(mc.gameDir,name,mc.displayWidth,mc.displayHeight,mc.getFramebuffer());} +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/VisibilitySkinChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/VisibilitySkinChecks.java new file mode 100644 index 0000000..d55ee2c --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/VisibilitySkinChecks.java @@ -0,0 +1,81 @@ +package cyano.lootable.buildtest; + +import com.mojang.authlib.GameProfile; +import com.mojang.authlib.minecraft.*; +import cyano.lootable.LootableBodies; +import cyano.lootable.entities.EntityLootableBody; +import cyano.lootable.graphics.CorpseRenderer; +import java.io.*; +import java.lang.reflect.*; +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import net.minecraft.client.Minecraft; +import net.minecraft.client.resources.*; +import net.minecraft.server.management.PlayerProfileCache; +import net.minecraft.tileentity.TileEntitySkull; +import net.minecraft.util.ResourceLocation; +import net.minecraftforge.fml.relauncher.ReflectionHelper; + +/** Deterministic service faults; no real account or authentication service required. */ +final class VisibilitySkinChecks { + static void run(Minecraft mc) throws Exception { + RuntimeChecks.require(!LootableBodies.useLocalSkin,"Visibility checks require the production texture default"); + File report=new File(mc.gameDir,"skin-checks.txt"); + List failures=new ArrayList<>(); + Field skinField=field(Minecraft.class,SkinManager.class); + SkinManager original=(SkinManager)skinField.get(mc); + GameProfile profile=new GameProfile(UUID.fromString("00000000-0000-4000-8000-000000000201"),"VisibilityFault"); + try { + skinField.set(mc,new SkinManager(mc.getTextureManager(),new File(mc.gameDir,"skin-fixture"),mc.getSessionService()) { + @Override public Map loadSkinFromCache(GameProfile p) { + throw new IllegalStateException("injected skin cache failure"); + } + }); + try { + ResourceLocation texture=CorpseRenderer.getSkin(profile); + RuntimeChecks.require(DefaultPlayerSkin.getDefaultSkin(profile.getId()).equals(texture),"Failed skin lookup did not use vanilla fallback"); + mark(report,"skin_cache_failure_has_visible_default",true); + } catch(Throwable failure) {failures.add("skin cache: "+failure);mark(report,"skin_cache_failure_has_visible_default",false);} + } finally {skinField.set(mc,original);} + Field cache=field(TileEntitySkull.class,PlayerProfileCache.class); + Field service=field(TileEntitySkull.class,MinecraftSessionService.class); + Object oldCache=cache.get(null),oldService=service.get(null); + AtomicInteger requests=new AtomicInteger(); + CountDownLatch failedRequest=new CountDownLatch(1); + try { + TileEntitySkull.setProfileCache(new PlayerProfileCache(null,new File(mc.gameDir,"fault-usercache.json")) { + @Override public GameProfile getGameProfileForUsername(String name) {return new GameProfile(profile.getId(),name);} + }); + TileEntitySkull.setSessionService((MinecraftSessionService)Proxy.newProxyInstance(MinecraftSessionService.class.getClassLoader(),new Class[]{MinecraftSessionService.class},(proxy,method,args)->{ + if(method.getName().equals("fillProfileProperties")) {requests.incrementAndGet();failedRequest.countDown();throw new IllegalStateException("injected profile lookup failure");} + throw new UnsupportedOperationException(method.getName()); + })); + EntityLootableBody body=new EntityLootableBody(mc.world);body.setUserName("VisibilityFault"); + try {body.onEntityUpdate();RuntimeChecks.require(body.getGameProfile()!=null,"Failure erased fallback profile");mark(report,"profile_failure_does_not_abort_client_tick",true);} + catch(Throwable failure) {failures.add("profile: "+failure);mark(report,"profile_failure_does_not_abort_client_tick",false);} + RuntimeChecks.require(failedRequest.await(2,TimeUnit.SECONDS),"Profile failure was never injected"); + // Deliberately slow lookup must leave the render/tick thread responsive. + TileEntitySkull.setSessionService((MinecraftSessionService)Proxy.newProxyInstance(MinecraftSessionService.class.getClassLoader(),new Class[]{MinecraftSessionService.class},(proxy,method,args)->{ + if(method.getName().equals("fillProfileProperties")) {requests.incrementAndGet();Thread.sleep(400);return args[0];} + throw new UnsupportedOperationException(method.getName()); + })); + EntityLootableBody slow=new EntityLootableBody(mc.world);slow.setUserName("VisibilitySlow"); + long start=System.nanoTime();slow.onEntityUpdate();long millis=(System.nanoTime()-start)/1000000; + try {RuntimeChecks.require(millis<150,"Slow profile blocked client tick for "+millis+"ms");mark(report,"delayed_profile_does_not_block_client_tick",true);} + catch(Throwable failure) {failures.add("delay: "+failure);mark(report,"delayed_profile_does_not_block_client_tick",false);} + boolean local=LootableBodies.useLocalSkin;LootableBodies.useLocalSkin=true; + slow.setUserName("VisibilityRenamed");slow.onEntityUpdate();LootableBodies.useLocalSkin=local; + Thread.sleep(650); // Only the fault fixture waits; never production code. + RuntimeChecks.require(requests.get()==2,"Faults never exercised actual profile services"); + RuntimeChecks.require(slow.getGameProfile().getName().equals("VisibilityRenamed"),"Late skin result overwrote renamed corpse"); + mark(report,"late_profile_result_preserves_new_name",true); + } finally {cache.set(null,oldCache);service.set(null,oldService);} + if(!failures.isEmpty())throw new AssertionError("VISIBILITY_SKIN_FAILURES "+failures); + } + private static void mark(File report,String name,boolean passed) throws Exception { + try(FileWriter w=new FileWriter(report,true)){w.write((passed?"PASS ":"FAIL ")+name+"\n");} + } + private static Field field(Class owner,Class type) {for(Field f:owner.getDeclaredFields())if(f.getType()==type){f.setAccessible(true);return f;}throw new AssertionError(type);} +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/VisibilitySkinClientChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/VisibilitySkinClientChecks.java new file mode 100644 index 0000000..52befda --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/VisibilitySkinClientChecks.java @@ -0,0 +1,53 @@ +package cyano.lootable.buildtest; + +import java.io.File; +import java.lang.reflect.Field; +import java.util.*; +import com.mojang.authlib.GameProfile; +import com.mojang.authlib.minecraft.MinecraftProfileTexture; +import cyano.lootable.LootableBodies; +import cyano.lootable.entities.EntityLootableBody; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.*; +import net.minecraft.client.gui.inventory.GuiInventory; +import net.minecraft.client.resources.SkinManager; +import net.minecraft.util.ScreenShotHelper; +import net.minecraft.world.*; + +final class VisibilitySkinClientChecks { + private int stage,frames; + private Field skinField; + private SkinManager original; + void tick(Minecraft mc) throws Exception { + if(stage==0 && mc.currentScreen instanceof GuiMainMenu) { + mc.gameSettings.pauseOnLostFocus=false;mc.gameSettings.limitFramerate=30;mc.gameSettings.renderDistanceChunks=4; + mc.launchIntegratedServer("skin-fixture","Skin fixture",new WorldSettings(4267321,GameType.SURVIVAL,false,false,WorldType.FLAT).enableCommands());stage=1; + } else if(stage==1 && mc.world!=null && mc.player!=null && ++frames>60) { + VisibilitySkinChecks.run(mc); + for(Field field:Minecraft.class.getDeclaredFields())if(field.getType()==SkinManager.class){skinField=field;skinField.setAccessible(true);} + original=(SkinManager)skinField.get(mc); + skinField.set(mc,new SkinManager(mc.getTextureManager(),new File(mc.gameDir,"skin-fixture"),mc.getSessionService()) { + @Override public Map loadSkinFromCache(GameProfile p){throw new IllegalStateException("injected render skin cache failure");} + }); + mc.displayGuiScreen(new Panel(mc));stage=2;frames=0; + } else if(stage==2 && ++frames>30) { + ScreenShotHelper.saveScreenshot(mc.gameDir,"skin-fallback-models.png",mc.displayWidth,mc.displayHeight,mc.getFramebuffer()); + skinField.set(mc,original);new File(mc.gameDir,"client.pass").createNewFile(); + System.out.println("LOOTABLE_BODIES_VISIBILITY_SKIN_PASS");mc.shutdown();stage=3; + } + } + private static final class Panel extends GuiScreen { + private final EntityLootableBody[] bodies=new EntityLootableBody[3]; + Panel(Minecraft mc){for(int i=0;i<3;i++){bodies[i]=new EntityLootableBody(mc.world);bodies[i].setGameProfile(new GameProfile(new UUID(0,512+i),"VisibilityModel"));}} + @Override public void drawScreen(int mouseX,int mouseY,float partial) { + drawRect(0,0,width,height,0xff304050); + String[] names={"Failed skin: wide arms","Failed skin: slim arms","Local texture"};boolean local=LootableBodies.useLocalSkin; + try {for(int i=0;i<3;i++) { + LootableBodies.useLocalSkin=i==2;int x=(2*i+1)*width/6; + drawCenteredString(fontRenderer,names[i],x,35,0xffffff); + GuiInventory.drawEntityOnScreen(x,height*2/3,65,20,-20,bodies[i]); + }} finally {LootableBodies.useLocalSkin=local;} + } + @Override public boolean doesGuiPauseGame(){return false;} + } +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/WallEscapeChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/WallEscapeChecks.java new file mode 100644 index 0000000..e51fb82 --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/WallEscapeChecks.java @@ -0,0 +1,245 @@ +package cyano.lootable.buildtest; + +import cyano.lootable.entities.EntityLootableBody; +import java.io.*; +import java.util.*; +import net.minecraft.block.BlockSnow; +import net.minecraft.entity.EntityList; +import net.minecraft.init.Blocks; +import net.minecraft.nbt.*; +import net.minecraft.server.MinecraftServer; +import net.minecraft.util.DamageSource; +import net.minecraft.util.EnumFacing; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.WorldServer; + +/** Real block collision and chunk-provider regressions; never shipped with the mod. */ +final class WallEscapeChecks { + private interface Check { void run() throws Exception; } + private static WorldServer world; + private static int sequence, failures; + private static final List results = new ArrayList<>(); + + static void run(MinecraftServer server, WorldServer target, File folder) throws Exception { + world = target; sequence = 0; failures = 0; results.clear(); + check("centred_body_never_uses_NaN_as_destination", WallEscapeChecks::centred); + for (EnumFacing direction : EnumFacing.values()) + check("only_clear_exit_" + direction, () -> oneExit(direction)); + check("off_centre_destination_checks_entire_body", WallEscapeChecks::overlap); + check("snow_layer_is_not_clear_air", WallEscapeChecks::snow); + check("fence_below_exit_is_not_clear_air", WallEscapeChecks::fence); + check("sealed_body_waits_without_random_drift", WallEscapeChecks::sealed); + check("already_clear_body_does_not_teleport", WallEscapeChecks::alreadyClear); + check("negative_coordinates_keep_finite_safe_exit", WallEscapeChecks::negative); + check("suffocation_damage_uses_escape_and_keeps_default_health", WallEscapeChecks::damage); + for (EnumFacing direction : EnumFacing.HORIZONTALS) + check("unloaded_exit_is_not_generated_" + direction, () -> unloaded(direction)); + check("ticking_sealed_body_waits_then_escapes_opened_wall", WallEscapeChecks::ticking); + check("escaped_and_sealed_bodies_saved_for_cold_reload", () -> seed(folder)); + try (PrintWriter out = new PrintWriter(new File(folder, "wall-escape-checks.txt"), "UTF-8")) { + for (String result : results) out.println(result); + out.println("checks=" + results.size() + " failures=" + failures); + } + RuntimeChecks.require(failures == 0, "Wall escape regressions: " + failures + " / " + results.size()); + System.out.println("LOOTABLE_BODIES_WALL_ESCAPE_PASS checks=" + results.size()); + } + + private static void check(String name, Check test) { + try { test.run(); results.add("PASS " + name); System.out.println("WALL_ESCAPE_PASS " + name); } + catch (Throwable error) { + failures++; results.add("FAIL " + name + ": " + error); + System.out.println("WALL_ESCAPE_FAIL " + name + ": " + error); error.printStackTrace(); + } + } + + private static BlockPos site(boolean negative) { + int x = (200 + sequence++ * 4) * 16 + 8; + return new BlockPos(negative ? -x : x, 80, 3208); + } + + private static void terrain(BlockPos centre, boolean solid) { + for (int x = -2; x <= 2; x++) for (int y = -2; y <= 3; y++) for (int z = -2; z <= 2; z++) + world.setBlockState(centre.add(x,y,z), (solid || y == -1 ? Blocks.STONE : Blocks.AIR).getDefaultState(), 2); + } + + private static EntityLootableBody body(BlockPos centre) { + EntityLootableBody body = new EntityLootableBody(world); + body.setPosition(centre.getX()+.5, centre.getY()+.015625, centre.getZ()+.5); + body.setNoGravity(true); body.setUserName("Wall Escape Fixture"); + for (int slot = 0; slot < 48; slot++) body.setInventorySlotContents(slot, RuntimeChecks.stack(slot)); + body.addItem(RuntimeChecks.stack(70)); body.addItem(RuntimeChecks.stack(71)); + RuntimeChecks.disableVacuum(body); + NBTTagCompound saved = NameLifetimeChecks.saved(body); + NBTTagCompound unresolved = new NBTTagCompound(); + unresolved.setString("Source", "FutureFixture"); unresolved.setString("Data", "retain verbatim"); + saved.setTag("LootableBodiesUnresolved", NbtRecoveryChecks.list(unresolved)); + NBTTagCompound invalid = new NBTTagCompound(); + invalid.setString("Source", "FutureMetadata"); invalid.setTag("Data", new NBTTagByteArray(new byte[]{4,3,2,1})); + saved.setTag("LootableBodiesInvalidFields", NbtRecoveryChecks.list(invalid)); + return (EntityLootableBody) EntityList.createEntityFromNBT(saved, world); + } + + private static void unchanged(NBTTagCompound before, EntityLootableBody body) { + NBTTagCompound after = NameLifetimeChecks.saved(body); + before = before.copy(); before.removeTag("Pos"); after.removeTag("Pos"); + RuntimeChecks.require(NameLifetimeChecks.same(before, after), "Escape changed saved fields besides position"); + PositionMotionChecks.finite(body); + } + + private static void clear(EntityLootableBody body) { + PositionMotionChecks.finite(body); + RuntimeChecks.require(world.getCollisionBoxes(body, body.getEntityBoundingBox()).isEmpty(), "Escape left body intersecting terrain"); + } + + private static void centred() { + BlockPos centre = site(false); terrain(centre, false); world.setBlockState(centre, Blocks.STONE.getDefaultState(), 2); + // A zero-vector lookup previously sampled (0,0,0). Test it as air, too. + net.minecraft.block.state.IBlockState origin = world.getBlockState(BlockPos.ORIGIN); + try { + world.setBlockToAir(BlockPos.ORIGIN); + EntityLootableBody body = body(centre); NBTTagCompound before = NameLifetimeChecks.saved(body); + body.jumpOutOfWall(); clear(body); unchanged(before, body); + RuntimeChecks.require(body.posY == centre.getY()+1.015625, "Centred body missed the existing upward exit priority"); + } finally { world.setBlockState(BlockPos.ORIGIN, origin, 2); } + } + + private static void oneExit(EnumFacing direction) { + BlockPos centre = site(false); terrain(centre, true); BlockPos exit = centre.offset(direction); world.setBlockToAir(exit); + EntityLootableBody body = body(centre); NBTTagCompound before = NameLifetimeChecks.saved(body); + body.jumpOutOfWall(); clear(body); unchanged(before, body); + RuntimeChecks.require(body.posX == exit.getX()+.5 && body.posY == exit.getY()+.015625 && body.posZ == exit.getZ()+.5, "Missed sole adjacent exit"); + } + + private static void overlap() { + BlockPos centre = site(false); terrain(centre, false); world.setBlockState(centre, Blocks.STONE.getDefaultState(), 2); + EntityLootableBody body = body(centre); body.setPosition(centre.getX()+.1, centre.getY()+.015625, centre.getZ()+.5); + NBTTagCompound before = NameLifetimeChecks.saved(body); body.jumpOutOfWall(); clear(body); unchanged(before, body); + } + + private static void snow() { + BlockPos centre = site(false); terrain(centre, true); world.setBlockToAir(centre.north()); + world.setBlockState(centre.up(), Blocks.SNOW_LAYER.getDefaultState().withProperty(BlockSnow.LAYERS, 8), 2); + EntityLootableBody body = body(centre); body.jumpOutOfWall(); clear(body); + RuntimeChecks.require(body.getPosition().equals(centre.north()), "Snow collision was ignored"); + } + + private static void fence() { + BlockPos centre = site(false); terrain(centre, true); world.setBlockToAir(centre.north()); world.setBlockToAir(centre.east()); + world.setBlockState(centre.north().down(), Blocks.OAK_FENCE.getDefaultState(), 2); + EntityLootableBody body = body(centre); body.jumpOutOfWall(); clear(body); + RuntimeChecks.require(body.getPosition().equals(centre.east()), "Tall collision below destination was ignored"); + } + + private static void sealed() { + BlockPos centre = site(false); terrain(centre, true); EntityLootableBody body = body(centre); + NBTTagCompound before = NameLifetimeChecks.saved(body); + for (int i = 0; i < 32; i++) body.jumpOutOfWall(); + RuntimeChecks.require(NameLifetimeChecks.same(before, NameLifetimeChecks.saved(body)), "Sealed corpse drifted or changed saved data"); + PositionMotionChecks.finite(body); + } + + private static void alreadyClear() { + BlockPos centre = site(false); terrain(centre, false); EntityLootableBody body = body(centre); + NBTTagCompound before = NameLifetimeChecks.saved(body); body.jumpOutOfWall(); + RuntimeChecks.require(NameLifetimeChecks.same(before, NameLifetimeChecks.saved(body)), "Clear corpse teleported on stale suffocation damage"); + } + + private static void negative() { + BlockPos centre = site(true); terrain(centre, true); world.setBlockToAir(centre.west()); + EntityLootableBody body = body(centre); NBTTagCompound before = NameLifetimeChecks.saved(body); + body.jumpOutOfWall(); clear(body); unchanged(before, body); + RuntimeChecks.require(body.getPosition().equals(centre.west()), "Negative coordinate exit was rounded incorrectly"); + } + + private static void damage() { + BlockPos centre = site(false); terrain(centre, true); world.setBlockToAir(centre.up()); + EntityLootableBody body = body(centre); float health = body.getHealth(); + body.attackEntityFrom(DamageSource.IN_WALL, 1); clear(body); + RuntimeChecks.require(body.isEntityAlive() && body.getHealth() == health, "Default suffocation health rules changed"); + } + + private static void unloaded(EnumFacing direction) throws Exception { + BlockPos centre = site(false); + int x = centre.getX() >> 4, z = centre.getZ() >> 4; + centre = new BlockPos(x*16+(direction == EnumFacing.WEST ? 0 : direction == EnumFacing.EAST ? 15 : 8), 80, + z*16+(direction == EnumFacing.NORTH ? 0 : direction == EnumFacing.SOUTH ? 15 : 8)); + terrain(centre, true); world.setBlockToAir(centre.offset(direction)); + int nx = x+direction.getXOffset(), nz = z+direction.getZOffset(); + world.getChunkProvider().queueUnload(world.getChunk(nx,nz)); + int attempts = world.getChunkProvider().getLoadedChunks().size()/100+2; + for (int i = 0; i < attempts && world.getChunkProvider().chunkExists(nx,nz); i++) world.getChunkProvider().tick(); + net.minecraft.world.storage.ThreadedFileIOBase.getThreadedIOInstance().waitForFinish(); + RuntimeChecks.require(!world.getChunkProvider().chunkExists(nx,nz), "Fixture neighbour did not unload"); + EntityLootableBody body = body(centre); NBTTagCompound before = NameLifetimeChecks.saved(body); + body.jumpOutOfWall(); + RuntimeChecks.require(!world.getChunkProvider().chunkExists(nx,nz), "Escape forced an unloaded neighbour to load"); + RuntimeChecks.require(NameLifetimeChecks.same(before, NameLifetimeChecks.saved(body)), "Escape moved without loaded terrain"); + world.getChunk(nx,nz); body.jumpOutOfWall(); clear(body); unchanged(before, body); + } + + private static void ticking() { + BlockPos centre = site(false); terrain(centre, true); EntityLootableBody body = body(centre); + NBTTagCompound before = NameLifetimeChecks.saved(body); + for (int i = 0; i < 100; i++) body.onUpdate(); + RuntimeChecks.require(body.posX == centre.getX()+.5 && body.posY == centre.getY()+.015625 && body.posZ == centre.getZ()+.5 && body.isEntityAlive(), "Ticking sealed corpse drifted or died"); + for (String key : new String[]{"Equipment","Aux","LootableBodiesUnresolved","LootableBodiesInvalidFields","DeathTime","DecayTime"}) + RuntimeChecks.require(NameLifetimeChecks.same(before.getTag(key),NameLifetimeChecks.saved(body).getTag(key)), "Ticking escape changed " + key); + world.setBlockToAir(centre.up()); + for (int i = 0; i < 30; i++) body.onUpdate(); + clear(body); RuntimeChecks.require(body.getPosition().equals(centre.up()), "Ticking corpse did not use newly opened exit"); + } + + private static void seed(File folder) throws Exception { + for (boolean trapped : new boolean[]{false,true}) { + BlockPos centre = site(false); terrain(centre, true); + if (!trapped) world.setBlockToAir(centre.up()); + EntityLootableBody body = body(centre); + body.setUniqueId(UUID.nameUUIDFromBytes(("wall-escape-"+trapped).getBytes(java.nio.charset.StandardCharsets.UTF_8))); + NBTTagCompound before = NameLifetimeChecks.saved(body); body.jumpOutOfWall(); unchanged(before,body); + if (!trapped) clear(body); + else RuntimeChecks.require(NameLifetimeChecks.same(before,NameLifetimeChecks.saved(body)), "Sealed disk fixture moved"); + RuntimeChecks.require(world.spawnEntity(body), "Could not save wall fixture"); + CompressedStreamTools.writeCompressed(NameLifetimeChecks.saved(body), new FileOutputStream(new File(folder,"wall-escape-"+trapped+".nbt"))); + } + } + + static void reload(WorldServer target, File folder) throws Exception { + world = target; + for (boolean trapped : new boolean[]{false,true}) { + NBTTagCompound expected; + try (InputStream in = new FileInputStream(new File(folder,"wall-escape-"+trapped+".nbt"))) { expected = CompressedStreamTools.readCompressed(in); } + NBTTagList pos = expected.getTagList("Pos",6); + world.getChunk(new BlockPos(pos.getDoubleAt(0),pos.getDoubleAt(1),pos.getDoubleAt(2))); + EntityLootableBody body = RuntimeChecks.find(world, expected.getUniqueId("UUID")); + RuntimeChecks.require(NameLifetimeChecks.same(expected,NameLifetimeChecks.saved(body)), "Cold reload changed wall fixture NBT"); + body.jumpOutOfWall(); + RuntimeChecks.require(NameLifetimeChecks.same(expected,NameLifetimeChecks.saved(body)), "Repeated escape changed cold-loaded fixture"); + if (!trapped) clear(body); + } + System.out.println("LOOTABLE_BODIES_WALL_ESCAPE_RELOAD_PASS bodies=2"); + } + + /** The sealed 2.2.0 Nether fixture that originally exposed random wall drift. */ + static void historical(MinecraftServer server, File folder, boolean reload) throws Exception { + world = server.getWorld(-1); + for (int x = 7; x <= 9; x++) for (int z = 7; z <= 9; z++) world.getChunk(x,z); + EntityLootableBody body = null; + for (net.minecraft.entity.Entity entity : world.loadedEntityList) + if (entity instanceof EntityLootableBody && Math.abs(entity.posX-128.5)<.001 && Math.abs(entity.posZ-128.5)<.001) + body = (EntityLootableBody) entity; + RuntimeChecks.require(body != null, "Original Nether corpse not found at its saved position"); + RuntimeChecks.require(!world.getCollisionBoxes(body,body.getEntityBoundingBox()).isEmpty(), "Historical buried-body terrain was cleared"); + NBTTagCompound expected = NameLifetimeChecks.saved(body); + File file = new File(folder,"wall-historical.nbt"); + if (reload) { + try (InputStream in = new FileInputStream(file)) { + RuntimeChecks.require(NameLifetimeChecks.same(CompressedStreamTools.readCompressed(in),expected), "Historical cold reload changed complete corpse NBT"); + } + } + for (int i = 0; i < 32; i++) body.jumpOutOfWall(); + RuntimeChecks.require(NameLifetimeChecks.same(expected,NameLifetimeChecks.saved(body)), "Historical sealed corpse drifted or lost saved data"); + PositionMotionChecks.finite(body); + if (!reload) try (OutputStream out = new FileOutputStream(file)) { CompressedStreamTools.writeCompressed(expected,out); } + System.out.println("LOOTABLE_BODIES_WALL_HISTORICAL_PASS reload="+reload+" slots="+body.getSizeInventory()); + } +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/WorldUpgradeChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/WorldUpgradeChecks.java new file mode 100644 index 0000000..8c195a7 --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/WorldUpgradeChecks.java @@ -0,0 +1,166 @@ +package cyano.lootable.buildtest; + +import com.mojang.authlib.GameProfile; +import cyano.lootable.LootableBodies; +import cyano.lootable.entities.EntityLootableBody; +import cyano.lootable.graphics.CorpseContainer; +import java.io.*; +import java.util.*; +import net.minecraft.entity.Entity; +import net.minecraft.entity.player.EntityPlayerMP; +import net.minecraft.inventory.*; +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.*; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.management.PlayerInteractionManager; +import net.minecraft.tileentity.TileEntity; +import net.minecraft.util.ResourceLocation; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.WorldServer; +import net.minecraftforge.common.util.FakePlayerFactory; + +/** Acceptance against worlds written by the actual historical production jars. */ +final class WorldUpgradeChecks { + private static NBTTagCompound manifest,ledger; + private static File folder; + private static boolean old,native112; + private static int checks; + static void run(MinecraftServer server,String phase) throws Exception { + folder=server.getWorld(0).getSaveHandler().getWorldDirectory(); + manifest=read(new File(folder,"upgrade-fixtures.nbt"));old=manifest.getString("Minecraft").equals("1.10.2");native112=manifest.getString("Minecraft").equals("1.12.2"); + File stored=new File(folder,"upgrade-recovered.nbt");ledger=stored.isFile()?read(stored):new NBTTagCompound();checks=0; + if(!ledger.hasKey("Recoveries",10))ledger.setTag("Recoveries",new NBTTagCompound()); + LootableBodies.allowCorpseDecay=false;LootableBodies.ticksPerItemDecay=-1; + int wave=phase.startsWith("upgrade-near")?0:phase.startsWith("upgrade-far")?1:phase.equals("upgrade-late")?2:-1; + boolean recover=phase.equals("upgrade-near-recover") || phase.equals("upgrade-far-reload") || phase.equals("upgrade-late") || phase.equals("upgrade-return"); + NBTTagList bodies=manifest.getTagList("Bodies",10); + for(int i=0;i=0 && record.getInteger("Wave")!=wave)continue; + WorldServer world=server.getWorld(record.getInteger("Dimension"));NBTTagCompound source=record.getCompoundTag("Body"); + int x=(int)source.getTagList("Pos",6).getDoubleAt(0),z=(int)source.getTagList("Pos",6).getDoubleAt(2);world.getChunk(x>>4,z>>4); + UUID uuid=source.getUniqueId("UUID");EntityLootableBody body=find(world,uuid);String id=uuid.toString(); + if(ledger.getString("Removed").equals(id)) {require(body==null,"Journal resurrected a deliberately removed corpse");continue;} + require(body!=null,"Missing historical corpse "+uuid); + require(body.getUniqueID().equals(uuid) && body.dimension==record.getInteger("Dimension"),"UUID/dimension changed"); + require(body.getCustomNameTag().equals(source.getString("CustomName")),"Owner attribution changed"); + require(body.posX==source.getTagList("Pos",6).getDoubleAt(0) && body.posY==source.getTagList("Pos",6).getDoubleAt(1) && body.posZ==source.getTagList("Pos",6).getDoubleAt(2),"Position changed in dimension "+world.provider.getDimension()+": "+body.getPositionVector()); + NBTTagCompound actual=save(body);require(actual.getInteger("LootableBodiesDataVersion")==2,"Missing corpse migration marker"); + require(NameLifetimeChecks.same(source.getTag("LootableBodiesInvalidFields"),actual.getTag("LootableBodiesInvalidFields")),"Original invalid-field evidence changed"); + Map expected=expectedInventory(source);Map observed=inventory(actual); + NBTTagCompound recovery=ledger.getCompoundTag("Recoveries").getCompoundTag(id); + Map recovered=storage(world,recovery); + merge(observed,recovered);require(expected.equals(observed),"Inventory conservation mismatch "+uuid+" expected="+expected+" actual="+observed); + verifyRaw(source,actual); + if(recover) { + if(!ledger.getCompoundTag("Recoveries").hasKey(id,10)) {ledger.getCompoundTag("Recoveries").setTag(id,new NBTTagCompound());recovery=ledger.getCompoundTag("Recoveries").getCompoundTag(id);} + recover(world,body,recovery); + require(inventory(save(body)).isEmpty(),"Supported inventory remained after GUI recovery"); + require(expected.equals(storage(world,recovery)),"GUI recovery lost or duplicated complete item data"); + verifyRaw(source,save(body)); + } + verifyChest(world,record.getInteger("Wave")); + } + verifyPlayer(server); + if(wave==0 || wave<0) verifyVanilla(server); + if(phase.equals("upgrade-final") && ledger.getString("Removed").isEmpty()) { + for(int i=0;i(item.getKeySet()))normalize(item.getTag(key)); + } + private static void add(Map map,NBTTagCompound source,boolean indexed) { + if(!source.hasKey("id",8) || source.getByte("Count")<=0)return; + NBTTagCompound item=source.copy();int count=item.getByte("Count")&255;item.removeTag("Count");if(!indexed)item.removeTag("Slot"); + String key=TypedNbt.canonical(item);map.put(key,map.getOrDefault(key,0)+count); + } + private static void merge(Map a,Map b){for(Map.Entry item:b.entrySet())a.put(item.getKey(),a.getOrDefault(item.getKey(),0)+item.getValue());} + private static Map inventory(NBTTagCompound body){Map map=new TreeMap<>();for(String key:new String[]{"Equipment","Aux"}){NBTTagList items=body.getTagList(key,10);for(int i=0;i expectedInventory(NBTTagCompound source) { + Map expected=new TreeMap<>();for(String key:new String[]{"Equipment","Aux"}) {NBTTagList list=source.getTagList(key,10);for(int i=0;i expected=new ArrayList<>(); + for(String key:new String[]{"Equipment","Aux"}) {NBTTagList list=source.getTagList(key,10);for(int i=0;i contents(IInventory inventory,boolean indexed) {Map map=new TreeMap<>();for(int slot=0;slot indexed(NBTTagList list) {Map map=new TreeMap<>();for(int i=0;i storage(WorldServer world,NBTTagCompound recovery) {Map map=new TreeMap<>();NBTTagList list=storageList(recovery);for(int i=0;i0) {destination=(IInventory)world.getTileEntity(position(stored.getCompoundTagAt(stored.tagCount()-1)));for(int i=0;i expected=indexed(source.getTagList("Items",10)),actual=contents((IInventory)tile,true);if(!expected.equals(actual)){Map missing=new TreeMap<>(expected),unexpected=new TreeMap<>(actual);missing.entrySet().removeAll(actual.entrySet());unexpected.entrySet().removeAll(expected.entrySet());System.out.println("VANILLA_CHEST_DIFF missing="+missing+" unexpected="+unexpected);}require(expected.equals(actual),"Vanilla source chest slots/data changed");} + } + private static void verifyPlayer(MinecraftServer server) throws Exception { + WorldServer world=server.getWorld(0);UUID id=manifest.getUniqueId("Player"); + NBTTagCompound expected=read(new File(folder,"upgrade-player-source.nbt")); + EntityPlayerMP player=new EntityPlayerMP(server,world,new GameProfile(id,"UpgradeFixture"),new PlayerInteractionManager(world));world.getSaveHandler().getPlayerNBTManager().readPlayerData(player); + // InventoryPlayer serializes armour as 100..103 and offhand as 150. + NBTTagList actual=player.inventory.writeToNBT(new NBTTagList());require(indexed(expected.getTagList("Inventory",10)).equals(indexedCurrent(actual)),"Player inventory slots, counts or custom data changed"); + require(indexed(expected.getTagList("EnderItems",10)).equals(indexedCurrent(player.getInventoryEnderChest().saveInventoryToNBT())),"Ender chest slots/data changed"); + require(player.getUniqueID().equals(id),"Player UUID changed");world.getSaveHandler().getPlayerNBTManager().writePlayerData(player); + } + private static Map indexedCurrent(NBTTagList list){Map map=new TreeMap<>();for(int i=0;i>4,((int)pos.getDoubleAt(2))>>4);Entity found=null;for(Entity e:world.loadedEntityList)if(e.getUniqueID().equals(source.getUniqueId("UUID")))found=e;require(found!=null,"Vanilla entity disappeared");NBTTagCompound actual=new NBTTagCompound();found.writeToNBTOptional(actual);require(actual.getString("id").equals(record.getString("ExpectedType")),"Vanilla entity-name conversion failed");require(found.dimension==record.getInteger("Dimension"),"Vanilla entity dimension changed");if(found instanceof IInventory)require(indexed(source.getTagList("Items",10)).equals(contents((IInventory)found,true)),"Vanilla minecart inventory changed");if(record.getString("ExpectedType").equals("minecraft:armor_stand"))require(indexedEquipment(source.getTagList("ArmorItems",10)).equals(indexedEquipment(actual.getTagList("ArmorItems",10))),"Vanilla armour stand equipment changed");} + } + private static Map indexedEquipment(NBTTagList list){Map map=new TreeMap<>();for(int i=0;i + + + + + + + diff --git a/src/integrationTest/resources/mcmod.info b/src/integrationTest/resources/mcmod.info new file mode 100644 index 0000000..b4cf69c --- /dev/null +++ b/src/integrationTest/resources/mcmod.info @@ -0,0 +1 @@ +[{"modid":"lootablebodiesbuildtest","name":"Lootable Bodies Build Tests","version":"1","mcversion":"1.10.2","authorList":[],"dependencies":[]}] diff --git a/src/legacySeed/java/cyano/lootable/fixture/LegacyWorldSeeder.java b/src/legacySeed/java/cyano/lootable/fixture/LegacyWorldSeeder.java new file mode 100644 index 0000000..30f52c9 --- /dev/null +++ b/src/legacySeed/java/cyano/lootable/fixture/LegacyWorldSeeder.java @@ -0,0 +1,120 @@ +package cyano.lootable.fixture; + +import com.mojang.authlib.GameProfile; +import cyano.lootable.LootableBodies; +import cyano.lootable.entities.EntityLootableBody; +import cyano.lootable.buildtest.FixtureItems; +import java.io.*; +import java.util.*; +import net.minecraft.entity.item.EntityArmorStand; +import net.minecraft.entity.item.EntityMinecartChest; +import net.minecraft.entity.passive.EntityPig; +import net.minecraft.entity.player.EntityPlayerMP; +import net.minecraft.init.*; +import net.minecraft.inventory.EntityEquipmentSlot; +import net.minecraft.inventory.IInventory; +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.*; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.management.PlayerInteractionManager; +import net.minecraft.tileentity.TileEntityChest; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.WorldServer; +import net.minecraftforge.fml.common.*; +import net.minecraftforge.fml.common.event.*; + +/** Uses the installed historical mod's own writer; never part of a release jar. */ +@Mod(modid="lootablebodiesbuildtest",name="Lootable Bodies World Fixtures",version="1",dependencies="required-after:lootablebodies") +public final class LegacyWorldSeeder { + private static final UUID PLAYER=UUID.fromString("00000000-2222-4222-8222-000000000001"); + private static boolean oldMinecraft,native112; + @Mod.EventHandler public void preInit(FMLPreInitializationEvent event){FixtureItems.register();} + @Mod.EventHandler public void started(FMLServerStartedEvent event) { + MinecraftServer server=FMLCommonHandler.instance().getMinecraftServerInstance(); + try {seed(server);} catch(Throwable error) {error.printStackTrace();} finally {server.initiateShutdown();} + } + private static void seed(MinecraftServer server) throws Exception { + String minecraft=server.getMinecraftVersion(),version=Loader.instance().getIndexedModList().get("lootablebodies").getVersion(); + oldMinecraft=minecraft.equals("1.10.2");native112=minecraft.equals("1.12.2"); + if(!oldMinecraft && !minecraft.equals("1.11.2") && !minecraft.equals("1.12.2")) throw new AssertionError("Wrong fixture Minecraft "+minecraft); + if(!version.equals(System.getProperty("lootablebodies.fixture.version"))) throw new AssertionError("Wrong historical mod "+version); + LootableBodies.allowCorpseDecay=false;LootableBodies.ticksPerItemDecay=-1; + NBTTagCompound manifest=new NBTTagCompound();manifest.setString("Minecraft",minecraft);manifest.setString("ModVersion",version); + NBTTagList bodies=new NBTTagList(),containers=new NBTTagList(),vanilla=new NBTTagList(); + for(int dimension:new int[]{0,-1,1}) { + WorldServer world=server.getWorld(dimension); + for(int wave=0;wave<3;wave++) { + int coordinate=wave==0?128:wave==1?4096:8192; + world.getChunk(coordinate>>4,coordinate>>4); + EntityLootableBody body=new EntityLootableBody(world); + body.setUniqueId(UUID.nameUUIDFromBytes((minecraft+"/"+version+"/"+dimension+"/"+wave).getBytes("UTF-8"))); + body.setPosition(coordinate+.5,80,coordinate+.5);body.setNoGravity(true);body.setUserName("SavedFixtureOwner");body.setRotation(37); + for(int slot=0;slot<48;slot++)body.setInventorySlotContents(slot,item(slot)); + for(int slot=70;slot<73;slot++)body.addItem(item(slot)); + if(Boolean.getBoolean("lootablebodies.fixture.recovery")) { + if(!version.equals("2.4.1.110021") && !version.equals("2.4.2.111021")) throw new AssertionError("Recovery fixtures require a checksum-pinned maintained writer"); + NBTTagCompound damaged=new NBTTagCompound();body.writeToNBTOptional(damaged); + NBTTagList equipment=damaged.getTagList("Equipment",10); + equipment.getCompoundTagAt(20).setString("id","lootablebodiesbuildtest:returning_item"); + NBTTagCompound duplicate=equipment.getCompoundTagAt(21).copy();duplicate.setByte("Count",(byte)9);duplicate.getCompoundTag("tag").setString("extension","duplicate slot");equipment.appendTag(duplicate); + NBTTagCompound invalid=equipment.getCompoundTagAt(22).copy();invalid.setInteger("Slot",999);invalid.setByte("Count",(byte)7);invalid.getCompoundTag("tag").setString("extension","invalid slot");equipment.appendTag(invalid); + damaged.setInteger("Name",17);damaged.setDouble("DeathTime",Double.NaN);damaged.setString("Health","opaque health"); + damaged.setString("ActiveEffects","opaque effects");damaged.setString("HurtTime","opaque hurt timer");damaged.setDouble("FallDistance",Double.POSITIVE_INFINITY); + body.readFromNBT(damaged);body.setPosition(coordinate+.5,80,coordinate+.5); + } + java.lang.reflect.Field vacuum=EntityLootableBody.class.getDeclaredField("vacuumTime");vacuum.setAccessible(true);vacuum.setByte(body,(byte)20); + if(!world.spawnEntity(body))throw new AssertionError("Historical fixture corpse spawn"); + NBTTagCompound record=new NBTTagCompound(),payload=new NBTTagCompound(); + if(!body.writeToNBTOptional(payload))throw new AssertionError("Historical corpse writer"); + record.setInteger("Dimension",dimension);record.setInteger("Wave",wave);record.setTag("Body",payload);bodies.appendTag(record); + BlockPos chestPos=new BlockPos(coordinate+2,80,coordinate+2);world.setBlockState(chestPos,Blocks.CHEST.getDefaultState()); + TileEntityChest chest=(TileEntityChest)world.getTileEntity(chestPos);for(int slot=0;slot<16;slot++)chest.setInventorySlotContents(slot,item(slot+6)); + NBTTagCompound chestRecord=new NBTTagCompound(),chestNbt=new NBTTagCompound(); + chest.writeToNBT(chestNbt); + chestRecord.setInteger("Dimension",dimension);chestRecord.setInteger("Wave",wave);chestRecord.setTag("Tile",chestNbt);containers.appendTag(chestRecord); + if(wave==0) { + EntityPig pig=new EntityPig(world);pig.setUniqueId(UUID.nameUUIDFromBytes((minecraft+"/pig/"+dimension).getBytes("UTF-8")));pig.setPosition(coordinate+4,82,coordinate+4);pig.setNoGravity(true);pig.setNoAI(true);pig.setCustomNameTag("Saved pig");world.spawnEntity(pig); + NBTTagCompound p=new NBTTagCompound();pig.writeToNBTOptional(p);NBTTagCompound v=new NBTTagCompound();v.setInteger("Dimension",dimension);v.setString("ExpectedType","minecraft:pig");v.setTag("Entity",p);vanilla.appendTag(v); + EntityArmorStand stand=new EntityArmorStand(world);stand.setUniqueId(UUID.nameUUIDFromBytes((minecraft+"/stand/"+dimension).getBytes("UTF-8")));stand.setPosition(coordinate+5,82,coordinate+5);stand.setNoGravity(true);stand.setItemStackToSlot(EntityEquipmentSlot.HEAD,item(0));world.spawnEntity(stand); + p=new NBTTagCompound();stand.writeToNBTOptional(p);v=new NBTTagCompound();v.setInteger("Dimension",dimension);v.setString("ExpectedType","minecraft:armor_stand");v.setTag("Entity",p);vanilla.appendTag(v); + EntityMinecartChest cart=new EntityMinecartChest(world,coordinate+6,82,coordinate+6);cart.setUniqueId(UUID.nameUUIDFromBytes((minecraft+"/cart/"+dimension).getBytes("UTF-8")));cart.setNoGravity(true);for(int slot=0;slot<8;slot++)cart.setInventorySlotContents(slot,item(slot+6));world.spawnEntity(cart); + p=new NBTTagCompound();cart.writeToNBTOptional(p);v=new NBTTagCompound();v.setInteger("Dimension",dimension);v.setString("ExpectedType","minecraft:chest_minecart");v.setTag("Entity",p);vanilla.appendTag(v); + } + } + } + WorldServer world=server.getWorld(0); + EntityPlayerMP player=new EntityPlayerMP(server,world,new GameProfile(PLAYER,"UpgradeFixture"),new PlayerInteractionManager(world));player.setPosition(136.5,82,136.5); + for(int slot=0;slot<36;slot++)player.inventory.setInventorySlotContents(slot,item(slot+6)); + for(int slot=0;slot<4;slot++)player.setItemStackToSlot(new EntityEquipmentSlot[]{EntityEquipmentSlot.HEAD,EntityEquipmentSlot.CHEST,EntityEquipmentSlot.LEGS,EntityEquipmentSlot.FEET}[slot],item(slot)); + player.setItemStackToSlot(EntityEquipmentSlot.OFFHAND,item(5));for(int slot=0;slot<27;slot++)player.getInventoryEnderChest().setInventorySlotContents(slot,item(slot+6)); + world.getSaveHandler().getPlayerNBTManager().writePlayerData(player); + manifest.setUniqueId("Player",PLAYER);manifest.setTag("Bodies",bodies);manifest.setTag("Containers",containers);manifest.setTag("Vanilla",vanilla); + server.saveAllWorlds(false);net.minecraft.world.storage.ThreadedFileIOBase.getThreadedIOInstance().waitForFinish(); + File folder=world.getSaveHandler().getWorldDirectory();try(FileOutputStream file=new FileOutputStream(new File(folder,"upgrade-fixtures.nbt"))){CompressedStreamTools.writeCompressed(manifest,file);} + try(FileWriter marker=new FileWriter(new File(folder,"legacy-seed.pass"))){marker.write(minecraft+" "+version+"\n");} + System.out.println("LOOTABLE_BODIES_LEGACY_SEED_PASS minecraft="+minecraft+" mod="+version+" corpses=9 dimensions=3"); + } + private static ItemStack item(int slot) { + ItemStack stack; + if(slot<4)stack=new ItemStack(new Item[]{Items.DIAMOND_HELMET,Items.DIAMOND_CHESTPLATE,Items.DIAMOND_LEGGINGS,Items.DIAMOND_BOOTS}[slot],1,slot+2); + else if(slot==4)stack=new ItemStack(Items.DIAMOND_SWORD,1,17); + else if(slot==5)stack=new ItemStack(Items.SHIELD,1,9); + else if(slot==6)stack=new ItemStack(Items.SPAWN_EGG,3); + else if(slot==7)stack=new ItemStack(Item.getItemFromBlock(Blocks.CHEST),2); + else if(slot==8)stack=new ItemStack(Items.BANNER,2,oldMinecraft?0:5); + else if(slot==9)stack=new ItemStack(FixtureItems.keepsake,11); + else if(slot==10)stack=new ItemStack(Items.BED,1,0); + else if(slot==11 && native112)stack=new ItemStack(Items.BED,1,5); + else stack=new ItemStack(Items.DIAMOND,slot%32+1); + NBTTagCompound tag=new NBTTagCompound();tag.setString("fixture_slot","original-"+slot);tag.setLong("typed_long",1234567890123L);tag.setByteArray("typed_bytes",new byte[]{0,-1,17}); + if(slot==6){NBTTagCompound pig=new NBTTagCompound();pig.setString("id",oldMinecraft?"Pig":"minecraft:pig");pig.setString("CustomName","Saved pig egg");tag.setTag("EntityTag",pig);} + if(slot==7){NBTTagCompound tile=new NBTTagCompound();NBTTagList items=new NBTTagList();NBTTagCompound nested=new ItemStack(Items.EMERALD,17).writeToNBT(new NBTTagCompound());nested.setByte("Slot",(byte)3);items.appendTag(nested);tile.setTag("Items",items);tag.setTag("BlockEntityTag",tile);} + if(slot==8 && oldMinecraft){NBTTagCompound banner=new NBTTagCompound();banner.setInteger("Base",5);tag.setTag("BlockEntityTag",banner);} + if(native112) { + try {tag.setTag("typed_longs",(NBTBase)Class.forName("net.minecraft.nbt.NBTTagLongArray").getConstructor(long[].class).newInstance((Object)new long[]{Long.MIN_VALUE,slot,Long.MAX_VALUE}));} + catch(ReflectiveOperationException failure){throw new IllegalStateException("Native 1.12 long-array fixture",failure);} + } + stack.setTagCompound(tag);stack.setStackDisplayName("Original keepsake "+slot);if(slot==4)stack.addEnchantment(Enchantments.UNBREAKING,2);return stack; + } +} diff --git a/src/main/java/cyano/lootable/ClientProxy.java b/src/main/java/cyano/lootable/ClientProxy.java index f173e9f..23a7e26 100644 --- a/src/main/java/cyano/lootable/ClientProxy.java +++ b/src/main/java/cyano/lootable/ClientProxy.java @@ -14,6 +14,7 @@ public class ClientProxy extends Proxy{ @Override public void preInit(FMLPreInitializationEvent e) { super.preInit(e); + net.minecraftforge.common.MinecraftForge.EVENT_BUS.register(new cyano.lootable.graphics.ClientConfigChanges()); // client-only pre-init code RenderingRegistry.registerEntityRenderingHandler(EntityLootableBody.class, new IRenderFactory() { @Override diff --git a/src/main/java/cyano/lootable/ItemStacks.java b/src/main/java/cyano/lootable/ItemStacks.java new file mode 100644 index 0000000..3eaac03 --- /dev/null +++ b/src/main/java/cyano/lootable/ItemStacks.java @@ -0,0 +1,7 @@ +package cyano.lootable; +import net.minecraft.item.ItemStack; +/** Shared guard for legacy integrations and the native empty-stack sentinel. */ +public final class ItemStacks { + private ItemStacks() { } + public static boolean emptyStack(ItemStack stack) { return stack == null || stack.isEmpty(); } +} diff --git a/src/main/java/cyano/lootable/LootableBodies.java b/src/main/java/cyano/lootable/LootableBodies.java index d9cfcd7..a479047 100644 --- a/src/main/java/cyano/lootable/LootableBodies.java +++ b/src/main/java/cyano/lootable/LootableBodies.java @@ -1,11 +1,14 @@ package cyano.lootable; +import net.minecraft.entity.Entity; +import cyano.lootable.upgrade.UpgradePreservation; +import net.minecraftforge.fml.common.FMLCommonHandler; +import net.minecraftforge.fml.common.event.FMLServerAboutToStartEvent; +import net.minecraftforge.fml.common.event.FMLServerStoppedEvent; import cyano.lootable.entities.EntityLootableBody; import cyano.lootable.events.PlayerDeathEventHandler; import cyano.lootable.graphics.GUIHandler; -import net.minecraft.entity.Entity; -import net.minecraft.util.ResourceLocation; import net.minecraftforge.common.MinecraftForge; import net.minecraftforge.common.config.Configuration; import net.minecraftforge.fml.common.Mod; @@ -16,14 +19,17 @@ import net.minecraftforge.fml.common.event.FMLPreInitializationEvent; import net.minecraftforge.fml.common.network.NetworkRegistry; import net.minecraftforge.fml.common.registry.EntityRegistry; +import java.io.File; @Mod(modid = LootableBodies.MODID, name=LootableBodies.NAME, version = LootableBodies.VERSION, - acceptedMinecraftVersions = "[1.12,)") + acceptedMinecraftVersions = "[1.12.2]", + guiFactory = "cyano.lootable.graphics.CorpseConfigGuiFactory") public class LootableBodies { public static final String MODID = "lootablebodies"; public static final String NAME ="DrCyano's Lootable Bodies"; - public static final String VERSION = "2.4.0"; + public static final String VERSION = "2.4.3.112021"; private static LootableBodies INSTANCE = null; + private static Configuration configuration; public static boolean displayNameTag = true; public static boolean addBonesToCorpse = true; @@ -34,26 +40,34 @@ public class LootableBodies { public static boolean hurtByMisc = false; public static boolean completelyInvulnerable = false; public static float corpseHP = 20; - + public static boolean allowCorpseDecay = true; public static boolean decayOnlyWhenEmpty = true; public static long corpseDecayTime = 3600*20; // in game ticks - - public static boolean corpseBuoyancy = false; - public static boolean voidPlatform= false; - public static boolean dimensionSpecificPlatform = true; - - @SidedProxy(clientSide="cyano.lootable.ClientProxy", serverSide="cyano.lootable.ServerProxy") + + public static boolean corpseBuoyancy = false; + public static boolean voidPlatform = false; + public static boolean dimensionSpecificPlatform = true; + + @SidedProxy(clientSide="cyano.lootable.ClientProxy", serverSide="cyano.lootable.ServerProxy") public static Proxy proxy; - - + + // Mark this method for receiving an FMLEvent (in this case, it's the FMLPreInitializationEvent) @EventHandler public void preInit(FMLPreInitializationEvent event) { + UpgradePreservation.register(FMLCommonHandler.instance().getDataFixer()); INSTANCE = this; + loadConfiguration(event.getSuggestedConfigurationFile()); + proxy.preInit(event); + } + + /** Reads startup rules. GUI saves deliberately do not call this method. */ + public static void loadConfiguration(File file) { // Do stuff in pre-init phase (read config, create blocks and items, register them) // load config - Configuration config = new Configuration(event.getSuggestedConfigurationFile()); + Configuration config = new Configuration(file); + configuration = config; config.load(); corpseHP = config.getFloat("corpse_HP", "options", corpseHP, 1,Short.MAX_VALUE, @@ -66,7 +80,7 @@ public class LootableBodies { + "location: assets/"+MODID+"/textures/entity/corpse/corpse.png"); displayNameTag = config.getBoolean("display_nametag", "options", displayNameTag, "If true, corpses will show their owner's name"); - + addBonesToCorpse = config.getBoolean("add_bones_to_corpse", "options", addBonesToCorpse, "If true, corpses will have bones and rotten flesh added to them."); @@ -86,7 +100,7 @@ public class LootableBodies { + "for every X number of minutes (default is 5 minutes) that they are. \n" + "Items damaged in this way will never be completely destroyed. \n" + "Set to 0 (or negative) to disable.")); - + allowCorpseDecay = config.getBoolean("enable_corpse_decay", "corpse decay", allowCorpseDecay, "If true, corpses will self-destruct after a period of time."); decayOnlyWhenEmpty = config.getBoolean("empty_only_decay", "corpse decay", decayOnlyWhenEmpty, @@ -100,7 +114,7 @@ public class LootableBodies { + "enable_corpse_decay option is set to true). \n" + "The format is hours:minutes:seconds or just hours:minutes"); corpseDecayTime = Math.max(parseTimeInSeconds(decayTime),2)*20; // 2 second minimum - + corpseBuoyancy = config.getBoolean("enable_corpse_buoyancy","corpse recovery", corpseBuoyancy, "If true, corpses will float in fluids."); voidPlatform = config.getBoolean("prevent_void_loss", "corpse recovery", voidPlatform, @@ -108,12 +122,37 @@ public class LootableBodies { dimensionSpecificPlatform= config.getBoolean("dim_specific_platform", "corpse recovery", dimensionSpecificPlatform, "If true, corpses generate dimension specific platforms, otherwise they only make cobblestone platforms"); + for (String category : new String[] {"options", "corpse damage", "corpse decay", "corpse recovery"}) { + for (net.minecraftforge.common.config.Property property : config.getCategory(category).values()) { + String key = property.getName(); + // Unknown pack options are retained without exposing them in our GUI. + if (!isKnownOption(key)) continue; + property.setLanguageKey("lootablebodies.config." + key); + property.setRequiresMcRestart(!key.equals("local_texture") && !key.equals("display_nametag")); + } + } config.save(); - proxy.preInit(event); } - - private int parseTimeInSeconds(String time) { + private static boolean isKnownOption(String key) { + return java.util.Arrays.asList("corpse_HP", "local_texture", "display_nametag", "add_bones_to_corpse", + "hurt_by_environment", "hurt_by_weapons", "hurt_by_misc", "item_decay_rate", + "enable_corpse_decay", "empty_only_decay", "corpse_decay_time", + "enable_corpse_buoyancy", "prevent_void_loss", "dim_specific_platform").contains(key); + } + + public static Configuration getConfiguration() { + return configuration; + } + + /** Persists pending rules, applying only this client's appearance preferences. */ + public static void saveClientConfiguration() { + if (configuration == null) return; + useLocalSkin = configuration.getCategory("options").get("local_texture").getBoolean(); + displayNameTag = configuration.getCategory("options").get("display_nametag").getBoolean(); + configuration.save(); + } + private static int parseTimeInSeconds(String time) { String[] component = time.split(":"); int hr = 0,min=0,sec=0; if(component.length > 0)hr = Integer.parseInt(component[0].trim()); @@ -127,22 +166,22 @@ private int parseTimeInSeconds(String time) { public void init(FMLInitializationEvent event) { // register entities registerItemRenders(); - + registerEntity(EntityLootableBody.class); MinecraftForge.EVENT_BUS.register(new PlayerDeathEventHandler()); NetworkRegistry.INSTANCE.registerGuiHandler( LootableBodies.getInstance(), GUIHandler.getInstance()); - + proxy.init(event); - - + + } private int entityIndex = 0; - private void registerEntity(Class entityClass){ + private void registerEntity(Class entityClass){ String idName = "Corpse"; - EntityRegistry.registerModEntity(new ResourceLocation("lootablebodies",idName), entityClass, idName, entityIndex++/*mod-specific entity id*/, this, 32/*trackingRange*/, 1/*updateFrequency*/, true/*sendsVelocityUpdates*/); - + EntityRegistry.registerModEntity(new net.minecraft.util.ResourceLocation("lootablebodies:corpse"), (Class)entityClass, idName, entityIndex++/*mod-specific entity id*/, this, 32/*trackingRange*/, 1/*updateFrequency*/, true/*sendsVelocityUpdates*/, entityIndex, entityIndex); + } - + private void registerItemRenders() { // client-side only if(proxy instanceof ServerProxy) return; @@ -150,7 +189,7 @@ private void registerItemRenders() { } - + @EventHandler public void postInit(FMLPostInitializationEvent event) { proxy.postInit(event); } @@ -158,15 +197,26 @@ private void registerItemRenders() { public static LootableBodies getInstance(){ return INSTANCE; } - - + + @EventHandler public void beforeWorldLoading(FMLServerAboutToStartEvent event) { + java.io.File world=event.getServer().getActiveAnvilConverter().getFile(event.getServer().getFolderName(), ""); + try { + int protectedBodies=UpgradePreservation.prepare(world.toPath()).size(); + net.minecraftforge.fml.common.FMLLog.info("Lootable Bodies preserved %s existing corpses before world loading",protectedBodies); + } catch(java.io.IOException unsafe) { + throw new IllegalStateException("Lootable Bodies could not preserve this world's corpses. World loading has been stopped; keep the original world and recovery journal.",unsafe); + } + } + @EventHandler public void worldStopped(FMLServerStoppedEvent event) {UpgradePreservation.close();} + + private static boolean or(boolean... bools){ for(int i = 0; i < bools.length; i++){ if(bools[i] == true) return true; } return false; } - + private static boolean and(boolean... bools){ for(int i = 0; i < bools.length; i++){ if(bools[i] == false) return false; diff --git a/src/main/java/cyano/lootable/entities/EntityLootableBody.java b/src/main/java/cyano/lootable/entities/EntityLootableBody.java index 9c97fc2..ee328ac 100644 --- a/src/main/java/cyano/lootable/entities/EntityLootableBody.java +++ b/src/main/java/cyano/lootable/entities/EntityLootableBody.java @@ -1,28 +1,39 @@ package cyano.lootable.entities; +import static cyano.lootable.ItemStacks.emptyStack; +import cyano.lootable.upgrade.UpgradePreservation; +import cyano.lootable.upgrade.ItemMigration; + import com.mojang.authlib.GameProfile; import cyano.lootable.LootableBodies; import net.minecraft.block.material.Material; import net.minecraft.block.state.IBlockState; +import net.minecraft.init.Blocks; import net.minecraft.entity.EntityLiving; import net.minecraft.entity.EnumCreatureAttribute; import net.minecraft.entity.SharedMonsterAttributes; +import net.minecraft.entity.ai.attributes.AttributeModifier; +import net.minecraft.entity.ai.attributes.IAttributeInstance; import net.minecraft.entity.item.EntityItem; import net.minecraft.entity.player.EntityPlayer; -import net.minecraft.init.Blocks; import net.minecraft.inventory.EntityEquipmentSlot; import net.minecraft.inventory.IInventory; import net.minecraft.item.Item; import net.minecraft.item.ItemSpade; import net.minecraft.item.ItemStack; +import net.minecraft.nbt.NBTBase; +import net.minecraft.nbt.NBTPrimitive; import net.minecraft.nbt.NBTTagCompound; import net.minecraft.nbt.NBTTagList; +import net.minecraft.potion.Potion; import net.minecraft.tileentity.TileEntitySkull; import net.minecraft.util.DamageSource; import net.minecraft.util.EntityDamageSource; import net.minecraft.util.EnumHand; +import net.minecraft.util.EnumFacing; import net.minecraft.util.NonNullList; import net.minecraft.util.SoundEvent; +import net.minecraft.util.ResourceLocation; import net.minecraft.util.math.AxisAlignedBB; import net.minecraft.util.math.BlockPos; import net.minecraft.world.World; @@ -31,7 +42,12 @@ import org.apache.commons.lang3.ObjectUtils; import java.util.*; +import java.nio.charset.StandardCharsets; import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.RejectedExecutionException; public class EntityLootableBody extends EntityLiving implements IInventory{ @@ -41,23 +57,53 @@ public class EntityLootableBody extends EntityLiving implements IInventory{ final static byte VACUUM_TIMELIMIT = 20; final static int VACUUM_RADIUS = 3; - + private final NonNullList mainInventory = NonNullList.withSize(6*7, ItemStack.EMPTY); private final Deque auxInventory = new LinkedList<>(); + private static final String UNRESOLVED_ITEMS_TAG = "LootableBodiesUnresolved"; + private final List unresolvedInventory = new ArrayList<>(); + private NBTTagCompound sourceVersions = new NBTTagCompound(); + private NBTTagCompound inventoryVersions = new NBTTagCompound(); + private static final String INVALID_FIELDS_TAG = "LootableBodiesInvalidFields"; + // Evidence about metadata is separate from inventory and must not prevent decay. + private final List invalidFields = new ArrayList<>(); + private final Map defaultAttributeBases = new HashMap<>(); + private final Map> defaultAttributeModifiers = new HashMap<>(); private byte vacuumTime = 0; + private boolean positionNeedsChunk; + private boolean malformedPosition; + private NBTBase loadedPosition; + private boolean missingPositionX, missingPositionZ; private long deathTimestamp; private long decayTimestamp; private final AtomicReference gpSwap = new AtomicReference<>(null) ; // for lazy-evaluation of player skins + // Profile services can be slow or unavailable. A bounded daemon worker must + // never hold up client ticks or prevent the default corpse from being drawn. + private static final ThreadPoolExecutor PROFILE_LOOKUPS = new ThreadPoolExecutor(0, 1, 30L, + TimeUnit.SECONDS, new ArrayBlockingQueue(64), task -> { + Thread thread = new Thread(task,"Lootable Bodies skin lookup"); + thread.setDaemon(true); + return thread; + }); private int terminate = -1; private static final int DEATH_COUNTDOWN = 10; public EntityLootableBody(World w) { super(w); + // Keep EntityLiving's actual defaults (including follow range 16 and + // configured corpse health), rather than the generic attribute defaults. + for(IAttributeInstance attribute : getAttributeMap().getAllAttributes()) { + String name = attribute.getAttribute().getName(); + defaultAttributeBases.put(name,attribute.getBaseValue()); + defaultAttributeModifiers.put(name,new ArrayList<>(attribute.getModifiers())); + } deathTimestamp = w.getTotalWorldTime(); decayTimestamp = deathTimestamp; - this.setAlwaysRenderNameTag(LootableBodies.displayNameTag); + // Local display preferences must not become world/server entity state. + // Existing saved visibility flags are still read unchanged by vanilla. + this.setAlwaysRenderNameTag(true); this.setSize(0.85F, 0.75F); this.isImmuneToFire = LootableBodies.completelyInvulnerable || (!LootableBodies.hurtByEnvironment); @@ -77,8 +123,9 @@ public EntityLootableBody(EntityPlayer player){ //this.newPosZ = posZ; // no newPosZ? this.markDirty(); } - - + + + private void log(String format, Object... o){ FMLLog.info("%s: %s",getClass().getSimpleName(), String.format(format, o)); } @@ -88,6 +135,21 @@ private void log(Object o){ } private String oldName = null; + @Override + public void onUpdate() { + if(!getEntityWorld().isRemote) { + // A Forge ticket can tick our centre chunk while neighbouring support + // terrain is absent. Never treat those missing collision boxes as air. + AxisAlignedBB bounds = getEntityBoundingBox(); + BlockPos from = new BlockPos(bounds.minX + Math.min(0D,motionX), 0, + bounds.minZ + Math.min(0D,motionZ)); + BlockPos to = new BlockPos(bounds.maxX + Math.max(0D,motionX), 0, + bounds.maxZ + Math.max(0D,motionZ)); + if(!getEntityWorld().isAreaLoaded(from,to)) return; + } + super.onUpdate(); + } + @Override public void onEntityUpdate(){ //log("position=%s; terminate=%s; time=%s; age=%s; deathTimestamp=%s; decayTimestamp=%s; Health=%s; isDead=%s" ,getPosition(),terminate,getEntityWorld().getTotalWorldTime(),getEntityWorld().getTotalWorldTime() - deathTimestamp, deathTimestamp, decayTimestamp, getHealth(), isDead); @@ -97,7 +159,11 @@ public void onEntityUpdate(){ if(isServer && terminate >= 0){ // in death mode - if(terminate == DEATH_COUNTDOWN) dropAllItems(); + if(terminate == DEATH_COUNTDOWN && !dropAllItems()) { + // Keep rejected stacks recoverable; vanilla must not remove their owner. + cancelRemoval(); + return; + } // handle self-removal from entity list in world if(terminate > 0) terminate--; if(terminate == 0){ @@ -116,8 +182,8 @@ public void onEntityUpdate(){ oldName = nameUpdate; if(nameUpdate != null && nameUpdate.trim().length() > 0) { GameProfile gp = new GameProfile(null, nameUpdate); - if((!LootableBodies.useLocalSkin) && isClient) gp = TileEntitySkull.updateGameProfile(gp); setGameProfile(gp); + if((!LootableBodies.useLocalSkin) && isClient) resolveProfile(gp); this.setCustomNameTag(nameUpdate); } else { setGameProfile(null); @@ -126,7 +192,12 @@ public void onEntityUpdate(){ } // handle decay time - final long currentTime = getEntityWorld().getTotalWorldTime(); + final long currentTime = Math.max(0L,getEntityWorld().getTotalWorldTime()); + if(isServer) { + // A clock rollback must not suspend decay indefinitely or overflow age. + deathTimestamp = checkedTimestamp("DeathTime",deathTimestamp,currentTime); + decayTimestamp = checkedTimestamp("DecayTime",decayTimestamp,currentTime); + } if(isServer && LootableBodies.allowCorpseDecay){ if(LootableBodies.decayOnlyWhenEmpty && isEmpty() == false){ deathTimestamp = currentTime; @@ -141,9 +212,10 @@ public void onEntityUpdate(){ if(isServer && LootableBodies.ticksPerItemDecay > 0){ long age = currentTime - decayTimestamp; if(age > LootableBodies.ticksPerItemDecay){ - int decayAmount = (int)(age / LootableBodies.ticksPerItemDecay); - decayTimestamp += decayAmount * LootableBodies.ticksPerItemDecay; - decayItems(decayAmount); + long elapsed = age / LootableBodies.ticksPerItemDecay; + // Consume the whole interval, even when damage exceeds an int. + decayTimestamp = currentTime - age % LootableBodies.ticksPerItemDecay; + decayItems((int)Math.min(elapsed,Integer.MAX_VALUE)); } } @@ -151,8 +223,8 @@ public void onEntityUpdate(){ // move items from buffer to main inventory if(isServer && !auxInventory.isEmpty()) { for (int slot = this.getSizeInventory() - 1; slot >= EQUIPMENT_SLOTS.length; slot--) { - if(!this.getStackInSlot(slot).isEmpty()){ - this.setInventorySlotContents(slot,auxInventory.pollFirst()); + if(emptyStack(this.getStackInSlot(slot))){ + if(emptyStack(mergeItem(slot,auxInventory.peekFirst()))) auxInventory.removeFirst(); this.markDirty(); break; } @@ -166,14 +238,13 @@ public void onEntityUpdate(){ posX - VACUUM_RADIUS, posY - VACUUM_RADIUS, posZ - VACUUM_RADIUS, posX + VACUUM_RADIUS, posY + VACUUM_RADIUS, posZ + VACUUM_RADIUS)); for(EntityItem item : items){ + if(item.isDead) continue; ItemStack stack = item.getItem(); this.addItem(stack); - } - for(EntityItem item : items){ getEntityWorld().removeEntity(item); } } - + if(isServer && LootableBodies.corpseBuoyancy && vacuumTime >= VACUUM_TIMELIMIT && (this.inWater || this.isInLava())) { this.motionY += 0.025D; this.motionY *= 0.9; @@ -185,7 +256,7 @@ public void onEntityUpdate(){ } } - + @Override public boolean handleWaterMovement() { if (this.world.handleMaterialAcceleration(this.getEntityBoundingBox().grow(0.0D, -0.4000000059604645D, 0.0D).shrink(0.001D), Material.WATER, this)) { @@ -234,7 +305,7 @@ private void decayItems(int amount) { } private ItemStack decayItem(ItemStack item, int amount) { - if(!item.isEmpty() + if(!emptyStack(item) && item.getCount() == 1 && item.isItemStackDamageable() && item.getItem().isDamageable()){ @@ -249,30 +320,57 @@ private ItemStack decayItem(ItemStack item, int amount) { public void addItem(ItemStack stack) { for(int slot = EQUIPMENT_SLOTS.length; slot < this.getSizeInventory(); slot++){ stack = mergeItem(slot,stack); - if(stack.isEmpty()) return; + if(emptyStack(stack)) return; } auxInventory.addLast(stack); + markDirty(); } + /** Retain an item whose container release was rejected. Uses the existing Aux format. */ + public void retainItem(ItemStack stack) { + if(emptyStack(stack) || stack.getCount() <= 0) return; + if(terminate >= 0 || !isEntityAlive()) cancelRemoval(); + addItem(stack); + } - public void initializeItems(NonNullList inv) { - - for(int i = 0; i < inv.size(); i++) { - if(i < mainInventory.size()) - mainInventory.set(i,inv.get(i)); - else - auxInventory.addLast(inv.get(i)); - } - + private void cancelRemoval() { + terminate = -1; + deathTime = 0; + dead = false; + isDead = false; + setHealth(getMaxHealth()); + vacuumTime = VACUUM_TIMELIMIT; + markDirty(); } + + public void initializeItems(ItemStack[] inv) { + initializeItems(Arrays.asList(inv)); + } + + public void initializeItems(NonNullList inv) { + initializeItems((List)inv); + } + + public void initializeItems(List inv) { + mainInventory.clear(); + for(int i=0; i 0) this.setInventorySlotContents(slot,stack.splitStack(amount)); + return stack.getCount() > 0 ? stack : ItemStack.EMPTY; } else if(current.getCount() < inventorySizeLimit && ItemStack.areItemsEqual(stack,current) && ItemStack.areItemStackTagsEqual(stack,current)){ @@ -293,7 +391,7 @@ protected void damageEntity(DamageSource src, float amount){ if(src instanceof EntityDamageSource && src.getTrueSource() instanceof EntityPlayer){ EntityPlayer player = (EntityPlayer)src.getTrueSource(); ItemStack item = player.getHeldItem(EnumHand.MAIN_HAND); - if(!item.isEmpty()) { + if(!emptyStack(item)) { if (item.getItem() instanceof ItemSpade || item.getItem().getToolClasses(item).contains("shovel")) { this.onKillCommand(); } @@ -336,6 +434,28 @@ public GameProfile getGameProfile(){ public void setGameProfile(GameProfile gp){ gpSwap.set(gp); } + /** Refreshes an existing client body after enabling player skins in the GUI. */ + public void refreshClientSkin() { + GameProfile profile = gpSwap.get(); + if (getEntityWorld().isRemote && !LootableBodies.useLocalSkin && profile != null && profile.getId() == null) + resolveProfile(profile); + } + private void resolveProfile(GameProfile initial) { + try { + PROFILE_LOOKUPS.execute(() -> { + try { + GameProfile resolved = TileEntitySkull.updateGameProfile(initial); + // A rename, reload or explicit profile update wins over a late lookup. + if(resolved != null) gpSwap.compareAndSet(initial,resolved); + } catch(RuntimeException unavailable) { + FMLLog.fine("Corpse profile lookup unavailable; using default skin"); + } + }); + } catch(RejectedExecutionException busy) { + // Keep the already installed default profile when the bounded queue is full. + FMLLog.fine("Corpse profile lookup busy; using default skin"); + } + } public void setUserName(String name){ this.setCustomNameTag(name); } @@ -370,63 +490,43 @@ protected boolean canDespawn() { } public void jumpOutOfWall(){ - log("Escaping from wall at position %s",this.getPosition()); - BlockPos currentCoord = this.getPosition(); - // first try going out to the nearest adjacent block - double[] vector = new double[3]; - vector[0] = currentCoord.getX()+0.5 - this.posX; - vector[1] = 0; - vector[2] = currentCoord.getZ()+0.5 - this.posZ; - double normalizer = 1.0/Math.sqrt(vector[0]*vector[0]+vector[1]*vector[1]+vector[2]*vector[2]); - vector[0] *= normalizer; - vector[1] *= normalizer; - vector[2] *= normalizer; - IBlockState bs = world.getBlockState(new BlockPos(this.posX+vector[0], this.posY+vector[1], this.posZ+vector[2])); - if(!(bs.getMaterial().blocksMovement())){ - this.setPosition(this.posX+vector[0], this.posY+vector[1], this.posZ+vector[2]); - return; + if(world.isRemote) return; + AxisAlignedBB bounds = getEntityBoundingBox(); + // Collision queries omit unloaded chunks. They cannot establish an exit + // until the complete local terrain (including tall neighbour shapes) exists. + if(!escapeTerrainLoaded(bounds) || world.getCollisionBoxes(this,bounds).isEmpty()) return; + BlockPos currentCoord = getPosition(); + double x = currentCoord.getX()+0.5 - posX; + double z = currentCoord.getZ()+0.5 - posZ; + double length = Math.hypot(x,z); + // At a block centre there is no horizontal direction to normalize. + if(length > 0 && tryWallExit(posX+x/length,posY,posZ+z/length)) return; + + // Retain the original adjacent-exit priority, but check the whole body. + for(EnumFacing direction : new EnumFacing[]{EnumFacing.UP,EnumFacing.NORTH, + EnumFacing.EAST,EnumFacing.SOUTH,EnumFacing.WEST,EnumFacing.DOWN}) { + BlockPos exit = currentCoord.offset(direction); + if(tryWallExit(exit.getX()+0.5,exit.getY()+0.015625,exit.getZ()+0.5)) return; } + // A sealed corpse must remain recoverable here, rather than wander through + // solid terrain or load chunks looking for an unchecked random destination. + } - // then try finding an open space in all adjacent blocks - BlockPos n; - n = currentCoord.up(); - if(!(world.getBlockState(n).getMaterial().blocksMovement())){ - this.setPosition(n.getX()+0.5,n.getY()+0.015625, n.getZ()+0.5); - return; - } - n = currentCoord.north(); - if(!(world.getBlockState(n).getMaterial().blocksMovement())){ - this.setPosition(n.getX()+0.5,n.getY()+0.015625, n.getZ()+0.5); - return; - } - n = currentCoord.east(); - if(!(world.getBlockState(n).getMaterial().blocksMovement())){ - this.setPosition(n.getX()+0.5,n.getY()+0.015625, n.getZ()+0.5); - return; - } - n = currentCoord.south(); - if(!(world.getBlockState(n).getMaterial().blocksMovement())){ - this.setPosition(n.getX()+0.5,n.getY()+0.015625, n.getZ()+0.5); - return; - } - n = currentCoord.west(); - if(!(world.getBlockState(n).getMaterial().blocksMovement())){ - this.setPosition(n.getX()+0.5,n.getY()+0.015625, n.getZ()+0.5); - return; - } - n = currentCoord.down(); - if(!(world.getBlockState(n).getMaterial().blocksMovement())){ - this.setPosition(n.getX()+0.5,n.getY()+0.015625, n.getZ()+0.5); - return; - } - // then if the above fails, move 2 blocks in a random direction - vector[0] = world.rand.nextDouble() * 2; - vector[1] = world.rand.nextDouble() * 2; - vector[2] = world.rand.nextDouble() * 2; - this.setPosition(this.posX+vector[0], this.posY+vector[1], this.posZ+vector[2]); - log("jumped to %s",this.getPosition()); + private boolean escapeTerrainLoaded(AxisAlignedBB bounds) { + return world.isAreaLoaded(new BlockPos(bounds.minX-1,0,bounds.minZ-1), + new BlockPos(bounds.maxX+1,0,bounds.maxZ+1)); } - + + private boolean tryWallExit(double x,double y,double z) { + AxisAlignedBB bounds = getEntityBoundingBox().offset(x-posX,y-posY,z-posZ); + if(bounds.minY < 0 || bounds.maxY > world.getHeight() || + !world.getWorldBorder().contains(bounds) || !escapeTerrainLoaded(bounds) || + !world.getCollisionBoxes(this,bounds).isEmpty()) return false; + setPosition(x,y,z); + return true; + } + + @Override public void onKillCommand() { if(terminate < 0) terminate = DEATH_COUNTDOWN; @@ -462,6 +562,11 @@ protected Item getDropItem() { return null; } + @Override + protected void dropEquipment(boolean wasRecentlyHit, int lootingModifier) { + // Corpse equipment belongs to the inventory release path, not mob loot. + } + @Override public boolean canBeSteered() { return false; @@ -520,11 +625,13 @@ public ItemStack getStackInSlot(int index) { */ @Override public void setInventorySlotContents(int index, ItemStack stack) { + if(stack == null) stack = ItemStack.EMPTY; if(index < EQUIPMENT_SLOTS.length){ super.setItemStackToSlot(EQUIPMENT_SLOTS[index], stack); } else { mainInventory.set(index - EQUIPMENT_SLOTS.length, stack); } + markDirty(); } /** @@ -535,7 +642,7 @@ public void setInventorySlotContents(int index, ItemStack stack) { @Override public ItemStack removeStackFromSlot(int index) { ItemStack i = this.getStackInSlot(index); - this.setInventorySlotContents(index,ItemStack.EMPTY); + this.setInventorySlotContents(index,null); return i; } @@ -548,6 +655,7 @@ public ItemStack removeStackFromSlot(int index) { @Override public ItemStack decrStackSize(int index, int count) { ItemStack i = this.getStackInSlot(index); + if(emptyStack(i) || count <= 0) return ItemStack.EMPTY; ItemStack result = i.splitStack(count); if (i.getCount() <= 0) { i = ItemStack.EMPTY; @@ -570,7 +678,9 @@ public int getInventoryStackLimit() { */ @Override public void markDirty() { - // do nothing + if(world != null && !world.isRemote && addedToChunk) { + world.markChunkDirty(getPosition(),null); + } } /** @@ -580,13 +690,15 @@ public void markDirty() { */ @Override public boolean isUsableByPlayer(EntityPlayer player) { - return true; + return player != null && player.isEntityAlive() && this.isEntityAlive() + && terminate < 0 && player.getEntityWorld() == getEntityWorld() + && player.getPosition().distanceSq(this.getPosition()) < 25; } - + @Override - protected boolean processInteract(EntityPlayer player, EnumHand hand){ - if(player.getPosition().distanceSq(this.getPosition()) < 25) { - FMLNetworkHandler.openGui(player, LootableBodies.getInstance(), 0, getEntityWorld(), getEntityId(), 0, 0); + public boolean processInteract(EntityPlayer player, EnumHand hand){ + if(isUsableByPlayer(player)) { + FMLNetworkHandler.openGui(player, LootableBodies.getInstance(), 0, getEntityWorld(), this.getEntityId(), 0, 0); return true; } return false; @@ -608,6 +720,7 @@ public void closeInventory(EntityPlayer player) { */ @Override public boolean isItemValidForSlot(int index, ItemStack stack) { + if(emptyStack(stack) || stack.getCount() <= 0) return false; if(index < 4){ return stack.getItem().isValidArmor (stack, EQUIPMENT_SLOTS[index], this); } else { @@ -633,34 +746,39 @@ public int getFieldCount() { @Override public void clear() { for(int i = 0; i < EQUIPMENT_SLOTS.length; i++){ - this.setInventorySlotContents(i,ItemStack.EMPTY); + this.setInventorySlotContents(i,null); } - mainInventory.clear(); auxInventory.clear(); + unresolvedInventory.clear(); } ////////// End of IInventory ////////// public boolean isEmpty(){ - if(!auxInventory.isEmpty()) return false; + if(!auxInventory.isEmpty() || !unresolvedInventory.isEmpty()) return false; for(int slot = 0; slot < this.getSizeInventory(); slot++){ - if(!this.getStackInSlot(slot).isEmpty()) return false; + if(!emptyStack(this.getStackInSlot(slot))) return false; } return true; } - private void dropAllItems(){ - for(EntityEquipmentSlot e : EQUIPMENT_SLOTS){ - ItemStack i = this.getItemStackFromSlot(e); - if(!i.isEmpty())this.getEntityWorld().spawnEntity(new EntityItem(this.getEntityWorld(),posX,posY,posZ, - i)); - }for(ItemStack i : mainInventory){ - if(!i.isEmpty())this.getEntityWorld().spawnEntity(new EntityItem(this.getEntityWorld(),posX,posY,posZ,i)); - }for(ItemStack i : auxInventory){ - if(!i.isEmpty())this.getEntityWorld().spawnEntity(new EntityItem(this.getEntityWorld(),posX,posY,posZ,i)); + private boolean dropAllItems(){ + for(int slot = 0; slot < getSizeInventory(); slot++){ + ItemStack stack = getStackInSlot(slot); + if(!emptyStack(stack) && getEntityWorld().spawnEntity( + new EntityItem(getEntityWorld(),posX,posY,posZ,stack.copy()))) { + setInventorySlotContents(slot,null); + } } - this.clear(); + Iterator iterator = auxInventory.iterator(); + while(iterator.hasNext()) { + ItemStack stack = iterator.next(); + if(emptyStack(stack) || getEntityWorld().spawnEntity( + new EntityItem(getEntityWorld(),posX,posY,posZ,stack.copy()))) iterator.remove(); + } + markDirty(); + return isEmpty(); } private static boolean matchesAny(String damageType, DamageSource... list){ @@ -679,11 +797,16 @@ public EnumCreatureAttribute getCreatureAttribute() @Override public void writeEntityToNBT(final NBTTagCompound root) { + UpgradePreservation.clearTransient(root); + root.setInteger(UpgradePreservation.VERSION,UpgradePreservation.DATA_VERSION); + if(sourceVersions.isEmpty()) root.removeTag(UpgradePreservation.SOURCES); + else root.setTag(UpgradePreservation.SOURCES,sourceVersions.copy()); + root.removeTag("ActiveEffects"); super.writeEntityToNBT(root); final NBTTagList equipmentListTag = new NBTTagList(); for (int i = 0; i < this.getSizeInventory(); i++) { ItemStack item = this.getStackInSlot(i); - if (!item.isEmpty()) { + if (!emptyStack(item)) { NBTTagCompound slotTag = new NBTTagCompound(); slotTag.setByte("Slot", (byte)i); item.writeToNBT(slotTag); @@ -692,60 +815,483 @@ public void writeEntityToNBT(final NBTTagCompound root) { } root.setTag("Equipment", equipmentListTag); + root.removeTag("Aux"); if(!this.auxInventory.isEmpty()){ final NBTTagList nbtauxtaglist = new NBTTagList(); Iterator iter = this.auxInventory.iterator(); while (iter.hasNext()) { ItemStack i = iter.next(); - if(i.isEmpty()) continue; + if(emptyStack(i)) continue; final NBTTagCompound nbttagcompound1 = new NBTTagCompound(); i.writeToNBT(nbttagcompound1); nbtauxtaglist.appendTag(nbttagcompound1); } root.setTag("Aux", nbtauxtaglist); } - if(vacuumTime < VACUUM_TIMELIMIT)root.setByte("Vac", vacuumTime); - if(getGameProfile() != null){ - root.setString("Name", getGameProfile().getName()); + root.removeTag(UNRESOLVED_ITEMS_TAG); + if(!unresolvedInventory.isEmpty()) { + NBTTagList retained = new NBTTagList(); + for(NBTTagCompound record : unresolvedInventory) retained.appendTag(record.copy()); + root.setTag(UNRESOLVED_ITEMS_TAG,retained); } + root.removeTag(INVALID_FIELDS_TAG); + if(!invalidFields.isEmpty()) { + NBTTagList retained = new NBTTagList(); + for(NBTTagCompound record : invalidFields) retained.appendTag(record.copy()); + root.setTag(INVALID_FIELDS_TAG,retained); + } + root.removeTag("Vac"); + if(vacuumTime < VACUUM_TIMELIMIT)root.setByte("Vac", vacuumTime); + root.removeTag("Name"); + String name = getCustomNameTag(); + if(name != null && !name.trim().isEmpty()) root.setString("Name",name); + else root.removeTag("CustomName"); root.setLong("DeathTime", this.getDeathTimestamp()); root.setLong("DecayTime",this.decayTimestamp); } @Override - public void readEntityFromNBT(final NBTTagCompound root) { - super.readEntityFromNBT(root); - if (root.hasKey("Equipment", 9)) { - final NBTTagList nbttaglist = root.getTagList("Equipment", 10); - for (int i = 0; i < nbttaglist.tagCount(); ++i) { - NBTTagCompound slotTag = nbttaglist.getCompoundTagAt(i); - int slot = slotTag.getByte("Slot"); - ItemStack item = new ItemStack(slotTag); - this.setInventorySlotContents(slot,item); + public void readFromNBT(final NBTTagCompound supplied) { + final NBTTagCompound root=UpgradePreservation.restore(supplied); + // Entity reads geometry before our inventory helper. Sanitize a copy + // before it builds bounding boxes or derives previous-tick positions. + NBTTagCompound clean = root.copy(); + boolean[] badPosition = new boolean[4], badMotion = new boolean[4]; + boolean[] badRotation = new boolean[3]; + clean.setTag("Pos",readVector(root.getTag("Pos"),false,badPosition)); + clean.setTag("Motion",readVector(root.getTag("Motion"),true,badMotion)); + clean.setTag("Rotation",readRotation(root.getTag("Rotation"),badRotation)); + Float distance = finiteFloat(root.getTag("FallDistance")); + boolean badFall = root.hasKey("FallDistance") && (distance == null || distance < 0F); + clean.setFloat("FallDistance",distance != null && distance >= 0F ? distance : 0F); + super.readFromNBT(clean); + // A repeated load must not interpolate from obsolete head/body angles. + prevRotationYaw = rotationYaw; prevRotationPitch = rotationPitch; + setRotationYawHead(rotationYaw); prevRotationYawHead = rotationYaw; + setRenderYawOffset(rotationYaw); prevRenderYawOffset = rotationYaw; + positionNeedsChunk = true; + malformedPosition = hasInvalidComponent(badPosition); + loadedPosition = root.hasKey("Pos") ? root.getTag("Pos").copy() : null; + missingPositionX = badPosition[0]; missingPositionZ = badPosition[2]; + if(malformedPosition && root.hasKey("Pos")) retainInvalid("Pos",root.getTag("Pos")); + if(hasInvalidComponent(badMotion) && root.hasKey("Motion")) retainInvalid("Motion",root.getTag("Motion")); + if(hasInvalidComponent(badRotation) && root.hasKey("Rotation")) retainInvalid("Rotation",root.getTag("Rotation")); + if(badFall) retainInvalid("FallDistance",root.getTag("FallDistance")); + } + + private static Float finiteFloat(NBTBase raw) { + Double number = finiteNumber(raw); + if(number == null) return null; + float value = number.floatValue(); + return Float.isFinite(value) && (number == 0D || value != 0F) ? value : null; + } + + private static NBTTagList readRotation(NBTBase raw, boolean[] invalid) { + NBTTagList list = raw instanceof NBTTagList ? (NBTTagList)raw : null; + invalid[2] = list == null || list.tagCount() != 2 || list.getTagType() < 1 || list.getTagType() > 6; + NBTTagList clean = new NBTTagList(); + for(int axis = 0; axis < 2; axis++) { + Float angle = list != null && axis < list.tagCount() ? finiteFloat(list.get(axis)) : null; + if(angle == null) { invalid[axis] = true; angle = 0F; } + // Preserve the legacy float remainder and the corpse's existing + // flat pitch before vanilla copies angles into head/render state. + clean.appendTag(new net.minecraft.nbt.NBTTagFloat(axis == 0 ? angle % 360F : 0F)); + } + return clean; + } + + private static boolean hasInvalidComponent(boolean[] invalid) { + for(boolean value : invalid) if(value) return true; + return false; + } + + private static NBTTagList readVector(NBTBase raw, boolean motion, boolean[] invalid) { + NBTTagList list = raw instanceof NBTTagList ? (NBTTagList)raw : null; + invalid[3] = list == null || list.tagCount() != 3 || list.getTagType() < 1 || list.getTagType() > 6; + NBTTagList clean = new NBTTagList(); + for(int axis = 0; axis < 3; axis++) { + Double value = list != null && axis < list.tagCount() ? finiteNumber(list.get(axis)) : null; + if(value == null || Math.abs(value) > (motion ? 10D : 3.0E7D)) { + invalid[axis] = true; + // A lost height must not put recoverable inventory in the void. + value = !motion && axis == 1 ? 64D : 0D; } + clean.appendTag(new net.minecraft.nbt.NBTTagDouble(value)); } - if(root.hasKey("Aux")){ - final NBTTagList nbttaglist = root.getTagList("Aux", 10); - for (int i = 0; i < nbttaglist.tagCount(); ++i) { - this.auxInventory.addLast(new ItemStack(nbttaglist.getCompoundTagAt(i))); + return clean; + } + + /** Complete a malformed NBT position using the authoritative containing chunk. */ + public void recoverPositionInChunk(int chunkX, int chunkZ) { + if(!positionNeedsChunk) return; + positionNeedsChunk = false; + boolean wrongX = net.minecraft.util.math.MathHelper.floor(posX / 16D) != chunkX; + boolean wrongZ = net.minecraft.util.math.MathHelper.floor(posZ / 16D) != chunkZ; + if(!malformedPosition && !wrongX && !wrongZ) { loadedPosition = null; return; } + if(!malformedPosition && loadedPosition != null) retainInvalid("Pos",loadedPosition); + loadedPosition = null; + double x = missingPositionX || wrongX ? chunkX * 16D + 8.5D : posX; + double z = missingPositionZ || wrongZ ? chunkZ * 16D + 8.5D : posZ; + // Chunk.addEntity flags a location mismatch dead immediately before + // EnteringChunk. Only the first pending NBT admission may undo that + // flag; saved zero health and normal later removals remain effective. + if(wrongX || wrongZ) isDead = false; + setLocationAndAngles(x,posY,z,rotationYaw,rotationPitch); + FMLLog.warning("Lootable Bodies recovered malformed corpse position in chunk %s,%s",chunkX,chunkZ); + } + + @Override + public void readEntityFromNBT(final NBTTagCompound supplied) { + final NBTTagCompound root=UpgradePreservation.restore(supplied); + sourceVersions=root.hasKey(UpgradePreservation.SOURCES,10) ? root.getCompoundTag(UpgradePreservation.SOURCES).copy() : new NBTTagCompound(); + // Persisted Equipment/Aux are already current after the first save. + // Only the transient protected original and retained raw records use old versions. + inventoryVersions=root.getBoolean(UpgradePreservation.ITEMS_PENDING) ? sourceVersions : new NBTTagCompound(); + // Replace, rather than append to, an existing inventory on a repeated load. + this.clear(); + // These flags are not saved. A reused entity must behave like a fresh + // load; its new Health still decides whether normal death handling starts. + terminate = -1; + dead = false; + isDead = false; + // Item readers may update their input tags; the caller's saved data is immutable. + NBTTagCompound vanilla = root.copy(); + // Our historical DeathTime is a world timestamp, not vanilla's animation. + vanilla.setShort("DeathTime",(short)0); + invalidFields.clear(); + NBTBase metadata = root.getTag(INVALID_FIELDS_TAG); + if(isInventoryList(metadata)) { + NBTTagList records = (NBTTagList)metadata; + for(int i = 0; i < records.tagCount(); i++) invalidFields.add(records.getCompoundTagAt(i).copy()); + } else if(metadata != null) retainInvalid("Recovery",metadata); + // NBT replaces state. Clear the old map without effect callbacks: saved + // attributes and absorption are restored below, just as on a fresh entity. + getActivePotionEffects().clear(); + for(IAttributeInstance attribute : getAttributeMap().getAllAttributes()) restoreAttribute(attribute); + vanilla.setTag("Attributes",readAttributes(root.getTag("Attributes"))); + vanilla.setTag("ActiveEffects",readEffects(root.getTag("ActiveEffects"))); + vanilla.setShort("HurtTime",(short)readTimer(root,"HurtTime",Short.MAX_VALUE,0)); + // Health must be checked against the loaded, validated effective maximum. + vanilla.removeTag("Health"); + vanilla.setFloat("AbsorptionAmount",readLivingFloat(root,"AbsorptionAmount",0F,Float.MAX_VALUE)); + super.readEntityFromNBT(vanilla); + updatePotionMetadata(); + for(IAttributeInstance attribute : getAttributeMap().getAllAttributes()) { + if(!Double.isFinite(attribute.getAttributeValue())) { + // Also protect against engine ordering/parent-attribute arithmetic. + if(root.hasKey("Attributes")) retainInvalid("Attributes",root.getTag("Attributes")); + restoreAttribute(attribute); } } - if(root.hasKey("Vac")){ - this.vacuumTime = root.getByte("Vac"); - } else { - this.vacuumTime = VACUUM_TIMELIMIT; + setHealth(readLivingFloat(root,"Health",getMaxHealth(),getMaxHealth())); + NBTBase equipment = root.getTag("Equipment"); + if(isInventoryList(equipment)) { + // Vanilla ArmorItems/HandItems mirror these six slots. The custom + // list is authoritative when present, so do not load both copies. + for(int i = 0; i < EQUIPMENT_SLOTS.length; i++) setInventorySlotContents(i,null); + } + readInventoryTag("Equipment",equipment); + readInventoryTag("Aux",root.getTag("Aux")); + NBTBase recovery = root.getTag(UNRESOLVED_ITEMS_TAG); + if(isInventoryList(recovery)) { + NBTTagList records = (NBTTagList)recovery; + for(int i = 0; i < records.tagCount(); i++) { + NBTTagCompound record = records.getCompoundTagAt(i); + String source = record.getString("Source"); + if((record.getKeySet().size() == 2 || (record.getKeySet().size() == 3 && record.hasKey("SourceVersions",10))) + && record.hasKey("Source",8) && record.hasKey("Data",10) + && (source.equals("Equipment") || source.equals("Aux"))) { + readInventoryEntry(source,record.getCompoundTag("Data"),record); + } else unresolvedInventory.add(record.copy()); + } + } else if(recovery != null) retainRaw("Recovery",recovery); + if(!unresolvedInventory.isEmpty()) FMLLog.warning( + "Lootable Bodies retained %s unreadable inventory records for later recovery",unresolvedInventory.size()); + + long now = Math.max(0L,getEntityWorld().getTotalWorldTime()); + this.vacuumTime = (byte)readTimer(root,"Vac",VACUUM_TIMELIMIT,VACUUM_TIMELIMIT); + this.setDeathTimestamp(readTimer(root,"DeathTime",now,now)); + this.decayTimestamp = readTimer(root,"DecayTime",now,now); + String fallback = readName(root,"CustomName"); + String name = readName(root,"Name"); + this.setUserName(name != null ? name : fallback != null ? fallback : ""); + this.setGameProfile(null); + this.oldName = null; + this.setRotation(this.rotationYaw, 0); + } + + private void retainInvalid(String source, NBTBase data) { + NBTTagCompound record = new NBTTagCompound(); + record.setString("Source",source); + record.setTag("Data",data.copy()); + if(!invalidFields.contains(record)) invalidFields.add(record); + } + + private void restoreAttribute(IAttributeInstance attribute) { + String name = attribute.getAttribute().getName(); + for(AttributeModifier modifier : new ArrayList<>(attribute.getModifiers())) attribute.removeModifier(modifier); + attribute.setBaseValue(defaultAttributeBases.getOrDefault(name,attribute.getAttribute().getDefaultValue())); + List defaults = defaultAttributeModifiers.get(name); + if(defaults != null) for(AttributeModifier modifier : defaults) attribute.applyModifier(modifier); + } + + private NBTTagList readEffects(NBTBase raw) { + NBTTagList clean = new NBTTagList(); + if(raw == null) return clean; + if(!isInventoryList(raw)) { retainInvalid("ActiveEffects",raw); return clean; } + Set seen = new HashSet<>(); + NBTTagList list = (NBTTagList)raw; + for(int i = 0; i < list.tagCount(); i++) { + NBTTagCompound record = list.getCompoundTagAt(i); + NBTBase rawId = record.getTag("Id"), rawAmplifier = record.getTag("Amplifier"); + Long id = integral(rawId); + // IDs are unsigned bytes on disk. Amplifiers are read as signed + // bytes by this engine; preserve all canonical byte encodings. + if(rawId != null && rawId.getId() == 1) id = id & 255L; + Long amplifier = rawAmplifier == null ? Long.valueOf(0) : integral(rawAmplifier); + Long duration = record.hasKey("Duration") ? integral(record.getTag("Duration")) : Long.valueOf(0); + Potion potion = id != null && id >= 0 && id <= 255 ? Potion.getPotionById(id.intValue()) : null; + if(potion == null || duration == null || duration < 0 || duration > Integer.MAX_VALUE + || amplifier == null || amplifier > 127 || (amplifier < 0 && rawAmplifier.getId() != 1) + || !seen.add(potion)) { + retainInvalid("ActiveEffect",record); continue; + } + NBTTagCompound accepted = new NBTTagCompound(); + accepted.setByte("Id",id.byteValue()); accepted.setInteger("Duration",duration.intValue()); + accepted.setByte("Amplifier",amplifier.byteValue()); + boolean changed = !Arrays.asList("Id","Duration","Amplifier","Ambient","ShowParticles","CurativeItems").containsAll(record.getKeySet()); + // Forge 1.11 persists curative stacks. Preserve their complete custom + // data; an unreadable list uses Forge's defaults and stays recoverable. + if(record.hasKey("CurativeItems")) { + NBTBase rawCuratives = record.getTag("CurativeItems"); + boolean valid = isInventoryList(rawCuratives); + if(valid) { + NBTTagList items = (NBTTagList)rawCuratives; + for(int c=0; c= 0 && flag <= 1) accepted.setBoolean(key,flag == 1); + else { accepted.setBoolean(key,fallback); changed = true; } + } + if(changed) retainInvalid("ActiveEffect",record); + clean.appendTag(accepted); + } + return clean; + } + + private static Double finiteNumber(NBTBase raw) { + if(!(raw instanceof NBTPrimitive) || raw.getId() < 1 || raw.getId() > 6) return null; + double value = ((NBTPrimitive)raw).getDouble(); + return Double.isFinite(value) ? value : null; + } + + private float readLivingFloat(NBTTagCompound root, String key, float fallback, float maximum) { + NBTBase raw = root.getTag(key); + if(raw == null) return fallback; + Double value = finiteNumber(raw); + if(value != null && value >= 0 && value <= maximum) { + float narrowed = value.floatValue(); + if(Float.isFinite(narrowed) && (value == 0 || narrowed > 0)) return narrowed; + } + retainInvalid(key,raw); + return fallback; + } + + private NBTTagList readAttributes(NBTBase raw) { + NBTTagList clean = new NBTTagList(); + if(raw == null) return clean; + if(!isInventoryList(raw)) { retainInvalid("Attributes",raw); return clean; } + Set seen = new HashSet<>(); + NBTTagList list = (NBTTagList)raw; + for(int i = 0; i < list.tagCount(); i++) { + NBTTagCompound record = list.getCompoundTagAt(i); + IAttributeInstance attribute = record.hasKey("Name",8) + ? getAttributeMap().getAttributeInstanceByName(record.getString("Name")) : null; + Double base = finiteNumber(record.getTag("Base")); + if(attribute == null || base == null) { retainInvalid("Attribute",record); continue; } + String name = attribute.getAttribute().getName(); + // corpse_HP has always allowed 1..Short.MAX_VALUE. Preserve a base + // saved under an older config, including vanilla's existing cap. + boolean configuredHealth = attribute.getAttribute() == SharedMonsterAttributes.MAX_HEALTH + && base >= 1D && base <= Short.MAX_VALUE; + if((attribute.getAttribute().clampValue(base) != base + && !configuredHealth && !base.equals(defaultAttributeBases.get(name))) || !seen.add(name)) { + retainInvalid("Attribute",record); continue; + } + boolean changed = !Arrays.asList("Name","Base","Modifiers").containsAll(record.getKeySet()); + NBTTagCompound accepted = new NBTTagCompound(); + accepted.setString("Name",name); accepted.setDouble("Base",base); + NBTBase rawModifiers = record.getTag("Modifiers"); + if(rawModifiers != null) { + NBTTagList modifiers = new NBTTagList(); + if(!isInventoryList(rawModifiers)) changed = true; + else { + Set ids = new HashSet<>(); + NBTTagList entries = (NBTTagList)rawModifiers; + for(int m = 0; m < entries.tagCount(); m++) { + NBTTagCompound entry = entries.getCompoundTagAt(m); + Long operation = integral(entry.getTag("Operation")); + Long most = integral(entry.getTag("UUIDMost")), least = integral(entry.getTag("UUIDLeast")); + Double amount = finiteNumber(entry.getTag("Amount")); + if(!entry.hasKey("Name",8) || entry.getString("Name").isEmpty() || operation == null + || operation < 0 || operation > 2 || most == null || least == null || amount == null) { + changed = true; continue; + } + UUID id = new UUID(most,least); + if(ids.contains(id)) { changed = true; continue; } + NBTTagCompound modifier = new NBTTagCompound(); + modifier.setString("Name",entry.getString("Name")); modifier.setDouble("Amount",amount); + modifier.setInteger("Operation",operation.intValue()); modifier.setUniqueId("UUID",id); + NBTTagList candidate = modifiers.copy(); candidate.appendTag(modifier); + if(!finiteAttributeValue(base,candidate)) { changed = true; continue; } + ids.add(id); modifiers.appendTag(modifier); + if(!Arrays.asList("Name","Amount","Operation","UUIDMost","UUIDLeast").containsAll(entry.getKeySet())) changed = true; + } + } + accepted.setTag("Modifiers",modifiers); + } + if(changed) retainInvalid("Attribute",record); + clean.appendTag(accepted); } - if(root.hasKey("DeathTime")){ - this.setDeathTimestamp(root.getLong("DeathTime")); + return clean; + } + + private static boolean finiteAttributeValue(double base, NBTTagList modifiers) { + double value = base; + for(int i = 0; i < modifiers.tagCount(); i++) { + NBTTagCompound m = modifiers.getCompoundTagAt(i); + if(m.getInteger("Operation") == 0) value += m.getDouble("Amount"); } - if(root.hasKey("DecayTime")){ - this.decayTimestamp = root.getLong("DecayTime"); + if(!Double.isFinite(value)) return false; + double additive = value; + for(int i = 0; i < modifiers.tagCount(); i++) { + NBTTagCompound m = modifiers.getCompoundTagAt(i); + if(m.getInteger("Operation") == 1) value += additive * m.getDouble("Amount"); } - if (root.hasKey("Name")) { - this.setUserName(root.getString("Name")); + if(!Double.isFinite(value)) return false; + for(int i = 0; i < modifiers.tagCount(); i++) { + NBTTagCompound m = modifiers.getCompoundTagAt(i); + if(m.getInteger("Operation") == 2) value *= 1D + m.getDouble("Amount"); } - this.setRotation(this.rotationYaw, 0); + return Double.isFinite(value); + } + + private long readTimer(NBTTagCompound root, String key, long maximum, long fallback) { + NBTBase raw = root.getTag(key); + if(raw == null) return fallback; + Long value = integral(raw); + if(value != null && value >= 0 && value <= maximum) return value; + retainInvalid(key,raw); + return fallback; + } + + private long checkedTimestamp(String key, long timestamp, long now) { + if(timestamp >= 0 && timestamp <= now) return timestamp; + retainInvalid(key,new net.minecraft.nbt.NBTTagLong(timestamp)); + markDirty(); + return now; + } + + private String readName(NBTTagCompound root, String key) { + NBTBase raw = root.getTag(key); + if(raw == null) return null; + // Entity metadata packets accept at most 32767 UTF-8 bytes. Preserve display + // names, Unicode and formatting; do not impose an account-name pattern. + if(raw.getId() == 8 && root.getString(key).length() <= 32767 + && root.getString(key).getBytes(StandardCharsets.UTF_8).length <= 32767) { + String name = root.getString(key); + return name.trim().isEmpty() ? "" : name; + } + retainInvalid(key,raw); + return null; + } + + + private static boolean isInventoryList(NBTBase data) { + return data instanceof NBTTagList && (((NBTTagList)data).tagCount() == 0 || ((NBTTagList)data).getTagType() == 10); + } + + private void retainRaw(String source, NBTBase data) { + NBTTagCompound record = new NBTTagCompound(); + record.setString("Source",source); + record.setTag("Data",data.copy()); + if(!inventoryVersions.isEmpty()) record.setTag("SourceVersions",inventoryVersions.copy()); + unresolvedInventory.add(record); + } + + private void readInventoryTag(String source, NBTBase data) { + if(data == null) return; + if(!isInventoryList(data)) { retainRaw(source + "List",data); return; } + NBTTagList list = (NBTTagList)data; + for(int i = 0; i < list.tagCount(); i++) readInventoryEntry(source,list.getCompoundTagAt(i),null); + } + + private static Long integral(NBTBase data) { + return data instanceof NBTPrimitive && data.getId() >= 1 && data.getId() <= 4 ? ((NBTPrimitive)data).getLong() : null; + } + + private ItemStack decodeInventoryItem(NBTTagCompound raw) { + // Ambiguous fields, unavailable IDs and unhandled capability data stay + // verbatim. Never guess an item, truncate a count or erase custom data. + for(String key : raw.getKeySet()) if(!Arrays.asList("id","Count","Damage","tag","ForgeCaps","Slot").contains(key)) return null; + if(!raw.hasKey("id",8)) return null; + Long count = integral(raw.getTag("Count")); + Long damage = raw.hasKey("Damage") ? integral(raw.getTag("Damage")) : Long.valueOf(0L); + if(count == null || count <= 0 || count > 127 || damage == null || damage < 0 || damage > 32767) return null; + if((raw.hasKey("tag") && !raw.hasKey("tag",10)) || (raw.hasKey("ForgeCaps") && !raw.hasKey("ForgeCaps",10))) return null; + try { + ResourceLocation id = new ResourceLocation(raw.getString("id")); + if(!Item.REGISTRY.containsKey(id)) return null; + Item registered = Item.REGISTRY.getObject(id); + if(registered == null || Item.getIdFromItem(registered) == 0) return null; + NBTTagCompound input = raw.copy(); + input.setString("id",id.toString()); + input.setByte("Count",count.byteValue()); + input.setShort("Damage",damage.shortValue()); + ItemStack stack = new ItemStack(input); + if(emptyStack(stack) || stack.getItem() != registered || stack.getCount() != count.intValue() + || stack.getItemDamage() != damage.intValue() || stack.getMaxStackSize() <= 0) return null; + NBTTagCompound encoded = stack.writeToNBT(new NBTTagCompound()); + if(!Objects.equals(raw.getTag("tag"),encoded.getTag("tag")) + || !Objects.equals(raw.getTag("ForgeCaps"),encoded.getTag("ForgeCaps"))) return null; + return stack; + } catch(RuntimeException unreadable) { return null; } + } + + private void readInventoryEntry(String source, NBTTagCompound raw, NBTTagCompound retained) { + NBTTagCompound context=retained!=null && retained.hasKey("SourceVersions",10) ? retained.getCompoundTag("SourceVersions") : inventoryVersions; + NBTTagCompound converted=ItemMigration.convert(raw,context); + ItemStack stack = converted==null ? null : decodeInventoryItem(converted); + if(emptyStack(stack)) { + if(retained == null) retainRaw(source,raw); + else { + NBTTagCompound copy=retained.copy(); + if(!copy.hasKey("SourceVersions") && !context.isEmpty()) copy.setTag("SourceVersions",context.copy()); + unresolvedInventory.add(copy); + } + return; + } + int limit = Math.min(stack.getMaxStackSize(),getInventoryStackLimit()); + if(source.equals("Equipment")) { + Long number = integral(raw.getTag("Slot")); + if(number != null && number >= 0 && number < getSizeInventory()) { + int slot = number.intValue(); + if(emptyStack(getStackInSlot(slot)) && isItemValidForSlot(slot,stack)) { + setInventorySlotContents(slot,stack.splitStack(Math.min(stack.getCount(),slot < 4 ? 1 : limit))); + } + } + } + // Keep each record, including identical duplicates. Existing overflow + // handles later GUI recovery without overwriting any indexed slot. + while(stack.getCount() > 0) auxInventory.addLast(stack.splitStack(Math.min(stack.getCount(),limit))); } public boolean useThinArms() { diff --git a/src/main/java/cyano/lootable/events/PlayerDeathEventHandler.java b/src/main/java/cyano/lootable/events/PlayerDeathEventHandler.java index 805707f..5b1cb97 100644 --- a/src/main/java/cyano/lootable/events/PlayerDeathEventHandler.java +++ b/src/main/java/cyano/lootable/events/PlayerDeathEventHandler.java @@ -1,5 +1,7 @@ package cyano.lootable.events; +import static cyano.lootable.ItemStacks.emptyStack; + import cyano.lootable.LootableBodies; import cyano.lootable.entities.EntityLootableBody; import net.minecraft.command.server.CommandSummon; @@ -9,9 +11,9 @@ import net.minecraft.inventory.EntityEquipmentSlot; import net.minecraft.item.ItemStack; import net.minecraft.util.EnumHandSide; -import net.minecraft.util.NonNullList; import net.minecraft.world.World; import net.minecraftforge.event.entity.living.LivingDeathEvent; +import net.minecraftforge.event.entity.EntityEvent; import net.minecraftforge.event.entity.living.LivingDropsEvent; import net.minecraftforge.fml.common.FMLLog; import net.minecraftforge.fml.common.eventhandler.Event; @@ -19,24 +21,36 @@ import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; import java.util.ArrayList; -import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.WeakHashMap; +import java.util.EnumSet; +import java.util.Set; +import java.util.Collections; import java.util.List; import java.util.Map; public class PlayerDeathEventHandler { - private static final Map> equipmentCache = new HashMap<>(); + private static final Map> equipmentCache = new WeakHashMap<>(); + + @SubscribeEvent(priority= EventPriority.HIGHEST) + public void corpseEnteringChunk(EntityEvent.EnteringChunk e) { + if(e.getEntity() instanceof EntityLootableBody && !e.getEntity().getEntityWorld().isRemote) + ((EntityLootableBody)e.getEntity()).recoverPositionInChunk(e.getNewChunkX(),e.getNewChunkZ()); + } - @SubscribeEvent(priority = EventPriority.LOW) + @SubscribeEvent(priority= EventPriority.LOW, receiveCanceled = true) public void entityDeathEvent(LivingDeathEvent e){ if(e.getEntity() instanceof EntityPlayer - && e.getResult() != Event.Result.DENY && !e.getEntity().getEntityWorld().isRemote) { final EntityPlayer player = (EntityPlayer)e.getEntity(); - if(player.isSpectator()) return; - Map cache = equipmentCache.computeIfAbsent(player,(EntityPlayer p)->new HashMap<>()); + equipmentCache.remove(player); + if(e.isCanceled() || e.getResult() == Event.Result.DENY || player.isSpectator() + || player.getEntityWorld().getGameRules().getBoolean("keepInventory")) return; + Map cache = new IdentityHashMap<>(); for(EntityEquipmentSlot slot : EntityLootableBody.EQUIPMENT_SLOTS){ - cache.put(player.getItemStackFromSlot(slot),slot); + ItemStack stack = player.getItemStackFromSlot(slot); + if(!emptyStack(stack)) cache.put(stack,slot); } if(player.getPrimaryHand() == EnumHandSide.LEFT){ @@ -44,55 +58,58 @@ public void entityDeathEvent(LivingDeathEvent e){ cache.put(player.getItemStackFromSlot(EntityEquipmentSlot.MAINHAND),EntityEquipmentSlot.OFFHAND); cache.put(player.getItemStackFromSlot(EntityEquipmentSlot.OFFHAND),EntityEquipmentSlot.MAINHAND); } + equipmentCache.put(player,cache); } } - @SubscribeEvent(priority= EventPriority.LOWEST) + @SubscribeEvent(priority= EventPriority.LOWEST, receiveCanceled = true) public void entityDropEvent(LivingDropsEvent e){ if(e.getEntity() instanceof EntityPlayer - && e.getResult() != Event.Result.DENY && !e.getEntity().getEntityWorld().isRemote) { final EntityPlayer player = (EntityPlayer)e.getEntity(); - if(player.isSpectator()) return; + Map cache = equipmentCache.remove(player); + if(e.isCanceled() || e.getResult() == Event.Result.DENY || player.isSpectator() + || player.getEntityWorld().getGameRules().getBoolean("keepInventory")) return; + if(cache == null) cache = Collections.emptyMap(); final World w = player.getEntityWorld(); - Map cache = equipmentCache.computeIfAbsent(player, (EntityPlayer p) -> new HashMap<>()); EntityLootableBody corpse = new EntityLootableBody(player); - corpse.forceSpawn = true; + CommandSummon k; corpse.setUserName(player.getName()); corpse.setRotation(player.rotationYaw); - - NonNullList items = NonNullList.create(); + + List items = new ArrayList<>(); + Set assignedSlots = EnumSet.noneOf(EntityEquipmentSlot.class); for (EntityItem itemEntity : e.getDrops()) { ItemStack item = itemEntity.getItem(); - if (!item.isEmpty() && cache.containsKey(item)) { - corpse.setItemStackToSlot(cache.get(item),item); + if(emptyStack(item) || item.getCount() <= 0) continue; + EntityEquipmentSlot slot = cache.get(item); + // Stage independent stacks so a rejected spawn cannot mutate vanilla drops. + if (slot != null && assignedSlots.add(slot)) { + corpse.setItemStackToSlot(slot,item.copy()); } else { - items.add(item); + items.add(item.copy()); } } - corpse.initializeItems(items); + corpse.initializeItems(items.toArray(new ItemStack[0])); if(LootableBodies.addBonesToCorpse){ corpse.addItem(new ItemStack(Items.BONE,1+w.rand.nextInt(3))); corpse.addItem(new ItemStack(Items.ROTTEN_FLESH,1+w.rand.nextInt(3))); } - w.spawnEntity(corpse); - w.updateEntityWithOptionalForce(corpse, false); - - e.getDrops().clear(); - - equipmentCache.remove(e.getEntity()); - + if(w.spawnEntity(corpse)) { + e.getDrops().clear(); + } else { + FMLLog.warning("%s: Corpse spawn rejected; original death drops retained", LootableBodies.MODID); + } } } - - @SuppressWarnings("unused") + + private static void log(String s, Object... o){ FMLLog.info("%s: %s", LootableBodies.MODID,String.format(s,o)); } - @SuppressWarnings("unused") private static void log(Object o){ FMLLog.info("%s: %s", LootableBodies.MODID,String.valueOf(o)); } diff --git a/src/main/java/cyano/lootable/graphics/ClientConfigChanges.java b/src/main/java/cyano/lootable/graphics/ClientConfigChanges.java new file mode 100644 index 0000000..59f17d2 --- /dev/null +++ b/src/main/java/cyano/lootable/graphics/ClientConfigChanges.java @@ -0,0 +1,24 @@ +package cyano.lootable.graphics; + +import cyano.lootable.LootableBodies; +import cyano.lootable.entities.EntityLootableBody; +import net.minecraft.client.Minecraft; +import net.minecraft.entity.Entity; +import net.minecraftforge.fml.client.event.ConfigChangedEvent; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; +import net.minecraftforge.fml.relauncher.Side; +import net.minecraftforge.fml.relauncher.SideOnly; + +@SideOnly(Side.CLIENT) +public final class ClientConfigChanges { + @SubscribeEvent public void changed(ConfigChangedEvent.OnConfigChangedEvent event) { + if (!LootableBodies.MODID.equals(event.getModID())) return; + boolean wasLocalSkin = LootableBodies.useLocalSkin; + LootableBodies.saveClientConfiguration(); + Minecraft minecraft = Minecraft.getMinecraft(); + if (wasLocalSkin && !LootableBodies.useLocalSkin && minecraft.world != null) { + for (Entity entity : minecraft.world.loadedEntityList) + if (entity instanceof EntityLootableBody) ((EntityLootableBody) entity).refreshClientSkin(); + } + } +} diff --git a/src/main/java/cyano/lootable/graphics/CorpseConfigGui.java b/src/main/java/cyano/lootable/graphics/CorpseConfigGui.java new file mode 100644 index 0000000..3607c23 --- /dev/null +++ b/src/main/java/cyano/lootable/graphics/CorpseConfigGui.java @@ -0,0 +1,80 @@ +package cyano.lootable.graphics; + +import cyano.lootable.LootableBodies; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import net.minecraft.client.gui.GuiScreen; +import net.minecraft.client.resources.I18n; +import net.minecraftforge.common.config.ConfigElement; +import net.minecraftforge.common.config.Property; +import net.minecraftforge.fml.client.config.DummyConfigElement.DummyCategoryElement; +import net.minecraftforge.fml.client.config.GuiConfig; +import net.minecraftforge.fml.client.config.GuiConfigEntries; +import net.minecraftforge.fml.client.config.IConfigElement; +import net.minecraftforge.fml.relauncher.Side; +import net.minecraftforge.fml.relauncher.SideOnly; + +@SideOnly(Side.CLIENT) +public final class CorpseConfigGui extends GuiConfig { + public CorpseConfigGui(GuiScreen parent) { + super(parent, elements(), LootableBodies.MODID, false, false, LootableBodies.NAME, + I18n.format("lootablebodies.config.local_file")); + } + + private static List elements() { + List appearance = new ArrayList<>(); + appearance.add(option("options", "local_texture")); + appearance.add(option("options", "display_nametag")); + List rules = new ArrayList<>(); + for (String key : Arrays.asList("corpse_HP", "add_bones_to_corpse", "item_decay_rate")) + rules.add(option("options", key)); + for (String key : Arrays.asList("hurt_by_environment", "hurt_by_weapons", "hurt_by_misc")) + rules.add(option("corpse damage", key)); + for (String key : Arrays.asList("enable_corpse_decay", "empty_only_decay", "corpse_decay_time")) + rules.add(option("corpse decay", key)); + for (String key : Arrays.asList("enable_corpse_buoyancy", "prevent_void_loss", "dim_specific_platform")) + rules.add(option("corpse recovery", key)); + return Arrays.asList( + new DummyCategoryElement("appearance", "lootablebodies.config.appearance", appearance), + new DummyCategoryElement("rules", "lootablebodies.config.rules", rules)); + } + + private static IConfigElement option(String category, String key) { + Property property = LootableBodies.getConfiguration().getCategory(category).get(key); + if (key.equals("corpse_decay_time")) property.setConfigEntryClass(DecayTimeEntry.class); + return new ConfigElement(property); + } + + /** Only constrain new GUI input; existing file values keep their startup semantics. */ + public static boolean isValidDecayTime(String value) { + if (!value.matches("\\s*[0-9]+\\s*:\\s*[0-9]+\\s*(?::\\s*[0-9]+\\s*)?")) return false; + String[] parts = value.trim().split(":"); + long total = 0; + int[] multiplier = {3600, 60, 1}; + long limit = Integer.MAX_VALUE / 20; + try { + for (int i = 0; i < parts.length; i++) { + long part = Long.parseLong(parts[i].trim()); + if (part > limit / multiplier[i]) return false; + total += part * multiplier[i]; + } + return total <= limit; + } catch (NumberFormatException invalid) { return false; } + } + + public static final class DecayTimeEntry extends GuiConfigEntries.StringEntry { + public DecayTimeEntry(GuiConfig screen, GuiConfigEntries entries, IConfigElement element) { + super(screen, entries, element); + isValidValue = isValidDecayTime(textFieldValue.getText()); + } + @Override public void keyTyped(char character, int key) { + super.keyTyped(character, key); + isValidValue = isValidDecayTime(textFieldValue.getText()); + } + @Override public boolean saveConfigElement() { + // Keep the previous value on invalid input instead of Forge's default reset. + return isValidDecayTime(textFieldValue.getText()) && super.saveConfigElement(); + } + } +} diff --git a/src/main/java/cyano/lootable/graphics/CorpseConfigGuiFactory.java b/src/main/java/cyano/lootable/graphics/CorpseConfigGuiFactory.java new file mode 100644 index 0000000..901386b --- /dev/null +++ b/src/main/java/cyano/lootable/graphics/CorpseConfigGuiFactory.java @@ -0,0 +1,18 @@ +package cyano.lootable.graphics; + +import java.util.Collections; +import java.util.Set; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiScreen; +import net.minecraftforge.fml.client.IModGuiFactory; +import net.minecraftforge.fml.relauncher.Side; +import net.minecraftforge.fml.relauncher.SideOnly; + +/** The regular Forge Mods-list Config button entry point. */ +@SideOnly(Side.CLIENT) +public final class CorpseConfigGuiFactory implements IModGuiFactory { + @Override public void initialize(Minecraft minecraft) { } + @Override public boolean hasConfigGui() { return true; } + @Override public GuiScreen createConfigGui(GuiScreen parent) { return new CorpseConfigGui(parent); } + @Override public Set runtimeGuiCategories() { return Collections.emptySet(); } +} diff --git a/src/main/java/cyano/lootable/graphics/CorpseContainer.java b/src/main/java/cyano/lootable/graphics/CorpseContainer.java index 6c3895b..bca9590 100644 --- a/src/main/java/cyano/lootable/graphics/CorpseContainer.java +++ b/src/main/java/cyano/lootable/graphics/CorpseContainer.java @@ -1,16 +1,23 @@ package cyano.lootable.graphics; +import static cyano.lootable.ItemStacks.emptyStack; + import cyano.lootable.entities.EntityLootableBody; import net.minecraft.entity.Entity; +import net.minecraft.entity.item.EntityItem; import net.minecraft.entity.player.EntityPlayer; +import net.minecraft.entity.player.EntityPlayerMP; import net.minecraft.entity.player.InventoryPlayer; import net.minecraft.inventory.EntityEquipmentSlot; import net.minecraft.inventory.IInventory; import net.minecraft.inventory.Slot; +import net.minecraft.inventory.ClickType; import net.minecraft.item.ItemArmor; import net.minecraft.item.ItemStack; import net.minecraftforge.fml.relauncher.Side; import net.minecraftforge.fml.relauncher.SideOnly; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.event.entity.item.ItemTossEvent; /** * Created by Chris on 4/12/2016. @@ -20,8 +27,11 @@ public class CorpseContainer extends net.minecraft.inventory.Container { private static final EntityEquipmentSlot[] EQUIPMENT_SLOTS = EntityLootableBody.EQUIPMENT_SLOTS; private final IInventory targetInventory; + private boolean dragging; public CorpseContainer(InventoryPlayer playerItems, IInventory entity){ this.targetInventory = entity; + net.minecraft.inventory.ContainerHorseInventory k; + net.minecraft.inventory.ContainerPlayer h; int index = 0; while(index < 4) { // armor @@ -34,7 +44,7 @@ public int getSlotStackLimit() { @Override public boolean isItemValid(ItemStack item) { - return super.isItemValid(item) && item.getItem().isValidArmor(item, EQUIPMENT_SLOTS[i], (Entity) targetInventory); + return !emptyStack(item) && item.getCount() > 0 && super.isItemValid(item) && item.getItem().isValidArmor(item, EQUIPMENT_SLOTS[i], (Entity) targetInventory); } @SideOnly(Side.CLIENT) public String getSlotTexture() @@ -77,12 +87,194 @@ public boolean canInteractWith(EntityPlayer entityplayer) { return targetInventory.isUsableByPlayer(entityplayer); } + @Override + public ItemStack slotClick(int slotId, int button, ClickType type, EntityPlayer player) { + // A click can arrive before the next player tick closes a stale window. + if(!canInteractWith(player)) { + resetDrag(); + return ItemStack.EMPTY; + } + if(type != ClickType.QUICK_CRAFT && dragging) { + resetDrag(); + return ItemStack.EMPTY; + } + if(type == ClickType.QUICK_CRAFT && getDragEvent(button) == 0 + && !emptyStack(player.inventory.getItemStack()) && isValidDragMode(extractDragMode(button),player)) dragging = true; + if(type == ClickType.THROW && emptyStack(player.inventory.getItemStack()) + && slotId >= 0 && slotId < inventorySlots.size()) { + Slot slot = getSlot(slotId); + if(slot.getHasStack() && slot.canTakeStack(player)) { + ItemStack original = slot.getStack().copy(); + ItemStack removed = slot.decrStackSize(button == 0 ? 1 : original.getCount()); + slot.onTake(player,removed); + if(!tryRelease(player,removed,true)) { + slot.putStack(original); + resync(player); + } + } + return ItemStack.EMPTY; + } + if((type == ClickType.PICKUP || type == ClickType.QUICK_MOVE) && slotId == -999 && (button == 0 || button == 1)) { + ItemStack cursor = player.inventory.getItemStack(); + if(!emptyStack(cursor)) { + ItemStack original = cursor.copy(); + ItemStack removed = cursor.splitStack(button == 0 ? cursor.getCount() : 1); + if(cursor.getCount() == 0) player.inventory.setItemStack(ItemStack.EMPTY); + if(!tryRelease(player,removed,true)) { + player.inventory.setItemStack(original); + resync(player); + } + } + return ItemStack.EMPTY; + } + if(type == ClickType.SWAP && button >= 0 && button < 9 + && slotId >= 0 && slotId < targetInventory.getSizeInventory()) { + Slot slot = getSlot(slotId); + ItemStack incoming = player.inventory.getStackInSlot(button); + ItemStack displaced = slot.getStack(); + if(!emptyStack(incoming) && !emptyStack(displaced) && slot.canTakeStack(player) && slot.isItemValid(incoming)) { + int limit = Math.min(slot.getItemStackLimit(incoming),incoming.getMaxStackSize()); + if(limit > 0 && incoming.getCount() > limit) { + slot.putStack(incoming.splitStack(limit)); + slot.onTake(player,displaced); + storeInPlayerInventory(player.inventory,displaced); + if(displaced.getCount() > 0) releaseOrRetain(player,displaced); + return ItemStack.EMPTY; + } + } + } + return super.slotClick(slotId,button,type,player); + } + + @Override + public void onContainerClosed(EntityPlayer player) { + ItemStack cursor = player.inventory.getItemStack(); + if(!emptyStack(cursor)) { + releaseOrRetain(player,cursor); + player.inventory.setItemStack(ItemStack.EMPTY); + } + } + + @Override + protected void resetDrag() { + dragging = false; + super.resetDrag(); + } + + private void resync(EntityPlayer player) { + // Accepted transaction replies assume client prediction succeeded. A + // rejected toss needs an explicit source/cursor correction as well. + if(player instanceof EntityPlayerMP) ((EntityPlayerMP)player).sendContainerToPlayer(this); + } + + private void releaseOrRetain(EntityPlayer player, ItemStack stack) { + if(emptyStack(stack) || stack.getCount() <= 0 || tryRelease(player,stack,false)) return; + ItemStack remainder = stack.copy(); + // InventoryPlayer discards overflow in creative mode. Retain real + // corpse/cursor items even when every player slot is full. + storeInPlayerInventory(player.inventory,remainder); + if(remainder.getCount() > 0) ((EntityLootableBody)targetInventory).retainItem(remainder); + } + + private void storeInPlayerInventory(InventoryPlayer inventory, ItemStack remainder) { + // Keep vanilla's selected-slot, off-hand, main-slot and first-empty + // priority, without InventoryPlayer's creative overflow discard. + while(remainder.getCount() > 0) { + int destination = -1; + if(!remainder.isItemDamaged()) { + if(canStore(inventory,inventory.currentItem,remainder)) destination = inventory.currentItem; + else if(canStore(inventory,40,remainder)) destination = 40; + else for(int i = 0; i < inventory.mainInventory.size(); i++) { + if(canStore(inventory,i,remainder)) { destination = i; break; } + } + } + if(destination < 0) destination = inventory.getFirstEmptyStack(); + if(destination < 0) return; + ItemStack current = inventory.getStackInSlot(destination); + int limit = Math.min(remainder.getMaxStackSize(),inventory.getInventoryStackLimit()); + int amount = Math.min(remainder.getCount(),Math.max(0,limit - (emptyStack(current) ? 0 : current.getCount()))); + if(amount == 0) return; + if(emptyStack(current)) { + inventory.setInventorySlotContents(destination,remainder.splitStack(amount)); + current = inventory.getStackInSlot(destination); + } else { + current.grow(amount); + remainder.shrink(amount); + } + current.setAnimationsToGo(5); + inventory.markDirty(); + } + } + + private boolean canStore(InventoryPlayer inventory, int slot, ItemStack incoming) { + ItemStack current = inventory.getStackInSlot(slot); + return !emptyStack(current) && current.isStackable() && current.getItem() == incoming.getItem() + && (!current.getHasSubtypes() || current.getMetadata() == incoming.getMetadata()) + && ItemStack.areItemStackTagsEqual(current,incoming) + && current.getCount() < Math.min(current.getMaxStackSize(),inventory.getInventoryStackLimit()); + } + + private boolean tryRelease(EntityPlayer player, ItemStack stack, boolean includeName) { + if(emptyStack(stack) || stack.getCount() <= 0) return false; + if(player.world.isRemote) { + player.dropItem(stack.copy(),includeName); + return true; + } + // Forge's legacy toss helper clears the caller's captured drops and + // ignores spawn rejection. Preserve both ownership and the toss event. + boolean capturing = player.captureDrops; + EntityItem dropped; + try { + player.captureDrops = true; + dropped = player.dropItem(stack.copy(),false,includeName); + if(dropped != null) player.capturedDrops.remove(dropped); + } finally { + player.captureDrops = capturing; + } + if(dropped == null) return false; + ItemTossEvent event = new ItemTossEvent(dropped,player); + if(MinecraftForge.EVENT_BUS.post(event)) return false; + if(capturing) { + player.capturedDrops.add(event.getEntityItem()); + return true; + } + return player.world.spawnEntity(event.getEntityItem()); + } + + @Override + protected boolean mergeItemStack(ItemStack stack, int start, int end, boolean reverse) { + boolean changed = false; + int step = reverse ? -1 : 1; + for(int pass = 0; pass < 2 && stack.getCount() > 0; pass++) { + for(int i = reverse ? end - 1 : start; i >= start && i < end && stack.getCount() > 0; i += step) { + Slot slot = getSlot(i); + if(!slot.isItemValid(stack)) continue; + ItemStack current = slot.getStack(); + int limit = Math.min(slot.getItemStackLimit(stack),stack.getMaxStackSize()); + if(pass == 0 && stack.isStackable() && !emptyStack(current) + && ItemStack.areItemsEqual(stack,current) && ItemStack.areItemStackTagsEqual(stack,current)) { + int amount = Math.min(stack.getCount(),Math.max(0,limit - current.getCount())); + if(amount > 0) { + current.grow(amount); + stack.shrink(amount); + slot.onSlotChanged(); + changed = true; + } + } else if(pass == 1 && emptyStack(current) && limit > 0) { + slot.putStack(stack.splitStack(Math.min(stack.getCount(),limit))); + changed = true; + } + } + } + return changed; + } + @Override public ItemStack transferStackInSlot(EntityPlayer player, int slot) { int hostSize = targetInventory.getSizeInventory(); ItemStack stack = ItemStack.EMPTY; - Slot slotObject = inventorySlots.get(slot); + Slot slotObject = (Slot) inventorySlots.get(slot); //null checks and checks if the item can be stacked (maxStackSize > 1) if (slotObject != null && slotObject.getHasStack()) { ItemStack stackInSlot = slotObject.getStack(); diff --git a/src/main/java/cyano/lootable/graphics/CorpseRenderer.java b/src/main/java/cyano/lootable/graphics/CorpseRenderer.java index 8bc69c3..9b58af9 100644 --- a/src/main/java/cyano/lootable/graphics/CorpseRenderer.java +++ b/src/main/java/cyano/lootable/graphics/CorpseRenderer.java @@ -2,7 +2,6 @@ import com.mojang.authlib.GameProfile; import com.mojang.authlib.minecraft.MinecraftProfileTexture; -import com.mojang.authlib.minecraft.MinecraftProfileTexture.Type; import cyano.lootable.LootableBodies; import cyano.lootable.entities.EntityLootableBody; import net.minecraft.client.Minecraft; @@ -82,20 +81,28 @@ protected ResourceLocation getEntityTexture(EntityLootableBody entity) { } public static ResourceLocation getSkin(GameProfile profile) { + ResourceLocation fallback = profile == null || profile.getId() == null + ? DefaultPlayerSkin.getDefaultSkinLegacy() : DefaultPlayerSkin.getDefaultSkin(profile.getId()); + if(profile == null || profile.getId() == null) return fallback; + try { final Minecraft minecraft = Minecraft.getMinecraft(); - final Map loadSkinFromCache = minecraft.getSkinManager().loadSkinFromCache(profile); // returned map may or may not be typed + final Map loadSkinFromCache = minecraft.getSkinManager().loadSkinFromCache(profile); // returned map may or may not be typed if (loadSkinFromCache.containsKey(MinecraftProfileTexture.Type.SKIN)) { ResourceLocation skin = minecraft.getSkinManager().loadSkin((MinecraftProfileTexture) loadSkinFromCache.get(MinecraftProfileTexture.Type.SKIN), MinecraftProfileTexture.Type.SKIN); return skin; } else { - return DefaultPlayerSkin.getDefaultSkin(profile.getId()); + return fallback; + } + } catch(RuntimeException unavailable) { + // A service/cache failure must not abort renderModel and hide the body. + return fallback; } } @Override public void doRender(EntityLootableBody entity, double x, double y, double z, float yaw, float partialTick) { - if (net.minecraftforge.common.MinecraftForge.EVENT_BUS.post(new net.minecraftforge.client.event.RenderLivingEvent.Pre(entity, this, partialTick, x, y, z))) + if (net.minecraftforge.common.MinecraftForge.EVENT_BUS.post(new net.minecraftforge.client.event.RenderLivingEvent.Pre(entity, this, x, y, z))) return; this.setModel(entity.useThinArms()); @@ -175,7 +182,7 @@ public void doRender(EntityLootableBody entity, double x, double y, double z, fl this.renderName(entity, x, y, z); } - net.minecraftforge.common.MinecraftForge.EVENT_BUS.post(new net.minecraftforge.client.event.RenderLivingEvent.Post(entity, this, partialTick, x, y, z)); + net.minecraftforge.common.MinecraftForge.EVENT_BUS.post(new net.minecraftforge.client.event.RenderLivingEvent.Post(entity, this, x, y, z)); this.setModel(true); } diff --git a/src/main/java/cyano/lootable/upgrade/ChunkVersionContext.java b/src/main/java/cyano/lootable/upgrade/ChunkVersionContext.java new file mode 100644 index 0000000..32b3183 --- /dev/null +++ b/src/main/java/cyano/lootable/upgrade/ChunkVersionContext.java @@ -0,0 +1,51 @@ +package cyano.lootable.upgrade; + +import cyano.lootable.LootableBodies; +import java.lang.reflect.Field; +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.util.datafix.DataFixer; +import net.minecraft.util.datafix.FixTypes; +import net.minecraft.util.datafix.IFixType; +import net.minecraft.world.WorldServer; +import net.minecraft.world.chunk.storage.AnvilChunkLoader; +import net.minecraft.world.chunk.storage.IChunkLoader; +import net.minecraftforge.event.world.WorldEvent; +import net.minecraftforge.fml.common.Mod; +import net.minecraftforge.fml.common.eventhandler.EventPriority; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; +import net.minecraftforge.fml.relauncher.ReflectionHelper; + +/** Supplies preserved source context before the Anvil loader invokes Forge's fixers. */ +@Mod.EventBusSubscriber(modid=LootableBodies.MODID) +public final class ChunkVersionContext { + private ChunkVersionContext() { } + + @SubscribeEvent(priority=EventPriority.HIGHEST) + public static void load(WorldEvent.Load event) { + if(!(event.getWorld() instanceof WorldServer))return; + IChunkLoader loader=((WorldServer)event.getWorld()).getChunkProvider().chunkLoader; + if(!(loader instanceof AnvilChunkLoader))return; + try { + // Forge has no setter for this pinned 1.12 Anvil boundary. World.Load + // precedes spawn preparation and later dimension chunk loading. + Field field=ReflectionHelper.findField(AnvilChunkLoader.class,"fixer","field_193416_e"); + DataFixer delegate=(DataFixer)field.get(loader); + if(!(delegate instanceof ContextFixer))field.set(loader,new ContextFixer(delegate)); + } catch(ReflectiveOperationException failure) { + throw new IllegalStateException("Lootable Bodies could not install safe chunk version handling",failure); + } + } + + private static final class ContextFixer extends DataFixer { + private final DataFixer delegate; + ContextFixer(DataFixer delegate){super(UpgradePreservation.MINECRAFT_VERSION);this.delegate=delegate;} + @Override public NBTTagCompound process(IFixType type,NBTTagCompound input) { + NBTTagCompound result=delegate.process(type,type==FixTypes.CHUNK?UpgradePreservation.chunkContext(input):input); + if(type==FixTypes.CHUNK)UpgradePreservation.clearChunkContext(result); + return result; + } + @Override public NBTTagCompound process(IFixType type,NBTTagCompound input,int version) { + return delegate.process(type,input,version); + } + } +} diff --git a/src/main/java/cyano/lootable/upgrade/ItemMigration.java b/src/main/java/cyano/lootable/upgrade/ItemMigration.java new file mode 100644 index 0000000..22a9283 --- /dev/null +++ b/src/main/java/cyano/lootable/upgrade/ItemMigration.java @@ -0,0 +1,123 @@ +package cyano.lootable.upgrade; + +import java.util.*; +import net.minecraft.item.Item; +import net.minecraft.nbt.*; +import net.minecraft.util.ResourceLocation; +import net.minecraft.util.datafix.*; +import net.minecraftforge.common.util.CompoundDataFixer; + +/** Runs item fixers on copies with the original source-version context. */ +public final class ItemMigration { + private enum Envelope implements IFixType { ITEM } + private static CompoundDataFixer pipeline; + private ItemMigration() { } + + static void register(CompoundDataFixer fixer) { + pipeline=fixer; + fixer.registerVanillaWalker(Envelope.ITEM,(nested,envelope,version)-> { + if(!envelope.getBoolean("Done")) { + envelope.setBoolean("Done",true); + envelope.setTag("Item",nested.process(FixTypes.ITEM_INSTANCE,envelope.getCompoundTag("Item"),version)); + } + return envelope; + }); + } + + /** Null means that conversion is ambiguous; retain the original raw record. */ + public static NBTTagCompound convert(NBTTagCompound raw,NBTTagCompound versions) { + if(versions==null || versions.isEmpty()) return raw.copy(); + if(versions.getBoolean("Ambiguous")) { + NBTTagCompound agreed=null; + for(int candidate:versions.getIntArray("CandidateDataVersions")) { + NBTTagCompound context=versions.copy();context.removeTag("Ambiguous");context.removeTag("CandidateDataVersions");context.setInteger("DataVersion",candidate); + NBTTagCompound converted=convert(raw,context); + if(converted==null || (agreed!=null && !agreed.equals(converted)))return null; + agreed=converted; + } + return agreed; + } + if(pipeline==null || !versions.hasKey("DataVersion",3)) return null; + int source=versions.getInteger("DataVersion"); + if(source<512 || source>1343 || integral(raw.getTag("Count"))==null) return null; + NBTTagCompound item=raw.copy(); + try { + NBTTagCompound current=new NBTTagCompound();pipeline.writeVersionData(current); + NBTTagCompound targets=current.getCompoundTag("ForgeDataVersion"); + List mods=new ArrayList<>(targets.getKeySet());Collections.sort(mods);mods.remove("minecraft");mods.add(0,"minecraft"); + // CompoundDataFixer invokes its global walkers once per outdated mod. + // Run one version context at a time so a nested banner/container is + // not converted twice merely because this mod also has a data fixer. + for(String mod:mods) { + if(mod.equals("lootablebodies") || mod.equals("lootablebodies_upgrade")) continue; + NBTTagCompound observed=versions.getCompoundTag("ForgeDataVersion"); + int old=mod.equals("minecraft") ? source : observed.hasKey(mod,3) ? observed.getInteger(mod) : -1; + if(old>=targets.getInteger(mod)) continue; + NBTTagCompound envelope=current.copy();envelope.setInteger("DataVersion",1343); + envelope.getCompoundTag("ForgeDataVersion").setInteger(mod,old); + if(mod.equals("minecraft")) envelope.setInteger("DataVersion",old); + envelope.setTag("Item",item); + item=pipeline.process(Envelope.ITEM,envelope).getCompoundTag("Item"); + } + if(!Objects.equals(integral(raw.getTag("Count")),integral(item.getTag("Count"))) + || !Objects.equals(raw.getTag("Slot"),item.getTag("Slot")) + || !Objects.equals(raw.getTag("ForgeCaps"),item.getTag("ForgeCaps"))) return null; + Long damage=raw.hasKey("Damage") ? integral(raw.getTag("Damage")) : Long.valueOf(0); + Long converted=item.hasKey("Damage") ? integral(item.getTag("Damage")) : Long.valueOf(0); + if(!Objects.equals(damage,converted)) { + NBTTagCompound banner=raw.getCompoundTag("tag").getCompoundTag("BlockEntityTag"); + boolean bannerFix=raw.getString("id").equals("minecraft:banner") && source<804 && banner.hasKey("Base",99) + && converted!=null && converted==(banner.getShort("Base")&15); + boolean bedFix=raw.getString("id").equals("minecraft:bed") && source<1125 + && Long.valueOf(0).equals(damage) && Long.valueOf(14).equals(converted); + if(!bannerFix && !bedFix)return null; + } + for(String key:raw.getKeySet()) if(!Arrays.asList("id","Count","Damage","Slot","tag").contains(key) + && !Objects.equals(raw.getTag(key),item.getTag(key))) return null; + if(!knownNestedItems(item) || !nestedInventoriesSurvive(raw,item)) return null; + return item; + } catch(RuntimeException ambiguous) {return null;} + } + + private static Long integral(NBTBase nbt) { + return nbt instanceof NBTPrimitive && nbt.getId()>=1 && nbt.getId()<=4 ? ((NBTPrimitive)nbt).getLong() : null; + } + private static boolean knownNestedItems(NBTBase tag) { + if(tag instanceof NBTTagCompound) { + NBTTagCompound compound=(NBTTagCompound)tag; + if(compound.hasKey("id") && compound.hasKey("Count")) { + if(!compound.hasKey("id",8)) return false; + ResourceLocation id=new ResourceLocation(compound.getString("id")); + if(!Item.REGISTRY.containsKey(id) || Item.getIdFromItem(Item.REGISTRY.getObject(id))==0) return false; + } + for(String key:compound.getKeySet()) if(!key.equals("ForgeCaps") && !knownNestedItems(compound.getTag(key))) return false; + } else if(tag instanceof NBTTagList) { + NBTTagList list=(NBTTagList)tag;for(int i=0;i event) { + for(RegistryEvent.MissingMappings.Mapping mapping:event.getAllMappings()) + if(mapping.key.equals(OLD_XP_SOUND)) mapping.remap(event.getRegistry().getValue(XP_SOUND)); + } +} diff --git a/src/main/java/cyano/lootable/upgrade/UpgradeJournal.java b/src/main/java/cyano/lootable/upgrade/UpgradeJournal.java new file mode 100644 index 0000000..42d0d8c --- /dev/null +++ b/src/main/java/cyano/lootable/upgrade/UpgradeJournal.java @@ -0,0 +1,347 @@ +package cyano.lootable.upgrade; + +import java.io.*; +import java.nio.channels.FileChannel; +import java.nio.file.*; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.*; +import java.util.regex.*; +import java.util.stream.Stream; +import java.util.zip.*; +import net.minecraft.nbt.*; + +/** Read-only Anvil inspection and immutable, checksummed corpse originals. */ +public final class UpgradeJournal { + private static final int FORMAT=2; + private static final long MAX_NBT=64L*1024*1024; + private static final Pattern REGION=Pattern.compile("r\\.(-?[0-9]+)\\.(-?[0-9]+)\\.mca"); + private final Path world, directory, snapshots, chunks; + private final Map active=new HashMap<>(); + + private UpgradeJournal(Path world) throws IOException { + this.world=world.toRealPath(); + directory=this.world.resolve("data/lootablebodies-upgrade"); + snapshots=directory.resolve("snapshots"); + chunks=directory.resolve("chunks"); + } + + /** Complete preservation before Minecraft opens any existing chunk. */ + public static UpgradeJournal scan(Path world) throws IOException { + Files.createDirectories(world); + UpgradeJournal journal=new UpgradeJournal(world); + journal.inspect(); + return journal; + } + + public int size() { return active.size(); } + + /** Only an entity already present in this chunk may consume its original. */ + public NBTTagCompound originalFor(UUID id,int chunkX,int chunkZ) throws IOException { + Path path=active.get(id); + if(path==null) return null; + NBTTagCompound original=readSnapshot(path); + return original.getInteger("ChunkX")==chunkX && original.getInteger("ChunkZ")==chunkZ ? original : null; + } + + NBTTagCompound originalFor(UUID id) throws IOException { + Path path=active.get(id); + return path==null ? null : readSnapshot(path); + } + + static boolean corpse(NBTTagCompound entity) { + String id=entity.getString("id"); + return id.equals("lootablebodies:corpse") || id.equals("lootablebodies.Corpse"); + } + + private void inspect() throws IOException { + // Reject links rather than silently skipping a linked dimension or writing + // a recovery journal outside this world. No region is opened in rw mode. + List regions=new ArrayList<>(); + if(Files.isSymbolicLink(directory) || Files.isSymbolicLink(world.resolve("data"))) + throw new IOException("Lootable Bodies upgrade journal must be inside the world"); + try(Stream paths=Files.walk(world)) { + for(Iterator it=paths.iterator();it.hasNext();) { + Path path=it.next(); + if(Files.isSymbolicLink(path)) throw new IOException("Cannot preserve a world containing linked paths: "+world.relativize(path)); + if(Files.isRegularFile(path) && path.getParent().getFileName().toString().equals("region") + && REGION.matcher(path.getFileName().toString()).matches()) regions.add(path); + } + } + regions.sort(Comparator.comparing(Path::toString)); + Files.createDirectories(snapshots); + Files.createDirectories(chunks); + // Old originals remain an integrity-checked recovery archive even after + // their corpses have been looted or removed. They are never reinserted. + try(DirectoryStream files=Files.newDirectoryStream(snapshots,"*.nbt")) { + for(Path path:files) readSnapshot(path); + } + Map previous=readIndex(); + NBTTagCompound worldVersions=readWorldVersions(); + NBTTagList updated=new NBTTagList(); + for(Path region:regions) { + String relative=world.relativize(region).toString().replace('\\','/'); + String hash=fileDigest(region); + NBTTagCompound record=previous.get(relative); + if(record==null || !hash.equals(record.getString("Digest"))) + record=scanRegion(region,relative,hash,worldVersions,record); + else record=record.copy(); + NBTTagList originals=record.getTagList("Snapshots",8); + for(int i=0;i readIndex() throws IOException { + Map records=new HashMap<>(); + Path index=directory.resolve("index.nbt"); + if(!Files.exists(index)) return records; + NBTTagCompound envelope=readCompressed(index); + byte[] payload=envelope.getByteArray("Payload"); + if(!envelope.hasKey("Format",3) || envelope.getInteger("Format")!=FORMAT + || !envelope.hasKey("Digest",8) || !envelope.hasKey("Payload",7) + || !digest(payload).equals(envelope.getString("Digest"))) + throw new IOException("Lootable Bodies upgrade index checksum mismatch"); + NBTTagCompound nbt; + try(DataInputStream input=new DataInputStream(new GZIPInputStream(new ByteArrayInputStream(payload)))) { + nbt=CompressedStreamTools.read(input,new NBTSizeTracker(MAX_NBT)); + } + if(!nbt.hasKey("Format",3) || nbt.getInteger("Format")!=FORMAT || !compoundList(nbt.getTag("Regions"))) + throw new IOException("Invalid Lootable Bodies upgrade index"); + NBTTagList regions=nbt.getTagList("Regions",10); + for(int i=0;i0 && list.getTagType()!=8) throw new IOException("Invalid recovery snapshot list"); + if(records.put(record.getString("Path"),record)!=null) throw new IOException("Duplicate recovery region record"); + for(int j=0;jInteger.MAX_VALUE/32 || rzInteger.MAX_VALUE/32) + throw new IOException("Region coordinates out of range"); + NBTTagList preserved=new NBTTagList(); + Map prior=new HashMap<>(); + Map priorLevels=new HashMap<>(); + Map matchingChunks=new HashMap<>(); + if(previous!=null) { + NBTTagList originals=previous.getTagList("Snapshots",8); + for(int i=0;iUpgradePreservation.chunkSourceVersion(selected.getCompoundTag("SourceVersions")))prior.put(key,original); + } + } + try(RandomAccessFile input=new RandomAccessFile(file.toFile(),"r")) { + long length=input.length(); + if(length<8192 || length%4096!=0) throw new IOException("Incomplete region: "+relative); + int[] offsets=new int[1024];for(int i=0;i<1024;i++) offsets[i]=input.readInt(); + Set allocated=new HashSet<>();allocated.add(0);allocated.add(1); + for(int i=0;i<1024;i++) { + if(offsets[i]==0) continue; + int sector=offsets[i]>>>8,count=offsets[i]&255; + if(sector<2 || count==0 || ((long)sector+count)*4096>length) throw new IOException("Invalid region allocation: "+relative); + for(int s=sector;s(count*4096-4)) throw new IOException("Invalid compressed chunk size: "+relative); + byte[] payload=new byte[bytes-1];input.readFully(payload); + InputStream stream=compression==1 ? new GZIPInputStream(new ByteArrayInputStream(payload)) : + compression==2 ? new InflaterInputStream(new ByteArrayInputStream(payload)) : null; + if(stream==null) throw new IOException("Unknown chunk compression: "+relative); + NBTTagCompound chunk; + try(DataInputStream nbt=new DataInputStream(stream)) { chunk=CompressedStreamTools.read(nbt,new NBTSizeTracker(MAX_NBT)); } + catch(RuntimeException damaged) { throw new IOException("Unreadable chunk in "+relative,damaged); } + if(!chunk.hasKey("Level",10)) throw new IOException("Missing chunk Level: "+relative); + NBTTagCompound level=chunk.getCompoundTag("Level");int x=(int)rx*32+(i%32),z=(int)rz*32+(i/32); + if(!level.hasKey("xPos",3) || !level.hasKey("zPos",3) || level.getInteger("xPos")!=x || level.getInteger("zPos")!=z) + throw new IOException("Chunk coordinate mismatch in "+relative); + if(!level.hasKey("Entities")) continue; + if(!compoundList(level.getTag("Entities"))) throw new IOException("Unreadable entity list in "+relative); + NBTTagList entities=level.getTagList("Entities",10); + String originalChunk=digest(payload); + for(int e=0;eUpgradePreservation.DATA_VERSION) + throw new IOException("Corpse uses a newer preservation format"); + NBTTagCompound snapshot=new NBTTagCompound();snapshot.setInteger("Format",FORMAT);snapshot.setString("Region",relative);snapshot.setString("RegionDigest",hash); + Path chunkFile=chunks.resolve(originalChunk+".bin"); + if(!Files.exists(chunkFile)) atomic(chunkFile,payload,false); + else if(!Arrays.equals(payload,Files.readAllBytes(chunkFile))) throw new IOException("Original chunk checksum collision"); + snapshot.setString("OriginalChunk",originalChunk);snapshot.setByte("OriginalCompression",(byte)compression);snapshot.setInteger("EntityIndex",e); + snapshot.setString("Dimension",world.relativize(file.getParent().getParent()).toString().replace('\\','/')); + snapshot.setInteger("ChunkX",x);snapshot.setInteger("ChunkZ",z);snapshot.setTag("Entity",entity.copy()); + NBTTagCompound versions=sourceVersions(chunk,worldVersions,entity),priorSnapshot=prior.get(chunkKey(x,z)); + if(priorSnapshot!=null) { + String savedChunk=priorSnapshot.getString("OriginalChunk"); + NBTTagCompound oldLevel=priorLevels.get(savedChunk); + if(oldLevel==null) { + byte[] originalBytes=Files.readAllBytes(chunks.resolve(savedChunk+".bin")); + InputStream originalStream=priorSnapshot.getByte("OriginalCompression")==1?new GZIPInputStream(new ByteArrayInputStream(originalBytes)):new InflaterInputStream(new ByteArrayInputStream(originalBytes)); + try(DataInputStream originalInput=new DataInputStream(originalStream)){oldLevel=CompressedStreamTools.read(originalInput,new NBTSizeTracker(MAX_NBT)).getCompoundTag("Level");} + priorLevels.put(savedChunk,oldLevel); + } + // Only metadata outside Level may differ. Never let an older + // journal replace changed terrain, inventory or live entities. + long key=chunkKey(x,z);Boolean same=matchingChunks.get(key); + if(same==null){same=sameNbt(oldLevel,level);matchingChunks.put(key,same);} + if(same)versions.setInteger("VerifiedChunkDataVersion",UpgradePreservation.chunkSourceVersion(priorSnapshot.getCompoundTag("SourceVersions"))); + } + snapshot.setTag("SourceVersions",versions); + byte[] original=compressed(snapshot);String filename=digest(original)+".nbt";Path destination=snapshotPath(filename); + if(Files.exists(destination)) {if(!Arrays.equals(original,Files.readAllBytes(destination))) throw new IOException("Recovery snapshot collision");} + else atomic(destination,original,false); + if(entity.hasKey("LootableBodiesUpgradePayload") || entity.hasKey("LootableBodiesInventorySource")) + throw new IOException("Unexpected intermediate corpse migration data on disk; original chunk has been preserved"); + preserved.appendTag(new NBTTagString(filename)); + } + } + } catch(RuntimeException damaged) { throw new IOException("Cannot safely preserve "+relative,damaged); } + NBTTagCompound record=new NBTTagCompound();record.setString("Path",relative);record.setString("Digest",hash);record.setTag("Snapshots",preserved);return record; + } + + private NBTTagCompound readWorldVersions() throws IOException { + NBTTagCompound context=new NBTTagCompound(); + Path level=world.resolve("level.dat"); + if(Files.isRegularFile(level)) { + NBTTagCompound root=readCompressed(level),data=root.getCompoundTag("Data"); + if(data.hasKey("DataVersion")) context.setTag("WorldDataVersion",data.getTag("DataVersion").copy()); + if(root.hasKey("FML")) context.setTag("WorldFML",root.getTag("FML").copy()); + else if(data.hasKey("FML")) context.setTag("WorldFML",data.getTag("FML").copy()); + } + return context; + } + + private static NBTTagCompound sourceVersions(NBTTagCompound chunk,NBTTagCompound world,NBTTagCompound entity) { + NBTTagCompound context=world.copy(); + if(chunk.hasKey("DataVersion")) context.setTag("ChunkDataVersion",chunk.getTag("DataVersion").copy()); + if(chunk.hasKey("ForgeDataVersion")) context.setTag("ForgeDataVersion",chunk.getTag("ForgeDataVersion").copy()); + int version=chunk.hasKey("DataVersion",3) ? chunk.getInteger("DataVersion") : -1; + if(version<512 || version>1343) version=world.hasKey("WorldDataVersion",3) ? world.getInteger("WorldDataVersion") : -1; + // Dotted IDs and our own per-corpse markers identify known source families. + // A lowercase ID alone cannot distinguish original 1.11 and 1.12 saves. + boolean legacy=entity.getString("id").equals("lootablebodies.Corpse"); + int marker=entity.hasKey(UpgradePreservation.VERSION,3) ? entity.getInteger(UpgradePreservation.VERSION) : 0; + if(marker==1) version=922; + else if(marker==UpgradePreservation.DATA_VERSION) version=1343; + if(legacy) {if(version!=512)context.setBoolean("Inferred",true);version=512;} + int family=-1; + NBTTagList mods=world.getCompoundTag("WorldFML").getTagList("ModList",10); + for(int i=0;i1343) { + version=family;context.setBoolean("Inferred",true); + } else if(marker==0 && !legacy && family>=512 && family<1125 && version>family) { + // An original older writer cannot have emitted a newer item format. + // Its retained FML metadata disambiguates a falsely current stamp. + version=family;context.setBoolean("Inferred",true); + } + if(version<512 || version>1343) { + // Keep ambiguous records retriable without guessing a bed colour or + // discarding known items whose conversion agrees in both families. + context.setBoolean("Ambiguous",true);context.setIntArray("CandidateDataVersions",new int[]{922,1343});version=922; + } + context.setInteger("DataVersion",version);return context; + } + + private static long chunkKey(int x,int z){return ((long)x<<32)|(z&0xffffffffL);} + private static boolean sameNbt(NBTBase a,NBTBase b) { + if(a==null || b==null)return a==b; + if(a.getId()!=b.getId())return false; + if(a instanceof NBTTagDouble)return Double.doubleToRawLongBits(((NBTTagDouble)a).getDouble())==Double.doubleToRawLongBits(((NBTTagDouble)b).getDouble()); + if(a instanceof NBTTagFloat)return Float.floatToRawIntBits(((NBTTagFloat)a).getFloat())==Float.floatToRawIntBits(((NBTTagFloat)b).getFloat()); + if(a instanceof NBTTagCompound) { + NBTTagCompound x=(NBTTagCompound)a,y=(NBTTagCompound)b; + if(!x.getKeySet().equals(y.getKeySet()))return false; + for(String key:x.getKeySet())if(!sameNbt(x.getTag(key),y.getTag(key)))return false; + return true; + } + if(a instanceof NBTTagList) { + NBTTagList x=(NBTTagList)a,y=(NBTTagList)b; + if(x.getTagType()!=y.getTagType() || x.tagCount()!=y.tagCount())return false; + for(int i=0;i { + NBTTagCompound level=chunk.getCompoundTag("Level");NBTTagList entities=level.getTagList("Entities",10); + for(int i=0;i {protect(entity,0,0,false);return entity;}); + } + + /** Called by FMLServerAboutToStartEvent, before loadAllWorlds on both sides. */ + public static UpgradeJournal prepare(Path world) throws IOException { + UpgradeJournal complete=UpgradeJournal.scan(world); + journal=complete;return complete; + } + public static void close() {journal=null;} + + /** Resolve damaged chunk stamps before Forge captures its immutable version map. */ + public static NBTTagCompound chunkContext(NBTTagCompound chunk) { + if(journal==null) return chunk; + NBTTagCompound level=chunk.getCompoundTag("Level"); + NBTTagList entities=level.getTagList("Entities",10);int source=-1; + try { + for(int i=0;i0) { + int stamped=versions.hasKey("ChunkDataVersion",3)?versions.getInteger("ChunkDataVersion"):-1; + version=stamped>=512 && stamped<=1343?Math.min(stamped,family):family; + } + return version; + } + + public static void clearChunkContext(NBTTagCompound chunk){chunk.removeTag(CONTEXT);} + + private static NBTTagCompound repairContext(NBTTagCompound chunk) { + if(journal==null || chunk.getBoolean(CONTEXT)) return chunk; + NBTTagCompound level=chunk.getCompoundTag("Level"); + NBTTagList entities=level.getTagList("Entities",10); + int source=-1; + try { + for(int i=0;i entries = zip.entries(); + int locales = 0; + while (entries.hasMoreElements()) { + java.util.zip.ZipEntry entry = entries.nextElement(); + if (!entry.getName().endsWith(".lang")) continue; + byte[] bytes; + try (java.io.InputStream input = zip.getInputStream(entry); java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream()) { + byte[] buffer = new byte[1024]; int count; + while ((count = input.read(buffer)) != -1) out.write(buffer, 0, count); + bytes = out.toByteArray(); + } + StandardCharsets.UTF_8.newDecoder().decode(java.nio.ByteBuffer.wrap(bytes)); + assertArrayEquals(Files.readAllBytes(Paths.get("build/resources/main", entry.getName())), bytes, archive + ": " + entry.getName()); + locales++; + } + assertEquals(14, locales, archive); + } + } + } + @Test void originalIdentityAndDeclaredReleaseRemainConsistent() throws Exception { + String source = text("src/main/java/cyano/lootable/LootableBodies.java"); + assertTrue(source.contains("MODID = \"lootablebodies\"")); + Properties release = new Properties(); + try (java.io.InputStream input = Files.newInputStream(Paths.get("gradle.properties"))) { release.load(input); } + assertTrue(source.contains("VERSION = \"" + release.getProperty("mod_version") + "\"")); + assertTrue(source.contains("String idName = \"Corpse\"")); + assertTrue(source.contains("new net.minecraft.util.ResourceLocation(\"lootablebodies:corpse\")")); + assertTrue(text("LICENSE-CC0").startsWith("CC0 1.0 Universal")); + } + @Test void processedResourcesHaveNoTemplatesOrDuplicateMetadata() throws Exception { + String info = text("build/resources/main/mcmod.info"); + assertTrue(info.contains("\"modid\": \"lootablebodies\"")); + Properties release = new Properties(); + try (java.io.InputStream input = Files.newInputStream(Paths.get("gradle.properties"))) { release.load(input); } + assertTrue(info.contains("\"version\": \"" + release.getProperty("mod_version") + "\"")); + assertTrue(info.contains("\"mcversion\": \"1.12.2\"")); + assertTrue(info.contains("Cyanobacterium")); + assertFalse(info.contains("${")); + assertFalse(Files.exists(Paths.get("mcmod.info"))); + assertFalse(Files.exists(Paths.get("resources/mcmod.info"))); + assertTrue(text("build/resources/main/pack.mcmeta").contains("\"pack_format\":3")); + } + @Test void legacyLocalesRetainEntityTranslation() throws Exception { + try (DirectoryStream locales = Files.newDirectoryStream(Paths.get("build/resources/main/assets/lootablebodies/lang"), "*.lang")) { + int count = 0; + for (Path locale : locales) { + assertTrue(locale.getFileName().toString().matches("[a-z]{2}_[a-z]{2}\\.lang")); + String value = new String(Files.readAllBytes(locale), StandardCharsets.UTF_8); + assertTrue(value.startsWith("entity.lootablebodies.Corpse.name="), locale.toString()); + assertFalse(value.contains("\uFFFD")); + count++; + } + assertEquals(14, count); + } + } + @Test void corpseSaveKeysAndSlotOrderRemainStable() throws Exception { + String source = text("src/main/java/cyano/lootable/entities/EntityLootableBody.java"); + for (String key : Arrays.asList("Equipment", "Aux", "Slot", "Vac", "Name", "DeathTime", "DecayTime")) + assertTrue(source.contains("\"" + key + "\""), key); + assertTrue(source.contains("NonNullList.withSize(6*7, ItemStack.EMPTY)")); + assertTrue(source.contains("EntityEquipmentSlot.HEAD, EntityEquipmentSlot.CHEST, EntityEquipmentSlot.LEGS, EntityEquipmentSlot.FEET, EntityEquipmentSlot.MAINHAND, EntityEquipmentSlot.OFFHAND")); + } + @Test void configurationNamesAndDefaultsRemainStable() throws Exception { + String source = text("src/main/java/cyano/lootable/LootableBodies.java"); + for (String key : Arrays.asList("corpse_HP", "local_texture", "display_nametag", "add_bones_to_corpse", "hurt_by_environment", "hurt_by_weapons", "hurt_by_misc", "item_decay_rate", "enable_corpse_decay", "empty_only_decay", "corpse_decay_time")) + assertTrue(source.contains("\"" + key + "\""), key); + assertTrue(source.contains("5F, -1F, 1e9F")); + assertTrue(source.contains("\"1:00:00\"")); + assertTrue(source.contains("addBonesToCorpse = true")); + assertTrue(source.contains("useLocalSkin = false")); + } +} diff --git a/src/test/java/cyano/lootable/contracts/ConfigScreenTest.java b/src/test/java/cyano/lootable/contracts/ConfigScreenTest.java new file mode 100644 index 0000000..b8d7ba7 --- /dev/null +++ b/src/test/java/cyano/lootable/contracts/ConfigScreenTest.java @@ -0,0 +1,120 @@ +package cyano.lootable.contracts; + +import cyano.lootable.LootableBodies; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.*; +import net.minecraftforge.common.config.Configuration; +import net.minecraftforge.common.config.Property; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.io.TempDir; +import static org.junit.jupiter.api.Assertions.*; + +class ConfigScreenTest { + @TempDir Path directory; + private Object previousHome; + @BeforeEach void prepareForgeConfig() throws Exception { + java.lang.reflect.Field home = net.minecraftforge.fml.relauncher.FMLInjectionData.class.getDeclaredField("minecraftHome"); + home.setAccessible(true); previousHome = home.get(null); home.set(null, directory.toFile()); + } + @AfterEach void restoreForgeConfig() throws Exception { + java.lang.reflect.Field home = net.minecraftforge.fml.relauncher.FMLInjectionData.class.getDeclaredField("minecraftHome"); + home.setAccessible(true); home.set(null, previousHome); + } + + private Configuration load(String contents) throws Exception { + Path file = directory.resolve("lootablebodies.cfg"); + Files.write(file, contents.getBytes(StandardCharsets.UTF_8)); + LootableBodies.class.getMethod("loadConfiguration", File.class).invoke(null, file.toFile()); + return (Configuration) LootableBodies.class.getMethod("getConfiguration").invoke(null); + } + + private void save() throws Exception { + LootableBodies.class.getMethod("saveClientConfiguration").invoke(null); + } + + @Test void modsListUsesAClientOnlyFactory() throws Exception { + String source = new String(Files.readAllBytes(Paths.get( + "src/main/java/cyano/lootable/LootableBodies.java")), StandardCharsets.UTF_8); + assertTrue(source.contains("guiFactory = \"cyano.lootable.graphics.CorpseConfigGuiFactory\"")); + assertFalse(source.contains("import net.minecraft.client")); + } + + @Test void existingKeysHaveCorrectRestartPolicyAndDefaults() throws Exception { + // Other tests deliberately alter the static gameplay fields. + LootableBodies.corpseHP = 20; LootableBodies.addBonesToCorpse = true; + LootableBodies.hurtByEnvironment = LootableBodies.hurtByAttacks = LootableBodies.hurtByMisc = false; + LootableBodies.allowCorpseDecay = LootableBodies.decayOnlyWhenEmpty = true; + LootableBodies.useLocalSkin = false; LootableBodies.displayNameTag = true; + Configuration config = load(""); + assertEquals(new TreeSet<>(Arrays.asList("options", "corpse damage", "corpse decay", "corpse recovery")), config.getCategoryNames()); + int count = 0; + for (String category : config.getCategoryNames()) for (Property property : config.getCategory(category).values()) { + boolean appearance = property.getName().equals("local_texture") || property.getName().equals("display_nametag"); + assertEquals(!appearance, property.requiresMcRestart(), property.getName()); + assertEquals(!appearance, property.requiresWorldRestart(), property.getName()); + assertTrue(property.getLanguageKey().startsWith("lootablebodies.config.")); + count++; + } + assertEquals(14, count); + assertFalse(LootableBodies.corpseBuoyancy);assertFalse(LootableBodies.voidPlatform);assertTrue(LootableBodies.dimensionSpecificPlatform); + assertEquals(6000, LootableBodies.ticksPerItemDecay); + assertEquals(72000, LootableBodies.corpseDecayTime); + assertEquals("1:00:00", config.getCategory("corpse decay").get("corpse_decay_time").getString()); + assertTrue(LootableBodies.completelyInvulnerable); + } + + @Test void appearanceSaveDoesNotActivatePendingGameplayChanges() throws Exception { + Configuration config = load("options {\n D:corpse_HP=37\n D:item_decay_rate=7\n B:local_texture=false\n B:display_nametag=true\n}\n" + + "\"corpse decay\" {\n S:corpse_decay_time=2:03:04\n}\n"); + config.getCategory("options").get("local_texture").set(true); + config.getCategory("options").get("display_nametag").set(false); + config.getCategory("options").get("corpse_HP").set(99); + config.getCategory("options").get("item_decay_rate").set(0); + config.getCategory("corpse decay").get("corpse_decay_time").set("0:00:10"); + save(); + assertTrue(LootableBodies.useLocalSkin); assertFalse(LootableBodies.displayNameTag); + assertEquals(37, LootableBodies.corpseHP); + assertEquals(8400, LootableBodies.ticksPerItemDecay); + assertEquals((2 * 3600 + 3 * 60 + 4) * 20, LootableBodies.corpseDecayTime); + Configuration disk = new Configuration(directory.resolve("lootablebodies.cfg").toFile()); disk.load(); + assertEquals(99, disk.getCategory("options").get("corpse_HP").getDouble()); + LootableBodies.class.getMethod("loadConfiguration", File.class).invoke(null, directory.resolve("lootablebodies.cfg").toFile()); + assertEquals(99, LootableBodies.corpseHP); + assertEquals(0, LootableBodies.ticksPerItemDecay); + assertEquals(200, LootableBodies.corpseDecayTime); + } + + @Test void savingPreservesUnknownOptionsAndLegacyCategories() throws Exception { + Configuration config = load("options {\n S:pack_note=keep this\n}\ncustom {\n I:example=42\n}\n"); + config.getCategory("options").get("local_texture").set(true); save(); + Configuration disk = new Configuration(directory.resolve("lootablebodies.cfg").toFile()); disk.load(); + assertEquals("keep this", disk.getCategory("options").get("pack_note").getString()); + assertEquals(42, disk.getCategory("custom").get("example").getInt()); + assertEquals(3, disk.getCategory("corpse damage").size()); + } + + @Test void timeInputRejectsMalformedAndOverflowingValues() throws Exception { + java.lang.reflect.Method validate = Class.forName("cyano.lootable.graphics.CorpseConfigGui") + .getMethod("isValidDecayTime", String.class); + for (String valid : Arrays.asList("1:00:00", "0:00", " 1 : 2 : 3 ", "0:120", "29826:9:42")) + assertEquals(true, validate.invoke(null, valid), valid); + for (String invalid : Arrays.asList("", "garbage", "1", "1:2:3:4", "1:-2", "NaN:00", "1.5:00", + "999999999999999999999:00", "29826:9:43", "0:0:2147483647")) + assertEquals(false, validate.invoke(null, invalid), invalid); + } + + @Test void englishLabelsCoverAllVisibleOptions() throws Exception { + Configuration config = load(""); + Properties language = new Properties(); + try (java.io.Reader reader = Files.newBufferedReader(Paths.get( + "build/resources/main/assets/lootablebodies/lang/en_us.lang"), StandardCharsets.UTF_8)) { language.load(reader); } + for (String category : config.getCategoryNames()) for (Property property : config.getCategory(category).values()) { + assertNotNull(language.getProperty(property.getLanguageKey()), property.getName()); + assertNotNull(language.getProperty(property.getLanguageKey() + ".tooltip"), property.getName()); + } + } +} diff --git a/src/test/java/cyano/lootable/contracts/CorpseTranslationTest.java b/src/test/java/cyano/lootable/contracts/CorpseTranslationTest.java new file mode 100644 index 0000000..5ffb0fa --- /dev/null +++ b/src/test/java/cyano/lootable/contracts/CorpseTranslationTest.java @@ -0,0 +1,31 @@ +package cyano.lootable.contracts; + +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.Properties; +import java.util.stream.Stream; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.TestFactory; +import static org.junit.jupiter.api.Assertions.*; + +class CorpseTranslationTest { + @TestFactory Stream everyLocaleTranslatesTheRegisteredEntityName() throws Exception { + Path folder = Paths.get("build/resources/main/assets/lootablebodies/lang"); + try (Stream files = Files.list(folder)) { + Path[] locales = files.filter(p -> p.toString().endsWith(".lang")).sorted().toArray(Path[]::new); + assertEquals(14, locales.length); + return Stream.of(locales).map(locale -> DynamicTest.dynamicTest(locale.getFileName().toString(), () -> { + Properties language = new Properties(); + try (Reader reader = Files.newBufferedReader(locale, StandardCharsets.UTF_8)) { language.load(reader); } + String original = language.getProperty("entity.lootablebodies.Corpse.name"); + assertNotNull(original, "Retain the original translation"); + assertEquals(original, language.getProperty("entity.lootablebodies.corpse.name")); + // EntityList.getEntityString returns the registered display name + // Corpse; the namespaced registry/save ID is a separate value. + assertEquals(original, language.getProperty("entity.Corpse.name"), + "Nameless corpses and their spawn eggs must resolve the actual runtime key"); + })); + } + } +} diff --git a/src/test/java/cyano/lootable/contracts/ReleaseContractTest.java b/src/test/java/cyano/lootable/contracts/ReleaseContractTest.java new file mode 100644 index 0000000..dba7b11 --- /dev/null +++ b/src/test/java/cyano/lootable/contracts/ReleaseContractTest.java @@ -0,0 +1,86 @@ +package cyano.lootable.contracts; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.Properties; +import javax.xml.parsers.DocumentBuilderFactory; +import org.junit.jupiter.api.Test; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import static org.junit.jupiter.api.Assertions.*; + +class ReleaseContractTest { + private Properties properties() throws Exception { + Properties values = new Properties(); + try (InputStream input = Files.newInputStream(Paths.get("gradle.properties"))) { + values.load(input); + } + return values; + } + + private String text(String path) throws Exception { + return new String(Files.readAllBytes(Paths.get(path)), StandardCharsets.UTF_8); + } + + @Test void releaseVersionEncodesTheActualMinecraftAndLoader() throws Exception { + Properties values = properties(); + String version = values.getProperty("mod_version"); + assertTrue(version.matches("[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+"), version); + assertEquals("2.4.3.112021", version); + assertEquals("forge", values.getProperty("loader_name")); + assertEquals("1", values.getProperty("loader_code")); + assertTrue(text("src/main/java/cyano/lootable/LootableBodies.java") + .contains("VERSION = \"" + version + "\"")); + } + + @Test void mavenIdentityIsIndependentOfSavedEntityIdentity() throws Exception { + Properties values = properties(); + assertEquals("zone.moddev.mc.lootablebodies", values.getProperty("mod_group")); + assertEquals("LootableBodies", values.getProperty("mod_artifact")); + assertEquals("lootablebodies", values.getProperty("mod_id")); + assertEquals("240968", values.getProperty("curseforge_project_id")); + String source = text("src/main/java/cyano/lootable/LootableBodies.java"); + assertTrue(source.startsWith("package cyano.lootable;")); + assertTrue(source.contains("String idName = \"Corpse\"")); + assertTrue(source.contains("acceptedMinecraftVersions = \"[1.12.2]\"")); + } + + @Test void maintainedReleaseHasLgplAndRetainsTheOriginalNotice() throws Exception { + assertTrue(text("LICENSE").contains("GNU LESSER GENERAL PUBLIC LICENSE")); + assertTrue(text("LICENSE").contains("Version 2.1, February 1999")); + assertTrue(text("LICENSE-CC0").startsWith("CC0 1.0 Universal")); + assertTrue(text("NOTICE").contains("Cyanobacterium")); + assertEquals("LGPL-2.1-only", properties().getProperty("mod_license")); + } + + @Test void pomDescribesThisModWithoutBundlingRuntimeOrTestingDependencies() throws Exception { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + Document pom = factory.newDocumentBuilder().parse("build/publications/mavenJava/pom-default.xml"); + Properties values = properties(); + assertEquals(values.getProperty("mod_group"), pom.getElementsByTagName("groupId").item(0).getTextContent()); + assertEquals(values.getProperty("mod_artifact"), pom.getElementsByTagName("artifactId").item(0).getTextContent()); + assertEquals(values.getProperty("mod_version"), pom.getElementsByTagName("version").item(0).getTextContent()); + assertEquals(values.getProperty("mod_license_name"), ((Element) pom.getElementsByTagName("license").item(0)) + .getElementsByTagName("name").item(0).getTextContent()); + assertEquals(0, pom.getElementsByTagName("dependency").getLength()); + assertTrue(text("build/publications/mavenJava/pom-default.xml").contains("Cyanobacterium")); + } + + @Test void productionMetadataLinksToTheMaintainedProject() throws Exception { + Properties values = properties(); + assertEquals("https://github.com/MinecraftModDevelopmentMods/LootableBodies", values.getProperty("mod_url")); + assertTrue(text("build/resources/main/mcmod.info").contains(values.getProperty("mod_url"))); + assertTrue(text("README.md").contains("LootableBodies-" + values.getProperty("mod_version") + ".jar")); + } + + @Test void adoptedModCreditsTheOriginalAuthorAndMaintainer() throws Exception { + assertEquals("Cyanobacterium,SkyBlade1978", properties().getProperty("mod_authors")); + String metadata = text("build/resources/main/mcmod.info"); + assertTrue(metadata.contains("\"authorList\": [\"Cyanobacterium\", \"SkyBlade1978\"]")); + assertTrue(text("build/publications/mavenJava/pom-default.xml").contains("SkyBlade1978")); + assertTrue(text("README.md").contains("SkyBlade1978")); + } +} diff --git a/src/test/java/cyano/lootable/contracts/UpgradeJournalTest.java b/src/test/java/cyano/lootable/contracts/UpgradeJournalTest.java new file mode 100644 index 0000000..faf01b5 --- /dev/null +++ b/src/test/java/cyano/lootable/contracts/UpgradeJournalTest.java @@ -0,0 +1,137 @@ +package cyano.lootable.contracts; + +import java.io.*; +import java.lang.reflect.*; +import java.nio.file.*; +import java.security.MessageDigest; +import java.util.*; +import java.util.zip.DeflaterOutputStream; +import net.minecraft.nbt.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import static org.junit.jupiter.api.Assertions.*; + +/** Synthetic region files test the preservation boundary, not old-world qualification. */ +class UpgradeJournalTest { + @TempDir Path world; + private static final UUID ID=UUID.fromString("00000000-1111-4111-8111-000000000001"); + private Object scan() throws Exception { + try { return Class.forName("cyano.lootable.upgrade.UpgradeJournal").getMethod("scan",Path.class).invoke(null,world); } + catch(InvocationTargetException error) { throw (Exception)error.getCause(); } + } + private NBTTagCompound original(Object journal,UUID id,int x,int z) throws Exception { + return (NBTTagCompound)journal.getClass().getMethod("originalFor",UUID.class,int.class,int.class).invoke(journal,id,x,z); + } + private NBTTagCompound body(UUID id) { + NBTTagCompound body=new NBTTagCompound();body.setString("id","lootablebodies.Corpse");body.setUniqueId("UUID",id); + body.setDouble("FallDistance",Double.NaN);body.setString("Name","Saved owner");body.setLong("DeathTime",700); + NBTTagList equipment=new NBTTagList(); + for(int i=0;i<48;i++) {NBTTagCompound item=new NBTTagCompound();item.setString("id","minecraft:diamond");item.setByte("Count",(byte)(i+1));item.setShort("Damage",(short)i);item.setByte("Slot",(byte)i);NBTTagCompound custom=new NBTTagCompound();custom.setString("keepsake","slot-"+i);item.setTag("tag",custom);equipment.appendTag(item);} + body.setTag("Equipment",equipment); + NBTTagCompound record=new NBTTagCompound();record.setString("Source","Equipment");record.setTag("Data",equipment.getCompoundTagAt(47).copy()); + NBTTagList recovery=new NBTTagList();recovery.appendTag(record);body.setTag("LootableBodiesUnresolved",recovery); + return body; + } + private Path region(String dimension,NBTTagCompound... bodies) throws Exception { + return regionVersion(dimension,512,bodies); + } + private Path regionVersion(String dimension,int version,NBTTagCompound... bodies) throws Exception { + NBTTagCompound chunk=new NBTTagCompound(),level=new NBTTagCompound();if(version>=0)chunk.setInteger("DataVersion",version);chunk.setTag("Level",level);level.setInteger("xPos",0);level.setInteger("zPos",0); + NBTTagList entities=new NBTTagList();for(NBTTagCompound body:bodies)entities.appendTag(body.copy());level.setTag("Entities",entities); + ByteArrayOutputStream bytes=new ByteArrayOutputStream(); + try(DataOutputStream compressed=new DataOutputStream(new DeflaterOutputStream(bytes))){CompressedStreamTools.write(chunk,compressed);} + int sectors=(bytes.size()+5+4095)/4096;byte[] contents=new byte[8192+sectors*4096]; + java.nio.ByteBuffer buffer=java.nio.ByteBuffer.wrap(contents);buffer.putInt((2<<8)|sectors);buffer.position(8192);buffer.putInt(bytes.size()+1);buffer.put((byte)2);buffer.put(bytes.toByteArray()); + Path region=world.resolve(dimension).resolve("region/r.0.0.mca");Files.createDirectories(region.getParent());Files.write(region,contents);return region; + } + private byte[] encoded(NBTTagCompound nbt) throws Exception {ByteArrayOutputStream bytes=new ByteArrayOutputStream();CompressedStreamTools.write(nbt,new DataOutputStream(bytes));return bytes.toByteArray();} + @Test void native112VersionIsNotDowngradedTo111() throws Exception { + NBTTagCompound b=body(ID);b.setString("id","lootablebodies:corpse");regionVersion("",1343,b); + assertEquals(1343,original(scan(),ID,0,0).getCompoundTag("SourceVersions").getInteger("DataVersion")); + } + @Test void current112MarkerCanBePreservedAndReloaded() throws Exception { + NBTTagCompound b=body(ID);b.setString("id","lootablebodies:corpse");b.setInteger("LootableBodiesDataVersion",2);regionVersion("",1343,b); + assertNotNull(original(scan(),ID,0,0));assertNotNull(original(scan(),ID,0,0)); + } + @Test void nativeIdentityWithoutAnyVersionEvidenceIsExplicitlyAmbiguous() throws Exception { + NBTTagCompound b=body(ID);b.setString("id","lootablebodies:corpse");regionVersion("",-1,b); + assertTrue(original(scan(),ID,0,0).getCompoundTag("SourceVersions").getBoolean("Ambiguous")); + } + @Test void old111MarkerProvesItsOwnSourceDespiteAnIncorrectNewChunkStamp() throws Exception { + NBTTagCompound b=body(ID);b.setString("id","lootablebodies:corpse");b.setInteger("LootableBodiesDataVersion",1);regionVersion("",1343,b); + assertEquals(922,original(scan(),ID,0,0).getCompoundTag("SourceVersions").getInteger("DataVersion")); + } + @Test void rootFmlMetadataDistinguishesAnUnstampedOriginal112World() throws Exception { + NBTTagCompound b=body(ID);b.setString("id","lootablebodies:corpse");regionVersion("",-1,b); + NBTTagCompound root=new NBTTagCompound(),fml=new NBTTagCompound(),mod=new NBTTagCompound();mod.setString("ModId","lootablebodies");mod.setString("ModVersion","2.4.0");NBTTagList mods=new NBTTagList();mods.appendTag(mod);fml.setTag("ModList",mods);root.setTag("FML",fml);root.setTag("Data",new NBTTagCompound()); + try(OutputStream output=Files.newOutputStream(world.resolve("level.dat"))){CompressedStreamTools.writeCompressed(root,output);} + NBTTagCompound context=original(scan(),ID,0,0).getCompoundTag("SourceVersions");assertTrue(context.getInteger("DataVersion")>=1139);assertFalse(context.getBoolean("Ambiguous"));assertEquals(fml,context.getCompoundTag("WorldFML")); + } + @Test void unmarkedOriginal111CorpseWithFalselyNewStampStillUses111ItemRules() throws Exception { + NBTTagCompound b=body(ID);b.setString("id","lootablebodies:corpse");regionVersion("",1343,b); + NBTTagCompound root=new NBTTagCompound(),fml=new NBTTagCompound(),mod=new NBTTagCompound();mod.setString("ModId","lootablebodies");mod.setString("ModVersion","2.3.0");NBTTagList mods=new NBTTagList();mods.appendTag(mod);fml.setTag("ModList",mods);root.setTag("FML",fml);root.setTag("Data",new NBTTagCompound()); + try(OutputStream output=Files.newOutputStream(world.resolve("level.dat"))){CompressedStreamTools.writeCompressed(root,output);} + assertEquals(922,original(scan(),ID,0,0).getCompoundTag("SourceVersions").getInteger("DataVersion")); + } + @Test void stampOnlyChangesRetainPreviouslyVerifiedChunkVersion() throws Exception { + NBTTagCompound b=body(ID);regionVersion("",512,b);scan(); + regionVersion("",-1,b); + NBTTagCompound versions=original(scan(),ID,0,0).getCompoundTag("SourceVersions"); + assertEquals(512,versions.getInteger("VerifiedChunkDataVersion")); + } + @Test void changedChunkPayloadCannotReusePreviousVersionEvidence() throws Exception { + NBTTagCompound b=body(ID);regionVersion("",512,b);scan(); + NBTTagCompound added=new NBTTagCompound();added.setString("id","minecraft:pig"); + regionVersion("",-1,b,added); + assertFalse(original(scan(),ID,0,0).getCompoundTag("SourceVersions").hasKey("VerifiedChunkDataVersion")); + } + private List snapshots() throws Exception {try(java.util.stream.Stream files=Files.walk(world.resolve("data/lootablebodies-upgrade/snapshots"))){List result=new ArrayList<>();files.filter(p->p.toString().endsWith(".nbt")).forEach(result::add);return result;}} + @Test void preservesCompleteTypedPayloadWithoutWritingSourceRegions() throws Exception { + NBTTagCompound body=body(ID);Path region=region("",body);byte[] before=Files.readAllBytes(region);java.nio.file.attribute.FileTime stamp=Files.getLastModifiedTime(region); + Object journal=scan();NBTTagCompound saved=original(journal,ID,0,0); + assertArrayEquals(encoded(body),encoded(saved.getCompoundTag("Entity")));assertEquals(48,saved.getCompoundTag("Entity").getTagList("Equipment",10).tagCount()); + assertEquals(512,saved.getCompoundTag("SourceVersions").getInteger("DataVersion")); + assertArrayEquals(before,Files.readAllBytes(region));assertEquals(stamp,Files.getLastModifiedTime(region)); + } + @Test void repeatedScanReusesOriginalBackups() throws Exception {region("",body(ID));scan();List first=snapshots();scan();assertEquals(first,snapshots());} + @Test void changedRegionsRetainOriginalsWithoutResurrectingRemovedBodies() throws Exception { + region("",body(ID));scan();List first=snapshots();UUID replacement=UUID.fromString("00000000-1111-4111-8111-000000000002");region("",body(replacement));Object journal=scan(); + assertNull(original(journal,ID,0,0));assertNotNull(original(journal,replacement,0,0));assertTrue(snapshots().containsAll(first));assertEquals(2,snapshots().size()); + } + @Test void staleIndexCannotOverwriteNewInventory() throws Exception { + NBTTagCompound body=body(ID);region("",body);scan();Path index=world.resolve("data/lootablebodies-upgrade/index.nbt");byte[] stale=Files.readAllBytes(index); + body.getTagList("Equipment",10).getCompoundTagAt(40).setByte("Count",(byte)99);region("",body);scan();Files.write(index,stale); + assertArrayEquals(encoded(body),encoded(original(scan(),ID,0,0).getCompoundTag("Entity"))); + } + @Test void interruptedTemporaryWriteIsNeverTrusted() throws Exception { + region("",body(ID));Path partial=world.resolve("data/lootablebodies-upgrade/.pending-interrupted");Files.createDirectories(partial.getParent());Files.write(partial,new byte[]{31,-117,8}); + assertNotNull(original(scan(),ID,0,0));assertArrayEquals(new byte[]{31,-117,8},Files.readAllBytes(partial)); + } + @Test void corruptBackupAbortsPreservation() throws Exception {region("",body(ID));scan();Path snapshot=snapshots().get(0);Files.write(snapshot,new byte[]{0,1,2});assertThrows(IOException.class,()->scan());} + @Test void corruptIndexAbortsPreservation() throws Exception {region("",body(ID));scan();Files.write(world.resolve("data/lootablebodies-upgrade/index.nbt"),new byte[]{0,1,2});assertThrows(IOException.class,()->scan());} + @Test void readableIndexDamageCannotSilentlyOmitPreservedCorpses() throws Exception { + region("",body(ID));scan();Path path=world.resolve("data/lootablebodies-upgrade/index.nbt"); + NBTTagCompound index=CompressedStreamTools.readCompressed(Files.newInputStream(path)); + if(index.hasKey("Payload",7)) {byte[] payload=index.getByteArray("Payload");payload[payload.length/2]^=1;index.setByteArray("Payload",payload);} + else index.getTagList("Regions",10).getCompoundTagAt(0).setTag("Snapshots",new NBTTagList()); + try(OutputStream output=Files.newOutputStream(path)){CompressedStreamTools.writeCompressed(index,output);} + assertThrows(IOException.class,()->scan(),"A readable damaged index must not be accepted as a complete scan"); + } + @Test void truncatedRegionAbortsBeforeLoading() throws Exception {Path region=region("",body(ID));Files.write(region,new byte[8193]);assertThrows(IOException.class,()->scan());} + @Test void duplicateUuidAcrossDimensionsAbortsInsteadOfGuessing() throws Exception {region("",body(ID));region("DIM-1",body(ID));assertThrows(IOException.class,()->scan());} + @Test void newDimensionsAreScannedOnLaterStarts() throws Exception {region("",body(ID));scan();UUID id=UUID.fromString("00000000-1111-4111-8111-000000000003");region("DIM1",body(id));assertEquals("DIM1",original(scan(),id,0,0).getString("Dimension"));} + @Test void chunkCoordinateMismatchCannotApplyAnOldPayload() throws Exception {region("",body(ID));assertNull(original(scan(),ID,32,0));} + @Test void retainsExactCompressedChunkAsWellAsDecodedCorpse() throws Exception { + Path region=region("",body(ID));byte[] source=Files.readAllBytes(region);int size=java.nio.ByteBuffer.wrap(source,8192,4).getInt(); + byte[] originalChunk=Arrays.copyOfRange(source,8197,8192+4+size);NBTTagCompound snapshot=original(scan(),ID,0,0); + assertTrue(snapshot.hasKey("OriginalChunk",8),"A decoded NBT copy cannot retain every original byte encoding"); + assertEquals(2,snapshot.getByte("OriginalCompression"));assertEquals(0,snapshot.getInteger("EntityIndex")); + assertArrayEquals(originalChunk,Files.readAllBytes(world.resolve("data/lootablebodies-upgrade/chunks/"+snapshot.getString("OriginalChunk")+".bin"))); + } + @Test void diskCannotSupplyAProcessedTemporaryPayload() throws Exception { + NBTTagCompound body=body(ID),fake=body(ID);fake.setInteger("LootableBodiesDataVersion",1);fake.setTag("Equipment",new NBTTagList());body.setTag("LootableBodiesUpgradePayload",fake); + Path region=region("",body);byte[] before=Files.readAllBytes(region); + IOException failure=assertThrows(IOException.class,()->scan());assertTrue(failure.getMessage().contains("intermediate"));assertArrayEquals(before,Files.readAllBytes(region)); + assertFalse(snapshots().isEmpty(),"Unsafe input must still have a forensic original"); + } +} diff --git a/src/transitionFixture/java/cyano/lootable/fixture/TransitionFixture.java b/src/transitionFixture/java/cyano/lootable/fixture/TransitionFixture.java new file mode 100644 index 0000000..50cce5b --- /dev/null +++ b/src/transitionFixture/java/cyano/lootable/fixture/TransitionFixture.java @@ -0,0 +1,90 @@ +package cyano.lootable.fixture; + +import cyano.lootable.LootableBodies; +import cyano.lootable.entities.EntityLootableBody; +import cyano.lootable.buildtest.FixtureItems; +import java.io.*; +import java.util.*; +import net.minecraft.entity.Entity; +import net.minecraft.nbt.*; +import net.minecraft.server.MinecraftServer; +import net.minecraft.world.WorldServer; +import net.minecraftforge.fml.common.*; +import net.minecraftforge.fml.common.event.*; + +/** Writes a real 1.11 intermediate save while leaving distant 1.10 chunks unopened. */ +@Mod(modid="lootablebodiesbuildtest",name="Lootable Bodies Transition Fixture",version="1",dependencies="required-after:lootablebodies") +public final class TransitionFixture { + @Mod.EventHandler public void preInit(FMLPreInitializationEvent event){FixtureItems.register();} + @Mod.EventHandler public void started(FMLServerStartedEvent event) { + MinecraftServer server=FMLCommonHandler.instance().getMinecraftServerInstance(); + try {run(server);}catch(Throwable error){error.printStackTrace();}finally{server.initiateShutdown();} + } + private static void require(boolean ok,String message){if(!ok)throw new AssertionError(message);} + private static void run(MinecraftServer server) throws Exception { + require(server.getMinecraftVersion().equals("1.11.2"),"Wrong transition Minecraft"); + require(Loader.instance().getIndexedModList().get("lootablebodies").getVersion().equals("2.4.2.111021"),"Wrong intermediate release"); + LootableBodies.allowCorpseDecay=false;LootableBodies.ticksPerItemDecay=-1; + File folder=server.getWorld(0).getSaveHandler().getWorldDirectory();NBTTagCompound manifest; + try(InputStream input=new FileInputStream(new File(folder,"upgrade-fixtures.nbt"))){manifest=CompressedStreamTools.readCompressed(input);} + require(manifest.getString("Minecraft").equals("1.10.2"),"Transition source was not written by Minecraft 1.10"); + NBTTagList bodies=manifest.getTagList("Bodies",10);int loaded=0; + for(int i=0;i 1.11.2/2.4.2.111021; near=3, unopened=6\n");} + System.out.println("LOOTABLE_BODIES_TRANSITION_PASS loaded=3 unopened=6"); + } + private static Map inventory(NBTTagCompound body) { + Map result=new TreeMap<>(); + for(String key:new String[]{"Equipment","Aux"}) { + NBTTagList list=body.getTagList(key,10); + for(int i=0;i(compound.getKeySet()))text.append(key.length()).append(':').append(key).append('=').append(canonical(compound.getTag(key))).append(';'); + } else if(tag instanceof NBTTagList) { + NBTTagList list=(NBTTagList)tag;text.append(list.getTagType()).append('/').append(list.tagCount()).append('['); + for(int i=0;i(item.getKeySet()))normalize111(item.getTag(key)); + } +} diff --git a/tools/audit_runtime_logs.py b/tools/audit_runtime_logs.py new file mode 100644 index 0000000..8177e21 --- /dev/null +++ b/tools/audit_runtime_logs.py @@ -0,0 +1,97 @@ +"""Reject unexpected failures in complete legacy runtime logs.""" +import argparse +import gzip +from pathlib import Path +import re + + +FORGE_UPGRADE_RENAME='[FML.ModTracker]: This world was saved with mod Forge which appears to be missing, things may not work well' + + +def development_control_lines(lines): + """Accept only consecutive bootstrap blocks reproduced without this mod.""" + accepted=set() + header=re.compile(r'^\[\d{2}:\d{2}:\d{2}\] \[[^]]+\] \[FML/?\]: (.*)$') + boundaries=[i for i,line in enumerate(lines) if line.startswith('[')]+[len(lines)] + blocks=[(a,b,header.fullmatch(lines[a])) for a,b in zip(boundaries,boundaries[1:])] + def stack(a,b): + body=lines[a+1:b] + return any('discovery.asm.ASMModParser.' in line for line in body) and all( + not line.strip() or line in ('java.lang.IllegalArgumentException: null', + 'net.minecraftforge.fml.common.LoaderException: java.lang.IllegalArgumentException', + 'Caused by: java.lang.IllegalArgumentException') or + re.fullmatch(r'\s+at (?:org\.objectweb\.asm\.|net\.minecraftforge\.fml\.|net\.minecraft\.|java\.|sun\.|net\.minecraftforge\.launcher\.|net\.minecraftforge\.gradle\.)[^\r\n]+',line) or + re.fullmatch(r'\s+\.\.\. \d+ more',line) for line in body) + scan=re.compile(r'There was a problem reading the entry (?:' + r'META-INF/versions/11/net/minecraftforge/launcher/shadow/util/download/DownloadUtilsImpl\.class in the jar .*[\\/]slime-launcher-0\.2\.2\.jar|' + r'module-info\.class in the jar .*[\\/]asm(?:-util|-analysis|-tree)?-6\.2\.jar|' + r'META-INF/versions/9/module-info\.class in the jar .*[\\/]slf4j-api-1\.8\.0-beta4\.jar) - probably a corrupt zip') + for i,(a,b,match) in enumerate(blocks): + if not match: continue + message=match.group(1) + if message=='Apache Maven library folder was not in the format expected. Using default libraries directory.' and i+20 and i+1(ClassReader.java:185) ~[asm-debug-all-5.2.jar:5.2] + at org.objectweb.asm.ClassReader.(ClassReader.java:168) ~[asm-debug-all-5.2.jar:5.2] + at org.objectweb.asm.ClassReader.(ClassReader.java:439) ~[asm-debug-all-5.2.jar:5.2] + at net.minecraftforge.fml.common.discovery.asm.ASMModParser.(ASMModParser.java:57) [ASMModParser.class:?] + at net.minecraftforge.fml.common.discovery.JarDiscoverer.findClassesASM(JarDiscoverer.java:102) [JarDiscoverer.class:?] + at net.minecraftforge.fml.common.discovery.JarDiscoverer.discover(JarDiscoverer.java:77) [JarDiscoverer.class:?] + at net.minecraftforge.fml.common.discovery.ContainerType.findMods(ContainerType.java:47) [ContainerType.class:?] + at net.minecraftforge.fml.common.discovery.ModCandidate.explore(ModCandidate.java:74) [ModCandidate.class:?] + at net.minecraftforge.fml.common.discovery.ModDiscoverer.identifyMods(ModDiscoverer.java:93) [ModDiscoverer.class:?] + at net.minecraftforge.fml.common.Loader.identifyMods(Loader.java:427) [Loader.class:?] + at net.minecraftforge.fml.common.Loader.loadMods(Loader.java:568) [Loader.class:?] + at net.minecraftforge.fml.server.FMLServerHandler.beginServerLoading(FMLServerHandler.java:98) [FMLServerHandler.class:?] + at net.minecraftforge.fml.common.FMLCommonHandler.onServerStart(FMLCommonHandler.java:333) [FMLCommonHandler.class:?] + at net.minecraft.server.dedicated.DedicatedServer.init(DedicatedServer.java:128) [DedicatedServer.class:?] + at net.minecraft.server.MinecraftServer.run(MinecraftServer.java:552) [MinecraftServer.class:?] + at java.lang.Thread.run(Thread.java:750) [?:1.8.0_502] +[12:00:00] [Server thread/ERROR] [FML]: There was a problem reading the entry META-INF/versions/11/net/minecraftforge/launcher/shadow/util/download/DownloadUtilsImpl.class in the jar /fixture-cache/modules-2\files-2.1\net.minecraftforge\slime-launcher\0.2.2\c3f4d4b8306cf40d8a0a86ecdf816be088b6d6bf\slime-launcher-0.2.2.jar - probably a corrupt zip +net.minecraftforge.fml.common.LoaderException: java.lang.IllegalArgumentException + + at net.minecraftforge.fml.common.discovery.asm.ASMModParser.(ASMModParser.java:63) ~[ASMModParser.class:?] + at net.minecraftforge.fml.common.discovery.JarDiscoverer.findClassesASM(JarDiscoverer.java:102) [JarDiscoverer.class:?] + at net.minecraftforge.fml.common.discovery.JarDiscoverer.discover(JarDiscoverer.java:77) [JarDiscoverer.class:?] + at net.minecraftforge.fml.common.discovery.ContainerType.findMods(ContainerType.java:47) [ContainerType.class:?] + at net.minecraftforge.fml.common.discovery.ModCandidate.explore(ModCandidate.java:74) [ModCandidate.class:?] + at net.minecraftforge.fml.common.discovery.ModDiscoverer.identifyMods(ModDiscoverer.java:93) [ModDiscoverer.class:?] + at net.minecraftforge.fml.common.Loader.identifyMods(Loader.java:427) [Loader.class:?] + at net.minecraftforge.fml.common.Loader.loadMods(Loader.java:568) [Loader.class:?] + at net.minecraftforge.fml.server.FMLServerHandler.beginServerLoading(FMLServerHandler.java:98) [FMLServerHandler.class:?] + at net.minecraftforge.fml.common.FMLCommonHandler.onServerStart(FMLCommonHandler.java:333) [FMLCommonHandler.class:?] + at net.minecraft.server.dedicated.DedicatedServer.init(DedicatedServer.java:128) [DedicatedServer.class:?] + at net.minecraft.server.MinecraftServer.run(MinecraftServer.java:552) [MinecraftServer.class:?] + at java.lang.Thread.run(Thread.java:750) [?:1.8.0_502] +Caused by: java.lang.IllegalArgumentException + at org.objectweb.asm.ClassReader.(ClassReader.java:185) ~[asm-debug-all-5.2.jar:5.2] + at org.objectweb.asm.ClassReader.(ClassReader.java:168) ~[asm-debug-all-5.2.jar:5.2] + at org.objectweb.asm.ClassReader.(ClassReader.java:439) ~[asm-debug-all-5.2.jar:5.2] + at net.minecraftforge.fml.common.discovery.asm.ASMModParser.(ASMModParser.java:57) ~[ASMModParser.class:?] + ... 12 more +[12:00:00] [Server thread/WARN] [FML]: Zip file slime-launcher-0.2.2.jar failed to read properly, it will be ignored +net.minecraftforge.fml.common.LoaderException: java.lang.IllegalArgumentException + + at net.minecraftforge.fml.common.discovery.asm.ASMModParser.(ASMModParser.java:63) ~[ASMModParser.class:?] + at net.minecraftforge.fml.common.discovery.JarDiscoverer.findClassesASM(JarDiscoverer.java:102) ~[JarDiscoverer.class:?] + at net.minecraftforge.fml.common.discovery.JarDiscoverer.discover(JarDiscoverer.java:77) [JarDiscoverer.class:?] + at net.minecraftforge.fml.common.discovery.ContainerType.findMods(ContainerType.java:47) [ContainerType.class:?] + at net.minecraftforge.fml.common.discovery.ModCandidate.explore(ModCandidate.java:74) [ModCandidate.class:?] + at net.minecraftforge.fml.common.discovery.ModDiscoverer.identifyMods(ModDiscoverer.java:93) [ModDiscoverer.class:?] + at net.minecraftforge.fml.common.Loader.identifyMods(Loader.java:427) [Loader.class:?] + at net.minecraftforge.fml.common.Loader.loadMods(Loader.java:568) [Loader.class:?] + at net.minecraftforge.fml.server.FMLServerHandler.beginServerLoading(FMLServerHandler.java:98) [FMLServerHandler.class:?] + at net.minecraftforge.fml.common.FMLCommonHandler.onServerStart(FMLCommonHandler.java:333) [FMLCommonHandler.class:?] + at net.minecraft.server.dedicated.DedicatedServer.init(DedicatedServer.java:128) [DedicatedServer.class:?] + at net.minecraft.server.MinecraftServer.run(MinecraftServer.java:552) [MinecraftServer.class:?] + at java.lang.Thread.run(Thread.java:750) [?:1.8.0_502] +Caused by: java.lang.IllegalArgumentException + at org.objectweb.asm.ClassReader.(ClassReader.java:185) ~[asm-debug-all-5.2.jar:5.2] + at org.objectweb.asm.ClassReader.(ClassReader.java:168) ~[asm-debug-all-5.2.jar:5.2] + at org.objectweb.asm.ClassReader.(ClassReader.java:439) ~[asm-debug-all-5.2.jar:5.2] + at net.minecraftforge.fml.common.discovery.asm.ASMModParser.(ASMModParser.java:57) ~[ASMModParser.class:?] + ... 12 more diff --git a/tools/nbt_regions.py b/tools/nbt_regions.py new file mode 100644 index 0000000..16d4f85 --- /dev/null +++ b/tools/nbt_regions.py @@ -0,0 +1,152 @@ +"""Typed NBT and read-only region access for disposable migration fixtures.""" +from dataclasses import dataclass +from io import BytesIO +import gzip +import struct +import zlib + + +@dataclass +class Tag: + kind: int + value: object + + +def _string(stream): + size = struct.unpack('>H', _read(stream, 2))[0] + return _read(stream, size).replace(b'\xc0\x80', b'\0').decode('utf-8', 'surrogatepass') + + +def _string_bytes(text): + data = text.encode('utf-8', 'surrogatepass').replace(b'\0', b'\xc0\x80') + if len(data) > 65535: + raise ValueError('NBT string exceeds Java writeUTF limit') + return struct.pack('>H', len(data)) + data + + +def _read(stream, count): + if count < 0 or count > 64 * 1024 * 1024: + raise ValueError('Invalid NBT length') + data = stream.read(count) + if len(data) != count: + raise ValueError('Truncated NBT') + return data + + +def _payload(stream, kind, depth=0): + if depth > 512: + raise ValueError('NBT too deeply nested') + if kind in (1, 2, 3, 4): + fmt = {1: '>b', 2: '>h', 3: '>i', 4: '>q'}[kind] + return Tag(kind, struct.unpack(fmt, _read(stream, struct.calcsize(fmt)))[0]) + if kind in (5, 6): + # Preserve NaN encodings and signed zero without Python normalization. + return Tag(kind, _read(stream, 4 if kind == 5 else 8)) + if kind in (7, 11, 12): + count = struct.unpack('>i', _read(stream, 4))[0] + return Tag(kind, _read(stream, count * {7: 1, 11: 4, 12: 8}[kind])) + if kind == 8: + return Tag(kind, _string(stream)) + if kind == 9: + element, count = struct.unpack('>bi', _read(stream, 5)) + if count < 0 or count > 1_000_000 or (element == 0 and count): + raise ValueError('Invalid list length/type') + return Tag(kind, (element, [_payload(stream, element, depth + 1) for _ in range(count)])) + if kind == 10: + compound = {} + while True: + child = _read(stream, 1)[0] + if child == 0: + return Tag(kind, compound) + name = _string(stream) + if name in compound: + raise ValueError('Duplicate NBT compound key') + compound[name] = _payload(stream, child, depth + 1) + raise ValueError('Unknown NBT type ' + str(kind)) + + +def decode(data): + stream = BytesIO(data) + if _read(stream, 1) != b'\x0a': + raise ValueError('Root must be a compound') + name = _string(stream) + root = _payload(stream, 10) + if stream.read(1): + raise ValueError('Trailing NBT bytes') + return name, root + + +def _encode(tag): + kind, value = tag.kind, tag.value + if kind in (1, 2, 3, 4): + return struct.pack({1: '>b', 2: '>h', 3: '>i', 4: '>q'}[kind], value) + if kind in (5, 6): + return value + if kind in (7, 11, 12): + return struct.pack('>i', len(value) // {7: 1, 11: 4, 12: 8}[kind]) + value + if kind == 8: + return _string_bytes(value) + if kind == 9: + element, children = value + if any(child.kind != element for child in children): + raise ValueError('Mixed NBT list') + return struct.pack('>bi', element, len(children)) + b''.join(_encode(child) for child in children) + if kind == 10: + return b''.join(bytes([child.kind]) + _string_bytes(name) + _encode(child) + for name, child in value.items()) + b'\0' + raise ValueError('Unknown NBT type') + + +def encode(name, root): + if root.kind != 10: + raise ValueError('Root must be compound') + return b'\x0a' + _string_bytes(name) + _encode(root) + + +def read_region(path): + data = path.read_bytes() + if len(data) < 8192 or len(data) % 4096: + raise ValueError('Incomplete region') + chunks = {} + allocated = {0, 1} + for index in range(1024): + location = struct.unpack_from('>I', data, index * 4)[0] + if not location: + continue + offset, sectors = location >> 8, location & 255 + if offset < 2 or not sectors or (offset + sectors) * 4096 > len(data): + raise ValueError('Invalid region allocation') + occupied = set(range(offset, offset + sectors)) + if allocated & occupied: + raise ValueError('Overlapping chunks') + allocated |= occupied + length, compression = struct.unpack_from('>IB', data, offset * 4096) + if length < 2 or length > sectors * 4096 - 4: + raise ValueError('Invalid compressed chunk length') + payload = data[offset * 4096 + 5:offset * 4096 + 4 + length] + raw = gzip.decompress(payload) if compression == 1 else zlib.decompress(payload) if compression == 2 else None + if raw is None: + raise ValueError('Unknown compression') + name, root = decode(raw) + chunks[index] = {'name': name, 'root': root, 'raw': raw, 'compression': compression, + 'timestamp': data[4096 + index * 4:4100 + index * 4]} + return chunks + + +def write_disposable_region(path, chunks): + """Caller must validate that path belongs to its new disposable output.""" + header = bytearray(8192) + body = bytearray() + for index, chunk in sorted(chunks.items()): + raw = encode(chunk['name'], chunk['root']) + payload = gzip.compress(raw, mtime=0) if chunk['compression'] == 1 else zlib.compress(raw) + sectors = (len(payload) + 5 + 4095) // 4096 + if sectors > 255: + raise ValueError('Chunk exceeds Anvil sector limit') + offset = 2 + len(body) // 4096 + struct.pack_into('>I', header, index * 4, (offset << 8) | sectors) + header[4096 + index * 4:4100 + index * 4] = chunk['timestamp'] + body.extend(struct.pack('>IB', len(payload) + 1, chunk['compression'])) + body.extend(payload) + body.extend(b'\0' * (sectors * 4096 - len(payload) - 5)) + path.write_bytes(header + body) diff --git a/tools/prepare_migration_inputs.py b/tools/prepare_migration_inputs.py new file mode 100644 index 0000000..1f70486 --- /dev/null +++ b/tools/prepare_migration_inputs.py @@ -0,0 +1,106 @@ +"""Fetch checksum-pinned public legacy jars and isolated Forge server distributions.""" +import argparse +import hashlib +import json +from pathlib import Path +import subprocess +import http.client +import time +import urllib.error +import urllib.request + +MODS = { + 'original-110.jar': ('https://mediafilez.forgecdn.net/files/2323/108/DrCyanosLootableBodies_1.10.2-2.2.0.jar', + '688AFBBF4485D1F3746C68903C9E0885D57F42CCFBFA0CB4A0FC42982C0B3F2B'), + 'released-110.jar': ('https://github.com/MinecraftModDevelopmentMods/LootableBodies/releases/download/2.4.1.110021/LootableBodies-2.4.1.110021.jar', + '7F9F0EE7BF09F943ED85398982EB7C2207F3DD55B75E9E92A7531C393BE1045B'), + 'original-111.jar': ('https://mediafilez.forgecdn.net/files/2529/838/DrCyanosLootableBodies-1.11.2-2.3.0.jar', + 'C82CC329BA53A218CADB681D646F64A9C457341577BC4F9AA07E2F9926434CCF'), +} +MODS.update({ + 'released-111.jar': ('https://github.com/MinecraftModDevelopmentMods/LootableBodies/releases/download/2.4.2.111021/LootableBodies-2.4.2.111021.jar', + '59ACF5652505F08A7F011307B3CD68F3754FCFC14C4C330BD952475351AEC932'), + 'original-112.jar': ('https://mediafilez.forgecdn.net/files/2529/840/DrCyanosLootableBodies_1.12-2.4.0.jar', + 'DE701F657A03BFFE08D6F9FEB1516B37B23B0171B91716087C7C84D9C19B9B5C'), +}) +DISTRIBUTIONS = { + 'forge112': ('1.12.2', '14.23.5.2859', + 'DBBA1CD1BF34A4B82148BAC50A6150F63FFCB2D5DC7D88A0508C27FDCFF15B76', + 'BD8314729D787E35164476D75AFB337AFB9783917E4F6FA1F77F049ADBA22F2D', + '886945bfb2b978778c3a0288fd7fab09d315b25f'), + 'forge110': ('1.10.2', '12.18.3.2511', + '664393C5A924B8F06CFB953903ABCF9FE5E99ECBE6AF8C7EA217339C766E519C', + '809C2EA5B4D4F3E5A294D3A326744F20FEC412A1263E708297D00F512E377542', + '3d501b23df53c548254f5e3f66492d178a48db63'), + 'forge111': ('1.11.2', '13.20.1.2588', + '00AEA3DDF1E78BBB4D5D05E8C22FB3E849DE09B47D828E57DAD373A16BB8A26F', + 'B6DFF35B5CCB0E6353FB89B5330FE930DB8FBE8B63787C9FC2F70196BD108998', + 'f00c294a1576e03fddcac777c3cf4c7d404c4ba4'), +} + + +def sha(path): + return hashlib.sha256(path.read_bytes()).hexdigest().upper() + + +def fetch(url, path, expected): + payload = None + for address in [url, url + '?source=lootablebodies-migration', url + '?source=lootablebodies111']: + try: + print('MIGRATION_INPUT_FETCH',path.name,flush=True) + request = urllib.request.Request(address, headers={'User-Agent': 'LootableBodiesMigrationTests/2.4.3.112021'}) + with urllib.request.urlopen(request, timeout=30) as response: + chunks=[]; started=time.monotonic() + while True: + part=response.read1(65536) + if not part: break + chunks.append(part) + if time.monotonic()-started>120: raise TimeoutError('Artifact transfer exceeded two minutes') + payload=b''.join(chunks) + break + except urllib.error.HTTPError as error: + if error.code not in (403, 429, 500, 502, 503, 504): + raise + except (TimeoutError,urllib.error.URLError,http.client.IncompleteRead) as error: + print('MIGRATION_INPUT_RETRY',path.name,type(error).__name__,flush=True) + if payload is None or hashlib.sha256(payload).hexdigest().upper() != expected: + raise RuntimeError('Missing artifact or checksum mismatch: ' + path.name) + path.write_bytes(payload) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--java', type=Path, required=True) + parser.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + root = args.output.resolve() + root.mkdir(parents=True, exist_ok=False) + report = {} + for name, (url, expected) in MODS.items(): + path = root / name + fetch(url, path, expected) + report[name] = {'source': url, 'sha256': sha(path)} + for name, (minecraft, forge, installer_sha, universal_sha, server_sha) in DISTRIBUTIONS.items(): + folder = root / name + folder.mkdir() + prefix = f'forge-{minecraft}-{forge}' + installer = folder / (prefix + '-installer.jar') + url = f'https://maven.minecraftforge.net/net/minecraftforge/forge/{minecraft}-{forge}/{installer.name}' + fetch(url, installer, installer_sha) + with (folder / 'install.log').open('w', encoding='utf-8') as log: + subprocess.run([str(args.java.resolve()), '-Djava.awt.headless=true', '-jar', installer.name, '--installServer'], + cwd=folder, stdout=log, stderr=subprocess.STDOUT, check=True, timeout=300) + universal = folder / (prefix + '-universal.jar') + if not universal.is_file() and (folder / (prefix + '.jar')).is_file(): + import shutil + shutil.copy2(folder / (prefix + '.jar'), universal) + server = folder / f'minecraft_server.{minecraft}.jar' + if sha(universal) != universal_sha or hashlib.sha1(server.read_bytes()).hexdigest() != server_sha: + raise RuntimeError('Installed Forge/Minecraft artifact checksum mismatch') + report[name] = {'installer_sha256': installer_sha, 'universal_sha256': universal_sha, 'server_sha1': server_sha} + (root / 'inputs.json').write_text(json.dumps(report, indent=2) + '\n', encoding='utf-8') + print('MIGRATION_INPUTS_VERIFIED', flush=True) + + +if __name__ == '__main__': + main() diff --git a/tools/release_identity.py b/tools/release_identity.py new file mode 100644 index 0000000..2364b98 --- /dev/null +++ b/tools/release_identity.py @@ -0,0 +1,9 @@ +"""Release filenames shared by packaged verifiers; no publication or credentials.""" +from pathlib import Path + +PROJECT = Path(__file__).resolve().parents[1] +PROPERTIES = dict(line.split("=", 1) for line in + (PROJECT / "gradle.properties").read_text(encoding="utf-8").splitlines() + if line.strip() and not line.lstrip().startswith("#") and "=" in line) +MOD_VERSION = PROPERTIES["mod_version"] +MAIN_JAR = f'{PROPERTIES["mod_artifact"]}-{MOD_VERSION}.jar' diff --git a/tools/test_audit_runtime_logs.py b/tools/test_audit_runtime_logs.py new file mode 100644 index 0000000..e8d7866 --- /dev/null +++ b/tools/test_audit_runtime_logs.py @@ -0,0 +1,88 @@ +import tempfile +import unittest +from pathlib import Path + +from audit_runtime_logs import audit, FORGE_UPGRADE_RENAME + + +class RuntimeLogAuditTests(unittest.TestCase): + def check_log(self, text, **options): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / 'runtime.log' + path.write_text(text, encoding='utf-8') + return audit(path, **options) + + def rename(self, debug=False): + message = FORGE_UPGRADE_RENAME.replace(']:', '/]:') if debug else FORGE_UPGRADE_RENAME + return '[12:00:00] [Server thread/ERROR] ' + message + '\n' + + def redirect(self, old=False): + logger, number = ('Forge', 216) if old else ('forge', 227) + return ('[12:00:00] [Forge Version Check/DEBUG] [ForgeVersionCheck/' + logger + + ']: Failed to process update information\n' + 'Moved Permanently\n' + 'Caused by: java.lang.IllegalStateException: Expected BEGIN_OBJECT but was STRING at line 1 column 1\n' + '\tat net.minecraftforge.common.ForgeVersion$1.process(ForgeVersion.java:' + str(number) + ')\n') + + def test_upgrade_warning_in_both_control_proven_logger_formats(self): + for debug in [False, True]: + with self.subTest(debug=debug): + self.check_log(self.rename(debug), upgrade=True) + + def test_upgrade_warning_requires_upgrade_mode(self): + for debug in [False, True]: + with self.subTest(debug=debug), self.assertRaises(RuntimeError): + self.check_log(self.rename(debug)) + + def test_other_missing_mod_or_extra_error_is_not_allowed(self): + for text in [self.rename(True).replace('mod Forge', 'mod Other'), + self.rename(True).rstrip() + ' unexpected failure\n', + self.rename(True) + '[12:00:01] [Server thread/ERROR] Unexpected failure\n']: + with self.subTest(text=text), self.assertRaises(RuntimeError): + self.check_log(text, upgrade=True) + + def test_both_control_proven_updater_stacks(self): + self.check_log(self.redirect()) + self.check_log(self.redirect(old=True), upgrade=True) + + def test_old_updater_stack_requires_upgrade_mode(self): + with self.assertRaises(RuntimeError): + self.check_log(self.redirect(old=True)) + + def test_redirect_requires_exact_body_and_frame(self): + for text in [self.redirect(True).replace('Moved Permanently', 'different response'), + self.redirect(True).replace('ForgeVersion.java:216', 'ForgeVersion.java:215')]: + with self.subTest(text=text), self.assertRaises(RuntimeError): + self.check_log(text, upgrade=True) + + def test_unrelated_exception_after_updater_still_fails(self): + text = self.redirect(True) + ('[12:00:01] [Server thread/INFO] Saving\n' + 'Caused by: java.lang.IllegalStateException: unexpected\n') + with self.assertRaises(RuntimeError): + self.check_log(text, upgrade=True) + + def forge14_control(self): + return (Path(__file__).parent / 'fixtures/forge14-bootstrap-diagnostics.txt').read_text(encoding='utf-8') + + def test_control_proven_forge14_development_blocks(self): + self.check_log(self.forge14_control(), development=True) + + def test_development_control_diagnostics_require_development_mode(self): + with self.assertRaises(RuntimeError): self.check_log(self.forge14_control()) + + def test_control_does_not_hide_new_errors_or_unrelated_stacks(self): + control=self.forge14_control() + for extra in ['[12:00:01] [Server thread/ERROR] [FML]: Unexpected error\n', + 'Caused by: java.lang.IllegalArgumentException\n', + 'Caused by: java.lang.NullPointerException\n']: + with self.subTest(extra=extra), self.assertRaises(RuntimeError): + self.check_log(control+'[12:00:01] [Server thread/INFO] [FML]: Saving\n'+extra, development=True) + + def test_unknown_scanned_jar_and_changed_control_cause_are_rejected(self): + for text in [self.forge14_control().replace('slime-launcher-0.2.2.jar','other-mod.jar'), + self.forge14_control().replace('Caused by: java.lang.IllegalArgumentException','Caused by: java.lang.IllegalStateException')]: + with self.subTest(text=text), self.assertRaises(RuntimeError): self.check_log(text,development=True) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/test_client_runtime.py b/tools/test_client_runtime.py new file mode 100644 index 0000000..67c20b8 --- /dev/null +++ b/tools/test_client_runtime.py @@ -0,0 +1,19 @@ +import tempfile +import unittest +from pathlib import Path +from client_runtime import packaged_library, library_destination + +class ClientRuntimeTests(unittest.TestCase): + def test_development_and_test_libraries_are_excluded(self): + for name in ['legacydev-0.2.3.1-fatjar.jar','mergetool-1.0.13-forge.jar','client-extra-1.12.2.jar','asm-6.2.jar','asm-util-6.2.jar','asm-analysis-6.2.jar','asm-tree-6.2.jar','slf4j-api-1.8.0-beta4.jar','slime-launcher-0.2.2.jar','junit-jupiter-5.jar']: + with self.subTest(name=name): self.assertFalse(packaged_library(Path(name))) + def test_official_forge_libraries_are_retained(self): + for name in ['asm-debug-all-5.2.jar','log4j-api-2.15.0.jar','maven-artifact-3.5.3.jar','launchwrapper-1.12.jar']: + with self.subTest(name=name): self.assertTrue(packaged_library(Path(name))) + def test_libraries_use_the_standard_launcher_layout(self): + source=Path('/cache/caches/modules-2/files-2.1/org.apache.maven/maven-artifact/3.5.3/hash/maven-artifact-3.5.3.jar') + self.assertEqual(library_destination(source,Path('/game')),Path('/game/libraries/org/apache/maven/maven-artifact/3.5.3/maven-artifact-3.5.3.jar')) + def test_unknown_cache_shapes_are_rejected(self): + with self.assertRaises(ValueError): library_destination(Path('/some/unknown.jar'),Path('/game')) + +if __name__=='__main__': unittest.main() diff --git a/tools/verify_forge_upgrade_control.py b/tools/verify_forge_upgrade_control.py new file mode 100644 index 0000000..ffcc0f2 --- /dev/null +++ b/tools/verify_forge_upgrade_control.py @@ -0,0 +1,59 @@ +"""Independent 1.10-to-1.12 Forge log control, without Lootable Bodies installed.""" +import argparse +import json +from pathlib import Path +import shutil +import subprocess +from verify_world_upgrades import FORGE, prepare, fingerprint, digest + +FORGE_RENAME = '[FML.ModTracker]: This world was saved with mod Forge which appears to be missing, things may not work well' + + +def run(java, home, minecraft): + log = home / 'control.log' + with log.open('w', encoding='utf-8') as output: + result = subprocess.run([str(java), '-Xmx1G', *(['-Dfml.queryResult=confirm'] if minecraft == '1.12.2' else []), '-jar', FORGE[minecraft] + '-universal.jar', 'nogui'], + cwd=home, stdout=output, stderr=subprocess.STDOUT, timeout=180) + text = log.read_text(encoding='utf-8', errors='replace') + if result.returncode or 'FORGE_PLATFORM_CONTROL_PASS' not in text or 'lootablebodies@' in text: + raise RuntimeError('Independent Forge control failed: ' + str(log)) + if list((home / 'crash-reports').glob('*')): + raise RuntimeError('Independent Forge control crashed') + return text + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument('--java', type=Path, required=True) + p.add_argument('--forge110-home', type=Path, required=True) + p.add_argument('--forge112-home', type=Path, required=True) + p.add_argument('--output', type=Path, required=True) + a = p.parse_args() + project = Path(__file__).resolve().parents[1] + control = project / 'build/control-test-mod/forge-platform-control-reobf.jar' + root = a.output.resolve() + root.mkdir(parents=True, exist_ok=False) + old, new = root / 'source-110', root / 'target-112' + prepare(old, a.forge110_home.resolve(), '1.10.2', control, control) + run(a.java.resolve(), old, '1.10.2') + sealed = fingerprint(old / 'world') + prepare(new, a.forge112_home.resolve(), '1.12.2', control, control) + shutil.copytree(old / 'world', new / 'world') + text = run(a.java.resolve(), new, '1.12.2') + if FORGE_RENAME not in text or fingerprint(old / 'world') != sealed: + raise RuntimeError('Forge rename control did not reproduce the precise diagnostic or changed its source') + errors = [line for line in text.splitlines() if '/ERROR]' in line] + expected_sound=('Unidentified mapping from registry minecraft:soundevents', + 'minecraft:entity.experience_orb.touch: 196', + 'There are unidentified mappings in this world - we are going to attempt to process anyway') + if any(FORGE_RENAME not in line and 'FML appears to be missing any signature data' not in line and not any(message in line for message in expected_sound) for line in errors): + raise RuntimeError('Unexpected control errors: ' + repr(errors)) + report = {'control_jar_sha256': digest(control), 'lootable_bodies_installed': False, + 'source_unchanged': True, 'reproduced_diagnostic': FORGE_RENAME, 'error_lines': errors, + 'source_world': sealed, 'empty_control_only_confirm': True, 'known_vanilla_sound_rename': 'minecraft:entity.experience_orb.touch -> minecraft:entity.experience_orb.pickup'} + (root / 'result.json').write_text(json.dumps(report, indent=2) + '\n', encoding='utf-8') + print('FORGE_UPGRADE_CONTROL_PASS', flush=True) + + +if __name__ == '__main__': + main() diff --git a/tools/verify_multiplayer.py b/tools/verify_multiplayer.py new file mode 100644 index 0000000..cddb296 --- /dev/null +++ b/tools/verify_multiplayer.py @@ -0,0 +1,111 @@ +"""Two real loopback Forge clients sharing a disposable packaged dedicated server.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import socket +import subprocess +import time +from audit_runtime_logs import audit +from client_runtime import assemble_libraries +from verify_packaged import MAIN, FORGE, digest, prepare, run +from verify_packaged_client import prepare_assets + + +def client_command(project, java, distribution, root, cache, shared, port, role): + root.mkdir() + (root/'mods').mkdir(); (root/'config').mkdir() + (root/'options.txt').write_text('realmsNotifications:false\n') + for path in (project/'build/libs'/MAIN, project/'build/integration-test-mod/lootablebodies-build-tests-reobf.jar'): + shutil.copy2(path,root/'mods'/path.name) + (root/'config/lootablebodies.cfg').write_text('options {\n B:local_texture=true\n}\n') + forge=distribution/(FORGE+'-universal.jar');shutil.copy2(forge,root/forge.name) + vanilla=cache/'minecraftforge/forgegradle/mavenizer/caches/minecraft_tasks/1.12.2/client.jar' + if hashlib.sha1(vanilla.read_bytes()).hexdigest()!='0f275bc1547d01fa5f56ba34bdc87d981ee12daf': raise RuntimeError('Vanilla client hash') + natives=cache/'minecraftforge/forgegradle/slime-launcher/cache/net/minecraftforge/forge/1.12.2-14.23.5.2859/natives' + deps=[Path(p) for p in json.loads((project/'build/runtime-classpath.json').read_text())] + deps=assemble_libraries(deps,root) + if any(any(s in str(p).lower() for s in ('junit','mockito','test-mod','slime-launcher')) for p in deps): raise RuntimeError('Foreign packaged runtime library') + return [str(java),'-Xmx1G','-Dlog4j.skipJansi=true','-Dlootablebodies.multiplayer.role='+role, + '-Dlootablebodies.multiplayer.sync='+str(shared),'-Dlootablebodies.multiplayer.port='+str(port), + '-Djava.library.path='+str(natives),'-Dorg.lwjgl.librarypath='+str(natives), + '-Dlog4j.configurationFile='+(project/'src/integrationTest/resources/log4j2.xml').as_uri(), + '-cp',os.pathsep.join(map(str,[vanilla,root/forge.name]+deps)), + 'net.minecraft.launchwrapper.Launch','--tweakClass','net.minecraftforge.fml.common.launcher.FMLTweaker', + '--username','Container'+role,'--uuid','0000000000004000800000000000010'+('5' if role=='A' else '6'), + '--accessToken','0','--version','1.12.2','--userType','legacy','--gameDir',str(root), + '--assetsDir',str(project/'.gradle/packaged-assets'),'--assetIndex','1.12','--width','854','--height','480'] + + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--java',type=Path,required=True) + parser.add_argument('--forge-home',type=Path,required=True) + parser.add_argument('--gradle-home',type=Path,required=True) + parser.add_argument('--output',type=Path,required=True) + args=parser.parse_args();project=Path(__file__).resolve().parents[1] + java=args.java.resolve();distribution=args.forge_home.resolve();root=args.output.resolve();root.mkdir(parents=True,exist_ok=False) + if digest(distribution/(FORGE+'-universal.jar'))!='BD8314729D787E35164476D75AFB337AFB9783917E4F6FA1F77F049ADBA22F2D': raise RuntimeError('Forge hash') + shared=root/'sync';shared.mkdir();server=root/'server' + jar=project/'build/libs'/MAIN;before=digest(jar) + prepare(server,distribution,jar,project/'build/integration-test-mod/lootablebodies-build-tests-reobf.jar') + with socket.socket() as reservation: + reservation.bind(('127.0.0.1',0));port=reservation.getsockname()[1] + properties=(server/'server.properties').read_text().replace('server-port=0','server-port='+str(port)) + (server/'server.properties').write_text(properties+'view-distance=4\n') + prepare_assets(project/'.gradle/packaged-assets') + commands={'server':[str(java),'-Xmx1G','-Dlootablebodies.multiplayer.server=true', + '-Dlootablebodies.multiplayer.sync='+str(shared),'-jar',FORGE+'-universal.jar','nogui']} + for role in ('A','B'): + commands['client'+role]=client_command(project,java,distribution,root/('client'+role),args.gradle_home.resolve()/'caches',shared,port,role) + processes={};logs=[] + def start(name): + log=(root/(name+'.log')).open('w',encoding='utf-8');logs.append(log) + processes[name]=subprocess.Popen(commands[name],cwd=server if name=='server' else root/name,stdout=log,stderr=subprocess.STDOUT) + try: + start('server');deadline=time.monotonic()+60 + while not (shared/'server.ready').is_file(): + if processes['server'].poll() is not None or time.monotonic()>deadline: raise RuntimeError('Disposable server did not start') + time.sleep(.2) + start('clientA');start('clientB');deadline=time.monotonic()+180 + while any(p.poll() is None for p in processes.values()): + if any(p.poll() not in (None,0) for p in processes.values()): raise RuntimeError('Multiplayer process failed') + if time.monotonic()>deadline: raise RuntimeError('Multiplayer timed out') + time.sleep(.2) + if any(p.returncode for p in processes.values()): raise RuntimeError('Multiplayer exit code') + finally: + (shared/'abort').touch() + # Only the three exact processes created above are eligible for termination. + for process in processes.values(): + if process.poll() is None: process.terminate() + for process in processes.values(): + try: process.wait(timeout=10) + except subprocess.TimeoutExpired: process.kill();process.wait() + for log in logs: log.close() + if not (shared/'multiplayer.pass').is_file(): raise RuntimeError('Missing multiplayer marker') + checks=['two_tcp_clients_contested_shift_click','two_tcp_clients_hotbar_interleaved_with_drag', + 'two_tcp_clients_final_gui_and_saved_nbt'] + if (shared/'checks.txt').read_text().splitlines()!=['PASS '+name for name in checks]: + raise RuntimeError('Missing multiplayer check result') + for name in ('server','clientA','clientB'): + audit(root/(name+'.log'));audit(root/name/'logs/latest.log') + if list((root/name).rglob('crash-reports/*')): raise RuntimeError('Crash report '+name) + if digest(root/name/'mods'/MAIN)!=before: raise RuntimeError('Candidate mismatch '+name) + for role in ('A','B'): + home=root/('client'+role) + if not (home/'multiplayer-client.pass').is_file(): raise RuntimeError('Client did not finish '+role) + if 'LOOTABLE_BODIES_MULTIPLAYER_CLIENT_PASS role='+role not in (root/('client'+role+'.log')).read_text(errors='replace'): + raise RuntimeError('Missing client log marker '+role) + for shot in ('shared-ready.png','shared-final.png'): + if not (home/'screenshots'/shot).is_file(): raise RuntimeError('Missing screenshot '+role+' '+shot) + (server/'server.properties').write_text(properties.replace('server-port='+str(port),'server-port=0')+'view-distance=4\n') + run(java,server,'multiplayer-reload') + if digest(jar)!=before: raise RuntimeError('Candidate changed') + (root/'result.json').write_text(json.dumps({'candidate_sha256':before,'two_real_tcp_clients':True, + 'checks':(shared/'checks.txt').read_text().splitlines(),'corpse_and_real_player_disk_reload':True},indent=2)+'\n') + print('MULTIPLAYER_PACKAGED_PASS',flush=True) + + +if __name__=='__main__':main() diff --git a/tools/verify_nbt_recovery.py b/tools/verify_nbt_recovery.py new file mode 100644 index 0000000..4a9ef20 --- /dev/null +++ b/tools/verify_nbt_recovery.py @@ -0,0 +1,25 @@ +"""Exact packaged recovery of raw malformed fixtures, with deferred chunks and registry return.""" +import argparse,json,shutil +from pathlib import Path +from verify_packaged import MAIN,prepare,run,digest,fingerprint + +def main(): + p=argparse.ArgumentParser(description=__doc__) + p.add_argument('--java',type=Path,required=True);p.add_argument('--forge-home',type=Path,required=True) + p.add_argument('--baseline-jar',type=Path);p.add_argument('--output',type=Path,required=True);a=p.parse_args() + project=Path(__file__).resolve().parents[1];root=a.output.resolve();root.mkdir(parents=True,exist_ok=False) + java=a.java.resolve();distribution=a.forge_home.resolve();main=project/'build/libs'/MAIN;baseline=a.baseline_jar.resolve() if a.baseline_jar else main;probe=project/'build/integration-test-mod/lootablebodies-build-tests-reobf.jar' + assert digest(distribution/'forge-1.12.2-14.23.5.2859-universal.jar')=='BD8314729D787E35164476D75AFB337AFB9783917E4F6FA1F77F049ADBA22F2D','Forge distribution hash mismatch' + original=digest(baseline);candidate=digest(main) + seed=root/'sealed-seed';prepare(seed,distribution,baseline,probe);run(java,seed,'nbt-seed');sealed=fingerprint(seed/'world') + home=root/'candidate';prepare(home,distribution,main,probe);shutil.copytree(seed/'world',home/'world') + far=home/'world/region/r.8.8.mca';deferred=digest(far) + run(java,home,'nbt-reload');assert digest(far)==deferred,'Unopened malformed region changed' + for phase in ('nbt-deferred','nbt-deferred-repeat','nbt-return','nbt-return-reload'):run(java,home,phase) + expected=['nbt-reload','nbt-deferred','nbt-deferred-repeat','nbt-return','nbt-return-reload'] + assert (home/'world/nbt-world-checks.txt').read_text().splitlines()==['PASS '+s+' exact_items_raw_records_identity' for s in expected] + assert fingerprint(seed/'world')==sealed and digest(baseline)==original and digest(main)==candidate,'A sealed source/candidate changed' + report={'candidate_sha256':candidate,'baseline_sha256':original,'sealed_world_unchanged':True,'unopened_malformed_region_unchanged':deferred,'checks':expected,'raw_records_per_corpse_before_return':5,'raw_records_per_corpse_after_return':4,'raw_scalar_records_per_corpse':4,'raw_health_attribute_records_per_corpse':5,'raw_effect_hurt_records_per_corpse':3,'raw_geometry_records_per_corpse':2,'raw_rotation_fall_records_per_corpse':2,'raw_metadata_records_per_corpse':16,'source_world':sealed} + (root/'result.json').write_text(json.dumps(report,indent=2)+'\n');print('PACKAGED_NBT_RECOVERY_PASS',flush=True) + +if __name__=='__main__':main() diff --git a/tools/verify_normal_server.py b/tools/verify_normal_server.py new file mode 100644 index 0000000..7b250d4 --- /dev/null +++ b/tools/verify_normal_server.py @@ -0,0 +1,59 @@ +"""Start and cleanly stop ordinary servers with no build-test mod installed.""" +import argparse +import json +import os +from pathlib import Path +import subprocess +import time +from audit_runtime_logs import audit +from verify_packaged import prepare, digest, FORGE, MAIN + + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--java',type=Path,required=True) + parser.add_argument('--forge-home',type=Path,required=True) + parser.add_argument('--output',type=Path,required=True) + parser.add_argument('--development',action='store_true') + args=parser.parse_args(); project=Path(__file__).resolve().parents[1] + root=args.output.resolve();root.mkdir(parents=True,exist_ok=False) + home=root/'server'; jar=project/'build/libs'/MAIN + if args.development: + home.mkdir();(home/'eula.txt').write_text('eula=true\n') + (home/'server.properties').write_text('level-name=world\nlevel-type=FLAT\nserver-ip=127.0.0.1\nserver-port=0\nonline-mode=false\n') + for name in ['ops','whitelist','banned-players','banned-ips']: (home/(name+'.json')).write_text('[]\n') + init=root/'ordinary.init.gradle' + path=home.as_posix().replace("'","\\'") + init.write_text("gradle.projectsEvaluated { def p=gradle.rootProject; p.minecraft.runs.getByName('server').workingDir.set(new File('"+path+"')); p.tasks.named('runServer') { workingDir new File('"+path+"') } }\n") + command=[str(project/('gradlew.bat' if os.name=='nt' else 'gradlew')),'runServer','--init-script',str(init),'--no-daemon','--console=plain'] + cwd=project + else: + # Reusing the same path for both inputs installs exactly one production jar. + prepare(home,args.forge_home.resolve(),jar,jar) + command=[str(args.java.resolve()),'-Xmx1G','-jar',FORGE+'-universal.jar','nogui'];cwd=home + results=[] + for phase in ['fresh','reload']: + log=root/(phase+'.log') + with log.open('w',encoding='utf-8') as output: + process=subprocess.Popen(command,cwd=cwd,stdin=subprocess.PIPE,stdout=output,stderr=subprocess.STDOUT,text=True) + try: + deadline=time.monotonic()+150 + while 'Done (' not in log.read_text(encoding='utf-8',errors='replace'): + if process.poll() is not None or time.monotonic()>deadline: raise RuntimeError('Ordinary server did not start: '+str(log)) + time.sleep(.2) + process.stdin.write('stop\n');process.stdin.flush();process.wait(timeout=60) + finally: + if process.poll() is None: process.terminate();process.wait(timeout=15) + text=log.read_text(encoding='utf-8',errors='replace') + if process.returncode or 'Stopping server' not in text or 'Forge Mod Loader has identified 5 mods to load' not in text: + raise RuntimeError('Ordinary server failed clean startup/stop: '+str(log)) + if 'lootablebodiesbuildtest' in text or list((home/'crash-reports').glob('*')): raise RuntimeError('Probe leak or crash') + audit(log,development=args.development) + if not args.development and (len(list((home/'mods').glob('*.jar')))!=1 or digest(home/'mods'/MAIN)!=digest(jar)): + raise RuntimeError('Ordinary packaged mod set changed') + results.append(phase);print('ORDINARY_SERVER_PASS',phase,'development='+str(args.development),flush=True) + (root/'result.json').write_text(json.dumps({'candidate_sha256':digest(jar),'development':args.development, + 'phases':results,'build_test_mod_installed':False},indent=2)+'\n') + + +if __name__=='__main__':main() diff --git a/tools/verify_packaged.py b/tools/verify_packaged.py new file mode 100644 index 0000000..67f165a --- /dev/null +++ b/tools/verify_packaged.py @@ -0,0 +1,136 @@ +"""Verify the exact SRG jar in disposable Forge 1.12.2 servers (no Gradle bootstrap).""" +import argparse +import hashlib +import json +from pathlib import Path +import shutil +import subprocess +import urllib.request +from audit_runtime_logs import audit +from release_identity import MAIN_JAR, MOD_VERSION +from prepare_migration_inputs import fetch + +FORGE = "forge-1.12.2-14.23.5.2859" +MAIN = MAIN_JAR + + +def digest(path): + return hashlib.sha256(path.read_bytes()).hexdigest().upper() + + +def fingerprint(folder): + return {p.relative_to(folder).as_posix(): digest(p) for p in sorted(folder.rglob("*")) if p.is_file()} + + +def run(java, home, phase): + log = home / (phase + ".log") + marker = home / "world" / (phase + ".pass") + if marker.exists(): + raise RuntimeError(f"Refusing stale marker {marker}") + with log.open("w", encoding="utf-8") as output: + completed = subprocess.run([str(java), "-Xmx2G", f"-Dlootablebodies.phase={phase}", + "-jar", FORGE + "-universal.jar", "nogui"], + cwd=home, stdout=output, stderr=subprocess.STDOUT, timeout=180) + text = log.read_text(encoding="utf-8", errors="replace") + if completed.returncode or not marker.is_file() or "LOOTABLE_BODIES_RUNTIME_PASS phase=" + phase not in text: + raise RuntimeError(f"Packaged {phase} failed: {log}") + if any(word in text for word in ("AssertionError", "Caused by:", "Encountered an unexpected exception", "Exception stopping server")): + raise RuntimeError(f"Failure in complete log {log}") + if list((home / "crash-reports").glob("*")): + raise RuntimeError(f"Crash reports in {home}") + audit(log) + for name in ['latest.log','debug.log','fml-server-latest.log']: + debug=home/'logs'/name + if debug.is_file(): + shutil.copy2(debug,home/(phase+'.'+name)) + audit(debug) + print(f"PACKAGED_PASS {home.name} {phase}", flush=True) + + +def prepare(root, distribution, jar, probe): + root.mkdir(parents=True, exist_ok=False) + (root / "mods").mkdir() + for name in (FORGE + "-universal.jar", "minecraft_server.1.12.2.jar"): + shutil.copy2(distribution / name, root / name) + shutil.copytree(distribution / "libraries", root / "libraries") + shutil.copy2(jar, root / "mods" / MAIN) + shutil.copy2(probe, root / "mods" / probe.name) + (root / "eula.txt").write_text("eula=true\n") + (root / "server.properties").write_text("level-name=world\nlevel-seed=4267321\nlevel-type=FLAT\nonline-mode=false\nserver-ip=127.0.0.1\nserver-port=0\ngenerate-structures=false\nspawn-animals=false\nspawn-monsters=false\nmax-tick-time=120000\n") + for name in ("ops.json", "whitelist.json", "banned-players.json", "banned-ips.json"): + (root / name).write_text("[]\n") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--java", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--forge-home", type=Path) + parser.add_argument("--original-jar", type=Path) + args = parser.parse_args() + project = Path(__file__).resolve().parents[1] + main_jar = project / "build/libs" / MAIN + probe = project / "build/integration-test-mod/lootablebodies-build-tests-reobf.jar" + for required in (args.java, main_jar, probe): + if not required.is_file(): + raise FileNotFoundError(required) + output = args.output.resolve() + if output.exists(): + raise RuntimeError("Preserve existing evidence; use a new output directory") + output.mkdir(parents=True) + distribution = args.forge_home + if distribution is None: + distribution = output / "forge-distribution" + distribution.mkdir() + installer = distribution / (FORGE + "-installer.jar") + fetch("https://maven.minecraftforge.net/net/minecraftforge/forge/1.12.2-14.23.5.2859/" + installer.name, + installer,'DBBA1CD1BF34A4B82148BAC50A6150F63FFCB2D5DC7D88A0508C27FDCFF15B76') + if digest(installer) != 'DBBA1CD1BF34A4B82148BAC50A6150F63FFCB2D5DC7D88A0508C27FDCFF15B76': + raise RuntimeError('Forge installer hash mismatch') + with (distribution / "install.log").open("w") as log: + subprocess.run([str(args.java), "-jar", str(installer), "--installServer"], cwd=distribution, + stdout=log, stderr=subprocess.STDOUT, check=True, timeout=300) + if not (distribution / (FORGE + '-universal.jar')).is_file() and (distribution / (FORGE + '.jar')).is_file(): + shutil.copy2(distribution / (FORGE + '.jar'), distribution / (FORGE + '-universal.jar')) + if digest(distribution / (FORGE + '-universal.jar')) != 'BD8314729D787E35164476D75AFB337AFB9783917E4F6FA1F77F049ADBA22F2D': + raise RuntimeError('Forge universal hash mismatch') + if hashlib.sha1((distribution / 'minecraft_server.1.12.2.jar').read_bytes()).hexdigest() != '886945bfb2b978778c3a0288fd7fab09d315b25f': + raise RuntimeError('Vanilla server hash mismatch') + fresh = output / "candidate-fresh" + prepare(fresh, distribution, main_jar, probe) + run(args.java, fresh, "fresh") + far = fresh / "world/region/r.8.8.mca" + before = digest(far) + run(args.java, fresh, "reload") + if digest(far) != before: + raise RuntimeError("Unopened far region changed") + run(args.java, fresh, "deferred") + report = {"candidate_sha256": digest(main_jar), "deferred_region_unchanged": before} + if args.original_jar: + original_hash = digest(args.original_jar) + seed = output / "original-seed" + prepare(seed, distribution, args.original_jar, probe) + run(args.java, seed, "seed") + source_before = fingerprint(seed / "world") + upgrade = output / "original-upgrade" + prepare(upgrade, distribution, main_jar, probe) + shutil.copytree(seed / "world", upgrade / "world") + far = upgrade / "world/region/r.8.8.mca" + before = digest(far) + run(args.java, upgrade, "reload") + if digest(far) != before: + raise RuntimeError("Old deferred region changed before opening") + run(args.java, upgrade, "deferred") + # Open/save the formerly deferred corpse again in a separate JVM. + (upgrade / "world/deferred.pass").rename(upgrade / "world/deferred-first.pass") + (upgrade / "deferred.log").rename(upgrade / "deferred-first.log") + run(args.java, upgrade, "deferred") + if fingerprint(seed / "world") != source_before or digest(args.original_jar) != original_hash: + raise RuntimeError("Original fixture changed") + report.update(original_sha256=original_hash, original_world=source_before, + original_world_unchanged=True, original_deferred_region_unchanged=before) + (output / "result.json").write_text(json.dumps(report, indent=2) + "\n") + + +if __name__ == "__main__": + main() diff --git a/tools/verify_packaged_client.py b/tools/verify_packaged_client.py new file mode 100644 index 0000000..7d1f83b --- /dev/null +++ b/tools/verify_packaged_client.py @@ -0,0 +1,141 @@ +"""Run the exact production jar and build-only verifier in a vanilla Forge client.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import urllib.request +from concurrent.futures import ThreadPoolExecutor +from audit_runtime_logs import audit +from client_runtime import assemble_libraries +from release_identity import MAIN_JAR + + +def prepare_assets(folder): + folder.mkdir(parents=True,exist_ok=True) + (folder/'indexes').mkdir(exist_ok=True) + data=urllib.request.urlopen('https://piston-meta.mojang.com/v1/packages/a21e1ded1a24ea1548dd8db0cf30b6acb02655a9/1.12.json').read() + if hashlib.sha1(data).hexdigest()!='a21e1ded1a24ea1548dd8db0cf30b6acb02655a9': raise RuntimeError('Asset index hash mismatch') + (folder/'indexes/1.12.json').write_bytes(data) + def fetch(obj): + sha=obj['hash']; path=folder/'objects'/sha[:2]/sha + if path.is_file() and hashlib.sha1(path.read_bytes()).hexdigest()==sha: return + payload=urllib.request.urlopen('https://resources.download.minecraft.net/'+sha[:2]+'/'+sha,timeout=45).read() + if len(payload)!=obj['size'] or hashlib.sha1(payload).hexdigest()!=sha: raise RuntimeError('Asset hash mismatch '+sha) + path.parent.mkdir(parents=True,exist_ok=True); path.write_bytes(payload) + with ThreadPoolExecutor(max_workers=8) as pool: + list(pool.map(fetch,json.loads(data)['objects'].values())) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--java',type=Path,required=True) + parser.add_argument('--forge-jar',type=Path,required=True) + parser.add_argument('--output',type=Path,required=True) + parser.add_argument('--gradle-home',type=Path,required=True) + parser.add_argument('--mode',choices=['default','config','skin','issues','names'],default='default') + args=parser.parse_args() + project=Path(__file__).resolve().parents[1] + root=args.output.resolve() + if root.exists(): raise RuntimeError('Preserve previous evidence; use a new output directory') + root.mkdir(parents=True) + (root/'mods').mkdir(); (root/'config').mkdir() + (root/'options.txt').write_text('realmsNotifications:false\n') + main=project/'build/libs'/MAIN_JAR + probe=project/'build/integration-test-mod/lootablebodies-build-tests-reobf.jar' + candidate_hash=hashlib.sha256(main.read_bytes()).hexdigest().upper() + for jar in [main,probe]: shutil.copy2(jar,root/'mods'/jar.name) + if hashlib.sha256((root/'mods'/main.name).read_bytes()).hexdigest().upper()!=candidate_hash: + raise RuntimeError('Candidate copy hash mismatch') + (root/'config/lootablebodies.cfg').write_text('options {\n B:local_texture=true\n}\n') + if args.mode not in ('default','names'): + (root/'config/lootablebodies.cfg').write_text('options {\n B:local_texture=false\n}\n') + cache=args.gradle_home.resolve()/'caches' + vanilla=cache/'minecraftforge/forgegradle/mavenizer/caches/minecraft_tasks/1.12.2/client.jar' + if hashlib.sha1(vanilla.read_bytes()).hexdigest()!='0f275bc1547d01fa5f56ba34bdc87d981ee12daf': + raise RuntimeError('Vanilla Minecraft 1.12.2 client hash mismatch') + # A pinned index avoids other Minecraft versions replacing a shared generic index. + assets=project/'.gradle/packaged-assets' + prepare_assets(assets) + natives=cache/'minecraftforge/forgegradle/slime-launcher/cache/net/minecraftforge/forge/1.12.2-14.23.5.2859/natives' + dependencies=[Path(p) for p in json.loads((project/'build/runtime-classpath.json').read_text())] + # Only official runtime libraries; the mapped Forge jar and development mappings stay out. + dependencies=assemble_libraries(dependencies,root) + if any(any(token in str(p).lower() for token in ['junit','mockito','test-mod','slime-launcher']) for p in dependencies): + raise RuntimeError('Testing/development library in packaged classpath') + forge=root/args.forge_jar.name + shutil.copy2(args.forge_jar,forge) + command=[str(args.java.resolve()),'-Xmx2G','-Dlootablebodies.clientCheck=true','-Dlog4j.skipJansi=true', + '-Djava.library.path='+str(natives),'-Dorg.lwjgl.librarypath='+str(natives), + '-Dlog4j.configurationFile='+(project/'src/integrationTest/resources/log4j2.xml').as_uri(), + # Vanilla client precedes Forge's server-oriented manifest Class-Path. + '-cp',os.pathsep.join(map(str,[vanilla,forge]+dependencies)), + 'net.minecraft.launchwrapper.Launch','--tweakClass','net.minecraftforge.fml.common.launcher.FMLTweaker', + '--username','BuildClient','--uuid','00000000000040008000000000000104','--accessToken','0', + '--version','1.12.2','--userType','legacy','--gameDir',str(root), + '--assetsDir',str(assets),'--assetIndex','1.12','--width','854','--height','480'] + if args.mode!='default': + option={'config':'configUiCheck','skin':'visibilitySkinCheck','issues':'issueClientCheck','names':'namelessClientCheck'}[args.mode] + command.insert(2,'-Dlootablebodies.'+option+'=true') + with (root/'client.log').open('w',encoding='utf-8') as log: + result=subprocess.run(command,cwd=root,stdout=log,stderr=subprocess.STDOUT,timeout=180) + text=(root/'client.log').read_text(errors='replace') + expected={'default':'LOOTABLE_BODIES_CLIENT_PASS','config':'LOOTABLE_BODIES_CONFIG_UI_PASS', + 'skin':'LOOTABLE_BODIES_VISIBILITY_SKIN_PASS','issues':'LOOTABLE_BODIES_ISSUE5_CLIENT_PASS','names':'LOOTABLE_BODIES_NAME_TRANSLATION_PASS'}[args.mode] + if result.returncode or not (root/'client.pass').is_file() or expected not in text: + raise RuntimeError('Packaged client failed: '+str(root/'client.log')) + if any(token in text for token in ['AssertionError','NoSuchMethodError','NoClassDefFoundError','[Client thread/FATAL]',"Couldn't render entity",'Missing sound for event:','Patcher expecting empty class data']): + raise RuntimeError('Client log contains a runtime failure') + if list((root/'crash-reports').glob('*')): raise RuntimeError('Client crash reports') + audit(root/'client.log') + if args.mode!='default': + reports={'config':'config-checks.txt','skin':'skin-checks.txt','issues':'issue5-client-checks.txt','names':'name-checks.txt'} + report=root/reports[args.mode] + if not report.is_file() or not report.read_text().strip() or any( + not line.startswith('PASS ') for line in report.read_text().splitlines()): + raise RuntimeError('Missing graphical mode assertions: '+str(report)) + if args.mode=='config': + command.insert(2,'-Dlootablebodies.configUiReload=true') + with (root/'restart.log').open('w',encoding='utf-8') as log: + restart=subprocess.run(command,cwd=root,stdout=log,stderr=subprocess.STDOUT,timeout=120) + if restart.returncode or not (root/'config-restart.pass').is_file(): + raise RuntimeError('Configuration did not survive process restart') + audit(root/'restart.log') + if list((root/'crash-reports').glob('*')) or hashlib.sha256(main.read_bytes()).hexdigest().upper()!=candidate_hash: + raise RuntimeError('Crash or candidate mutation in graphical mode') + (root/'result.json').write_text(json.dumps({'candidate_sha256':candidate_hash,'mode':args.mode, + 'checks':report.read_text().splitlines(),'screenshots':sorted(p.name for p in (root/'screenshots').glob('*.png')), + 'computer_control':'none'},indent=2)+'\n') + print('PACKAGED_CLIENT_MODE_PASS',args.mode,flush=True) + return + checks=['connected_player_death_and_respawn','network_shift_click_exact_nbt', + 'first_save_reload_corpse_and_player','network_normal_click_exact_nbt', + 'second_save_reload_corpse_and_player'] + if (root/'connected-checks.txt').read_text().splitlines()!=['PASS '+name for name in checks]: + raise RuntimeError('Missing connected-player client checks') + drop_checks=['network_accepted_throw','network_cancelled_throw_source_resync', + 'network_rejected_outside_cursor_resync','network_accepted_outside_drop','network_double_click_exact_nbt', + 'network_creative_clone_and_fill','creative_cancelled_close_full_inventory_disk_reload'] + if (root/'drop-client-checks.txt').read_text().splitlines()!=['PASS '+name for name in drop_checks]: + raise RuntimeError('Missing connected drop/creative client checks') + nbt_checks=['malformed_disk_load_gui_exact_inventory','malformed_network_recovery_preserves_raw_records', + 'malformed_corpse_and_player_disk_reload'] + if (root/'nbt-client-checks.txt').read_text().splitlines()!=['PASS '+name for name in nbt_checks]: + raise RuntimeError('Missing malformed NBT graphical recovery checks') + screenshots=['nbt-recovered-corpse.png','nbt-after-recovery-reload.png','drop-cancelled-source-resync.png','drop-rejected-cursor-resync.png','drop-creative-filled.png','drop-creative-final-reload.png','corpse-gui-fresh.png','corpse-gui-reload.png','connected-player-death.png', + 'connected-corpse-before-recovery.png','connected-corpse-after-shift-recovery.png', + 'connected-corpse-first-reload.png','connected-corpse-after-normal-recovery.png', + 'connected-corpse-final-reload.png'] + for name in screenshots: + if not (root/'screenshots'/name).is_file() or (root/'screenshots'/name).stat().st_size==0: + raise RuntimeError('Missing client screenshot '+name) + if hashlib.sha256(main.read_bytes()).hexdigest().upper()!=candidate_hash: + raise RuntimeError('Candidate changed during graphical checks') + (root/'result.json').write_text(json.dumps({'candidate_sha256':candidate_hash, + 'fresh_and_reload_gui':True,'connected_player_checks':checks,'drop_creative_checks':drop_checks,'nbt_checks':nbt_checks,'screenshots':screenshots},indent=2)+'\n') + print('PACKAGED_CLIENT_PASS',flush=True) + + +if __name__=='__main__': main() diff --git a/tools/verify_release_setup.py b/tools/verify_release_setup.py new file mode 100644 index 0000000..f18ec81 --- /dev/null +++ b/tools/verify_release_setup.py @@ -0,0 +1,130 @@ +"""Exercise release rejection paths locally; every upload task must fail before execution.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import zipfile +from release_identity import PROJECT, PROPERTIES, MOD_VERSION, MAIN_JAR + + +def sha(path): + return hashlib.sha256(path.read_bytes()).hexdigest().upper() + + +def fingerprint(folder): + return {p.relative_to(folder).as_posix(): sha(p) for p in sorted(folder.rglob("*")) if p.is_file()} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + output = args.output.resolve() + output.mkdir(parents=True, exist_ok=False) + bundle = PROJECT / "build/release/bundle" + before = fingerprint(bundle) + if len(before) != 6: + raise RuntimeError("Run prepareReleaseBundle first; exactly six bundle files are required") + wrapper = str(PROJECT / ("gradlew.bat" if os.name == "nt" else "gradlew")) + results = [] + + def run(name, tasks, expected=None, prepared=False): + log = output / (name + ".log") + with log.open("w", encoding="utf-8") as stream: + process = subprocess.run([wrapper, *tasks, "--offline", "--no-daemon", "--console=plain"], + cwd=PROJECT, stdout=stream, stderr=subprocess.STDOUT, timeout=180) + text = log.read_text(encoding="utf-8", errors="replace") + if expected is None: + if process.returncode or "BUILD SUCCESSFUL" not in text: + raise RuntimeError(f"Valid release contract failed: {log}") + elif process.returncode == 0 or expected not in text: + raise RuntimeError(f"Expected rejection {expected!r} was not proved: {log}") + if prepared and any(f"> Task :{task}" in text for task in ("compileJava", "jar", "sourcesJar", "javadocJar")): + raise RuntimeError(f"Prepared input caused a jar rebuild: {log}") + results.append({"case": name, "expected_rejection": expected, "exit": process.returncode}) + print("RELEASE_CONTRACT_PASS", name, flush=True) + + run("valid-prepared", ["verifyPreparedReleaseArtifacts", f"-PpreparedReleaseDir={bundle}"], prepared=True) + for name, override, expected in [ + ("three-component-version", "mod_version=2.4.1", "Major.Minor.Bug.112021"), + ("wrong-target", "mod_version=2.4.1.111022", "Major.Minor.Bug.112021"), + ("wrong-group", "mod_group=zone.moddev.mc.orespawn", "Release identity mismatch: mod_group"), + ("wrong-artifact", "mod_artifact=OreSpawn", "Release identity mismatch: mod_artifact"), + ("wrong-mod-id", "mod_id=orespawn", "Release identity mismatch: mod_id"), + ("wrong-project", "curseforge_project_id=245586", "Release identity mismatch: curseforge_project_id"), + ("wrong-loader", "loader_name=neoforge", "Release identity mismatch: loader_name"), + ("wrong-license", "mod_license=CC0-1.0", "Release identity mismatch: mod_license"), + ("wrong-java", "java_version=17", "Release identity mismatch: java_version"), + ("stale-java-version", "mod_version=2.4.4.112021", "Java release version or persistent identity differs"), + ]: + run(name, ["verifyReleaseConfiguration", "-P" + override], expected) + + def fixture(name): + target = output / name + shutil.copytree(bundle, target) + return target + + def sums(folder): + (folder / "SHA256SUMS").write_text("".join( + f"{sha(p)} {p.name}\n" for p in sorted(folder.glob("*.jar"))), encoding="utf-8") + + def rewrite_jar(folder, entry, replacement): + jar = folder / MAIN_JAR + temporary = jar.with_suffix(".rewritten") + with zipfile.ZipFile(jar) as source, zipfile.ZipFile(temporary, "w") as target: + for info in source.infolist(): + target.writestr(info, replacement if info.filename == entry else source.read(info.filename)) + temporary.replace(jar) + sums(folder) + + for name, expected in [ + ("missing-sources", "exactly the declared jars"), + ("empty-main", "Empty artifact"), + ("bad-checksum", "checksums do not match"), + ("changed-pom", "POM differs"), + ("changed-changelog", "changelog differs"), + ("extra-jar", "exactly the declared jars"), + ("deobfuscated-main", "runtime SRG mappings"), + ("changed-license", "licence or attribution"), + ("foreign-class", "Foreign production class"), + ]: + folder = fixture(name) + if name == "missing-sources": + (folder / MAIN_JAR.replace(".jar", "-sources.jar")).unlink() + elif name == "empty-main": + (folder / MAIN_JAR).write_bytes(b"") + elif name == "bad-checksum": + (folder / "SHA256SUMS").write_text("0" * 64 + " " + MAIN_JAR + "\n") + elif name == "changed-pom": + pom = folder / f'{PROPERTIES["mod_artifact"]}-{MOD_VERSION}.pom' + pom.write_bytes(pom.read_bytes().replace(b"Cyanobacterium", b"OtherAuthor")) + elif name == "changed-changelog": + (folder / "CHANGELOG.txt").write_text("unreviewed release notes\n") + elif name == "extra-jar": + (folder / "unexpected.jar").write_bytes(b"unexpected") + elif name == "deobfuscated-main": + shutil.copyfile(PROJECT / "build/libs-dev" / MAIN_JAR.replace(".jar", "-deobf.jar"), folder / MAIN_JAR) + sums(folder) + elif name == "changed-license": + rewrite_jar(folder, "META-INF/LICENSE", b"wrong licence\n") + elif name == "foreign-class": + with zipfile.ZipFile(folder / MAIN_JAR, "a") as jar: + jar.writestr("foreign/Injected.class", bytes.fromhex("CAFEBABE00000034")) + sums(folder) + run(name, ["verifyPreparedReleaseArtifacts", f"-PpreparedReleaseDir={folder}"], expected, prepared=True) + + for task in ["publish", "publishToMavenLocal", "publishMavenJavaPublicationToReleaseRepository"]: + run("blocked-" + task, [task, "-PpublicationEnabled=true", f"-Dmaven.repo.local={output / 'unused-maven-local'}"], + "Publication is disabled") + if (output / "unused-maven-local").exists() or fingerprint(bundle) != before: + raise RuntimeError("Publication guard wrote a repository or immutable input changed") + run("valid-prepared-final", ["verifyPreparedReleaseArtifacts", f"-PpreparedReleaseDir={bundle}"], prepared=True) + (output / "result.json").write_text(json.dumps({"checks": results, "bundle_unchanged": True, + "prepared_jar_rebuild": False, "publication_disabled": True}, indent=2) + "\n", encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/tools/verify_upgrade_abort.py b/tools/verify_upgrade_abort.py new file mode 100644 index 0000000..e2f2ee6 --- /dev/null +++ b/tools/verify_upgrade_abort.py @@ -0,0 +1,64 @@ +"""Prove packaged servers stop before world loading when preservation is unsafe.""" +import argparse +import json +from pathlib import Path +import shutil +import subprocess +from verify_world_upgrades import prepare, fingerprint, digest, FORGE +from release_identity import MAIN_JAR + + +def source_files(world): + return {name: value for name, value in fingerprint(world).items() + if not name.startswith('data/lootablebodies-upgrade')} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--java', type=Path, required=True) + parser.add_argument('--forge-home', type=Path, required=True) + parser.add_argument('--source-world', type=Path, required=True) + parser.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + project = Path(__file__).resolve().parents[1] + candidate = project / 'build/libs' / MAIN_JAR + probe = project / 'build/integration-test-mod/lootablebodies-build-tests-reobf.jar' + source = args.source_world.resolve() + sealed = fingerprint(source) + root = args.output.resolve() + root.mkdir(parents=True, exist_ok=False) + report = {'candidate_sha256': digest(candidate), 'cases': {}} + for case in ['damaged-index', 'blocked-journal']: + home = root / case + prepare(home, args.forge_home.resolve(), '1.12.2', candidate, probe) + shutil.copytree(source, home / 'world') + world = home / 'world' + journal = world / 'data/lootablebodies-upgrade' + journal.parent.mkdir(exist_ok=True) + if case == 'damaged-index': + journal.mkdir() + (journal / 'index.nbt').write_bytes(b'interrupted invalid index') + else: + journal.write_bytes(b'cannot create a journal directory here') + before = source_files(world) + log = home / 'abort.log' + with log.open('w', encoding='utf-8') as output: + result = subprocess.run([str(args.java.resolve()), '-Xmx1G', '-jar', + FORGE['1.12.2'] + '-universal.jar', 'nogui'], + cwd=home, stdout=output, stderr=subprocess.STDOUT, timeout=180) + text = log.read_text(encoding='utf-8', errors='replace') + if "Lootable Bodies could not preserve this world's corpses" not in text: + raise RuntimeError('Missing explicit preservation abort: ' + str(log)) + if any(token in text for token in ['Preparing start region', 'Preparing spawn area', + 'LOOTABLE_BODIES_RUNTIME_PASS', 'Done (']): + raise RuntimeError('World loading continued after unsafe preservation') + if before != source_files(world) or fingerprint(source) != sealed: + raise RuntimeError('A failed preflight changed source world data') + report['cases'][case] = {'exit': result.returncode, 'source_unchanged': True, + 'world_data_unchanged': True, 'world_loading_started': False} + print('UPGRADE_ABORT_PASS', case, flush=True) + (root / 'result.json').write_text(json.dumps(report, indent=2) + '\n', encoding='utf-8') + + +if __name__ == '__main__': + main() diff --git a/tools/verify_visibility.py b/tools/verify_visibility.py new file mode 100644 index 0000000..22b48cb --- /dev/null +++ b/tools/verify_visibility.py @@ -0,0 +1,104 @@ +"""Run real two-client corpse visibility and death/respawn packet checks.""" +import argparse +import json +import socket +import subprocess +import time +from pathlib import Path +import shutil +from audit_runtime_logs import audit +from verify_multiplayer import client_command +from verify_packaged import prepare, FORGE, MAIN, digest, run +from verify_packaged_client import prepare_assets + + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--java',type=Path,required=True) + parser.add_argument('--forge-home',type=Path,required=True) + parser.add_argument('--gradle-home',type=Path,required=True) + parser.add_argument('--output',type=Path,required=True) + parser.add_argument('--mode',choices=['visibility','respawn'],required=True) + parser.add_argument('--source-server',type=Path) + parser.add_argument('--cases',type=int,default=16) + parser.add_argument('--simultaneous',action='store_true') + parser.add_argument('--drop-spawn',action='store_true') + parser.add_argument('--counter-collision',action='store_true') + args=parser.parse_args() + project=Path(__file__).resolve().parents[1] + java=args.java.resolve(); distribution=args.forge_home.resolve(); home=args.output.resolve() + home.mkdir(parents=True,exist_ok=False); shared=home/'sync'; shared.mkdir() + jar=project/'build/libs'/MAIN; before=digest(jar) + probe=project/'build/integration-test-mod/lootablebodies-build-tests-reobf.jar' + prepare(home/'server',distribution,jar,probe) + source_before=None + if args.source_server: + from verify_world_upgrades import fingerprint + source_before=fingerprint(args.source_server/'world') + shutil.copytree(args.source_server/'world',home/'server/world') + with socket.socket() as reservation: + reservation.bind(('127.0.0.1',0)); port=reservation.getsockname()[1] + props=(home/'server/server.properties').read_text().replace('server-port=0','server-port='+str(port))+'view-distance=4\n' + (home/'server/server.properties').write_text(props) + commands={'server':[str(java),'-Xmx1G','-Dlootablebodies.'+args.mode+'=true', + '-Dlootablebodies.multiplayer.sync='+str(shared),'-Dlootablebodies.respawn.cases='+str(args.cases), + '-jar',FORGE+'-universal.jar','nogui']} + if args.source_server: commands['server'].insert(2,'-Dlootablebodies.visibility.reload=true') + if args.simultaneous: commands['server'].insert(2,'-Dlootablebodies.respawn.simultaneous=true') + prepare_assets(project/'.gradle/packaged-assets') + for role in ['A','B']: + command=client_command(project,java,distribution,home/('client'+role),args.gradle_home.resolve()/'caches',shared,port,role) + command.insert(2,'-Dlootablebodies.'+args.mode+'=true') + if args.simultaneous: command.insert(2,'-Dlootablebodies.respawn.simultaneous=true') + if args.counter_collision: command.insert(2,'-Dlootablebodies.respawn.counterCollision=true') + if args.drop_spawn and role=='A': command.insert(2,'-Dlootablebodies.respawn.dropSpawn=true') + commands['client'+role]=command + (home/('client'+role)/'config/lootablebodies.cfg').write_text('options {\n B:local_texture=false\n}\n') + processes={}; logs=[] + def start(name): + log=(home/(name+'.log')).open('w',encoding='utf-8');logs.append(log) + processes[name]=subprocess.Popen(commands[name],cwd=home/name,stdout=log,stderr=subprocess.STDOUT) + try: + start('server');deadline=time.monotonic()+60 + while not (shared/'server.ready').is_file(): + if processes['server'].poll() is not None or time.monotonic()>deadline: raise RuntimeError('Server did not start') + time.sleep(.2) + start('clientA');start('clientB');deadline=time.monotonic()+650 + while any(process.poll() is None for process in processes.values()): + if processes['server'].poll() is not None and not (shared/(args.mode+'.pass')).is_file(): + raise RuntimeError('Server failed: '+str(home/'server.log')) + if any(process.poll() not in (None,0) for process in processes.values()): raise RuntimeError('Owned client failed') + if time.monotonic()>deadline: raise RuntimeError('Visibility/respawn check timed out') + time.sleep(.2) + if any(process.returncode for process in processes.values()): raise RuntimeError('Process exit code') + finally: + (shared/'abort').touch() + for process in processes.values(): + if process.poll() is None: process.terminate() + for process in processes.values(): + try: process.wait(timeout=10) + except subprocess.TimeoutExpired: process.kill();process.wait() + for log in logs: log.close() + if not (shared/(args.mode+'.pass')).is_file(): raise RuntimeError('Missing server result') + for name in ['server','clientA','clientB']: + if digest(home/name/'mods'/MAIN)!=before: raise RuntimeError('Candidate copy changed') + audit(home/(name+'.log'));audit(home/name/'logs/latest.log') + if list((home/name).rglob('crash-reports/*.txt')): raise RuntimeError('Crash report') + for role in ['A','B']: + if not (home/('client'+role)/(args.mode+'-client.pass')).is_file(): raise RuntimeError('Client did not finish '+role) + if args.mode=='visibility' and not args.source_server: + (home/'server/server.properties').write_text(props.replace('server-port='+str(port),'server-port=0')) + run(java,home/'server','visibility-reload') + if args.source_server and fingerprint(args.source_server/'world')!=source_before: + raise RuntimeError('Source world changed') + if digest(jar)!=before: raise RuntimeError('Candidate changed') + checks=(shared/'checks.txt').read_text().splitlines() + if not checks or (args.mode=='respawn' and len(checks)!=args.cases): raise RuntimeError('Incomplete case matrix') + (home/'result.json').write_text(json.dumps({'candidate_sha256':before,'mode':args.mode, + 'checks':checks,'two_tcp_clients':True,'simultaneous':args.simultaneous,'drop_spawn':args.drop_spawn, + 'counter_collision':args.counter_collision,'connected_restart':bool(args.source_server), + 'computer_control':'none'},indent=2)+'\n') + print('VISIBILITY_RESPAWN_PASS',args.mode,flush=True) + + +if __name__=='__main__':main() diff --git a/tools/verify_world_upgrades.py b/tools/verify_world_upgrades.py new file mode 100644 index 0000000..6723f5c --- /dev/null +++ b/tools/verify_world_upgrades.py @@ -0,0 +1,299 @@ +"""Sealed historical-writer fixtures and exact packaged forward-upgrade matrix.""" +import argparse +import hashlib +import json +from pathlib import Path +import shutil +import subprocess +import gzip +import struct +from audit_runtime_logs import audit, FORGE_UPGRADE_RENAME +from release_identity import MAIN_JAR +from nbt_regions import Tag, decode, encode, read_region, write_disposable_region + +ORIGINS = { + 'original-110': ('1.10.2', '2.2.0', '688AFBBF4485D1F3746C68903C9E0885D57F42CCFBFA0CB4A0FC42982C0B3F2B'), + 'released-110': ('1.10.2', '2.4.1.110021', '7F9F0EE7BF09F943ED85398982EB7C2207F3DD55B75E9E92A7531C393BE1045B'), + 'original-111': ('1.11.2', '2.3.0', 'C82CC329BA53A218CADB681D646F64A9C457341577BC4F9AA07E2F9926434CCF'), + 'recovery-110': ('1.10.2', '2.4.1.110021', '7F9F0EE7BF09F943ED85398982EB7C2207F3DD55B75E9E92A7531C393BE1045B'), +} +ORIGINS.update({ + 'via111-110': ('1.10.2','2.2.0','688AFBBF4485D1F3746C68903C9E0885D57F42CCFBFA0CB4A0FC42982C0B3F2B'), + 'released-111': ('1.11.2', '2.4.2.111021', '59ACF5652505F08A7F011307B3CD68F3754FCFC14C4C330BD952475351AEC932'), + 'original-112': ('1.12.2', '2.4.0', 'DE701F657A03BFFE08D6F9FEB1516B37B23B0171B91716087C7C84D9C19B9B5C'), + 'recovery-111': ('1.11.2', '2.4.2.111021', '59ACF5652505F08A7F011307B3CD68F3754FCFC14C4C330BD952475351AEC932'), +}) +FORGE = {'1.12.2': 'forge-1.12.2-14.23.5.2859','1.10.2': 'forge-1.10.2-12.18.3.2511', '1.11.2': 'forge-1.11.2-13.20.1.2588'} +FORGE_SHA = {'1.12.2': 'BD8314729D787E35164476D75AFB337AFB9783917E4F6FA1F77F049ADBA22F2D','1.10.2': '809C2EA5B4D4F3E5A294D3A326744F20FEC412A1263E708297D00F512E377542', + '1.11.2': 'B6DFF35B5CCB0E6353FB89B5330FE930DB8FBE8B63787C9FC2F70196BD108998'} + + +def digest(path): + return hashlib.sha256(path.read_bytes()).hexdigest().upper() + + +def fingerprint(path): + return {p.relative_to(path).as_posix(): digest(p) for p in sorted(path.rglob('*')) if p.is_file()} + + +def prepare(home, distribution, minecraft, mod, probe): + home.mkdir(parents=True, exist_ok=False) + (home / 'mods').mkdir() + forge = FORGE[minecraft] + '-universal.jar' + if digest(distribution / forge) != FORGE_SHA[minecraft]: + raise RuntimeError('Incorrect Forge distribution') + for name in [forge, f'minecraft_server.{minecraft}.jar']: + shutil.copy2(distribution / name, home / name) + shutil.copytree(distribution / 'libraries', home / 'libraries') + shutil.copy2(mod, home / 'mods' / mod.name) + shutil.copy2(probe, home / 'mods' / probe.name) + (home / 'eula.txt').write_text('eula=true\n', encoding='utf-8') + (home / 'server.properties').write_text('level-name=world\nlevel-seed=4267321\nlevel-type=FLAT\nonline-mode=false\nserver-ip=127.0.0.1\nserver-port=0\ngenerate-structures=false\nspawn-animals=false\nspawn-monsters=false\nspawn-protection=0\nmax-tick-time=120000\n', encoding='utf-8') + for name in ['ops', 'whitelist', 'banned-players', 'banned-ips']: + (home / (name + '.json')).write_text('[]\n', encoding='utf-8') + + +def seed(java, home, minecraft, version, recovery=False): + with (home / 'seed.log').open('w', encoding='utf-8') as log: + completed = subprocess.run([str(java), '-Xmx2G', '-Dlootablebodies.fixture.version=' + version, + '-Dlootablebodies.fixture.recovery=' + str(recovery).lower(), + '-jar', FORGE[minecraft] + '-universal.jar', 'nogui'], cwd=home, + stdout=log, stderr=subprocess.STDOUT, timeout=240) + log = home / 'seed.log' + text = log.read_text(encoding='utf-8', errors='replace') + expected = f'LOOTABLE_BODIES_LEGACY_SEED_PASS minecraft={minecraft} mod={version} corpses=9 dimensions=3' + if completed.returncode or expected not in text or not (home / 'world/legacy-seed.pass').is_file(): + raise RuntimeError(f'Historical writer failed: {log}') + if list((home / 'crash-reports').glob('*')): + raise RuntimeError(f'Historical writer crashed: {home}') + audit(log) + return fingerprint(home / 'world') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--java', type=Path, required=True) + parser.add_argument('--forge110-home', type=Path, required=True) + parser.add_argument('--forge111-home', type=Path, required=True) + parser.add_argument('--forge112-home', type=Path, required=True) + for origin in ORIGINS: + if origin.startswith('recovery-') or origin=='via111-110': + continue + parser.add_argument('--' + origin, type=Path, required=True) + parser.add_argument('--origins', nargs='+', choices=list(ORIGINS), default=list(ORIGINS)) + parser.add_argument('--output', type=Path, required=True) + parser.add_argument('--seed-only', action='store_true') + parser.add_argument('--seeds', nargs='+', type=Path) + parser.add_argument('--variants', nargs='+', choices=['normal', 'stamps'], default=['normal', 'stamps']) + parser.add_argument('--control-report', type=Path) + args = parser.parse_args() + project = Path(__file__).resolve().parents[1] + if not args.seed_only: + verify(args, project) + return + root = args.output.resolve() + root.mkdir(parents=True, exist_ok=False) + seeder = project / 'build/legacy-seed/lootablebodies-world-fixtures-reobf.jar' + report = {'seeder_sha256': digest(seeder), 'origins': {}} + for origin, (minecraft, version, expected) in ORIGINS.items(): + if origin not in args.origins: + continue + if origin=='via111-110': + details=seed_transition(args,project,root,seeder) + report['origins'][origin]=details + (root/'seed-report.json').write_text(json.dumps(report,indent=2)+'\n',encoding='utf-8') + print('LEGACY_WORLD_SEALED',origin,len(details['world']),'files',flush=True) + continue + source = getattr(args, origin.replace('recovery-', 'released-').replace('-', '_')).resolve() + if digest(source) != expected: + raise RuntimeError('Historical jar checksum mismatch: ' + origin) + distribution = {'1.10.2':args.forge110_home,'1.11.2':args.forge111_home,'1.12.2':args.forge112_home}[minecraft] + home = root / origin + prepare(home, distribution.resolve(), minecraft, source, seeder) + hashes = seed(args.java.resolve(), home, minecraft, version, origin.startswith('recovery-')) + report['origins'][origin] = {'minecraft': minecraft, 'mod_version': version, 'jar_sha256': expected, 'world': hashes} + (root / 'seed-report.json').write_text(json.dumps(report, indent=2) + '\n', encoding='utf-8') + print('LEGACY_WORLD_SEALED', origin, len(hashes), 'files', flush=True) + + +def seed_transition(args,project,root,seeder): + original=args.original_110.resolve();released=args.released_111.resolve() + if digest(original)!=ORIGINS['original-110'][2] or digest(released)!=ORIGINS['released-111'][2]: + raise RuntimeError('Transition production jar checksum mismatch') + source=root/'via111-110-source' + prepare(source,args.forge110_home.resolve(),'1.10.2',original,seeder) + sealed=seed(args.java.resolve(),source,'1.10.2','2.2.0') + target=root/'via111-110' + probe=project/'build/transition-fixture/lootablebodies-111-transition-reobf.jar' + prepare(target,args.forge111_home.resolve(),'1.11.2',released,probe) + shutil.copytree(source/'world',target/'world') + distant={str(p.relative_to(target/'world')):digest(p) for p in (target/'world').rglob('region/r.*.mca') if p.name in ('r.8.8.mca','r.16.16.mca')} + with (target/'transition.log').open('w',encoding='utf-8') as log: + result=subprocess.run([str(args.java.resolve()),'-Xmx2G','-jar',FORGE['1.11.2']+'-universal.jar','nogui'],cwd=target,stdout=log,stderr=subprocess.STDOUT,timeout=180) + text=(target/'transition.log').read_text(encoding='utf-8',errors='replace') + if result.returncode or 'LOOTABLE_BODIES_TRANSITION_PASS loaded=3 unopened=6' not in text or not (target/'world/transition.pass').is_file(): + raise RuntimeError('Real released 1.11 intermediate writer failed: '+str(target/'transition.log')) + audit(target/'transition.log',upgrade=True) + if fingerprint(source/'world')!=sealed or len(distant)!=6 or any(digest(target/'world'/p)!=value for p,value in distant.items()): + raise RuntimeError('Transition changed its sealed source or unopened distant regions') + if not (target/'world/data/lootablebodies-upgrade/index.nbt').is_file() or list(target.rglob('crash-reports/*.txt')): + raise RuntimeError('Missing intermediate journal or unexpected crash') + return {'minecraft':'1.10.2 via 1.11.2','mod_version':'2.2.0 via 2.4.2.111021', + 'jar_sha256':digest(original),'intermediate_jar_sha256':digest(released),'transition_probe_sha256':digest(probe), + 'source_world_unchanged':True,'distant_regions_unchanged':distant,'world':fingerprint(target/'world')} + + +PHASES = ['upgrade-near', 'upgrade-near-reload', 'upgrade-near-recover', 'upgrade-near-recovered', + 'upgrade-far', 'upgrade-far-reload', 'upgrade-late', 'upgrade-return', 'upgrade-final', 'upgrade-final-reload'] + + +def run_upgrade(java, home, phase): + marker = home / 'world' / (phase + '.pass') + if marker.exists(): + raise RuntimeError('Refusing stale phase marker') + log = home / (phase + '.log') + with log.open('w', encoding='utf-8') as output: + result = subprocess.run([str(java), '-Xmx2G', '-Dlootablebodies.phase=' + phase, + '-jar', FORGE['1.12.2'] + '-universal.jar', 'nogui'], cwd=home, + stdout=output, stderr=subprocess.STDOUT, timeout=240) + text = log.read_text(encoding='utf-8', errors='replace') + if result.returncode or not marker.is_file() or 'LOOTABLE_BODIES_WORLD_UPGRADE_PASS phase=' + phase not in text or 'LOOTABLE_BODIES_RUNTIME_PASS phase=' + phase not in text: + raise RuntimeError('World upgrade failed: ' + str(log)) + audit(log, upgrade=True) + for name in ['latest.log','debug.log','fml-server-latest.log']: + debug=home/'logs'/name + if debug.is_file(): + shutil.copy2(debug,home/(phase+'.'+name)) + audit(debug,upgrade=True) + if list((home / 'crash-reports').glob('*')): + raise RuntimeError('World upgrade crash report: ' + str(home)) + + +def patch_stamps(world, disposable): + if not world.resolve().is_relative_to(disposable.resolve()): + raise RuntimeError('Fixture mutation outside disposable output') + changes = [] + for path in sorted(world.rglob('region/*.mca')): + chunks = read_region(path) + changed = False + for chunk in chunks.values(): + root = chunk['root'].value + level = root['Level'].value + bodies = [e for e in level.get('Entities', Tag(9, (10, []))).value[1] + if e.value.get('id', Tag(8, '')).value in ('lootablebodies.Corpse', 'lootablebodies:corpse')] + if not bodies: + continue + dimension = -1 if 'DIM-1' in path.parts else 1 if 'DIM1' in path.parts else 0 + wave = 0 if level['xPos'].value == 8 else 1 if level['xPos'].value == 256 else 2 + kind = wave * 3 + {0: 0, -1: 1, 1: 2}[dimension] + root.pop('DataVersion', None) + root.pop('ForgeDataVersion', None) + if kind == 1: + root['DataVersion'] = Tag(8, 'damaged chunk stamp') + elif kind == 2: + root['DataVersion'] = Tag(6, struct.pack('>d', float('nan'))) + elif kind == 3: + root['DataVersion'] = Tag(3, -1) + elif kind == 4: + root['ForgeDataVersion'] = Tag(8, 'damaged Forge stamp') + elif kind == 5: + root['DataVersion'] = Tag(3, 1343) + root['ForgeDataVersion'] = Tag(10, {'minecraft': Tag(3, 1343), 'lootablebodies': Tag(3, 2)}) + elif kind == 6: + root['DataVersion'] = Tag(3, 2147483647) + elif kind == 7: + root['DataVersion'] = Tag(3, 0) + elif kind == 8: + root['DataVersion'] = Tag(2, 512) + changed = True + changes.append({'region': path.relative_to(world).as_posix(), 'chunk': [level['xPos'].value, level['zPos'].value], 'stamp_case': kind}) + if changed: + write_disposable_region(path, chunks) + path = world / 'level.dat' + name, level = decode(gzip.decompress(path.read_bytes())) + level.value['Data'].value['DataVersion'] = Tag(8, 'damaged world stamp') + path.write_bytes(gzip.compress(encode(name, level), mtime=0)) + return changes + + +def verify(args, project): + if not args.seeds: + raise ValueError('--seeds is required for the upgrade matrix') + if not args.control_report: + raise ValueError('Run the independent Forge upgrade control first and supply --control-report') + control = json.loads(args.control_report.read_text(encoding='utf-8')) + if control.get('lootable_bodies_installed') is not False or control.get('source_unchanged') is not True or control.get('reproduced_diagnostic') != FORGE_UPGRADE_RENAME or control.get('control_jar_sha256') != digest(project / 'build/control-test-mod/forge-platform-control-reobf.jar'): + raise RuntimeError('Invalid independent Forge control evidence') + root = args.output.resolve() + root.mkdir(parents=True, exist_ok=False) + candidate = project / 'build/libs' / MAIN_JAR + probe = project / 'build/integration-test-mod/lootablebodies-build-tests-reobf.jar' + candidate_hash = digest(candidate) + probe_hash = digest(probe) + sources = {} + for seed_root in args.seeds: + seed_root = seed_root.resolve() + report = json.loads((seed_root / 'seed-report.json').read_text(encoding='utf-8')) + for name, details in report['origins'].items(): + if name not in args.origins: + continue + if name in sources: + raise ValueError('Ambiguous duplicate origin ' + name) + source = seed_root / name / 'world' + if fingerprint(source) != details['world']: + raise RuntimeError('Sealed source fixture changed: ' + name) + sources[name] = (source, details) + if set(sources) != set(args.origins): + raise RuntimeError('Missing requested historical fixture') + report = {'candidate_sha256': candidate_hash, 'probe_sha256':probe_hash, 'cases': {}} + for origin, (source, details) in sources.items(): + for variant in args.variants: + name = origin + '-' + variant + home = root / name + prepare(home, args.forge112_home.resolve(), '1.12.2', candidate, probe) + shutil.copytree(source, home / 'world') + world = home / 'world' + player_files = list((world / 'playerdata').glob('*.dat')) + if len(player_files) != 1: + raise RuntimeError('Expected exactly one sealed fixture player') + shutil.copy2(player_files[0], world / 'upgrade-player-source.nbt') + changes = patch_stamps(world, root) if variant == 'stamps' else [] + distant = {str(path.relative_to(world)): digest(path) for path in world.rglob('region/r.*.mca') + if path.name in ('r.8.8.mca', 'r.16.16.mca')} + if len(distant) != 6: + raise RuntimeError('Missing distant region fixtures') + phases = [] + for phase in PHASES: + run_upgrade(args.java.resolve(), home, phase) + wave = 0 if phase.startswith('upgrade-near') else 1 if phase.startswith('upgrade-far') else 2 if phase == 'upgrade-late' else -1 + persisted = 0 + for region in world.rglob('region/r.*.mca'): + if wave >= 0 and region.name != ['r.0.0.mca', 'r.8.8.mca', 'r.16.16.mca'][wave]: + continue + for chunk in read_region(region).values(): + for entity in chunk['root'].value['Level'].value.get('Entities', Tag(9, (10, []))).value[1]: + if entity.value.get('id', Tag(8, '')).value not in ('lootablebodies.Corpse', 'lootablebodies:corpse'): + continue + if wave >= 0 or phase in ('upgrade-return', 'upgrade-final', 'upgrade-final-reload'): + if entity.value['id'].value != 'lootablebodies:corpse' or entity.value.get('LootableBodiesDataVersion') != Tag(3, 2): + raise RuntimeError('A loaded corpse was not actually saved in the new format') + persisted += 1 + if persisted < (3 if wave >= 0 else 8): + raise RuntimeError('Missing persisted corpses after world save') + for relative, original in distant.items(): + unopened = (relative.endswith('r.8.8.mca') and phase.startswith('upgrade-near')) or ( + relative.endswith('r.16.16.mca') and phase in PHASES[:6]) + if unopened and digest(world / relative) != original: + raise RuntimeError('Unopened distant region was modified: ' + relative) + phases.append(phase) + if fingerprint(source) != details['world'] or digest(candidate) != candidate_hash or digest(probe) != probe_hash: + raise RuntimeError('Sealed source/candidate changed') + report['cases'][name] = {'phases': phases, 'distant_regions': distant, 'stamp_mutations': changes, + 'source_unchanged': True, 'origin_jar_sha256': details['jar_sha256']} + (root / 'result.json').write_text(json.dumps(report, indent=2) + '\n', encoding='utf-8') + print('WORLD_UPGRADE_PASS', name, phase, flush=True) + print('WORLD_UPGRADE_MATRIX_PASS cases=' + str(len(report['cases'])), flush=True) + + +if __name__ == '__main__': + main() From f842839a33f670ee92d2471e514c2e041d2127f4 Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Thu, 22 Oct 2026 22:39:02 +0100 Subject: [PATCH 2/8] Preserve existing journals in upgrade abort tests Keep the copied journal before injecting deliberately invalid data, so worlds already saved by the maintained versions run through the same checks. --- tools/test_upgrade_abort.py | 61 +++++++++++++++++++++++++++++++++++ tools/verify_upgrade_abort.py | 30 +++++++++++++---- 2 files changed, 84 insertions(+), 7 deletions(-) create mode 100644 tools/test_upgrade_abort.py diff --git a/tools/test_upgrade_abort.py b/tools/test_upgrade_abort.py new file mode 100644 index 0000000..63f6f84 --- /dev/null +++ b/tools/test_upgrade_abort.py @@ -0,0 +1,61 @@ +import shutil +import tempfile +import unittest +from pathlib import Path +from verify_upgrade_abort import inject_unsafe_journal + + +class UpgradeAbortFixtureTests(unittest.TestCase): + def check_fixture(self, case, existing): + with tempfile.TemporaryDirectory() as directory: + home = Path(directory) + source = home / 'source' + journal = source / 'data/lootablebodies-upgrade' + journal.parent.mkdir(parents=True) + if existing: + journal.mkdir() + (journal / 'index.nbt').write_bytes(b'original journal index') + (journal / 'original-corpse.nbt').write_bytes(b'original corpse payload') + run = home / 'run' + world = run / 'world' + shutil.copytree(source, world) + inject_unsafe_journal(world, case) + changed = world / 'data/lootablebodies-upgrade' + if case == 'damaged-index': + self.assertEqual(b'interrupted invalid index', (changed / 'index.nbt').read_bytes()) + else: + self.assertEqual(b'cannot create a journal directory here', changed.read_bytes()) + if existing: + saved = run / 'original-journal' + self.assertEqual(b'original journal index', (saved / 'index.nbt').read_bytes()) + self.assertEqual(b'original corpse payload', (saved / 'original-corpse.nbt').read_bytes()) + self.assertEqual(b'original journal index', (journal / 'index.nbt').read_bytes()) + self.assertEqual(b'original corpse payload', (journal / 'original-corpse.nbt').read_bytes()) + else: + self.assertFalse((run / 'original-journal').exists()) + + def test_existing_journal_damaged_index(self): + self.check_fixture('damaged-index', True) + + def test_existing_journal_blocked_directory(self): + self.check_fixture('blocked-journal', True) + + def test_new_journal_damaged_index(self): + self.check_fixture('damaged-index', False) + + def test_new_journal_blocked_directory(self): + self.check_fixture('blocked-journal', False) + + def test_unknown_case_preserves_existing_journal(self): + with tempfile.TemporaryDirectory() as directory: + world = Path(directory) / 'world' + journal = world / 'data/lootablebodies-upgrade' + journal.mkdir(parents=True) + (journal / 'index.nbt').write_bytes(b'original') + with self.assertRaises(ValueError): + inject_unsafe_journal(world, 'unknown') + self.assertEqual(b'original', (journal / 'index.nbt').read_bytes()) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/verify_upgrade_abort.py b/tools/verify_upgrade_abort.py index e2f2ee6..cd91e55 100644 --- a/tools/verify_upgrade_abort.py +++ b/tools/verify_upgrade_abort.py @@ -13,6 +13,28 @@ def source_files(world): if not name.startswith('data/lootablebodies-upgrade')} +def inject_unsafe_journal(world, case): + if case not in ('damaged-index', 'blocked-journal'): + raise ValueError('Unknown abort fixture: ' + case) + world = world.resolve() + journal = world / 'data/lootablebodies-upgrade' + journal.parent.mkdir(exist_ok=True) + if journal.exists(): + # This is the disposable world copy; retain its prior journal outside + # the world before injecting the deliberate fault. Never touch source. + saved = world.parent / 'original-journal' + if journal.is_symlink() or not journal.resolve().is_relative_to(world): + raise RuntimeError('Abort fixture journal is outside its disposable world') + if saved.parent.resolve() != world.parent or saved.exists(): + raise RuntimeError('Abort fixture journal backup is not a new sibling') + journal.rename(saved) + if case == 'damaged-index': + journal.mkdir() + (journal / 'index.nbt').write_bytes(b'interrupted invalid index') + else: + journal.write_bytes(b'cannot create a journal directory here') + + def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--java', type=Path, required=True) @@ -33,13 +55,7 @@ def main(): prepare(home, args.forge_home.resolve(), '1.12.2', candidate, probe) shutil.copytree(source, home / 'world') world = home / 'world' - journal = world / 'data/lootablebodies-upgrade' - journal.parent.mkdir(exist_ok=True) - if case == 'damaged-index': - journal.mkdir() - (journal / 'index.nbt').write_bytes(b'interrupted invalid index') - else: - journal.write_bytes(b'cannot create a journal directory here') + inject_unsafe_journal(world, case) before = source_files(world) log = home / 'abort.log' with log.open('w', encoding='utf-8') as output: From 8c7a427da8d1dd640fe3bd06162b8fc8fa9f4acf Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Thu, 22 Oct 2026 23:11:57 +0100 Subject: [PATCH 3/8] Use the official Ubuntu archive for client checks Avoid the slow Azure package mirror when installing the display and audio dependencies on hosted runners. Keep the same packages and test limits. --- .github/workflows/build.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a6df05f..de38831 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -128,6 +128,9 @@ jobs: - name: Install legacy client display and audio support timeout-minutes: 10 run: | + if [ -f /etc/apt/apt-mirrors.txt ]; then + echo 'https://archive.ubuntu.com/ubuntu/' | sudo tee /etc/apt/apt-mirrors.txt > /dev/null + fi sudo apt-get update -o Acquire::Retries=2 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 sudo apt-get install --yes --no-install-recommends -o Acquire::Retries=2 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 x11-xserver-utils libgl1-mesa-dri libglu1-mesa libopenal1 pulseaudio pulseaudio-utils command -v xrandr From c1154880ff4ab5c169ad1b028d2103cd43516b23 Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Thu, 22 Oct 2026 23:53:21 +0100 Subject: [PATCH 4/8] Handle Forge login failures in client checks --- .github/workflows/build.yml | 2 +- .../buildtest/FixtureConnections.java | 34 +++++++++ .../buildtest/MultiplayerClientChecks.java | 4 +- .../buildtest/RespawnPacketClientChecks.java | 4 +- .../buildtest/VisibilityClientChecks.java | 4 +- tools/audit_runtime_logs.py | 10 ++- tools/fixture_login.py | 75 +++++++++++++++++++ tools/test_audit_runtime_logs.py | 8 ++ tools/test_fixture_login.py | 51 +++++++++++++ tools/verify_multiplayer.py | 8 +- tools/verify_visibility.py | 8 +- 11 files changed, 194 insertions(+), 14 deletions(-) create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/FixtureConnections.java create mode 100644 tools/fixture_login.py create mode 100644 tools/test_fixture_login.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index de38831..33ff08e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -217,7 +217,7 @@ jobs: - name: Audit complete runtime file logs and crash directories run: >- - python3 tools/audit_runtime_logs.py --recursive + python3 tools/audit_runtime_logs.py --recursive --fixture-logins build/packaged-runtime build/packaged-nbt build/ordinary-packaged build/packaged-client build/packaged-client-config build/packaged-client-skin build/packaged-client-issues build/packaged-client-names build/packaged-multiplayer build/packaged-respawn build/packaged-respawn-restart diff --git a/src/integrationTest/java/cyano/lootable/buildtest/FixtureConnections.java b/src/integrationTest/java/cyano/lootable/buildtest/FixtureConnections.java new file mode 100644 index 0000000..d685cdd --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/FixtureConnections.java @@ -0,0 +1,34 @@ +package cyano.lootable.buildtest; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiDisconnected; +import net.minecraft.client.gui.GuiMainMenu; +import net.minecraft.client.multiplayer.ServerData; +import net.minecraftforge.fml.client.FMLClientHandler; + +/** Uses ordinary Forge connection setup; retries only a runner-confirmed pre-login encoding failure. */ +final class FixtureConnections { + private FixtureConnections() {} + static void connect(Minecraft mc) { + String address="127.0.0.1:"+Integer.parseInt(System.getProperty("lootablebodies.multiplayer.port")); + FMLClientHandler.instance().connectToServer(new GuiMainMenu(),new ServerData("Disposable check",address,false)); + } + static boolean retry(Minecraft mc,String role) throws Exception { + if(!(mc.currentScreen instanceof GuiDisconnected)||mc.world!=null)return false; + File sync=new File(System.getProperty("lootablebodies.multiplayer.sync")); + File permit=new File(sync,"login-retry-"+role),used=new File(sync,"login-retried-"+role); + if(!permit.isFile())return false; + int requested=read(permit),previous=used.isFile()?read(used):0; + if(requested<=previous)return false; + RuntimeChecks.require(requested==previous+1&&requested<=3,"Invalid fixture login retry permit"); + Files.write(used.toPath(),Integer.toString(requested).getBytes(StandardCharsets.UTF_8)); + System.out.println("FIXTURE_FORGE_LOGIN_RETRY role="+role+" attempt="+requested); + mc.displayGuiScreen(new GuiMainMenu());return true; + } + private static int read(File file) throws Exception { + return Integer.parseInt(new String(Files.readAllBytes(file.toPath()),StandardCharsets.UTF_8).trim()); + } +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/MultiplayerClientChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/MultiplayerClientChecks.java index 60555c4..81337e7 100644 --- a/src/integrationTest/java/cyano/lootable/buildtest/MultiplayerClientChecks.java +++ b/src/integrationTest/java/cyano/lootable/buildtest/MultiplayerClientChecks.java @@ -9,7 +9,6 @@ import java.util.Set; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.GuiMainMenu; -import net.minecraft.client.multiplayer.GuiConnecting; import net.minecraft.entity.Entity; import net.minecraft.inventory.ClickType; import net.minecraft.util.EnumHand; @@ -36,9 +35,10 @@ final class MultiplayerClientChecks { if(finished){mc.shutdown();return;} RuntimeChecks.require(++ticks<6000,"Multiplayer client timeout role="+role+" phase="+lastPhase); if(new File(MultiplayerChecks.sync(),"abort").exists())throw new AssertionError("Multiplayer runner abort"); + if(FixtureConnections.retry(mc,role))connecting=false; if(!connecting && mc.currentScreen instanceof GuiMainMenu){ connecting=true;mc.gameSettings.pauseOnLostFocus=false;mc.gameSettings.limitFramerate=30;mc.gameSettings.renderDistanceChunks=4; - mc.displayGuiScreen(new GuiConnecting(new GuiMainMenu(),mc,"127.0.0.1",Integer.parseInt(System.getProperty("lootablebodies.multiplayer.port")))); + FixtureConnections.connect(mc); } if(mc.world==null || mc.player==null)return; int phase;try{phase=Integer.parseInt(new String(Files.readAllBytes(new File(MultiplayerChecks.sync(),"phase").toPath()),StandardCharsets.UTF_8).trim());}catch(IOException busy){return;} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/RespawnPacketClientChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/RespawnPacketClientChecks.java index f2c8719..1ad940f 100644 --- a/src/integrationTest/java/cyano/lootable/buildtest/RespawnPacketClientChecks.java +++ b/src/integrationTest/java/cyano/lootable/buildtest/RespawnPacketClientChecks.java @@ -10,7 +10,6 @@ import java.util.*; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.*; -import net.minecraft.client.multiplayer.GuiConnecting; import net.minecraft.entity.Entity; import net.minecraft.item.ItemStack; import net.minecraft.util.*; @@ -77,9 +76,10 @@ private void respawn(Minecraft mc) { int nextIndex=Integer.parseInt(state[0]),nextPhase=Integer.parseInt(state[1]); if(nextIndex!=index||nextPhase!=phase){index=nextIndex;phase=nextPhase;frames=0;guiFrames=0;System.out.println("RESPAWN_CLIENT_PHASE "+role+" "+index+" "+phase);} uuid=state[2].equals("none")?null:UUID.fromString(state[2]);entityId=Integer.parseInt(state[3]);frames++; + if(FixtureConnections.retry(mc,role))connecting=false; if(!connecting&&mc.currentScreen instanceof GuiMainMenu) { connecting=true;mc.gameSettings.pauseOnLostFocus=false;mc.gameSettings.limitFramerate=30;mc.gameSettings.renderDistanceChunks=4; - mc.displayGuiScreen(new GuiConnecting(new GuiMainMenu(),mc,"127.0.0.1",Integer.parseInt(System.getProperty("lootablebodies.multiplayer.port")))); + FixtureConnections.connect(mc); } if(mc.world==null||mc.player==null)return; if(!traced) { diff --git a/src/integrationTest/java/cyano/lootable/buildtest/VisibilityClientChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/VisibilityClientChecks.java index aee1b2a..75f2bbb 100644 --- a/src/integrationTest/java/cyano/lootable/buildtest/VisibilityClientChecks.java +++ b/src/integrationTest/java/cyano/lootable/buildtest/VisibilityClientChecks.java @@ -8,7 +8,6 @@ import java.util.*; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.*; -import net.minecraft.client.multiplayer.GuiConnecting; import net.minecraft.entity.Entity; import net.minecraft.inventory.ClickType; import net.minecraft.item.ItemStack; @@ -39,9 +38,10 @@ final class VisibilityClientChecks { RuntimeChecks.require(!VisibilityChecks.file("abort").exists(),"Visibility runner aborted"); int n=Integer.parseInt(new String(Files.readAllBytes(VisibilityChecks.file("phase").toPath()),StandardCharsets.UTF_8).trim()); if(n!=phase){phase=n;frames=0;guiFrames=0;respawnSent=false;rendered.clear();System.out.println("VISIBILITY_CLIENT_PHASE "+role+" "+phase);}frames++; + if(FixtureConnections.retry(mc,role))connecting=false; if(!connecting && mc.world==null && mc.currentScreen instanceof GuiMainMenu && (phase==0 || phase==9 || phase==11)) { connecting=true;mc.gameSettings.pauseOnLostFocus=false;mc.gameSettings.limitFramerate=30;mc.gameSettings.renderDistanceChunks=4; - mc.displayGuiScreen(new GuiConnecting(new GuiMainMenu(),mc,"127.0.0.1",Integer.parseInt(System.getProperty("lootablebodies.multiplayer.port")))); + FixtureConnections.connect(mc); } if(mc.world==null || mc.player==null)return; if(frames%200==0)System.out.println("VISIBILITY_CLIENT_STATE "+role+" phase="+phase+" position="+mc.player.getPosition()+" rendered="+rendered+" screen="+(mc.currentScreen==null?null:mc.currentScreen.getClass().getSimpleName())+" container="+mc.player.openContainer.getClass().getSimpleName()); diff --git a/tools/audit_runtime_logs.py b/tools/audit_runtime_logs.py index 8177e21..c2e0882 100644 --- a/tools/audit_runtime_logs.py +++ b/tools/audit_runtime_logs.py @@ -41,11 +41,13 @@ def stack(a,b): return accepted -def audit(path, development=False, upgrade=False): +def audit(path, development=False, upgrade=False, fixture_logins=False): if path.name.endswith('.log.gz'): with gzip.open(path,'rt',encoding='utf-8',errors='replace') as stream: text=stream.read() else: text=path.read_text(encoding='utf-8',errors='replace') failures=[] + from fixture_login import receipt_lines + login_lines=receipt_lines(path,text) if fixture_logins else set() # These exact diagnostics are reproduced by the independent Forge control. platform=['FML appears to be missing any signature data. This is not a good thing'] if development: @@ -54,6 +56,7 @@ def audit(path, development=False, upgrade=False): proven=development_control_lines(lines) if development else set() version_check_block=False for number,line in enumerate(lines): + if number in login_lines: continue if line.startswith('['): version_check_block=('Forge Version Check/DEBUG]' in line and ('[ForgeVersionCheck/forge]: Failed to process update information' in line or @@ -65,7 +68,7 @@ def audit(path, development=False, upgrade=False): for message in (FORGE_UPGRADE_RENAME, FORGE_UPGRADE_RENAME.replace(']:', '/]:'), FORGE_UPGRADE_RENAME.replace('FML.ModTracker','fml.ModTracker'), FORGE_UPGRADE_RENAME.replace('FML.ModTracker','fml.ModTracker').replace(']:','/]:'))) if re.search(r'/ERROR\]|/FATAL\]|\bERROR (?:Error processing|Unable to locate)',line) and number not in proven and not known_upgrade_rename and not any(message in line for message in platform): failures.append(line) - if re.search(r'AssertionError|NoSuchMethodError|NoSuchFieldError|NoClassDefFoundError|ClassNotFoundException|NullPointerException|OutOfMemoryError|Exception stopping server|Encountered an unexpected exception|Couldn.t render entity|Missing sound for event:|Patcher expecting empty class data|Skipping Entity with id|Unidentified mapping|Unable to load model',line): + if re.search(r'Can.t serialize unregistered packet|AssertionError|NoSuchMethodError|NoSuchFieldError|NoClassDefFoundError|ClassNotFoundException|NullPointerException|OutOfMemoryError|Exception stopping server|Encountered an unexpected exception|Couldn.t render entity|Missing sound for event:|Patcher expecting empty class data|Skipping Entity with id|Unidentified mapping|Unable to load model',line): failures.append(line) # Reproduced in the Forge-only control's complete DEBUG log: its old # HTTP promotions URL returns "Moved Permanently" instead of JSON. @@ -86,6 +89,7 @@ def audit(path, development=False, upgrade=False): parser.add_argument('--development',action='store_true') parser.add_argument('--upgrade',action='store_true') parser.add_argument('--recursive',action='store_true') + parser.add_argument('--fixture-logins',action='store_true',help='Require hashed receipts for recovered Forge-only pre-login encoding failures') parser.add_argument('logs',nargs='+',type=Path) args=parser.parse_args() for path in args.logs: @@ -94,4 +98,4 @@ def audit(path, development=False, upgrade=False): paths=sorted(p for p in path.rglob('*') if p.is_file() and (p.name.endswith('.log') or p.name.endswith('.log.gz'))) if not paths: raise RuntimeError('Missing runtime logs: '+str(path)) if list(path.rglob('crash-reports/*.txt')): raise RuntimeError('Unexpected crash reports: '+str(path)) - for log in paths: print(audit(log,args.development,args.upgrade)) + for log in paths: print(audit(log,args.development,args.upgrade,args.fixture_logins)) diff --git a/tools/fixture_login.py b/tools/fixture_login.py new file mode 100644 index 0000000..3124a6e --- /dev/null +++ b/tools/fixture_login.py @@ -0,0 +1,75 @@ +"""Bounded retries for a Forge-only pre-login failure, with immutable log receipts.""" +import hashlib +import json +import re +import time +from pathlib import Path + +FAILURE = re.compile(r"^\[\d{2}:\d{2}:\d{2}\] \[Server thread/INFO\] \[(?:minecraft/NetHandlerPlayServer|net\.minecraft\.network\.NetHandlerPlayServer)\]: Container([AB]) lost connection: Internal Exception: io\.netty\.handler\.codec\.EncoderException: java\.io\.IOException: Can't serialize unregistered packet$") +JOIN = re.compile(r'^\[\d{2}:\d{2}:\d{2}\] \[Server thread/INFO\] \[(?:minecraft/DedicatedServer|net\.minecraft\.server\.dedicated\.DedicatedServer)\]: Container([AB]) (joined|left) the game$') +LIMIT = 3 + +class LoginMonitor: + def __init__(self, home): + self.home=Path(home);self.seen=0;self.counts={'A':0,'B':0} + def poll(self): + text=(self.home/'server.log').read_text(encoding='utf-8',errors='replace') + lines=text[:text.rfind('\n')+1].splitlines() + for line in lines[self.seen:]: + match=FAILURE.fullmatch(line) + if match: + role=match[1];self.counts[role]+=1 + if self.counts[role]>LIMIT:raise RuntimeError('Forge login retry limit: '+role) + (self.home/'sync'/('login-retry-'+role)).write_text(str(self.counts[role])) + elif 'lost connection: Internal Exception:' in line: + raise RuntimeError('Unrecognised connection failure: '+line) + self.seen=len(lines) + +def start_clients(start, processes, home, monitor): + """Keep the shared Forge handshake channel out of concurrent initial login.""" + start('clientA');deadline=time.monotonic()+75 + while 'ContainerA joined the game' not in (Path(home)/'server.log').read_text(errors='replace'): + monitor.poll() + if any(p.poll() is not None for p in processes.values()) or time.monotonic()>deadline: + raise RuntimeError('First client did not join') + time.sleep(.2) + start('clientB') + +def receipt_lines(path, text): + receipt=path.with_name(path.name+'.login-retries.json') + if not receipt.is_file():return set() + data=json.loads(receipt.read_text(encoding='utf-8')) + if data.get('schema')!=1 or data.get('sha256')!=hashlib.sha256(path.read_bytes()).hexdigest(): + raise RuntimeError('Invalid or stale Forge login receipt: '+str(path)) + lines=text.splitlines();allowed=set(data['lines']);counts={'A':0,'B':0};online=set();pending=set();actual=set() + for i,line in enumerate(lines): + join=JOIN.fullmatch(line) + if join: + role=join[1] + if join[2]=='joined':online.add(role);pending.discard(role) + else:online.discard(role) + match=FAILURE.fullmatch(line) + if match: + role=match[1];counts[role]+=1;pending.add(role);actual.add(i) + if role in online or counts[role]>LIMIT:raise RuntimeError('Failure outside pre-login retry scope') + if not actual or pending or actual!=allowed:raise RuntimeError('Unrecovered or incomplete Forge login receipt') + return allowed + +def write_receipts(home): + home=Path(home) + for path in [home/'server.log',home/'server/logs/latest.log',home/'server/logs/debug.log']: + if not path.is_file():continue + text=path.read_text(encoding='utf-8',errors='replace');lines=text.splitlines() + allowed=[i for i,line in enumerate(lines) if FAILURE.fullmatch(line)] + if not allowed:continue + counts={'A':0,'B':0} + for i in allowed:counts[FAILURE.fullmatch(lines[i])[1]]+=1 + for role,count in counts.items(): + used=home/'sync'/('login-retried-'+role) + if count and (not used.is_file() or used.read_text().strip()!=str(count)): + raise RuntimeError('Unconsumed Forge login retry: '+role) + receipt=path.with_name(path.name+'.login-retries.json') + receipt.write_text(json.dumps({'schema':1,'sha256':hashlib.sha256(path.read_bytes()).hexdigest(),'lines':allowed},indent=2)+'\n') + try:receipt_lines(path,text) + except Exception: + receipt.unlink();raise diff --git a/tools/test_audit_runtime_logs.py b/tools/test_audit_runtime_logs.py index e8d7866..491e64c 100644 --- a/tools/test_audit_runtime_logs.py +++ b/tools/test_audit_runtime_logs.py @@ -24,6 +24,14 @@ def redirect(self, old=False): 'Caused by: java.lang.IllegalStateException: Expected BEGIN_OBJECT but was STRING at line 1 column 1\n' '\tat net.minecraftforge.common.ForgeVersion$1.process(ForgeVersion.java:' + str(number) + ')\n') + def test_login_encoding_failure_at_info_level_is_rejected(self): + text = ("[12:00:00] [Server thread/INFO] [minecraft/NetHandlerPlayServer]: " + "ContainerA lost connection: Internal Exception: io.netty.handler.codec.EncoderException: " + "java.io.IOException: Can't serialize unregistered packet\n") + for options in ({}, {'development': True}, {'upgrade': True}): + with self.subTest(options=options), self.assertRaises(RuntimeError): + self.check_log(text, **options) + def test_upgrade_warning_in_both_control_proven_logger_formats(self): for debug in [False, True]: with self.subTest(debug=debug): diff --git a/tools/test_fixture_login.py b/tools/test_fixture_login.py new file mode 100644 index 0000000..460afac --- /dev/null +++ b/tools/test_fixture_login.py @@ -0,0 +1,51 @@ +import json +import tempfile +import unittest +from pathlib import Path +from fixture_login import LoginMonitor, write_receipts +from audit_runtime_logs import audit + +FAILURE = "[12:00:00] [Server thread/INFO] [minecraft/NetHandlerPlayServer]: ContainerA lost connection: Internal Exception: io.netty.handler.codec.EncoderException: java.io.IOException: Can't serialize unregistered packet\n" +JOIN = "[12:00:02] [Server thread/INFO] [minecraft/DedicatedServer]: ContainerA joined the game\n" + +class FixtureLoginTests(unittest.TestCase): + def setUp(self): + self.temp=tempfile.TemporaryDirectory();self.addCleanup(self.temp.cleanup) + self.root=Path(self.temp.name);(self.root/'sync').mkdir() + self.log=self.root/'server.log';self.log.write_text('') + self.monitor=LoginMonitor(self.root) + def test_only_exact_stock_failure_grants_retry(self): + self.log.write_text(FAILURE);self.monitor.poll() + self.assertEqual((self.root/'sync/login-retry-A').read_text(),'1') + def test_unrelated_login_error_never_grants_retry(self): + self.log.write_text(FAILURE.replace("Can't serialize unregistered packet",'Other failure')) + with self.assertRaises(RuntimeError):self.monitor.poll() + self.assertFalse((self.root/'sync/login-retry-A').exists()) + def test_retries_are_bounded(self): + self.log.write_text(FAILURE*4) + with self.assertRaises(RuntimeError):self.monitor.poll() + def test_receipt_requires_recovered_connection_and_consumed_permit(self): + self.log.write_text(FAILURE);self.monitor.poll() + with self.assertRaises(RuntimeError):write_receipts(self.root) + self.log.write_text(FAILURE+JOIN) + with self.assertRaises(RuntimeError):write_receipts(self.root) + (self.root/'sync/login-retried-A').write_text('1') + write_receipts(self.root) + audit(self.log,fixture_logins=True) + with self.assertRaises(RuntimeError):audit(self.log) + def test_receipt_cannot_accept_changed_logs_or_other_errors(self): + self.log.write_text(FAILURE+JOIN);self.monitor.poll() + (self.root/'sync/login-retried-A').write_text('1');write_receipts(self.root) + self.log.write_text(FAILURE+JOIN+'[12:00:03] [Server thread/ERROR] [FML]: Bad inventory\n') + with self.assertRaises(RuntimeError):audit(self.log,fixture_logins=True) + def test_retry_after_a_play_session_is_not_allowed(self): + self.log.write_text(JOIN+FAILURE);self.monitor.poll() + (self.root/'sync/login-retried-A').write_text('1') + with self.assertRaises(RuntimeError):write_receipts(self.root) + def test_expected_disconnect_then_reconnect_can_be_retried(self): + left='[12:00:01] [Server thread/INFO] [minecraft/DedicatedServer]: ContainerA left the game\n' + self.log.write_text(JOIN+left+FAILURE+JOIN);self.monitor.poll() + (self.root/'sync/login-retried-A').write_text('1');write_receipts(self.root) + audit(self.log,fixture_logins=True) + +if __name__=='__main__':unittest.main() diff --git a/tools/verify_multiplayer.py b/tools/verify_multiplayer.py index cddb296..5b3747f 100644 --- a/tools/verify_multiplayer.py +++ b/tools/verify_multiplayer.py @@ -9,6 +9,7 @@ import subprocess import time from audit_runtime_logs import audit +from fixture_login import LoginMonitor, start_clients, write_receipts from client_runtime import assemble_libraries from verify_packaged import MAIN, FORGE, digest, prepare, run from verify_packaged_client import prepare_assets @@ -69,8 +70,10 @@ def start(name): while not (shared/'server.ready').is_file(): if processes['server'].poll() is not None or time.monotonic()>deadline: raise RuntimeError('Disposable server did not start') time.sleep(.2) - start('clientA');start('clientB');deadline=time.monotonic()+180 + monitor=LoginMonitor(root) + start_clients(start,processes,root,monitor);deadline=time.monotonic()+180 while any(p.poll() is None for p in processes.values()): + monitor.poll() if any(p.poll() not in (None,0) for p in processes.values()): raise RuntimeError('Multiplayer process failed') if time.monotonic()>deadline: raise RuntimeError('Multiplayer timed out') time.sleep(.2) @@ -89,8 +92,9 @@ def start(name): 'two_tcp_clients_final_gui_and_saved_nbt'] if (shared/'checks.txt').read_text().splitlines()!=['PASS '+name for name in checks]: raise RuntimeError('Missing multiplayer check result') + write_receipts(root) for name in ('server','clientA','clientB'): - audit(root/(name+'.log'));audit(root/name/'logs/latest.log') + audit(root/(name+'.log'),fixture_logins=True);audit(root/name/'logs/latest.log',fixture_logins=True) if list((root/name).rglob('crash-reports/*')): raise RuntimeError('Crash report '+name) if digest(root/name/'mods'/MAIN)!=before: raise RuntimeError('Candidate mismatch '+name) for role in ('A','B'): diff --git a/tools/verify_visibility.py b/tools/verify_visibility.py index 22b48cb..eecbfc8 100644 --- a/tools/verify_visibility.py +++ b/tools/verify_visibility.py @@ -7,6 +7,7 @@ from pathlib import Path import shutil from audit_runtime_logs import audit +from fixture_login import LoginMonitor, start_clients, write_receipts from verify_multiplayer import client_command from verify_packaged import prepare, FORGE, MAIN, digest, run from verify_packaged_client import prepare_assets @@ -63,8 +64,10 @@ def start(name): while not (shared/'server.ready').is_file(): if processes['server'].poll() is not None or time.monotonic()>deadline: raise RuntimeError('Server did not start') time.sleep(.2) - start('clientA');start('clientB');deadline=time.monotonic()+650 + monitor=LoginMonitor(home) + start_clients(start,processes,home,monitor);deadline=time.monotonic()+650 while any(process.poll() is None for process in processes.values()): + monitor.poll() if processes['server'].poll() is not None and not (shared/(args.mode+'.pass')).is_file(): raise RuntimeError('Server failed: '+str(home/'server.log')) if any(process.poll() not in (None,0) for process in processes.values()): raise RuntimeError('Owned client failed') @@ -80,9 +83,10 @@ def start(name): except subprocess.TimeoutExpired: process.kill();process.wait() for log in logs: log.close() if not (shared/(args.mode+'.pass')).is_file(): raise RuntimeError('Missing server result') + write_receipts(home) for name in ['server','clientA','clientB']: if digest(home/name/'mods'/MAIN)!=before: raise RuntimeError('Candidate copy changed') - audit(home/(name+'.log'));audit(home/name/'logs/latest.log') + audit(home/(name+'.log'),fixture_logins=True);audit(home/name/'logs/latest.log',fixture_logins=True) if list((home/name).rglob('crash-reports/*.txt')): raise RuntimeError('Crash report') for role in ['A','B']: if not (home/('client'+role)/(args.mode+'-client.pass')).is_file(): raise RuntimeError('Client did not finish '+role) From 89a7e94153d93df2199ab8acda03f6c2f1ca1d33 Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Thu, 22 Oct 2026 23:54:09 +0100 Subject: [PATCH 5/8] Preserve client check logs across server reloads --- tools/fixture_login.py | 13 +++++++++++++ tools/test_fixture_login.py | 13 ++++++++++++- tools/verify_multiplayer.py | 3 ++- tools/verify_visibility.py | 3 ++- 4 files changed, 29 insertions(+), 3 deletions(-) diff --git a/tools/fixture_login.py b/tools/fixture_login.py index 3124a6e..f2fc070 100644 --- a/tools/fixture_login.py +++ b/tools/fixture_login.py @@ -73,3 +73,16 @@ def write_receipts(home): try:receipt_lines(path,text) except Exception: receipt.unlink();raise + +def archive_live_logs(home): + """Preserve each first-process log and receipt before Forge replaces latest.log.""" + home=Path(home) + for name in ('latest.log','debug.log'): + source=home/'server/logs'/name + if not source.is_file():continue + target=home/('server-live-'+name) + receipt=source.with_name(source.name+'.login-retries.json') + target_receipt=target.with_name(target.name+'.login-retries.json') + if target.exists() or target_receipt.exists():raise RuntimeError('Refusing existing archived fixture log') + source.rename(target) + if receipt.is_file():receipt.rename(target_receipt) diff --git a/tools/test_fixture_login.py b/tools/test_fixture_login.py index 460afac..95c01a8 100644 --- a/tools/test_fixture_login.py +++ b/tools/test_fixture_login.py @@ -2,7 +2,7 @@ import tempfile import unittest from pathlib import Path -from fixture_login import LoginMonitor, write_receipts +from fixture_login import LoginMonitor, write_receipts, archive_live_logs from audit_runtime_logs import audit FAILURE = "[12:00:00] [Server thread/INFO] [minecraft/NetHandlerPlayServer]: ContainerA lost connection: Internal Exception: io.netty.handler.codec.EncoderException: java.io.IOException: Can't serialize unregistered packet\n" @@ -14,6 +14,17 @@ def setUp(self): self.root=Path(self.temp.name);(self.root/'sync').mkdir() self.log=self.root/'server.log';self.log.write_text('') self.monitor=LoginMonitor(self.root) + def test_live_log_receipts_survive_the_cold_reload(self): + self.log.write_text(FAILURE+JOIN);self.monitor.poll() + (self.root/'sync/login-retried-A').write_text('1') + latest=self.root/'server/logs/latest.log';latest.parent.mkdir(parents=True) + latest.write_text(FAILURE+JOIN);write_receipts(self.root) + archive_live_logs(self.root) + latest.write_text('[12:01:00] [Server thread/INFO] [FML]: Cold reload passed\n') + audit(latest,fixture_logins=True) + audit(self.root/'server-live-latest.log',fixture_logins=True) + self.assertEqual((self.root/'server-live-latest.log').read_text(),FAILURE+JOIN) + def test_only_exact_stock_failure_grants_retry(self): self.log.write_text(FAILURE);self.monitor.poll() self.assertEqual((self.root/'sync/login-retry-A').read_text(),'1') diff --git a/tools/verify_multiplayer.py b/tools/verify_multiplayer.py index 5b3747f..82eab92 100644 --- a/tools/verify_multiplayer.py +++ b/tools/verify_multiplayer.py @@ -9,7 +9,7 @@ import subprocess import time from audit_runtime_logs import audit -from fixture_login import LoginMonitor, start_clients, write_receipts +from fixture_login import LoginMonitor, start_clients, write_receipts, archive_live_logs from client_runtime import assemble_libraries from verify_packaged import MAIN, FORGE, digest, prepare, run from verify_packaged_client import prepare_assets @@ -105,6 +105,7 @@ def start(name): for shot in ('shared-ready.png','shared-final.png'): if not (home/'screenshots'/shot).is_file(): raise RuntimeError('Missing screenshot '+role+' '+shot) (server/'server.properties').write_text(properties.replace('server-port='+str(port),'server-port=0')+'view-distance=4\n') + archive_live_logs(root) run(java,server,'multiplayer-reload') if digest(jar)!=before: raise RuntimeError('Candidate changed') (root/'result.json').write_text(json.dumps({'candidate_sha256':before,'two_real_tcp_clients':True, diff --git a/tools/verify_visibility.py b/tools/verify_visibility.py index eecbfc8..cc25b1c 100644 --- a/tools/verify_visibility.py +++ b/tools/verify_visibility.py @@ -7,7 +7,7 @@ from pathlib import Path import shutil from audit_runtime_logs import audit -from fixture_login import LoginMonitor, start_clients, write_receipts +from fixture_login import LoginMonitor, start_clients, write_receipts, archive_live_logs from verify_multiplayer import client_command from verify_packaged import prepare, FORGE, MAIN, digest, run from verify_packaged_client import prepare_assets @@ -92,6 +92,7 @@ def start(name): if not (home/('client'+role)/(args.mode+'-client.pass')).is_file(): raise RuntimeError('Client did not finish '+role) if args.mode=='visibility' and not args.source_server: (home/'server/server.properties').write_text(props.replace('server-port='+str(port),'server-port=0')) + archive_live_logs(home) run(java,home/'server','visibility-reload') if args.source_server and fingerprint(args.source_server/'world')!=source_before: raise RuntimeError('Source world changed') From b0b63e02ffa591fe2783eea67c3309281c869d46 Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Thu, 22 Oct 2026 23:59:13 +0100 Subject: [PATCH 6/8] Keep client startup separate from Forge login --- .github/workflows/build.yml | 2 ++ .../lootable/buildtest/FixtureConnections.java | 3 +++ .../buildtest/MultiplayerClientChecks.java | 2 +- .../buildtest/RespawnPacketClientChecks.java | 2 +- .../buildtest/VisibilityClientChecks.java | 2 +- tools/audit_runtime_logs.py | 2 +- tools/fixture_login.py | 17 +++++++---------- tools/test_fixture_login.py | 10 +++++++++- tools/verify_multiplayer.py | 2 +- tools/verify_visibility.py | 2 +- 10 files changed, 27 insertions(+), 17 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 33ff08e..8695bfa 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -304,6 +304,8 @@ jobs: build/packaged-client/nbt-client-checks.txt build/packaged-client/connected-checks.txt build/packaged-client/screenshots/*.png + build/packaged-*/**/*.login-retries.json + build/packaged-*/sync/login-* build/packaged-multiplayer/**/*.log build/packaged-multiplayer/**/crash-reports/** build/packaged-multiplayer/clientA/screenshots/*.png diff --git a/src/integrationTest/java/cyano/lootable/buildtest/FixtureConnections.java b/src/integrationTest/java/cyano/lootable/buildtest/FixtureConnections.java index d685cdd..fe1a1e2 100644 --- a/src/integrationTest/java/cyano/lootable/buildtest/FixtureConnections.java +++ b/src/integrationTest/java/cyano/lootable/buildtest/FixtureConnections.java @@ -12,6 +12,9 @@ /** Uses ordinary Forge connection setup; retries only a runner-confirmed pre-login encoding failure. */ final class FixtureConnections { private FixtureConnections() {} + static boolean ready(String role) { + return "A".equals(role)||new File(System.getProperty("lootablebodies.multiplayer.sync"),"client-A-joined").isFile(); + } static void connect(Minecraft mc) { String address="127.0.0.1:"+Integer.parseInt(System.getProperty("lootablebodies.multiplayer.port")); FMLClientHandler.instance().connectToServer(new GuiMainMenu(),new ServerData("Disposable check",address,false)); diff --git a/src/integrationTest/java/cyano/lootable/buildtest/MultiplayerClientChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/MultiplayerClientChecks.java index 81337e7..fa4d264 100644 --- a/src/integrationTest/java/cyano/lootable/buildtest/MultiplayerClientChecks.java +++ b/src/integrationTest/java/cyano/lootable/buildtest/MultiplayerClientChecks.java @@ -36,7 +36,7 @@ final class MultiplayerClientChecks { RuntimeChecks.require(++ticks<6000,"Multiplayer client timeout role="+role+" phase="+lastPhase); if(new File(MultiplayerChecks.sync(),"abort").exists())throw new AssertionError("Multiplayer runner abort"); if(FixtureConnections.retry(mc,role))connecting=false; - if(!connecting && mc.currentScreen instanceof GuiMainMenu){ + if(!connecting && FixtureConnections.ready(role) && mc.currentScreen instanceof GuiMainMenu){ connecting=true;mc.gameSettings.pauseOnLostFocus=false;mc.gameSettings.limitFramerate=30;mc.gameSettings.renderDistanceChunks=4; FixtureConnections.connect(mc); } diff --git a/src/integrationTest/java/cyano/lootable/buildtest/RespawnPacketClientChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/RespawnPacketClientChecks.java index 1ad940f..b1aba16 100644 --- a/src/integrationTest/java/cyano/lootable/buildtest/RespawnPacketClientChecks.java +++ b/src/integrationTest/java/cyano/lootable/buildtest/RespawnPacketClientChecks.java @@ -77,7 +77,7 @@ private void respawn(Minecraft mc) { if(nextIndex!=index||nextPhase!=phase){index=nextIndex;phase=nextPhase;frames=0;guiFrames=0;System.out.println("RESPAWN_CLIENT_PHASE "+role+" "+index+" "+phase);} uuid=state[2].equals("none")?null:UUID.fromString(state[2]);entityId=Integer.parseInt(state[3]);frames++; if(FixtureConnections.retry(mc,role))connecting=false; - if(!connecting&&mc.currentScreen instanceof GuiMainMenu) { + if(!connecting&&FixtureConnections.ready(role)&&mc.currentScreen instanceof GuiMainMenu) { connecting=true;mc.gameSettings.pauseOnLostFocus=false;mc.gameSettings.limitFramerate=30;mc.gameSettings.renderDistanceChunks=4; FixtureConnections.connect(mc); } diff --git a/src/integrationTest/java/cyano/lootable/buildtest/VisibilityClientChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/VisibilityClientChecks.java index 75f2bbb..56e862e 100644 --- a/src/integrationTest/java/cyano/lootable/buildtest/VisibilityClientChecks.java +++ b/src/integrationTest/java/cyano/lootable/buildtest/VisibilityClientChecks.java @@ -39,7 +39,7 @@ final class VisibilityClientChecks { int n=Integer.parseInt(new String(Files.readAllBytes(VisibilityChecks.file("phase").toPath()),StandardCharsets.UTF_8).trim()); if(n!=phase){phase=n;frames=0;guiFrames=0;respawnSent=false;rendered.clear();System.out.println("VISIBILITY_CLIENT_PHASE "+role+" "+phase);}frames++; if(FixtureConnections.retry(mc,role))connecting=false; - if(!connecting && mc.world==null && mc.currentScreen instanceof GuiMainMenu && (phase==0 || phase==9 || phase==11)) { + if(!connecting && FixtureConnections.ready(role) && mc.world==null && mc.currentScreen instanceof GuiMainMenu && (phase==0 || phase==9 || phase==11)) { connecting=true;mc.gameSettings.pauseOnLostFocus=false;mc.gameSettings.limitFramerate=30;mc.gameSettings.renderDistanceChunks=4; FixtureConnections.connect(mc); } diff --git a/tools/audit_runtime_logs.py b/tools/audit_runtime_logs.py index c2e0882..f801727 100644 --- a/tools/audit_runtime_logs.py +++ b/tools/audit_runtime_logs.py @@ -80,7 +80,7 @@ def audit(path, development=False, upgrade=False, fixture_logins=False): failures.append(line) if not text.strip(): failures.append('Empty runtime log') if failures: raise RuntimeError(str(path)+'\n'+'\n'.join(failures)) - return {'path':str(path),'lines':len(text.splitlines()),'expected_platform_errors':sum( + return {'path':str(path),'lines':len(text.splitlines()),'recovered_forge_pre_login_failures':len(login_lines),'expected_platform_errors':sum( 1 for line in text.splitlines() if re.search(r'/ERROR\]|/FATAL\]',line))} diff --git a/tools/fixture_login.py b/tools/fixture_login.py index f2fc070..219eb0d 100644 --- a/tools/fixture_login.py +++ b/tools/fixture_login.py @@ -2,7 +2,6 @@ import hashlib import json import re -import time from pathlib import Path FAILURE = re.compile(r"^\[\d{2}:\d{2}:\d{2}\] \[Server thread/INFO\] \[(?:minecraft/NetHandlerPlayServer|net\.minecraft\.network\.NetHandlerPlayServer)\]: Container([AB]) lost connection: Internal Exception: io\.netty\.handler\.codec\.EncoderException: java\.io\.IOException: Can't serialize unregistered packet$") @@ -16,6 +15,9 @@ def poll(self): text=(self.home/'server.log').read_text(encoding='utf-8',errors='replace') lines=text[:text.rfind('\n')+1].splitlines() for line in lines[self.seen:]: + joined=JOIN.fullmatch(line) + if joined and joined[1]=='A' and joined[2]=='joined': + (self.home/'sync/client-A-joined').touch() match=FAILURE.fullmatch(line) if match: role=match[1];self.counts[role]+=1 @@ -25,15 +27,10 @@ def poll(self): raise RuntimeError('Unrecognised connection failure: '+line) self.seen=len(lines) -def start_clients(start, processes, home, monitor): - """Keep the shared Forge handshake channel out of concurrent initial login.""" - start('clientA');deadline=time.monotonic()+75 - while 'ContainerA joined the game' not in (Path(home)/'server.log').read_text(errors='replace'): - monitor.poll() - if any(p.poll() is not None for p in processes.values()) or time.monotonic()>deadline: - raise RuntimeError('First client did not join') - time.sleep(.2) - start('clientB') +def start_clients(start): + """Boot both clients together; the client B controller waits for A's join marker.""" + start('clientA');start('clientB') + def receipt_lines(path, text): receipt=path.with_name(path.name+'.login-retries.json') diff --git a/tools/test_fixture_login.py b/tools/test_fixture_login.py index 95c01a8..07dec25 100644 --- a/tools/test_fixture_login.py +++ b/tools/test_fixture_login.py @@ -2,7 +2,7 @@ import tempfile import unittest from pathlib import Path -from fixture_login import LoginMonitor, write_receipts, archive_live_logs +from fixture_login import LoginMonitor, write_receipts, archive_live_logs, start_clients from audit_runtime_logs import audit FAILURE = "[12:00:00] [Server thread/INFO] [minecraft/NetHandlerPlayServer]: ContainerA lost connection: Internal Exception: io.netty.handler.codec.EncoderException: java.io.IOException: Can't serialize unregistered packet\n" @@ -14,6 +14,14 @@ def setUp(self): self.root=Path(self.temp.name);(self.root/'sync').mkdir() self.log=self.root/'server.log';self.log.write_text('') self.monitor=LoginMonitor(self.root) + def test_clients_boot_together_but_only_a_join_opens_b_connection_gate(self): + starts=[] + start_clients(starts.append) + self.assertEqual(starts,['clientA','clientB']) + self.assertFalse((self.root/'sync/client-A-joined').exists()) + self.log.write_text(JOIN);self.monitor.poll() + self.assertTrue((self.root/'sync/client-A-joined').is_file()) + def test_live_log_receipts_survive_the_cold_reload(self): self.log.write_text(FAILURE+JOIN);self.monitor.poll() (self.root/'sync/login-retried-A').write_text('1') diff --git a/tools/verify_multiplayer.py b/tools/verify_multiplayer.py index 82eab92..9d80ab6 100644 --- a/tools/verify_multiplayer.py +++ b/tools/verify_multiplayer.py @@ -71,7 +71,7 @@ def start(name): if processes['server'].poll() is not None or time.monotonic()>deadline: raise RuntimeError('Disposable server did not start') time.sleep(.2) monitor=LoginMonitor(root) - start_clients(start,processes,root,monitor);deadline=time.monotonic()+180 + start_clients(start);deadline=time.monotonic()+180 while any(p.poll() is None for p in processes.values()): monitor.poll() if any(p.poll() not in (None,0) for p in processes.values()): raise RuntimeError('Multiplayer process failed') diff --git a/tools/verify_visibility.py b/tools/verify_visibility.py index cc25b1c..190f344 100644 --- a/tools/verify_visibility.py +++ b/tools/verify_visibility.py @@ -65,7 +65,7 @@ def start(name): if processes['server'].poll() is not None or time.monotonic()>deadline: raise RuntimeError('Server did not start') time.sleep(.2) monitor=LoginMonitor(home) - start_clients(start,processes,home,monitor);deadline=time.monotonic()+650 + start_clients(start);deadline=time.monotonic()+650 while any(process.poll() is None for process in processes.values()): monitor.poll() if processes['server'].poll() is not None and not (shared/(args.mode+'.pass')).is_file(): From 5b2ce7b199a064d0a4f202b68d48fb7d1e40f7f9 Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Fri, 23 Oct 2026 00:18:43 +0100 Subject: [PATCH 7/8] Initialise Forge before connecting test clients --- .../java/cyano/lootable/buildtest/FixtureConnections.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/integrationTest/java/cyano/lootable/buildtest/FixtureConnections.java b/src/integrationTest/java/cyano/lootable/buildtest/FixtureConnections.java index fe1a1e2..7322ea8 100644 --- a/src/integrationTest/java/cyano/lootable/buildtest/FixtureConnections.java +++ b/src/integrationTest/java/cyano/lootable/buildtest/FixtureConnections.java @@ -17,6 +17,7 @@ static boolean ready(String role) { } static void connect(Minecraft mc) { String address="127.0.0.1:"+Integer.parseInt(System.getProperty("lootablebodies.multiplayer.port")); + FMLClientHandler.instance().setupServerList(); FMLClientHandler.instance().connectToServer(new GuiMainMenu(),new ServerData("Disposable check",address,false)); } static boolean retry(Minecraft mc,String role) throws Exception { From c99ad8b57bd51da3d92654bdce742a8e04f524a5 Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Fri, 23 Oct 2026 00:49:43 +0100 Subject: [PATCH 8/8] Recognise completed client shutdown in checks --- .github/workflows/build.yml | 3 +++ tools/fixture_login.py | 23 ++++++++++++++++++++++- tools/test_fixture_login.py | 37 +++++++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8695bfa..c1c17e0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -328,6 +328,9 @@ jobs: build/packaged-client-names/screenshots/*.png build/ordinary-development/**/*.log build/ordinary-packaged/**/*.log + build/packaged-*/client*/*.pass + build/packaged-*/sync/state + build/packaged-*/sync/phase build/packaged-respawn-restart/**/*.log build/packaged-respawn-restart/sync/*.txt build/packaged-visibility/**/*.log diff --git a/tools/fixture_login.py b/tools/fixture_login.py index 219eb0d..e83620f 100644 --- a/tools/fixture_login.py +++ b/tools/fixture_login.py @@ -8,6 +8,25 @@ JOIN = re.compile(r'^\[\d{2}:\d{2}:\d{2}\] \[Server thread/INFO\] \[(?:minecraft/DedicatedServer|net\.minecraft\.server\.dedicated\.DedicatedServer)\]: Container([AB]) (joined|left) the game$') LIMIT = 3 +RESET = re.compile(r"^\[\d{2}:\d{2}:\d{2}\] \[Server thread/INFO\] \[(?:minecraft/NetHandlerPlayServer|net\.minecraft\.network\.NetHandlerPlayServer)\]: Container([AB]) lost connection: Internal Exception: io\.netty\.channel\.unix\.Errors\$NativeIoException: syscall:read\(\.\.\) failed: Connection reset by peer$") + + +def completed_client(home, role): + """A terminal phase, pass file and exact client log marker must all agree.""" + log=home/('client'+role+'.log') + if not log.is_file():return False + lines=log.read_text(encoding='utf-8',errors='replace').splitlines() + for mode,phase,marker in [('respawn','9','LOOTABLE_BODIES_RESPAWN_CLIENT_PASS '+role), + ('visibility','18','LOOTABLE_BODIES_VISIBILITY_CLIENT_PASS role='+role), + ('multiplayer','8','LOOTABLE_BODIES_MULTIPLAYER_CLIENT_PASS role='+role)]: + if not (home/('client'+role)/(mode+'-client.pass')).is_file():continue + state=home/'sync'/('state' if mode=='respawn' else 'phase') + if not state.is_file():continue + fields=state.read_text().split();index=1 if mode=='respawn' else 0 + if len(fields)<=index or fields[index]!=phase:continue + if any(line==marker or line.endswith(': '+marker) for line in lines):return True + return False + class LoginMonitor: def __init__(self, home): self.home=Path(home);self.seen=0;self.counts={'A':0,'B':0} @@ -24,7 +43,9 @@ def poll(self): if self.counts[role]>LIMIT:raise RuntimeError('Forge login retry limit: '+role) (self.home/'sync'/('login-retry-'+role)).write_text(str(self.counts[role])) elif 'lost connection: Internal Exception:' in line: - raise RuntimeError('Unrecognised connection failure: '+line) + reset=RESET.fullmatch(line) + if not reset or not completed_client(self.home,reset[1]): + raise RuntimeError('Unrecognised connection failure: '+line) self.seen=len(lines) def start_clients(start): diff --git a/tools/test_fixture_login.py b/tools/test_fixture_login.py index 07dec25..4cc0725 100644 --- a/tools/test_fixture_login.py +++ b/tools/test_fixture_login.py @@ -14,6 +14,43 @@ def setUp(self): self.root=Path(self.temp.name);(self.root/'sync').mkdir() self.log=self.root/'server.log';self.log.write_text('') self.monitor=LoginMonitor(self.root) + def test_completed_respawn_client_may_close_its_transport(self): + reset=FAILURE.replace("io.netty.handler.codec.EncoderException: java.io.IOException: Can't serialize unregistered packet",'io.netty.channel.unix.Errors$NativeIoException: syscall:read(..) failed: Connection reset by peer') + self.log.write_text(reset) + client=self.root/'clientA';client.mkdir();(client/'respawn-client.pass').touch() + (self.root/'clientA.log').write_text('LOOTABLE_BODIES_RESPAWN_CLIENT_PASS A\n') + (self.root/'sync/state').write_text('16 9 none 0') + self.monitor.poll() + self.assertFalse((self.root/'sync/login-retry-A').exists()) + def test_completed_visibility_client_may_close_its_transport(self): + reset=FAILURE.replace("io.netty.handler.codec.EncoderException: java.io.IOException: Can't serialize unregistered packet",'io.netty.channel.unix.Errors$NativeIoException: syscall:read(..) failed: Connection reset by peer') + self.log.write_text(reset);client=self.root/'clientA';client.mkdir() + (client/'visibility-client.pass').touch() + (self.root/'clientA.log').write_text('LOOTABLE_BODIES_VISIBILITY_CLIENT_PASS role=A\n') + (self.root/'sync/phase').write_text('18');self.monitor.poll() + def test_completed_shared_inventory_client_may_close_its_transport(self): + reset=FAILURE.replace("io.netty.handler.codec.EncoderException: java.io.IOException: Can't serialize unregistered packet",'io.netty.channel.unix.Errors$NativeIoException: syscall:read(..) failed: Connection reset by peer') + self.log.write_text(reset);client=self.root/'clientA';client.mkdir() + (client/'multiplayer-client.pass').touch() + (self.root/'clientA.log').write_text('LOOTABLE_BODIES_MULTIPLAYER_CLIENT_PASS role=A\n') + (self.root/'sync/phase').write_text('8');self.monitor.poll() + + def test_reset_before_client_pass_or_in_active_phase_still_fails(self): + reset=FAILURE.replace("io.netty.handler.codec.EncoderException: java.io.IOException: Can't serialize unregistered packet",'io.netty.channel.unix.Errors$NativeIoException: syscall:read(..) failed: Connection reset by peer') + self.log.write_text(reset) + with self.assertRaises(RuntimeError):self.monitor.poll() + client=self.root/'clientA';client.mkdir();(client/'respawn-client.pass').touch() + (self.root/'clientA.log').write_text('LOOTABLE_BODIES_RESPAWN_CLIENT_PASS A\n') + (self.root/'sync/state').write_text('15 3 none 0') + with self.assertRaises(RuntimeError):self.monitor.poll() + def test_client_marker_without_actual_completed_client_log_still_fails(self): + reset=FAILURE.replace("io.netty.handler.codec.EncoderException: java.io.IOException: Can't serialize unregistered packet",'io.netty.channel.unix.Errors$NativeIoException: syscall:read(..) failed: Connection reset by peer') + self.log.write_text(reset) + client=self.root/'clientA';client.mkdir();(client/'respawn-client.pass').touch() + (self.root/'clientA.log').write_text('RESPAWN_CLIENT_PHASE A 16 9\n') + (self.root/'sync/state').write_text('16 9 none 0') + with self.assertRaises(RuntimeError):self.monitor.poll() + def test_clients_boot_together_but_only_a_join_opens_b_connection_gate(self): starts=[] start_clients(starts.append)