From 7ea484b02afdc4dc778854f45c118c4c970c72f2 Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Fri, 30 Oct 2026 20:52:00 +0000 Subject: [PATCH] Fix Minecraft 1.12.2 machine state and strengthen regression tests --- .gitignore | 10 + CHANGELOG.txt | 12 + README.md | 2 + TEST-COVERAGE-BACKPORT.md | 229 +++++ build.gradle | 16 + .../poweradvantage/PowerAdvantage.java | 6 + .../compat/BaseMetalsCompat.java | 28 + .../conduitnetwork/ConduitNetworkManager.java | 37 +- .../conduitnetwork/ConduitRegistry.java | 58 +- .../gui/FilteredFluidTankGUI.java | 1 + .../poweradvantage/gui/FluidTankGUI.java | 1 + .../conveyors/TileEntityConveyor.java | 10 +- .../conveyors/TileEntityOverflowFilter.java | 16 +- .../conveyors/TileEntitySmeltableFilter.java | 5 +- .../fluidmachines/MetalTankTileEntity.java | 2 +- .../fluidmachines/StorageTankTileEntity.java | 2 +- .../registry/MachineGUIRegistry.java | 13 +- .../poweradvantage/api/PoweredEntity.java | 4 + .../api/simple/SimpleMachineGUI.java | 63 +- .../UniversalBucketStartupTest.java | 27 + .../compat/CarbonDustFallbackTest.java | 115 +++ .../ConduitNetworkManagerTest.java | 62 ++ .../LoadedConduitNetworkTest.java | 221 +++++ .../PowerTransferAccountingTest.java | 52 ++ .../conveyors/ConveyorTransferTest.java | 43 + .../conveyors/SmeltableFilterTest.java | 25 + .../registry/MachineGuiTitleTest.java | 55 ++ test-rig/build.gradle | 44 + test-rig/client-resources/README.md | 37 + .../client/ClientResourceProbe.java | 81 ++ .../client/MachineGuiLayoutProbe.java | 483 +++++++++++ .../resources/gui-label-expectations.json | 46 + .../resources/gui-native-mappings.json | 122 +++ .../client-resources/resources/mcmod.info | 11 + .../client-resources/resources/pack.mcmeta | 1 + test-rig/client-resources/run.ps1 | 190 +++++ .../client-resources/test-gui-contract.ps1 | 41 + test-rig/gradle.properties | 7 + test-rig/migration/legacy_connected_oracle.py | 209 +++++ test-rig/migration/native112.py | 73 ++ test-rig/migration/native112_code.py | 50 ++ test-rig/migration/native112_connected.py | 171 ++++ test-rig/migration/native112_diagnostics.py | 20 + test-rig/migration/native112_harvest.py | 229 +++++ test-rig/migration/native112_lifecycle.py | 44 + .../migration/native112_machine_operations.py | 282 +++++++ test-rig/migration/native112_partial.py | 148 ++++ .../migration/native112_recipe_progression.py | 226 +++++ test-rig/migration/rig_transport.py | 115 +++ test-rig/migration/semantic_machine_audit.py | 305 +++++++ test-rig/migration/steam_control_audit.py | 318 +++++++ test-rig/migration/test_native112_code.py | 63 ++ .../migration/test_native112_connected.py | 60 ++ .../migration/test_native112_diagnostics.py | 16 + test-rig/migration/test_native112_harvest.py | 208 +++++ .../test_native112_machine_operations.py | 85 ++ test-rig/migration/test_native112_partial.py | 117 +++ .../test_native112_recipe_progression.py | 221 +++++ .../migration/test_semantic_machine_audit.py | 272 ++++++ .../migration/test_steam_control_audit.py | 412 +++++++++ test-rig/settings.gradle | 4 + .../mc/advantageworks/AdvantageWorksMod.java | 24 + .../BatteryOperationalCases.java | 38 + .../BucketOperationalCases.java | 219 +++++ .../advantageworks/DrillOperationalCases.java | 223 +++++ .../ElectricLifecycleOperationalCases.java | 283 +++++++ .../ElectricOperationalCases.java | 792 ++++++++++++++++++ .../ElectricOperationalRates.java | 97 +++ .../LegacyPartialNetworkCases.java | 466 +++++++++++ .../mc/advantageworks/MachineContext.java | 319 +++++++ .../MachineOperationalProbe.java | 520 ++++++++++++ .../mc/advantageworks/MachineScenario.java | 39 + .../mc/advantageworks/NativeEntityHold.java | 36 + .../mc/advantageworks/NativeFluidPort.java | 46 + .../mc/advantageworks/NativeFluids.java | 15 + .../mc/advantageworks/NativeHarvestProbe.java | 298 +++++++ .../NativeRecipeProgressionProbe.java | 282 +++++++ .../mc/advantageworks/NativeStacks.java | 12 + .../mc/advantageworks/NativeWorldFluids.java | 24 + .../advantageworks/PowerOperationalCases.java | 219 +++++ .../SteamNetworkLifecycleCases.java | 375 +++++++++ .../SteamOperationalActors.java | 213 +++++ .../advantageworks/SteamOperationalCases.java | 355 ++++++++ .../SteamOperationalSupplementalCases.java | 259 ++++++ .../SteamReloadControlCases.java | 139 +++ test-rig/src/main/resources/mcmod.info | 11 + .../ColdInspectionPlanTest.java | 205 +++++ .../advantageworks/LegacyFluidPortTest.java | 60 ++ .../LegacyPartialNetworkCasesTest.java | 190 +++++ .../MachineScenarioTimedHooksTest.java | 42 + .../NativeRecipeProgressionAdapterTest.java | 88 ++ ...iveRecipeProgressionCrusherLookupTest.java | 90 ++ ...ativeRecipeProgressionDiagnosticsTest.java | 41 + .../NativeRecipeProgressionTransportTest.java | 44 + .../SteamNetworkLifecycleLedgerTest.java | 154 ++++ .../SteamReloadControlHoldTest.java | 57 ++ 96 files changed, 12049 insertions(+), 52 deletions(-) create mode 100644 TEST-COVERAGE-BACKPORT.md create mode 100644 src/test/java/com/mcmoddev/poweradvantage/UniversalBucketStartupTest.java create mode 100644 src/test/java/com/mcmoddev/poweradvantage/compat/CarbonDustFallbackTest.java create mode 100644 src/test/java/com/mcmoddev/poweradvantage/conduitnetwork/ConduitNetworkManagerTest.java create mode 100644 src/test/java/com/mcmoddev/poweradvantage/conduitnetwork/LoadedConduitNetworkTest.java create mode 100644 src/test/java/com/mcmoddev/poweradvantage/conduitnetwork/PowerTransferAccountingTest.java create mode 100644 src/test/java/com/mcmoddev/poweradvantage/machines/conveyors/ConveyorTransferTest.java create mode 100644 src/test/java/com/mcmoddev/poweradvantage/machines/conveyors/SmeltableFilterTest.java create mode 100644 src/test/java/com/mcmoddev/poweradvantage/registry/MachineGuiTitleTest.java create mode 100644 test-rig/build.gradle create mode 100644 test-rig/client-resources/README.md create mode 100644 test-rig/client-resources/java/zone/moddev/mc/advantageworks/client/ClientResourceProbe.java create mode 100644 test-rig/client-resources/java/zone/moddev/mc/advantageworks/client/MachineGuiLayoutProbe.java create mode 100644 test-rig/client-resources/resources/gui-label-expectations.json create mode 100644 test-rig/client-resources/resources/gui-native-mappings.json create mode 100644 test-rig/client-resources/resources/mcmod.info create mode 100644 test-rig/client-resources/resources/pack.mcmeta create mode 100644 test-rig/client-resources/run.ps1 create mode 100644 test-rig/client-resources/test-gui-contract.ps1 create mode 100644 test-rig/gradle.properties create mode 100644 test-rig/migration/legacy_connected_oracle.py create mode 100644 test-rig/migration/native112.py create mode 100644 test-rig/migration/native112_code.py create mode 100644 test-rig/migration/native112_connected.py create mode 100644 test-rig/migration/native112_diagnostics.py create mode 100644 test-rig/migration/native112_harvest.py create mode 100644 test-rig/migration/native112_lifecycle.py create mode 100644 test-rig/migration/native112_machine_operations.py create mode 100644 test-rig/migration/native112_partial.py create mode 100644 test-rig/migration/native112_recipe_progression.py create mode 100644 test-rig/migration/rig_transport.py create mode 100644 test-rig/migration/semantic_machine_audit.py create mode 100644 test-rig/migration/steam_control_audit.py create mode 100644 test-rig/migration/test_native112_code.py create mode 100644 test-rig/migration/test_native112_connected.py create mode 100644 test-rig/migration/test_native112_diagnostics.py create mode 100644 test-rig/migration/test_native112_harvest.py create mode 100644 test-rig/migration/test_native112_machine_operations.py create mode 100644 test-rig/migration/test_native112_partial.py create mode 100644 test-rig/migration/test_native112_recipe_progression.py create mode 100644 test-rig/migration/test_semantic_machine_audit.py create mode 100644 test-rig/migration/test_steam_control_audit.py create mode 100644 test-rig/settings.gradle create mode 100644 test-rig/src/main/java/zone/moddev/mc/advantageworks/AdvantageWorksMod.java create mode 100644 test-rig/src/main/java/zone/moddev/mc/advantageworks/BatteryOperationalCases.java create mode 100644 test-rig/src/main/java/zone/moddev/mc/advantageworks/BucketOperationalCases.java create mode 100644 test-rig/src/main/java/zone/moddev/mc/advantageworks/DrillOperationalCases.java create mode 100644 test-rig/src/main/java/zone/moddev/mc/advantageworks/ElectricLifecycleOperationalCases.java create mode 100644 test-rig/src/main/java/zone/moddev/mc/advantageworks/ElectricOperationalCases.java create mode 100644 test-rig/src/main/java/zone/moddev/mc/advantageworks/ElectricOperationalRates.java create mode 100644 test-rig/src/main/java/zone/moddev/mc/advantageworks/LegacyPartialNetworkCases.java create mode 100644 test-rig/src/main/java/zone/moddev/mc/advantageworks/MachineContext.java create mode 100644 test-rig/src/main/java/zone/moddev/mc/advantageworks/MachineOperationalProbe.java create mode 100644 test-rig/src/main/java/zone/moddev/mc/advantageworks/MachineScenario.java create mode 100644 test-rig/src/main/java/zone/moddev/mc/advantageworks/NativeEntityHold.java create mode 100644 test-rig/src/main/java/zone/moddev/mc/advantageworks/NativeFluidPort.java create mode 100644 test-rig/src/main/java/zone/moddev/mc/advantageworks/NativeFluids.java create mode 100644 test-rig/src/main/java/zone/moddev/mc/advantageworks/NativeHarvestProbe.java create mode 100644 test-rig/src/main/java/zone/moddev/mc/advantageworks/NativeRecipeProgressionProbe.java create mode 100644 test-rig/src/main/java/zone/moddev/mc/advantageworks/NativeStacks.java create mode 100644 test-rig/src/main/java/zone/moddev/mc/advantageworks/NativeWorldFluids.java create mode 100644 test-rig/src/main/java/zone/moddev/mc/advantageworks/PowerOperationalCases.java create mode 100644 test-rig/src/main/java/zone/moddev/mc/advantageworks/SteamNetworkLifecycleCases.java create mode 100644 test-rig/src/main/java/zone/moddev/mc/advantageworks/SteamOperationalActors.java create mode 100644 test-rig/src/main/java/zone/moddev/mc/advantageworks/SteamOperationalCases.java create mode 100644 test-rig/src/main/java/zone/moddev/mc/advantageworks/SteamOperationalSupplementalCases.java create mode 100644 test-rig/src/main/java/zone/moddev/mc/advantageworks/SteamReloadControlCases.java create mode 100644 test-rig/src/main/resources/mcmod.info create mode 100644 test-rig/src/test/java/zone/moddev/mc/advantageworks/ColdInspectionPlanTest.java create mode 100644 test-rig/src/test/java/zone/moddev/mc/advantageworks/LegacyFluidPortTest.java create mode 100644 test-rig/src/test/java/zone/moddev/mc/advantageworks/LegacyPartialNetworkCasesTest.java create mode 100644 test-rig/src/test/java/zone/moddev/mc/advantageworks/MachineScenarioTimedHooksTest.java create mode 100644 test-rig/src/test/java/zone/moddev/mc/advantageworks/NativeRecipeProgressionAdapterTest.java create mode 100644 test-rig/src/test/java/zone/moddev/mc/advantageworks/NativeRecipeProgressionCrusherLookupTest.java create mode 100644 test-rig/src/test/java/zone/moddev/mc/advantageworks/NativeRecipeProgressionDiagnosticsTest.java create mode 100644 test-rig/src/test/java/zone/moddev/mc/advantageworks/NativeRecipeProgressionTransportTest.java create mode 100644 test-rig/src/test/java/zone/moddev/mc/advantageworks/SteamNetworkLifecycleLedgerTest.java create mode 100644 test-rig/src/test/java/zone/moddev/mc/advantageworks/SteamReloadControlHoldTest.java diff --git a/.gitignore b/.gitignore index 1104f3f..4cda3b2 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,12 @@ /.idea/ # Gradle and Forge run output +**/build/ +**/.gradle/ +**/logs/ +**/.cache/ +**/__pycache__/ +**/.venv/ /build/ /.gradle/ /eclipse/ @@ -28,6 +34,10 @@ /.ci-dependencies/ # Machine-specific agent context +**/AGENTS.md +**/AGENT.md +**/agent-notes/ +**/.codex/ /AGENTS.md /AGENT.md /agent-notes/ diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 4f3babd..9160fce 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,15 @@ +Unreleased test-coverage repairs for 1.12.2 + +- Restore the missing carbon-dust recipe name from registered coal and charcoal powders, without replacing existing entries or changing recipe amounts. +- Enable Forge's universal bucket during mod construction so oil buckets work without another mod enabling them first. +- Stop steam, electricity and fluids from loading chunks or crossing an unloaded pipe section. Reconnect the network when that section loads again. +- Keep refused energy available to the rest of the network instead of charging it to the source. +- Check all conveyor destination slots, respect stack limits and preserve different item NBT. +- Route overflow through the same insertion checks and save both inventories after transfers. +- Stop the smeltable filter from accepting items whose furnace result is an empty stack. +- Save changing timers and energy even when a machine has not sent a GUI update. +- Add measured natural-tick jobs, negative controls and exact full-process restart comparisons. + Power Advantage 2.4.1.112021 - Restore conduit networks and machine GUI state after a world reload. diff --git a/README.md b/README.md index eef70e9..5398930 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ Power Advantage provides shared power, fluid, and item transport systems for its Minecraft 1.12.2 expansion mods. +The [test coverage backport notes](TEST-COVERAGE-BACKPORT.md#final-recorded-candidate) record the final machine, restart, control, partial-network, GUI, endurance, connected-world and reproducibility results alongside exact candidate hashes. The bounded legacy checks pass; the recipe progression graph is still partial. Earlier failures, modern follow-up findings and manual-testing limits are kept in the notes. This is not an all-machines or all-worlds guarantee. + ## Building Minecraft 1.12.2 The `master-1.12.2` build uses ForgeGradle 7.0.34 and Gradle 9.6.1. Run Gradle with Java 17; Gradle resolves the Java 8 toolchain used for compilation. diff --git a/TEST-COVERAGE-BACKPORT.md b/TEST-COVERAGE-BACKPORT.md new file mode 100644 index 0000000..29409bf --- /dev/null +++ b/TEST-COVERAGE-BACKPORT.md @@ -0,0 +1,229 @@ +# Test Coverage Backport: Minecraft 1.12.2 + +## Current Status + +The provisional checkpoint below is retained as history. See [Final Recorded Candidate](#final-recorded-candidate) for the completed candidate l gates, exact hashes and remaining coverage limits. The older pending statements are not the current result. + +**Provisional, 30 October 2026. The latest controls and boiler-boundary repairs still need native requalification.** + +This work brings the stronger machine tests back to 1.12.2. The aim is to catch lost contents, incorrect resource use and broken restart behaviour, not simply confirm that a server starts or a block animates. + +Earlier candidates passed useful gates, listed below. Those results belong to their recorded jar hashes and manifests. They do not automatically qualify newer source or rebuilt jars. In particular, the last Steam/harness build round passed before the final water-only boiler boundary patch was added. + +There are still known defects awaiting closure through tests. This is not a statement that every machine, configuration or old world is bug-free. + +## What The Tests Exercise + +The packaged harness is a separate test mod. It places finite, isolated jobs in disposable worlds, lets the native server tick them, and records actual inventories, tanks, energy and process state. It does not call a machine's tick method to make an operating test complete. + +| Area | Examples in the operating suite | Important limit | +| --- | --- | --- | +| Item transport | Six conveyor directions, blocked inventories, dropped items, filters, overflow, custom item NBT and stack limits | These are declared layouts and stocks, not every modded inventory implementation. | +| Fluid handling | Real source pickup, water and Advantage oils, full/refusing tanks, filters, switches, discharge and distillation | A passing transfer does not prove every boiler's public fluid API is safe. | +| Steam | Finite boilers, furnace/crusher/still inputs, unavailable power, full outputs, pumps, tanks, elevators and musket controls | Rates and conservation are checked per fixture; visual indications are separate. | +| Electric | Processing, generation, battery chemistry and priority, growth, lighting, turret and redstone controls | Representative valid, invalid and scarce conditions are tested, not every combination. | +| Moving drills | Steam and Electric drills in all six facings, one track movement, cargo/NBT/facing/energy retention, no-power and redstone controls | This is not an endurance test of unrestricted mining through every block type. | +| Harvest | Actual mining/drop contracts in a dedicated run, followed by restart checks | Does not certify every tool, enchantment or external block. | +| Reload | Full process shutdown and cold comparison of block states, typed NBT, inventories and recorded entities | A frozen cold comparison is not proof of another long period of active work after restart. | + +The [first-loaded control cases](test-rig/src/main/java/zone/moddev/mc/advantageworks/SteamReloadControlCases.java) add six explicit checks: + +- `steam/lifecycle/crusher-comparator-matrix`: actual empty, 1, 32 and 64-item inputs must give signals 0, 1, 8 and 15. +- `steam/lifecycle/crusher-first-loaded-unpowered`: progress 106 must become 107 on the first real tick. +- `steam/lifecycle/crusher-first-loaded-signalled`: real redstone must prevent productive crushing on that tick. +- `steam/lifecycle/crusher-first-loaded-no-steam`: no steam must not be mistaken for productive work. +- `steam/lifecycle/furnace-first-loaded-unpowered`: restored burning state must receive its first permitted steam heat bonus. +- `steam/lifecycle/furnace-first-loaded-signalled`: redstone must prevent that bonus without inventing steam consumption. + +These check exact progress, heat, fuel time, items and steam, rather than waiting until some eventual output appears. They need the independent [Steam control audit](test-rig/migration/steam_control_audit.py); an older required-case list alone must not allow their omission. + +Unit tests also call the actual crusher comparator and boiler fill/drain methods. The coal-boiler save-notification tests use a non-null server-side world test double and a client-side negative control. They prove notification behaviour, not a real disk save. + +## Completed Historical Gates + +The following are retained local receipts from earlier candidates. Paths are relative to `test-rig/build/`; generated receipts are deliberately not committed. + +| Gate | Recorded result | Receipt | +| --- | --- | --- | +| Ordinary operating jobs and cold restart | 173 operating cases and 173 strict cold comparisons passed | `coverage-20261030-candidate-f-173/native112-machine-operation-results.json` | +| Active steam endurance | One steam network/crusher/hopper circuit passed 36,000 measured ticks, about 1,799.93 seconds between its first and last samples, then a strict cold comparison | `coverage-20261030-candidate-f-endurance/native112-machine-operation-results.json` | +| Harvest/drop exercise | Exercise and restored-state checks passed | `coverage-20261030-candidate-f-harvest/native112-harvest-results.json` | +| Later network candidate | 53 of 54 Power/partial-network operating cases passed; the crusher consumer-reload case failed. All 54 cold comparisons passed. Overall result remains FAIL. | `coverage-20261030-candidate-h-partial/native112-machine-operation-results.json` | +| Release and harness build round `r` | Steam release suite and harness checks/build passed before the final boundary patch | Local build receipts; repeat for the final candidate | + +- Connected imported-machine fixtures passed two 2,048-tick operating intervals and a full process restart in `coverage-20261030-candidate-f-connected/native112-connected-results.json`. This covers those recorded source fixtures, not arbitrary old worlds or other mods. + +The endurance result is deliberately narrow: one active steam circuit with finite input and stored steam. It is not 30-minute evidence for every machine, fluids, drills, weapons, partial-unload layout or mod combination. The ordinary `all` group does not run that endurance case. + +## Partial Networks And Restart Findings + +The [partial-network checks](test-rig/src/main/java/zone/moddev/mc/advantageworks/LegacyPartialNetworkCases.java) separate source-only, consumer-only and pipe-bridge unloading for steam, electricity and fluids. + +A valid result requires an actual native unload callback, 240 consecutive ticks of absence without an unrequested reload, retained endpoints staying loaded, and explicit recovery of the same chunk. With a loaded source, electricity and fluid stock must stay constant during isolation; steam may lose only its rated idle amount. This catches transfers through a missing bridge even if total resources remain conserved. + +The older code reloaded released chunks while collecting cached requests. The subsequent network-guard candidate passed eight of the nine partial operating cases, but exposed a separate crusher problem: its unload/load NBT matched exactly, then an initially true redstone cache reduced saved progress before the first scheduled control update. That is an operating defect, not permission to add a grace period to the test. + +An earlier fluid-consumer run also showed 4,000 mB in memory but 3,995 mB after restart following an unsafe reload. It did not recur in the later guarded-network cold run. The coal tank's additional save callback still needs qualification against the final jars; the earlier pass must not be stretched to cover that newer change. + +## Earlier Review: Known And Pending + +These were the findings before final qualification. The specific repairs have since passed the bounded gates below; the broader coverage limits still apply. + +- **KNOWN, closure pending:** Electric and geothermal boiler copies had the same empty typed-drain null dereference as the coal boiler. Targeted null/empty/invalid request guards and water-only fill checks are now in the working tree, with actual-method tests for all three boiler types. They remain open for qualification until the rebuilt candidate passes. Do not describe all known bugs as resolved yet. +- Crusher/furnace first-loaded control behaviour, comparator range and the final boiler boundaries need the consolidated build, operating and cold checks against one immutable candidate. +- Recipe audit evidence includes FAIL/PARTIAL results. Do not claim complete progression, all recipe modes or every ingredient path from registration or a passing machine test alone. +- GUI title/layout checks do not certify every rendered screen, resolution, animation, texture or multiplayer interaction. Those still need client observation. +- Other fluid machines, oil-boiler paths, arbitrary external capabilities, broad chunk topologies, every configuration combination and unlisted migration routes are not certified by the focused boiler repair. +- No generated world is a release artifact. Never use the original player world as a disposable fixture. + +## Running The Checks + +Use Java 17 for Gradle and Java 8 for the native legacy server. Build Power first, then the matching Steam/Electric dependencies, and finally the harness. Follow each repository's pinned dependency instructions; do not mix development and reobfuscated release jars. + +From each mod repository, run its release suite: + +```powershell +.\gradlew.bat clean check build javadoc verifyReleaseDependencies verifyReleaseArtifacts writeReleaseChecksums verifyEclipseProductionClasspath +``` + +From PowerAdvantage, build the separate harness: + +```powershell +.\gradlew.bat -p test-rig clean test assemble +``` + +The native runner needs a prepared Python environment with `nbtlib` and a hash-pinned input manifest. Use fresh output directories and an unused port. Do not change jars, scripts or manifests while a run is active. + +### Profile And Bootstrap Boundary + +There is no `test-rig/profiles/packaged-current.json` in this 1.12 checkout. The historical Java-runner defaults and profiles described by the older 1.10 rig are not the 1.12 final qualification path. For example, the tracked 1.10 packaged-current profile still selects harness 1.0.0 under `build/libs/`, not the current 1.1.0 artifact under `test-rig/build/gradle/libs/`. This guide does not claim that those public defaults were repinned or exercised for candidate l. + +The Gradle commands above build and check artifacts; they are not a claim that a clean checkout can run the final native campaign using a default Java-runner task. No clean-checkout/default-profile end-to-end pass is asserted here. + +The authoritative runtime input is the exact native CLI manifest used for the recorded candidate: + +```text +D:\PowerAdvantage\_verification\backport-coverage-20261030\pa112-candidate-l\inputs.json +``` + +Rerunning it requires the retained, hash-verified seed installation and fixtures, Forge launcher/libraries, and exact dependency and mod jars referenced by the manifest. Migration routes also need their recorded source-world fixtures. These local inputs and generated saves are not committed to the repository; a clone alone does not supply them. Restore and verify those inputs before starting the native CLI. Do not substitute a fresh world, another dependency release or the historical profile defaults and reuse the old result. + +The commands below deliberately use that retained manifest. A different installation or replacement input needs a separately recorded manifest and qualification run; do not rewrite the earlier pins to make it fit. + +```powershell +$python = ".\test-rig\migration\.venv\Scripts\python.exe" +$java8 = "C:\path\to\jdk8\bin\java.exe" +$manifest = "D:\PowerAdvantage\_verification\backport-coverage-20261030\pa112-candidate-l\inputs.json" + +& $python .\test-rig\migration\native112_machine_operations.py run --manifest $manifest --java8 $java8 --port 25666 --group all --output .\test-rig\build\coverage-example-all +& $python .\test-rig\migration\steam_control_audit.py --runtime .\test-rig\build\coverage-example-all --line 112 +``` + +The [partial wrapper](test-rig/migration/native112_partial.py) runs the Power district and adds independent quantitative unloading/recovery checks. It needs a different fresh directory: + +```powershell +& $python .\test-rig\migration\native112_partial.py --manifest $manifest --java8 $java8 --port 25667 --output .\test-rig\build\coverage-example-partial +``` + +Run the bounded endurance smoke before the genuine 30-minute case. Keep both receipts: + +```powershell +& $python .\test-rig\migration\native112_machine_operations.py run --manifest $manifest --java8 $java8 --port 25668 --group endurance-smoke --output .\test-rig\build\coverage-example-smoke +& $python .\test-rig\migration\native112_machine_operations.py run --manifest $manifest --java8 $java8 --port 25668 --group endurance --output .\test-rig\build\coverage-example-endurance +``` + +Endurance must show 36,000 measured ticks, at least 1,799 wall-clock seconds under the documented first-to-last-sample convention, continuing paid work and finite resource accounting. Merely leaving a server open for half an hour is not equivalent. + +Keep the manifest, jar hashes, catalog, raw measurements, first-process save, cold inspection, diagnostics and final receipts together. An absent case, FAIL, PARTIAL, changed protocol or unqualified dependency profile is not a pass. Preserve failed attempts; never overwrite a receipt just to obtain a green result. + +## Before Calling A Candidate Ready + +Require the final release/build gates, the complete declared operating catalog, independent control/partial audits and strict cold comparisons to pass on matching artifacts. Repeat the agreed endurance job after changes that affect it. State the exact remaining gaps, then do the client/visual and representative-world checks. Historical passes are useful evidence, not a substitute for that final qualification. + +## Final Recorded Candidate + +**30 October 2026: candidate l has passed its declared legacy operating, cold-restart, control, partial-network, GUI, endurance, connected-world, harvest and reproducibility gates. Recipe progression remains PARTIAL; manual and broader modern follow-up limits remain below.** + +This is an additional checkpoint, not a replacement for the earlier manifests or failed receipts above. The provisional and KNOWN entries above describe the earlier review state. The final candidate has since passed the declared machine/control checks, including the repaired boiler contracts, but this is still not an all-machines, all-worlds or complete-progression guarantee. No earlier pins have been rewritten. + +### Exact Candidate Pins + +The immutable input manifest is `_verification/backport-coverage-20261030/pa112-candidate-l/inputs.json`, relative to the workspace containing the version checkouts. Its SHA-256 is: + +```text +C0356A105D32682A6BE24B9B8E5B4DC1429A6737CB5BB85E3248EFD55D419283 +``` + +All listed artifact bytes were checked against this manifest when this section was written. + +| Artifact | SHA-256 | +| --- | --- | +| `PowerAdvantage-2.4.1.112021.jar` | `4D33FA81D5A3D8ADA003385D621D00BE129DDA771303B97F1CF3F7FD0F9DC1D0` | +| `SteamAdvantage-2.2.2.112021.jar` | `6382407BD6FF422CE2310DF4649B877D5D2C685649C5A01B571E3D25934F8255` | +| `ElectricAdvantage-2.2.3.112021.jar` | `3F79F8A987CD1A5B783E619066E1800ECC46817A2277C86B4B4A45670CF2505A` | +| `AdvantageWorksTestHarness-1.1.0.112021.jar` | `C42E457F8DAEDBFDED55B6107780032A7062AAD0D291D63AF8064B07D934C330` | +| `BaseMetals-1.12-2.5.0-rc2.332.jar` | `034FD791A9E77345C19F6098C01D4B5B66F90EF4A953FB0831089935EAD9DFFB` | +| `MMDLib-1.12-1.0.0-rc2.36.jar` | `FE2229E6755A5FD306C33CC22DCCF055378D371339133E4912C5DC9213E360AF` | +| `OreSpawn-1.12-3.3.1.179.jar` | `86A5E567950D5428F413A0C744FD4EA6DF5807955CBD444392595A1871CC1DFA` | + +The harness is test-only, not part of a distributed PowerAdvantage jar. Client GUI proof uses separately recorded mapped/deobf inputs in `client-input.json`; those are not interchangeable with the release-jar hashes above. + +### Completed Gates + +| Gate | Result | Retained reference | +| --- | --- | --- | +| Ordinary native operation | 188 declared cases PASS | `test-rig/build/coverage-20261030-candidate-l-final-all/native112-machine-operation-results.json` | +| Full process restart | All 188 strict cold comparisons PASS | Same receipt, restart phase; raw fixture and inspection files remain beside it | +| Independent Steam controls | All six first-loaded/comparator controls PASS, including measured tile holds and raw progress/heat/steam comparisons | `_verification/backport-coverage-20261030/pa112-candidate-l-final-steam-controls-audit.json` | +| Independent partial networks | All nine source/consumer/bridge cases PASS; actual unload, 240 absent ticks, resource isolation and explicit restoration checked | `_verification/backport-coverage-20261030/pa112-candidate-l-final-partial-audit.json` | +| Native machine GUI probe | 39 layouts, 351 caption checks and 355 rejected controls PASS | `run-client-resource-runs/20261030-174812-e28da9ac/client-resources.json`, with mapped inputs in `client-input.json` | +| Owned native crafting | 452 craft-consumption checks PASS across three modes, before and after restart | `test-rig/build/coverage-20261030-final-l/native112-recipe-audit-results.json` | +| Server recipe book | 452 owned craft-unlock checks PASS | Same recipe receipt; server book only, not ingredient advancements or a client handshake | +| Final active endurance | 36,000 measured ticks, 1,799.96 seconds between first and last samples, 287 completed products and no lost progress; strict cold comparison PASS | `test-rig/build/coverage-20261030-candidate-l-final-endurance/native112-machine-operation-results.json`, with the raw batch/resource ledger beside it | +| Final harvest/drop exercise | Native exercise and full-restart restored-state checks PASS | `test-rig/build/coverage-20261030-candidate-l-final-harvest/native112-harvest-results.json` | +| Connected source worlds | Legacy and maintained 1.12 fixtures each passed two 2,048-tick operating intervals across a full process restart | `test-rig/build/coverage-20261030-final-l-connected/native112-connected-results.json`; exact inputs in `_verification/backport-coverage-20261030/pa112-final-l-connected-inputs.json` | +| Final reproducibility | Two independent clean source exports per mod reproduced all four expected artifacts, using paths with spaces and the retained dependency cache | The three `qualification.json` files under `_verification/backport-coverage-20261030/PA112 reproducibility network-carbon with spaces`, `PA112 reproducibility final-steam-t with spaces` and `PA112 reproducibility final-electric with spaces` | + +The GUI evidence is retained outside `build/`. It is explicitly `MACHINE_GUI_ONLY`: these counts do not prove every world model, texture, resolution or player interaction. + +Across all six Minecraft lines, the final ordinary matrix reported **904 operating passes and 904 cold passes**: 188 per legacy line and 132 per modern line. The two legacy recipe runs together account for **904 owned native crafts**; the 452 server recipe-book checks belong to 1.12.2 alone. These totals must not be confused with a count of distinct machines or survival progression routes. + +The earlier unsafe chunk reload and crusher first-tick regression no longer reproduce in these final partial/control cases. The final boiler method tests also cover null, empty, zero, negative and mismatched requests, refusal of lava in an empty water-only tank, positive water acceptance, unchanged finite offers, simulation behaviour and owner save notifications. That closes the specific copied-boiler failure under the tested contracts; it does not qualify every fluid path in the mods. + +### Bounded Recipe Classification + +The native recipe audit is **PASS**, but its overall receipt remains **PARTIAL** because complete progression is not established. Its declared scope is native craft consumption and registered processing functions, not running every production machine or proving finite raw-material conservation. + +| Mode | This line's owned native craft checks | Types not reached by the bounded graph | +| --- | --- | --- | +| NORMAL | 81 before + 81 after restart | 4 | +| TECH_PROGRESSION | 75 before + 75 after restart | 4 | +| APOCALYPTIC | 70 before + 70 after restart | 47 | + +The four common graph gaps are: + +- Lead-acid and nickel-hydride batteries need water buckets. The graph does not model collecting water into a bucket. +- Petroplastic needs refinery or trade routes that the graph does not model. +- `electricadvantage:li_nugget` is registered and visible, but no consuming input or reachable route was found in these profiles. Its intended role remains uncertain; no new balance recipe was invented. + +The additional 28 entries in **1.10 TECH_PROGRESSION** follow the graph's first diamond-rod provider. That Base Metals beta recipe has empty ingredients and cannot supply that path. Iron/steel rod alternatives can satisfy the same Advantage recipes, but those alternatives were not exercised natively as progression routes in this audit. The 32 graph gaps are therefore not proof that 32 player recipes are unobtainable. The 1.12 profile has only the common four gaps in this mode. + +**APOCALYPTIC** has the common four plus 43 uncredited loot/trade component routes. Additional Loot Tables was absent and the native loot sample was empty. Actual delivery through those routes remains unproven; absence from this sample is not proof that the design is impossible. + +Server craft-unlock checks are not ingredient-trigger advancement tests and do not prove that a real network client displays each unlock. + +### Final Evidence And Remaining Limits + +The final rows above close the previously pending endurance, connected-world and reproducibility gates without changing any candidate pins. `final-production-pins-t.json` and `final-production-recheck-u.json` under `_verification/backport-coverage-20261030/` retain the 18 production artifact hashes and unchanged-HEAD checks. `final-hygiene-v.json` records the tracked-file and generated-output checks. Generated worlds and receipts remain local, not release content. + +The endurance result remains one finite steam network/crusher/hopper circuit, not 30-minute coverage of every machine. Its 1,799.96-second figure uses the documented first-to-last-sample convention. Reproducibility used the retained pinned dependency cache; it is not a fresh-cache or default-profile bootstrap claim. + +The wider campaign's bounded frozen and connected migration checks also completed. Those fixture-specific results do not establish arbitrary player-world compatibility. Separate modern lifecycle, partial-network, bridge and drill/read reruns are recorded under each modern checkout's `test-rig/build/coverage-20261030-candidate--final-*` directories. + +The original 1.14 holder-extended receipt remains FAIL: its last observation pair spans 38 native ticks against a 40-tick requirement, although the absence samples and unchanged event counters span 189 ticks without a reload. The corrected pure validator measures continuous absence while retaining the 40-native-tick minimum and load/unload counter checks. A separately authorized fresh-and-imported run then passed at 19:32:01 on 30 October, with identical production jars, target manifest and fixture geometry. No gameplay change was needed, and the original failure was not overwritten. + +The strict pure-validator repair passed 41 focused unit cases per modern line, 1.13 through 1.16: 164 cases in total. Four independent raw audits passed 96 partial-network cases, 24 per line, while retaining the 40-native-tick minimum and rejecting observed or hidden reloads. These audits replay recorded measurements; they are not new native world runs. The proof is retained in `_verification/backport-coverage-20261030/extended-absence-repair/completed-summary.json`. + +That fresh native follow-up is recorded in `PA114/PowerAdvantage/test-rig/build/coverage-20261030-candidate-e-final-holder-extended-continuity-v/native114-holder-extended-results.json`, relative to the version workspace. The matching exit-0 receipt is `_verification/backport-coverage-20261030/pa114-candidate-e-final-holder-extended-continuity-v.log.exit.json`. Source, helper and holder-protocol checks passed; both fresh and imported profiles are PASS. A separate audit of this new run also passed, bound to its exact report hash in `_verification/backport-coverage-20261030/pa114-native-continuity-v-independent.json`. The original failed evidence remains unchanged. + +Still open are the recipe-graph gaps above, manual gameplay/visual checks outside the measured cases, arbitrary external capabilities and unlisted world/mod combinations. No release, publication or blanket compatibility claim follows from these bounded passes. diff --git a/build.gradle b/build.gradle index d23c056..92298ad 100644 --- a/build.gradle +++ b/build.gradle @@ -14,6 +14,21 @@ plugins { id 'net.minecraftforge.gradle' version '7.0.34' } +sourceSets { + guiProbe { + java.srcDir 'test-rig/client-resources/java' + resources.srcDir 'test-rig/client-resources/resources' + compileClasspath += sourceSets.main.compileClasspath + sourceSets.main.output + } +} + +tasks.register('clientResourceProbeJar', Jar) { + dependsOn tasks.named('guiProbeClasses') + from sourceSets.guiProbe.output + archiveFileName = 'advantageclientresources-deobf.jar' + destinationDirectory = layout.buildDirectory.dir('client-resource-probe') +} + group = project.mod_group version = project.mod_version base.archivesName = 'PowerAdvantage' @@ -172,6 +187,7 @@ def rebornCoreCoordinate = usingStagedRebornCore : "RebornCore:RebornCore-1.12.2:${project.reborncore_version}:universal" dependencies { + testImplementation 'junit:junit:4.13.2' implementation minecraft.dependency( "net.minecraftforge:forge:${project.minecraft_version}-${project.forge_version}") // Mavenizer's legacy metadata omits this compile-time Minecraft library. diff --git a/src/main/java/com/mcmoddev/poweradvantage/PowerAdvantage.java b/src/main/java/com/mcmoddev/poweradvantage/PowerAdvantage.java index d3549ee..bc82c50 100644 --- a/src/main/java/com/mcmoddev/poweradvantage/PowerAdvantage.java +++ b/src/main/java/com/mcmoddev/poweradvantage/PowerAdvantage.java @@ -11,6 +11,7 @@ import net.minecraft.item.ItemStack; import net.minecraft.tileentity.TileEntity; import net.minecraftforge.common.config.Configuration; +import net.minecraftforge.fluids.FluidRegistry; import net.minecraftforge.fml.common.FMLLog; import net.minecraftforge.fml.common.Mod; import net.minecraftforge.fml.common.Mod.EventHandler; @@ -216,6 +217,11 @@ compile files( acceptedMinecraftVersions = "[1.12.2]") public class PowerAdvantage { + // Forge registers the bucket during its pre-init, before our fluid setup. + static { + FluidRegistry.enableUniversalBucket(); + } + /** The identifier for this mod */ public static final String MODID = "poweradvantage"; /** The display name for this mod */ diff --git a/src/main/java/com/mcmoddev/poweradvantage/compat/BaseMetalsCompat.java b/src/main/java/com/mcmoddev/poweradvantage/compat/BaseMetalsCompat.java index 1f71094..d6eeebe 100644 --- a/src/main/java/com/mcmoddev/poweradvantage/compat/BaseMetalsCompat.java +++ b/src/main/java/com/mcmoddev/poweradvantage/compat/BaseMetalsCompat.java @@ -5,9 +5,14 @@ import net.minecraft.block.Block; import net.minecraft.item.Item; import net.minecraft.item.ItemStack; +import net.minecraft.item.crafting.IRecipe; import net.minecraft.util.ResourceLocation; +import net.minecraftforge.event.RegistryEvent; import net.minecraftforge.fml.common.FMLLog; import net.minecraftforge.fml.common.Loader; +import net.minecraftforge.fml.common.Mod; +import net.minecraftforge.fml.common.eventhandler.EventPriority; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; import net.minecraftforge.fml.common.registry.ForgeRegistries; import net.minecraftforge.oredict.OreDictionary; import org.apache.logging.log4j.Level; @@ -15,7 +20,10 @@ import java.lang.reflect.Field; import java.lang.reflect.Method; import java.util.List; +import java.util.function.BiConsumer; +import java.util.function.Function; +@Mod.EventBusSubscriber(modid = PowerAdvantage.MODID) public final class BaseMetalsCompat { private static final String MODID = "basemetals"; @@ -32,6 +40,26 @@ public static boolean isLoaded() { return Loader.isModLoaded(MODID); } + @SubscribeEvent(priority = EventPriority.HIGHEST) + public static void registerRecipeAliases(RegistryEvent.Register event) { + // Item registration has finished; populate aliases before recipe factories inspect them. + registerCarbonDustFallback(); + } + + public static void registerCarbonDustFallback() { + registerCarbonDustFallback(OreDictionary::getOres, OreDictionary::registerOre); + } + + static void registerCarbonDustFallback(Function> ores, + BiConsumer register) { + if (!ores.apply("dustCarbon").isEmpty()) return; + for (String source : new String[]{"dustCoal", "dustCharcoal"}) { + for (ItemStack stack : ores.apply(source)) { + if (stack != null && !stack.isEmpty()) register.accept("dustCarbon", stack.copy()); + } + } + } + public static void setStrongHammers(boolean enabled) { if (!isLoaded()) return; if (setOldStrongHammers(enabled)) return; diff --git a/src/main/java/com/mcmoddev/poweradvantage/conduitnetwork/ConduitNetworkManager.java b/src/main/java/com/mcmoddev/poweradvantage/conduitnetwork/ConduitNetworkManager.java index 68642ed..14460d5 100644 --- a/src/main/java/com/mcmoddev/poweradvantage/conduitnetwork/ConduitNetworkManager.java +++ b/src/main/java/com/mcmoddev/poweradvantage/conduitnetwork/ConduitNetworkManager.java @@ -13,6 +13,7 @@ import com.mcmoddev.poweradvantage.util.PowerHelper; import net.minecraft.block.state.IBlockState; import net.minecraft.util.EnumFacing; +import net.minecraft.util.math.ChunkPos; import net.minecraft.world.World; /** @@ -124,18 +125,40 @@ public void invalidateAll() { } /** - * Invalidates the cached network at a block location and then rescans the world to make a new - * cached network + * Invalidates networks crossing a changed chunk or touching its boundary. + * Boundary networks must be rescanned when a pipe-only chunk returns. * - * @param coord The block position that needs revalidating - * @param w The world instance for the same dimension as the coord - * @param type The energy type that is triggering the revalidation (should be the same type as - * this manager) + * @param dimension dimension containing the changed chunk + * @param chunk changed chunk position + */ + public void invalidateChunk(int dimension, ChunkPos chunk) { + lock.writeLock().lock(); + try { + int minX = (chunk.x << 4) - 1; + int minZ = (chunk.z << 4) - 1; + for (BlockPos4D coord : new ArrayList<>(networkCache.keySet())) { + if (coord.dimension == dimension && coord.pos.getX() >= minX + && coord.pos.getX() <= minX + 17 && coord.pos.getZ() >= minZ + && coord.pos.getZ() <= minZ + 17) invalidate(coord); + } + } finally { + lock.writeLock().unlock(); + } + } + + /** + * Rebuilds a network using only blocks already loaded in this world. + * + * @param coord origin of the network + * @param w world containing the network + * @param type conduit type being scanned */ public void revalidate(BlockPos4D coord, World w, ConduitType type) { lock.writeLock().lock(); try { invalidate(coord); + if (!w.isBlockLoaded(coord.pos)) return; + createNewNetwork(coord); recursiveScan(w, coord, w.getBlockState(coord.pos), type); } finally { lock.writeLock().unlock(); @@ -172,7 +195,7 @@ protected void recursiveScan(World w, BlockPos4D coord, IBlockState src, Conduit for (int i = 0; i < EnumFacing.values().length; i++) { EnumFacing face = EnumFacing.values()[i]; BlockPos4D n = coord.offset(face); - if (areInSameNetwork(coord, n)) { + if (!w.isBlockLoaded(n.pos) || areInSameNetwork(coord, n)) { continue; } IBlockState blockstate = w.getBlockState(n.pos); diff --git a/src/main/java/com/mcmoddev/poweradvantage/conduitnetwork/ConduitRegistry.java b/src/main/java/com/mcmoddev/poweradvantage/conduitnetwork/ConduitRegistry.java index 0c5afcc..bb99ecd 100644 --- a/src/main/java/com/mcmoddev/poweradvantage/conduitnetwork/ConduitRegistry.java +++ b/src/main/java/com/mcmoddev/poweradvantage/conduitnetwork/ConduitRegistry.java @@ -14,7 +14,11 @@ import net.minecraft.tileentity.TileEntity; import net.minecraft.util.EnumFacing; import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.ChunkPos; import net.minecraft.world.World; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.event.world.ChunkEvent; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; import java.util.*; import java.util.concurrent.locks.Lock; @@ -45,6 +49,7 @@ public static ConduitRegistry getInstance() { try { if (instance == null) { instance = new ConduitRegistry(); + MinecraftForge.EVENT_BUS.register(instance); } } finally { initLock.unlock(); @@ -87,6 +92,7 @@ public List getRequestsForPower(World w, BlockPos coord, ConduitTy * priority. */ public List getRequestsForPower(World w, BlockPos coord, ConduitType conduitType, ConduitType energyType) { + if (!w.isBlockLoaded(coord)) return Collections.emptyList(); int dimension = w.provider.getDimension(); clearIfWorldChanged(w, dimension); ConduitNetworkManager manager = getConduitNetworkManager(conduitType); @@ -107,6 +113,7 @@ public List getRequestsForPower(World w, BlockPos coord, ConduitTy private List collectRequests(World w, List net, ConduitType conduitType, ConduitType energyType) { List requests = new ArrayList<>(); for (BlockPos4D pos : net) { + if (!w.isBlockLoaded(pos.pos)) continue; Block b = w.getBlockState(pos.pos).getBlock(); if (b instanceof ITileEntityProvider) { TileEntity e = w.getTileEntity(pos.pos); @@ -184,27 +191,29 @@ public static float transmitPowerToConsumers( World world, BlockPos srcPos, Object providerInstance ) { List requests = ConduitRegistry.getInstance().getRequestsForPower(world, srcPos, networkType, powerType); + return distributePower(availableEnergy, powerType, minimumPriority, world, providerInstance, requests); + } + + static float distributePower(float availableEnergy, ConduitType powerType, byte minimumPriority, + World world, Object providerInstance, List requests) { + if (!Float.isFinite(availableEnergy) || availableEnergy <= 0) return 0; float e = availableEnergy; for (PowerRequest req : requests) { - if (req.amount <= 0) continue; + if (!Float.isFinite(req.amount) || req.amount <= 0) continue; if (req.entity == providerInstance) continue; if (req.priority < minimumPriority) continue; + float offered = Math.min(e, req.amount); + float accepted; if (req instanceof ExternalPowerRequest) { - e -= LightWeightPowerRegistry.getInstance().addPower(world, ((ExternalPowerRequest) req).pos, - powerType, Math.min(e, req.amount)); - if (e <= 0) break; - continue; - } - - if (req.entity == null) continue; - if (req.amount < e) { - e -= req.entity.addEnergy(req.amount, powerType); + accepted = LightWeightPowerRegistry.getInstance().addPower(world, ((ExternalPowerRequest) req).pos, + powerType, offered); } else { - req.entity.addEnergy(e, powerType); - e = 0; - break; + if (req.entity == null) continue; + accepted = req.entity.addEnergy(offered, powerType); } + if (Float.isFinite(accepted)) e -= Math.max(0, Math.min(offered, accepted)); + if (e <= 0) break; } return availableEnergy - e; } @@ -297,6 +306,29 @@ public void conduitBlockRemovedEvent(World w, int dimension, BlockPos location, } } + /** Clears networks crossing a changed chunk, including adjacent pipe-only boundaries. */ + @SubscribeEvent + public void onChunkLoad(ChunkEvent.Load event) { + invalidateChunk(event); + } + + /** Clears networks before an unloaded section can be used for another transfer. */ + @SubscribeEvent + public void onChunkUnload(ChunkEvent.Unload event) { + invalidateChunk(event); + } + + private void invalidateChunk(ChunkEvent event) { + World world = event.getWorld(); + if (world.isRemote) return; + int dimension = world.provider.getDimension(); + clearIfWorldChanged(world, dimension); + for (ConduitNetworkManager manager : networkManagers.values()) { + manager.invalidateChunk(dimension, new ChunkPos( + event.getChunk().x, event.getChunk().z)); + } + } + private ConduitNetworkManager getConduitNetworkManager(ConduitType type) { if (networkManagers.containsKey(type)) { return networkManagers.get(type); diff --git a/src/main/java/com/mcmoddev/poweradvantage/gui/FilteredFluidTankGUI.java b/src/main/java/com/mcmoddev/poweradvantage/gui/FilteredFluidTankGUI.java index ab29c34..246cf21 100644 --- a/src/main/java/com/mcmoddev/poweradvantage/gui/FilteredFluidTankGUI.java +++ b/src/main/java/com/mcmoddev/poweradvantage/gui/FilteredFluidTankGUI.java @@ -37,6 +37,7 @@ public static void drawFluidBar( FluidStack fs, float barHeight, int xPos, int yPos, ResourceLocation displayImage, GUIContainer guiContainer, int x, int y, float zLevel ) { + barHeight = Float.isFinite(barHeight) ? Math.max(0F, Math.min(1F, barHeight)) : 0F; final int w = 16; final int barSlotHeight = 60; final int h = (int) (barSlotHeight * barHeight); diff --git a/src/main/java/com/mcmoddev/poweradvantage/gui/FluidTankGUI.java b/src/main/java/com/mcmoddev/poweradvantage/gui/FluidTankGUI.java index d4cf5ae..9e94c5b 100644 --- a/src/main/java/com/mcmoddev/poweradvantage/gui/FluidTankGUI.java +++ b/src/main/java/com/mcmoddev/poweradvantage/gui/FluidTankGUI.java @@ -45,6 +45,7 @@ public static void drawFluidBar( FluidStack fs, float barHeight, int xPos, int yPos, ResourceLocation displayImage, GUIContainer guiContainer, int x, int y, float zLevel ) { + barHeight = Float.isFinite(barHeight) ? Math.max(0F, Math.min(1F, barHeight)) : 0F; final int w = 16; final int barSlotHeight = 60; final int h = (int) (barSlotHeight * barHeight); diff --git a/src/main/java/com/mcmoddev/poweradvantage/machines/conveyors/TileEntityConveyor.java b/src/main/java/com/mcmoddev/poweradvantage/machines/conveyors/TileEntityConveyor.java index 6d45415..72061bc 100644 --- a/src/main/java/com/mcmoddev/poweradvantage/machines/conveyors/TileEntityConveyor.java +++ b/src/main/java/com/mcmoddev/poweradvantage/machines/conveyors/TileEntityConveyor.java @@ -135,8 +135,9 @@ protected static boolean canInsertItemInto(ItemStack item, ISidedInventory dest, ItemStack destItem = dest.getStackInSlot(slot); if(destItem == null || destItem.isEmpty()) { return true; - } else { - return ItemStack.areItemsEqual(item, destItem); + } else if (ItemStack.areItemsEqual(item, destItem) && ItemStack.areItemStackTagsEqual(item, destItem) + && destItem.getCount() < Math.min(dest.getInventoryStackLimit(), destItem.getMaxStackSize())) { + return true; } } } @@ -159,10 +160,13 @@ protected static boolean transferItem(ISidedInventory src, EnumFacing srcFace, I ItemStack otherItem = dest.getStackInSlot(destValidSlots[j]); if(otherItem != null && !otherItem.isEmpty() && ItemStack.areItemsEqual(item, otherItem) + && ItemStack.areItemStackTagsEqual(item, otherItem) && otherItem.getCount() < dest.getInventoryStackLimit() && otherItem.getCount() < otherItem.getItem().getItemStackLimit(otherItem)){ src.decrStackSize(srcValidSlots[i], 1); otherItem.grow(1); + src.markDirty(); + dest.markDirty(); return true; } } @@ -173,6 +177,8 @@ protected static boolean transferItem(ISidedInventory src, EnumFacing srcFace, I ItemStack otherItem = dest.getStackInSlot(destValidSlots[j]); if(otherItem == null || otherItem.isEmpty()){ dest.setInventorySlotContents(destValidSlots[j], src.decrStackSize(srcValidSlots[i], 1)); + src.markDirty(); + dest.markDirty(); return true; } } diff --git a/src/main/java/com/mcmoddev/poweradvantage/machines/conveyors/TileEntityOverflowFilter.java b/src/main/java/com/mcmoddev/poweradvantage/machines/conveyors/TileEntityOverflowFilter.java index 4c99e1a..b20e548 100644 --- a/src/main/java/com/mcmoddev/poweradvantage/machines/conveyors/TileEntityOverflowFilter.java +++ b/src/main/java/com/mcmoddev/poweradvantage/machines/conveyors/TileEntityOverflowFilter.java @@ -26,21 +26,7 @@ private boolean canInsert(ItemStack item, EnumFacing f) { if (target instanceof IInventory) { EnumFacing side = f.getOpposite(); ISidedInventory dt = InventoryWrapper.wrap((IInventory) target); - int[] slots = dt.getSlotsForFace(side); - for (int i = 0; i < slots.length; i++) { - int slot = slots[i]; - if (dt.canInsertItem(slot, item, side)) { - ItemStack targetSlot = dt.getStackInSlot(slot); - if ((targetSlot == null) || - (ItemStack.areItemsEqual(item, targetSlot) - && !targetSlot.getItem().isDamageable() - && targetSlot.getCount() < targetSlot.getMaxStackSize() - && targetSlot.getCount() < dt.getInventoryStackLimit())) { - return true; - } - } - } - return false; + return canInsertItemInto(item, dt, side); } else { return false; } diff --git a/src/main/java/com/mcmoddev/poweradvantage/machines/conveyors/TileEntitySmeltableFilter.java b/src/main/java/com/mcmoddev/poweradvantage/machines/conveyors/TileEntitySmeltableFilter.java index 735cf19..e809129 100644 --- a/src/main/java/com/mcmoddev/poweradvantage/machines/conveyors/TileEntitySmeltableFilter.java +++ b/src/main/java/com/mcmoddev/poweradvantage/machines/conveyors/TileEntitySmeltableFilter.java @@ -7,8 +7,9 @@ public class TileEntitySmeltableFilter extends TileEntityConveyorFilter { @Override public boolean matchesFilter(ItemStack item) { - if (item == null) return false; - return FurnaceRecipes.instance().getSmeltingResult(item) != null; + if (item == null || item.isEmpty()) return false; + ItemStack output = FurnaceRecipes.instance().getSmeltingResult(item); + return output != null && !output.isEmpty(); } } diff --git a/src/main/java/com/mcmoddev/poweradvantage/machines/fluidmachines/MetalTankTileEntity.java b/src/main/java/com/mcmoddev/poweradvantage/machines/fluidmachines/MetalTankTileEntity.java index 7ca83b3..2265e89 100644 --- a/src/main/java/com/mcmoddev/poweradvantage/machines/fluidmachines/MetalTankTileEntity.java +++ b/src/main/java/com/mcmoddev/poweradvantage/machines/fluidmachines/MetalTankTileEntity.java @@ -12,7 +12,7 @@ public class MetalTankTileEntity extends TileEntitySimpleFluidMachine { public MetalTankTileEntity() { - super(Fluid.BUCKET_VOLUME * 10, MetalTankTileEntity.class.getName()); + super(Fluid.BUCKET_VOLUME * 10, "tile.poweradvantage.fluid_metal_tank.name"); } diff --git a/src/main/java/com/mcmoddev/poweradvantage/machines/fluidmachines/StorageTankTileEntity.java b/src/main/java/com/mcmoddev/poweradvantage/machines/fluidmachines/StorageTankTileEntity.java index 986c190..e781784 100644 --- a/src/main/java/com/mcmoddev/poweradvantage/machines/fluidmachines/StorageTankTileEntity.java +++ b/src/main/java/com/mcmoddev/poweradvantage/machines/fluidmachines/StorageTankTileEntity.java @@ -12,7 +12,7 @@ public class StorageTankTileEntity extends TileEntitySimpleFluidMachine { public StorageTankTileEntity() { - super(Fluid.BUCKET_VOLUME * 4, StorageTankTileEntity.class.getName()); + super(Fluid.BUCKET_VOLUME * 4, "tile.poweradvantage.fluid_storage_tank.name"); } /** diff --git a/src/main/java/com/mcmoddev/poweradvantage/registry/MachineGUIRegistry.java b/src/main/java/com/mcmoddev/poweradvantage/registry/MachineGUIRegistry.java index 7901a2d..16761ea 100644 --- a/src/main/java/com/mcmoddev/poweradvantage/registry/MachineGUIRegistry.java +++ b/src/main/java/com/mcmoddev/poweradvantage/registry/MachineGUIRegistry.java @@ -1,8 +1,12 @@ package com.mcmoddev.poweradvantage.registry; +import net.minecraft.block.Block; import net.minecraft.entity.player.EntityPlayer; +import net.minecraft.inventory.IInventory; import net.minecraft.tileentity.TileEntity; import net.minecraft.util.math.BlockPos; +import net.minecraft.util.text.ITextComponent; +import net.minecraft.util.text.TextComponentTranslation; import net.minecraft.world.World; import net.minecraftforge.fml.common.network.IGuiHandler; import net.minecraftforge.fml.relauncher.Side; @@ -83,6 +87,13 @@ public void removeGUI(int guiID) { guiTable.remove(guiID); } + public static ITextComponent menuTitle(TileEntity tile, Block block) { + if (tile instanceof IInventory && ((IInventory) tile).hasCustomName()) { + return ((IInventory) tile).getDisplayName(); + } + return new TextComponentTranslation(block.getTranslationKey() + ".name"); + } + /** * Implementation of net.minecraftforge.fml.common.network.IGuiHandler */ @@ -113,4 +124,4 @@ public Object getServerGuiElement( } return null; } -} \ No newline at end of file +} diff --git a/src/main/java/cyano/poweradvantage/api/PoweredEntity.java b/src/main/java/cyano/poweradvantage/api/PoweredEntity.java index 2ad04c6..89e7c49 100644 --- a/src/main/java/cyano/poweradvantage/api/PoweredEntity.java +++ b/src/main/java/cyano/poweradvantage/api/PoweredEntity.java @@ -174,6 +174,10 @@ public final void update() { if (this.isServer() && ((this.getWorld().getTotalWorldTime() + this.getTickOffset()) % powerUpdateInterval == 0)) { this.powerUpdate(); } + if (this.isServer()) { + // Timers and energy may change without a GUI or neighbour update. + this.getWorld().markChunkDirty(this.getPos(), this); + } } private void refreshConduitNetworkAfterLoad() { diff --git a/src/main/java/cyano/poweradvantage/api/simple/SimpleMachineGUI.java b/src/main/java/cyano/poweradvantage/api/simple/SimpleMachineGUI.java index bdf5d1a..863283c 100644 --- a/src/main/java/cyano/poweradvantage/api/simple/SimpleMachineGUI.java +++ b/src/main/java/cyano/poweradvantage/api/simple/SimpleMachineGUI.java @@ -4,7 +4,9 @@ import com.mcmoddev.poweradvantage.PowerAdvantage; import com.mcmoddev.poweradvantage.math.Integer2D; import com.mcmoddev.poweradvantage.registry.ITileEntityGUI; +import com.mcmoddev.poweradvantage.registry.MachineGUIRegistry; import net.minecraft.client.gui.inventory.GuiContainer; +import net.minecraft.client.renderer.GlStateManager; import net.minecraft.entity.player.EntityPlayer; import net.minecraft.entity.player.InventoryPlayer; import net.minecraft.inventory.IInventory; @@ -12,9 +14,9 @@ import net.minecraft.item.ItemStack; import net.minecraft.tileentity.TileEntity; import net.minecraft.util.ResourceLocation; +import net.minecraft.util.text.ITextComponent; import net.minecraftforge.fml.relauncher.Side; import net.minecraftforge.fml.relauncher.SideOnly; -import org.lwjgl.opengl.GL11; import java.util.HashMap; import java.util.Map; @@ -31,6 +33,10 @@ * */ public class SimpleMachineGUI implements ITileEntityGUI { + private static final int MACHINE_TOP = 12; + private static final int MACHINE_HEIGHT = 128; + private static final int PLAYER_PANEL_TOP = 150; + private static final int PLAYER_SLOTS_TOP = PLAYER_PANEL_TOP + 8; /** * This is the texture resource for drawing the player's inventory. @@ -63,7 +69,7 @@ public class SimpleMachineGUI implements ITileEntityGUI { public SimpleMachineGUI(ResourceLocation guiImage, Integer2D... inventorySlotCoordinates){ this.guiDisplayImage = guiImage; this.guiWidth = 176; - this.guiHeight = 222; + this.guiHeight = 240; if(inventorySlotCoordinates == null){ this.inventorySlotCoordinates = new Integer2D[0]; } else { @@ -149,14 +155,14 @@ public Container(InventoryPlayer playerItems, IInventory entity){ int index = 0; for(Integer2D pt : inventorySlotCoordinates){ if(specialSlots.containsKey(index)){ - SlotContext params = new SlotContext(playerItems,entity,index, pt.X, pt.Y); + SlotContext params = new SlotContext(playerItems,entity,index, pt.X, pt.Y + MACHINE_TOP); this.addSlotToContainer(specialSlots.get(index).apply(params)); } else { - this.addSlotToContainer(new Slot(entity, index, pt.X, pt.Y)); + this.addSlotToContainer(new Slot(entity, index, pt.X, pt.Y + MACHINE_TOP)); } index++; } - bindPlayerInventory(playerItems, 140); + bindPlayerInventory(playerItems, PLAYER_SLOTS_TOP); } @Override @@ -221,25 +227,62 @@ else if (!this.mergeItemStack(stackInSlot, 0, hostSize, false)) { @SideOnly(Side.CLIENT) public class GUIContainer extends net.minecraft.client.gui.inventory.GuiContainer{ private final Object entity; + private final InventoryPlayer playerInventory; + private final ITextComponent machineTitle; public GUIContainer(InventoryPlayer playerItems, IInventory entity) { super(new Container(playerItems,entity)); //System.out.println("Size: "+this.xSize+"x"+this.ySize+" at coordinate ("+this.guiLeft+","+this.guiTop+")"); this.xSize = guiWidth; this.ySize = guiHeight; this.entity = entity; + this.playerInventory = playerItems; + if (entity instanceof TileEntity && ((TileEntity) entity).hasWorld()) { + TileEntity tile = (TileEntity) entity; + this.machineTitle = MachineGUIRegistry.menuTitle(tile, tile.getWorld().getBlockState(tile.getPos()).getBlock()); + } else { + this.machineTitle = entity.getDisplayName(); + } + } + + @Override + public void drawScreen(int mouseX, int mouseY, float partialTicks) { + this.drawDefaultBackground(); + super.drawScreen(mouseX, mouseY, partialTicks); + this.renderHoveredToolTip(mouseX, mouseY); + if (fontRenderer.getStringWidth(machineTitle.getUnformattedText()) > width - 8 + && mouseY >= guiTop + 1 && mouseY < guiTop + MACHINE_TOP) { + this.drawHoveringText(machineTitle.getUnformattedText(), mouseX, mouseY); + } } + + @Override + protected void drawGuiContainerForegroundLayer(int mouseX, int mouseY) { + String machineName = fitLabel(machineTitle.getUnformattedText(), width - 8); + fontRenderer.drawStringWithShadow(machineName, (xSize - fontRenderer.getStringWidth(machineName)) / 2, 1, 0xFFFFFF); + fontRenderer.drawStringWithShadow(fitLabel(playerInventory.getDisplayName().getUnformattedText(), xSize - 16), + 8, MACHINE_TOP + MACHINE_HEIGHT, 0xFFFFFF); + } + + private String fitLabel(String text, int availableWidth) { + return fontRenderer.getStringWidth(text) <= availableWidth ? text + : fontRenderer.trimStringToWidth(text, Math.max(0, availableWidth - fontRenderer.getStringWidth("..."))) + "..."; + } + @Override protected void drawGuiContainerBackgroundLayer(float f, int i, int j) { - GL11.glColor4f(1.0F, 1.0F, 1.0F, 1.0F); + GlStateManager.enableBlend(); + GlStateManager.tryBlendFuncSeparate(770, 771, 1, 0); + GlStateManager.color(1.0F, 1.0F, 1.0F, 1.0F); this.mc.renderEngine.bindTexture(playerInventoryBox); int x = (width - xSize) / 2; int y = (height - ySize) / 2; final int playerInventoryWidth = 176; - final int playerInventoryHeight = 222; - this.drawTexturedModalRect(x, y, 0, 0, playerInventoryWidth, playerInventoryHeight); // x, y, textureOffsetX, textureOffsetY, width, height) + final int playerInventoryHeight = 90; + this.drawTexturedModalRect(x, y + PLAYER_PANEL_TOP, 0, 132, playerInventoryWidth, playerInventoryHeight); this.mc.renderEngine.bindTexture(guiDisplayImage); - this.drawTexturedModalRect(x, y, 0, 0, guiWidth, guiHeight); // x, y, textureOffsetX, textureOffsetY, width, height) - drawGUIDecorations(entity, this, x, y, this.zLevel); + this.drawTexturedModalRect(x, y + MACHINE_TOP, 0, 0, guiWidth, MACHINE_HEIGHT); + drawGUIDecorations(entity, this, x, y + MACHINE_TOP, this.zLevel); + GlStateManager.disableBlend(); } /** * Gets the z-coordinate of this GUI element diff --git a/src/test/java/com/mcmoddev/poweradvantage/UniversalBucketStartupTest.java b/src/test/java/com/mcmoddev/poweradvantage/UniversalBucketStartupTest.java new file mode 100644 index 0000000..5abb809 --- /dev/null +++ b/src/test/java/com/mcmoddev/poweradvantage/UniversalBucketStartupTest.java @@ -0,0 +1,27 @@ +package com.mcmoddev.poweradvantage; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import net.minecraft.init.Bootstrap; +import net.minecraftforge.fluids.FluidRegistry; +import org.junit.Test; + +import static org.junit.Assert.*; + +public class UniversalBucketStartupTest { + @Test public void bucketOptInBelongsToTheMainClassStaticInitializer() throws Exception { + String source = new String(Files.readAllBytes(Paths.get("src/main/java", + PowerAdvantage.class.getName().replace('.', '/') + ".java")), StandardCharsets.UTF_8); + String body = source.substring(source.indexOf("public class PowerAdvantage")); + assertTrue("Opt in during construction, not a lifecycle callback or an optional integration", + java.util.regex.Pattern.compile("static\\s*\\{\\s*FluidRegistry\\.enableUniversalBucket\\(\\);\\s*\\}") + .matcher(body).find()); + } + + @Test public void initializingPowerEnablesTheForgeBucketApi() throws Exception { + Bootstrap.register(); + Class.forName(PowerAdvantage.class.getName(), true, PowerAdvantage.class.getClassLoader()); + assertTrue("Power must opt in itself before Forge registers the item", FluidRegistry.isUniversalBucketEnabled()); + } +} diff --git a/src/test/java/com/mcmoddev/poweradvantage/compat/CarbonDustFallbackTest.java b/src/test/java/com/mcmoddev/poweradvantage/compat/CarbonDustFallbackTest.java new file mode 100644 index 0000000..2e5b286 --- /dev/null +++ b/src/test/java/com/mcmoddev/poweradvantage/compat/CarbonDustFallbackTest.java @@ -0,0 +1,115 @@ +package com.mcmoddev.poweradvantage.compat; + +import com.mcmoddev.poweradvantage.PowerAdvantage; +import java.lang.reflect.Method; +import java.util.*; +import net.minecraft.init.Bootstrap; +import net.minecraft.init.Items; +import net.minecraft.item.ItemStack; +import net.minecraft.item.crafting.IRecipe; +import net.minecraft.item.crafting.Ingredient; +import net.minecraft.nbt.NBTTagCompound; +import net.minecraftforge.event.RegistryEvent; +import net.minecraftforge.fml.common.Mod; +import net.minecraftforge.fml.common.eventhandler.EventPriority; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; +import net.minecraftforge.oredict.OreDictionary; +import net.minecraftforge.oredict.OreIngredient; +import org.junit.BeforeClass; +import org.junit.Test; +import static org.junit.Assert.*; + +public class CarbonDustFallbackTest { + @BeforeClass public static void bootstrap() { Bootstrap.register(); } + + private static final class Registry { + final Map> ores = new HashMap<>(); + List entries(String name) { return ores.computeIfAbsent(name, key -> new ArrayList<>()); } + void apply() { BaseMetalsCompat.registerCarbonDustFallback(this::entries, (name, stack) -> entries(name).add(stack)); } + } + + @Test public void bothProvidersAreCopiedWithoutChangingQuantityMetadataOrNbt() { + Registry registry = new Registry(); + ItemStack coal = new ItemStack(Items.COAL, 3, 0); + coal.setTagCompound(new NBTTagCompound()); + coal.getTagCompound().setString("Witness", "original"); + ItemStack charcoal = new ItemStack(Items.COAL, 2, 1); + registry.entries("dustCoal").add(coal); + registry.entries("dustCharcoal").add(charcoal); + registry.apply(); + List actual = registry.entries("dustCarbon"); + assertEquals(2, actual.size()); + assertNotSame(coal, actual.get(0)); + assertNotSame(charcoal, actual.get(1)); + assertTrue(ItemStack.areItemStacksEqual(coal, actual.get(0))); + assertTrue(ItemStack.areItemStacksEqual(charcoal, actual.get(1))); + coal.setCount(9); + coal.getTagCompound().setString("Witness", "changed"); + charcoal.setItemDamage(0); + assertEquals(3, actual.get(0).getCount()); + assertEquals("original", actual.get(0).getTagCompound().getString("Witness")); + assertEquals(1, actual.get(1).getMetadata()); + } + + @Test public void existingCanonicalEntriesRemainAuthoritative() { + Registry registry = new Registry(); + ItemStack original = new ItemStack(Items.DIAMOND); + registry.entries("dustCarbon").add(original); + registry.entries("dustCoal").add(new ItemStack(Items.COAL)); + registry.apply(); + assertEquals(1, registry.entries("dustCarbon").size()); + assertSame(original, registry.entries("dustCarbon").get(0)); + } + + @Test public void missingAndEmptyProvidersDoNotInventMaterials() { + Registry registry = new Registry(); + registry.entries("dustCoal").addAll(Arrays.asList(null, ItemStack.EMPTY, new ItemStack(Items.COAL, 0))); + registry.apply(); + assertTrue(registry.entries("dustCarbon").isEmpty()); + } + + @Test public void repeatedCallsDoNotDuplicateTheFallback() { + Registry registry = new Registry(); + registry.entries("dustCoal").add(new ItemStack(Items.COAL)); + registry.apply(); + registry.apply(); + assertEquals(1, registry.entries("dustCarbon").size()); + } + + @Test public void steelAndUnrelatedMaterialsAreUntouched() { + Registry registry = new Registry(); + ItemStack steel = new ItemStack(Items.IRON_INGOT); + registry.entries("blockSteel").add(steel); + registry.entries("dustGraphite").add(new ItemStack(Items.DIAMOND)); + registry.apply(); + assertSame(steel, registry.entries("blockSteel").get(0)); + assertEquals(1, registry.entries("blockSteel").size()); + assertTrue(registry.entries("dustCarbon").isEmpty()); + } + + @Test public void nativeIngredientRejectsBeforeTheAliasAndAcceptsBothProvidersAfterIt() { + String prefix = "advworksCarbon" + UUID.randomUUID().toString().replace("-", ""); + OreDictionary.registerOre(prefix + "dustCoal", new ItemStack(Items.COAL, 1, 0)); + OreDictionary.registerOre(prefix + "dustCharcoal", new ItemStack(Items.COAL, 1, 1)); + Ingredient ingredient = new OreIngredient(prefix + "dustCarbon"); + assertEquals(0, ingredient.getMatchingStacks().length); + assertFalse(ingredient.apply(new ItemStack(Items.COAL))); + BaseMetalsCompat.registerCarbonDustFallback(name -> OreDictionary.getOres(prefix + name), + (name, stack) -> OreDictionary.registerOre(prefix + name, stack)); + assertEquals(2, OreDictionary.getOres(prefix + "dustCarbon").size()); + assertTrue(ingredient.apply(new ItemStack(Items.COAL, 1, 0))); + assertTrue(ingredient.apply(new ItemStack(Items.COAL, 1, 1))); + assertFalse(ingredient.apply(ItemStack.EMPTY)); + assertFalse(ingredient.apply(new ItemStack(Items.IRON_INGOT))); + assertEquals(2, new OreIngredient(prefix + "dustCarbon").getMatchingStacks().length); + } + + @Test public void recipeRegistryHookHasHighestPriorityAfterItemRegistration() throws Exception { + Mod.EventBusSubscriber subscriber = BaseMetalsCompat.class.getAnnotation(Mod.EventBusSubscriber.class); + assertNotNull(subscriber); + assertEquals(PowerAdvantage.MODID, subscriber.modid()); + Method hook = BaseMetalsCompat.class.getMethod("registerRecipeAliases", RegistryEvent.Register.class); + assertEquals(EventPriority.HIGHEST, hook.getAnnotation(SubscribeEvent.class).priority()); + assertTrue(hook.getGenericParameterTypes()[0].getTypeName().contains(IRecipe.class.getName())); + } +} diff --git a/src/test/java/com/mcmoddev/poweradvantage/conduitnetwork/ConduitNetworkManagerTest.java b/src/test/java/com/mcmoddev/poweradvantage/conduitnetwork/ConduitNetworkManagerTest.java new file mode 100644 index 0000000..8e5f9b1 --- /dev/null +++ b/src/test/java/com/mcmoddev/poweradvantage/conduitnetwork/ConduitNetworkManagerTest.java @@ -0,0 +1,62 @@ +package com.mcmoddev.poweradvantage.conduitnetwork; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import cyano.poweradvantage.api.ConduitType; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.ChunkPos; +import org.junit.Test; +import com.mcmoddev.poweradvantage.math.BlockPos4D; + +public class ConduitNetworkManagerTest { + private final ConduitNetworkManager manager = new ConduitNetworkManager(new ConduitType("test")); + + private static BlockPos4D pos(int dimension, int x, int z) { + return new BlockPos4D(dimension, new BlockPos(x, 80, z)); + } + + @Test public void unloadingAnIntermediateChunkInvalidatesTheWholeNetwork() { + BlockPos4D source = pos(0, 13, 8), bridge = pos(0, 20, 8), sink = pos(0, 40, 8); + manager.addBlockToNetwork(source, bridge); + manager.addBlockToNetwork(source, sink); + manager.invalidateChunk(0, new ChunkPos(1, 0)); + assertFalse(manager.isValidatedNetwork(source)); + assertFalse(manager.isValidatedNetwork(bridge)); + assertFalse(manager.isValidatedNetwork(sink)); + } + + @Test public void loadingAPipeOnlyChunkInvalidatesBothBoundaryNetworks() { + BlockPos4D left = pos(0, 15, 8), right = pos(0, 32, 8); + manager.createNewNetwork(left); + manager.createNewNetwork(right); + manager.invalidateChunk(0, new ChunkPos(1, 0)); + assertFalse(manager.isValidatedNetwork(left)); + assertFalse(manager.isValidatedNetwork(right)); + } + + @Test public void allFourChunkBoundariesAreInvalidated() { + BlockPos4D[] edges = {pos(0, 15, 20), pos(0, 32, 20), pos(0, 20, 15), pos(0, 20, 32)}; + for (BlockPos4D edge : edges) manager.createNewNetwork(edge); + manager.invalidateChunk(0, new ChunkPos(1, 1)); + for (BlockPos4D edge : edges) assertFalse(manager.isValidatedNetwork(edge)); + } + + @Test public void negativeChunkCoordinatesUseTheSameBoundaryRules() { + BlockPos4D edge = pos(0, -17, -8); + manager.createNewNetwork(edge); + manager.invalidateChunk(0, new ChunkPos(-1, -1)); + assertFalse(manager.isValidatedNetwork(edge)); + } + + @Test public void unrelatedNetworksAndDimensionsRemainCached() { + BlockPos4D nearby = pos(0, 14, 8), distant = pos(0, 80, 8), otherDimension = pos(-1, 20, 8); + manager.createNewNetwork(nearby); + manager.createNewNetwork(distant); + manager.createNewNetwork(otherDimension); + manager.invalidateChunk(0, new ChunkPos(1, 0)); + assertTrue(manager.isValidatedNetwork(nearby)); + assertTrue(manager.isValidatedNetwork(distant)); + assertTrue(manager.isValidatedNetwork(otherDimension)); + } +} diff --git a/src/test/java/com/mcmoddev/poweradvantage/conduitnetwork/LoadedConduitNetworkTest.java b/src/test/java/com/mcmoddev/poweradvantage/conduitnetwork/LoadedConduitNetworkTest.java new file mode 100644 index 0000000..55c9da6 --- /dev/null +++ b/src/test/java/com/mcmoddev/poweradvantage/conduitnetwork/LoadedConduitNetworkTest.java @@ -0,0 +1,221 @@ +package com.mcmoddev.poweradvantage.conduitnetwork; + +import static org.junit.Assert.*; + +import cyano.poweradvantage.api.ConduitType; +import cyano.poweradvantage.api.ITypedConduit; +import cyano.poweradvantage.api.PowerConnectorContext; +import java.lang.reflect.Method; +import java.util.*; +import net.minecraft.block.Block; +import net.minecraft.block.material.Material; +import net.minecraft.block.state.IBlockState; +import net.minecraft.init.Blocks; +import net.minecraft.init.Bootstrap; +import net.minecraft.profiler.Profiler; +import net.minecraft.tileentity.TileEntity; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.ChunkPos; +import net.minecraft.world.World; +import net.minecraft.world.WorldProviderSurface; +import net.minecraft.world.chunk.Chunk; +import net.minecraft.world.chunk.IChunkProvider; +import net.minecraftforge.event.world.ChunkEvent; +import net.minecraftforge.fml.common.DummyModContainer; +import net.minecraftforge.fml.common.ModContainer; +import net.minecraftforge.fml.common.ModMetadata; +import net.minecraftforge.fml.common.eventhandler.EventBus; +import net.minecraftforge.fml.common.eventhandler.ListenerList; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; +import org.junit.BeforeClass; +import org.junit.Test; +import com.mcmoddev.poweradvantage.math.BlockPos4D; + +public class LoadedConduitNetworkTest { + private static final ConduitType TYPE = new ConduitType("test-chunk-boundary"); + + @BeforeClass public static void bootstrap() { Bootstrap.register(); } + + @Test public void anUnloadedOriginIsNotReadOrCached() { + GuardWorld world = new GuardWorld(false); + ConduitNetworkManager manager = new ConduitNetworkManager(TYPE); + manager.revalidate(pos(15), world, TYPE); + assertEquals(0, world.blockReads); + assertFalse(manager.isValidatedNetwork(pos(15))); + } + + @Test public void anIsolatedLoadedOriginIsStillAValidatedNetwork() { + GuardWorld world = lineWorld(); + ConduitNetworkManager manager = new ConduitNetworkManager(TYPE); + manager.revalidate(pos(15), world, TYPE); + assertEquals(Collections.singletonList(pos(15)), manager.getNetwork(pos(15))); + } + + @Test public void scanningStopsAtTheUnloadedNeighbourChunk() { + GuardWorld world = lineWorld(); + ConduitNetworkManager manager = new ConduitNetworkManager(TYPE); + manager.revalidate(pos(15), world, TYPE); + assertFalse(manager.areInSameNetwork(pos(15), pos(16))); + assertTrue(world.blockReads > 0); + } + + @Test public void requestAtAnUnloadedOriginDoesNotReadBlocksOrTiles() { + GuardWorld world = new GuardWorld(false); + assertTrue(new ConduitRegistry().getRequestsForPower(world, pos(15).pos, TYPE).isEmpty()); + assertEquals(0, world.blockReads); + assertEquals(0, world.tileReads); + } + + @Test public void collectingCachedRequestsNeverReadsAnUnloadedEntry() throws Exception { + GuardWorld world = lineWorld(); + Method collect = ConduitRegistry.class.getDeclaredMethod("collectRequests", World.class, + List.class, ConduitType.class, ConduitType.class); + collect.setAccessible(true); + Object requests = collect.invoke(new ConduitRegistry(), world, + Arrays.asList(pos(15), pos(20)), TYPE, TYPE); + assertTrue(((List) requests).isEmpty()); + assertEquals(1, world.blockReads); + assertEquals(0, world.tileReads); + } + + @Test public void nativeUnloadEventDisconnectsCachedLoadedEndpoints() throws Exception { + GuardWorld world = lineWorld(); + world.loaded.add(new ChunkPos(1, 0)); + world.loaded.add(new ChunkPos(2, 0)); + EventFixture events = new EventFixture(); + ConduitNetworkManager manager = managerFor(events.registry, world); + manager.revalidate(pos(15), world, TYPE); + assertTrue(manager.areInSameNetwork(pos(15), pos(33))); + world.loaded.remove(new ChunkPos(1, 0)); + assertEquals(0, events.registry.unloads); + events.bus.post(new TestUnload(new Chunk(world, 1, 0))); + assertEquals("The real Forge dispatcher must deliver exactly one unload callback", 1, events.registry.unloads); + assertEquals(0, events.registry.loads); + assertFalse(manager.isValidatedNetwork(pos(15))); + assertFalse(manager.isValidatedNetwork(pos(33))); + manager.revalidate(pos(15), world, TYPE); + assertFalse(manager.areInSameNetwork(pos(15), pos(33))); + } + + @Test public void pipeOnlyLoadEventReconnectsWithoutReplacingAnyBlock() throws Exception { + GuardWorld world = lineWorld(); + EventFixture events = new EventFixture(); + ConduitNetworkManager manager = managerFor(events.registry, world); + manager.revalidate(pos(15), world, TYPE); + assertFalse(manager.areInSameNetwork(pos(15), pos(33))); + world.loaded.add(new ChunkPos(1, 0)); + world.loaded.add(new ChunkPos(2, 0)); + assertEquals(0, events.registry.loads); + events.bus.post(new TestLoad(new Chunk(world, 1, 0))); + assertEquals("The real Forge dispatcher must deliver exactly one load callback", 1, events.registry.loads); + assertEquals(0, events.registry.unloads); + assertFalse(manager.isValidatedNetwork(pos(15))); + manager.revalidate(pos(15), world, TYPE); + assertTrue(manager.areInSameNetwork(pos(15), pos(33))); + } + + @Test public void clientChunkEventsDoNotInvalidateServerNetworks() throws Exception { + GuardWorld server = lineWorld(); + EventFixture events = new EventFixture(); + ConduitNetworkManager manager = managerFor(events.registry, server); + manager.createNewNetwork(pos(15)); + events.bus.post(new TestUnload(new Chunk(new GuardWorld(true), 0, 0))); + assertEquals("Ignoring a client event must not be confused with a missing callback", 1, events.registry.unloads); + assertTrue(manager.isValidatedNetwork(pos(15))); + } + + private static BlockPos4D pos(int x) { return new BlockPos4D(0, new BlockPos(x, 80, 8)); } + + private static GuardWorld lineWorld() { + GuardWorld world = new GuardWorld(false); + world.loaded.add(new ChunkPos(0, 0)); + Block pipe = new TestConduit(); + for (int x = 15; x <= 33; x++) world.blocks.put(pos(x).pos, pipe.getDefaultState()); + return world; + } + + private static ConduitNetworkManager managerFor(ConduitRegistry registry, GuardWorld world) throws Exception { + registry.conduitBlockPlacedEvent(world, 0, pos(15).pos, TYPE); + Method getManager = ConduitRegistry.class.getDeclaredMethod("getConduitNetworkManager", ConduitType.class); + getManager.setAccessible(true); + return (ConduitNetworkManager) getManager.invoke(registry, TYPE); + } + + // Plain JUnit does not run Forge's event transformer. Supply only its no-arg constructor + // and listener-list prerequisites; Forge still registers and ASM-dispatches the production methods. + private static final class EventFixture { + final EventBus bus = new EventBus(); + final CountingRegistry registry = new CountingRegistry(); + EventFixture() throws Exception { + Method register = EventBus.class.getDeclaredMethod("register", Class.class, Object.class, + Method.class, ModContainer.class); + register.setAccessible(true); + register(register, TestLoad.class, "onChunkLoad", ChunkEvent.Load.class); + register(register, TestUnload.class, "onChunkUnload", ChunkEvent.Unload.class); + } + private void register(Method register, Class fixtureEvent, String name, Class nativeEvent) throws Exception { + Method handler = ConduitRegistry.class.getMethod(name, nativeEvent); + assertNotNull("The production callback must retain its Forge subscription", handler.getAnnotation(SubscribeEvent.class)); + ModMetadata owner = new ModMetadata(); + owner.modId = "test-network-events"; + owner.name = "Network event unit fixture"; + owner.version = "test"; + register.invoke(bus, fixtureEvent, registry, handler, new DummyModContainer(owner)); + java.lang.reflect.Field listeners = EventBus.class.getDeclaredField("listeners"); + listeners.setAccessible(true); + Map installed = (Map) listeners.get(bus); + assertTrue("Forge registration must install the callback", installed.containsKey(registry)); + assertEquals(name.equals("onChunkLoad") ? 1 : 2, ((List) installed.get(registry)).size()); + } + } + + public static final class CountingRegistry extends ConduitRegistry { + int loads, unloads; + @Override public void onChunkLoad(ChunkEvent.Load event) { loads++; super.onChunkLoad(event); } + @Override public void onChunkUnload(ChunkEvent.Unload event) { unloads++; super.onChunkUnload(event); } + } + + public static final class TestLoad extends ChunkEvent.Load { + private static final ListenerList LISTENERS = new ListenerList(); + public TestLoad() { this(new Chunk(new GuardWorld(false), 0, 0)); } + TestLoad(Chunk chunk) { super(chunk); } + @Override public ListenerList getListenerList() { return LISTENERS; } + } + + public static final class TestUnload extends ChunkEvent.Unload { + private static final ListenerList LISTENERS = new ListenerList(); + public TestUnload() { this(new Chunk(new GuardWorld(false), 0, 0)); } + TestUnload(Chunk chunk) { super(chunk); } + @Override public ListenerList getListenerList() { return LISTENERS; } + } + + private static final class TestConduit extends Block implements ITypedConduit { + TestConduit() { super(Material.ROCK); } + @Override public boolean canAcceptConnection(PowerConnectorContext context) { return true; } + @Override public ConduitType[] getTypes() { return new ConduitType[]{TYPE}; } + @Override public boolean isPowerSink(ConduitType type) { return false; } + @Override public boolean isPowerSource(ConduitType type) { return false; } + } + + // No native provider exists here: any unloaded read fails instead of silently loading a chunk. + private static final class GuardWorld extends World { + final Set loaded = new HashSet<>(); + final Map blocks = new HashMap<>(); + int blockReads, tileReads; + GuardWorld(boolean remote) { super(null, null, new WorldProviderSurface(), new Profiler(), remote); } + @Override protected IChunkProvider createChunkProvider() { throw new AssertionError("Unexpected provider creation"); } + @Override protected boolean isChunkLoaded(int x, int z, boolean allowEmpty) { + return loaded.contains(new ChunkPos(x, z)); + } + @Override public IBlockState getBlockState(BlockPos pos) { + assertTrue("Unloaded block read at " + pos, isBlockLoaded(pos)); + blockReads++; + return blocks.getOrDefault(pos, Blocks.AIR.getDefaultState()); + } + @Override public TileEntity getTileEntity(BlockPos pos) { + assertTrue("Unloaded tile read at " + pos, isBlockLoaded(pos)); + tileReads++; + return null; + } + } +} diff --git a/src/test/java/com/mcmoddev/poweradvantage/conduitnetwork/PowerTransferAccountingTest.java b/src/test/java/com/mcmoddev/poweradvantage/conduitnetwork/PowerTransferAccountingTest.java new file mode 100644 index 0000000..66c63eb --- /dev/null +++ b/src/test/java/com/mcmoddev/poweradvantage/conduitnetwork/PowerTransferAccountingTest.java @@ -0,0 +1,52 @@ +package com.mcmoddev.poweradvantage.conduitnetwork; + +import cyano.poweradvantage.api.*; +import java.util.Arrays; +import org.junit.Test; +import static org.junit.Assert.assertEquals; + +public class PowerTransferAccountingTest { + private static final ConduitType TYPE = new ConduitType("electricity"); + + @Test public void partialAcceptanceLeavesEnergyForTheNextConsumer() { + Sink first = new Sink(10), second = new Sink(100); + assertEquals(80, send(80, first, second), 0); + assertEquals(10, first.stored, 0); + assertEquals(70, second.stored, 0); + } + + @Test public void rejectedOfferIsNotChargedToTheSource() { + assertEquals(0, send(80, new Sink(0)), 0); + } + + @Test public void onlyActuallyStoredEnergyIsChargedToTheSource() { + Sink first = new Sink(10), second = new Sink(5); + assertEquals(15, send(80, first, second), 0); + } + + private static float send(float available, Sink... sinks) { + return ConduitRegistry.distributePower(available, TYPE, PowerRequest.LOW_PRIORITY, null, null, + Arrays.stream(sinks).map(s -> new PowerRequest(PowerRequest.MEDIUM_PRIORITY, 100, s)) + .collect(java.util.stream.Collectors.toList())); + } + + private static final class Sink implements IPowerMachine { + final float capacity; + float stored; + Sink(float capacity) { this.capacity = capacity; } + @Override public float getEnergyCapacity(ConduitType type) { return capacity; } + @Override public float getEnergy(ConduitType type) { return stored; } + @Override public void setEnergy(float energy, ConduitType type) { stored = energy; } + @Override public float addEnergy(float energy, ConduitType type) { + float accepted = Math.min(energy, capacity - stored); + stored += accepted; + return accepted; + } + @Override public float subtractEnergy(float energy, ConduitType type) { return 0; } + @Override public PowerRequest getPowerRequest(ConduitType type) { return PowerRequest.REQUEST_NOTHING; } + @Override public boolean isPowerSink(ConduitType type) { return true; } + @Override public boolean isPowerSource(ConduitType type) { return false; } + @Override public boolean canAcceptConnection(PowerConnectorContext context) { return true; } + @Override public ConduitType[] getTypes() { return new ConduitType[]{TYPE}; } + } +} diff --git a/src/test/java/com/mcmoddev/poweradvantage/machines/conveyors/ConveyorTransferTest.java b/src/test/java/com/mcmoddev/poweradvantage/machines/conveyors/ConveyorTransferTest.java new file mode 100644 index 0000000..1c9fa1c --- /dev/null +++ b/src/test/java/com/mcmoddev/poweradvantage/machines/conveyors/ConveyorTransferTest.java @@ -0,0 +1,43 @@ +package com.mcmoddev.poweradvantage.machines.conveyors; + +import net.minecraft.inventory.InventoryBasic; +import net.minecraft.item.ItemStack; +import net.minecraft.init.Items; +import net.minecraft.util.EnumFacing; +import org.junit.BeforeClass; +import org.junit.Test; +import com.mcmoddev.poweradvantage.util.InventoryWrapper; +import static org.junit.Assert.*; + +public class ConveyorTransferTest { + @BeforeClass public static void bootstrap() { net.minecraft.init.Bootstrap.register(); } + + @Test public void laterEmptySlotRemainsAnAvailableDestination() { + InventoryBasic destination = new InventoryBasic("test", false, 2); + destination.setInventorySlotContents(0, new ItemStack(net.minecraft.item.Item.getItemFromBlock(net.minecraft.init.Blocks.COBBLESTONE), 64)); + assertTrue(TileEntityConveyor.canInsertItemInto(new ItemStack(net.minecraft.item.Item.getItemFromBlock(net.minecraft.init.Blocks.DIRT)), InventoryWrapper.wrap(destination), EnumFacing.UP)); + } + + @Test public void fullStackIsNotAnAvailableDestination() { + InventoryBasic destination = new InventoryBasic("test", false, 1); + destination.setInventorySlotContents(0, new ItemStack(net.minecraft.item.Item.getItemFromBlock(net.minecraft.init.Blocks.DIRT), 64)); + assertFalse(TileEntityConveyor.canInsertItemInto(new ItemStack(net.minecraft.item.Item.getItemFromBlock(net.minecraft.init.Blocks.DIRT)), InventoryWrapper.wrap(destination), EnumFacing.UP)); + } + + @Test public void transfersPreserveDifferentCustomItemData() { + InventoryBasic source = new InventoryBasic("source", false, 2), + target = new InventoryBasic("target", false, 2); + source.setInventorySlotContents(0, tagged(3, "A")); source.setInventorySlotContents(1, tagged(3, "B")); target.setInventorySlotContents(0, tagged(1, "A")); + for (int n = 0; n < 6; n++) assertTrue(TileEntityConveyor.transferItem(InventoryWrapper.wrap(source), EnumFacing.EAST, + InventoryWrapper.wrap(target), EnumFacing.WEST)); + assertTrue(source.isEmpty()); + assertEquals(4, target.getStackInSlot(0).getCount()); assertEquals("A", target.getStackInSlot(0).getTagCompound().getString("Witness")); + assertEquals(3, target.getStackInSlot(1).getCount()); assertEquals("B", target.getStackInSlot(1).getTagCompound().getString("Witness")); + } + + private static ItemStack tagged(int count, String value) { + ItemStack stack = new ItemStack(net.minecraft.item.Item.getItemFromBlock(net.minecraft.init.Blocks.DIRT), count); + net.minecraft.nbt.NBTTagCompound tag = new net.minecraft.nbt.NBTTagCompound(); tag.setString("Witness", value); stack.setTagCompound(tag); + return stack; + } +} diff --git a/src/test/java/com/mcmoddev/poweradvantage/machines/conveyors/SmeltableFilterTest.java b/src/test/java/com/mcmoddev/poweradvantage/machines/conveyors/SmeltableFilterTest.java new file mode 100644 index 0000000..d99345e --- /dev/null +++ b/src/test/java/com/mcmoddev/poweradvantage/machines/conveyors/SmeltableFilterTest.java @@ -0,0 +1,25 @@ +package com.mcmoddev.poweradvantage.machines.conveyors; + +import net.minecraft.init.Blocks; +import net.minecraft.init.Items; +import net.minecraft.item.ItemStack; +import org.junit.BeforeClass; +import org.junit.Test; +import static org.junit.Assert.*; + +public class SmeltableFilterTest { + @BeforeClass public static void bootstrap() { net.minecraft.init.Bootstrap.register(); } + @Test public void furnaceInputsStillMatch() { + TileEntitySmeltableFilter filter = new TileEntitySmeltableFilter(); + assertTrue(filter.matchesFilter(new ItemStack(Items.POTATO))); + assertTrue(filter.matchesFilter(new ItemStack(Blocks.IRON_ORE))); + } + @Test public void anEmptyRecipeResultDoesNotMatch() { + assertFalse(new TileEntitySmeltableFilter().matchesFilter(new ItemStack(Blocks.DIRT))); + } + @Test public void missingInputDoesNotMatch() { + TileEntitySmeltableFilter filter = new TileEntitySmeltableFilter(); + assertFalse(filter.matchesFilter(null)); + assertFalse(filter.matchesFilter(ItemStack.EMPTY)); + } +} diff --git a/src/test/java/com/mcmoddev/poweradvantage/registry/MachineGuiTitleTest.java b/src/test/java/com/mcmoddev/poweradvantage/registry/MachineGuiTitleTest.java new file mode 100644 index 0000000..69c52c6 --- /dev/null +++ b/src/test/java/com/mcmoddev/poweradvantage/registry/MachineGuiTitleTest.java @@ -0,0 +1,55 @@ +package com.mcmoddev.poweradvantage.registry; + +import com.mcmoddev.poweradvantage.machines.fluidmachines.MetalTankTileEntity; +import com.mcmoddev.poweradvantage.machines.fluidmachines.StorageTankTileEntity; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.Properties; +import net.minecraft.entity.player.InventoryPlayer; +import net.minecraft.inventory.IInventory; +import net.minecraft.util.text.ITextComponent; +import net.minecraft.util.text.TextComponentTranslation; +import org.junit.BeforeClass; +import org.junit.Test; +import static org.junit.Assert.*; + +/** Caption inputs only; these tests do not prove that the client renders a label. */ +public class MachineGuiTitleTest { + @BeforeClass public static void bootstrap() { + net.minecraft.init.Bootstrap.register(); + } + + @Test public void tankNamesUseTheirPublishedTranslationsRatherThanJavaClassNames() throws Exception { + assertCaption(new StorageTankTileEntity(), "tile.poweradvantage.fluid_storage_tank.name", + "Portable Fluid Tank"); + assertCaption(new MetalTankTileEntity(), "tile.poweradvantage.fluid_metal_tank.name", + "Metal Fluid Tank"); + } + + @Test public void renamedTanksKeepThePlayersFullName() { + MetalTankTileEntity tile = new MetalTankTileEntity(); + tile.setCustomInventoryName("John's oil tank"); + assertTrue(tile.hasCustomName()); + assertEquals("John's oil tank", tile.getDisplayName().getUnformattedText()); + } + + @Test public void playerInventorySuppliesTheNativeInventoryCaption() { + ITextComponent caption = new InventoryPlayer(null).getDisplayName(); + assertTrue(caption instanceof TextComponentTranslation); + assertEquals("container.inventory", ((TextComponentTranslation) caption).getKey()); + } + + private static void assertCaption(IInventory tank, String key, String literal) throws Exception { + ITextComponent caption = tank.getDisplayName(); + assertTrue("An unnamed tank needs a translatable caption", caption instanceof TextComponentTranslation); + assertEquals(key, ((TextComponentTranslation) caption).getKey()); + Properties english = new Properties(); + try (InputStream stream = MachineGuiTitleTest.class.getResourceAsStream( + "/assets/poweradvantage/lang/en_us.lang")) { + assertNotNull("Published Power Advantage English language resource", stream); + english.load(new InputStreamReader(stream, StandardCharsets.UTF_8)); + } + assertEquals(literal, english.getProperty(key)); + } +} diff --git a/test-rig/build.gradle b/test-rig/build.gradle new file mode 100644 index 0000000..714b1fe --- /dev/null +++ b/test-rig/build.gradle @@ -0,0 +1,44 @@ +plugins { + id 'java' + id 'net.minecraftforge.renamer' version '1.1.5' + id 'net.minecraftforge.gradle' version '7.0.34' +} + +layout.buildDirectory = layout.projectDirectory.dir('build/gradle') +group = 'zone.moddev.mc.advantageworks' +version = project.harness_version +base.archivesName = 'AdvantageWorksTestHarness' +java.toolchain.languageVersion = JavaLanguageVersion.of(8) +tasks.withType(JavaCompile).configureEach { + sourceCompatibility = '1.8' + targetCompatibility = '1.8' + options.encoding = 'UTF-8' +} +tasks.withType(AbstractArchiveTask).configureEach { + preserveFileTimestamps = false + reproducibleFileOrder = true +} +minecraft { + mappings channel: project.mapping_channel, version: project.mapping_version +} +repositories { + minecraft.mavenizer(it) + maven fg.forgeMaven + maven fg.minecraftLibsMaven + mavenCentral() +} +dependencies { + implementation minecraft.dependency("net.minecraftforge:forge:${project.minecraft_version}-${project.forge_version}") + compileOnly files(project.findProperty('powerAdvantageDeobfJar') + ?: '../build/libs-dev/PowerAdvantage-2.4.1.112021-deobf.jar') + testImplementation 'junit:junit:4.13.2' +} +tasks.named('jar', Jar) { + archiveClassifier = 'deobf' + destinationDirectory = layout.buildDirectory.dir('libs-dev') +} +def releaseJar = renamer.classes(tasks.named('jar', Jar)) { + map.from minecraft.dependency.toSrgFile + archiveClassifier = null +} +tasks.named('assemble') { dependsOn releaseJar } diff --git a/test-rig/client-resources/README.md b/test-rig/client-resources/README.md new file mode 100644 index 0000000..69775d5 --- /dev/null +++ b/test-rig/client-resources/README.md @@ -0,0 +1,37 @@ +# Native Machine GUI Check + +This isolated client probe checks the three Advantage mods on Minecraft 1.12.2, +Forge 14.23.5.2859 and stable mappings 39. It opens no saved world and registers +no gameplay content. It is not included in release jars or ordinary launches. + +Use Java 8, the matching native launch profile, and the exact three candidate +jars being tested. `run.ps1` requires `-JavaHome`, `-PowerJar`, `-SteamJar` and +`-ElectricJar`; `-LaunchProfile` selects the prepared isolated launch when it is +not `runClient.launch`. `-PreflightOnly` checks and snapshots inputs without +starting Minecraft or claiming a GUI pass. + +A full run requires 39 distinct screens, 351 caption/layout cases and 355 +rejected negative controls, with the expected images. It preserves the original +artwork's baked labels and tests the generic title and inventory rows separately. +This is GUI evidence, not machine-operation or manual-play acceptance. + +## Retained Evidence + +Each invocation writes to a new `run-client-resource-runs/-/` +directory at the PowerAdvantage project root, outside Gradle's `build/` output. +It retains input hashes, logs, images and `client-resources.json`. Preflight or +client failures also write `runner-failure.json`; partial native evidence is not +replaced with a passing report. Gradle `clean` does not remove this directory. +The output remains Git-ignored. Moving the output path does not recover older +captures already lost from the former build-based location. + +With all six version checkouts available, run this from the workspace root: + +```powershell +./PA116/PowerAdvantage/test-rig/client-resources/test-launcher.ps1 -StaticOnly -CrossVersionEvidenceRoots -WorkspaceRoot . +``` + +This static contract checks all six runner sources and rejects parser-valid +changes to the old `build/` output, traversal into `build/`, an absolute build +path or a changed project root. A missing runner fails the check. It starts no +Java process and does not qualify the native GUI or the candidate jars. diff --git a/test-rig/client-resources/java/zone/moddev/mc/advantageworks/client/ClientResourceProbe.java b/test-rig/client-resources/java/zone/moddev/mc/advantageworks/client/ClientResourceProbe.java new file mode 100644 index 0000000..6c821c1 --- /dev/null +++ b/test-rig/client-resources/java/zone/moddev/mc/advantageworks/client/ClientResourceProbe.java @@ -0,0 +1,81 @@ +package zone.moddev.mc.advantageworks.client; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import net.minecraft.client.Minecraft; +import net.minecraftforge.common.ForgeVersion; +import net.minecraftforge.fml.common.Loader; +import net.minecraftforge.fml.common.Mod; +import net.minecraftforge.fml.common.ModContainer; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; +import net.minecraftforge.fml.common.gameevent.TickEvent; +import net.minecraftforge.fml.relauncher.Side; +import org.apache.logging.log4j.LogManager; + +/** Native, worldless GUI capture only; no operating or migration claims. */ +@Mod(modid = ClientResourceProbe.MODID, name = "Advantage GUI Capture", version = "1.0.0.112021", + clientSideOnly = true, acceptedMinecraftVersions = "[1.12.2]", + dependencies = "required-after:poweradvantage;required-after:steamadvantage;required-after:electricadvantage") +@Mod.EventBusSubscriber(modid = ClientResourceProbe.MODID, value = Side.CLIENT) +public final class ClientResourceProbe { + static final String MODID = "advantageclientresources"; + private static boolean finished; + private static int ticks; + + @SubscribeEvent public static void tick(TickEvent.ClientTickEvent event) { + if (!Boolean.getBoolean("advantageworks.clientResources") || finished || event.phase != TickEvent.Phase.END) return; + Minecraft minecraft = Minecraft.getMinecraft(); + if (++ticks < 5) return; + finished = true; + JsonObject result = new JsonObject(); + result.addProperty("scope", "MACHINE_GUI_ONLY"); + try { + result.addProperty("minecraft", ForgeVersion.mcVersion); + result.addProperty("forge", ForgeVersion.getVersion()); + require("1.12.2".equals(ForgeVersion.mcVersion) && "14.23.5.2859".equals(ForgeVersion.getVersion()), + "Wrong native client target"); + require(minecraft.world == null && minecraft.player == null && !minecraft.isIntegratedServerRunning(), + "GUI capture must not open a world or connect to a server"); + require(Boolean.getBoolean("advantageworks.requireExtensions") + && Boolean.getBoolean("advantageworks.machineGuiLayouts"), "All three mods and the GUI audit are required"); + JsonArray mods = new JsonArray(); + JsonObject versions = new JsonObject(); + for (String id : new String[]{"poweradvantage", "steamadvantage", "electricadvantage"}) { + ModContainer mod = Loader.instance().getIndexedModList().get(id); + require(mod != null, "Combined launch omitted " + id); + String expected = System.getProperty("advantageworks.expectedVersion." + id); + require(expected != null && !expected.isEmpty() && expected.equals(mod.getVersion()), + "Candidate version differs from frozen input: " + id); + mods.add(id); + versions.addProperty(id, mod.getVersion()); + } + result.add("advantage_mods", mods); + result.add("mod_versions", versions); + require(minecraft.fontRenderer != null && minecraft.getTextureManager() != null, "Client resources are not ready"); + MachineGuiLayoutProbe.verify(minecraft, result); + require(result.get("machine_gui_layout_count").getAsInt() == 39 + && result.get("machine_gui_text_spec").getAsInt() == 2 + && result.get("machine_gui_text_checks").getAsInt() == 351 + && result.get("machine_gui_negative_controls_rejected").getAsInt() == 355, + "Incomplete native GUI coverage"); + result.addProperty("status", "PASS"); + LogManager.getLogger(MODID).info("ADVANTAGE_MACHINE_GUIS PASS screens=39 captions=351 controls=355"); + } catch (RuntimeException | LinkageError | AssertionError failure) { + result.addProperty("status", "FAIL"); + result.addProperty("error", failure.toString()); + LogManager.getLogger(MODID).error("ADVANTAGE_MACHINE_GUIS FAIL", failure); + } finally { + try { Files.write(Paths.get("client-resources.json"), result.toString().getBytes(StandardCharsets.UTF_8)); } + catch (IOException failure) { throw new IllegalStateException("Could not write native GUI result", failure); } + finally { minecraft.shutdown(); } + } + } + + private static void require(boolean condition, String message) { + if (!condition) throw new IllegalStateException(message); + } +} diff --git a/test-rig/client-resources/java/zone/moddev/mc/advantageworks/client/MachineGuiLayoutProbe.java b/test-rig/client-resources/java/zone/moddev/mc/advantageworks/client/MachineGuiLayoutProbe.java new file mode 100644 index 0000000..8e9eb67 --- /dev/null +++ b/test-rig/client-resources/java/zone/moddev/mc/advantageworks/client/MachineGuiLayoutProbe.java @@ -0,0 +1,483 @@ +package zone.moddev.mc.advantageworks.client; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import net.minecraft.client.renderer.GlStateManager; +import org.lwjgl.opengl.GL11; +import cyano.poweradvantage.api.GUIBlock; +import cyano.poweradvantage.api.PoweredEntity; +import net.minecraftforge.fluids.FluidStack; +import net.minecraftforge.fluids.FluidTank; +import cyano.poweradvantage.api.simple.SimpleMachineGUI; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import javax.imageio.ImageIO; +import net.minecraft.block.Block; +import net.minecraft.client.Minecraft; +import net.minecraft.client.entity.EntityPlayerSP; +import net.minecraft.client.shader.Framebuffer; +import net.minecraft.client.multiplayer.WorldClient; +import net.minecraft.entity.player.EntityPlayer; +import net.minecraft.entity.player.InventoryPlayer; +import net.minecraft.inventory.IInventory; +import net.minecraft.inventory.Slot; +import net.minecraft.tileentity.TileEntity; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.text.TextComponentTranslation; +import net.minecraftforge.fml.common.registry.ForgeRegistries; +import com.mcmoddev.poweradvantage.registry.MachineGUIRegistry; + +/** Renders every registered machine screen, not just its detached decorations. */ +final class MachineGuiLayoutProbe { + private static final int[][] VIEWPORTS = {{320, 240, 1}, {426, 240, 2}, {640, 360, 1}}; + + static void verify(Minecraft minecraft, JsonObject result) { + if (!Boolean.getBoolean("advantageworks.machineGuiLayouts")) return; + JsonArray screens = new JsonArray(); + result.add("machine_gui_layouts", screens); + result.addProperty("machine_gui_text_spec", 2); + int textChecks = 0, negativeControls = 0; + List problems = new ArrayList<>(); + EntityPlayerSP originalPlayer = minecraft.player; + try { + JsonObject expectations; + try (InputStream stream = MachineGuiLayoutProbe.class.getResourceAsStream("/gui-label-expectations.json")) { + require(stream != null, "Missing independent GUI label expectations"); + expectations = new JsonParser().parse(new InputStreamReader(stream, StandardCharsets.UTF_8)).getAsJsonObject(); + } + require("Inventory".equals(net.minecraft.client.resources.I18n.format("container.inventory")), "GUI label audit requires en_us"); + // ContainerScreen needs a carried-item inventory, but no world or connection. + Class allocator = Class.forName("sun.misc.Unsafe"); + Field singleton = allocator.getDeclaredField("theUnsafe"); + singleton.setAccessible(true); + EntityPlayerSP player = (EntityPlayerSP) allocator.getMethod("allocateInstance", Class.class) + .invoke(singleton.get(null), EntityPlayerSP.class); + InventoryPlayer inventory = new InventoryPlayer(player); + Field inventoryField = EntityPlayer.class.getDeclaredField("inventory"); + inventoryField.setAccessible(true); + inventoryField.set(player, inventory); + Field cooldowns = EntityPlayer.class.getDeclaredField("cooldownTracker"); + cooldowns.setAccessible(true); + cooldowns.set(player, new net.minecraft.util.CooldownTracker()); + minecraft.player = player; + Field definitionsField = MachineGUIRegistry.class.getDeclaredField("guiTable"); + definitionsField.setAccessible(true); + Map definitions = (Map) definitionsField.get(null); + List blocks = new ArrayList<>(); + ForgeRegistries.BLOCKS.forEach(block -> { + if (block instanceof GUIBlock && ((GUIBlock) block).getGuiID() > 0) blocks.add(block); + }); + blocks.sort(Comparator.comparing(block -> block.getRegistryName().toString())); + for (Block block : blocks) { + JsonObject row = new JsonObject(); + String id = block.getRegistryName().toString(); + row.addProperty("block", id); + screens.add(row); + try { + Object definition = definitions.get(((GUIBlock) block).getGuiID()); + require(definition instanceof SimpleMachineGUI, "No shared screen definition"); + Field textureField = SimpleMachineGUI.class.getDeclaredField("guiDisplayImage"); + textureField.setAccessible(true); + net.minecraft.util.ResourceLocation texture = (net.minecraft.util.ResourceLocation) textureField.get(definition); + try (net.minecraft.client.resources.IResource resource = minecraft.getResourceManager().getResource(texture)) { + BufferedImage artwork = ImageIO.read(resource.getInputStream()); + // The GUI UVs use a logical 256-square atlas; Electric supplies 2x artwork. + require(artwork != null && artwork.getWidth() >= 256 && artwork.getWidth() % 256 == 0 + && artwork.getWidth() == artwork.getHeight(), + "Missing or incorrectly sized GUI texture: " + texture); + row.addProperty("texture_pixels", artwork.getWidth()); + } + row.addProperty("texture", texture.toString()); + TileEntity tile = block.createTileEntity(null, block.getDefaultState()); + require(tile instanceof IInventory, "GUI block has no inventory tile"); + GuiWorld world = (GuiWorld) allocator.getMethod("allocateInstance", Class.class) + .invoke(singleton.get(null), GuiWorld.class); + world.state = block.getDefaultState(); + Field clientSide = net.minecraft.world.World.class.getDeclaredField("isRemote"); + clientSide.setAccessible(true); + clientSide.setBoolean(world, true); + tile.setWorld(world); + tile.setPos(BlockPos.ORIGIN); + SimpleMachineGUI.Container menu = ((SimpleMachineGUI) definition).new Container(inventory, (IInventory) tile); + require(expectations.getAsJsonObject("machines").has(id), "No human-facing label expectation for " + id); + String expectedTitle = expectations.getAsJsonObject("machines").get(id).getAsString(); + net.minecraft.util.text.ITextComponent title = menuTitle(tile, block); + RecordingScreen screen = new RecordingScreen((SimpleMachineGUI) definition, menu, inventory, title, + expectedTitle, expectations.get("inventory").getAsString()); + row.addProperty("definition", definition.getClass().getName()); + row.addProperty("expected_title", expectedTitle); + row.addProperty("menu_title", title.getUnformattedText()); + row.addProperty("slots", menu.inventorySlots.size()); + int renders = 0; + for (int[] viewport : VIEWPORTS) { + screen.setWorldAndResolution(minecraft, viewport[0], viewport[1]); + verifySlots(screen, menu); + for (int fill = 0; fill <= 2; fill++) { + for (net.minecraft.block.properties.IProperty property : block.getDefaultState().getPropertyKeys()) { + if (property instanceof net.minecraft.block.properties.PropertyBool && "active".equals(property.getName())) { + world.state = block.getDefaultState().withProperty((net.minecraft.block.properties.PropertyBool) property, fill > 0); + } + } + populate(tile, fill * 0.5F); + BufferedImage actual = render(minecraft, screen, viewport, false); + String name = id.replace(':', '-') + "-" + viewport[0] + "x" + viewport[1] + + "-scale" + viewport[2] + "-fill" + fill; + File target = new File("machine-gui-layouts/" + name + ".png"); + target.getParentFile().mkdirs(); + ImageIO.write(actual, "png", target); + require(screen.tooltipCalls > 0, "Native item tooltip rendering was not dispatched"); + screen.verifyDrawBounds(); + renders++; + BufferedImage background = render(minecraft, screen, viewport, true); + screen.labels = LabelMode.EXPECTED; + BufferedImage expected = render(minecraft, screen, viewport, false); + screen.labels = LabelMode.PRODUCTION; + String labelError = labelError(actual, expected, background, screen, viewport[2]); + require(labelError == null, labelError); + textChecks++; + if (fill == 0 && viewport == VIEWPORTS[0]) verifyNoTextOverArtwork(actual, background, screen); + require(title.getUnformattedText().equals(expectedTitle), "Menu title differs from the equipment name: " + title.getUnformattedText()); + require(screen.getYSize() == expectations.getAsJsonObject("layout").get("height").getAsInt(), "Missing reserved label space"); + verifyInventoryRows(menu); + require(labelError(background, expected, background, screen, viewport[2]) != null, + "Negative control accepted a screen with no labels"); + negativeControls++; + } + } + inventory.setInventorySlotContents(0, new net.minecraft.item.ItemStack(net.minecraft.init.Items.DIAMOND, 2)); + screen.setWorldAndResolution(minecraft, 320, 240); + BufferedImage tooltip = render(minecraft, screen, VIEWPORTS[0], false, true); + require(screen.itemTooltipCalls == 1, "Hovered inventory item has no native tooltip"); + require(screen.hovered() == menu.inventorySlots.get(menu.inventorySlots.size() - 9), "Inventory hitbox is misaligned"); + ImageIO.write(tooltip, "png", new File("machine-gui-layouts/" + id.replace(':', '-') + "-tooltip.png")); + inventory.setInventorySlotContents(0, net.minecraft.item.ItemStack.EMPTY); + if (block == com.mcmoddev.poweradvantage.init.Blocks.metal_storage_tank) { + negativeControls += verifyMissingLabelControls(minecraft, screen); + verifyLongName(minecraft, (SimpleMachineGUI) definition, menu, inventory); + } + if (block == com.mcmoddev.poweradvantage.init.Blocks.metal_storage_tank) { + verifyHelperLimits(screen); + } + row.addProperty("renders", renders); + row.addProperty("tooltip_and_inventory_hitbox", "PASS"); + row.addProperty("required_text", "PASS"); + row.addProperty("blank_label_controls_rejected", 9); + row.addProperty("status", "PASS"); + } catch (Exception | LinkageError failure) { + inventory.setInventorySlotContents(0, net.minecraft.item.ItemStack.EMPTY); + org.apache.logging.log4j.LogManager.getLogger("advantageclientresources").error("GUI fixture {} failed", id, failure); + row.addProperty("status", "FAIL"); + row.addProperty("error", failure.toString()); + problems.add(id + ": " + failure); + } + } + result.addProperty("machine_gui_layout_count", screens.size()); + require(!blocks.isEmpty(), "No registered machine screens were audited"); + if (Boolean.getBoolean("advantageworks.requireExtensions")) require(blocks.size() == 39, + "Expected all 14 Power, 10 Steam and 15 Electric screens, found " + blocks.size()); + require(problems.isEmpty(), "Machine GUI failures: " + problems); + result.addProperty("machine_gui_text_status", "PASS"); + result.addProperty("machine_gui_text_checks", textChecks); + result.addProperty("machine_gui_negative_controls_rejected", negativeControls); + result.addProperty("machine_gui_layout_status", "PASS"); + } catch (Exception failure) { + result.addProperty("machine_gui_layout_status", "FAIL"); + throw new IllegalStateException("Machine GUI audit failed", failure); + } finally { + minecraft.player = originalPlayer; + } + } + + private static IInventory inventoryOf(SimpleMachineGUI.Container menu) throws Exception { + Field entity = SimpleMachineGUI.Container.class.getDeclaredField("entity"); + entity.setAccessible(true); + return (IInventory) entity.get(menu); + } + + private static net.minecraft.util.text.ITextComponent menuTitle(TileEntity tile, Block block) throws Exception { + try { + Method factory = MachineGUIRegistry.class.getDeclaredMethod("menuTitle", TileEntity.class, Block.class); + factory.setAccessible(true); + return (net.minecraft.util.text.ITextComponent) factory.invoke(null, tile, block); + } catch (NoSuchMethodException legacy) { + // Use the legacy inventory name when no caption resolver exists. + return tile instanceof net.minecraft.world.IWorldNameable ? ((net.minecraft.world.IWorldNameable) tile).getDisplayName() + : new TextComponentTranslation(block.getTranslationKey() + ".name"); + } + } + + private static void verifySlots(SimpleMachineGUI.GUIContainer screen, SimpleMachineGUI.Container menu) { + require(screen.getLeft() >= 0 && screen.getTop() >= 0 + && screen.getLeft() + screen.getXSize() <= screen.width + && screen.getTop() + screen.getYSize() <= screen.height, "Screen is clipped at a supported GUI size"); + require(menu.inventorySlots.size() >= 36, "Missing player inventory"); + for (int i = 0; i < menu.inventorySlots.size(); i++) { + Slot slot = menu.inventorySlots.get(i); + require(slot.xPos >= 0 && slot.yPos >= 0 && slot.xPos + 16 <= screen.getXSize() + && slot.yPos + 16 <= screen.getYSize(), "Slot outside the screen: " + i); + for (int j = 0; j < i; j++) { + Slot other = menu.inventorySlots.get(j); + require(slot.xPos + 16 <= other.xPos || other.xPos + 16 <= slot.xPos + || slot.yPos + 16 <= other.yPos || other.yPos + 16 <= slot.yPos, "Overlapping slots: " + i + ", " + j); + } + } + } + + private static void populate(TileEntity tile, float fraction) throws IllegalAccessException { + if (tile instanceof PoweredEntity) { + PoweredEntity powered = (PoweredEntity) tile; + for (cyano.poweradvantage.api.ConduitType type : powered.getTypes()) { + powered.setEnergy(powered.getEnergyCapacity(type) * fraction, type); + } + } + for (Class type = tile.getClass(); type != null; type = type.getSuperclass()) { + for (Field field : type.getDeclaredFields()) { + if (java.lang.reflect.Modifier.isStatic(field.getModifiers()) || !FluidTank.class.isAssignableFrom(field.getType())) continue; + field.setAccessible(true); + FluidTank tank = (FluidTank) field.get(tile); + if (tank != null) tank.setFluid(fraction == 0 ? null + : new FluidStack(net.minecraftforge.fluids.FluidRegistry.WATER, (int) (tank.getCapacity() * fraction))); + } + } + } + + private static BufferedImage render(Minecraft minecraft, RecordingScreen screen, + int[] viewport, boolean backgroundOnly) throws Exception { + return render(minecraft, screen, viewport, backgroundOnly, false); + } + + private static BufferedImage render(Minecraft minecraft, RecordingScreen screen, + int[] viewport, boolean backgroundOnly, boolean hover) throws Exception { + int width = viewport[0] * viewport[2], height = viewport[1] * viewport[2]; + Framebuffer target = new Framebuffer(width, height, true); + target.bindFramebuffer(true); + GlStateManager.matrixMode(5889); + GlStateManager.pushMatrix(); GlStateManager.loadIdentity(); + GlStateManager.ortho(0, viewport[0], viewport[1], 0, 1000, 3000); + GlStateManager.matrixMode(5888); + GlStateManager.pushMatrix(); GlStateManager.loadIdentity(); + GlStateManager.translate(0, 0, -2000); + GlStateManager.enableAlpha(); + GlStateManager.enableDepth(); + GlStateManager.depthFunc(515); + GlStateManager.disableBlend(); + GlStateManager.color(1, 1, 1, 1); + try { + GlStateManager.clearColor(0.12F, 0.18F, 0.23F, 1); + GlStateManager.clear(16384 | 256); + if (backgroundOnly) { + LabelMode original = screen.labels; + screen.labels = LabelMode.HIDDEN; + screen.drawScreen(-100, -100, 0F); + screen.labels = original; + } else { + Slot hotbar = screen.inventorySlots.inventorySlots.get(screen.inventorySlots.inventorySlots.size() - 9); + screen.drawScreen(screen.hoverTitle ? screen.width / 2 : hover ? screen.getLeft() + hotbar.xPos + 1 : -100, + screen.hoverTitle ? screen.getTop() + 4 : hover ? screen.getTop() + hotbar.yPos + 1 : -100, 0F); + } + ByteBuffer pixels = ByteBuffer.allocateDirect(width * height * 4); + GL11.glReadPixels(0, 0, width, height, 6408, 5121, pixels); + BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + for (int y = 0; y < height; y++) for (int x = 0; x < width; x++) { + int index = ((height - y - 1) * width + x) * 4; + image.setRGB(x, y, (pixels.get(index) & 255) << 16 + | (pixels.get(index + 1) & 255) << 8 | pixels.get(index + 2) & 255); + } + return image; + } finally { + GlStateManager.popMatrix(); + GlStateManager.matrixMode(5889); GlStateManager.popMatrix(); + GlStateManager.matrixMode(5888); + target.deleteFramebuffer(); + minecraft.getFramebuffer().bindFramebuffer(true); + GlStateManager.enableDepth(); + GlStateManager.color(1, 1, 1, 1); + } + } + + private static String labelError(BufferedImage actual, BufferedImage expected, BufferedImage background, + SimpleMachineGUI.GUIContainer screen, int scale) { + int labelIndex = 0; + for (int[] rect : new int[][]{{-screen.getLeft() + 4, 1, screen.width - screen.getLeft() - 4, 12}, {8, 140, 168, 150}}) { + int ink = 0, missing = 0; + for (int y = rect[1]; y < rect[3]; y++) for (int x = rect[0]; x < rect[2]; x++) { + int px = (screen.getLeft() + x) * scale, py = (screen.getTop() + y) * scale; + if (expected.getRGB(px, py) != background.getRGB(px, py)) { + ink++; + if (actual.getRGB(px, py) != expected.getRGB(px, py)) missing++; + } + } + if (ink < 20 || missing != 0) return (labelIndex == 0 ? "Machine name" : "Inventory label") + + " missing, incorrect or clipped: expectedInk=" + ink + ", mismatched=" + missing; + labelIndex++; + } + return null; + } + + private static void verifyInventoryRows(SimpleMachineGUI.Container menu) { + int first = menu.inventorySlots.size() - 36; + for (int i = 0; i < 36; i++) { + Slot slot = menu.inventorySlots.get(first + i); + int expectedY = i < 27 ? 158 + (i / 9) * 18 : 216; + require(slot.yPos == expectedY && slot.xPos == 8 + (i % 9) * 18, "Inventory slots do not align with their labelled panel"); + } + } + + private static void verifyNoTextOverArtwork(BufferedImage actual, BufferedImage hidden, RecordingScreen screen) { + for (int y = 0; y < screen.getYSize(); y++) { + if (y < 12 || (y >= 140 && y < 150)) continue; + for (int x = 0; x < screen.getXSize(); x++) require(actual.getRGB(screen.getLeft() + x, screen.getTop() + y) + == hidden.getRGB(screen.getLeft() + x, screen.getTop() + y), "Text is drawn over artwork or inventory slots"); + } + } + + private static int verifyMissingLabelControls(Minecraft minecraft, RecordingScreen screen) throws Exception { + screen.labels = LabelMode.EXPECTED; + BufferedImage expected = render(minecraft, screen, VIEWPORTS[0], false); + BufferedImage hidden = render(minecraft, screen, VIEWPORTS[0], true); + for (LabelMode mode : new LabelMode[]{LabelMode.TITLE_ONLY, LabelMode.INVENTORY_ONLY, LabelMode.WRONG_TEXT, LabelMode.OVER_GAUGE}) { + screen.labels = mode; + BufferedImage broken = render(minecraft, screen, VIEWPORTS[0], false); + ImageIO.write(broken, "png", new File("machine-gui-layouts/negative-control-" + mode.name().toLowerCase(java.util.Locale.ROOT) + ".png")); + require(labelError(broken, expected, hidden, screen, 1) != null, "Label detector accepted negative control " + mode); + } + screen.labels = LabelMode.PRODUCTION; + return 4; + } + + private static void verifyLongName(Minecraft minecraft, SimpleMachineGUI definition, SimpleMachineGUI.Container menu, + InventoryPlayer inventory) throws Exception { + String name = String.join(" / ", java.util.Collections.nCopies(16, "John's oil reserve")); + cyano.poweradvantage.api.simple.TileEntitySimpleFluidMachine named = + (cyano.poweradvantage.api.simple.TileEntitySimpleFluidMachine) inventoryOf(menu); + String previousName = named.hasCustomName() ? named.getName() : null; + named.setCustomInventoryName(name); + try { + RecordingScreen screen = new RecordingScreen(definition, menu, inventory, + new net.minecraft.util.text.TextComponentString(name), name, "Inventory"); + screen.setWorldAndResolution(minecraft, 320, 240); + BufferedImage actual = render(minecraft, screen, VIEWPORTS[0], false); + BufferedImage hidden = render(minecraft, screen, VIEWPORTS[0], true); + screen.labels = LabelMode.EXPECTED; + BufferedImage expected = render(minecraft, screen, VIEWPORTS[0], false); + require(labelError(actual, expected, hidden, screen, 1) == null, "Long custom name is clipped instead of being fitted"); + ImageIO.write(actual, "png", new File("machine-gui-layouts/metal-tank-long-name.png")); + screen.labels = LabelMode.PRODUCTION; + screen.hoverTitle = true; + BufferedImage hover = render(minecraft, screen, VIEWPORTS[0], false); + require(name.equals(screen.titleTooltip), "Full custom name is not available from its caption tooltip"); + ImageIO.write(hover, "png", new File("machine-gui-layouts/metal-tank-long-name-tooltip.png")); + } finally { + named.setCustomInventoryName(previousName); + } + } + + private static void require(boolean value, String message) { + if (!value) throw new IllegalStateException(message); + } + + private static void verifyHelperLimits(RecordingScreen screen) throws Exception { + for (String name : new String[]{"com.mcmoddev.electricadvantage.gui.GUIHelper", + "com.mcmoddev.steamadvantage.gui.GUIHelper"}) { + if (!net.minecraftforge.fml.common.Loader.isModLoaded(name.contains("electricadvantage") ? "electricadvantage" : "steamadvantage")) continue; + String[] methods = name.contains("electricadvantage") ? new String[]{"drawProgressBar"} + : new String[]{"drawDownArrowProgress", "drawFlameProgress"}; + for (String method : methods) { + Method draw = Class.forName(name).getMethod(method, int.class, int.class, float.class, SimpleMachineGUI.GUIContainer.class); + for (float value : new float[]{-1F, 0F, 0.5F, 1F, 15F, Float.NaN, Float.NEGATIVE_INFINITY, Float.POSITIVE_INFINITY}) { + screen.draws.clear(); + draw.invoke(null, screen.getLeft() + 44, screen.getTop() + 46, value, screen); + screen.verifyDrawBounds(); + } + } + } + } + + private static final class GuiWorld extends WorldClient { + private net.minecraft.block.state.IBlockState state; + private GuiWorld() { + super(null, null, 0, net.minecraft.world.EnumDifficulty.NORMAL, new net.minecraft.profiler.Profiler()); + throw new AssertionError("Allocate the read-only GUI fixture without a world constructor"); + } + @Override public net.minecraft.block.state.IBlockState getBlockState(BlockPos pos) { return state; } + } + + private static final class RecordingScreen extends SimpleMachineGUI.GUIContainer { + private final List draws = new ArrayList<>(); + private int tooltipCalls; + private int itemTooltipCalls; + private LabelMode labels = LabelMode.PRODUCTION; + private final String expectedTitle, expectedInventory; + private boolean hoverTitle; + private String titleTooltip; + RecordingScreen(SimpleMachineGUI definition, SimpleMachineGUI.Container menu, InventoryPlayer inventory, + net.minecraft.util.text.ITextComponent title, String expectedTitle, String expectedInventory) throws Exception { + definition.super(inventory, inventoryOf(menu)); + this.inventorySlots = menu; + this.expectedTitle = expectedTitle; + this.expectedInventory = expectedInventory; + } + @Override protected void drawGuiContainerForegroundLayer(int x, int y) { + if (labels == LabelMode.PRODUCTION) { super.drawGuiContainerForegroundLayer(x, y); return; } + if (labels == LabelMode.HIDDEN) return; + if (labels != LabelMode.INVENTORY_ONLY) { + String text = labels == LabelMode.WRONG_TEXT ? "Untranslated machine.name" : expectedTitle; + if (fontRenderer.getStringWidth(text) > width - 8) { + while (fontRenderer.getStringWidth(text + "...") > width - 8) text = text.substring(0, text.offsetByCodePoints(text.length(), -1)); + text += "..."; + } + int textX = (176 - fontRenderer.getStringWidth(text)) / 2; + fontRenderer.drawStringWithShadow(text, textX, labels == LabelMode.OVER_GAUGE ? 20 : 1, 0xFFFFFF); + } + if (labels != LabelMode.TITLE_ONLY) fontRenderer.drawStringWithShadow(expectedInventory, 8, 140, 0xFFFFFF); + } + @Override public void drawScreen(int x, int y, float ticks) { + draws.clear(); tooltipCalls = 0; itemTooltipCalls = 0; titleTooltip = null; + super.drawScreen(x, y, ticks); + } + @Override protected void renderHoveredToolTip(int x, int y) { + tooltipCalls++; + super.renderHoveredToolTip(x, y); + } + @Override public void renderToolTip(net.minecraft.item.ItemStack item, int x, int y) { + itemTooltipCalls++; + super.renderToolTip(item, x, y); + } + @Override public void drawHoveringText(String text, int x, int y) { + titleTooltip = text; + super.drawHoveringText(text, x, y); + } + Slot hovered() throws Exception { + Field field = net.minecraft.client.gui.inventory.GuiContainer.class.getDeclaredField("hoveredSlot"); + field.setAccessible(true); + return (Slot) field.get(this); + } + @Override public void drawTexturedModalRect(int x, int y, int u, int v, int width, int height) { + draws.add(new int[]{x - getLeft(), y - getTop(), u, v, width, height}); + super.drawTexturedModalRect(x, y, u, v, width, height); + } + void verifyDrawBounds() { + for (int[] draw : draws) { + require(draw[4] >= 0 && draw[5] >= 0 && draw[0] >= 0 && draw[1] >= 0 + && draw[0] + draw[4] <= getXSize() && draw[1] + draw[5] <= getYSize() + && draw[2] >= 0 && draw[3] >= 0 && draw[2] + draw[4] <= 256 && draw[3] + draw[5] <= 256, + "Decoration outside screen or texture: " + java.util.Arrays.toString(draw)); + if (draw[5] > 0) require(draw[1] >= 12 && (draw[1] + draw[5] <= 140 || draw[1] >= 150), + "Artwork intrudes into a required label row: " + java.util.Arrays.toString(draw)); + } + } + } + + private enum LabelMode { PRODUCTION, HIDDEN, EXPECTED, TITLE_ONLY, INVENTORY_ONLY, WRONG_TEXT, OVER_GAUGE } +} diff --git a/test-rig/client-resources/resources/gui-label-expectations.json b/test-rig/client-resources/resources/gui-label-expectations.json new file mode 100644 index 0000000..2f82b82 --- /dev/null +++ b/test-rig/client-resources/resources/gui-label-expectations.json @@ -0,0 +1,46 @@ +{ + "locale": "en_us", + "inventory": "Inventory", + "layout": { "height": 240, "machineTop": 12, "machineHeight": 128, "inventoryTop": 150, "titleY": 1, "inventoryLabelY": 140, "inventoryRows": [158, 176, 194, 216] }, + "machines": { + "poweradvantage:fluid_drain": "Drain", + "poweradvantage:fluid_discharge": "Discharge Grate", + "poweradvantage:fluid_storage_tank": "Portable Fluid Tank", + "poweradvantage:fluid_metal_tank": "Metal Fluid Tank", + "poweradvantage:still": "Distillation Furnace", + "poweradvantage:item_conveyor": "Conveyor", + "poweradvantage:item_filter_block": "Block Filter Conveyor", + "poweradvantage:item_filter_food": "Food Filter Conveyor", + "poweradvantage:item_filter_fuel": "Fuel Filter Conveyor", + "poweradvantage:item_filter_inventory": "Filter Conveyor", + "poweradvantage:item_filter_ore": "Ore Filter Conveyor", + "poweradvantage:item_filter_plant": "Plantable Filter Conveyor", + "poweradvantage:item_filter_smelt": "Furnace Filter Conveyor", + "poweradvantage:item_filter_overflow": "Overflow Filter Conveyor", + "steamadvantage:steam_boiler_coal": "Coal-Fired Steam Boiler", + "steamadvantage:steam_boiler_electric": "Electric Steam Boiler", + "steamadvantage:steam_boiler_geothermal": "Geothermal Steam Boiler", + "steamadvantage:steam_boiler_oil": "Oil-Burning Steam Boiler", + "steamadvantage:steam_tank": "Steam Boiler Tank", + "steamadvantage:steam_furnace": "Steam-Powered Furnace", + "steamadvantage:steam_crusher": "Steam-Powered Rock Crusher", + "steamadvantage:steam_drill": "Steam-Powered Drill", + "steamadvantage:steam_still": "Steam-Powered Still", + "steamadvantage:steam_pump": "Steam-Powered Pump", + "electricadvantage:electric_battery_array": "Battery Array", + "electricadvantage:electric_crusher": "Electric Rock Crusher", + "electricadvantage:electric_drill": "Electric Mining Drill", + "electricadvantage:electric_fabricator": "Automated Assembler", + "electricadvantage:electric_furnace": "Electric Arc Furnace", + "electricadvantage:electric_oven": "Electric Oven", + "electricadvantage:electric_pump": "Electric Pump", + "electricadvantage:electric_still": "Electric Distiller", + "electricadvantage:growth_chamber": "Growth Chamber", + "electricadvantage:growth_chamber_controller": "Growth Chamber Control Unit", + "electricadvantage:hydroelectric_generator": "Hydroelectric Generator", + "electricadvantage:laser_turret": "Laser Turret", + "electricadvantage:photovoltaic_generator": "Photovoltaic Generator", + "electricadvantage:plastic_refinery": "Petro-Plastic Refinery", + "electricadvantage:steam_powered_generator": "Steam-Powered Electric Generator" + } +} diff --git a/test-rig/client-resources/resources/gui-native-mappings.json b/test-rig/client-resources/resources/gui-native-mappings.json new file mode 100644 index 0000000..39e245d --- /dev/null +++ b/test-rig/client-resources/resources/gui-native-mappings.json @@ -0,0 +1,122 @@ +[ + [ + "import net.minecraftforge.registries.ForgeRegistries;", + "import net.minecraftforge.fml.common.registry.ForgeRegistries;" + ], + [ + "import cyano.poweradvantage.api.fluid.FluidStack;", + "import net.minecraftforge.fluids.FluidStack;" + ], + [ + "import cyano.poweradvantage.api.fluid.FluidTank;", + "import net.minecraftforge.fluids.FluidTank;" + ], + [ + "import zone.moddev.mc.poweradvantage.registry.MachineGUIRegistry;", + "import com.mcmoddev.poweradvantage.registry.MachineGUIRegistry;" + ], + [ + "net.minecraft.resources.IResource", + "net.minecraft.client.resources.IResource" + ], + [ + "block.createTileEntity(block.getDefaultState(), null)", + "block.createTileEntity(null, block.getDefaultState())" + ], + [ + "net.minecraft.state.IProperty", + "net.minecraft.block.properties.IProperty" + ], + [ + "block.getDefaultState().getProperties()", + "block.getDefaultState().getPropertyKeys()" + ], + [ + "net.minecraft.state.BooleanProperty", + "net.minecraft.block.properties.PropertyBool" + ], + [ + "block.getDefaultState().with(", + "block.getDefaultState().withProperty(" + ], + [ + ".getString()", + ".getUnformattedText()" + ], + [ + "screen.render(", + "screen.drawScreen(" + ], + [ + "public void render(int x, int y, float ticks)", + "public void drawScreen(int x, int y, float ticks)" + ], + [ + "super.render(x, y, ticks)", + "super.drawScreen(x, y, ticks)" + ], + [ + "GlStateManager.translatef(", + "GlStateManager.translate(" + ], + [ + "GlStateManager.enableAlphaTest()", + "GlStateManager.enableAlpha()" + ], + [ + "GlStateManager.enableDepthTest()", + "GlStateManager.enableDepth()" + ], + [ + "GlStateManager.color4f(", + "GlStateManager.color(" + ], + [ + "net.minecraft.init.Fluids.WATER", + "net.minecraftforge.fluids.FluidRegistry.WATER" + ], + [ + "zone.moddev.mc.poweradvantage.init.Blocks", + "com.mcmoddev.poweradvantage.init.Blocks" + ], + [ + "zone.moddev.mc.electricadvantage.gui.GUIHelper", + "com.mcmoddev.electricadvantage.gui.GUIHelper" + ], + [ + "zone.moddev.mc.steamadvantage.gui.GUIHelper", + "com.mcmoddev.steamadvantage.gui.GUIHelper" + ], + [ + "net.minecraftforge.fml.ModList.get().isLoaded(", + "net.minecraftforge.fml.common.Loader.isModLoaded(" + ], + [ + "net.minecraft.world.dimension.DimensionType.OVERWORLD", + "0" + ], + [ + "net.minecraft.util.INameable", + "net.minecraft.world.IWorldNameable" + ], + [ + "new TextComponentTranslation(block.getTranslationKey())", + "new TextComponentTranslation(block.getTranslationKey() + \".name\")" + ], + [ + "net.minecraft.util.text.ITextComponent previousName = named.getCustomName();", + "String previousName = named.hasCustomName() ? named.getName() : null;" + ], + [ + "named.setCustomInventoryName(previousName == null ? null : previousName.getUnformattedText());", + "named.setCustomInventoryName(previousName);" + ], + [ + "Slot hovered() { return hoveredSlot; }", + "Slot hovered() throws Exception {\n Field field = net.minecraft.client.gui.inventory.GuiContainer.class.getDeclaredField(\"hoveredSlot\");\n field.setAccessible(true);\n return (Slot) field.get(this);\n }" + ], + [ + "Match the uncorrected 1.15 menu provider for the retained red run.", + "Use the legacy inventory name when no caption resolver exists." + ] +] diff --git a/test-rig/client-resources/resources/mcmod.info b/test-rig/client-resources/resources/mcmod.info new file mode 100644 index 0000000..234e048 --- /dev/null +++ b/test-rig/client-resources/resources/mcmod.info @@ -0,0 +1,11 @@ +[ + { + "modid": "advantageclientresources", + "name": "Advantage GUI Capture", + "description": "Disposable native GUI test adapter; not part of Power Advantage.", + "version": "1.0.0.112021", + "mcversion": "1.12.2", + "authorList": ["Advantage Works"], + "dependencies": ["poweradvantage", "steamadvantage", "electricadvantage"] + } +] diff --git a/test-rig/client-resources/resources/pack.mcmeta b/test-rig/client-resources/resources/pack.mcmeta new file mode 100644 index 0000000..1801641 --- /dev/null +++ b/test-rig/client-resources/resources/pack.mcmeta @@ -0,0 +1 @@ +{"pack":{"pack_format":3,"description":"Advantage native GUI capture fixtures"}} diff --git a/test-rig/client-resources/run.ps1 b/test-rig/client-resources/run.ps1 new file mode 100644 index 0000000..4c4ec73 --- /dev/null +++ b/test-rig/client-resources/run.ps1 @@ -0,0 +1,190 @@ +param( + [Parameter(Mandatory = $true)][string]$JavaHome, + [Parameter(Mandatory = $true)][string]$PowerJar, + [Parameter(Mandatory = $true)][string]$SteamJar, + [Parameter(Mandatory = $true)][string]$ElectricJar, + [string]$ProbeJar, + [string]$LaunchProfile = 'runClient.launch', + [switch]$PreflightOnly, + [ValidateRange(30, 1800)][int]$TimeoutSeconds = 300 +) +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.IO.Compression.FileSystem +$project = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path +$runtime = Join-Path $project ('run-client-resource-runs/' + [DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss') + '-' + [guid]::NewGuid().ToString('N').Substring(0, 8)) +New-Item -ItemType Directory -Path $runtime | Out-Null +$phase = 'launch-preflight' +$inputReport = [ordered]@{ minecraft = '1.12.2'; forge = '14.23.5.2859'; mappings = 'stable_39-1.12'; scope = 'MACHINE_GUI_ONLY'; qualification = 'UNQUALIFIED' } +function Get-Record([string]$Path) { + $file = Get-Item -LiteralPath $Path + [ordered]@{ path = $file.FullName; bytes = $file.Length; sha256 = (Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash } +} +function Read-ModMetadata([string]$Path) { + $jar = [IO.Compression.ZipFile]::OpenRead($Path) + try { + $entry = $jar.GetEntry('mcmod.info') + if (-not $entry) { return @() } + $reader = [IO.StreamReader]::new($entry.Open()) + try { $json = $reader.ReadToEnd() } finally { $reader.Dispose() } + if ($json -match '\$\{[^}]+\}') { throw "Unexpanded mod metadata: $Path" } + $metadata = $json | ConvertFrom-Json + if ($metadata.modList) { return @($metadata.modList) } + return @($metadata) + } finally { $jar.Dispose() } +} +function Quote-Argument([string]$Value) { + '"' + [regex]::Replace([regex]::Replace($Value, '(\\*)"', '$1$1\"'), '(\\+)$', '$1$1') + '"' +} +function Assert-GuiReport($Report, [string]$Images) { + if ($Report.status -ne 'PASS' -or $Report.scope -ne 'MACHINE_GUI_ONLY' -or + $Report.minecraft -ne '1.12.2' -or $Report.forge -ne '14.23.5.2859' -or + $Report.machine_gui_layout_status -ne 'PASS' -or $Report.machine_gui_text_status -ne 'PASS' -or + $Report.machine_gui_text_spec -ne 2 -or $Report.machine_gui_layout_count -ne 39 -or + $Report.machine_gui_text_checks -ne 351 -or $Report.machine_gui_negative_controls_rejected -ne 355) { + throw 'Native GUI report is failed, incomplete, or from a different contract/engine.' + } + $expected = (Get-Content -LiteralPath (Join-Path $PSScriptRoot 'resources/gui-label-expectations.json') -Raw | ConvertFrom-Json).machines + $rows = @($Report.machine_gui_layouts) + $ids = @($rows | ForEach-Object { $_.block }) + if ($rows.Count -ne 39 -or @($ids | Sort-Object -Unique).Count -ne 39 -or + @($Report.advantage_mods | Sort-Object -Unique).Count -ne 3) { throw 'Missing or duplicate GUI rows/mods.' } + foreach ($id in $expected.PSObject.Properties.Name) { + $row = @($rows | Where-Object { $_.block -eq $id }) + if ($row.Count -ne 1 -or $row[0].status -ne 'PASS' -or $row[0].renders -ne 9 -or + $row[0].required_text -ne 'PASS' -or $row[0].tooltip_and_inventory_hitbox -ne 'PASS' -or + $row[0].blank_label_controls_rejected -ne 9 -or + $row[0].expected_title -cne $expected.$id -or $row[0].menu_title -cne $expected.$id) { + throw "Incomplete or incorrect GUI row: $id" + } + $stem = $id.Replace(':', '-') + foreach ($viewport in @(@(320,240,1), @(426,240,2), @(640,360,1))) { + foreach ($fill in 0..2) { + $name = "$stem-$($viewport[0])x$($viewport[1])-scale$($viewport[2])-fill$fill.png" + if (-not (Test-Path -LiteralPath (Join-Path $Images $name))) { throw "Missing native capture: $name" } + } + } + if (-not (Test-Path -LiteralPath (Join-Path $Images "$stem-tooltip.png"))) { throw "Missing item-tooltip capture: $id" } + } + foreach ($name in 'negative-control-title_only.png','negative-control-inventory_only.png','negative-control-wrong_text.png', + 'negative-control-over_gauge.png','metal-tank-long-name.png','metal-tank-long-name-tooltip.png') { + if (-not (Test-Path -LiteralPath (Join-Path $Images $name))) { throw "Missing control/custom-name capture: $name" } + } + if (@(Get-ChildItem -LiteralPath $Images -Filter '*.png' -File).Count -ne 396) { throw 'Expected exactly 396 native GUI images.' } +} +try { + $java = Join-Path $JavaHome 'bin/java.exe' + if (-not (Test-Path -LiteralPath $java) -or (Get-Content -LiteralPath (Join-Path $JavaHome 'release') -Raw) -notmatch '(?m)^JAVA_VERSION="1\.8\.') { + throw 'Supply a Java 8 runtime for the native 1.12 client.' + } + if (-not $ProbeJar) { $ProbeJar = Join-Path $project 'build/client-resource-probe/advantageclientresources-deobf.jar' } + $launchPath = if ([IO.Path]::IsPathRooted($LaunchProfile)) { $LaunchProfile } else { Join-Path $project $LaunchProfile } + [xml]$launch = Get-Content -LiteralPath $launchPath -Raw + Copy-Item -LiteralPath $launchPath -Destination (Join-Path $runtime 'launch.xml') + $attributes = @{} + foreach ($attribute in $launch.launchConfiguration.stringAttribute) { $attributes[$attribute.key] = $attribute.value } + $environment = @{} + foreach ($map in $launch.launchConfiguration.mapAttribute) { + if ($map.key -eq 'org.eclipse.debug.core.environmentVariables') { + foreach ($entry in $map.mapEntry) { $environment[$entry.key] = $entry.value } + } + } + if ($environment.MC_VERSION -ne '1.12.2' -or $environment.FORGE_VERSION -ne '14.23.5.2859' -or + $environment.MCP_MAPPINGS -ne 'stable_39-1.12' -or + $attributes['org.eclipse.jdt.launching.MAIN_TYPE'] -ne 'net.minecraftforge.launcher.Main' -or + $attributes['org.eclipse.jdt.launching.PROGRAM_ARGUMENTS'] -notmatch '--main net\.minecraftforge\.legacydev\.MainClient\b' -or + $attributes['org.eclipse.jdt.launching.PROGRAM_ARGUMENTS'] -match '--(?:server|port|gameDir|world)\b|1\.(?:13\.2|14\.4|15\.2|16\.5)' -or + $attributes['org.eclipse.jdt.launching.VM_ARGUMENTS'] -match '(?i)advantageworks\.|-javaagent:|--(?:server|port|gameDir|world)\b') { + throw 'Regenerate a native 1.12.2 / Forge 14.23.5.2859 stable-39 client launch without injected probe arguments.' + } + $inputReport.java = Get-Record $java + $inputReport.launch = Get-Record $launchPath + $inputReport.runner = Get-Record $PSCommandPath + $inputReport.expectations = Get-Record (Join-Path $PSScriptRoot 'resources/gui-label-expectations.json') + New-Item -ItemType Directory -Path (Join-Path $runtime 'mods') | Out-Null + $candidates = @{ poweradvantage = $PowerJar; steamadvantage = $SteamJar; electricadvantage = $ElectricJar; advantageclientresources = $ProbeJar } + $candidateRecords = [ordered]@{} + $versions = @{} + foreach ($id in $candidates.Keys) { + $source = (Resolve-Path -LiteralPath $candidates[$id]).Path + $before = Get-Record $source + $metadata = @(Read-ModMetadata $source | Where-Object { $_.modid -eq $id }) + if ($metadata.Count -ne 1 -or $metadata[0].mcversion -ne '1.12.2' -or -not $metadata[0].version) { + throw "Missing/incorrect native metadata: $id" + } + $target = Join-Path $runtime "mods/$id.jar" + Copy-Item -LiteralPath $source -Destination $target + $record = Get-Record $target + if ($record.sha256 -ne $before.sha256) { throw "Candidate changed while snapshotting: $source" } + $record.origin_path = $source + $record.modid = $id + $record.version = [string]$metadata[0].version + $candidateRecords[$id] = $record + $versions[$id] = $record.version + } + $inputReport.candidates = $candidateRecords + $classpath = @() + $classpathRecords = @() + $classpathNode = @($launch.launchConfiguration.listAttribute | Where-Object { $_.key -eq 'org.eclipse.jdt.launching.CLASSPATH' }) + foreach ($entry in $classpathNode.listEntry) { + [xml]$cp = $entry.value + $archive = $cp.runtimeClasspathEntry.externalArchive + if (-not $archive) { continue } + if ($archive -match '(?:forge|client-extra)-1\.(?:10\.2|13\.2|14\.4|15\.2|16\.5)|[\\/]PA11(?:0|3|4|5|6)[\\/]') { throw "Wrong-engine runtime archive: $archive" } + if ([IO.Path]::GetExtension($archive) -eq '.jar') { + $mods = @(Read-ModMetadata $archive) + if (@($mods | Where-Object { $candidates.ContainsKey([string]$_.modid) }).Count -gt 0) { continue } + } + $classpath += $archive + $classpathRecords += Get-Record $archive + } + if (-not ($classpath -match 'forge-1\.12\.2-14\.23\.5\.2859-stable-39-1\.12\.jar$') -or + -not ($classpath -match 'legacydev-.*\.jar$')) { throw 'Missing native mapped Forge/legacydev bootstrap.' } + $inputReport.archives = $classpathRecords + $arguments = @([regex]::Matches($attributes['org.eclipse.jdt.launching.PROGRAM_ARGUMENTS'], '"[^"]*"|\S+') | ForEach-Object { $_.Value.Trim('"') }) + $vmArguments = @([regex]::Matches($attributes['org.eclipse.jdt.launching.VM_ARGUMENTS'], '"[^"]*"|\S+') | ForEach-Object { $_.Value.Trim('"') }) + $vmArguments += '-Dadvantageworks.clientResources=true','-Dadvantageworks.requireExtensions=true','-Dadvantageworks.machineGuiLayouts=true','-Xmx2G' + foreach ($id in 'poweradvantage','steamadvantage','electricadvantage') { $vmArguments += "-Dadvantageworks.expectedVersion.$id=$($versions[$id])" } + $inputReport.environment = $environment + $inputReport.program_arguments = $arguments + $inputReport.vm_arguments = $vmArguments + if ($PreflightOnly) { $inputReport.qualification = 'PREFLIGHT_ONLY_NOT_CLIENT_EVIDENCE' } + $inputReport | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath (Join-Path $runtime 'client-input.json') -Encoding utf8 + if ($PreflightOnly) { Write-Output "Native GUI preflight only; no client started. Evidence: $runtime"; return } + $phase = 'native-client' + $previous = @{} + try { + foreach ($key in $environment.Keys) { + $previous[$key] = [Environment]::GetEnvironmentVariable($key, 'Process') + [Environment]::SetEnvironmentVariable($key, $environment[$key], 'Process') + } + $command = @($vmArguments) + @('-cp', ($classpath -join [IO.Path]::PathSeparator), $attributes['org.eclipse.jdt.launching.MAIN_TYPE']) + $arguments + $process = Start-Process -FilePath $java -ArgumentList (($command | ForEach-Object { Quote-Argument $_ }) -join ' ') -WorkingDirectory $runtime -WindowStyle Hidden -PassThru -RedirectStandardOutput (Join-Path $runtime 'launcher.stdout.log') -RedirectStandardError (Join-Path $runtime 'launcher.stderr.log') + if (-not $process.WaitForExit($TimeoutSeconds * 1000)) { + & taskkill.exe /PID $process.Id /T /F | Out-Null + throw "Native client timed out after $TimeoutSeconds seconds; failed evidence retained." + } + $process.WaitForExit() + $process.Refresh() + if ($process.ExitCode -ne 0) { throw "Native client exited $($process.ExitCode); evidence retained." } + } finally { + foreach ($key in $previous.Keys) { [Environment]::SetEnvironmentVariable($key, $previous[$key], 'Process') } + } + $phase = 'native-report' + $report = Get-Content -LiteralPath (Join-Path $runtime 'client-resources.json') -Raw | ConvertFrom-Json + Assert-GuiReport $report (Join-Path $runtime 'machine-gui-layouts') + foreach ($id in 'poweradvantage','steamadvantage','electricadvantage') { + if ($report.mod_versions.$id -cne $versions[$id]) { throw "Native loaded version mismatch: $id" } + } + foreach ($record in $candidateRecords.Values) { + if ((Get-FileHash -LiteralPath $record.path -Algorithm SHA256).Hash -ne $record.sha256) { throw "Frozen runtime jar changed: $($record.path)" } + } + $inputReport.qualification = 'NATIVE_GUI_PASS' + $inputReport.result = Get-Record (Join-Path $runtime 'client-resources.json') + $inputReport.images = @(Get-ChildItem -LiteralPath (Join-Path $runtime 'machine-gui-layouts') -Filter '*.png' -File | Sort-Object Name | ForEach-Object { Get-Record $_.FullName }) + $inputReport | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath (Join-Path $runtime 'client-input.json') -Encoding utf8 + Write-Output "Native GUI PASS: 39 screens, 351 caption cases, 355 rejected controls. Evidence: $runtime" +} catch { + $inputReport | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath (Join-Path $runtime 'client-input.json') -Encoding utf8 + [ordered]@{ status = 'FAIL'; phase = $phase; error = $_.Exception.Message } | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $runtime 'runner-failure.json') -Encoding utf8 + throw +} diff --git a/test-rig/client-resources/test-gui-contract.ps1 b/test-rig/client-resources/test-gui-contract.ps1 new file mode 100644 index 0000000..33ca1b4 --- /dev/null +++ b/test-rig/client-resources/test-gui-contract.ps1 @@ -0,0 +1,41 @@ +param([string]$ReferenceRoot = (Join-Path $PSScriptRoot '../../../../PA113/PowerAdvantage/test-rig/client-resources')) +$ErrorActionPreference = 'Stop' +$checks = 0 +function Assert-Contract([bool]$Condition, [string]$Message) { + if (-not $Condition) { throw $Message } + $script:checks++ +} +$relative = 'java/zone/moddev/mc/advantageworks/client/MachineGuiLayoutProbe.java' +$reference = Join-Path (Resolve-Path -LiteralPath $ReferenceRoot).Path $relative +Assert-Contract ((Get-FileHash -LiteralPath $reference -Algorithm SHA256).Hash -eq '8D01DAF611BEDC9A4F378A15539FFF08D6DDDE62A53AD54FDE833DAD6601626C') 'Pinned native 1.13 GUI oracle changed.' +$expected = (Get-Content -LiteralPath $reference -Raw).Replace("`r`n", "`n") +$pairs = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'resources/gui-native-mappings.json') -Raw | ConvertFrom-Json +foreach ($pair in $pairs) { + Assert-Contract ($expected.Contains($pair[0])) "Mapping source is absent: $($pair[0])" + $expected = $expected.Replace($pair[0], $pair[1]) +} +$actual = (Get-Content -LiteralPath (Join-Path $PSScriptRoot $relative) -Raw).Replace("`r`n", "`n") +Assert-Contract ($expected.TrimEnd() -ceq $actual.TrimEnd()) 'GUI source changed beyond the declared native API mappings; assertions must not be weakened.' +$labels = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'resources/gui-label-expectations.json') -Raw | ConvertFrom-Json +$original = Get-Content -LiteralPath (Join-Path $ReferenceRoot 'resources/gui-label-expectations.json') -Raw | ConvertFrom-Json +Assert-Contract (($labels | ConvertTo-Json -Depth 6 -Compress) -ceq ($original | ConvertTo-Json -Depth 6 -Compress)) 'Reviewed literal captions or layout rows changed.' +Assert-Contract (@($labels.machines.PSObject.Properties).Count -eq 39 -and $labels.inventory -ceq 'Inventory') 'All 39 equipment names and Inventory are required.' +$driver = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'java/zone/moddev/mc/advantageworks/client/ClientResourceProbe.java') -Raw +foreach ($literal in 'Minecraft.getMinecraft()', 'MachineGuiLayoutProbe.verify(minecraft, result);', + 'minecraft.world == null && minecraft.player == null && !minecraft.isIntegratedServerRunning()', + 'get("machine_gui_layout_count").getAsInt() == 39', 'get("machine_gui_text_checks").getAsInt() == 351', + 'get("machine_gui_negative_controls_rejected").getAsInt() == 355') { + Assert-Contract ($driver.Contains($literal)) "Native client lost a required gate: $literal" +} +$runner = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'run.ps1') -Raw +foreach ($literal in '$Report.machine_gui_text_spec -ne 2', '$Report.machine_gui_text_checks -ne 351', + '$Report.machine_gui_negative_controls_rejected -ne 355', '$Report.machine_gui_layout_count -ne 39', + '$row[0].tooltip_and_inventory_hitbox -ne ''PASS''', '$row[0].blank_label_controls_rejected -ne 9', + '-Dadvantageworks.machineGuiLayouts=true', 'Candidate changed while snapshotting:', + 'PREFLIGHT_ONLY_NOT_CLIENT_EVIDENCE', 'runner-failure.json', '-WindowStyle Hidden') { + Assert-Contract ($runner.Contains($literal)) "Runner lost a required native acceptance gate: $literal" +} +$tokens = $null; $errors = $null +[System.Management.Automation.Language.Parser]::ParseFile((Join-Path $PSScriptRoot 'run.ps1'), [ref]$tokens, [ref]$errors) | Out-Null +Assert-Contract ($errors.Count -eq 0) 'Native GUI runner has PowerShell syntax errors.' +Write-Output "PASS: $checks native mapping/oracle/runner source checks. No build or native rendering performed." diff --git a/test-rig/gradle.properties b/test-rig/gradle.properties new file mode 100644 index 0000000..d272768 --- /dev/null +++ b/test-rig/gradle.properties @@ -0,0 +1,7 @@ +org.gradle.jvmargs=-Xmx2G +org.gradle.daemon=false +minecraft_version=1.12.2 +forge_version=14.23.5.2859 +mapping_channel=stable +mapping_version=39-1.12 +harness_version=1.1.0.112021 diff --git a/test-rig/migration/legacy_connected_oracle.py b/test-rig/migration/legacy_connected_oracle.py new file mode 100644 index 0000000..6868b3f --- /dev/null +++ b/test-rig/migration/legacy_connected_oracle.py @@ -0,0 +1,209 @@ +"""Historical operating oracles only; no source creation or historical runtime defaults.""" +import base64 +from collections import Counter +import gzip +import io +import json +from pathlib import Path +import struct +import nbtlib +from rig_transport import WORLD, copy_fixture, digest, fingerprint, stage, write_json +from native112_code import Server + + +def typed_nbt(value): + if isinstance(value, nbtlib.Compound): return {key: typed_nbt(tag) for key, tag in value.items()} + if isinstance(value, nbtlib.List): return ["List", value.subtype.tag_id, [typed_nbt(tag) for tag in value]] + plain = value.unpack() + if hasattr(plain, "tolist"): plain = plain.tolist() + return [type(value).__name__, plain] + + +def saved_tile_nbt(value): + result = typed_nbt(value) + for key in ("Tank", "InputTank", "TankIn", "TankOut", "FuelTank", "WaterTank"): + tank = result.get(key) + if (isinstance(tank, dict) and set(tank) == {"Amount", "FluidName"} + and tank["Amount"] == ["Int", 0] and tank["FluidName"][0] == "String" and tank["FluidName"][1]): + result[key] = {"Empty": ["Byte", 1]} + return result + + +def parse_nbt(entry): + return nbtlib.File.parse(io.BytesIO(gzip.decompress(base64.b64decode(entry["nbtBase64"])))).unpack() + + +def plain(value): + if isinstance(value, dict): return {k: plain(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): return [plain(v) for v in value] + if hasattr(value, "tolist"): return value.tolist() + if hasattr(value, "item"): return value.item() + return value + + +def read(runtime, label): + return json.loads((runtime / f"connected-{label}.json").read_text()) + + +def entries(document): + return {e["id"]: e for e in document["snapshot"]} + + +def data(entry): + return plain(parse_nbt(entry)) if "nbtBase64" in entry else {} + + +def stocks(document, station, kinds=None): + counts = Counter() + for e in document["snapshot"]: + if e["station"] == station and (kinds is None or e["kind"] in kinds): + for stack in data(e).get("Items", []): + counts[stack["id"]] += int(stack["Count"]) + return counts + + +def state(document): + failures, cases, water = [], {}, Counter() + for station, raw, product, total in (("C-CONVEYOR", "minecraft:dirt", "minecraft:dirt", 1728), + ("C-STEAM", "minecraft:iron_ore", "minecraft:iron_ingot", 192), + ("C-ELECTRIC", "minecraft:iron_ore", "minecraft:iron_ingot", 64)): + count = stocks(document, station) + accounted = count[raw] if raw == product else count[raw] + count[product] + if accounted != total: + failures.append(f"{station}: expected {total} {raw}/{product}, got {accounted}") + produced = stocks(document, station, {"output"})[product] if raw == product else count[product] + cases[station] = dict(produced=produced, remaining=total - produced, accounted=accounted) + count = stocks(document, "C-STEAM") + if count["minecraft:cobblestone"] + count["minecraft:gravel"] != 64: + failures.append("C-STEAM: crusher input/output lost or duplicated") + cases["C-CRUSHER"] = dict(produced=count["minecraft:gravel"], remaining=count["minecraft:cobblestone"]) + for station in ("C-SDRILL", "C-EDRILL"): + targets = [e for e in document["snapshot"] if e["station"] == station and e["kind"].startswith("target-")] + remaining = sum(e["actualName"] == e["name"] for e in targets) + mined = len(targets) - remaining + drops = stocks(document, station, {"drill", "rear"}) + output = sum(drops.values()) + if output != mined: + failures.append(f"{station}: mined {mined} blocks but retained {output} drops") + product = "minecraft:gravel" if station == "C-SDRILL" else "minecraft:cobblestone" + if set(drops) - {product}: + failures.append(station + ": unexpected drill product identity") + cases[station] = dict(produced=output, remaining=remaining, targets=len(targets)) + for e in document["snapshot"]: + if "nbtBase64" not in e or e["kind"].startswith("target-"): + continue + if e.get("witness") != e["id"]: + failures.append(e["id"] + ": source tile witness lost/replaced") + for value in data(e).get("Energy", []): + number = struct.unpack("!f", struct.pack("!I", int(value) & 0xffffffff))[0] + if not (0 <= number < 1000000): + failures.append(e["id"] + ": invalid energy") + tank = data(e).get("Tank", {}) + if int(tank.get("Amount", 0)) > 0: + if tank.get("FluidName") not in ("water", "minecraft:water"): + failures.append(e["id"] + ": unexpected fluid") + water[e["station"]] += int(tank["Amount"]) + charge = [s.get("tag", {}).get("energy", 0) for s in data(entries(document)["C-ELECTRIC-battery"]).get("Items", [])] + cases["battery"] = dict(charge=sum(charge)) + return dict(status="FAIL" if failures else "PASS", failures=failures, cases=cases, water=dict(water)) + + +def cold_state(document): + verdict = state(document) + if sum(verdict["water"].values()) != 90000: + verdict["failures"].append("Source must start with exactly 90,000 mB water") + for e in document["snapshot"]: + if e["name"].startswith(("steamadvantage:", "electricadvantage:")): + if any(data(e).get("Energy", [])): + verdict["failures"].append(e["id"] + ": nonzero initial power reserve") + if verdict["cases"]["battery"]["charge"] != 0: + verdict["failures"].append("Initial battery is not empty") + verdict["status"] = "FAIL" if verdict["failures"] else "PASS" + return verdict + + +def operation(before, after, ticks): + verdict = state(after) + previous = state(before) + for station, amount in verdict["water"].items(): + if amount > previous["water"].get(station, 0): + verdict["failures"].append(station + ": finite water was created") + if not after.get("held") or after["requestedTicks"] != ticks or after["advancedTicks"] != ticks: + verdict["failures"].append("Missing exact native tick interval/freeze proof") + for name, case in previous["cases"].items(): + if name == "battery": + continue + current = verdict["cases"][name] + if case["remaining"] > 0 and current["produced"] <= case["produced"]: + # A nine-block electric drilling cycle can span an observation window. + old = data(entries(before).get(name + "-drill", {})).get("progress", 0) + new = data(entries(after).get(name + "-drill", {})).get("progress", 0) + if name not in ("C-SDRILL", "C-EDRILL") or new <= old: + verdict["failures"].append(name + ": no further work from retained inputs") + old_entries = entries(before) + for key, entry in entries(after).items(): + if "objectIdentity" in old_entries[key] and not entry["kind"].startswith("target-"): + if entry.get("objectIdentity") != old_entries[key]["objectIdentity"]: + verdict["failures"].append(key + ": tile replaced during the observation interval") + verdict["status"] = "FAIL" if verdict["failures"] else "PASS" + return verdict + + +def prepare(runtime, profile, definition, port, source=None): + launcher = stage(runtime, profile) + if source is not None: + copy_fixture(source, runtime) + definition = json.loads((source / "connected-fixture.json").read_text()) + else: + write_json(runtime / "migration-fixture.json", definition["blocks"]) + write_json(runtime / "connected-fixture.json", definition) + for marker in ("advantage-connected.allow", "migration-operate.allow"): + (runtime / marker).touch() + props = runtime / "server.properties" + props.write_text(props.read_text().replace("server-port=25584", f"server-port={port}") + + "spawn-monsters=false\nspawn-animals=false\n") + return launcher + + +class ConnectedServer(Server): + def __init__(self, *args): + self.commands = [] + super().__init__(*args) + + def send(self, command): + if command != "stop" and not command.startswith(("advconnected ", "save-all")): + raise AssertionError("Forbidden connected-world mutation: " + command) + self.commands.append(command) + super().send(command) + + def observe(self, operation_name, label, ticks=0): + self.send(f"advconnected step {ticks} {label}" if ticks else f"advconnected {operation_name} {label}") + self.wait("ADVCONNECTED COMPLETE " + label, max(120, ticks / 20 + 90)) + + def close(self): + try: + super().close() + finally: + path = Path(self.log.name) + write_json(path.with_suffix(".commands.json"), self.commands) + + +def boot(runtime, args, launcher, label): + return ConnectedServer(runtime, args.java8, launcher, label) + + +def resume(runtime, args, launcher, label): + server = boot(runtime, args, launcher, label) + try: + server.observe("snapshot", label + "-loaded") + loaded = read(runtime, label + "-loaded") + server.observe("step", label + "-worked", args.ticks) + worked = read(runtime, label + "-worked") + verdict = operation(loaded, worked, args.ticks) + if worked["buildCallsThisProcess"] != 0: + verdict["status"] = "FAIL" + verdict["failures"].append("Equipment rebuilt") + server.send("save-all flush") + finally: + server.close() + return verdict diff --git a/test-rig/migration/native112.py b/test-rig/migration/native112.py new file mode 100644 index 0000000..a8075cb --- /dev/null +++ b/test-rig/migration/native112.py @@ -0,0 +1,73 @@ +"""Strict native Forge 25 inputs for the expanded operating checks; never opens source worlds.""" + +import json +from pathlib import Path +import zipfile +from rig_transport import WORLD, digest, stage, write_json + +RIG = Path(__file__).resolve().parents[1] +LAUNCHER = "forge-1.12.2-14.23.5.2859.jar" + + +def output_path(path): + path = path.resolve() + if not path.is_relative_to((RIG / "build").resolve()): + raise ValueError("Native112 outputs must remain under test-rig/build") + if path.exists(): raise FileExistsError(path) + return path + + +def require_disjoint(output, protected): + output, protected = output.resolve(), protected.resolve() + if output.is_relative_to(protected) or protected.is_relative_to(output): + raise ValueError("Output overlaps a protected input: " + str(protected)) + + +def identity(path): + with zipfile.ZipFile(path) as archive: + document = json.loads(archive.read("mcmod.info").decode("utf-8-sig")) + mods = document if isinstance(document, list) else document.get("modList", []) + if len(mods) != 1: raise ValueError("Expected one declared mod per supplied jar") + return mods[0]["modid"], mods[0]["version"] + + +def import_existing(source, output): + """Retain approved jar digests; add explicit identities and a current seed-code seal.""" + from native112_code import code_files + source, output = source.resolve(), output.resolve() + require_disjoint(output, source) + if output.exists(): raise FileExistsError(output) + document = json.loads(source.read_text(encoding="utf-8")) + target = document["target112"] + for jar in target["jars"]: + path = (source.parent / jar["path"]).resolve() + if digest(path) != jar["sha256"].upper(): raise AssertionError("Archived source jar changed") + mod, version = identity(path) + jar.update(path=str(path), id=mod, version=version) + seed = (source.parent / target["seed"]).resolve() + target.update(seed=str(seed), launcher_sha256=digest(seed / LAUNCHER), + serverCode={"files": code_files(seed)}, qualificationStatus="UNQUALIFIED_EXPANDED_COVERAGE") + write_json(output, dict(target112=target, baseManifest=str(source), baseSha256=digest(source))) + pinned_profile(output, "target112") + + +def pinned_profile(manifest_path, key): + manifest_path = manifest_path.resolve() + target = json.loads(manifest_path.read_text(encoding="utf-8"))[key] + seed = (manifest_path.parent / target["seed"]).resolve() + jars, ids = [], set() + for jar in target["jars"]: + path = (manifest_path.parent / jar["path"]).resolve() + if digest(path) != jar["sha256"].upper(): raise AssertionError("Pinned jar changed: " + str(path)) + actual = identity(path) + if actual != (jar["id"], jar["version"]): + raise ValueError("Wrong native mod identity/version: " + str(path)) + if jar["id"] in {"poweradvantage", "steamadvantage", "electricadvantage", "advantageworkstest"} and not jar["version"].endswith(".112021"): + raise ValueError("Wrong Advantage platform version") + if jar["id"] in ids: raise ValueError("Duplicate mod identity") + ids.add(jar["id"]); jars.append(path) + if key != "target112" or target["launcher"] != LAUNCHER or not {"poweradvantage", "advantageworkstest"} <= ids: + raise ValueError("Incomplete/wrong native112 input profile") + if digest(seed / LAUNCHER) != target["launcher_sha256"].upper() or not (seed / "libraries").is_dir(): + raise AssertionError("Native Forge 25 seed changed") + return seed, LAUNCHER, jars diff --git a/test-rig/migration/native112_code.py b/test-rig/migration/native112_code.py new file mode 100644 index 0000000..2ee5f73 --- /dev/null +++ b/test-rig/migration/native112_code.py @@ -0,0 +1,50 @@ +"""Exact, code-only Forge seed custody; worlds and configuration are not code.""" + +import json +from pathlib import Path +from rig_transport import Server as HistoricalServer, digest + +INVENTORY = "native112-code-inventory.json" + + +def code_files(root): + root = Path(root) + paths = list((root / "libraries").rglob("*")) + list(root.glob("*.jar")) + return {path.relative_to(root).as_posix(): {"sha256": digest(path), "size": path.stat().st_size} + for path in sorted(paths) if path.is_file()} + + +def verify_code(root, expected=None): + root = Path(root) + receipt = root / INVENTORY + if expected is None: + expected = json.loads(receipt.read_text(encoding="utf-8")) + files = expected.get("files") + if not isinstance(files, dict) or not files: + raise ValueError("Missing sealed native112 code inventory") + if code_files(root) != files: + raise AssertionError("Native112 server code differs from its exact sealed inventory: " + str(root)) + return expected + + +def verify_if_sealed(root): + if (Path(root) / INVENTORY).is_file(): + verify_code(root) + + +class CodeCustody: + """Adds pre-boot and post-shutdown checks without changing server commands.""" + def __init__(self, runtime, *args, **kwargs): + self.code_runtime = Path(runtime) + verify_if_sealed(self.code_runtime) + super().__init__(runtime, *args, **kwargs) + + def close(self): + try: + return super().close() + finally: + verify_if_sealed(self.code_runtime) + + +class Server(CodeCustody, HistoricalServer): + pass diff --git a/test-rig/migration/native112_connected.py b/test-rig/migration/native112_connected.py new file mode 100644 index 0000000..c04a2d4 --- /dev/null +++ b/test-rig/migration/native112_connected.py @@ -0,0 +1,171 @@ +"""Resume copied legacy networks without rebuilding, refilling or replacing machines.""" +import argparse +import base64 +import gzip +import io +from datetime import datetime, timezone +import json +from pathlib import Path +import socket +import traceback +import nbtlib + +import legacy_connected_oracle as oracle +import semantic_machine_audit +from rig_transport import WORLD, digest, fingerprint, write_json +from native112 import output_path, pinned_profile, require_disjoint +from native112_code import INVENTORY, verify_code +from native112_lifecycle import read_json, require_native_boot, verify_staged +from legacy_connected_oracle import saved_tile_nbt + +ENGINE = "112" +SOURCE_KEYS = ("legacy112", "maintained112") +PROBE_SHA = "B9F756681593B508A8E3222BD592A3DFCB474303532B154045893B3C585EF1D3" + + +def strict_snapshot(before, after): + def rows(document): + result = {} + for row in document["snapshot"]: + if row["id"] in result: raise AssertionError("Duplicate connected witness") + value = {key: row[key] for key in ("station", "kind", "name", "x", "y", "z", "actualName", "properties")} + if "nbtBase64" in row: + saved = nbtlib.File.parse(io.BytesIO(gzip.decompress(base64.b64decode(row["nbtBase64"], validate=True)))) + value["nbt"] = saved_tile_nbt(saved) + for field in ("ForgeData", "ForgeCaps"): + if value["nbt"].get(field) == {}: value["nbt"].pop(field) + if row["actualName"] in ("steamadvantage:steam_drill", "electricadvantage:electric_drill"): + facing = row["properties"].get("facing") + if "Facing" not in value["nbt"] and facing in ("down", "up", "north", "south", "west", "east"): + value["nbt"]["Facing"] = ["Byte", ("down", "up", "north", "south", "west", "east").index(facing)] + identity = value["nbt"].get("id") + if identity is not None and identity[0] == "String": + canonical = identity[1].replace("minecraft:", "", 1) if "advantage." in identity[1] else identity[1] + canonical = canonical.replace("advantage.", "advantage:", 1) + value["nbt"]["id"] = ["String", "minecraft:chest" if canonical == "Chest" else canonical] + result[row["id"]] = value + return result + left, right = rows(before), rows(after) + changed = sorted(key for key in left.keys() | right.keys() if left.get(key) != right.get(key)) + return dict(status="FAIL" if changed else "PASS", changed=changed, + comparison="Exact block/property/typed-NBT equality, including target-only keys; tile-ID aliases, absent/empty Forge compounds and drill Facing encoded in block state are canonicalized") + + +class NativeServer(oracle.ConnectedServer): + def close(self): + try: return super().close() + finally: require_native_boot(Path(self.log.name)) + + +def run(args): + output = output_path(args.output) + target = pinned_profile(args.target_manifest, "target" + ENGINE) + pins = read_json(args.target_manifest)["target" + ENGINE] + previous = read_json(args.previous) + if previous.get("status") != "PASS": raise AssertionError("Archived source provenance must be complete") + for path in (args.target_manifest, args.previous, target[0], *target[2]): require_disjoint(output, path) + selected = [row for row in previous["sources"] if read_json(Path(row["report"]))["sourceKey"] in SOURCE_KEYS] + if len(selected) != len(SOURCE_KEYS): raise AssertionError("Incomplete native legacy source selection") + with socket.socket() as listener: listener.bind(("127.0.0.1", args.port)) + output.mkdir(parents=True, exist_ok=False) + helpers = [Path(__file__), Path(oracle.__file__), Path(semantic_machine_audit.__file__), + Path(__file__).with_name("native" + ENGINE + "_code.py"), + Path(__file__).with_name("native" + ENGINE + "_lifecycle.py"), + Path(__file__).with_name("native" + ENGINE + "_diagnostics.py")] + hashes = {str(path): digest(path) for path in helpers} + report = dict(status="RUNNING", startedAtUtc=datetime.now(timezone.utc).isoformat(), routes=[], + targetManifestSha256=digest(args.target_manifest), previousSha256=digest(args.previous), + protocolSha256=hashes, scope="Imported connected machines; two native 2048-tick intervals and a full process restart; no destination construction") + receipt = output / ("native" + ENGINE + "-connected-results.json") + write_json(receipt, report) + for index, row in enumerate(selected): + server = None + source = None + source_before = None + outcome = dict(status="RUNNING", phases={}) + report["routes"].append(outcome) + try: + history = Path(row["report"]) + if digest(history) != row["sha256"]: raise AssertionError("Archived source provenance changed") + provenance = read_json(history) + source = Path(provenance["source"]) + require_disjoint(output, source) + source_before = fingerprint(source) + expected = read_json(Path(provenance["sourceHashes"])) + if digest(Path(provenance["sourceHashes"])) != provenance["sourceHashesSha256"]: + raise AssertionError("Archived source hashes changed") + if fingerprint(source / WORLD) != expected: raise AssertionError("Protected source world changed") + archived = read_json(Path(provenance["sourceManifest"]))[provenance["sourceKey"]] + source_pins = {Path(jar["path"]).name: jar["sha256"].upper() for jar in archived["jars"]} + if {jar.name: digest(jar) for jar in (source / "mods").glob("*.jar")} != source_pins: + raise AssertionError("Protected source mod bytes changed") + probes = [Path(jar["path"]) for jar in archived["jars"] if jar["sha256"].upper() == PROBE_SHA] + if len(probes) != 1 or digest(probes[0]) != PROBE_SHA: + raise AssertionError("Missing exact archived server-only LegacyProbe 1.0.0") + profile = target[:2] + (target[2] + probes,) + runtime = output / provenance["sourceKey"] + launcher = oracle.prepare(runtime, profile, None, args.port, source) + verify_code(runtime, pins["serverCode"]) + write_json(runtime / INVENTORY, pins["serverCode"]) + verify_staged(runtime, profile) + if (runtime / "connected-build.allow").exists(): raise AssertionError("Destination construction authority leaked") + definition = read_json(runtime / "connected-fixture.json") + checkpoint = provenance["sourceCheckpoint"] + previous_document = oracle.read(source, checkpoint) + if oracle.state(previous_document)["status"] != "PASS": raise AssertionError("Invalid retained source stocks") + outcome.update(source=str(source), sourceKey=provenance["sourceKey"], sourceBefore=source_before, + legacyProbe=dict(path=str(probes[0]), sha256=PROBE_SHA, modid="advantagemigrationprobe", version="1.0.0"), + runtime=str(runtime), stagedInputs=read_json(runtime / "input-jars.json")) + for phase in ("converted", "restarted"): + server = NativeServer(runtime, args.java8, launcher, phase) + server.observe("snapshot", phase + "-loaded") + loaded = oracle.read(runtime, phase + "-loaded") + if not loaded.get("held") or loaded.get("buildCallsThisProcess") != 0 or loaded.get("advancedTicks") != 0: + raise AssertionError("Preservation was not observed before operation") + preservation = strict_snapshot(previous_document, loaded) + outcome["phases"][phase + "-preservation"] = preservation + write_json(receipt, report) + if preservation["status"] != "PASS": raise AssertionError("Unticked imported state changed: " + str(preservation)) + server.observe("step", phase + "-worked", 2048) + worked = oracle.read(runtime, phase + "-worked") + operation = oracle.operation(loaded, worked, 2048) + completed = semantic_machine_audit.connected_interval(loaded, worked, definition, 2048) + outcome["phases"][phase + "-operation"] = operation + outcome["phases"][phase + "-completed-work"] = completed + if operation["status"] != "PASS" or completed["status"] != "PASS": + raise AssertionError("Connected machines failed measured work") + server.send("save-all flush") + server.wait("Saved the world", 120) + server.close(); server = None + verify_staged(runtime, profile) + previous_document = worked + write_json(receipt, report) + outcome["status"] = "PASS" + except Exception as error: + outcome.update(status="FAIL", failure=str(error), traceback=traceback.format_exc()) + finally: + if server is not None: + try: server.close() + except Exception as error: outcome.update(status="FAIL", shutdownError=str(error)) + if source_before is not None: + outcome["sourceUnchanged"] = fingerprint(source) == source_before + if not outcome["sourceUnchanged"]: outcome["status"] = "FAIL" + write_json(receipt, report) + print(outcome.get("sourceKey", str(index)) + ": " + outcome["status"], flush=True) + report["protocolUnchanged"] = hashes == {str(path): digest(path) for path in helpers} + report["status"] = "PASS" if report["protocolUnchanged"] and all(row["status"] == "PASS" for row in report["routes"]) else "FAIL" + if digest(args.target_manifest) != report["targetManifestSha256"] or digest(args.previous) != report["previousSha256"]: + report["status"] = "FAIL" + report["completedAtUtc"] = datetime.now(timezone.utc).isoformat() + write_json(receipt, report) + return int(report["status"] != "PASS") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--target-manifest", type=Path, required=True) + parser.add_argument("--previous", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--java8", type=Path, required=True) + parser.add_argument("--port", type=int, required=True) + raise SystemExit(run(parser.parse_args())) diff --git a/test-rig/migration/native112_diagnostics.py b/test-rig/migration/native112_diagnostics.py new file mode 100644 index 0000000..bb20525 --- /dev/null +++ b/test-rig/migration/native112_diagnostics.py @@ -0,0 +1,20 @@ +"""Strict native 1.12 diagnostics, recording two mis-levelled notices in pinned OreSpawn 3.""" + +import re + +LEGACY_ORESPAWN_SHA = "86A5E567950D5428F413A0C744FD4EA6DF5807955CBD444392595A1871CC1DFA" +INTEGRATION_NOTICE = re.compile( + r"^\[[^\]]+\] \[Server thread/FATAL\] \[orespawn\]: Loading Integration For (orespawn|basemetals)$") + +def audit_lines(lines, orespawn_sha=None): + classified = [] + for number, line in enumerate(lines, 1): + if orespawn_sha == LEGACY_ORESPAWN_SHA and INTEGRATION_NOTICE.fullmatch(line): + classified.append(dict(line=number, message=line, + classification="PINNED_ORESPAWN_3_INTEGRATION_NOTICE", + dependencySha256=LEGACY_ORESPAWN_SHA)) + continue + if any(marker in line for marker in ("/ERROR]", "/FATAL]", "NoSuchFieldError", "NoSuchMethodError", + "Encountered an unexpected exception", "Failed to reload data packs")): + raise AssertionError("Native runtime diagnostic at line " + str(number) + ": " + line) + return dict(status="PASS", classifiedDiagnostics=classified) diff --git a/test-rig/migration/native112_harvest.py b/test-rig/migration/native112_harvest.py new file mode 100644 index 0000000..9a28fcb --- /dev/null +++ b/test-rig/migration/native112_harvest.py @@ -0,0 +1,229 @@ +"""Native legacy survival harvesting: exact tools/drops, 64 connection masks and cold reboot.""" + +import argparse +from datetime import datetime, timezone +import math +from pathlib import Path +import shutil +import socket +import traceback +from uuid import UUID + +from rig_transport import digest, write_json +from native112 import identity, output_path, pinned_profile, require_disjoint +from native112_code import INVENTORY, Server, verify_code +from native112_lifecycle import prepare, read_json, require_native_boot, verify_staged + +EXPECTED = { + "sulfur/hand-fails": (0, False, False, 0, 0, 1), + "sulfur/axe-fails": (0, False, False, 0, 0, 1), + "sulfur/wood": (0, True, False, 1, 4, 8), + "sulfur/stone": (0, True, False, 1, 4, 8), + "sulfur/silk": (0, True, True, 1, 1, 1), + "lithium/hand-fails": (1, False, False, 0, 0, 1), + "lithium/wood-fails": (1, False, False, 0, 0, 1), + "lithium/gold-fails": (1, False, False, 0, 0, 1), + "lithium/axe-fails": (1, False, False, 0, 0, 1), + "lithium/stone": (1, True, False, 1, 1, 1), + "lithium/diamond": (1, True, False, 1, 1, 1), + "lithium/silk": (1, True, True, 1, 1, 1), + "frame/hand": (-1, True, False, 1, 1, 1), + "frame/silk": (-1, True, True, 1, 1, 1), +} +for _mask in range(64): + for _family in ("wire", "scaffold"): + EXPECTED[f"{_family}/{_mask}/hand"] = (-1, True, False, 1, 1, 1) + EXPECTED[f"{_family}/{_mask}/silk"] = (-1, True, True, 1, 1, 1) + +ISOLATION_PROTOCOL = "native112-live-drops-v1" +MINECRAFT = "1.12.2" +WATER_ONLY = {"wire/waterlogged", "scaffold/waterlogged", "frame/waterlogged"} +NA_REASON = f"Minecraft {MINECRAFT} has no waterlogged blockstate/fluid API" +# Historical GUIBlock explicitly sets light opacity to zero, independent of +# PhotovoltaicGeneratorBlock's full-cube/opaque overrides. Preserve both facts. +PROPERTIES = {"solar/native-solidity": ("electricadvantage:photovoltaic_generator", True, 0), + "frame/native-opacity": ("poweradvantage:steel_frame", False, 0)} + + +def block_identity(name): + if name.startswith("sulfur/"): return "electricadvantage:sulfur_ore" + if name.startswith("lithium/"): return "electricadvantage:li_ore" + if name.startswith("frame/"): return "poweradvantage:steel_frame" + return "electricadvantage:" + ("electric_conduit" if name.startswith("wire/") else "electric_track") + + +def drop_identity(name): + if not EXPECTED[name][3]: return "" + if name in ("sulfur/wood", "sulfur/stone"): return "electricadvantage:sulfur_powder" + return block_identity(name) + + +def expected_boxes(name): + if not name.startswith("wire/"): return [[0, 0, 0, 1, 1, 1]] + mask = int(name.split("/")[1]) + boxes = [[.375, .375, .375, .625, .625, .625]] + for bit, coordinates in ( + (2, [.375, .375, .375, .625, 1, .625]), + (1, [.375, 0, .375, .625, .625, .625]), + (32, [.375, .375, .375, 1, .625, .625]), + (16, [0, .375, .375, .625, .625, .625]), + (8, [.375, .375, .375, .625, .625, 1]), + (4, [.375, .375, 0, .625, .625, .625])): + if mask & bit or mask == 0 and bit >= 4: boxes.append(coordinates) + return boxes + + +def require_checks(document, label): + rows = document.get("checks") + if not isinstance(rows, list) or any(not isinstance(row, dict) or not isinstance(row.get("id"), str) for row in rows): + raise AssertionError("Missing native harvest witness rows") + actual = {row["id"]: row for row in rows} + if (document.get("label") != label or document.get("minecraft") != MINECRAFT + or actual.keys() != EXPECTED.keys() | PROPERTIES.keys() | WATER_ONLY + or len(rows) != 275 or document.get("executedChecks") != 272 + or document.get("notApplicableChecks") != 3): + raise AssertionError("Incomplete native112 harvest witness set") + if document.get("status") != "PASS": + failures = [row["id"] + ": " + row.get("failure", str(row.get("status"))) + for row in rows if row.get("status") not in ("PASS", "N/A")] + raise AssertionError("Native112 harvest witnesses failed: " + "; ".join(failures[:5])) + for name in WATER_ONLY: + if actual[name].get("status") != "N/A" or actual[name].get("reason") != NA_REASON: + raise AssertionError("Waterlogging must remain an explicit unsupported N/A: " + name) + for name, (level, allowed, silk, minimum, maximum, trials) in EXPECTED.items(): + row = actual[name] + counts = row.get("actualDropCounts", []) + mask = int(name.split("/")[1]) if name.startswith(("wire/", "scaffold/")) else -1 + ore = name.startswith(("sulfur/", "lithium/")) + if (row.get("status") != "PASS" or row.get("block") != block_identity(name) + or type(row.get("historicalHarvestLevel")) is not int or row["historicalHarvestLevel"] != level + or row.get("harvestAllowed") is not allowed or row.get("silkTouch") is not silk + or type(row.get("connectionMask")) is not int or row["connectionMask"] != mask + or mask >= 0 and (type(row.get("actualConnectionMask")) is not int or row["actualConnectionMask"] != mask) + or row.get("actualFullCube") is not ore or row.get("actualOpaque") is not ore + or row.get("materialSolid") is not True + or type(row.get("trials")) is not int or row["trials"] != trials or not isinstance(counts, list) + or len(counts) != trials + or any(type(count) is not int or not minimum <= count <= maximum for count in counts)): + raise AssertionError("Historical mining/drop oracle failed: " + name) + boxes = row.get("collisionBoxes") + if (not isinstance(boxes, list) or any(not isinstance(box, list) or len(box) != 6 + or any(type(value) not in (int, float) or not math.isfinite(value) for value in box) for box in boxes) + or sorted(map(tuple, boxes)) != sorted(map(tuple, expected_boxes(name)))): + raise AssertionError("Legacy collision geometry changed: " + name) + for name, (block, opaque, light_opacity) in PROPERTIES.items(): + row = actual[name] + if (row.get("status") != "PASS" or row.get("block") != block or row.get("actualOpaque") is not opaque + or row.get("actualFullCube") is not opaque or type(row.get("lightOpacity")) is not int + or row["lightOpacity"] != light_opacity): + raise AssertionError("Legacy frame/photovoltaic opacity changed: " + name) + + +def require_isolation(document): + if document.get("fixtureIsolation") != ISOLATION_PROTOCOL: + raise AssertionError("Missing native112 live-drop fixture isolation protocol") + seen_ids, seen_uuids = set(), set() + for name, spec in EXPECTED.items(): + rows = [row for row in document["checks"] if row["id"] == name] + if len(rows) != 1: raise AssertionError("Missing/duplicate harvest row: " + name) + row = rows[0] + evidence = row.get("dropEvidence", []) + if not isinstance(evidence, list) or len(evidence) != spec[5]: + raise AssertionError("Incomplete per-trial drop isolation evidence: " + name) + for trial, entry in enumerate(evidence): + if (not isinstance(entry, dict) or type(entry.get("trial")) is not int or entry["trial"] != trial + or type(entry.get("liveAfterCleanup")) is not int or entry["liveAfterCleanup"] != 0 + or any(type(entry.get(key)) is not int or entry[key] < 0 + for key in ("liveBeforeCleanup", "removedBeforeCleanup", "worldTime"))): + raise AssertionError("Harvest trial was not isolated: " + name) + drops = entry.get("actualDrops") + if not isinstance(drops, list): raise AssertionError("Missing actual native drop identities: " + name) + count = 0 + for drop in drops: + if not isinstance(drop, dict): raise AssertionError("Invalid native drop record: " + name) + entity_id, uuid = drop.get("entityId"), drop.get("uuid") + try: valid_uuid = isinstance(uuid, str) and str(UUID(uuid)) == uuid + except (ValueError, TypeError, AttributeError): valid_uuid = False + if (type(entity_id) is not int or entity_id in seen_ids or not valid_uuid or uuid in seen_uuids + or drop.get("removed") is not False or drop.get("item") != drop_identity(name) + or type(drop.get("count")) is not int or drop["count"] <= 0 + or type(drop.get("damage")) is not int or drop["damage"] != 0 or drop.get("tagged") is not False + or any(type(drop.get(axis)) not in (int, float) or not math.isfinite(drop[axis]) + or not low <= drop[axis] <= high + for axis, low, high in (("x",1198,1203),("y",6,11),("z",1022,1027)))): + raise AssertionError("Removed, repeated, wrong or invalid native harvest drop: " + name) + seen_ids.add(entity_id); seen_uuids.add(uuid) + count += drop["count"] + if count != row["actualDropCounts"][trial]: + raise AssertionError("Native drop identities disagree with counted items: " + name) + + +def run(args): + output = output_path(args.output) + require_disjoint(output, args.target_manifest) + target = pinned_profile(args.target_manifest.resolve(), "target112") + for protected in (target[0], *target[2]): require_disjoint(output, protected) + if not {"poweradvantage","steamadvantage","electricadvantage","advantageworkstest"} <= {identity(jar)[0] for jar in target[2]}: + raise AssertionError("Harvest requires all three Advantage mods and the native harness") + expected_code = read_json(args.target_manifest)["target112"].get("serverCode") + if not expected_code: raise AssertionError("Harvest requires an exact sealed server-code inventory") + verify_code(target[0], expected_code) + with socket.socket() as listener: listener.bind(("127.0.0.1", args.port)) + launcher = prepare(output, target, [], args.port) + verify_code(output, expected_code) + write_json(output / INVENTORY, expected_code) + (output / "advantage-native-harvest.allow").touch() + helpers = [Path(__file__), *[Path(__file__).with_name(name) for name in + ("rig_transport.py","native112.py","native112_lifecycle.py","native112_code.py","native112_diagnostics.py")], + Path(__file__).parents[1] / "src/main/java/zone/moddev/mc/advantageworks/NativeHarvestProbe.java"] + report = dict(status="RUNNING", started_at_utc=datetime.now(timezone.utc).isoformat(), phases={}, + fixture_isolation_protocol=ISOLATION_PROTOCOL, runner_sha256=digest(Path(__file__)), + target_manifest=str(args.target_manifest.resolve()), target_manifest_sha256=digest(args.target_manifest), + scope="Real survival harvest on each of two cold boots; 270 mining cases, solar solidity, dedicated frame opacity, 3 waterlogging N/A", + source_baseline="Legacy sulfur pickaxe 0, dust 1..4; lithium pickaxe 1, ore 1; PISTON frame/wire/scaffold material; native nonopaque geometry", + executed_per_boot=272, not_applicable_per_boot=3) + report["protocolSha256"] = {str(path): digest(path) for path in helpers} + for path in helpers: + destination = output / "protocol-sources" / path.name + destination.parent.mkdir(exist_ok=True) + shutil.copy2(path, destination) + server = None + try: + for label in ("exercise", "restored"): + verify_staged(output, target) + server = Server(output, args.java8, launcher, label) + server.send("advharvest " + label) + server.wait("ADVHARVEST COMPLETE " + label, 120) + document = read_json(output / "advantage-works-results" / f"native-harvest-{label}.json") + report["phases"][label] = document + server.send("save-all flush") + server.close() + server = None + verify_staged(output, target) + require_native_boot(output / f"console-{label}.log") + require_checks(document, label) + require_isolation(document) + verify_staged(output, target) + report["status"] = "PASS" + except Exception as error: + report.update(status="FAIL", failure=str(error), traceback=traceback.format_exc()) + finally: + if server: + try: server.close() + except Exception as error: report.update(status="FAIL", shutdown_error=str(error)) + report["completed_at_utc"] = datetime.now(timezone.utc).isoformat() + report["protocolUnchanged"] = report["protocolSha256"] == {str(path): digest(path) for path in helpers} + if not report["protocolUnchanged"]: report.update(status="FAIL", protocolError="Harvest protocol changed during run") + report["manifestUnchanged"] = digest(args.target_manifest) == report["target_manifest_sha256"] + if not report["manifestUnchanged"]: report.update(status="FAIL", manifestError="Harvest input manifest changed during run") + write_json(output / "native112-harvest-results.json", report) + return int(report["status"] != "PASS") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--target-manifest", type=Path, required=True) + parser.add_argument("--java8", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--port", type=int, default=25596) + raise SystemExit(run(parser.parse_args())) diff --git a/test-rig/migration/native112_lifecycle.py b/test-rig/migration/native112_lifecycle.py new file mode 100644 index 0000000..6bcd049 --- /dev/null +++ b/test-rig/migration/native112_lifecycle.py @@ -0,0 +1,44 @@ +"""Disposable-runtime transport for expanded 1.13 checks; no expected-state writes.""" + +import json +from pathlib import Path +from rig_transport import copy_fixture, digest, stage, write_json +from native112 import LAUNCHER +from native112_diagnostics import audit_lines + + +def read_json(path): return json.loads(path.read_text(encoding="utf-8")) + + +def require_native_boot(path): + text = path.read_text(encoding="utf-8", errors="replace") + pins = read_json(path.parent / "input-jars.json") + legacy = [pin["sha256"].upper() for pin in pins if pin["file"] == "OreSpawn-1.12-3.3.1.179.jar"] + diagnostics = audit_lines(text.splitlines(), legacy[0] if len(legacy) == 1 else None) + diagnostics.update(logSha256=digest(path), nativeProcessExit="Required separately by Server.close") + write_json(path.with_suffix(".diagnostics.json"), diagnostics) + failures = [line for line in text.splitlines() if "/ERROR]" in line or any(marker in line for marker in ( + "NoSuchFieldError", "NoSuchMethodError", "Failed to reload data packs", "Encountered an unexpected exception"))] + if failures: raise AssertionError("Native runtime errors: " + str(failures[:5])) + for marker in ("Starting minecraft server version 1.12.2", "Forge Mod Loader version 14.23.5.2859 for Minecraft 1.12.2 loading"): + if marker not in text: raise AssertionError("Missing genuine native112 boot proof: " + marker) + + +def verify_staged(runtime, profile): + expected = {jar.name: digest(jar) for jar in profile[2]} + actual = {jar.name: digest(jar) for jar in (runtime / "mods").glob("*.jar")} + recorded = {row["file"]: row["sha256"].upper() for row in read_json(runtime / "input-jars.json")} + if actual != expected or recorded != expected: raise AssertionError("Staged mod bytes differ from pinned inputs") + if digest(runtime / LAUNCHER) != digest(profile[0] / LAUNCHER): raise AssertionError("Staged launcher differs") + + +def prepare(runtime, profile, definition, port, source=None): + if profile[1] != LAUNCHER: raise ValueError("Wrong engine for native112 test") + launcher = stage(runtime, profile) + if source is not None: copy_fixture(source, runtime) + properties = runtime / "server.properties" + lines = [line for line in properties.read_text().splitlines() if not line.startswith("server-port=")] + properties.write_text("\n".join(lines) + f"\nserver-port={port}\n", encoding="utf-8") + write_json(runtime / "lifecycle-fixture.json", definition) + verify_staged(runtime, profile) + return launcher diff --git a/test-rig/migration/native112_machine_operations.py b/test-rig/migration/native112_machine_operations.py new file mode 100644 index 0000000..8c89380 --- /dev/null +++ b/test-rig/migration/native112_machine_operations.py @@ -0,0 +1,282 @@ +"""Run independent finite machine jobs on native world ticks, then inspect a cold restart.""" + +import argparse +import copy +from datetime import datetime, timezone +import json +from pathlib import Path +import shutil +import socket +import traceback + +import nbtlib + +from rig_transport import digest, write_json +from native112 import RIG, LAUNCHER, output_path, pinned_profile, require_disjoint +from native112_code import Server, code_files, verify_code, INVENTORY +from native112_lifecycle import prepare, read_json, require_native_boot, verify_staged + + +REQUIRED = { + "power": {"conveyor/down", "conveyor/up", "conveyor/north", "conveyor/south", "conveyor/west", "conveyor/east", + "conveyor/blocked", "conveyor/item-entity", "conveyor/nbt-preserved", "filter/block", "filter/food", "filter/fuel", "filter/ore", + "filter/plant", "filter/smelt", "filter/inventory-0", "filter/inventory-1", "filter/inventory-2", + "filter/inventory-3", "filter/overflow-0", "filter/overflow-1", "filter/overflow-2", "filter/overflow-3", + "drain/minecraft_water", "drain/poweradvantage_crude_oil", "drain/poweradvantage_refined_oil", + "drain/non-source", "tank/filter-true", "tank/filter-false", "tank/full", "fluid-switch/true", + "fluid-switch/false", "discharge/true", "discharge/false", "conveyor/locked-source", "conveyor/locked-destination", + "still/production", "still/no-fuel", "still/invalid", "still/full", "still/one-space", "still/redstone"}, + "steam": {"coal-production", "coal-invalid-fuel", "coal-dry", "coal-redstone", "oil-production", "oil-dry", + "oil-water-not-fuel", "oil-small-charge", "geothermal-cold", "geothermal-dry", + "electric-production", "electric-no-power", "electric-dry", "electric-insufficient", "geothermal-production", + "furnace-three-lanes", "furnace-no-fuel", "furnace-full-outputs", "furnace-invalid-input", "furnace-without-steam", + "crusher-and-tank-discharge", "crusher-no-power", "crusher-full-outputs", "crusher-invalid-input", + "tank-redstone-stop", "tank-backup-no-cycling", "still-production", "still-no-power", "still-full-output", + "still-one-output-space", "still-incomplete-input", "still-invalid-fluid", + "pump-3x3-intake-lift", "pump-no-power", "pump-insufficient-steam", "pump-full-output", + "elevator-up", "elevator-down", "elevator-no-power", "elevator-scarce", + "musket-last-cartridge", "musket-no-ammo", "musket-powderless", "musket-infinity", + "extra-priority-backup-eligible", "extra-priority-consumer-first", "extra-recoil-0", "extra-recoil-1", + "extra-recoil-5", "extra-explosive-0", "extra-explosive-1", "extra-musket-solid-wall"}, + "electric": {"furnace", "crusher", "oven", "parallel-inputs", "fabricator-recursive", "refinery", "still", + "still-faced-input", "pump", "growth-chain", "growth-scarcity-full", "led-rate", "led-exhaustion", + "hydro-rate", "solar-daylight-rate", "solar-blocklight-rate", "steam-generator-rate", "steam-generator-supply", + "battery-priority", "battery-redstone", "turret-hit", "turret-no-power", "turret-scarce", "turret-occluded", + "turret-owner-credit", "battery-chemistry-lead_acid", "battery-chemistry-nickel_hydride", + "battery-chemistry-alkaline", "battery-chemistry-lithium"}, +} + + +for _district in ("steam", "electric"): + REQUIRED[_district].update("drill-track-" + facing + "-" + mode + for facing in ("down", "up", "north", "south", "west", "east") + for mode in ("move", "no-power", "redstone-blocked")) +REQUIRED["electric"].update("lifecycle/" + name for name in ("battery-mixed-charge", "battery-mixed-discharge", + "battery-fresh-empty-tag", "battery-fresh-no-tag", "cable-switch", "cable-static-gap", "solar-redstone")) +REQUIRED["steam"].update("lifecycle/network-" + control + "-" + state + for control in ("switch", "pipe-gap") for state in ("off-on", "stays-off")) +REQUIRED["power"].update("bucket/" + name for name in ("startup", "crude_oil", "refined_oil")) + + +def require_case_set(catalog, group): + exclusions = catalog.get("notApplicable", []) + if ({row.get("id") for row in exclusions} != {"pickup/" + name for name in ("power-drain", "steam-pump", "electric-pump")} + or len(exclusions) != 3 or any(row.get("status") != "NOT_APPLICABLE" for row in exclusions)): + raise AssertionError("The three waterlogging-only exclusions must be explicit; no other skip is accepted") + rows = catalog.get("cases", []) + ids = {row["id"] for row in rows} + if not ids or len(rows) != len(ids): raise AssertionError("Empty/duplicate operating catalog") + if group == "power/bucket": + if ids != {"power/bucket/" + name for name in ("startup", "crude_oil", "refined_oil")}: + raise AssertionError("Bucket test must contain all three exact native cases") + return ids + if group in ("endurance", "endurance-smoke"): + suffix = "30min" if group == "endurance" else "smoke" + if ids != {"steam/lifecycle/network-endurance-" + suffix}: + raise AssertionError("Endurance must contain exactly its declared finite active station") + return ids + groups = REQUIRED if group == "all" else {group: REQUIRED[group]} + for prefix, names in groups.items(): + missing = {prefix + "/" + name for name in names} - ids + if missing: raise AssertionError("Missing required operating families: " + str(sorted(missing))) + if group != "all" and any(not key.startswith(group + "/") for key in ids): + raise AssertionError("Operating result includes the wrong group") + return ids + + +def validate_operation(catalog, document, group): + expected = require_case_set(catalog, group) + rows = document.get("cases", []) + if len(rows) != len(expected) or {row["id"] for row in rows} != expected: + raise AssertionError("Incomplete operating result; a passing subset is forbidden") + if document.get("held") is not True: raise AssertionError("Final measurement is not quiescent") + failures = [] + for row in rows: + assertions = row.get("assertions", []) + measured = [check for check in assertions if check.get("phase") == "verify"] + if (row.get("status") != "PASS" or row.get("verifiedAssertions", 0) < 1 or not measured + or any(check.get("status") != "PASS" for check in assertions) + or row.get("finishTick", 0) - row.get("startTick", 0) < row.get("requiredTicks", 1) + or not row.get("before") or not row.get("after")): + failures.append({"id": row["id"], "failure": row.get("failure", "Missing/failed measured natural-tick outcome")}) + return dict(status="FAIL" if failures or document.get("status") != "PASS" else "PASS", cases=len(rows), failures=failures) + + +def normalized_rows(rows): + result = {} + for row in rows: + key = (row["x"], row["y"], row["z"]) + if key in result: raise AssertionError("Duplicate persisted position") + value = {name: row[name] for name in ("block", "state")} + if "nbt" in row: value["nbt"] = saved_tile_nbt(nbtlib.parse_nbt(row["nbt"])) + result[key] = value + return result + + +def typed_nbt(value): + if isinstance(value, nbtlib.Compound): return {key: typed_nbt(tag) for key, tag in value.items()} + if isinstance(value, nbtlib.List): return ["List", value.subtype.tag_id, [typed_nbt(tag) for tag in value]] + plain = value.unpack() + if hasattr(plain, "tolist"): plain = plain.tolist() + return [type(value).__name__, plain] + + +def saved_tile_nbt(value): + result = typed_nbt(value) + for key in ("Tank", "InputTank", "TankIn", "TankOut", "FuelTank", "WaterTank"): + tank = result.get(key) + if (isinstance(tank, dict) and set(tank) == {"Amount", "FluidName"} + and tank["Amount"] == ["Int", 0] and tank["FluidName"][0] == "String" and tank["FluidName"][1]): + # FluidTank.readFromNBT drops a zero-volume fluid identity. No material or filter is stored here. + result[key] = {"Empty": ["Byte", 1]} + return result + + +def validate_restart(fixture, inspected): + before = fixture.get("cases", []) + after = inspected.get("cases", []) + if (not before or len(after) != len(before) or {r["id"] for r in before} != {r["id"] for r in after} + or len({r["id"] for r in after}) != len(after) or inspected.get("held") is not True + or inspected.get("constructionCallsThisProcess") != 0): + raise AssertionError("Missing/rebuilt/unfrozen full-process restart evidence") + actual = {row["id"]: normalized_rows(row["positions"]) for row in after} + failures = [] + for row in before: + expected = normalized_rows(row["positions"]) + if actual[row["id"]] != expected: + changed = [dict(pos=key, expected=value, actual=actual[row["id"]].get(key)) for key, value in expected.items() + if actual[row["id"]].get(key) != value] + extra = set(actual[row["id"]]) - set(expected) + failures.append(dict(id=row["id"], differences=changed, extraPositions=sorted(extra))) + actual_case = next(case for case in after if case["id"] == row["id"]) + def actors(case): + rows = case.get("entities", []) + result = {actor["uuid"]: typed_nbt(nbtlib.parse_nbt(actor["nbt"])) if "nbt" in actor else actor for actor in rows} + if len(result) != len(rows): raise AssertionError("Duplicate persisted actor UUID") + return result + if actors(row) != actors(actual_case): failures.append(dict(id=row["id"], difference="Saved test actors changed across restart")) + return dict(status="FAIL" if failures else "PASS", cases=len(before), failures=failures, + comparison="Full block/state/typed-NBT equality including target-only keys; only exact zero-volume named tanks canonicalized; no expected-state writes") + + +def pin(args): + base = read_json(args.base) + destination = args.output.resolve() + if destination.exists(): raise FileExistsError(destination) + destination.mkdir(parents=True) + replacements = { + "poweradvantage": RIG.parent / "build/libs/PowerAdvantage-2.4.1.112021.jar", + "steamadvantage": RIG.parent.parent / "SteamAdvantage/SteamAdvantage/build/libs/SteamAdvantage-2.2.2.112021.jar", + "electricadvantage": RIG.parent.parent / "ElectricAdvantage/ElectricAdvantage/build/libs/ElectricAdvantage-2.2.3.112021.jar", + "advantageworkstest": RIG / "build/gradle/libs/AdvantageWorksTestHarness-1.1.0.112021.jar", + } + if getattr(args, "original_production", False): + replacements = {key: value for key, value in replacements.items() if key == "advantageworkstest"} + target = copy.deepcopy(base["target112"]) + for jar in target["jars"]: + source = replacements.get(jar["id"], Path(jar["path"])) + checksum = digest(source) + folder = destination / "artifacts" / checksum + folder.mkdir(parents=True, exist_ok=True) + path = folder / source.name + if path.exists(): raise FileExistsError(path) + shutil.copy2(source, path) + if digest(path) != checksum: raise AssertionError("Archive copy differs") + jar.update(path=str(path), originPath=str(source), sha256=checksum) + if jar["id"] in replacements: jar["sourceRef"] = "LOCAL_OPERATING_CANDIDATE_" + args.label + target["qualificationStatus"] = "UNQUALIFIED_OPERATING_CANDIDATE" + target["scope"] = args.label + target["serverCode"] = {"files": code_files(Path(target["seed"]))} + path = destination / "inputs.json" + write_json(path, {"target112": target, "baseManifest": str(args.base.resolve()), "baseSha256": digest(args.base)}) + pinned_profile(path, "target112") + print(str(path), flush=True) + return 0 + + +def run(args): + output = output_path(args.output) + target = pinned_profile(args.manifest.resolve(), "target112") + for protected in (args.manifest, target[0], *target[2]): require_disjoint(output, protected) + with socket.socket() as listener: listener.bind(("127.0.0.1", args.port)) + launcher = prepare(output, target, [], args.port) + expected_code = read_json(args.manifest)["target112"].get("serverCode") + if not expected_code: + raise AssertionError("Operating inputs require an exact server-code inventory") + verify_code(target[0], expected_code) + verify_code(output, expected_code) + write_json(output / INVENTORY, expected_code) + for marker in ("advantage-machine-operation.allow", "migration-operate.allow"): (output / marker).touch() + report = dict(status="RUNNING", group=args.group, manifest=str(args.manifest.resolve()), manifestSha256=digest(args.manifest), + startedAtUtc=datetime.now(timezone.utc).isoformat(), phases={}, + scope="Finite natural-tick machine jobs, negative controls, then frozen strict full-process restart; no rebuilding") + helpers = [Path(__file__), Path(__file__).with_name("native112_lifecycle.py"), + Path(__file__).with_name("rig_transport.py"), + Path(__file__).with_name("native112_diagnostics.py"), + Path(__file__).with_name("native112_code.py"), Path(__file__).with_name("native112.py")] + report["protocolSha256"] = {str(path): digest(path) for path in helpers} + for path in helpers: + destination = output / "protocol-sources" / path.name + destination.parent.mkdir(exist_ok=True) + shutil.copy2(path, destination) + server = None + try: + server = Server(output, args.java8, launcher, "operate") + server.send("gamerule doMobSpawning false") + server.send("gamerule randomTickSpeed 0") + server.send("gamerule doWeatherCycle false") + server.send("weather clear") + server.send("advmachines run " + args.group + " measured") + server.wait("ADVMACHINES COMPLETE measured", 2500 if args.group == "endurance" else 1500) + catalog = read_json(output / "advantage-works-results/machine-catalog-measured.json") + result = read_json(output / "advantage-works-results/machine-operation-measured.json") + report["phases"]["operation"] = validate_operation(catalog, result, args.group) + server.send("save-all flush") + server.close(); server = None + require_native_boot(output / "console-operate.log") + fixture = read_json(output / "machine-operation-fixture.json") + report["fixtureSha256"] = digest(output / "machine-operation-fixture.json") + # Keep the actual first-process save independently of subsequent restart writes. + shutil.copytree(output / "AdvantageWorksMigration", output / "pre-restart-save") + write_json(output / "native112-machine-operation-results.json", report) + server = Server(output, args.java8, launcher, "restart") + server.send("advmachines inspect restarted") + server.wait("ADVMACHINES INSPECTED restarted", 360) + observed = read_json(output / "advantage-works-results/machine-inspection-restarted.json") + report["phases"]["restart"] = validate_restart(fixture, observed) + server.send("save-all flush") + server.close(); server = None + require_native_boot(output / "console-restart.log") + if digest(output / "machine-operation-fixture.json") != report["fixtureSha256"]: raise AssertionError("Fixture authority changed across restart") + verify_staged(output, target) + report["status"] = "PASS" if all(row["status"] == "PASS" for row in report["phases"].values()) else "FAIL" + except BaseException as error: + report.update(status="FAIL", failure=str(error), traceback=traceback.format_exc()) + finally: + if server: + try: server.close() + except BaseException as error: report.update(status="FAIL", shutdownError=str(error)) + report["protocolUnchanged"] = {str(path): digest(path) for path in helpers} == report["protocolSha256"] + if not report["protocolUnchanged"]: report.update(status="FAIL", protocolError="Operating protocol changed during the run") + report["completedAtUtc"] = datetime.now(timezone.utc).isoformat() + write_json(output / "native112-machine-operation-results.json", report) + print(report["status"] + ": " + str(output), flush=True) + return int(report["status"] != "PASS") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + p = commands.add_parser("pin") + p.add_argument("--base", type=Path, required=True); p.add_argument("--output", type=Path, required=True) + p.add_argument("--label", required=True) + p.add_argument("--original-production", action="store_true", help="Keep the original three production jars; replace only the test harness") + r = commands.add_parser("run") + r.add_argument("--manifest", type=Path, required=True); r.add_argument("--output", type=Path, required=True) + r.add_argument("--java8", type=Path, required=True); r.add_argument("--port", type=int, required=True) + r.add_argument("--group", choices=("all", *REQUIRED, "power/bucket", "endurance", "endurance-smoke"), default="all") + args = parser.parse_args() + return pin(args) if args.command == "pin" else run(args) + + +if __name__ == "__main__": raise SystemExit(main()) diff --git a/test-rig/migration/native112_partial.py b/test-rig/migration/native112_partial.py new file mode 100644 index 0000000..9bcaf3e --- /dev/null +++ b/test-rig/migration/native112_partial.py @@ -0,0 +1,148 @@ +"""Explicit native 112 partial-network adapter; shared running protocols are never modified.""" +import argparse +import base64 +import gzip +import io +import json +import math +from datetime import datetime, timezone +from pathlib import Path +import traceback +import nbtlib + +import native112_machine_operations as operating +from rig_transport import digest, write_json + +REQUIRED = {"power/partial/" + kind + suffix + for kind in ("steam", "electric", "fluid") for suffix in ("", "-source", "-consumer")} + + +def validate_partial(catalog, measured, fixture, inspected): + def keyed(rows): + result = {row["id"]: row for row in rows} + if len(result) != len(rows): + raise AssertionError("Duplicate native case ID") + return result + catalogs = keyed(catalog["cases"]) + actual = keyed(measured["cases"]) + saved = keyed(fixture["cases"]) + cold = keyed(inspected["cases"]) + if not all(REQUIRED <= set(rows) for rows in (catalogs, actual, saved, cold)): + raise AssertionError("Missing required native partial-network case; a passing subset is forbidden") + if measured.get("held") is not True or inspected.get("held") is not True or inspected.get("constructionCallsThisProcess") != 0: + raise AssertionError("Missing untouched cold inspection") + results = [] + for identity in sorted(REQUIRED): + row = actual[identity] + if row.get("status") != "PASS" or row.get("partial.completed") is not True or row.get("partial.cleanupCalled") is not True: + raise AssertionError("Partial case did not complete: " + identity) + if row.get("partial.measuredTicks") != 1100 or row["finishTick"] - row["startTick"] != 1100: + raise AssertionError("Missing exact measured natural-tick window: " + identity) + if row.get("partial.absentTicks", -1) < 240: + raise AssertionError("No sustained real partial absence: " + identity) + events = row.get("partial.events", []) + unloads = [event for event in events if event.get("operation") == "unload"] + loads = [event for event in events if event.get("operation") == "load"] + if len(unloads) != 1 or len(loads) != 1 or loads[0].get("typedStateEqual") is not True: + raise AssertionError("Missing/duplicate native chunk events or changed typed reload state: " + identity) + selected = row["partial.selectedChunk"] + if any(event.get("chunk") != selected for event in unloads + loads): + raise AssertionError("Native events refer to another chunk") + restore = row["partial.restoreTick"] + if not unloads[0]["tick"] < restore <= loads[0]["tick"] < 1100: + raise AssertionError("Native release/recovery order is invalid: " + identity) + if restore - unloads[0]["tick"] < 240: + raise AssertionError("Native callback times do not prove the required absence duration") + if loads[0].get("explicitRecovery") is not True: + raise AssertionError("Automatic reload was accepted as explicit recovery") + if not unloads[0].get("networkBlocks") or unloads[0]["networkBlocks"] != loads[0].get("networkBlocks"): + raise AssertionError("Actual native bridge/endpoint block observations changed") + def typed(encoded): + return operating.typed_nbt(nbtlib.File.parse(io.BytesIO(gzip.decompress( + base64.b64decode(encoded, validate=True))))) + before_tags = {tuple(tag["pos"]): typed(tag["observedNbtBase64"]) for tag in unloads[0].get("unloadState", [])} + after_tags = {tuple(tag["pos"]): typed(tag["observedNbtBase64"]) for tag in loads[0].get("unloadState", [])} + if before_tags != after_tags or (identity.endswith(("-source", "-consumer")) and len(before_tags) != 1): + raise AssertionError("Native callback typed NBT changed or was missing") + absent = [sample for sample in row.get("partial.observations", []) + if sample.get("selectedPresent") is False and sample.get("explicitRecovery") is False] + if not absent or max(p["tick"] for p in absent) - min(p["tick"] for p in absent) < 220: + raise AssertionError("Missing independent sustained-absence snapshots") + if any(sample.get("retainedLoaded") is not True for sample in row.get("partial.observations", [])): + raise AssertionError("Retained endpoint did not remain loaded") + if any(event.get("failure") for event in events): + raise AssertionError("Native callback failure was concealed") + if identity.endswith("-source"): + if row.get("partial.isolationApplicable") is not False: + raise AssertionError("Source-unloaded case must not claim a live-source witness") + else: + if row.get("partial.isolationApplicable") is not True: + raise AssertionError("Missing live-source isolation witness") + samples = row.get("partial.isolationSamples") + if not isinstance(samples, list) or len(samples) < 221: + raise AssertionError("Missing at least 220 stable source-isolation ticks") + ticks = [sample.get("tick") for sample in samples] + if (any(type(tick) is not int for tick in ticks) or not 0 <= ticks[0] < ticks[-1] <= 1100 + or ticks != list(range(ticks[0], ticks[-1] + 1))): + raise AssertionError("Source-isolation observations skip or duplicate native ticks") + if not unloads[0]["tick"] <= ticks[0] <= unloads[0]["tick"] + 1 or ticks[-1] != restore: + raise AssertionError("Isolation must cover the absent interval with at most one unload-phase grace tick") + kind = identity.split("/")[-1].split("-")[0] + anchor = samples[0].get("source") + if type(anchor) not in (int, float) or not math.isfinite(anchor) or anchor < 0: + raise AssertionError("Invalid measured source-isolation anchor") + for sample, tick in zip(samples, ticks): + source = sample.get("source") + expected_source = anchor - (tick - ticks[0]) * (0.0625 if kind == "steam" else 0) + if (sample.get("sourceLoaded") is not True or sample.get("selectedAbsent") is not True + or type(source) not in (int, float) or not math.isfinite(source) or source != expected_source + or sample.get("expectedSource") != expected_source): + raise AssertionError("Ghost flow crossed the missing bridge/consumer: " + identity) + if (row.get("partial.isolationAnchorTick") != ticks[0] + or row.get("partial.isolationLastTick") != ticks[-1] + or row.get("partial.isolationAnchorSource") != anchor + or row.get("partial.isolationLastSource") != samples[-1]["source"] + or row.get("partial.isolationStableTicks") != ticks[-1] - ticks[0] + or ticks[-1] - ticks[0] < 220): + raise AssertionError("Isolation summary does not match actual quantitative samples") + results.append({"id": identity, "status": "PASS", "absentTicks": row["partial.absentTicks"], + "restoreTick": restore, "unloadTick": unloads[0]["tick"], "loadTick": loads[0]["tick"]}) + return {"status": "PASS", "cases": results} + + +def run(args): + own = Path(__file__).resolve() + own_hash = digest(own) + args.group = "power" + result = operating.run(args) + root = args.output.resolve() + base_path = root / "native112-machine-operation-results.json" + report = {"status": "FAIL", "wrapperSha256": own_hash, "baseReportSha256": digest(base_path), + "completedAtUtc": datetime.now(timezone.utc).isoformat()} + try: + base = json.loads(base_path.read_text()) + if result != 0 or base.get("status") != "PASS" or base.get("protocolUnchanged") is not True: + raise AssertionError("Underlying strict native operating/cold campaign failed") + reports = root / "advantage-works-results" + def read(path): return json.loads(path.read_text()) + report.update(validate_partial(read(reports / "machine-catalog-measured.json"), + read(reports / "machine-operation-measured.json"), read(root / "machine-operation-fixture.json"), + read(reports / "machine-inspection-restarted.json"))) + report["evidenceSha256"] = {str(path.relative_to(root)): digest(path) for path in ( + base_path, reports / "machine-catalog-measured.json", reports / "machine-operation-measured.json", + root / "machine-operation-fixture.json", reports / "machine-inspection-restarted.json")} + except Exception as error: + report.update(status="FAIL", failure=str(error), traceback=traceback.format_exc()) + if digest(own) != own_hash: + report.update(status="FAIL", failure="Partial adapter changed during its run") + write_json(root / "native112-partial-results.json", report) + return int(report["status"] != "PASS") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--manifest", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--java8", type=Path, required=True) + parser.add_argument("--port", type=int, required=True) + raise SystemExit(run(parser.parse_args())) diff --git a/test-rig/migration/native112_recipe_progression.py b/test-rig/migration/native112_recipe_progression.py new file mode 100644 index 0000000..4105699 --- /dev/null +++ b/test-rig/migration/native112_recipe_progression.py @@ -0,0 +1,226 @@ +"""Bounded native legacy recipe audit; does NOT qualify live machine production.""" + +import argparse +from datetime import datetime, timezone +import json +from pathlib import Path +import shutil +import socket +import traceback + +from rig_transport import digest, write_json +from native112 import output_path, pinned_profile, require_disjoint +from native112_code import INVENTORY, Server, verify_code +from native112_lifecycle import prepare, read_json, require_native_boot, verify_staged + +MODES=("NORMAL","TECH_PROGRESSION","APOCALYPTIC") +MC="1.12.2" +RAW=("log","sapling","cobblestone","coal","iron_ore","gold_ore","sand","clay_ball","gravel","redstone", + "diamond","emerald","obsidian","potato","poisonous_potato","wheat","reeds","gunpowder","flint", + "leather","string","feather","blaze_rod","ender_pearl","quartz","bone","cactus","dirt") +ORE_ROOTS=("oreCopper","oreTin","oreZinc","oreLead","oreNickel","oreSilver","oreLithium","oreSulfur") +EXCLUSIONS={ + "poweradvantage:infinite_steam@0":"Creative-only source", + "poweradvantage:infinite_electricity@0":"Creative-only source", + "poweradvantage:infinite_quantum@0":"Creative-only source", + "poweradvantage:crude_oil@0":"World fluid identity; natural collection is outside this audit", + "poweradvantage:refined_oil@0":"World fluid identity; live distillation is outside this audit", + "poweradvantage:fluid_pipe_terminal@0":"Hidden ordinary-pipe state", + "steamadvantage:drillbit@0":"Drill-created helper", + "steamadvantage:platform@0":"Elevator-created helper", + "steamadvantage:pump_pipe_steam@0":"Pump-created helper", + "electricadvantage:pump_pipe_electric@0":"Pump-created helper", + "electricadvantage:laser_turret_evil@0":"Hidden converted turret state", +} +CORE={"poweradvantage:sprocket@0","poweradvantage:fluid_pipe@0","steamadvantage:steam_governor@0", + "steamadvantage:steam_pipe@0","electricadvantage:integrated_circuit@0","electricadvantage:psu@0", + "electricadvantage:electric_conduit@0","electricadvantage:photovoltaic_generator@0"} + + +def mode_checks(doc,mode,rubber): + if (doc.get("minecraft")!=MC or doc.get("mode")!=mode or doc.get("rubber") is not rubber + or doc.get("status")!="EXPORTED_NOT_QUALIFIED" or not doc.get("crafting") or not doc.get("targets")): + raise AssertionError("Wrong engine/mode/configuration or empty native recipe audit") + if not CORE <= set(doc["targets"]): raise AssertionError("Critical registered components are missing") + if not doc.get("smelting") or not doc.get("crushing") or doc.get("crusherRegistry") in (None,"MISSING") or doc.get("crusherLookupError"): + raise AssertionError("Registered smelting/crusher functions are missing or unsupported") + crafting=doc["crafting"] + ids=[r["id"] for r in crafting] + if len(ids)!=len(set(ids)): raise AssertionError("Duplicate native recipe evidence") + for row in crafting: + owned=isinstance(row.get("output"),str) and row["output"].startswith(("poweradvantage:","steamadvantage:","electricadvantage:")) + if row.get("owned") is not owned: raise AssertionError("Native recipe ownership evidence is missing or inconsistent") + if row.get("status")=="PASS" and (row.get("negativeControlsRejected")!=2 + or type(row.get("count")) is not int or row["count"]<=0 or not row.get("output") + or not isinstance(row.get("inputs"),list) or not any(row["inputs"])): + raise AssertionError("Incomplete native match/negative-control evidence") + if row.get("status")=="PASS" and row.get("owned") is True and row.get("consumption")!="NATIVE_SLOT_CRAFTING": + raise AssertionError("Owned craft lacks actual native input consumption") + if row.get("status")=="PASS" and row.get("owned") is True and (row.get("actualCrafted")!=row["output"] or type(row.get("actualCraftedCount")) is not int or row["actualCraftedCount"]!=row["count"]): + raise AssertionError("Native crafted output identity/count was not verified") + if row.get("status")=="PASS" and row.get("owned") is True and row.get("recipeBookCraftUnlock")!="PASS": + raise AssertionError("Missing native recipe-book result or explicit legacy N/A") + def rows(output): return [r for r in crafting if r.get("output")==output] + for target in ("poweradvantage:sprocket@0","electricadvantage:integrated_circuit@0","electricadvantage:photovoltaic_generator@0"): + if bool(rows(target)) != (mode!="APOCALYPTIC"): raise AssertionError("Recipe mode restriction changed: "+target) + for target in ("steamadvantage:steam_governor@0","electricadvantage:psu@0"): + if not rows(target): raise AssertionError("Mode lost a foundational recipe: "+target) + iron=set(doc.get("ores",{}).get("ingotIron",[])) + pipes=[r for r in rows("poweradvantage:fluid_pipe@0") + if len([x for x in r.get("inputs",[]) if x])==6 + and all(x in iron for x in r["inputs"] if x)] + if not pipes or any(r["count"]!=(3 if mode=="APOCALYPTIC" else 6) for r in pipes): + raise AssertionError("Native iron-pipe yield changed") + chips=rows("electricadvantage:integrated_circuit@0") + # Forge may expand a wildcard ore entry into concrete matching-stack variants. + def matches_provider(value,choices): + return (isinstance(value,str) and "@" in value and isinstance(choices,list) + and any(value==choice or value.rsplit("@",1)[0]+"@32767"==choice for choice in choices)) + def matches_slot(value,choices,ore): + entries=doc.get("ores",{}).get(ore,[]) + return (isinstance(choices,list) and bool(choices) + and matches_provider(value,entries) and matches_provider(value,choices) + and all(matches_provider(choice,entries) for choice in choices)) + prefix=["plastic","dustRedstone","plastic"] if mode=="TECH_PROGRESSION" else [] + for chip in chips: + inputs=chip.get("inputs",[]); providers=chip.get("providers",[]) + if (type(chip.get("count")) is not int or chip["count"]!=3 + or not isinstance(inputs,list) or len(inputs)!=len(prefix)+6): + raise AssertionError("Microchip mode ingredients or three-chip yield changed") + if not isinstance(providers,list) or len(providers)!=len(inputs): + raise AssertionError("Microchip native ingredient providers are missing") + if not any(all(matches_slot(value,choices,ore) for value,choices,ore in + zip(inputs,providers,prefix+["ingotSilicon"]*3+[metal]*3)) + for metal in ("nuggetCopper","nuggetTin","nuggetGold")): + raise AssertionError("Microchip ingredients disagree with native ore providers") + + +def reachability(doc): + # Availability closure only: renewable raw access, not finite resource quantities. + registered=set(doc["targets"]) | set(doc.get("blockItems",[])) + known={"minecraft:"+name+"@0":{"via":"declared_raw_resource","inputs":[]} for name in RAW} + for name in ORE_ROOTS: + for token in doc.get("ores",{}).get(name,[]): + normalized=token.replace("@32767","@0") + if normalized in set(doc.get("blockItems",[])): known[normalized]={"via":"declared_natural_ore:"+name,"inputs":[]} + for loot in doc.get("sampledNativeLoot",[]): + if doc["mode"]=="APOCALYPTIC" and loot.get("item") in set(doc["targets"]): + known.setdefault(loot["item"],{"via":"sampled_native_chest_loot","table":loot.get("table"),"seed":loot.get("seed"),"inputs":[]}) + changed=True; rounds=0 + while changed: + changed=False; rounds+=1 + if rounds>100: raise AssertionError("Recipe availability closure failed to converge") + for row in doc["crafting"]: + inputs=[x for x in row.get("inputs",[]) if x] + if row.get("status")=="PASS" and inputs and all(x in known for x in inputs) and row["output"] not in known: + known[row["output"]]={"via":row["id"],"inputs":inputs}; changed=True + if "minecraft:furnace@0" in known and any(x in known for x in ("minecraft:coal@0","minecraft:coal@1")): + for edge in doc.get("smelting",[]): + if edge["input"] in known and edge["output"] not in known: + known[edge["output"]]={"via":"registered_furnace_function","inputs":[edge["input"],"minecraft:furnace@0"]}; changed=True + for edge in doc.get("crushing",[]): + tools=[x for x in edge.get("tools",[]) if x in known] + if edge["input"] in known and tools and edge["output"] not in known: + known[edge["output"]]={"via":"registered_crusher_function","inputs":[edge["input"],tools[0]]}; changed=True + blocked=[]; excluded={} + for target in doc["targets"]: + if target in EXCLUSIONS: excluded[target]=EXCLUSIONS[target] + elif doc["mode"]=="APOCALYPTIC" and target=="electricadvantage:photovoltaic_generator@0": + excluded[target]="Explicit legacy Apocalyptic restriction" + elif target not in known: blocked.append(target) + return {"reachable":known,"blocked":sorted(blocked),"excluded":excluded,"rounds":rounds, + "unverifiedNativeRecipes":[{"id":r["id"],"output":r.get("output"),"reason":r.get("reason")} + for r in doc["crafting"] if r.get("owned") and r.get("status")!="PASS"], + "scope":"AVAILABLE_TYPES_FROM_CONCRETE_NATIVE_RECIPE_WITNESSES; NOT_FINITE_MATERIAL_CONSERVATION", + "machineProduction":"NOT_TESTED", + "practicalGaps":["Bucket/world-fluid collection is not credited as a free input", + "Refinery and other non-registry machine paths require operating evidence", + "Apocalyptic parts require actual sampled loot; generated ALT config is not loot proof", + "Only the concrete ingredient alternatives actually exercised are credited", + "Recipe-book discovery, shift-crafting and player GUI interactions are outside this audit"]} + + +def canonical(doc): + value={k:doc[k] for k in ("minecraft","mode","rubber","crafting","targets","ores","smelting","crushing","sampledNativeLoot")} + for key in ("crafting","targets","smelting","crushing","sampledNativeLoot"): + value[key]=sorted(value[key],key=lambda x:json.dumps(x,sort_keys=True)) + return value + + +def run(args): + root=output_path(args.output) + target=pinned_profile(args.target_manifest.resolve(),"target112") + for protected in (args.target_manifest,target[0],*target[2]): require_disjoint(root,protected) + code=read_json(args.target_manifest)["target112"].get("serverCode") + if not code: raise AssertionError("Recipe audit requires an exact native engine-code inventory") + verify_code(target[0],code) + with socket.socket() as check: check.bind(("127.0.0.1",args.port)) + root.mkdir(parents=True) + helpers=[Path(__file__),*[Path(__file__).with_name(name) for name in + ("rig_transport.py","native112.py","native112_code.py","native112_lifecycle.py","native112_diagnostics.py")], + Path(__file__).parents[1]/"src/main/java/zone/moddev/mc/advantageworks/NativeRecipeProgressionProbe.java"] + seals={str(p):digest(p) for p in helpers} + for path in helpers: + destination=root/"protocol-sources"/path.name + destination.parent.mkdir(exist_ok=True) + shutil.copy2(path,destination) + report={"status":"RUNNING","auditStatus":"RUNNING","completeProgression":"PARTIAL","startedAtUtc":datetime.now(timezone.utc).isoformat(),"modes":{}, + "scope":"BOUNDED_NATIVE_CRAFTING_AND_TYPE_AVAILABILITY_AUDIT","machineProduction":"NOT_TESTED", + "manifest":str(args.target_manifest.resolve()),"manifestSha256":digest(args.target_manifest),"protocolSha256":seals} + server=None + try: + for mode in ([args.mode] if args.mode else MODES): + runtime=root/mode.lower() + launcher=prepare(runtime,target,[],args.port) + verify_code(runtime,code); write_json(runtime/INVENTORY,code) + (runtime/"advantage-legacy-recipes.allow").touch() + config=runtime/"config/poweradvantage.cfg"; config.parent.mkdir(exist_ok=True) + config.write_text('options {\n S:recipe_mode='+mode+'\n B:plastic_equals_rubber='+str(not args.no_rubber).lower()+'\n}\n',encoding="utf-8") + proofs={} + for phase in ("initial","restart"): + verify_staged(runtime,target) + label=mode.lower()+"-"+phase + server=Server(runtime,args.java8,launcher,label) + server.send("advlegacyrecipes "+label) + server.wait("ADVLEGACYRECIPES COMPLETE "+label,180) + doc=read_json(runtime/"advantage-works-results"/("legacy-recipes-"+label+".json")) + server.send("save-all flush"); server.close(); server=None + require_native_boot(runtime/("console-"+label+".log")); verify_staged(runtime,target) + audit_error=None + try: + mode_checks(doc,mode,not args.no_rubber) + if any(row.get("owned") and row.get("status")!="PASS" for row in doc["crafting"]): + raise AssertionError("Some active owned recipes lack passing native craft/consumption evidence") + except AssertionError as error: audit_error=str(error) + proofs[phase]={"native":doc,"availability":reachability(doc), + "auditStatus":"FAIL" if audit_error else "PASS","auditError":audit_error} + if canonical(proofs["initial"]["native"])!=canonical(proofs["restart"]["native"]): + raise AssertionError("Registered recipe/provider semantics changed across restart") + report["modes"][mode]=proofs + # Never label this narrower recipe audit as complete machine qualification. + report["auditStatus"]="FAIL" if any(phase["auditStatus"]!="PASS" for mode in report["modes"].values() for phase in mode.values()) else "PASS" + report["status"]="FAIL" if report["auditStatus"]=="FAIL" else "PARTIAL" + except Exception as error: + report.update(status="FAIL",auditStatus="FAIL",failure=str(error),traceback=traceback.format_exc()) + finally: + if server: + try: server.close() + except Exception as error: report.update(status="FAIL",shutdownError=str(error)) + report["protocolUnchanged"]=seals=={str(p):digest(p) for p in helpers} + report["manifestUnchanged"]=report["manifestSha256"]==digest(args.target_manifest) + if not report["protocolUnchanged"] or not report["manifestUnchanged"]: report.update(status="FAIL",auditStatus="FAIL") + report["completedAtUtc"]=datetime.now(timezone.utc).isoformat() + write_json(root/"native112-recipe-audit-results.json",report) + print("LEGACY_RECIPE_AUDIT auditStatus="+report["auditStatus"]+" completeProgression="+report["completeProgression"],flush=True) + return 1 if report["status"]=="FAIL" else 2 if report["status"]=="PARTIAL" else 0 + + +if __name__=="__main__": + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument("--target-manifest",type=Path,required=True) + parser.add_argument("--java8",type=Path,required=True) + parser.add_argument("--output",type=Path,required=True) + parser.add_argument("--port",type=int,default=25597) + parser.add_argument("--mode",choices=MODES) + parser.add_argument("--no-rubber",action="store_true") + raise SystemExit(run(parser.parse_args())) diff --git a/test-rig/migration/rig_transport.py b/test-rig/migration/rig_transport.py new file mode 100644 index 0000000..1615bc8 --- /dev/null +++ b/test-rig/migration/rig_transport.py @@ -0,0 +1,115 @@ +"""Disposable-world transport shared with the archived migration harness. + +No platform defaults or historical profile discovery are exported here. +""" +import hashlib +import json +from pathlib import Path +import queue +import shutil +import subprocess +import threading +import time + +WORLD = "AdvantageWorksMigration" + + +def digest(path): + return hashlib.sha256(path.read_bytes()).hexdigest().upper() + + +def fingerprint(root): + return {str(p.relative_to(root)): digest(p) for p in sorted(root.rglob("*")) if p.is_file()} + + +def write_json(path, value): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value, indent=2, sort_keys=True), encoding="utf-8") + + +class Server: + def __init__(self, runtime, java, launcher, label): + self.lines = queue.Queue() + self.log = (runtime / f"console-{label}.log").open("w", encoding="utf-8") + self.process = subprocess.Popen( + [str(java), "-Xms512M", "-Xmx2G", "-Dfml.queryResult=confirm", "-jar", launcher, "nogui"], + cwd=runtime, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, encoding="utf-8", errors="replace", creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) + self.thread = threading.Thread(target=self.read, daemon=True) + self.thread.start() + try: + self.wait("Done (", 300) + except BaseException: + self.close() + raise + + def read(self): + for line in self.process.stdout: + self.log.write(line) + self.log.flush() + self.lines.put(line) + + def wait(self, token, timeout=60): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + line = self.lines.get(timeout=0.5) + if token in line: + return + if any(failure in line for failure in ("Migration probe failed", "Actor probe failed", "Recipe probe failed", "Connected probe failed", "ADVRECIPES FAIL", "ADVPROGRESSION FAIL")): + raise RuntimeError(line.strip()) + except queue.Empty: + if self.process.poll() is not None: + raise RuntimeError(f"Server exited ({self.process.returncode}); see {self.log.name}") + raise TimeoutError(f"Waiting for {token!r}; see {self.log.name}") + + def send(self, command): + self.process.stdin.write(command + "\n") + self.process.stdin.flush() + + def snapshot(self, label, build=False): + self.send(f"advprobe {'build' if build else 'snapshot'} {label}") + self.wait("ADVPROBE COMPLETE", 120) + + def close(self): + if self.process.poll() is None: + try: + self.send("stop") + self.process.wait(timeout=90) + except (OSError, subprocess.TimeoutExpired): + self.process.kill() + self.process.wait(timeout=15) + self.thread.join(timeout=5) + self.log.close() + if self.process.returncode != 0: + raise RuntimeError(f"Unclean shutdown ({self.process.returncode}): {self.log.name}") + + +def stage(runtime, profile): + seed, launcher, mods = profile + runtime.mkdir(parents=True, exist_ok=False) + shutil.copytree(seed / "libraries", runtime / "libraries") + for p in seed.glob("*.jar"): + # Preserve the complete sealed root-jar inventory; only the pinned launcher is executed. + shutil.copy2(p, runtime / p.name) + (runtime / "mods").mkdir() + identities = [] + for jar in mods: + shutil.copy2(jar, runtime / "mods" / jar.name) + identities.append({"file": jar.name, "source": str(jar), "sha256": digest(jar)}) + write_json(runtime / "input-jars.json", identities) + (runtime / "eula.txt").write_text("eula=true\n") + (runtime / "advantage-migration.allow").touch() + (runtime / "server.properties").write_text( + f"level-name={WORLD}\nlevel-type=FLAT\nonline-mode=false\nserver-ip=127.0.0.1\nserver-port=25584\n" + "spawn-protection=0\nview-distance=4\nmax-tick-time=60000\nallow-nether=false\n" + "generator-settings=3;minecraft:bedrock,2*minecraft:dirt,minecraft:grass;1;\n" + ) + return launcher + + +def copy_fixture(source, destination): + shutil.copytree(source / WORLD, destination / WORLD) + shutil.copy2(source / "migration-fixture.json", destination / "migration-fixture.json") + if (source / "config").exists(): shutil.copytree(source / "config", destination / "config") diff --git a/test-rig/migration/semantic_machine_audit.py b/test-rig/migration/semantic_machine_audit.py new file mode 100644 index 0000000..535628c --- /dev/null +++ b/test-rig/migration/semantic_machine_audit.py @@ -0,0 +1,305 @@ +"""Independent, read-only machine evidence checks; never starts or edits Minecraft. + +Historical oracles stay frozen. This second opinion consumes their raw snapshots, +not their PASS labels. Progress counters alone are not completed mining work. +""" + +import argparse +import base64 +from collections import Counter +import gzip +import hashlib +import io +import json +import math +from pathlib import Path +import struct + +import nbtlib + + +PRODUCTS = { + "C-CONVEYOR": ("minecraft:dirt", "minecraft:dirt", 1728), + "C-STEAM": ("minecraft:iron_ore", "minecraft:iron_ingot", 192), + "C-ELECTRIC": ("minecraft:iron_ore", "minecraft:iron_ingot", 64), +} +DRILLS = {"C-SDRILL": "minecraft:gravel", "C-EDRILL": "minecraft:cobblestone"} +# Commissioning batch: four inputs must become four named products. These +# expectations do not import the implementation or the historical batch oracle. +BATCH_MACHINES = ( + ("S-04", (3, 1, 7), ((2, 1, 7), (4, 1, 7)), "steamadvantage:steam_furnace", "iron_ingot", True), + ("S-04", (3, 1, 13), ((2, 1, 13), (4, 1, 13)), "steamadvantage:steam_crusher", "gravel", False), + ("E-03", (6, 1, 6), (), "electricadvantage:electric_furnace", "iron_ingot", False), + ("E-03", (3, 1, 6), (), "electricadvantage:electric_crusher", "gravel", False), + ("E-03", (9, 1, 6), (), "electricadvantage:electric_oven", "cooked_beef", False), +) + + +def unpack(entry): + if "nbtBase64" in entry: + value = nbtlib.File.parse(io.BytesIO(gzip.decompress(base64.b64decode(entry["nbtBase64"], validate=True)))) + elif "nbt" in entry: + value = nbtlib.parse_nbt(entry["nbt"]) + else: + return {} + return value.unpack() + + +def observed(document, definition): + expected = {entry["id"]: entry for entry in definition["blocks"]} + rows = document["snapshot"] + if not rows or len(rows) != len(expected) or {row["id"] for row in rows} != set(expected): + raise ValueError("Missing, duplicate or extra snapshot witnesses") + inventories, water, tiles, targets = {}, Counter(), {}, Counter() + for row in rows: + witness = expected[row["id"]] + if any(row.get(key) != witness.get(key) for key in ("station", "kind", "name", "x", "y", "z")): + raise ValueError("Changed witness identity: " + row["id"]) + target = row["kind"].startswith("target-") + if target: + allowed = {row["name"], "minecraft:air"} + if row["station"] == "C-SDRILL": allowed.add("steamadvantage:drillbit") + if row.get("actualName") not in allowed: + raise ValueError("Target replaced by an unrelated block: " + row["id"]) + if row["actualName"] != row["name"]: targets[row["station"]] += 1 + # A newly created drill-bit tile is a legitimate mining product, + # not an original machine whose object/witness must be retained. + inventories[row["id"]] = Counter() + continue + elif row.get("actualName") != row["name"]: + raise ValueError("Fixture block lost/replaced: " + row["id"]) + data = unpack(row) + if "nbtBase64" in row or "nbt" in row: + if row.get("witness") != row["id"]: + raise ValueError("Tile witness lost: " + row["id"]) + tiles[row["id"]] = (row, data) + counts, slots = Counter(), set() + for stack in data.get("Items", []): + slot, count = stack.get("Slot"), stack.get("Count") + if slot is None or slot in slots or not isinstance(count, int) or not 1 <= count <= 64: + raise ValueError("Invalid stack quantity/slot: " + row["id"]) + slots.add(slot) + counts[stack["id"]] += count + if stack["id"] == "electricadvantage:lithium_battery": + charge = stack.get("tag", {}).get("energy") + if charge is None or not math.isfinite(charge) or not 0 <= charge <= 8000 or count != 1: + raise ValueError("Invalid/missing battery charge: " + row["id"]) + inventories[row["id"]] = counts + for bits in data.get("Energy", []): + energy = struct.unpack(">f", struct.pack(">I", int(bits) & 0xffffffff))[0] + if not math.isfinite(energy) or energy < 0: + raise ValueError("Invalid machine energy: " + row["id"]) + if row["kind"].startswith("water-") and not row["kind"].startswith("water-pipe-") and "Tank" not in data: + raise ValueError("Missing water tank evidence: " + row["id"]) + if "Tank" in data: + tank = data["Tank"] + if tank.get("Empty") == 1 and "Amount" not in tank and "FluidName" not in tank: + continue + amount = tank.get("Amount") + if not isinstance(amount, int) or amount < 0 or tank.get("FluidName") not in ("water", "minecraft:water"): + raise ValueError("Malformed or wrong-fluid tank: " + row["id"]) + water[row["station"]] += amount + station_counts = {} + for row in rows: + station_counts.setdefault(row["station"], Counter()).update(inventories[row["id"]]) + permitted = { + "C-CONVEYOR": {"minecraft:dirt"}, + "C-STEAM": {"minecraft:iron_ore", "minecraft:iron_ingot", "minecraft:cobblestone", "minecraft:gravel", "minecraft:coal"}, + "C-ELECTRIC": {"minecraft:iron_ore", "minecraft:iron_ingot", "minecraft:coal", "electricadvantage:lithium_battery"}, + "C-SDRILL": {"minecraft:gravel", "minecraft:coal"}, + "C-EDRILL": {"minecraft:cobblestone", "minecraft:coal"}, + } + for station, counts in station_counts.items(): + if set(counts) - permitted[station]: raise ValueError("Unexpected items in " + station) + products = {} + for station, (raw, product, total) in PRODUCTS.items(): + counts = station_counts[station] + if (counts[raw] if raw == product else counts[raw] + counts[product]) != total: + raise ValueError("Item conservation failed: " + station) + products[station] = inventories[station + "-output"][product] if raw == product else counts[product] + counts = station_counts["C-STEAM"] + if counts["minecraft:cobblestone"] + counts["minecraft:gravel"] != 64: + raise ValueError("Crusher input/output conservation failed") + products["C-CRUSHER"] = counts["minecraft:gravel"] + for station, item in DRILLS.items(): + drops = inventories[station + "-drill"] + inventories[station + "-rear"] + if set(drops) - {item} or drops[item] != targets[station]: + raise ValueError("Drill targets and exact product yield disagree: " + station) + products[station] = drops[item] + return dict(products=products, water=water, inventories=station_counts, tiles=tiles) + + +def connected_interval(before, after, definition, ticks): + failures, incomplete, deltas = [], [], {} + try: + if not isinstance(ticks, int) or isinstance(ticks, bool) or ticks <= 0: + raise ValueError("No positive operating interval") + for document in (before, after): + if document.get("held") is not True or document.get("buildCallsThisProcess") != 0: + raise ValueError("Missing freeze/no-rebuild proof") + if after.get("requestedTicks") != ticks or after.get("advancedTicks") != ticks: + raise ValueError("Incomplete measured tick interval") + if after.get("gameTime", 0) <= before.get("gameTime", 0): + raise ValueError("World time did not advance") + old, new = observed(before, definition), observed(after, definition) + for station, count in new["water"].items(): + if count > old["water"].get(station, 0): raise ValueError("Finite water created: " + station) + for station, counts in new["inventories"].items(): + if counts["minecraft:coal"] > old["inventories"][station]["minecraft:coal"]: + raise ValueError("Fuel created: " + station) + for key, (row, _) in old["tiles"].items(): + current = new["tiles"].get(key) + if current is None or row.get("objectIdentity") is None or current[0].get("objectIdentity") != row["objectIdentity"]: + raise ValueError("Tile replaced/missing within measured interval: " + key) + totals = {station: total for station, (_, _, total) in PRODUCTS.items()} + totals["C-CRUSHER"] = 64 + totals.update({station: sum(row["station"] == station and row["kind"].startswith("target-") + for row in definition["blocks"]) for station in DRILLS}) + for station, previous in old["products"].items(): + delta = new["products"][station] - previous + deltas[station] = delta + if delta < 0: raise ValueError("Previously completed output lost: " + station) + if previous < totals[station] and delta == 0: + if station in DRILLS: + incomplete.append(station + ": no completed block/drop; progress counters are diagnostic only") + else: + raise ValueError("No completed work: " + station) + except (KeyError, TypeError, ValueError, OverflowError) as error: + failures.append(str(error)) + return dict(status="FAIL" if failures else "INCOMPLETE" if incomplete else "PASS", + failures=failures, incomplete=incomplete, completedOutputDeltas=deltas) + + +def audit_connected(root, ticks=512): + routes = sorted(root.glob("route-*/target116")) + if len(routes) != 15: raise ValueError("Expected all 15 native connected routes") + results, hashes = {}, {} + for route in routes: + definition_path = route / "connected-fixture.json" + definition = json.loads(definition_path.read_text()) + hashes[str(definition_path)] = hashlib.sha256(definition_path.read_bytes()).hexdigest().upper() + for phase in ("converted", "restarted"): + paths = [route / f"connected-{phase}-{state}.json" for state in ("loaded", "worked")] + for path in paths: hashes[str(path)] = hashlib.sha256(path.read_bytes()).hexdigest().upper() + before, after = [json.loads(path.read_text()) for path in paths] + results[route.parent.name + "/" + phase] = connected_interval(before, after, definition, ticks) + counts = Counter(result["status"] for result in results.values()) + return dict(scope="Read-only semantic recheck of recorded connected snapshots; not a new Minecraft run", ticks=ticks, + results=results, counts=dict(counts), inputHashes=hashes, + status="FAIL" if counts["FAIL"] else "INCOMPLETE" if counts["INCOMPLETE"] else "PASS") + + +def endurance_batch(documents, definitions): + """Check measured endpoint facts, not the ledger's asserted output counts.""" + failures, frames = [], {} + try: + for station, document in documents.items(): + if document.get("station") != station or not document.get("snapshot"): + raise ValueError("Missing or wrong station snapshot: " + station) + frame = {} + for row in document["snapshot"]: + key = tuple(row["pos"]) + if key in frame: raise ValueError("Duplicate position: " + station) + frame[key] = (row, unpack(row)) + frames[station] = frame + + def at(station, relative): + absolute = tuple(a + b for a, b in zip(definitions[station]["origin"], relative)) + if absolute not in frames[station]: raise ValueError("Unobserved position: " + str((station, relative))) + row, data = frames[station][absolute] + if "nbt" not in row: raise ValueError("Missing tile evidence: " + str((station, relative))) + return row, data + + def contents(station, relative): + _, data = at(station, relative) + counts, slots = Counter(), set() + if "Items" not in data: raise ValueError("Missing inventory evidence") + for stack in data["Items"]: + if stack.get("Slot") in slots or "Slot" not in stack or not 1 <= stack.get("Count", 0) <= 64: + raise ValueError("Malformed inventory evidence") + slots.add(stack["Slot"]) + counts[stack["id"]] += stack["Count"] + return counts + + for station, pos, neighbours, block, product, fuel in BATCH_MACHINES: + if at(station, pos)[0]["block"] != block: raise ValueError("Wrong processing machine") + counts = sum((contents(station, p) for p in (pos, *neighbours)), Counter()) + if fuel: counts.pop("minecraft:coal", None) + if counts != {"minecraft:" + product: 4}: raise ValueError("Incorrect exact processing yield: " + block) + for pos in ((2, 1, 6), (3, 1, 6), (4, 1, 6)): + if contents("W-04", pos): raise ValueError("Conveyor has not emptied") + if contents("W-04", (5, 1, 6)) != {"minecraft:dirt": 16}: raise ValueError("Conveyor product not delivered") + ghosts = {9: {1: ("minecraft:dirt", 1), 2: ("minecraft:cobblestone", 1), 3: ("minecraft:coal", 1)}, + 15: {}, 18: {1: ("minecraft:dirt", 1)}} + for z in (9, 15, 18): + if contents("W-04", (2, 1, z)): raise ValueError("Filter source not exhausted") + # Reference slots are not throughput. They must nevertheless be + # exactly retained, with no extra hidden buffered/ghost inventory. + stacks = at("W-04", (3, 1, z))[1]["Items"] + contents("W-04", (3, 1, z)) + actual = {s["Slot"]: (s["id"], s["Count"]) for s in stacks} + if actual != ghosts[z]: raise ValueError("Filter reference/hidden inventory changed") + forward = {"minecraft:apple": 8} + if z == 15: forward["minecraft:dirt"] = 8 + if contents("W-04", (4, 1, z)) != forward: raise ValueError("Filter forward route incorrect") + if contents("W-04", (3, 0, z)) != ({} if z == 15 else {"minecraft:dirt": 8}): + raise ValueError("Filter lower route incorrect") + fluids = Counter() + for pos, key in (((4, 1, 8), "Tank"), ((6, 1, 8), "Tank"), ((8, 1, 8), "Tank"), ((6, 1, 8), "InputTank")): + tank = at("W-03", pos)[1].get(key) + if not isinstance(tank, dict): raise ValueError("Missing oil tank evidence") + if tank.get("Empty") == 1 and "Amount" not in tank and "FluidName" not in tank: continue + amount = tank.get("Amount") + if not isinstance(amount, int) or amount < 0 or "FluidName" not in tank: raise ValueError("Malformed oil amount/identity") + if amount: fluids[tank["FluidName"]] += amount + if fluids != {"poweradvantage:refined_oil": 1000}: raise ValueError("Incorrect oil quantity/identity") + except (KeyError, ValueError, TypeError) as error: + failures.append(str(error)) + return dict(status="FAIL" if failures else "PASS", failures=failures) + + +def audit_endurance(root, definitions_path): + definitions = {row["id"]: row for row in json.loads(definitions_path.read_text())["stations"]} + stations = ("S-04", "E-03", "W-04", "W-03") + labels = [f"cycle-{n:03d}" for n in range(1, 31)] + ["before-restart", "after-restart", "restart-resumed"] + results, hashes = {}, {} + for profile in ("fresh116", "original110-chained113-native114"): + previous_time = -1 + for label in labels: + documents = {} + for station in stations: + path = root / profile / "advantage-works-results" / f"{station}-checkpoint-{label}.json" + hashes[str(path)] = hashlib.sha256(path.read_bytes()).hexdigest().upper() + documents[station] = json.loads(path.read_text()) + result = endurance_batch(documents, definitions) + current_time = documents["W-03"]["worldTime"] + if current_time <= previous_time: + result["status"] = "FAIL" + result["failures"].append("Checkpoint world time did not advance") + previous_time = current_time + results[profile + "/" + label] = result + counts = Counter(row["status"] for row in results.values()) + hashes[str(definitions_path)] = hashlib.sha256(definitions_path.read_bytes()).hexdigest().upper() + return dict(scope="Independent endpoint recheck, five processors/three filter lanes/conveyor/oil; not a wall-clock, rate or all-machine proof", + status="FAIL" if counts["FAIL"] else "PASS", counts=dict(counts), results=results, inputHashes=hashes) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + choice = parser.add_mutually_exclusive_group(required=True) + choice.add_argument("--connected-root", type=Path) + choice.add_argument("--endurance-root", type=Path) + parser.add_argument("--definitions", type=Path, + default=Path(__file__).parents[1] / "src/main/resources/assets/advantageworkstest/works/works-1.13.2.json") + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--ticks", type=int, default=512) + args = parser.parse_args() + result = audit_connected(args.connected_root, args.ticks) if args.connected_root else audit_endurance(args.endurance_root, args.definitions) + # Never overwrite earlier good or bad evidence. + with args.output.open("x", encoding="utf-8") as stream: json.dump(result, stream, indent=2) + print(json.dumps({key: value for key, value in result.items() if key not in ("results", "inputHashes")}, indent=2)) + return int(result["status"] != "PASS") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test-rig/migration/steam_control_audit.py b/test-rig/migration/steam_control_audit.py new file mode 100644 index 0000000..2c51321 --- /dev/null +++ b/test-rig/migration/steam_control_audit.py @@ -0,0 +1,318 @@ +"""Independent offline Steam reload/control audit; never launches or edits a world. + +Rechecks the complete declared all/steam operation group and its exact cold +restart, then derives the six extra control outcomes from typed snapshots. +PASS labels and duplicated scalar measurements are not sufficient evidence. +""" + +import argparse +import base64 +import gzip +import hashlib +import importlib +import io +import json +import math +from pathlib import Path +import struct + +import nbtlib + + +LINES = ("110", "112", "113", "114", "115", "116") +PREFIX = "steam/lifecycle/" +COMPARATOR = PREFIX + "crusher-comparator-matrix" +CRUSHERS = {PREFIX + "crusher-first-loaded-" + name: (signal, steam) + for name, signal, steam in (("unpowered", False, 40), + ("signalled", True, 40), ("no-steam", False, 0))} +FURNACES = {PREFIX + "furnace-first-loaded-" + name: signal + for name, signal in (("unpowered", False), ("signalled", True))} +IDS = {COMPARATOR} | set(CRUSHERS) | set(FURNACES) +INPUTS = { + "catalog": "advantage-works-results/machine-catalog-measured.json", + "operation": "advantage-works-results/machine-operation-measured.json", + "fixture": "machine-operation-fixture.json", + "cold": "advantage-works-results/machine-inspection-restarted.json", +} + + +def require(value, message): + if not value: + raise AssertionError(message) + + +def native(line): + require(line in LINES, "Unsupported Minecraft line") + return importlib.import_module("native" + line + "_machine_operations") + + +def indexed(document, label): + rows = document.get("cases", []) + require(isinstance(rows, list) and rows, "Missing " + label + " cases") + result = {row["id"]: row for row in rows} + require(len(result) == len(rows), "Duplicate " + label + " case") + return result + + +def f32(value): + return struct.unpack(">f", struct.pack(">f", value))[0] + + +def bits(value): + return struct.unpack(">i", struct.pack(">f", value))[0] + + +def from_bits(value): + number = struct.unpack(">f", struct.pack(">I", int(value) & 0xffffffff))[0] + require(math.isfinite(number), "Non-finite saved float") + return number + + +def metric(row, key, expected, integer=False): + require(key in row, "Missing actual metric: " + key) + value = row[key] + if integer: + require(type(value) is int and value == expected, "Wrong integer metric: " + key) + else: + require(type(value) in (int, float) and math.isfinite(value) + and bits(value) == bits(expected), "Metric disagrees with saved outcome: " + key) + + +def nbt(row, line): + if line == "110" or "nbtBase64" in row: + require(isinstance(row.get("nbtBase64"), str), "Missing native binary NBT") + raw = gzip.decompress(base64.b64decode(row["nbtBase64"], validate=True)) + data = nbtlib.File.parse(io.BytesIO(raw)) + if line != "110" and "nbt" in row: + typed = native(line).typed_nbt + require(typed(data) == typed(nbtlib.parse_nbt(row["nbt"])), "Binary/text NBT disagree") + else: + require(isinstance(row.get("nbt"), str), "Missing saved tile NBT") + data = nbtlib.parse_nbt(row["nbt"]) + require(isinstance(data, nbtlib.Compound), "Tile NBT is not a compound") + return data + + +def positions(rows, base, line): + require(isinstance(rows, list) and rows, "Missing observed positions") + result = {} + for row in rows: + require(all(type(row.get(k)) is int for k in ("x", "y", "z")), "Invalid position") + key = tuple(row[k] for k in ("x", "y", "z")) + require(key not in result, "Duplicate observed position") + require(isinstance(row.get("block"), str) and isinstance(row.get("state"), str), + "Missing observed block/state") + value = {"block": row["block"], "state": row["state"]} + if "nbt" in row or "nbtBase64" in row: + value["nbt"] = base.typed_nbt(nbt(row, line)) + result[key] = value + return result + + +def tile(rows, position, block, line): + row = next((r for r in rows if tuple(r[k] for k in ("x", "y", "z")) == position), None) + require(row is not None and row["block"] == block, "Missing/wrong control machine") + data = nbt(row, line) + for key, value in zip(("x", "y", "z"), position): + require(isinstance(data.get(key), nbtlib.Int) and int(data[key]) == value, + "Tile coordinates disagree with observed position") + return data + + +def inventory(data, slot, item, count): + stacks = data.get("Items") + require(isinstance(stacks, nbtlib.List), "Missing typed inventory") + require(len(stacks) == (1 if count else 0), "Unexpected input/output stack or lost input") + if count: + stack = stacks[0] + require(isinstance(stack, nbtlib.Compound), "Malformed inventory stack") + require(isinstance(stack.get("Slot"), nbtlib.Byte) and int(stack["Slot"]) == slot, + "Wrong input slot/type") + require(isinstance(stack.get("Count"), nbtlib.Byte) and int(stack["Count"]) == count, + "Wrong input quantity/type") + require(isinstance(stack.get("id"), nbtlib.String) and str(stack["id"]) == item, + "Wrong input/output item") + if "Damage" in stack: + require(isinstance(stack["Damage"], nbtlib.Short) and int(stack["Damage"]) == 0, + "Wrong input metadata") + + +def energy(data): + values = data.get("Energy") + require(isinstance(values, nbtlib.IntArray) and len(values) == 1, "Missing typed single steam reserve") + return from_bits(values[0]) + + +def process(data): + values = data.get("Data") + require(isinstance(values, nbtlib.IntArray) and len(values) == 6, "Missing typed furnace process state") + return [int(value) for value in values] + + +def progress(data): + value = data.get("progress") + require(isinstance(value, nbtlib.Short), "Crusher progress must be a saved short") + return int(value) + + +def control_case(row, saved, declaration, base, line): + index = saved.get("index") + require(type(index) is int and index >= 0, "Missing station index") + require(type(row.get("startTick")) is int and type(row.get("finishTick")) is int + and row["finishTick"] - row["startTick"] == 1 + and type(row.get("requiredTicks")) is int and row["requiredTicks"] == 1 + and type(declaration.get("ticks")) is int and declaration["ticks"] == 1, + "Control must observe exactly the first native tick") + measured = positions(row["after"], base, line) + require(measured == positions(saved["positions"], base, line), + "Measured after-state differs from the cold fixture") + origin = (50000 + index * 64, 80, 50000) + def at(x, y=4, z=8): + return (origin[0] + x, origin[1] + y, origin[2] + z) + require(row.get("control.heldAtMeasuredEnd") is True, "Missing actual measured-end tile hold") + witnesses = row.get("control.heldTiles") + expected = {(x, 4, 8) for x in ((3, 9, 15, 21) if row["id"] == COMPARATOR else (8,))} + require(isinstance(witnesses, list) and len(witnesses) == len(expected), + "Missing/extra measured tile hold records") + actual = set() + for witness in witnesses: + require(isinstance(witness, dict) + and all(type(witness.get(key)) is int for key in ("x", "y", "z")), + "Malformed measured tile hold coordinates") + point = tuple(witness[key] for key in ("x", "y", "z")) + require(point in expected and point not in actual, "Wrong/duplicate measured tile hold") + require(all(witness.get(key) is True for key in ("removed", "sameTile", "nbtUnchanged")), + "Tile hold must remove ticking, retain the same tile and leave NBT unchanged") + observed = measured.get(at(*point)) + require(observed is not None and "nbt" in observed + and observed["block"] == ("steamadvantage:steam_furnace" if row["id"] in FURNACES + else "steamadvantage:steam_crusher"), + "Held tile is not the measured native machine") + actual.add(point) + require(actual == expected, "Incomplete measured tile hold coverage") + return at + + +def audit_documents(catalog, operation, fixture, cold, line): + report = {"status": "FAIL", "line": line, "scope": "Six Steam first-tick/control cases plus full declared operation/cold checks", + "cases": {}, "failures": []} + try: + base = native(line) + group = catalog.get("group") + require(group in ("all", "steam"), "A complete all/steam group is required, not a six-case subset") + report["baseGroup"] = group + report["operation"] = base.validate_operation(catalog, operation, group) + require(report["operation"]["status"] == "PASS", "Base operation audit failed") + observed = indexed(operation, "operation") + saved = indexed(fixture, "fixture") + restarted = indexed(cold, "restart") + declared = indexed(catalog, "catalog") + require(set(observed) == set(saved) == set(restarted) == set(declared), + "Operation and cold fixture must cover the same complete case set") + require(IDS <= set(observed), "Missing one or more of the six Steam control cases") + report["cold"] = base.validate_restart(fixture, cold) + require(report["cold"]["status"] == "PASS", "Base exact cold-restart audit failed") + for case_id in sorted(IDS): + row = observed[case_id] + at = control_case(row, saved[case_id], declared[case_id], base, line) + require(positions(saved[case_id]["positions"], base, line) + == positions(restarted[case_id]["positions"], base, line), + "Control typed NBT changed across cold restart") + if case_id == COMPARATOR: + counts, actual = row.get("comparator.inputs"), row.get("comparator.actual") + require(isinstance(counts, list) and all(type(n) is int for n in counts) + and counts == [0, 1, 32, 64], "Missing/wrong comparator stock measurements") + require(isinstance(actual, list) and all(type(n) is int for n in actual) + and len(actual) == 4, "Missing comparator output measurements") + expected_positions = {at(3 + 6 * i) for i in range(4)} + for stage in ("before", "after"): + require(set(positions(row[stage], base, line)) == expected_positions, "Wrong comparator layout") + for i, count in enumerate(counts): + data = tile(row[stage], at(3 + 6 * i), "steamadvantage:steam_crusher", line) + inventory(data, 0, "minecraft:stone", count) + require(energy(data) == 0 and progress(data) == 0, "Comparator witness must remain unpowered") + for i, count in enumerate(counts): + metric(row, "count/" + str(3 + 6 * i) + "/4/8/minecraft:stone", count, integer=True) + wanted = 0 if count == 0 else 1 + 14 * count // 64 + require(actual[i] == wanted, "Comparator output disagrees with typed inventory") + else: + is_crusher = case_id in CRUSHERS + signal, initial = CRUSHERS[case_id] if is_crusher else (FURNACES[case_id], 40) + require(row.get("firstLoaded.signalled") is signal, "Missing/wrong first-tick control measurement") + expected_positions = {at(8), at(7)} if signal else {at(8)} + for stage in ("before", "after"): + snapshot = positions(row[stage], base, line) + require(set(snapshot) == expected_positions, "Wrong controlled-machine layout") + if signal: + require(snapshot[at(7)]["block"] == "minecraft:redstone_block", "Missing actual redstone source") + block = "steamadvantage:steam_crusher" if is_crusher else "steamadvantage:steam_furnace" + before = tile(row["before"], at(8), block, line) + after = tile(row["after"], at(8), block, line) + item = "minecraft:stone" if is_crusher else "minecraft:sand" + for data in (before, after): + inventory(data, 0 if is_crusher else 1, item, 1) + require(energy(before) == initial, "Wrong saved initial steam reserve") + metric(row, "count/8/4/8/" + item, 1, integer=True) + metric(row, "count/8/4/8/minecraft:" + ("cobblestone" if is_crusher else "glass"), 0, integer=True) + if is_crusher: + wanted = 105 if signal or initial == 0 else 107 + reserve = 0 if initial == 0 else 39.9375 if signal else 38.4375 + require(progress(before) == 106 and progress(after) == wanted, "Wrong saved first-tick crusher work") + metric(row, "firstLoaded.progress", progress(after), integer=True) + else: + before_data, after_data = process(before), process(after) + require(before_data == [bits(.25), 0, 0, 10, 10, bits(1000)], "Wrong loaded furnace state") + heat = 989.5 if signal else 1027.0 + work = f32(.25 + f32(f32(3.5e-6) * heat)) + require(after_data == [bits(work), 0, 0, 9, 10, bits(heat)], "Wrong saved first-tick furnace work/heat/fuel") + metric(row, "firstLoaded.heat", from_bits(after_data[5])) + metric(row, "firstLoaded.progress", from_bits(after_data[0])) + reserve = 40 if signal else 39.5 + require(energy(after) == reserve, "Wrong saved first-tick steam consumption") + metric(row, "firstLoaded.steam", energy(after)) + metric(row, "energy/8/4/8/steam", energy(after)) + report["cases"][case_id] = {"status": "PASS", "heldTilesChecked": len(row["control.heldTiles"]), + "comparison": "explicit tile holds and raw metrics checked against typed before/after state"} + report["status"] = "PASS" + except Exception as error: + report["failures"].append(str(error)) + return report + + +def unique_object(pairs): + result = {} + for key, value in pairs: + require(key not in result, "Duplicate JSON key: " + key) + result[key] = value + return result + + +def audit(runtime, line): + runtime = Path(runtime).resolve() + raw = {name: (runtime / path).read_bytes() for name, path in INPUTS.items()} + documents = {name: json.loads(value, object_pairs_hook=unique_object) for name, value in raw.items()} + report = audit_documents(**documents, line=line) + report["runtime"] = str(runtime) + report["inputSha256"] = {INPUTS[name]: hashlib.sha256(value).hexdigest().upper() for name, value in raw.items()} + report["auditorSha256"] = hashlib.sha256(Path(__file__).read_bytes()).hexdigest().upper() + report["baseValidatorSha256"] = hashlib.sha256(Path(native(line).__file__).read_bytes()).hexdigest().upper() + for name, value in raw.items(): + require((runtime / INPUTS[name]).read_bytes() == value, "Raw evidence changed during audit") + return report + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--runtime", type=Path, required=True) + parser.add_argument("--line", choices=LINES, required=True) + args = parser.parse_args(argv) + try: + report = audit(args.runtime, args.line) + except Exception as error: + report = {"status": "FAIL", "line": args.line, "runtime": str(args.runtime), "failures": [str(error)]} + print(json.dumps(report, indent=2, sort_keys=True, allow_nan=False)) + return int(report["status"] != "PASS") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test-rig/migration/test_native112_code.py b/test-rig/migration/test_native112_code.py new file mode 100644 index 0000000..cbfe8ff --- /dev/null +++ b/test-rig/migration/test_native112_code.py @@ -0,0 +1,63 @@ +"""Server-code drift is rejected before process creation, without opening worlds.""" + +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch +from native112_code import INVENTORY, Server, code_files, verify_code + + +class Native112CodeCustodyTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.root = Path(self.temp.name) + (self.root / "libraries/native").mkdir(parents=True) + (self.root / "libraries/native/server.jar").write_bytes(b"native") + (self.root / "forge.jar").write_bytes(b"forge") + self.expected = {"files": code_files(self.root)} + (self.root / INVENTORY).write_text(json.dumps(self.expected), encoding="utf-8") + + def tearDown(self): + self.temp.cleanup() + + def test_exact_seal_accepts_world_and_config_changes_outside_code(self): + (self.root / "world").mkdir() + (self.root / "world/level.dat").write_bytes(b"mutable world") + (self.root / "server.properties").write_text("mutable configuration") + self.assertEqual(self.expected, verify_code(self.root)) + + def test_changed_missing_extra_and_timestamp_only_archive_bytes_are_rejected(self): + original = self.root / "libraries/native/server.jar" + for mutation in ("changed", "missing", "extra", "zip-timestamp-only"): + original.write_bytes(b"native") + extra = self.root / "libraries/extra.jar" + if extra.exists(): + extra.unlink() + if mutation in ("changed", "zip-timestamp-only"): + original.write_bytes(b"nativE") + elif mutation == "missing": + original.unlink() + else: + extra.write_bytes(b"extra") + with self.subTest(mutation=mutation), self.assertRaises(AssertionError): + verify_code(self.root) + + def test_code_failure_precedes_external_process_creation(self): + (self.root / "forge.jar").write_bytes(b"changed") + with patch("rig_transport.subprocess.Popen") as process, self.assertRaises(AssertionError): + Server(self.root, Path("java"), "forge.jar", "must-not-start") + process.assert_not_called() + + def test_incomplete_inventory_cannot_authorize_runtime(self): + with self.assertRaises(ValueError): + verify_code(self.root, {"files": {}}) + + def test_extra_root_jar_is_not_exempted(self): + (self.root / "installer.jar").write_bytes(b"unexpected") + with self.assertRaises(AssertionError): + verify_code(self.root) + + +if __name__ == "__main__": + unittest.main() diff --git a/test-rig/migration/test_native112_connected.py b/test-rig/migration/test_native112_connected.py new file mode 100644 index 0000000..d990869 --- /dev/null +++ b/test-rig/migration/test_native112_connected.py @@ -0,0 +1,60 @@ +import base64 +import copy +import gzip +import io +import unittest +import nbtlib +from native112_connected import strict_snapshot + + +def document(tag='{id:"steamadvantage:steam_furnace",Energy:[I;1],Items:[{Slot:1b,Count:4b,id:"minecraft:iron_ore"}]}'): + output = io.BytesIO() + nbtlib.File(nbtlib.parse_nbt(tag)).write(output) + return dict(snapshot=[dict(id="test", station="C-STEAM", kind="furnace", name="steamadvantage:steam_furnace", + x=80, y=80, z=80, actualName="steamadvantage:steam_furnace", properties={"facing":"north"}, + nbtBase64=base64.b64encode(gzip.compress(output.getvalue())).decode("ascii"))]) + + +class NativeLegacyConnectedOracleTest(unittest.TestCase): + def test_equal_typed_native_binary_is_a_pass(self): + self.assertEqual("PASS", strict_snapshot(document(), document())["status"]) + + def test_extra_resource_changed_quantity_and_changed_nbt_type_fail(self): + for bad in ('{id:"steamadvantage:steam_furnace",Energy:[I;1],Items:[{Slot:1b,Count:3b,id:"minecraft:iron_ore"}]}', + '{id:"steamadvantage:steam_furnace",Energy:[I;1],Items:[{Slot:1b,Count:4,id:"minecraft:iron_ore"}]}', + '{id:"steamadvantage:steam_furnace",Energy:[I;1],Items:[{Slot:1b,Count:4b,id:"minecraft:iron_ore"}],HiddenFluid:1000}'): + self.assertEqual("FAIL", strict_snapshot(document(), document(bad))["status"]) + + def test_missing_extra_duplicate_or_moved_block_is_not_preservation(self): + source = document() + for kind in ("missing", "extra", "duplicate", "moved"): + after = copy.deepcopy(source) + if kind == "missing": after["snapshot"] = [] + elif kind in ("extra", "duplicate"): + row = copy.deepcopy(after["snapshot"][0]) + if kind == "extra": row["id"] = "unknown" + after["snapshot"].append(row) + else: after["snapshot"][0]["x"] += 1 + if kind == "duplicate": + with self.assertRaises(AssertionError): strict_snapshot(source, after) + else: self.assertEqual("FAIL", strict_snapshot(source, after)["status"]) + + def test_historical_tile_id_alias_is_not_a_state_change(self): + old = document('{id:"minecraft:steamadvantage.steam_furnace",Energy:[I;1],Items:[{Slot:1b,Count:4b,id:"minecraft:iron_ore"}]}') + self.assertEqual("PASS", strict_snapshot(old, document())["status"]) + + def test_only_empty_forge_metadata_is_equivalent_to_absence(self): + empty = document('{id:"steamadvantage:steam_furnace",Energy:[I;1],Items:[{Slot:1b,Count:4b,id:"minecraft:iron_ore"}],ForgeData:{},ForgeCaps:{}}') + self.assertEqual("PASS", strict_snapshot(document(), empty)["status"]) + custom = document('{id:"steamadvantage:steam_furnace",Energy:[I;1],Items:[{Slot:1b,Count:4b,id:"minecraft:iron_ore"}],ForgeData:{Hidden:1}}') + self.assertEqual("FAIL", strict_snapshot(document(), custom)["status"]) + + def test_legacy_missing_facing_comes_from_actual_state_not_a_default(self): + def drill(tag): + result = document(tag) + result["snapshot"][0].update(actualName="steamadvantage:steam_drill", name="steamadvantage:steam_drill", kind="drill") + return result + old = drill('{id:"steamadvantage:steam_drill",Energy:[I;1],Items:[]}') + for facing, expected in ((2, "PASS"), (3, "FAIL")): + current = drill('{id:"steamadvantage:steam_drill",Energy:[I;1],Items:[],Facing:' + str(facing) + 'b}') + self.assertEqual(expected, strict_snapshot(old, current)["status"]) diff --git a/test-rig/migration/test_native112_diagnostics.py b/test-rig/migration/test_native112_diagnostics.py new file mode 100644 index 0000000..8fda233 --- /dev/null +++ b/test-rig/migration/test_native112_diagnostics.py @@ -0,0 +1,16 @@ +import unittest +from native112_diagnostics import audit_lines, LEGACY_ORESPAWN_SHA + + +class NativeDiagnosticsTests(unittest.TestCase): + def test_known_notice_is_recorded_only_for_the_exact_dependency(self): + message = "[12:00:00] [Server thread/FATAL] [orespawn]: Loading Integration For basemetals" + self.assertEqual(message, audit_lines([message], LEGACY_ORESPAWN_SHA)["classifiedDiagnostics"][0]["message"]) + for wrong in (None, "0" * 64): + with self.assertRaises(AssertionError): audit_lines([message], wrong) + + def test_other_fatals_and_errors_remain_failures(self): + for message in ("[Server thread/FATAL] [orespawn]: bad integration", + "[12:00:00] [Server thread/FATAL] [orespawn]: Loading Integration For unknown", + "[Server thread/ERROR] broken machine"): + with self.assertRaises(AssertionError): audit_lines([message], LEGACY_ORESPAWN_SHA) diff --git a/test-rig/migration/test_native112_harvest.py b/test-rig/migration/test_native112_harvest.py new file mode 100644 index 0000000..b5bb37d --- /dev/null +++ b/test-rig/migration/test_native112_harvest.py @@ -0,0 +1,208 @@ +"""Unit fixtures exercise rejection logic only; they are NOT native runtime evidence.""" + +from copy import deepcopy +import hashlib +import json +from pathlib import Path +import unittest +from uuid import UUID +import native112_harvest as native + + +def sample(label="exercise"): + rows, identity = [], 1 + for name, (level, allowed, silk, minimum, maximum, trials) in native.EXPECTED.items(): + evidence = [] + for trial in range(trials): + drops = [] + if minimum: + drops.append(dict(entityId=identity, uuid=str(UUID(int=identity)), item=native.drop_identity(name), + count=minimum, damage=0, tagged=False, removed=False, x=1200.5, y=8.5, z=1024.5)) + identity += 1 + evidence.append(dict(trial=trial, worldTime=1, removedBeforeCleanup=trial, + liveBeforeCleanup=int(trial > 0), liveAfterCleanup=0, actualDrops=drops)) + mask = int(name.split("/")[1]) if name.startswith(("wire/", "scaffold/")) else -1 + row = dict(id=name, block=native.block_identity(name), status="PASS", historicalHarvestLevel=level, + harvestAllowed=allowed, silkTouch=silk, trials=trials, actualDropCounts=[minimum]*trials, + connectionMask=mask, actualFullCube=name.startswith(("sulfur/","lithium/")), + actualOpaque=name.startswith(("sulfur/","lithium/")), materialSolid=True, + collisionBoxes=native.expected_boxes(name), dropEvidence=evidence) + if mask >= 0: row["actualConnectionMask"] = mask + rows.append(row) + for name, (block, opaque, light_opacity) in native.PROPERTIES.items(): + rows.append(dict(id=name, status="PASS", block=block, actualOpaque=opaque, actualFullCube=opaque, + lightOpacity=light_opacity)) + for name in native.WATER_ONLY: + rows.append(dict(id=name, status="N/A", reason=native.NA_REASON)) + return dict(label=label, minecraft=native.MINECRAFT, status="PASS", executedChecks=272, notApplicableChecks=3, + checks=rows, fixtureIsolation=native.ISOLATION_PROTOCOL) + + +def row(document, name="sulfur/wood"): + return next(entry for entry in document["checks"] if entry["id"] == name) + + +class LegacyHarvestTests(unittest.TestCase): + def test_all_270_original_tool_drop_trial_oracles_are_preserved(self): + digest = hashlib.sha256(json.dumps(native.EXPECTED, sort_keys=True, separators=(",",":")).encode()).hexdigest().upper() + self.assertEqual("A43F316D748DFCCB4DA9B72AE771851FFBD30661C56E5609A0D04E0AC0050D5B", digest) + self.assertEqual(270, len(native.EXPECTED)) + self.assertEqual(2, len(native.PROPERTIES)) + self.assertEqual(3, len(native.WATER_ONLY)) + + def test_complete_unit_fixture_can_exercise_both_phase_validators(self): + for label in ("exercise","restored"): + document = sample(label) + native.require_checks(document, label) + native.require_isolation(document) + + def test_missing_duplicate_unknown_and_wrong_engine_rows_fail(self): + for mutation in ("missing","duplicate","unknown","engine","label","executed","na-count"): + document = sample() + if mutation == "missing": document["checks"].pop() + if mutation == "duplicate": document["checks"].append(deepcopy(document["checks"][0])) + if mutation == "unknown": document["checks"][0]["id"] = "unknown" + if mutation == "engine": document["minecraft"] = "1.13.2" + if mutation == "label": document["label"] = "different" + if mutation == "executed": document["executedChecks"] = 275 + if mutation == "na-count": document["notApplicableChecks"] = 0 + with self.subTest(mutation=mutation), self.assertRaises(AssertionError): + native.require_checks(document, "exercise") + + def test_waterlogging_is_never_misreported_as_pass_or_silently_omitted(self): + for name in native.WATER_ONLY: + for field, value in (("status","PASS"),("status","SKIP"),("reason","unsupported"),("reason",None)): + document = sample() + row(document, name)[field] = value + with self.subTest(name=name, field=field), self.assertRaises(AssertionError): + native.require_checks(document, "exercise") + document = sample() + row(document)["status"] = "N/A" + with self.assertRaises(AssertionError): native.require_checks(document, "exercise") + + def test_tools_levels_quantities_flags_and_actual_masks_remain_strict(self): + for name, field, value in ( + ("sulfur/wood","actualDropCounts",[5]*8),("sulfur/silk","actualDropCounts",[4]), + ("lithium/wood-fails","actualDropCounts",[1]),("lithium/stone","historicalHarvestLevel",0), + ("lithium/gold-fails","harvestAllowed",True),("sulfur/silk","silkTouch",False), + ("wire/63/hand","connectionMask",0),("wire/63/hand","actualConnectionMask",0), + ("wire/0/hand","actualFullCube",True),("frame/hand","actualOpaque",True), + ("scaffold/0/silk","materialSolid",False),("sulfur/wood","trials",True), + ("sulfur/hand-fails","actualDropCounts",[False])): + document = sample() + row(document,name)[field] = deepcopy(value) + with self.subTest(name=name,field=field), self.assertRaises(AssertionError): + native.require_checks(document,"exercise") + + def test_legacy_wire_plus_and_all_six_arms_are_not_modern_full_boxes(self): + self.assertEqual(5, len(native.expected_boxes("wire/0/hand"))) + self.assertEqual(7, len(native.expected_boxes("wire/63/hand"))) + for name in ("wire/0/hand","wire/63/silk","wire/16/hand","scaffold/0/hand","frame/hand"): + document = sample() + row(document,name)["collisionBoxes"] = [[0,0,0,1,1,1]] if name.startswith("wire/") else native.expected_boxes("wire/0/hand") + with self.subTest(name=name), self.assertRaisesRegex(AssertionError,"collision"): + native.require_checks(document,"exercise") + + def test_missing_duplicate_nonfinite_and_malformed_collision_boxes_fail(self): + for boxes in ([], [[False,0,0,1,1,1]], [[0,0,0,float("nan"),1,1]], [[0,0,1]], "full"): + document = sample() + row(document,"frame/hand")["collisionBoxes"] = boxes + with self.subTest(boxes=boxes), self.assertRaises(AssertionError): + native.require_checks(document,"exercise") + document = sample() + boxes = row(document,"wire/0/hand")["collisionBoxes"] + boxes.append(deepcopy(boxes[0])) + with self.assertRaises(AssertionError): native.require_checks(document,"exercise") + + def test_frame_opacity_and_solar_fullness_are_dedicated_not_implicit_checks(self): + for name, field, value in ( + ("frame/native-opacity","actualOpaque",True),("frame/native-opacity","actualFullCube",True), + ("frame/native-opacity","lightOpacity",255),("solar/native-solidity","actualOpaque",False), + ("solar/native-solidity","actualFullCube",False),("solar/native-solidity","lightOpacity",255)): + document=sample() + row(document,name)[field]=value + with self.subTest(name=name,field=field), self.assertRaisesRegex(AssertionError,"opacity"): + native.require_checks(document,"exercise") + + def test_count_only_pass_cannot_hide_wrong_identity_metadata_or_position(self): + for field,value in (("item","unit:wrong"),("damage",1),("tagged",True),("removed",True), + ("uuid",""),("uuid","not-a-uuid"),("entityId","1"),("count",True), + ("x",0),("y",float("inf")),("z",None)): + document=sample() + row(document)["dropEvidence"][0]["actualDrops"][0][field]=value + with self.subTest(field=field,value=value), self.assertRaises(AssertionError): + native.require_isolation(document) + + def test_removed_or_repeated_entity_cannot_be_counted_again(self): + for across_rows in (False,True): + for field in ("uuid","entityId"): + document=sample() + first=row(document)["dropEvidence"][0]["actualDrops"][0] + later=row(document,"sulfur/stone")["dropEvidence"][0] if across_rows else row(document)["dropEvidence"][1] + later["actualDrops"][0][field]=first[field] + with self.subTest(across_rows=across_rows,field=field), self.assertRaisesRegex(AssertionError,"repeated"): + native.require_isolation(document) + + def test_missing_protocol_or_per_trial_audits_fail(self): + for mutation in ("protocol","trial"): + document=sample() + if mutation=="protocol": document["fixtureIsolation"]="native113-live-drops-v1" + else: row(document)["dropEvidence"].pop() + with self.subTest(mutation=mutation), self.assertRaises(AssertionError): native.require_isolation(document) + + def test_cleanup_negative_counts_bad_sequence_and_bool_timestamps_fail(self): + for field,value in (("liveAfterCleanup",1),("liveAfterCleanup",False),("trial",True),("trial",4), + ("worldTime",True),("removedBeforeCleanup",-1),("liveBeforeCleanup",-1)): + document=sample() + row(document)["dropEvidence"][0][field]=value + with self.subTest(field=field,value=value), self.assertRaisesRegex(AssertionError,"not isolated"): + native.require_isolation(document) + + def test_wrong_tool_cannot_hide_unexpected_live_drop(self): + document=sample() + row(document,"lithium/wood-fails")["dropEvidence"][0]["actualDrops"] = [ + dict(entityId=99999,uuid=str(UUID(int=99999)),item="electricadvantage:li_ore", + count=1,damage=0,tagged=False,removed=False,x=1200.5,y=8.5,z=1024.5)] + with self.assertRaises(AssertionError): native.require_isolation(document) + + def test_failed_aggregate_or_actual_case_failure_is_not_relabeled_green(self): + document=sample(); document["status"]="FAIL" + row(document,"sulfur/silk").update(status="FAIL",failure="Wrong actual drop: sulfur powder") + with self.assertRaisesRegex(AssertionError,"sulfur/silk: Wrong actual drop"): + native.require_checks(document,"exercise") + document=sample(); document["status"]="FAIL" + with self.assertRaises(AssertionError): native.require_checks(document,"exercise") + + def test_java_observes_native_survival_and_real_unfiltered_item_identities(self): + source=(Path(__file__).parents[1]/"src/main/java/zone/moddev/mc/advantageworks/NativeHarvestProbe.java").read_text() + for literal in ("new PlayerInteractionManager(world)", "setGameType(GameType.SURVIVAL)", + "player.interactionManager.tryHarvestBlock(POS)", "block.canHarvestBlock(world, POS, player)", + "entity != null && !entity.isDead", 'actual.addProperty("uuid", entity.getUniqueID().toString())', + 'actual.addProperty("removed", entity.isDead)', "block.addCollisionBoxToList(", + "block.getActualState(world.getBlockState(POS), world, POS)", '"frame/native-opacity"', + 'getRequiredPermissionLevel() { return 4; }', '"AdvantageWorksMigration"', + "WorldType.FLAT", '"advantage-migration.allow"'): + self.assertIn(literal,source) + self.assertNotIn(".getDrops(",source) + self.assertNotIn("import com.mcmoddev",source) + self.assertNotIn("import zone.moddev.mc.electricadvantage",source) + self.assertNotIn("import zone.moddev.mc.steamadvantage",source) + + def test_runner_enforces_code_custody_before_boot_without_editing_shared_helpers(self): + source=Path(native.__file__).read_text() + for literal in ('verify_code(target[0], expected_code)', 'verify_code(output, expected_code)', + 'write_json(output / INVENTORY, expected_code)', 'verify_staged(output, target)', + 'report["protocolUnchanged"]', 'report["manifestUnchanged"]'): + self.assertIn(literal,source) + self.assertLess(source.index('verify_code(output, expected_code)'), source.index('server = Server(')) + + def test_legacy_solar_light_opacity_is_an_explicit_constructor_setting_not_a_full_cube_inference(self): + root=Path(__file__).resolve().parents[2] + source=(root/'src/main/java/cyano/poweradvantage/api/GUIBlock.java').read_text() + constructor=source.split('public GUIBlock(Material m)',1)[1].split('}',1)[0] + self.assertIn('super(m);',constructor) + self.assertIn('this.setLightOpacity(0);',constructor) + self.assertEqual((True,0),native.PROPERTIES['solar/native-solidity'][1:]) + + +if __name__=="__main__": unittest.main() diff --git a/test-rig/migration/test_native112_machine_operations.py b/test-rig/migration/test_native112_machine_operations.py new file mode 100644 index 0000000..a4e7837 --- /dev/null +++ b/test-rig/migration/test_native112_machine_operations.py @@ -0,0 +1,85 @@ +"""The new native suite must reject incomplete jobs and extra/lost restarted state.""" + +import copy +import unittest + +from native112_machine_operations import REQUIRED, validate_operation, validate_restart + + +def stored(nbt='{Items:[{Slot:0b,id:"minecraft:dirt",Count:16b}],Energy:[I;1065353216]}'): + return dict(id="power/test", positions=[dict(x=50000, y=80, z=50000, block="minecraft:chest", state="chest", nbt=nbt)]) + + +class FullProcessMachineOracleTests(unittest.TestCase): + def pair(self): + return {"cases": [stored()]}, dict(held=True, constructionCallsThisProcess=0, cases=[stored()]) + + def test_actual_same_loaded_state_passes(self): + before, after = self.pair() + self.assertEqual("PASS", validate_restart(before, after)["status"]) + + def test_target_only_extra_resource_is_not_ignored(self): + before, after = self.pair() + after["cases"][0]["positions"][0]["nbt"] = '{Items:[{Slot:0b,id:"minecraft:dirt",Count:16b}],Energy:[I;1065353216],HiddenTank:{Amount:1000}}' + self.assertEqual("FAIL", validate_restart(before, after)["status"]) + + def test_changed_quantity_or_nbt_type_fails(self): + for bad in ('{Items:[{Slot:0b,id:"minecraft:dirt",Count:15b}],Energy:[I;1065353216]}', + '{Items:[{Slot:0b,id:"minecraft:dirt",Count:16}],Energy:[I;1065353216]}'): + before, after = self.pair() + after["cases"][0]["positions"][0]["nbt"] = bad + self.assertEqual("FAIL", validate_restart(before, after)["status"]) + + def test_missing_or_rebuilt_or_unfrozen_cases_fail(self): + for bad in (dict(held=True, constructionCallsThisProcess=0, cases=[]), + dict(held=True, constructionCallsThisProcess=1, cases=[stored()]), + dict(held=False, constructionCallsThisProcess=0, cases=[stored()])): + with self.assertRaises(AssertionError): validate_restart({"cases": [stored()]}, bad) + + def test_extra_position_fails(self): + before, after = self.pair() + extra = copy.deepcopy(after["cases"][0]["positions"][0]); extra["x"] += 1 + after["cases"][0]["positions"].append(extra) + self.assertEqual("FAIL", validate_restart(before, after)["status"]) + + def test_operating_catalog_cannot_be_empty(self): + with self.assertRaises(AssertionError): validate_operation({"cases": []}, {"cases": []}, "all") + + def test_duplicate_entity_uuid_is_not_collapsed(self): + before, after = self.pair() + actor = dict(uuid="example", nbt='{Health:20.0f}') + before["cases"][0]["entities"] = [actor] + after["cases"][0]["entities"] = [actor, actor] + with self.assertRaises(AssertionError): validate_restart(before, after) + + def test_only_exact_zero_volume_named_tank_representation_may_change(self): + for original, expected in (("{Amount:0,FluidName:\"poweradvantage:crude_oil\"}", "PASS"), + ("{Amount:1,FluidName:\"poweradvantage:crude_oil\"}", "FAIL"), + ("{Amount:0,FluidName:\"poweradvantage:crude_oil\",Hidden:1}", "FAIL"), + ("{Amount:0b,FluidName:\"poweradvantage:crude_oil\"}", "FAIL")): + before, after = self.pair() + before["cases"][0]["positions"][0]["nbt"] = "{Tank:" + original + "}" + after["cases"][0]["positions"][0]["nbt"] = "{Tank:{Empty:1b}}" + self.assertEqual(expected, validate_restart(before, after)["status"]) + + def test_pass_label_cannot_hide_short_clock_missing_output_or_failed_assertion(self): + ids = sorted("power/" + name for name in REQUIRED["power"]) + catalog = dict(cases=[dict(id=key) for key in ids]) + catalog["notApplicable"] = [dict(id="pickup/" + name, status="NOT_APPLICABLE") + for name in ("power-drain", "steam-pump", "electric-pump")] + good = dict(status="PASS", held=True, cases=[dict(id=key, status="PASS", verifiedAssertions=1, + assertions=[dict(phase="verify", status="PASS")], before=[stored()], after=[stored()], + startTick=100, finishTick=180, requiredTicks=80) for key in ids]) + self.assertEqual("PASS", validate_operation(catalog, good, "power")["status"]) + for patch in (dict(finishTick=179), dict(verifiedAssertions=0), dict(after=[]), + dict(assertions=[dict(phase="verify", status="FAIL")])): + bad = copy.deepcopy(good); bad["cases"][0].update(patch) + self.assertEqual("FAIL", validate_operation(catalog, bad, "power")["status"]) + + def test_platform_exclusions_cannot_hide_an_ordinary_machine(self): + catalog = dict(cases=[dict(id="power/" + name) for name in REQUIRED["power"]], + notApplicable=[dict(id="power/still/production", status="NOT_APPLICABLE")]) + with self.assertRaises(AssertionError): validate_operation(catalog, {}, "power") + + +if __name__ == "__main__": unittest.main() diff --git a/test-rig/migration/test_native112_partial.py b/test-rig/migration/test_native112_partial.py new file mode 100644 index 0000000..f3fe72b --- /dev/null +++ b/test-rig/migration/test_native112_partial.py @@ -0,0 +1,117 @@ +import copy +import unittest +from native112_partial import REQUIRED, validate_partial + + +def evidence(): + ids = sorted(REQUIRED) + catalog = {"cases": [{"id": key} for key in ids]} + measured = {"held": True, "cases": []} + for key in ids: + endpoint = key.endswith(("-source", "-consumer")) + import base64, gzip, io, nbtlib + tag = nbtlib.File(nbtlib.parse_nbt('{Energy:[I;1140457472],id:"fixture:tile",Count:1b}')) + output = io.BytesIO(); tag.write(output) + encoded = base64.b64encode(gzip.compress(output.getvalue())).decode() + saved = [{"pos": [50000, 84, 50000], "observedNbtBase64": encoded}] if endpoint else [] + block = [{"pos": [50000, 84, 50000], "state": "fixture:block"}] + measured["cases"].append({"id": key, "status": "PASS", "partial.completed": True, + "partial.cleanupCalled": True, "partial.measuredTicks": 1100, "startTick": 10, "finishTick": 1110, + "partial.absentTicks": 240, "partial.selectedChunk": [3125, 3125], "partial.restoreTick": 481, + "partial.events": [ + {"operation": "unload", "tick": 241, "chunk": [3125, 3125], "unloadState": saved, "networkBlocks": block}, + {"operation": "load", "tick": 482, "chunk": [3125, 3125], "typedStateEqual": True, + "explicitRecovery": True, "unloadState": copy.deepcopy(saved), "networkBlocks": copy.deepcopy(block)}], + "partial.observations": [ + {"tick": tick, "selectedPresent": False, "explicitRecovery": False, "retainedLoaded": True} + for tick in (260, 480)]}) + fixture = {"cases": [{"id": key} for key in ids]} + for row in measured["cases"]: + applicable = not row["id"].endswith("-source") + row["partial.isolationApplicable"] = applicable + if not applicable: + continue + steam = row["id"].split("/")[-1].split("-")[0] == "steam" + samples = [{"tick": tick, "source": 5000 - (tick - 241) * (0.0625 if steam else 0), + "expectedSource": 5000 - (tick - 241) * (0.0625 if steam else 0), + "sourceLoaded": True, "selectedAbsent": True} for tick in range(241, 482)] + row.update({"partial.isolationSamples": samples, "partial.isolationAnchorTick": 241, + "partial.isolationLastTick": 481, "partial.isolationAnchorSource": 5000, + "partial.isolationLastSource": samples[-1]["source"], "partial.isolationStableTicks": 240}) + cold = {"held": True, "constructionCallsThisProcess": 0, "cases": [{"id": key} for key in ids]} + return catalog, measured, fixture, cold + + +class NativePartialOracleTest(unittest.TestCase): + def test_ghost_transfer_fails_even_when_global_conservation_and_all_flags_pass(self): + for kind in ("steam", "electric", "fluid"): + values = evidence() + row = next(r for r in values[1]["cases"] if r["id"] == "power/partial/" + kind) + for sample in row["partial.isolationSamples"][1:]: + sample["source"] -= 1 + row["partial.isolationLastSource"] -= 1 + with self.assertRaises(AssertionError): validate_partial(*values) + + def test_unobserved_late_ghost_flow_cannot_hide_in_equal_anchor_and_final_totals(self): + values = evidence() + row = next(r for r in values[1]["cases"] if r["id"] == "power/partial/electric") + row["partial.isolationSamples"][120]["source"] -= 4 + with self.assertRaises(AssertionError): validate_partial(*values) + + def test_isolation_must_cover_every_absent_tick_not_only_a_passing_subset(self): + for change in (lambda r: r.pop("partial.isolationSamples"), + lambda r: r["partial.isolationSamples"].pop(80), + lambda r: r["partial.isolationSamples"][0].update(sourceLoaded=False), + lambda r: r.update({"partial.isolationStableTicks": 220}), + lambda r: r["partial.isolationSamples"].pop()): + values = evidence() + row = next(r for r in values[1]["cases"] if r["id"] == "power/partial/fluid-consumer") + change(row) + with self.assertRaises(AssertionError): validate_partial(*values) + + def test_all_nine_cases_and_actual_witnesses_are_required(self): + values = evidence() + self.assertEqual("PASS", validate_partial(*values)["status"]) + self.assertEqual(9, len(validate_partial(*values)["cases"])) + + def test_missing_or_duplicate_case_is_not_a_passing_subset(self): + for index in range(4): + values = evidence() + values[index]["cases"].pop() + with self.assertRaises(AssertionError): validate_partial(*values) + values = evidence() + values[index]["cases"].append(copy.deepcopy(values[index]["cases"][0])) + with self.assertRaises(AssertionError): validate_partial(*values) + + def test_flags_timing_and_native_events_cannot_be_manufactured(self): + changes = [ + lambda r: r.update({"partial.completed": False}), + lambda r: r.update({"partial.absentTicks": 1}), + lambda r: r.update({"partial.measuredTicks": 1099}), + lambda r: r["partial.events"].pop(), + lambda r: r["partial.events"].append(copy.deepcopy(r["partial.events"][0])), + lambda r: r["partial.events"][1].update(typedStateEqual=False), + lambda r: r["partial.events"][1].update(explicitRecovery=False), + lambda r: r["partial.events"][0].update(tick=480), + lambda r: r["partial.observations"][0].update(retainedLoaded=False), + lambda r: r["partial.observations"].pop(), + lambda r: r["partial.events"][1].update(failure="hidden"), + lambda r: r["partial.events"][1].update(networkBlocks=[])] + for change in changes: + values = evidence(); change(values[1]["cases"][0]) + with self.assertRaises(AssertionError): validate_partial(*values) + + def test_cold_proof_cannot_be_missing_rebuilt_or_unfrozen(self): + for change in (lambda r: r.update(held=False), lambda r: r.update(constructionCallsThisProcess=1)): + values = evidence(); change(values[3]) + with self.assertRaises(AssertionError): validate_partial(*values) + + def test_actual_callback_nbt_is_compared_not_just_pass_flags(self): + values = evidence() + row = next(r for r in values[1]["cases"] if r["id"].endswith("-source")) + row["partial.events"][1]["unloadState"] = [] + with self.assertRaises(AssertionError): validate_partial(*values) + + +if __name__ == "__main__": + unittest.main() diff --git a/test-rig/migration/test_native112_recipe_progression.py b/test-rig/migration/test_native112_recipe_progression.py new file mode 100644 index 0000000..3ea35c2 --- /dev/null +++ b/test-rig/migration/test_native112_recipe_progression.py @@ -0,0 +1,221 @@ +"""Synthetic documents test validators only; no native recipe qualification is implied.""" +from copy import deepcopy +from pathlib import Path +import unittest +import native112_recipe_progression as native + +def fixture(mode="NORMAL"): + rows=[] + ores={"ingotIron":["minecraft:iron_ingot@0"], + "ingotSilicon":["electricadvantage:silicon_ingot@0"], + "nuggetCopper":["basemetals:copper_nugget@0"], + "nuggetTin":["basemetals:tin_nugget@0"], + "nuggetGold":["minecraft:gold_nugget@0"], + "plastic":["poweradvantage:plastic@0"],"dustRedstone":["minecraft:redstone@0"]} + def recipe(output,inputs,count=1): + rows.append(dict(id="craft/"+str(len(rows)),output=output,inputs=inputs,count=count, + providers=[[item] for item in inputs], + actualCrafted=output,actualCraftedCount=count, + status="PASS",owned=True,negativeControlsRejected=2,consumption="NATIVE_SLOT_CRAFTING", + recipeBookCraftUnlock="PASS")) + recipe("poweradvantage:fluid_pipe@0",["minecraft:iron_ingot@0"]*6,3 if mode=="APOCALYPTIC" else 6) + recipe("steamadvantage:steam_governor@0",["minecraft:iron_nugget@0"]) + recipe("electricadvantage:psu@0",["electricadvantage:control_circuit@0"]) + if mode!="APOCALYPTIC": + recipe("poweradvantage:sprocket@0",["basemetals:steel_ingot@0"],4) + for metal in ("Copper","Tin","Gold"): + inputs=[ores["ingotSilicon"][0]]*3+[ores["nugget"+metal][0]]*3 + if mode=="TECH_PROGRESSION": + inputs=[ores["plastic"][0],ores["dustRedstone"][0],ores["plastic"][0]]+inputs + recipe("electricadvantage:integrated_circuit@0",inputs,3) + recipe("electricadvantage:photovoltaic_generator@0",["minecraft:glass@0"]) + return dict(minecraft=native.MC,mode=mode,rubber=True,status="EXPORTED_NOT_QUALIFIED",crafting=rows, + targets=sorted(native.CORE),ores=ores, + blockItems=[],smelting=[dict(input="minecraft:iron_ore@0",output="minecraft:iron_ingot@0",count=1)], + crusherRegistry="unit:fixture",crushing=[dict(input="minecraft:potato@0",output="poweradvantage:starch@0",tools=[])],sampledNativeLoot=[]) + +class RecipeAuditTests(unittest.TestCase): + def test_missing_process_functions_and_lookup_errors_remain_required_failures(self): + for mode in native.MODES: + for field,value in (("smelting",[]),("crushing",[]),("crusherRegistry","MISSING"), + ("crusherLookupError","java.lang.NullPointerException")): + doc=fixture(mode);doc[field]=value + with self.subTest(mode=mode,field=field),self.assertRaisesRegex(AssertionError,"smelting/crusher"): + native.mode_checks(doc,mode,True) + + def test_actual_mode_contracts_cover_all_three_modes(self): + self.assertEqual(("NORMAL","TECH_PROGRESSION","APOCALYPTIC"),native.MODES) + for mode in native.MODES: native.mode_checks(fixture(mode),mode,True) + + def test_wrong_mode_rubber_engine_or_empty_registry_is_rejected(self): + for field,value in (("mode","TECH_PROGRESSION"),("rubber",False),("minecraft","1.13.2"),("crafting",[]),("targets",[])): + doc=fixture();doc[field]=value + with self.subTest(field=field),self.assertRaises(AssertionError): native.mode_checks(doc,"NORMAL",True) + + def test_missing_native_consumption_or_negative_control_is_rejected(self): + for field,value in (("consumption","RESULT_ONLY"),("negativeControlsRejected",0),("recipeBookCraftUnlock","NOT_TESTED"),("count",0)): + doc=fixture();doc["crafting"][0][field]=value + with self.subTest(field=field),self.assertRaises(AssertionError): native.mode_checks(doc,"NORMAL",True) + + def test_missing_core_or_duplicate_recipe_cannot_qualify(self): + doc=fixture();doc["targets"].pop() + with self.assertRaises(AssertionError): native.mode_checks(doc,"NORMAL",True) + doc=fixture();doc["crafting"].append(deepcopy(doc["crafting"][0])) + with self.assertRaises(AssertionError): native.mode_checks(doc,"NORMAL",True) + + def test_apocalyptic_does_not_gain_free_chips_sprockets_or_solar(self): + for target in ("poweradvantage:sprocket@0","electricadvantage:integrated_circuit@0","electricadvantage:photovoltaic_generator@0"): + doc=fixture("APOCALYPTIC") + extra=deepcopy(doc["crafting"][0]);extra.update(id="forbidden",output=target);doc["crafting"].append(extra) + with self.subTest(target=target),self.assertRaises(AssertionError): native.mode_checks(doc,"APOCALYPTIC",True) + + def test_pipe_yields_and_tech_chip_complexity_remain_strict(self): + for mode in native.MODES: + doc=fixture(mode);doc["crafting"][0]["count"]=99 + with self.subTest(mode=mode),self.assertRaises(AssertionError):native.mode_checks(doc,mode,True) + doc=fixture("TECH_PROGRESSION") + next(r for r in doc["crafting"] if r["output"]=="electricadvantage:integrated_circuit@0")["inputs"]=["minecraft:dirt@0"]*6 + with self.assertRaises(AssertionError): native.mode_checks(doc,"TECH_PROGRESSION",True) + + def test_wrong_chip_materials_are_not_blessed_by_a_root_flag(self): + for mode in ("NORMAL","TECH_PROGRESSION"): + doc=fixture(mode);doc["chipSemanticsVerified"]=True + chip=next(r for r in doc["crafting"] if r["output"]=="electricadvantage:integrated_circuit@0") + chip["inputs"]=["minecraft:dirt@0"]*len(chip["inputs"]) + chip["providers"]=[[item] for item in chip["inputs"]] + with self.subTest(mode=mode),self.assertRaisesRegex(AssertionError,"Microchip"): + native.mode_checks(doc,mode,True) + + def test_chip_yield_is_three_even_when_native_count_fields_agree(self): + for mode in ("NORMAL","TECH_PROGRESSION"): + for count in (1,2,4,64): + doc=fixture(mode) + chip=next(r for r in doc["crafting"] if r["output"]=="electricadvantage:integrated_circuit@0") + chip.update(count=count,actualCraftedCount=count) + with self.subTest(mode=mode,count=count),self.assertRaisesRegex(AssertionError,"Microchip"): + native.mode_checks(doc,mode,True) + + def test_chip_nuggets_cannot_be_mixed_or_replaced_by_ingots(self): + for mode in ("NORMAL","TECH_PROGRESSION"): + for metals in (["basemetals:copper_nugget@0","basemetals:tin_nugget@0","minecraft:gold_nugget@0"], + ["basemetals:copper_ingot@0"]*3): + doc=fixture(mode);doc["ores"]["ingotCopper"]=["basemetals:copper_ingot@0"] + chip=next(r for r in doc["crafting"] if r["output"]=="electricadvantage:integrated_circuit@0") + chip["inputs"][-3:]=metals;chip["providers"][-3:]=[[item] for item in metals] + with self.subTest(mode=mode,metals=metals),self.assertRaisesRegex(AssertionError,"Microchip"): + native.mode_checks(doc,mode,True) + + def test_chip_requires_actual_native_ore_entries(self): + for mode in ("NORMAL","TECH_PROGRESSION"): + for ore in ("ingotSilicon","nuggetCopper"): + doc=fixture(mode);doc["ores"].pop(ore) + with self.subTest(mode=mode,ore=ore),self.assertRaisesRegex(AssertionError,"Microchip"): + native.mode_checks(doc,mode,True) + + def test_tech_chip_requires_plastic_and_redstone(self): + for slot in (0,1,2): + doc=fixture("TECH_PROGRESSION") + chip=next(r for r in doc["crafting"] if r["output"]=="electricadvantage:integrated_circuit@0") + chip["inputs"][slot]=doc["ores"]["ingotSilicon"][0] + chip["providers"][slot]=[chip["inputs"][slot]] + with self.subTest(slot=slot),self.assertRaisesRegex(AssertionError,"Microchip"): + native.mode_checks(doc,"TECH_PROGRESSION",True) + + def test_chip_requires_complete_consistent_provider_evidence(self): + for mode in ("NORMAL","TECH_PROGRESSION"): + for mutation in ("missing","short","empty","wrong-selected","extra-wrong-choice"): + doc=fixture(mode) + chip=next(r for r in doc["crafting"] if r["output"]=="electricadvantage:integrated_circuit@0") + if mutation=="missing": chip.pop("providers") + elif mutation=="short": chip["providers"].pop() + elif mutation=="empty": chip["providers"][0]=[] + elif mutation=="wrong-selected": + alternate="othermod:valid_chip_ingredient@0" + ore="plastic" if mode=="TECH_PROGRESSION" else "ingotSilicon" + doc["ores"][ore].append(alternate);chip["inputs"][0]=alternate + else: chip["providers"][0].append("minecraft:dirt@0") + with self.subTest(mode=mode,mutation=mutation),self.assertRaisesRegex(AssertionError,"Microchip"): + native.mode_checks(doc,mode,True) + + def test_chip_uses_actual_ore_entries_not_item_name_heuristics(self): + for mode in ("NORMAL","TECH_PROGRESSION"): + doc=fixture(mode) + replacements={values[0]:"othermod:material_"+str(i)+"@7" + for i,values in enumerate(doc["ores"].values())} + doc["ores"]={name:[replacements[item] for item in entries] for name,entries in doc["ores"].items()} + for row in doc["crafting"]: + row["inputs"]=[replacements.get(item,item) for item in row["inputs"]] + row["providers"]=[[replacements.get(item,item) for item in choices] for choices in row["providers"]] + native.mode_checks(doc,mode,True) + + def test_chip_wildcard_ores_accept_native_expanded_providers(self): + doc=fixture();doc["ores"]["ingotSilicon"]=["othermod:silicon@32767"] + for chip in (r for r in doc["crafting"] if r["output"]=="electricadvantage:integrated_circuit@0"): + chip["inputs"][:3]=["othermod:silicon@5"]*3 + chip["providers"][:3]=[["othermod:silicon@5","othermod:silicon@7"] for _ in range(3)] + native.mode_checks(doc,"NORMAL",True) + + def test_chip_provider_wildcards_cannot_broaden_specific_ore_entries(self): + doc=fixture() + chip=next(r for r in doc["crafting"] if r["output"]=="electricadvantage:integrated_circuit@0") + chip["providers"][0]=[chip["inputs"][0].rsplit("@",1)[0]+"@32767"] + with self.assertRaisesRegex(AssertionError,"Microchip"): + native.mode_checks(doc,"NORMAL",True) + + def test_unseeded_self_and_mutual_cycles_are_not_reachable(self): + doc=fixture() + for output,inputs in (("poweradvantage:a@0",["poweradvantage:b@0"]),("poweradvantage:b@0",["poweradvantage:a@0"])): + doc["crafting"].append(dict(id=output,output=output,inputs=inputs,status="PASS")) + doc["targets"].append(output) + result=native.reachability(doc) + self.assertIn("poweradvantage:a@0",result["blocked"]);self.assertIn("poweradvantage:b@0",result["blocked"]) + + def test_generated_loot_configuration_is_not_a_real_acquisition(self): + doc=fixture("APOCALYPTIC");doc["generatedLootConfig"]=["poweradvantage:sprocket@0"] + self.assertNotIn("poweradvantage:sprocket@0",native.reachability(doc)["reachable"]) + doc["sampledNativeLoot"]=[dict(item="poweradvantage:sprocket@0",table="simple_dungeon",seed=4)] + self.assertIn("poweradvantage:sprocket@0",native.reachability(doc)["reachable"]) + doc["mode"]="NORMAL" + self.assertNotIn("poweradvantage:sprocket@0",native.reachability(doc)["reachable"]) + + def test_ore_roots_never_seed_a_crafted_nonblock_part(self): + doc=fixture();doc["ores"]["oreCopper"]=["poweradvantage:sprocket@0"] + self.assertNotIn("poweradvantage:sprocket@0",native.reachability(doc)["reachable"]) + + def test_smelting_requires_a_reachable_furnace_and_fuel(self): + doc=fixture();doc["smelting"]=[dict(input="minecraft:iron_ore@0",output="minecraft:iron_ingot@0",count=1)] + self.assertNotIn("minecraft:iron_ingot@0",native.reachability(doc)["reachable"]) + doc["crafting"].append(dict(id="furnace",inputs=["minecraft:cobblestone@0"]*8,output="minecraft:furnace@0",status="PASS")) + self.assertIn("minecraft:iron_ingot@0",native.reachability(doc)["reachable"]) + + def test_crushing_requires_an_obtainable_valid_hammer(self): + doc=fixture();doc["crushing"]=[dict(input="minecraft:potato@0",output="poweradvantage:starch@0",tools=["basemetals:stone_crackhammer@0"])] + self.assertNotIn("poweradvantage:starch@0",native.reachability(doc)["reachable"]) + doc["crafting"].append(dict(id="hammer",inputs=["minecraft:cobblestone@0"],output="basemetals:stone_crackhammer@0",status="PASS")) + self.assertIn("poweradvantage:starch@0",native.reachability(doc)["reachable"]) + + def test_unverified_native_recipes_and_missing_machine_edges_are_explicit(self): + doc=fixture();doc["crafting"][0].update(status="UNVERIFIED",reason="Missing provider") + result=native.reachability(doc) + self.assertEqual("NOT_TESTED",result["machineProduction"]) + self.assertTrue(result["unverifiedNativeRecipes"]);self.assertTrue(result["practicalGaps"]) + self.assertNotIn("minecraft:water_bucket@0",result["reachable"]) + + def test_native_helper_and_runner_use_real_callbacks_and_never_register_themselves(self): + root=Path(__file__).resolve().parents[1] + java=(root/"src/main/java/zone/moddev/mc/advantageworks/NativeRecipeProgressionProbe.java").read_text() + for text in ("recipe.matches(grid,world)","recipe.getCraftingResult(grid)","new SlotCrafting(","negativeControlsRejected"): + self.assertIn(text,java) + runner=Path(native.__file__).read_text() + for text in ('"auditStatus"','"completeProgression":"PARTIAL"', 'verify_code(runtime,code)', 'return 1 if report["status"]=="FAIL" else 2'): + self.assertIn(text,runner) + self.assertNotIn("tickUpdate(",java) + self.assertIn("recipe.getRemainingItems(grid)",java) + self.assertIn("recipe.getIngredients()",java) + self.assertIn("cell.getMatchingStacks()",java) + self.assertIn("((IShapedRecipe)recipe).getRecipeWidth()",java) + self.assertNotIn('invoke(recipe,"getIngredients")',java) + self.assertNotIn('getMethod("getRemainingItems"',java) + self.assertNotIn('getField("recipeItems")',java) + +if __name__=="__main__": unittest.main() diff --git a/test-rig/migration/test_semantic_machine_audit.py b/test-rig/migration/test_semantic_machine_audit.py new file mode 100644 index 0000000..b5d5ed7 --- /dev/null +++ b/test-rig/migration/test_semantic_machine_audit.py @@ -0,0 +1,272 @@ +"""Deliberately corrupt evidence in ways the frozen operating oracle accepted.""" + +import base64 +import copy +import gzip +import io +import json +import unittest + +import nbtlib +from legacy_connected_oracle import entries, operation as historical_operation +from semantic_machine_audit import BATCH_MACHINES, connected_interval, endurance_batch + + +def fixture(source): + """Pure JSON-compatible connected layout; never stages jars or constructs a world.""" + blocks = [] + + def put(station, role, x, z, name, seed=None, meta=0, y=80): + entry = dict(id=f"{station}-{role}", station=station, kind=role, x=x, y=y, z=z, + name=name, metadata=meta) + if seed is not None: + entry["seed"] = seed + blocks.append(entry) + + def items(values): + return "{Items:[" + ",".join('{Slot:%db,id:"%s",Count:%db,Damage:0s}' % e for e in values) + "]}" + + def supply(station, x, count): + for i in range(count): + bx = x + 1 + i * 4 + put(station, f"water-{i}", bx, 50, "poweradvantage:fluid_metal_tank", + '{Tank:{FluidName:"water",Amount:10000}}') + put(station, f"water-pipe-{i}", bx, 49, "poweradvantage:fluid_pipe") + put(station, f"boiler-{i}", bx, 48, "steamadvantage:steam_boiler_coal", + items([(0, "minecraft:coal", 32)])) + for dx in range(1, 13): + put(station, f"steam-pipe-{dx}", x + dx, 47, "steamadvantage:steam_pipe") + + station = "C-CONVEYOR" + put(station, "input", 1, 40, "minecraft:chest", items([(i, "minecraft:dirt", 64) for i in range(27)])) + put(station, "belt-1", 2, 40, "poweradvantage:item_conveyor", meta=5) + put(station, "belt-2", 3, 40, "poweradvantage:item_conveyor", meta=5) + put(station, "output", 4, 40, "minecraft:chest") + station = "C-STEAM" + supply(station, 64, 3) + put(station, "storage", 76, 46, "steamadvantage:steam_tank") + put(station, "furnace", 68, 46, "steamadvantage:steam_furnace", + items([(0, "minecraft:coal", 32)] + [(i, "minecraft:iron_ore", 64) for i in (1, 2, 3)])) + put(station, "crusher", 74, 46, "steamadvantage:steam_crusher", items([(0, "minecraft:cobblestone", 64)])) + station = "C-ELECTRIC" + supply(station, 128, 2) + put(station, "generator", 141, 47, "electricadvantage:steam_powered_generator") + for x in (142, 144): + put(station, f"wire-{x}", x, 47, "electricadvantage:electric_conduit") + put(station, "battery", 143, 47, "electricadvantage:electric_battery_array", + '{Items:[{Slot:0b,id:"electricadvantage:lithium_battery",Count:1b,Damage:0s,tag:{energy:0.0f}}]}') + put(station, "furnace", 145, 47, "electricadvantage:electric_furnace", items([(0, "minecraft:iron_ore", 64)])) + for station, origin, electric in (("C-SDRILL", 192, False), ("C-EDRILL", 256, True)): + supply(station, origin, 2) + if electric: + put(station, "generator", origin + 13, 47, "electricadvantage:steam_powered_generator") + pipe = "electricadvantage:electric_conduit" + else: + put(station, "steam-link", origin + 13, 47, "steamadvantage:steam_pipe") + pipe = "steamadvantage:steam_pipe" + for dx in (14, 15): + put(station, f"power-link-{dx}", origin + dx, 47, pipe) + for z in range(43, 47): + put(station, f"side-link-{z}", origin + 15, z, pipe) + put(station, "drill", origin + 14, 43, + "electricadvantage:electric_drill" if electric else "steamadvantage:steam_drill", meta=2) + put(station, "rear", origin + 14, 44, "minecraft:chest") + for layer in range(32 if electric else 63): + for dx in (-1, 0, 1) if electric else (0,): + for dy in (-1, 0, 1) if electric else (0,): + put(station, f"target-{layer}-{dx + 1}-{dy + 1}", origin + 14 + dx, 42 - layer, + "minecraft:cobblestone", y=80 + dy) + return dict(format=1, sourceProfile=source, bounds=[0, 78, -32, 280, 84, 64], blocks=blocks) + + +def nbt(entry, value): + stream = io.BytesIO() + nbtlib.File(nbtlib.parse_nbt(value)).write(stream) + entry["nbtBase64"] = base64.b64encode(gzip.compress(stream.getvalue())).decode("ascii") + entry["nbt"] = value + + +def sample(): + document = dict(held=True, requestedTicks=0, advancedTicks=0, buildCallsThisProcess=0, + snapshot=copy.deepcopy(fixture("test")["blocks"])) + for entry in document["snapshot"]: + entry["actualName"] = entry["name"] + if "seed" in entry or entry["kind"] in ("drill", "rear", "output"): + nbt(entry, entry.get("seed", "{Items:[]}")) + entry.update(witness=entry["id"], objectIdentity=len(entry["id"])) + return document + + +class BatchFixture: + """In-memory endpoint evidence only; no server, commands or filesystem writer.""" + def __init__(self): + self.definitions = {name: {"origin": [0, 0, 0]} for name in ("S-04", "E-03", "W-04", "W-03")} + self.snapshots = {name: {} for name in self.definitions} + for station, pos, neighbours, product in ( + ("S-04", (3, 1, 7), ((2, 1, 7), (4, 1, 7)), "iron_ingot"), + ("S-04", (3, 1, 13), ((2, 1, 13), (4, 1, 13)), "gravel"), + ("E-03", (6, 1, 6), (), "iron_ingot"), + ("E-03", (3, 1, 6), (), "gravel"), + ("E-03", (9, 1, 6), (), "cooked_beef")): + self.put(station, pos, [("minecraft:" + product, 4)]) + for neighbour in neighbours: + self.put(station, neighbour) + for x in (2, 3, 4): + self.put("W-04", (x, 1, 6)) + self.put("W-04", (5, 1, 6), [("minecraft:dirt", 16)]) + for z in (9, 15, 18): + self.put("W-04", (2, 1, z)) + self.put("W-04", (3, 1, z)) + forward = [("minecraft:apple", 8)] + if z == 15: + forward.append(("minecraft:dirt", 8)) + self.put("W-04", (4, 1, z), forward) + self.put("W-04", (3, 0, z), [] if z == 15 else [("minecraft:dirt", 8)]) + for x in (4, 6, 8): + self.put("W-03", (x, 1, 8)) + self.entry("W-03", (8, 1, 8))["data"]["Tank"] = { + "FluidName": "poweradvantage:refined_oil", "Amount": 1000} + + def put(self, station, pos, items=()): + self.snapshots[station][pos] = {"pos": pos, "data": {"Items": [ + {"Slot": slot, "id": name, "Count": count} for slot, (name, count) in enumerate(items)]}} + + def entry(self, station, pos): + return self.snapshots[station][pos] + + +def pair(): + before = sample() + before["gameTime"] = 100 + after = copy.deepcopy(before) + after.update(gameTime=164, requestedTicks=64, advancedTicks=64) + rows = entries(after) + source = ','.join('{Slot:%db,id:"minecraft:dirt",Count:%db}' % (slot, 63 if slot == 26 else 64) for slot in range(27)) + nbt(rows["C-CONVEYOR-input"], '{Items:[' + source + ']}') + nbt(rows["C-CONVEYOR-output"], '{Items:[{Slot:0b,id:"minecraft:dirt",Count:1b}]}') + nbt(rows["C-STEAM-furnace"], '{Items:[{Slot:0b,id:"minecraft:coal",Count:32b},' + '{Slot:1b,id:"minecraft:iron_ore",Count:63b},{Slot:2b,id:"minecraft:iron_ore",Count:64b},' + '{Slot:3b,id:"minecraft:iron_ore",Count:64b},{Slot:4b,id:"minecraft:iron_ingot",Count:1b}]}') + for key, raw, product in (("C-ELECTRIC-furnace", "iron_ore", "iron_ingot"), ("C-STEAM-crusher", "cobblestone", "gravel")): + nbt(rows[key], '{Items:[{Slot:0b,id:"minecraft:%s",Count:63b},{Slot:1b,id:"minecraft:%s",Count:1b}]}' % (raw, product)) + for station, product, count in (("C-SDRILL", "gravel", 1), ("C-EDRILL", "cobblestone", 9)): + targets = [row for row in after["snapshot"] if row["station"] == station and row["kind"].startswith("target-")] + for row in targets[:count]: row["actualName"] = "minecraft:air" + nbt(rows[station + "-drill"], '{Items:[{Slot:0b,id:"minecraft:%s",Count:%db}]}' % (product, count)) + return before, after + + +class SemanticMachineAuditTest(unittest.TestCase): + def check(self, before, after): + return connected_interval(before, after, fixture("test"), 64) + + def assertPreviouslyAccepted(self, before, after): + self.assertEqual("PASS", historical_operation(before, after, 64)["status"]) + self.assertEqual("FAIL", self.check(before, after)["status"]) + + def test_real_material_transformation_and_finite_fixture_pass(self): + before, after = pair() + self.assertEqual("PASS", self.check(before, after)["status"]) + + def test_unexpected_items_no_longer_hide_behind_expected_counts(self): + before, after = pair() + nbt(entries(after)["C-CONVEYOR-output"], '{Items:[{Slot:0b,id:"minecraft:dirt",Count:1b},{Slot:1b,id:"minecraft:diamond",Count:1b}]}') + self.assertPreviouslyAccepted(before, after) + + def test_replacement_is_not_mining(self): + before, after = pair() + target = next(row for row in after["snapshot"] if row["station"] == "C-EDRILL" and row["actualName"] == "minecraft:air") + target["actualName"] = "minecraft:diamond_block" + self.assertPreviouslyAccepted(before, after) + + def test_negative_water_is_not_consumed_water(self): + before, after = pair() + nbt(entries(after)["C-STEAM-water-0"], '{Tank:{FluidName:"water",Amount:-1}}') + self.assertPreviouslyAccepted(before, after) + + def test_battery_charge_is_bounded_and_required(self): + for charge in (-1, 8001): + with self.subTest(charge=charge): + before, after = pair() + nbt(entries(after)["C-ELECTRIC-battery"], '{Items:[{Slot:0b,id:"electricadvantage:lithium_battery",Count:1b,tag:{energy:%df}}]}' % charge) + self.assertPreviouslyAccepted(before, after) + + def test_self_reported_tick_count_cannot_replace_advancing_world_time(self): + before, after = pair() + after["gameTime"] = before["gameTime"] + self.assertPreviouslyAccepted(before, after) + + def test_invalid_start_state_cannot_be_ignored(self): + before, after = pair() + nbt(entries(before)["C-ELECTRIC-furnace"], '{Items:[{Slot:0b,id:"minecraft:iron_ore",Count:63b}]}') + self.assertPreviouslyAccepted(before, after) + + def test_counter_only_drill_progress_is_incomplete_not_completed_work(self): + before, after = pair() + for row in after["snapshot"]: + if row["station"] == "C-EDRILL" and row["kind"].startswith("target-"): row["actualName"] = row["name"] + nbt(entries(after)["C-EDRILL-drill"], '{progress:10s,Items:[]}') + self.assertEqual("PASS", historical_operation(before, after, 64)["status"]) + self.assertEqual("INCOMPLETE", self.check(before, after)["status"]) + + def test_no_work_wrong_yield_and_missing_witness_fail(self): + for mutation in ("idle", "lost", "missing", "duplicate", "fuel", "zero-ticks"): + with self.subTest(mutation=mutation): + before, after = pair() + if mutation == "idle": + after = copy.deepcopy(before) + after.update(gameTime=164, requestedTicks=64, advancedTicks=64) + elif mutation == "lost": nbt(entries(after)["C-ELECTRIC-furnace"], '{Items:[]}') + elif mutation == "missing": after["snapshot"].pop() + elif mutation == "duplicate": after["snapshot"].append(copy.deepcopy(after["snapshot"][0])) + elif mutation == "fuel": nbt(entries(after)["C-STEAM-boiler-0"], '{Items:[{Slot:0b,id:"minecraft:coal",Count:33b}]}') + else: after["advancedTicks"] = 0 + self.assertEqual("FAIL", self.check(before, after)["status"]) + + +class IndependentEnduranceTest(unittest.TestCase): + def setUp(self): + self.rig = BatchFixture() + # Complete the legacy self-test's deliberately partial fake-world data. + for pos in ((4, 1, 8), (6, 1, 8)): + self.rig.entry("W-03", pos)["data"]["Tank"] = {"Empty": 1} + self.rig.entry("W-03", (6, 1, 8))["data"]["InputTank"] = {"Empty": 1} + for z, ghosts in ((9, [(1, "dirt"), (2, "cobblestone"), (3, "coal")]), (15, []), (18, [(1, "dirt")])): + self.rig.entry("W-04", (3, 1, z))["data"]["Items"] = [ + {"Slot": slot, "id": "minecraft:" + name, "Count": 1} for slot, name in ghosts] + + def documents(self): + docs = {} + for station in ("S-04", "E-03", "W-04", "W-03"): + rows = [] + for pos, entry in self.rig.snapshots[station].items(): + block = next((block for s, p, _, block, _, _ in BATCH_MACHINES if s == station and p == pos), "minecraft:chest") + rows.append(dict(pos=pos, block=block, nbt=json.dumps(entry["data"]))) + docs[station] = dict(station=station, snapshot=rows) + return docs + + def strict(self): + return endurance_batch(self.documents(), self.rig.definitions) + + def test_complete_measured_batch_passes(self): + self.assertEqual("PASS", self.strict()["status"]) + + def test_extra_hidden_filter_items_are_not_ignored(self): + self.rig.entry("W-04", (3, 1, 18))["data"]["Items"].append({"Slot": 4, "id": "minecraft:diamond", "Count": 1}) + self.assertEqual("FAIL", self.strict()["status"]) + + def test_lost_reference_filter_does_not_pass_as_throughput(self): + self.rig.entry("W-04", (3, 1, 9))["data"]["Items"] = [] + self.assertEqual("FAIL", self.strict()["status"]) + + def test_missing_input_tank_is_not_an_empty_input_tank(self): + del self.rig.entry("W-03", (6, 1, 8))["data"]["InputTank"] + self.assertEqual("FAIL", self.strict()["status"]) + + def test_machine_identity_is_required_not_only_seeded_output(self): + docs = self.documents() + docs["E-03"]["snapshot"][0]["block"] = "minecraft:chest" + self.assertEqual("FAIL", endurance_batch(docs, self.rig.definitions)["status"]) + + +if __name__ == "__main__": unittest.main() diff --git a/test-rig/migration/test_steam_control_audit.py b/test-rig/migration/test_steam_control_audit.py new file mode 100644 index 0000000..b2b62bd --- /dev/null +++ b/test-rig/migration/test_steam_control_audit.py @@ -0,0 +1,412 @@ +"""Synthetic mutation controls for the offline auditor; not native qualification.""" + +import base64 +import contextlib +from copy import deepcopy +import gzip +import io +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +import nbtlib +import steam_control_audit as audit + + +LINE = Path(__file__).resolve().parents[3].name[2:] + + +def snapshot(position, block, data=None): + row = dict(zip(("x", "y", "z"), position), block=block, state=block) + if data is not None: + data = deepcopy(data) + data.update({key: nbtlib.Int(value) for key, value in zip(("x", "y", "z"), position)}) + row["nbt"] = nbtlib.serialize_tag(data) + if LINE == "110": + stream = io.BytesIO() + nbtlib.File(data).write(stream) + row["nbtBase64"] = base64.b64encode(gzip.compress(stream.getvalue())).decode("ascii") + return row + + +def data(item, count, slot, reserve=0, work=None, furnace=None): + entries = [] + if count: + entries.append(nbtlib.Compound(Slot=nbtlib.Byte(slot), Count=nbtlib.Byte(count), + id=nbtlib.String(item), Damage=nbtlib.Short(0))) + value = nbtlib.Compound(Items=nbtlib.List[nbtlib.Compound](entries), + Energy=nbtlib.IntArray([audit.bits(reserve)])) + if work is not None: + value["progress"] = nbtlib.Short(work) + if furnace is not None: + value["Data"] = nbtlib.IntArray(furnace) + return value + + +def fixture(): + base = audit.native(LINE) + ids = sorted({district + "/" + name for district, names in base.REQUIRED.items() for name in names} | audit.IDS) + catalog = {"group": "all", "cases": []} + if LINE in ("110", "112"): + catalog["notApplicable"] = [dict(id="pickup/" + name, status="NOT_APPLICABLE") + for name in ("power-drain", "steam-pump", "electric-pump")] + operation = {"status": "PASS", "held": True, "cases": []} + saved = {"cases": []} + for index, case_id in enumerate(ids): + origin = (50000 + index * 64, 80, 50000) + def at(x): + return (origin[0] + x, origin[1] + 4, origin[2] + 8) + row = dict(id=case_id, status="PASS", startTick=10, finishTick=11, requiredTicks=1, + verifiedAssertions=1, assertions=[dict(phase="verify", status="PASS")]) + if case_id == audit.COMPARATOR: + before, after = [], [] + for i, stock in enumerate((0, 1, 32, 64)): + tile = snapshot(at(3 + 6 * i), "steamadvantage:steam_crusher", + data("minecraft:stone", stock, 0, work=0)) + before.append(tile); after.append(deepcopy(tile)) + row["count/" + str(3 + 6 * i) + "/4/8/minecraft:stone"] = stock + row.update({"comparator.inputs": [0, 1, 32, 64], "comparator.actual": [0, 1, 8, 15]}) + elif case_id in audit.CRUSHERS or case_id in audit.FURNACES: + crusher = case_id in audit.CRUSHERS + signal, initial = audit.CRUSHERS[case_id] if crusher else (audit.FURNACES[case_id], 40) + if crusher: + block, item, slot = "steamadvantage:steam_crusher", "minecraft:stone", 0 + work = 105 if signal or initial == 0 else 107 + reserve = 0 if initial == 0 else 39.9375 if signal else 38.4375 + before_data = data(item, 1, slot, initial, work=106) + after_data = data(item, 1, slot, reserve, work=work) + row["firstLoaded.progress"] = work + else: + block, item, slot = "steamadvantage:steam_furnace", "minecraft:sand", 1 + heat = 989.5 if signal else 1027.0 + work = audit.f32(.25 + audit.f32(audit.f32(3.5e-6) * heat)) + reserve = 40 if signal else 39.5 + before_data = data(item, 1, slot, initial, furnace=[audit.bits(.25), 0, 0, 10, 10, audit.bits(1000)]) + after_data = data(item, 1, slot, reserve, furnace=[audit.bits(work), 0, 0, 9, 10, audit.bits(heat)]) + row.update({"firstLoaded.heat": heat, "firstLoaded.progress": work}) + before = [snapshot(at(8), block, before_data)] + after = [snapshot(at(8), block, after_data)] + if signal: + control = snapshot(at(7), "minecraft:redstone_block") + before.append(control); after.append(deepcopy(control)) + row.update({"firstLoaded.signalled": signal, "firstLoaded.steam": reserve, + "energy/8/4/8/steam": reserve, "count/8/4/8/" + item: 1, + "count/8/4/8/minecraft:" + ("cobblestone" if crusher else "glass"): 0}) + else: + before = [snapshot(at(1), "minecraft:stone")] + after = deepcopy(before) + row.update(before=before, after=after) + if case_id in audit.IDS: + columns = (3, 9, 15, 21) if case_id == audit.COMPARATOR else (8,) + row["control.heldTiles"] = [dict(x=x, y=4, z=8, removed=True, sameTile=True, nbtUnchanged=True) + for x in columns] + row["control.heldAtMeasuredEnd"] = True + catalog["cases"].append(dict(id=case_id, ticks=1)) + operation["cases"].append(row) + saved["cases"].append(dict(id=case_id, index=index, positions=deepcopy(after), entities=[])) + cold = deepcopy(saved) + cold.update(held=True, constructionCallsThisProcess=0) + return dict(catalog=catalog, operation=operation, fixture=saved, cold=cold) + + +def case(bundle, case_id, phase="operation"): + return next(row for row in bundle[phase]["cases"] if row["id"] == case_id) + + +def change_tile(row, line, **changes): + tag = audit.nbt(row, line) + tag.update(changes) + replacement = snapshot(tuple(row[key] for key in ("x", "y", "z")), row["block"], tag) + row.update(replacement) + + +class SteamControlAuditTests(unittest.TestCase): + def verify(self, bundle, status="FAIL"): + result = audit.audit_documents(**bundle, line=LINE) + self.assertEqual(status, result["status"], result) + return result + + def test_full_native_schema_recomputes_operation_and_exact_cold(self): + bundle = fixture(); base = audit.native(LINE) + with patch.object(base, "validate_operation", wraps=base.validate_operation) as operation, \ + patch.object(base, "validate_restart", wraps=base.validate_restart) as cold: + result = self.verify(bundle, "PASS") + operation.assert_called_once(); cold.assert_called_once() + self.assertEqual(audit.IDS, set(result["cases"])) + self.assertEqual(len(bundle["operation"]["cases"]), result["cold"]["cases"]) + + def test_each_control_case_is_required_even_if_all_documents_omit_it(self): + for omitted in audit.IDS: + bundle = fixture() + for document in bundle.values(): + document["cases"] = [r for r in document["cases"] if r["id"] != omitted] + with self.subTest(omitted=omitted): + self.verify(bundle) + + def test_six_passing_control_cases_cannot_replace_the_full_base_group(self): + bundle = fixture() + for document in bundle.values(): + document["cases"] = [r for r in document["cases"] if r["id"] in audit.IDS] + self.verify(bundle) + + def test_each_raw_measurement_is_required_not_just_pass_labels(self): + original = fixture() + for row in original["operation"]["cases"]: + if row["id"] not in audit.IDS: + continue + for key in row: + if not key.startswith(("firstLoaded.", "comparator.", "energy/", "count/")): + continue + bundle = deepcopy(original); case(bundle, row["id"]).pop(key) + with self.subTest(case=row["id"], key=key): + self.verify(bundle) + + def test_every_raw_scalar_mutation_is_rejected(self): + original = fixture() + for row in original["operation"]["cases"]: + if row["id"] not in audit.IDS: + continue + for key, value in row.items(): + if not key.startswith(("firstLoaded.", "energy/", "count/")): + continue + bundle = deepcopy(original) + case(bundle, row["id"])[key] = not value if isinstance(value, bool) else value + 1 + with self.subTest(case=row["id"], key=key): + self.verify(bundle) + + def test_comparator_arrays_reject_mutation_boolean_and_wrong_length(self): + for field in ("comparator.inputs", "comparator.actual"): + for value in ([0, 1, 7, 15], [0, True, 8, 15], [0, 1, 8]): + bundle = fixture(); case(bundle, audit.COMPARATOR)[field] = value + with self.subTest(field=field, value=value): + self.verify(bundle) + + def test_wrong_saved_crusher_progress_is_not_rescued_by_correct_scalar(self): + for case_id in audit.CRUSHERS: + bundle = fixture() + for row in (case(bundle, case_id)["after"][0], + case(bundle, case_id, "fixture")["positions"][0], + case(bundle, case_id, "cold")["positions"][0]): + change_tile(row, LINE, progress=nbtlib.Short(125)) + with self.subTest(case=case_id): + self.verify(bundle) + + def test_wrong_saved_energy_is_not_rescued_by_matching_metrics(self): + for case_id in set(audit.CRUSHERS) | set(audit.FURNACES): + bundle = fixture(); row = case(bundle, case_id) + row["firstLoaded.steam"] = row["energy/8/4/8/steam"] = 12.0 + for record in (row["after"][0], case(bundle, case_id, "fixture")["positions"][0], + case(bundle, case_id, "cold")["positions"][0]): + change_tile(record, LINE, Energy=nbtlib.IntArray([audit.bits(12)])) + with self.subTest(case=case_id): + self.verify(bundle) + + def test_furnace_wrong_work_heat_or_burn_time_rejected_when_cold_matches(self): + for case_id in audit.FURNACES: + for field in range(6): + bundle = fixture(); row = case(bundle, case_id) + for record in (row["after"][0], case(bundle, case_id, "fixture")["positions"][0], + case(bundle, case_id, "cold")["positions"][0]): + tag = audit.nbt(record, LINE) + values = [int(n) for n in tag["Data"]]; values[field] += 1 + change_tile(record, LINE, Data=nbtlib.IntArray(values)) + with self.subTest(case=case_id, field=field): + self.verify(bundle) + + def test_typed_progress_and_energy_cannot_be_substituted_by_equal_numbers(self): + for key, value in (("progress", nbtlib.Int(107)), + ("Energy", nbtlib.List[nbtlib.Int]([audit.bits(38.4375)]))): + bundle = fixture(); case_id = audit.PREFIX + "crusher-first-loaded-unpowered" + for record in (case(bundle, case_id)["after"][0], case(bundle, case_id, "fixture")["positions"][0], + case(bundle, case_id, "cold")["positions"][0]): + change_tile(record, LINE, **{key: value}) + with self.subTest(field=key): + self.verify(bundle) + + def test_inventory_quantity_identity_slot_or_extra_output_rejected(self): + case_id = audit.PREFIX + "crusher-first-loaded-unpowered" + for changed in ("quantity", "identity", "slot", "extra-output", "count-type"): + bundle = fixture() + for record in (case(bundle, case_id)["after"][0], case(bundle, case_id, "fixture")["positions"][0], + case(bundle, case_id, "cold")["positions"][0]): + tag = audit.nbt(record, LINE); stack = tag["Items"][0] + if changed == "quantity": stack["Count"] = nbtlib.Byte(2) + elif changed == "identity": stack["id"] = nbtlib.String("minecraft:dirt") + elif changed == "slot": stack["Slot"] = nbtlib.Byte(1) + elif changed == "count-type": stack["Count"] = nbtlib.Int(1) + else: tag["Items"].append(nbtlib.Compound(Slot=nbtlib.Byte(1), Count=nbtlib.Byte(1), + id=nbtlib.String("minecraft:cobblestone"))) + change_tile(record, LINE, Items=tag["Items"]) + with self.subTest(mutation=changed): + self.verify(bundle) + + def test_comparator_inventory_must_match_the_measured_stock(self): + bundle = fixture() + for record in (case(bundle, audit.COMPARATOR)["after"][2], + case(bundle, audit.COMPARATOR, "fixture")["positions"][2], + case(bundle, audit.COMPARATOR, "cold")["positions"][2]): + tag = audit.nbt(record, LINE); tag["Items"][0]["Count"] = nbtlib.Byte(31) + change_tile(record, LINE, Items=tag["Items"]) + self.verify(bundle) + + def test_first_tick_is_not_a_later_tick_or_boolean_counter(self): + case_id = next(iter(audit.CRUSHERS)) + for field, value in (("finishTick", 12), ("requiredTicks", True), ("startTick", True)): + bundle = fixture(); case(bundle, case_id)[field] = value + with self.subTest(field=field): + self.verify(bundle) + + def test_inconsistent_after_fixture_is_rejected_even_when_cold_agrees(self): + bundle = fixture(); case_id = next(iter(audit.CRUSHERS)) + for phase in ("fixture", "cold"): + case(bundle, case_id, phase)["positions"][0]["state"] += "[changed]" + self.verify(bundle) + + def test_cold_is_recomputed_for_unrelated_cases_not_only_the_six(self): + bundle = fixture() + other = next(row for row in bundle["cold"]["cases"] if row["id"] not in audit.IDS) + other["positions"][0]["block"] = "minecraft:air" + result = self.verify(bundle) + self.assertEqual("FAIL", result["cold"]["status"]) + + def test_missing_unrelated_cold_case_cannot_shrink_the_restart_oracle(self): + bundle = fixture(); omitted = next(r["id"] for r in bundle["fixture"]["cases"] if r["id"] not in audit.IDS) + for phase in ("fixture", "cold"): + bundle[phase]["cases"] = [r for r in bundle[phase]["cases"] if r["id"] != omitted] + self.verify(bundle) + + def test_failed_unrelated_operation_is_not_rescued_by_root_pass(self): + bundle = fixture() + next(r for r in bundle["operation"]["cases"] if r["id"] not in audit.IDS)["verifiedAssertions"] = 0 + result = self.verify(bundle) + self.assertEqual("FAIL", result["operation"]["status"]) + + def test_redstone_control_requires_the_actual_adjacent_block(self): + bundle = fixture(); case_id = audit.PREFIX + "crusher-first-loaded-signalled" + for collection in (case(bundle, case_id)["before"], case(bundle, case_id)["after"], + case(bundle, case_id, "fixture")["positions"], case(bundle, case_id, "cold")["positions"]): + collection[1].update(block="minecraft:air", state="minecraft:air") + self.verify(bundle) + + def test_duplicate_cases_and_positions_fail_closed(self): + for mutation in ("case", "position"): + bundle = fixture() + if mutation == "case": bundle["operation"]["cases"].append(deepcopy(bundle["operation"]["cases"][0])) + else: + row = case(bundle, audit.COMPARATOR); row["after"].append(deepcopy(row["after"][0])) + with self.subTest(mutation=mutation): + self.verify(bundle) + + def test_nonfinite_or_boolean_raw_number_is_not_valid_measurement(self): + for value in (float("nan"), float("inf"), True): + bundle = fixture(); case(bundle, next(iter(audit.CRUSHERS)))["firstLoaded.steam"] = value + with self.subTest(value=value): + self.verify(bundle) + + def test_offline_cli_reads_raw_reports_and_never_trusts_a_summary(self): + bundle = fixture() + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) + for name, relative in audit.INPUTS.items(): + path = root / relative; path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(bundle[name]), encoding="utf-8") + summary = root / ("native" + LINE + "-machine-operation-results.json") + summary.write_text('{"status":"FAIL"}', encoding="utf-8") + with contextlib.redirect_stdout(io.StringIO()) as stream: + self.assertEqual(0, audit.main(["--runtime", folder, "--line", LINE])) + result = json.loads(stream.getvalue()) + self.assertEqual("PASS", result["status"]); self.assertEqual(4, len(result["inputSha256"])) + self.assertEqual('{"status":"FAIL"}', summary.read_text(encoding="utf-8")) + (root / audit.INPUTS["cold"]).unlink() + with contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(1, audit.main(["--runtime", folder, "--line", LINE])) + + def test_binary_nbt_cannot_be_overruled_by_correct_looking_text(self): + bundle = fixture(); case_id = audit.PREFIX + "crusher-first-loaded-unpowered" + for record in (case(bundle, case_id)["after"][0], case(bundle, case_id, "fixture")["positions"][0], + case(bundle, case_id, "cold")["positions"][0]): + tag = audit.nbt(record, LINE); tag["progress"] = nbtlib.Short(125) + stream = io.BytesIO(); nbtlib.File(tag).write(stream) + record["nbtBase64"] = base64.b64encode(gzip.compress(stream.getvalue())).decode("ascii") + self.verify(bundle) + + def test_missing_saved_tile_nbt_is_not_replaced_by_scalar_measurements(self): + bundle = fixture(); case_id = audit.PREFIX + "crusher-first-loaded-unpowered" + for record in (case(bundle, case_id)["after"][0], case(bundle, case_id, "fixture")["positions"][0], + case(bundle, case_id, "cold")["positions"][0]): + record.pop("nbt", None); record.pop("nbtBase64", None) + self.verify(bundle) + + def test_cold_must_be_held_and_have_no_reconstruction(self): + for field, value in (("held", False), ("constructionCallsThisProcess", 1)): + bundle = fixture(); bundle["cold"][field] = value + with self.subTest(field=field): + self.verify(bundle) + + def test_every_control_requires_both_explicit_hold_measurements(self): + for case_id in audit.IDS: + for field in ("control.heldTiles", "control.heldAtMeasuredEnd"): + bundle = fixture(); case(bundle, case_id).pop(field) + with self.subTest(case=case_id, missing=field): + self.verify(bundle) + + def test_hold_at_end_is_a_true_boolean_not_a_truthy_label(self): + for case_id in audit.IDS: + for value in (False, 1, "true", None): + bundle = fixture(); case(bundle, case_id)["control.heldAtMeasuredEnd"] = value + with self.subTest(case=case_id, value=value): + self.verify(bundle) + + def test_each_hold_requires_removal_same_tile_and_unchanged_nbt(self): + for case_id in audit.IDS: + for field in ("removed", "sameTile", "nbtUnchanged"): + for value in ("missing", False, 1, "true", None): + bundle = fixture(); held = case(bundle, case_id)["control.heldTiles"][0] + if value == "missing": held.pop(field) + else: held[field] = value + with self.subTest(case=case_id, field=field, value=value): + self.verify(bundle) + + def test_hold_records_are_complete_unique_and_not_extra_tiles(self): + for case_id in audit.IDS: + for mutation in ("empty", "missing", "duplicate", "extra", "not-list"): + bundle = fixture(); row = case(bundle, case_id); held = row["control.heldTiles"] + if mutation == "empty": row["control.heldTiles"] = [] + elif mutation == "missing": held.pop() + elif mutation == "duplicate": + if len(held) > 1: held[-1] = deepcopy(held[0]) + else: held.append(deepcopy(held[0])) + elif mutation == "extra": + held.append(dict(x=2, y=4, z=8, removed=True, sameTile=True, nbtUnchanged=True)) + else: row["control.heldTiles"] = {"allHeld": True} + with self.subTest(case=case_id, mutation=mutation): + self.verify(bundle) + + def test_hold_coordinates_must_name_the_actual_measured_control_tiles(self): + for case_id in audit.IDS: + for field in ("x", "y", "z"): + for mutation in ("different", "boolean", "missing"): + bundle = fixture(); held = case(bundle, case_id)["control.heldTiles"][0] + if mutation == "different": held[field] += 1 + elif mutation == "boolean": held[field] = True + else: held.pop(field) + with self.subTest(case=case_id, field=field, mutation=mutation): + self.verify(bundle) + + def test_hold_flags_never_rescue_after_fixture_or_cold_nbt_drift(self): + for stage in ("fixture", "cold"): + bundle = fixture(); case_id = audit.PREFIX + "crusher-first-loaded-unpowered" + record = case(bundle, case_id, stage)["positions"][0] + change_tile(record, LINE, progress=nbtlib.Short(0)) + with self.subTest(stage=stage): + self.verify(bundle) + + def test_json_duplicate_keys_are_not_silently_overwritten(self): + with self.assertRaises(AssertionError): + json.loads('{"status":"FAIL","status":"PASS"}', object_pairs_hook=audit.unique_object) + + +if __name__ == "__main__": + unittest.main() diff --git a/test-rig/settings.gradle b/test-rig/settings.gradle new file mode 100644 index 0000000..9a6c843 --- /dev/null +++ b/test-rig/settings.gradle @@ -0,0 +1,4 @@ +plugins { + id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0' +} +rootProject.name = 'AdvantageWorksTestRig' diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/AdvantageWorksMod.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/AdvantageWorksMod.java new file mode 100644 index 0000000..62d19f6 --- /dev/null +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/AdvantageWorksMod.java @@ -0,0 +1,24 @@ +package zone.moddev.mc.advantageworks; + +import net.minecraftforge.fml.common.Mod; +import net.minecraftforge.fml.common.event.FMLPreInitializationEvent; +import net.minecraftforge.fml.common.event.FMLServerStartingEvent; + +@Mod(modid = "advantageworkstest", name = "Advantage Works Test Harness", version = "1.1.0.112021", + acceptableRemoteVersions = "*", dependencies = "required-after:poweradvantage") +public final class AdvantageWorksMod { + @Mod.Instance("advantageworkstest") public static AdvantageWorksMod instance; + @Mod.EventHandler public void preInit(FMLPreInitializationEvent event) { + net.minecraftforge.common.ForgeChunkManager.setForcedChunkLoadingCallback(this, (tickets, world) -> { + for (net.minecraftforge.common.ForgeChunkManager.Ticket ticket : tickets) + for (net.minecraft.util.math.ChunkPos chunk : ticket.getChunkList()) + net.minecraftforge.common.ForgeChunkManager.forceChunk(ticket, chunk); + }); + MachineOperationalProbe.register(); + } + @Mod.EventHandler public void serverStarting(FMLServerStartingEvent event) { + MachineOperationalProbe.registerCommands(event); + NativeHarvestProbe.registerCommands(event); + NativeRecipeProgressionProbe.registerCommands(event); + } +} diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/BatteryOperationalCases.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/BatteryOperationalCases.java new file mode 100644 index 0000000..a32a8d3 --- /dev/null +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/BatteryOperationalCases.java @@ -0,0 +1,38 @@ +package zone.moddev.mc.advantageworks; + +import cyano.poweradvantage.api.ConduitType; +import cyano.poweradvantage.api.PoweredEntity; +import java.util.List; +import net.minecraft.block.properties.PropertyBool; + +final class BatteryOperationalCases { + private static final PropertyBool LED_LIT = PropertyBool.create("lit"); + + static void register(List cases) { + String[] chemistries = {"lead_acid", "nickel_hydride", "alkaline", "lithium"}; + int[] capacities = {1000, 2000, 4000, 8000}; + for (int index = 0; index < chemistries.length; index++) { + final String chemistry = chemistries[index]; + final int capacity = capacities[index]; + cases.add(new MachineScenario("electric/battery-chemistry-" + chemistry, + "Each battery chemistry supplies a real LED from finite item NBT, retaining its rated capacity and exact energy debit", 160, + c -> { + c.block(7, 4, 8, "electricadvantage:electric_battery_array"); + c.put(7, 4, 8, 0, "electricadvantage:" + chemistry + "_battery", 1); + NativeStacks.tag(c.inventory(7, 4, 8).getStackInSlot(0)).setFloat("energy", 10); + c.block(8, 4, 8, "electricadvantage:led_bar"); + }, c -> { + PoweredEntity array = (PoweredEntity)c.tile(7, 4, 8); + c.require(array.getEnergyCapacity(new ConduitType("electricity")) == capacity, "rated battery capacity retained"); + c.require(c.world.getBlockState(c.at(8, 4, 8)).getValue(LED_LIT), "battery powers the live LED"); + net.minecraft.util.math.BlockPos source = c.at(7, 4, 8), sink = c.at(8, 4, 8); + float debit = ElectricOperationalRates.ledDebit(c.activationTick, + ElectricOperationalRates.offset(source.getX(), source.getY(), source.getZ()), + ElectricOperationalRates.offset(sink.getX(), sink.getY(), sink.getZ()), 160, 10); + float remaining = c.energy(7, 4, 8, "electricity") + c.energy(8, 4, 8, "electricity"); + c.require(debit > 0 && Math.abs(10 - remaining - debit) < 0.001f, "exact finite charge is conserved apart from rated lighting cost"); + c.require(c.inventory(7, 4, 8).getStackInSlot(0).getCount() == 1, "battery item is retained, not consumed"); + })); + } + } +} diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/BucketOperationalCases.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/BucketOperationalCases.java new file mode 100644 index 0000000..69bd5a3 --- /dev/null +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/BucketOperationalCases.java @@ -0,0 +1,219 @@ +package zone.moddev.mc.advantageworks; + +import com.mojang.authlib.GameProfile; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.UUID; +import net.minecraft.init.Items; +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.network.EnumPacketDirection; +import net.minecraft.network.NetHandlerPlayServer; +import net.minecraft.network.NetworkManager; +import net.minecraft.network.Packet; +import net.minecraft.util.ActionResult; +import net.minecraft.util.EnumActionResult; +import net.minecraft.util.EnumFacing; +import net.minecraft.util.EnumHand; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.RayTraceResult; +import net.minecraft.util.math.Vec3d; +import net.minecraft.world.GameType; +import net.minecraftforge.common.ForgeModContainer; +import net.minecraftforge.common.util.FakePlayer; +import net.minecraftforge.fluids.Fluid; +import net.minecraftforge.fluids.FluidRegistry; +import net.minecraftforge.fluids.FluidStack; +import net.minecraftforge.fluids.FluidTank; +import net.minecraftforge.fluids.FluidUtil; +import net.minecraftforge.fluids.IFluidBlock; +import net.minecraftforge.fluids.UniversalBucket; +import net.minecraftforge.fml.common.Loader; + +/** Native item interactions run in setup; verification and restart inspection never create fluid. */ +final class BucketOperationalCases { + private static final int VOLUME = 1000; + private static final int X = 12, Y = 2, Z = 12; + + private BucketOperationalCases() {} + + static void register(List cases) { + cases.add(new MachineScenario("power/bucket/startup", + "Forge's universal bucket is enabled, registered and available independently of Base Metals. " + + "Standalone mode additionally rejects Steam, Electric and Base Metals.", 1, + c -> { + c.block(6, Y, Z, "minecraft:chest"); + startup(c); + }, c -> { + startup(c); + c.require(c.tile(6, Y, Z) instanceof net.minecraft.inventory.IInventory, + "The startup witness remains present for exact cold-state inspection"); + })); + for (String fluid : new String[]{"crude_oil", "refined_oil"}) roundTrip(cases, fluid); + } + + private static UniversalBucket startup(MachineContext c) { + c.record("bucket/enabled", FluidRegistry.isUniversalBucketEnabled()); + c.require(FluidRegistry.isUniversalBucketEnabled(), "Universal bucket opt-in is enabled"); + UniversalBucket bucket = ForgeModContainer.getInstance().universalBucket; + c.record("bucket/created", bucket != null); + c.require(bucket != null, "Forge actually created its universal bucket before our fluid setup"); + c.record("bucket/registered", bucket.getRegistryName() != null + && Item.REGISTRY.getObject(bucket.getRegistryName()) == bucket); + c.require(bucket.getRegistryName() != null + && Item.REGISTRY.getObject(bucket.getRegistryName()) == bucket, + "Universal bucket is a real registered item, not merely an enabled flag"); + c.record("bucket/item", bucket.getRegistryName().toString()); + for (String mod : new String[]{"basemetals", "steamadvantage", "electricadvantage", "orespawn"}) + c.record("bucket/mod/" + mod, Loader.isModLoaded(mod)); + if (Boolean.getBoolean("advworks.bucket.requireStandalone")) + for (String mod : new String[]{"basemetals", "steamadvantage", "electricadvantage"}) + c.require(!Loader.isModLoaded(mod), "Standalone bucket proof excludes " + mod); + return bucket; + } + + private static final class Witness { + Fluid fluid; + FluidTank source; + NBTTagCompound recovered; + } + + private static void roundTrip(List cases, String name) { + Witness witness = new Witness(); + cases.add(new MachineScenario("power/bucket/" + name, + "One finite 1000-mB oil packet fills a native empty bucket, is placed through the registered " + + "bucket's real right-click ray, and is collected through the vanilla empty bucket and Forge " + + "event handler. The recovered registered bucket retains exact typed fluid NBT and amount " + + "in a chest; no source block or extra fluid remains.", 20, c -> { + UniversalBucket bucket = startup(c); + witness.fluid = NativeFluids.get(name); + c.require(witness.fluid != null && witness.fluid.getBlock() != null, + "Oil has a registered fluid and placeable block: " + name); + c.require(FluidRegistry.getBucketFluids().contains(witness.fluid), + "Oil is registered with the universal bucket"); + c.block(6, Y, Z, "minecraft:chest"); + for (int dx = -1; dx <= 1; dx++) for (int dz = -1; dz <= 1; dz++) + c.block(X + dx, Y - 1, Z + dz, "minecraft:stone"); + for (EnumFacing face : new EnumFacing[]{EnumFacing.NORTH, EnumFacing.SOUTH, EnumFacing.WEST, EnumFacing.EAST}) { + BlockPos wall = c.at(X, Y, Z).offset(face); + c.block(wall.getX() - c.origin.getX(), Y, wall.getZ() - c.origin.getZ(), "minecraft:stone"); + } + c.block(X, Y, Z, "minecraft:air"); + + witness.source = new FluidTank(new FluidStack(witness.fluid, VOLUME), VOLUME); + ItemStack emptyBucket = new ItemStack(Items.BUCKET); + ItemStack simulated = fill(emptyBucket, witness.source, false); + requireFilled(c, simulated, bucket, witness.fluid); + c.require(witness.source.getFluidAmount() == VOLUME, "Simulated filling does not drain the donor"); + c.require(emptyBucket.getItem() == Items.BUCKET && count(emptyBucket) == 1 + && FluidUtil.getFluidContained(emptyBucket) == null, "Filling does not mutate its input prototype"); + ItemStack filled = fill(emptyBucket, witness.source, true); + requireFilled(c, filled, bucket, witness.fluid); + c.require(witness.source.getFluidAmount() == 0, "Actual filling drains exactly 1000 mB"); + + FluidTank scarce = new FluidTank(new FluidStack(witness.fluid, VOLUME - 1), VOLUME); + c.require(count(fill(new ItemStack(Items.BUCKET), scarce, true)) == 0, + "A 999-mB packet cannot manufacture a full bucket"); + c.require(scarce.getFluidAmount() == VOLUME - 1, "Failed filling preserves the scarce control"); + + FakePlayer player = player(c, name); + player.setHeldItem(EnumHand.MAIN_HAND, filled); + requireRay(c, player, false, c.at(X, Y - 1, Z)); + ActionResult placed = click(c, player); + c.require(placed.getType() == EnumActionResult.SUCCESS, "Registered filled bucket right-click succeeds"); + ItemStack returned = placed.getResult(); + c.require(count(returned) == 1 && returned.getItem() == Items.BUCKET + && FluidUtil.getFluidContained(returned) == null, "Survival placement returns one empty bucket"); + player.setHeldItem(EnumHand.MAIN_HAND, returned); + c.require(c.world.getBlockState(c.at(X, Y, Z)).getBlock() == witness.fluid.getBlock(), + "Placed block is the exact oil, not vanilla water"); + c.require(c.world.getBlockState(c.at(X, Y, Z)).getBlock() instanceof IFluidBlock, + "Placed oil exposes its native drain contract"); + FluidStack preview = ((IFluidBlock)witness.fluid.getBlock()).drain(c.world, c.at(X, Y, Z), false); + c.require(preview != null && preview.getFluid() == witness.fluid && preview.amount == VOLUME, + "Placed source represents exactly the one finite oil packet"); + c.record("bucket/placedFluid", witness.fluid.getName()); + c.record("bucket/placedAmount", preview.amount); + + requireRay(c, player, true, c.at(X, Y, Z)); + ActionResult collected = click(c, player); + c.require(collected.getType() == EnumActionResult.SUCCESS, + "Vanilla empty bucket invokes Forge's real pickup event successfully"); + ItemStack recovered = collected.getResult(); + requireFilled(c, recovered, bucket, witness.fluid); + c.record("bucket/sourceGone", c.world.isAirBlock(c.at(X, Y, Z))); + c.require(c.world.isAirBlock(c.at(X, Y, Z)), "Collection removes the source exactly once"); + witness.recovered = (NBTTagCompound) recovered.writeToNBT(new NBTTagCompound()).copy(); + player.setHeldItem(EnumHand.MAIN_HAND, ItemStack.EMPTY); + c.inventory(6, Y, Z).setInventorySlotContents(0, recovered); + c.inventory(6, Y, Z).markDirty(); + c.record("bucket/recoveredNbt", witness.recovered.toString()); + c.record("bucket/donorRemaining", witness.source.getFluidAmount()); + }, c -> { + UniversalBucket bucket = startup(c); + ItemStack stored = c.inventory(6, Y, Z).getStackInSlot(0); + requireFilled(c, stored, bucket, witness.fluid); + c.require(witness.recovered.equals(stored.writeToNBT(new NBTTagCompound())), + "Exact recovered item and typed NBT survive ordinary world ticks"); + c.require(witness.source.getFluidAmount() == 0, "The finite donor remains empty"); + c.require(c.world.isAirBlock(c.at(X, Y, Z)), "No hidden or regenerated oil source remains"); + for (int slot = 1; slot < c.inventory(6, Y, Z).getSizeInventory(); slot++) + c.require(count(c.inventory(6, Y, Z).getStackInSlot(slot)) == 0, "No duplicate output in chest slot " + slot); + })); + } + + private static void requireFilled(MachineContext c, ItemStack stack, UniversalBucket bucket, Fluid expected) { + c.require(count(stack) == 1 && stack.getItem() == bucket, "Exactly one real universal bucket holds the oil"); + FluidStack contained = FluidUtil.getFluidContained(stack); + c.require(contained != null && contained.getFluid() == expected && contained.amount == VOLUME, + "Native bucket capability retains exact fluid identity and 1000-mB quantity"); + NBTTagCompound data = stack.writeToNBT(new NBTTagCompound()); + c.require(data.hasKey("id", 8) && data.hasKey("Count", 1) && data.getByte("Count") == 1 + && data.hasKey("tag", 10), "Stored item uses typed native registry/count/container NBT"); + NBTTagCompound tag = data.getCompoundTag("tag"); + c.require(tag.hasKey("FluidName", 8) && tag.getString("FluidName").equals(expected.getName()) + && tag.hasKey("Amount", 3) && tag.getInteger("Amount") == VOLUME, + "Container NBT stores the exact fluid name and integer quantity"); + FluidStack restored = FluidUtil.getFluidContained(new ItemStack(data)); + c.require(restored != null && restored.getFluid() == expected && restored.amount == VOLUME, + "Serialized native item restores the same fluid packet"); + } + + private static ItemStack fill(ItemStack empty, FluidTank source, boolean execute) { + net.minecraftforge.fluids.FluidActionResult result = + FluidUtil.tryFillContainer(empty, source, VOLUME, null, execute); + return result.isSuccess() ? result.getResult() : ItemStack.EMPTY; + } + + private static ActionResult click(MachineContext c, FakePlayer player) { + ItemStack held = player.getHeldItem(EnumHand.MAIN_HAND); + return held.getItem().onItemRightClick(c.world, player, EnumHand.MAIN_HAND); + } + + private static FakePlayer player(MachineContext c, String fluid) { + UUID uuid = UUID.nameUUIDFromBytes(("AdvWorksBucket/" + fluid + "/" + c.origin).getBytes(StandardCharsets.UTF_8)); + FakePlayer player = new FakePlayer(c.world, new GameProfile(uuid, "BucketWitness")); + player.connection = new NetHandlerPlayServer(c.world.getMinecraftServer(), + new NetworkManager(EnumPacketDirection.SERVERBOUND), player) { + @Override public void sendPacket(Packet packet) { } + }; + player.interactionManager.setGameType(GameType.SURVIVAL); + player.setPositionAndRotation(c.at(X, Y + 2, Z).getX() + 0.5, + c.at(X, Y + 2, Z).getY(), c.at(X, Y + 2, Z).getZ() + 0.5, 0, 90); + c.require(!player.capabilities.isCreativeMode && !player.isSpectator(), "Native bucket interaction uses survival"); + return player; + } + + private static void requireRay(MachineContext c, FakePlayer player, boolean fluid, BlockPos expected) { + Vec3d eye = new Vec3d(player.posX, player.posY + player.getEyeHeight(), player.posZ); + RayTraceResult ray = c.world.rayTraceBlocks(eye, eye.add(0, -5, 0), fluid, !fluid, false); + c.require(ray != null && ray.typeOfHit == RayTraceResult.Type.BLOCK + && ray.getBlockPos().equals(expected) && ray.sideHit == EnumFacing.UP, + "Native downward ray reaches the intended floor/source, not a nearby wall"); + } + + private static int count(ItemStack stack) { + return stack == null || stack.isEmpty() ? 0 : stack.getCount(); + } +} diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/DrillOperationalCases.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/DrillOperationalCases.java new file mode 100644 index 0000000..a4101e9 --- /dev/null +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/DrillOperationalCases.java @@ -0,0 +1,223 @@ +package zone.moddev.mc.advantageworks; + +import cyano.poweradvantage.api.ConduitType; +import cyano.poweradvantage.api.PoweredEntity; +import java.util.List; +import net.minecraft.block.properties.IProperty; +import net.minecraft.block.state.IBlockState; +import net.minecraft.entity.item.EntityItem; +import net.minecraft.inventory.IInventory; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.tileentity.TileEntity; +import net.minecraft.util.EnumFacing; +import net.minecraft.util.math.AxisAlignedBB; +import net.minecraft.util.math.BlockPos; + +/** Finite track moves on native world ticks; no direct calls to drill update/movement methods. */ +final class DrillOperationalCases { + private static final int TICKS = 80; + private static final int X = 12, Y = 8, Z = 12; + private static final float STEAM_LEAK_PER_TICK = 0.0625F; + + private DrillOperationalCases() {} + + static void register(List cases) { + for (String district : new String[]{"steam", "electric"}) { + for (EnumFacing facing : EnumFacing.values()) { + for (String mode : new String[]{"move", "no-power", "redstone-blocked"}) { + scenario(cases, district, facing, mode); + } + } + } + } + + private static final class Witness { + NBTTagCompound[] slots; + NBTTagCompound audit; + String customName, tileId; + int inventorySize; + } + + private static void scenario(List cases, String district, EnumFacing facing, String mode) { + boolean steam = district.equals("steam"); + boolean move = mode.equals("move"), noPower = mode.equals("no-power"); + String namespace = steam ? "steamadvantage" : "electricadvantage"; + String drill = namespace + ":" + district + "_drill"; + String track = namespace + ":" + district + "_track"; + String conduit = namespace + (steam ? ":steam_pipe" : ":electric_conduit"); + String energyType = steam ? "steam" : "electricity"; + // The single track is perpendicular to the bore, never in its input/output path. + EnumFacing travel = facing.getAxis() == EnumFacing.Axis.X ? EnumFacing.SOUTH : EnumFacing.EAST; + BlockPos source = new BlockPos(X, Y, Z), destination = source.offset(travel); + BlockPos front = source.offset(facing), nextFront = destination.offset(facing); + BlockPos hold = destination.offset(travel); + float reserve = noPower ? 0 : steam ? 50 : 500; + float moveCost = steam ? 10 : 250; + Witness witness = new Witness(); + String id = district + "/drill-track-" + facing.getName() + "-" + mode; + cases.add(new MachineScenario(id, + "A " + facing.getName() + "-facing " + district + " drill takes one perpendicular track step on natural ticks, " + + "retaining every inventory slot, item/tile custom NBT and facing, leaving a conduit and steel frame, " + + "and paying exactly its movement cost. Bedrock stops the bore and a destination redstone hold prevents " + + "further work; no-power and source-redstone controls must not move or spend movement energy.", + TICKS, c -> { + c.block(X, Y, Z, drill); + c.facing(X, Y, Z, facing.getName()); + block(c, destination, track); + block(c, front, "minecraft:bedrock"); + block(c, nextFront, "minecraft:bedrock"); + block(c, hold, "minecraft:redstone_block"); + if (mode.equals("redstone-blocked")) block(c, source.offset(travel.getOpposite()), "minecraft:redstone_block"); + c.require(c.world.isBlockPowered(at(c, source)) == mode.equals("redstone-blocked"), + "Only the redstone-blocked control has a powered source position"); + c.require(c.world.isBlockPowered(at(c, destination)), + "The destination has a real redstone hold before the move"); + PoweredEntity original = (PoweredEntity) c.tile(X, Y, Z); + try { + c.require(original.getClass().getField("ENERGY_COST_MOVE").getFloat(null) == moveCost, + "Native rated movement cost is " + moveCost + " " + energyType); + } catch (ReflectiveOperationException error) { + throw new IllegalStateException("Native drill movement rating unavailable", error); + } + witness.inventorySize = c.inventory(X, Y, Z).getSizeInventory(); + c.require(witness.inventorySize == (steam ? 5 : 6), "Native drill inventory is available in full"); + c.put(X, Y, Z, 0, "minecraft:dirt", 11); + c.put(X, Y, Z, witness.inventorySize - 1, "minecraft:cobblestone", 7); + for (int slot : new int[]{0, witness.inventorySize - 1}) { + NBTTagCompound itemTag = NativeStacks.tag(c.inventory(X, Y, Z).getStackInSlot(slot)); + itemTag.setString("AdvantageWorksPayload", id + "/slot-" + slot); + itemTag.setIntArray("Witness", new int[]{slot, 11, 7, facing.getIndex()}); + } + witness.customName = "Drill witness " + id; + witness.audit = new NBTTagCompound(); + witness.audit.setString("case", id); + witness.audit.setIntArray("origin", new int[]{at(c, source).getX(), at(c, source).getY(), at(c, source).getZ()}); + NBTTagCompound forgeData = new NBTTagCompound(); + forgeData.setTag("AdvantageWorksDrill", witness.audit.copy()); + NBTTagCompound patch = new NBTTagCompound(); + patch.setString("CustomName", witness.customName); + patch.setTag("ForgeData", forgeData); + c.seed(X, Y, Z, patch); + c.power(X, Y, Z, energyType, reserve); + NBTTagCompound saved = c.nbt(X, Y, Z); + witness.tileId = saved.getString("id"); + c.require(!witness.tileId.isEmpty(), "Original drill has a persistent tile identity"); + witness.slots = inventorySnapshot(c.inventory(X, Y, Z)); + c.require(itemCount(c.inventory(X, Y, Z)) == 18, "Exactly eighteen tagged cargo items are seeded"); + c.require(drops(c).isEmpty(), "No dropped resources exist before natural operation"); + c.record("drill/source", xyz(at(c, source))); + c.record("drill/destination", xyz(at(c, destination))); + c.record("drill/frontFacing", facing.getName()); + c.record("drill/trackDirection", travel.getName()); + c.record("drill/initialEnergy", reserve); + c.record("drill/movementCost", moveCost); + }, c -> { + BlockPos active = move ? destination : source; + requireBlock(c, source, move ? conduit : drill, "Source becomes the correct conduit only after a successful move"); + requireBlock(c, destination, move ? drill : track, "Destination contains the moved drill, or the untouched single track in a control"); + requireBlock(c, front, move ? "poweradvantage:steel_frame" : "minecraft:bedrock", + "The old bore mouth becomes one steel frame only on movement"); + requireBlock(c, nextFront, "minecraft:bedrock", "Destination bedrock stop is never mined"); + requireBlock(c, hold, "minecraft:redstone_block", "Destination hold remains physically present"); + c.require(!move || c.world.isBlockPowered(at(c, active)), "Moved drill is held by real redstone"); + if (mode.equals("redstone-blocked")) c.require(c.world.isBlockPowered(at(c, source)), "Blocked control remains inhibited"); + + TileEntity tile = c.tile(active.getX(), active.getY(), active.getZ()); + c.require(tile instanceof PoweredEntity && !tile.isInvalid(), "Live destination/source drill is a valid powered tile"); + c.require(tile.getPos().equals(at(c, active)), "Tile coordinates match the actual occupied block, not the old location"); + IBlockState state = c.world.getBlockState(at(c, active)); + c.require(readFacing(state) == facing, "Physical block retains its six-way facing"); + IBlockState restored = state.getBlock().getStateFromMeta(state.getBlock().getMetaFromState(state)); + c.require(readFacing(restored) == facing, "Facing survives the native metadata save/load codec"); + + NBTTagCompound saved = c.nbt(active.getX(), active.getY(), active.getZ()); + c.require(saved.getString("id").equals(witness.tileId), "Tile registry identity survives the move"); + c.require(saved.getInteger("x") == at(c, active).getX() + && saved.getInteger("y") == at(c, active).getY() + && saved.getInteger("z") == at(c, active).getZ(), "Serialized tile coordinates are rebased at the real destination"); + c.require(saved.getString("CustomName").equals(witness.customName), "Custom inventory name is retained"); + c.require(saved.getCompoundTag("ForgeData").getCompoundTag("AdvantageWorksDrill").equals(witness.audit), + "Unrelated nested Forge tile NBT survives untouched"); + if (steam) c.require(saved.hasKey("Facing", 1) && saved.getByte("Facing") == facing.getIndex(), + "Steam drill also retains its legacy NBT facing"); + c.require(saved.getShort("progress") == 0 && !saved.hasKey("targetX") + && !saved.hasKey("targetY") && !saved.hasKey("targetZ"), + "Bounded move/control has no fabricated mining progress or target"); + + IInventory inventory = c.inventory(active.getX(), active.getY(), active.getZ()); + c.require(inventory.getSizeInventory() == witness.inventorySize, "Inventory size is unchanged"); + NBTTagCompound[] actual = inventorySnapshot(inventory); + for (int slot = 0; slot < witness.slots.length; slot++) { + c.require(witness.slots[slot].equals(actual[slot]), "Exact item identity, quantity and custom NBT retained in slot " + slot); + } + c.require(itemCount(inventory) == 18, "All eighteen finite cargo items remain, with no mining products"); + TileEntity other = c.world.getTileEntity(at(c, move ? source : destination)); + c.require(!(other instanceof IInventory) || itemCount((IInventory) other) == 0, + "Cargo is not duplicated into the vacated source or untouched track"); + int dropped = 0; + for (EntityItem item : drops(c)) dropped += count(item.getItem()); + c.record("drill/droppedResources", dropped); + c.require(dropped == 0 && drops(c).isEmpty(), "Moving never spills or duplicates cargo as dropped entities"); + int drills = 0, tracks = 0; + for (int x = X - 2; x <= X + 2; x++) for (int y = Y - 2; y <= Y + 2; y++) for (int z = Z - 2; z <= Z + 2; z++) { + String block = String.valueOf(c.world.getBlockState(c.at(x, y, z)).getBlock().getRegistryName()); + if (block.equals(drill)) drills++; + if (block.equals(track)) tracks++; + } + c.require(drills == 1 && tracks == (move ? 0 : 1), "Exactly one drill remains and only a successful step consumes the sole track"); + float energy = ((PoweredEntity) tile).getEnergy(new ConduitType(energyType)); + float expected = reserve - (move ? moveCost : 0) - (steam && !noPower ? TICKS * STEAM_LEAK_PER_TICK : 0); + c.record("drill/finalEnergy", energy); + c.record("drill/expectedEnergy", expected); + c.require(Float.isFinite(energy) && energy == expected, + "Live drill retains exact reserve minus one successful movement debit and rated per-tick steam leakage"); + })); + } + + private static int count(ItemStack stack) { return stack == null ? 0 : stack.getCount(); } + + private static int itemCount(IInventory inventory) { + int total = 0; + for (int slot = 0; slot < inventory.getSizeInventory(); slot++) total += count(inventory.getStackInSlot(slot)); + return total; + } + + private static NBTTagCompound[] inventorySnapshot(IInventory inventory) { + NBTTagCompound[] result = new NBTTagCompound[inventory.getSizeInventory()]; + for (int slot = 0; slot < result.length; slot++) { + ItemStack stack = inventory.getStackInSlot(slot); + result[slot] = count(stack) == 0 ? new NBTTagCompound() : stack.writeToNBT(new NBTTagCompound()); + } + return result; + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private static EnumFacing readFacing(IBlockState state) { + for (IProperty property : state.getPropertyKeys()) if (property.getName().equals("facing")) { + return (EnumFacing) state.getValue(property); + } + throw new IllegalStateException("Drill has no native facing property: " + state); + } + + private static List drops(MachineContext c) { + BlockPos min = c.at(0, 0, 0), max = c.at(31, 31, 31); + return c.world.getEntitiesWithinAABB(EntityItem.class, new AxisAlignedBB(min.getX(), min.getY(), min.getZ(), + max.getX() + 1, max.getY() + 1, max.getZ() + 1)); + } + + private static void block(MachineContext c, BlockPos local, String id) { + c.block(local.getX(), local.getY(), local.getZ(), id); + } + + private static BlockPos at(MachineContext c, BlockPos local) { + return c.at(local.getX(), local.getY(), local.getZ()); + } + + private static int[] xyz(BlockPos pos) { return new int[]{pos.getX(), pos.getY(), pos.getZ()}; } + + private static void requireBlock(MachineContext c, BlockPos local, String id, String message) { + BlockPos pos = at(c, local); + c.require(id.equals(String.valueOf(c.world.getBlockState(pos).getBlock().getRegistryName())), message + " at " + pos); + } +} diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/ElectricLifecycleOperationalCases.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/ElectricLifecycleOperationalCases.java new file mode 100644 index 0000000..e12a076 --- /dev/null +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/ElectricLifecycleOperationalCases.java @@ -0,0 +1,283 @@ +package zone.moddev.mc.advantageworks; + +import cyano.poweradvantage.api.ConduitType; +import cyano.poweradvantage.api.PoweredEntity; +import java.util.List; +import net.minecraft.block.properties.PropertyBool; +import net.minecraft.inventory.IInventory; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.util.ResourceLocation; +import net.minecraft.util.math.BlockPos; + +/** Finite legacy jobs. Verification only reads the world; live phase actions need a core hook. */ +public final class ElectricLifecycleOperationalCases { + private static final String EA = "electricadvantage:"; + private static final String ELECTRICITY = "electricity"; + private static final PropertyBool LIT = PropertyBool.create("lit"); + private static final PropertyBool ACTIVE = PropertyBool.create("active"); + private static final int TICKS = 160; + private static final String[] CHEMISTRIES = {"lead_acid", "nickel_hydride", "alkaline", "lithium"}; + private static final float[] CAPACITIES = {1000, 2000, 4000, 8000}; + private static final float[] GAPS = {11, 13, 17, 19, 23, 29, 31, 37}; + + private ElectricLifecycleOperationalCases() {} + + public static void register(List cases) { + mixedCharge(cases); + mixedDischarge(cases); + freshRefill(cases, false); + freshRefill(cases, true); + cableSwitch(cases); + cableGap(cases); + solarControl(cases); + } + + private static void mixedCharge(List cases) { + cases.add(new MachineScenario("electric/lifecycle/battery-mixed-charge", + "Eight mixed batteries with 180 total room accept exactly 180 of a finite 200 electricity, " + + "all reach their own capacity and the source retains 20. No battery stock or custom NBT is lost.", + TICKS, c -> { + c.block(6, 4, 8, EA + "steam_powered_generator"); + c.power(6, 4, 8, ELECTRICITY, 200); + float[] charge = new float[8]; + for (int slot = 0; slot < 8; slot++) charge[slot] = capacity(slot) - GAPS[slot]; + stockBank(c, charge, false); + close(c, "setup.capacity", bankCapacity(c), 30000); + close(c, "setup.room", 30000 - c.energy(7, 4, 8, ELECTRICITY), 180); + }, c -> { + window(c, TICKS); + float[] charge = readBank(c, true); + for (int slot = 0; slot < 8; slot++) close(c, "slot" + slot + ".full", charge[slot], capacity(slot)); + close(c, "bank.full", sum(charge), 30000); + close(c, "source.retained", c.energy(6, 4, 8, ELECTRICITY), 20); + NBTTagCompound generator = c.nbt(6, 4, 8); + c.require(generator.hasKey("steam", 5), "Finite generator records its real steam reserve"); + close(c, "source.noSteam", generator.getFloat("steam"), 0); + close(c, "total.electricity", sum(charge) + c.energy(6, 4, 8, ELECTRICITY), 30020); + })); + } + + private static void mixedDischarge(List cases) { + cases.add(new MachineScenario("electric/lifecycle/battery-mixed-discharge", + "A real LED drains across seven nearly empty battery slots into the eighth mixed battery. " + + "The exact eight-tick lighting debit, remaining item charge and LED buffer conserve 10.875 electricity.", + TICKS, c -> { + float[] charge = new float[8]; + for (int slot = 0; slot < 7; slot++) charge[slot] = 0.125f; + charge[7] = 10; + stockBank(c, charge, false); + c.block(8, 4, 8, EA + "led_bar"); + }, c -> { + window(c, TICKS); + float[] charge = readBank(c, true); + float debit = ElectricOperationalRates.ledDebit(c.activationTick, + phase(c, 7, 4, 8), phase(c, 8, 4, 8), TICKS, 10.875f); + c.record("led.expectedDebit", debit); + c.require(debit > 0.875f, "The finite window must drain beyond the first seven batteries"); + for (int slot = 0; slot < 7; slot++) close(c, "slot" + slot + ".empty", charge[slot], 0); + float led = c.energy(8, 4, 8, ELECTRICITY); + close(c, "lastSlot.remaining", charge[7], 10.875f - debit - led); + close(c, "total.electricity", sum(charge) + led, 10.875f - debit); + c.require(c.world.getBlockState(c.at(8, 4, 8)).getValue(LIT), "Retained charge still powers the LED"); + })); + } + + private static void freshRefill(List cases, boolean emptyTags) { + cases.add(new MachineScenario("electric/lifecycle/battery-fresh-" + (emptyTags ? "empty-tag" : "no-tag"), + "Fresh mixed batteries historically start full, even with an empty compound and no energy key. " + + "Natural LED use creates room and a finite generator refills it, persisting first-use charge " + + "without creating energy or replacing any of the eight battery items.", TICKS, c -> { + c.block(6, 4, 8, EA + "steam_powered_generator"); + c.power(6, 4, 8, ELECTRICITY, 20); + stockBank(c, null, emptyTags); + close(c, "setup.freshCapacity", bankCapacity(c), 30000); + close(c, "setup.freshCharge", c.energy(7, 4, 8, ELECTRICITY), 30000); + // LED phase 3, array phase 2, generator phase 0: discharge precedes the refill. + c.require(phase(c, 6, 4, 8) == 0 && phase(c, 7, 4, 8) == 2 + && phase(c, 7, 3, 8) == 3, "Fixture retains the declared distinct native phases"); + c.block(7, 3, 8, EA + "led_bar"); + }, c -> { + window(c, TICKS); + float[] charge = readBank(c, false); + float generatedReserve = c.energy(6, 4, 8, ELECTRICITY); + float led = c.energy(7, 3, 8, ELECTRICITY); + int firstSupply = Math.min( + ElectricOperationalRates.firstUpdateDelay(c.activationTick, phase(c, 6, 4, 8)), + ElectricOperationalRates.firstUpdateDelay(c.activationTick, phase(c, 7, 4, 8))); + int paidUpdates = 0; + for (int delay : ElectricOperationalRates.updateDelays(c.activationTick, phase(c, 7, 3, 8), TICKS)) + if (delay > firstSupply) paidUpdates++; + float debit = paidUpdates * 0.125f; + c.record("fresh.expectedLightingDebit", debit); + c.record("fresh.sourceDebit", 20 - generatedReserve); + c.require(debit > 1.5f, "Window requires sustained fresh-battery use"); + c.require(20 - generatedReserve > 0.5f, "Source supplied more than the LED's initial buffer"); + c.require(30000 - sum(charge) <= 0.5f, "Real refills keep the batteries within one LED buffer of full"); + close(c, "fresh.conservation", sum(charge) + generatedReserve + led, 30020 - debit); + for (int slot = 1; slot < 8; slot++) close(c, "fresh.slot" + slot, charge[slot], capacity(slot)); + ItemStack first = c.inventory(7, 4, 8).getStackInSlot(0); + c.require(first.hasTagCompound() && first.getTagCompound().hasKey("energy", 5), + "First-use charge is now persisted in real item NBT"); + c.require(c.world.getBlockState(c.at(7, 3, 8)).getValue(LIT), "Refilled stock sustains the LED"); + })); + } + + private static void cableSwitch(List cases) { + cases.add(new MachineScenario("electric/lifecycle/cable-switch", + "A 16-block-separated battery and oven work through actual electric cable and an unpowered switch. " + + "A separate redstone-disabled switch blocks all transfer. One potato costs 400 of 500 electricity; " + + "the blocked lane retains its potato and all 500 electricity.", 260, c -> { + cableLane(c, 8, false, false); + cableLane(c, 20, true, false); + }, c -> { + window(c, 260); + for (int z : new int[]{8, 20}) { + boolean enabled = z == 8; + c.require(c.world.getBlockState(c.at(11, 4, z)).getValue(ACTIVE) == enabled, + "Switch state reflects its real redstone input in lane " + z); + itemCount(c, 19, 4, z, 0, "minecraft:potato", enabled ? 0 : 1); + itemCount(c, 3, 4, z, 0, EA + "lithium_battery", 1); + itemCount(c, 19, 4, z, 1, "minecraft:baked_potato", enabled ? 1 : 0); + float remaining = c.energy(3, 4, z, ELECTRICITY) + c.energy(19, 4, z, ELECTRICITY); + close(c, "lane" + z + ".remaining", remaining, enabled ? 100 : 500); + if (!enabled) close(c, "blocked.ovenEnergy", c.energy(19, 4, z, ELECTRICITY), 0); + } + })); + } + + private static void cableGap(List cases) { + cases.add(new MachineScenario("electric/lifecycle/cable-static-gap", + "A single missing cable isolates a finite battery from a valid oven input. This is a static " + + "broken-route control, not proof of interruption or reconnection during operation.", 260, c -> { + cableLane(c, 8, false, true); + }, c -> { + window(c, 260); + c.require(c.world.isAirBlock(c.at(15, 4, 8)), "Declared one-block cable gap remains open"); + c.require(c.world.getBlockState(c.at(11, 4, 8)).getValue(ACTIVE), "Switch is on, not hiding the cable gap"); + itemCount(c, 19, 4, 8, 0, "minecraft:potato", 1); + itemCount(c, 3, 4, 8, 0, EA + "lithium_battery", 1); + itemCount(c, 19, 4, 8, 1, "minecraft:baked_potato", 0); + close(c, "gap.battery", c.energy(3, 4, 8, ELECTRICITY), 500); + close(c, "gap.oven", c.energy(19, 4, 8, ELECTRICITY), 0); + })); + } + + private static void cableLane(MachineContext c, int z, boolean disabled, boolean gap) { + c.electricSupply(3, 4, z, 500); + c.block(11, 4, z, EA + "electric_switch"); + for (int x = 4; x <= 18; x++) + if (x != 11 && !(gap && x == 15)) c.block(x, 4, z, EA + "electric_conduit"); + if (gap) c.block(15, 4, z, "minecraft:air"); + c.block(19, 4, z, EA + "electric_oven"); + c.put(19, 4, z, 0, "minecraft:potato", 1); + if (disabled) c.block(11, 5, z, "minecraft:redstone_block"); + } + + private static void solarControl(List cases) { + cases.add(new MachineScenario("electric/lifecycle/solar-redstone", + "Two equally lit roofed panels have actual level-14 incident block light. The unpowered " + + "control generates at the retained blocklight rate; redstone inhibition must produce zero " + + "from the first power update. No time or weather is changed.", TICKS, c -> { + c.lightHalo(); + solarRoom(c, 8); + solarRoom(c, 22); + c.block(7, 2, 8, "minecraft:redstone_block"); + }, c -> { + window(c, TICKS); + for (int x : new int[]{8, 22}) { + c.require(!c.world.canSeeSky(c.at(x, 3, 8)), "Panel " + x + " is under a real roof"); + c.require(c.world.getLight(c.at(x, 3, 8)) == 14, "Panel " + x + " has real incident light"); + } + float perUpdate = (float)(Math.PI * 12500 / (24000 / Math.PI / 8)) * 0.1f * 14 * 0.0666666f; + float actual = c.energy(22, 2, 8, ELECTRICITY); + c.record("control.generated", actual); + c.require(actual >= perUpdate * 18 && actual <= perUpdate * 20 + 0.01f, + "Uninhibited panel actually generates at its native blocklight rate"); + c.require(c.world.isBlockPowered(c.at(8, 2, 8)), "Negative panel has a real redstone signal"); + close(c, "inhibited.generated", c.energy(8, 2, 8, ELECTRICITY), 0); + })); + } + + private static void solarRoom(MachineContext c, int x) { + for (int dx = -2; dx <= 2; dx++) for (int y = 0; y <= 4; y++) for (int dz = -2; dz <= 2; dz++) + if (Math.abs(dx) == 2 || Math.abs(dz) == 2 || y == 0 || y == 4) + c.block(x + dx, y, 8 + dz, "minecraft:stone"); + c.block(x, 2, 8, EA + "photovoltaic_generator"); + c.block(x + 1, 3, 8, "minecraft:glowstone"); + } + + private static void stockBank(MachineContext c, float[] charge, boolean emptyTags) { + c.block(7, 4, 8, EA + "electric_battery_array"); + for (int slot = 0; slot < 8; slot++) { + c.put(7, 4, 8, slot, battery(slot), 1); + ItemStack stack = c.inventory(7, 4, 8).getStackInSlot(slot); + if (charge != null) { + NBTTagCompound tag = NativeStacks.tag(stack); + tag.setFloat("energy", charge[slot]); + tag.setInteger("AdvWorksSlot", slot); + } else if (emptyTags) { + stack.setTagCompound(new NBTTagCompound()); + } + if (charge == null) c.require(emptyTags ? stack.hasTagCompound() && !stack.getTagCompound().hasKey("energy") + : !stack.hasTagCompound(), "Fresh slot " + slot + " starts without a charge key"); + } + c.inventory(7, 4, 8).markDirty(); + } + + private static float[] readBank(MachineContext c, boolean markers) { + IInventory bank = c.inventory(7, 4, 8); + c.require(bank.getSizeInventory() == 8, "Battery array retains all eight stock slots"); + float[] charge = new float[8]; + for (int slot = 0; slot < 8; slot++) { + ItemStack stack = bank.getStackInSlot(slot); + c.require(count(stack) == 1 && new ResourceLocation(battery(slot)).equals(stack.getItem().getRegistryName()), + "Slot " + slot + " retains its exact battery identity and quantity"); + NBTTagCompound tag = stack.hasTagCompound() ? stack.getTagCompound() : null; + if (markers) c.require(tag != null && tag.hasKey("energy", 5) + && tag.hasKey("AdvWorksSlot", 3) && tag.getInteger("AdvWorksSlot") == slot, + "Slot " + slot + " retains its custom item NBT"); + c.require(tag == null || !tag.hasKey("energy") || tag.hasKey("energy", 5), "Stored charge retains float NBT"); + charge[slot] = tag != null && tag.hasKey("energy") ? tag.getFloat("energy") : capacity(slot); + c.require(Float.isFinite(charge[slot]) && charge[slot] >= 0 && charge[slot] <= capacity(slot), + "Slot " + slot + " charge is finite and within its own capacity"); + c.record("battery.slot" + slot + ".charge", charge[slot]); + } + close(c, "bank.capacity", bankCapacity(c), 30000); + close(c, "bank.itemLedger", c.energy(7, 4, 8, ELECTRICITY), sum(charge)); + return charge; + } + + private static float bankCapacity(MachineContext c) { + return ((PoweredEntity)c.tile(7, 4, 8)).getEnergyCapacity(new ConduitType(ELECTRICITY)); + } + + private static String battery(int slot) { return EA + CHEMISTRIES[slot % CHEMISTRIES.length] + "_battery"; } + private static float capacity(int slot) { return CAPACITIES[slot % CAPACITIES.length]; } + private static float sum(float[] values) { float total = 0; for (float value : values) total += value; return total; } + private static int count(ItemStack stack) { return stack == null || stack.isEmpty() ? 0 : stack.getCount(); } + + private static void itemCount(MachineContext c, int x, int y, int z, int slot, String item, int expected) { + ItemStack stack = c.inventory(x, y, z).getStackInSlot(slot); + int quantity = count(stack); + c.require(quantity == expected && (quantity == 0 || new ResourceLocation(item).equals(stack.getItem().getRegistryName())), + "Slot " + slot + " at " + c.at(x, y, z) + " retains " + expected + " " + item); + } + + private static int phase(MachineContext c, int x, int y, int z) { + BlockPos pos = c.at(x, y, z); + return ElectricOperationalRates.offset(pos.getX(), pos.getY(), pos.getZ()); + } + + private static void window(MachineContext c, int ticks) { + long elapsed = c.world.getTotalWorldTime() - c.activationTick; + c.record("observedTicks", elapsed); + c.require(elapsed == ticks, "Supplemental job observes exactly " + ticks + " natural ticks"); + } + + private static void close(MachineContext c, String key, float actual, float expected) { + c.record(key, actual); + c.require(Float.isFinite(actual) && Math.abs(actual - expected) <= 0.001f, + key + " expected " + expected + ", got " + actual); + } +} diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/ElectricOperationalCases.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/ElectricOperationalCases.java new file mode 100644 index 0000000..3762c54 --- /dev/null +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/ElectricOperationalCases.java @@ -0,0 +1,792 @@ +package zone.moddev.mc.advantageworks; + +import java.util.List; +import java.util.UUID; +import com.mojang.authlib.GameProfile; +import net.minecraft.entity.EntityList; +import net.minecraft.entity.Entity; +import net.minecraft.entity.SharedMonsterAttributes; +import net.minecraft.entity.item.EntityItem; +import net.minecraft.entity.monster.EntityHusk; +import net.minecraft.entity.player.EntityPlayer; +import net.minecraft.inventory.IInventory; +import net.minecraft.item.ItemStack; +import net.minecraft.util.EnumFacing; +import net.minecraft.block.properties.PropertyBool; +import net.minecraft.scoreboard.ScorePlayerTeam; +import net.minecraft.util.ResourceLocation; +import net.minecraft.util.math.AxisAlignedBB; +import net.minecraft.util.math.BlockPos; +import net.minecraftforge.fluids.Fluid; +import net.minecraftforge.fluids.FluidStack; +import net.minecraftforge.fluids.FluidTank; +import net.minecraftforge.common.util.FakePlayer; + +/** Independent operating contracts. Only setup seeds inputs; all work uses world ticks. */ +public final class ElectricOperationalCases { + private static final String EA = "electricadvantage:"; + private static final PropertyBool LED_LIT = PropertyBool.create("lit"); + private static final String ELECTRICITY = "electricity"; + private static final String GREENHOUSE = "greenhouse"; + private static final int Y = 2; + private static final int Z = 8; + private static final int[] CELLS = {3, 9, 15, 21, 27}; + + private ElectricOperationalCases() {} + + public static void register(List cases) { + processor(cases, "furnace", "electric_furnace", "minecraft:iron_ore", + "minecraft:iron_ingot", 4, 6, 1, 1600, 440); + processor(cases, "crusher", "electric_crusher", "minecraft:cobblestone", + "minecraft:gravel", 3, 6, 1, 2400, 440); + processor(cases, "oven", "electric_oven", "minecraft:potato", + "minecraft:baked_potato", 1, 1, 1, 400, 240); + parallelInputs(cases); + fabricator(cases); + refinery(cases); + still(cases); + pump(cases); + growth(cases); + led(cases); + hydro(cases); + solar(cases); + steamGeneration(cases); + batteries(cases); + turret(cases); + } + + private static void parallelInputs(List cases) { + cases.add(new MachineScenario("electric/parallel-inputs", + "All four furnace lanes and all three crusher lanes finish independently from finite stock " + + "and debit exactly 6400 and 7200 electricity without shared slot-progress or output loss.", 260, c -> { + c.block(8, Y, Z, EA + "electric_furnace"); + c.electricSupply(7, Y, Z, 7800); + for (int slot = 0; slot < 4; slot++) c.put(8, Y, Z, slot, "minecraft:iron_ore", 1); + c.block(22, Y, Z, EA + "electric_crusher"); + c.electricSupply(21, Y, Z, 7800); + for (int slot = 0; slot < 3; slot++) c.put(22, Y, Z, slot, "minecraft:cobblestone", 1); + }, c -> { + equal(c, "parallel.furnaceProducts", output(c, 8, 4, 6, "minecraft:iron_ingot"), 4); + equal(c, "parallel.furnaceInputs", inputCount(c, 8, 4, "minecraft:iron_ore"), 0); + equal(c, "parallel.crusherProducts", output(c, 22, 3, 6, "minecraft:gravel"), 3); + equal(c, "parallel.crusherInputs", inputCount(c, 22, 3, "minecraft:cobblestone"), 0); + close(c, "parallel.furnaceDebit", 7800 - pairEnergy(c, 8), 6400, 0.01f); + close(c, "parallel.crusherDebit", 7800 - pairEnergy(c, 22), 7200, 0.01f); + })); + } + + private static void processor(List cases, String name, String block, + String input, String output, int inputs, int outputs, int yield, int cost, int ticks) { + final float reserve = cost * 2 + 800; + cases.add(new MachineScenario("electric/" + name, + "Two finite inputs produce exact recipe output and energy debit; no power, invalid input, " + + "full output and insufficient total energy cannot create products or destroy inputs.", ticks, + c -> { + for (int x : CELLS) c.block(x, Y, Z, EA + block); + for (int i = 0; i < CELLS.length; i++) { + int x = CELLS[i]; + c.put(x, Y, Z, 0, i == 2 ? "minecraft:bedrock" : input, 2); + if (i != 1) c.electricSupply(x - 1, Y, Z, i == 4 ? cost / 2f : reserve); + } + for (int slot = inputs; slot < inputs + outputs; slot++) + c.put(CELLS[3], Y, Z, slot, output, 64); + c.record("energyPerRecipe", cost); + c.record("positiveFiniteReserve", reserve); + }, c -> { + int x = CELLS[0]; + equal(c, "positive.output", output(c, x, inputs, outputs, output), 2 * yield); + equal(c, "positive.input", slotCount(c, x, 0, input), 0); + close(c, "positive.energyDebit", reserve - pairEnergy(c, x), 2 * cost, 0.01f); + equal(c, "unpowered.output", output(c, CELLS[1], inputs, outputs, output), 0); + equal(c, "unpowered.input", slotCount(c, CELLS[1], 0, input), 2); + equal(c, "invalid.input", slotCount(c, CELLS[2], 0, "minecraft:bedrock"), 2); + equal(c, "invalid.output", output(c, CELLS[2], inputs, outputs, output), 0); + close(c, "invalid.energyConserved", pairEnergy(c, CELLS[2]), reserve, 0.01f); + equal(c, "full.input", slotCount(c, CELLS[3], 0, input), 2); + equal(c, "full.output", output(c, CELLS[3], inputs, outputs, output), outputs * 64); + close(c, "full.energyConserved", pairEnergy(c, CELLS[3]), reserve, 0.01f); + equal(c, "scarce.input", slotCount(c, CELLS[4], 0, input), 2); + equal(c, "scarce.output", output(c, CELLS[4], inputs, outputs, output), 0); + float left = pairEnergy(c, CELLS[4]); + c.record("scarce.energyRemaining", left); + c.require(left >= 0 && left < cost / 2f, "Scarcity spends finite energy without completing a recipe"); + })); + } + + private static void fabricator(List cases) { + cases.add(new MachineScenario("electric/fabricator-recursive", + "The actual fabricator recursively crafts four sticks from one log, retains two surplus planks " + + "and the reference item. Its first craft costs 3200 electricity; the measured finite debit also " + + "accounts for attempted second-craft work. Cyclic missing ingredients, no power, " + + "full outputs and scarce energy must not fabricate or consume raw materials.", 260, c -> { + for (int x : CELLS) { + c.block(x, Y, Z, EA + "electric_fabricator"); + c.put(x, Y, Z, 0, "minecraft:log", 1); + c.put(x, Y, Z, 14, "minecraft:stick", 1); + } + for (int i = 0; i < CELLS.length; i++) + if (i != 1) c.electricSupply(CELLS[i] - 1, Y, Z, i == 4 ? 1600 : 4000); + c.put(CELLS[2], Y, Z, 0, "minecraft:bedrock", 1); + c.put(CELLS[2], Y, Z, 14, "minecraft:gold_ingot", 1); + c.put(CELLS[3], Y, Z, 12, "minecraft:stick", 64); + c.put(CELLS[3], Y, Z, 13, "minecraft:stick", 64); + c.record("fabricator.constructionGameTime", c.world.getTotalWorldTime()); + }, c -> { + equal(c, "recursive.sticks", output(c, CELLS[0], 12, 2, "minecraft:stick"), 4); + equal(c, "recursive.log", slotCount(c, CELLS[0], 0, "minecraft:log"), 0); + equal(c, "recursive.surplusPlanks", inputCount(c, CELLS[0], 12, "minecraft:planks"), 2); + equal(c, "recursive.reference", slotCount(c, CELLS[0], 14, "minecraft:stick"), 1); + recordSchedule(c, "fabricatorSupply", c.activationTick, CELLS[0] - 1, Y, Z, 260); + requireWindow(c, c.activationTick, 260); + float earlyDebit = ElectricOperationalRates.fabricatorDebit(c.activationTick, phase(c, CELLS[0] - 1, Y, Z), 260, true); + float lateDebit = ElectricOperationalRates.fabricatorDebit(c.activationTick, phase(c, CELLS[0] - 1, Y, Z), 260, false); + float debit = 4000 - pairEnergy(c, CELLS[0]); + c.record("recursive.expectedDebitSourceFirst", earlyDebit); + c.record("recursive.expectedDebitMachineFirst", lateDebit); + c.record("recursive.energyDebit", debit); + c.require(debit == earlyDebit || debit == lateDebit, "Fabricator debit equals completed craft plus phase-bounded second-craft work"); + for (int i : new int[] {1, 3, 4}) { + equal(c, "negative" + i + ".rawLog", slotCount(c, CELLS[i], 0, "minecraft:log"), 1); + equal(c, "negative" + i + ".output", output(c, CELLS[i], 12, 2, "minecraft:stick"), i == 3 ? 128 : 0); + equal(c, "negative" + i + ".reference", slotCount(c, CELLS[i], 14, "minecraft:stick"), 1); + } + equal(c, "boundedCycle.input", slotCount(c, CELLS[2], 0, "minecraft:bedrock"), 1); + equal(c, "boundedCycle.output", output(c, CELLS[2], 12, 2, "minecraft:gold_ingot"), 0); + close(c, "boundedCycle.energy", pairEnergy(c, CELLS[2]), 4000, 0.01f); + close(c, "full.energy", pairEnergy(c, CELLS[3]), 4000, 0.01f); + c.require(pairEnergy(c, CELLS[4]) < 1600, "Scarce fabricator attempts work but cannot complete it"); + })); + } + + private static void refinery(List cases) { + cases.add(new MachineScenario("electric/refinery", + "200 mB refined oil becomes exactly two petrolplastic ingots for 2400 electricity. " + + "No electricity, invalid water, full output and 99 mB scarcity do not produce plastic.", 240, c -> { + for (int i = 0; i < CELLS.length; i++) { + int x = CELLS[i]; + c.block(x, Y, Z, EA + "plastic_refinery"); + if (i != 1) c.electricSupply(x - 1, Y, Z, 4000); + if (i != 2) reserve(c, x, Y, Z, "getTank", "poweradvantage:refined_oil", i == 4 ? 99 : 200); + } + rejectFluid(c, CELLS[2], "minecraft:water", 200); + c.put(CELLS[3], Y, Z, 0, EA + "petrolplastic_ingot", 64); + }, c -> { + equal(c, "positive.plastic", c.count(CELLS[0], Y, Z, EA + "petrolplastic_ingot"), 2); + equal(c, "positive.oil", fluidAmount(c, CELLS[0], Y, Z), 0); + close(c, "positive.energyDebit", 4000 - pairEnergy(c, CELLS[0]), 2400, 0.01f); + for (int i = 1; i < CELLS.length; i++) { + equal(c, "negative" + i + ".plastic", c.count(CELLS[i], Y, Z, EA + "petrolplastic_ingot"), i == 3 ? 64 : 0); + equal(c, "negative" + i + ".fluid", fluidAmount(c, CELLS[i], Y, Z), i == 2 ? 0 : i == 4 ? 99 : 200); + if (i != 1) close(c, "negative" + i + ".energy", pairEnergy(c, CELLS[i]), 4000, 0.01f); + } + })); + } + + private static void still(List cases) { + cases.add(new MachineScenario("electric/still", + "40 mB crude oil distills to 20 mB refined oil for 320 electricity, using the real tanks. " + + "No power, invalid water, full output and a one-mB incomplete recipe preserve inputs and energy.", 80, c -> { + for (int i = 0; i < CELLS.length; i++) { + int x = CELLS[i]; + c.block(x, Y, Z, EA + "electric_still"); + if (i != 1) c.electricSupply(x - 1, Y, Z, 1000); + if (i != 2) reserve(c, x, Y, Z, "getInputTank", "poweradvantage:crude_oil", i == 4 ? 1 : 40); + } + rejectFluid(c, CELLS[2], "minecraft:water", 40); + reserve(c, CELLS[3], Y, Z, "getOutputTank", "poweradvantage:refined_oil", 1000); + equal(c, "setup.fullOutput", tankAmount(c, CELLS[3], 1), 1000); + }, c -> { + equal(c, "positive.input", tankAmount(c, CELLS[0], 0), 0); + equal(c, "positive.output", tankAmount(c, CELLS[0], 1), 20); + fluidIs(c, CELLS[0], 1, "poweradvantage:refined_oil"); + close(c, "positive.energyDebit", 1000 - pairEnergy(c, CELLS[0]), 320, 0.01f); + for (int i = 1; i < CELLS.length; i++) { + equal(c, "negative" + i + ".input", tankAmount(c, CELLS[i], 0), i == 2 ? 0 : i == 4 ? 1 : 40); + equal(c, "negative" + i + ".output", tankAmount(c, CELLS[i], 1), i == 3 ? 1000 : 0); + if (i != 1) close(c, "negative" + i + ".energy", pairEnergy(c, CELLS[i]), 1000, 0.01f); + } + })); + cases.add(new MachineScenario("electric/still-faced-input", + "Each real Advantage fluid face simulates and accepts exactly eight mB crude oil without simulation " + + "mutation. The 48 finite mB then operate on natural ticks, making 24 mB refined oil for 384 electricity.", 80, c -> { + c.block(8, Y, Z, EA + "electric_still"); + c.electricSupply(7, Y, Z, 1000); + Fluid oil = NativeFluids.get("poweradvantage:crude_oil"); + c.require(oil != null, "Crude oil exists for faced automation"); + int stored = 0; + for (EnumFacing face : EnumFacing.values()) { + NativeFluidPort handler = fluids(c, 8, face); + equal(c, "face." + face + ".simulated", handler.fill(new FluidStack(oil, 8), false), 8); + equal(c, "face." + face + ".unchanged", tankAmount(c, 8, 0), stored); + equal(c, "face." + face + ".accepted", handler.fill(new FluidStack(oil, 8), true), 8); + stored += 8; + } + c.record("setup.facedInputReserve", stored); + }, c -> { + equal(c, "faced.input", tankAmount(c, 8, 0), 0); + equal(c, "faced.output", tankAmount(c, 8, 1), 24); + fluidIs(c, 8, 1, "poweradvantage:refined_oil"); + close(c, "faced.energyDebit", 1000 - pairEnergy(c, 8), 384, 0.01f); + for (EnumFacing face : EnumFacing.values()) { + FluidStack simulated = fluids(c, 8, face).drain(24, false); + equal(c, "face." + face + ".drainable", simulated == null ? 0 : simulated.amount, 24); + c.require(simulated != null && simulated.getFluid() == NativeFluids.get("poweradvantage:refined_oil"), + "Face offers the real refined product " + face); + equal(c, "face." + face + ".drainSimulationUnchanged", tankAmount(c, 8, 1), 24); + } + })); + } + + private static void pump(List cases) { + cases.add(new MachineScenario("electric/pump", + "A finite enclosed source is removed, delivered as 1000 mB water, and charged 1000 + " + + "three vertical levels at 32 electricity. No power, no source, full tank and 1095 energy " + + "cannot remove the source or create water.", 100, c -> { + for (int i = 0; i < CELLS.length; i++) { + int x = CELLS[i]; + c.block(x, 5, Z, EA + "electric_pump"); + c.block(x, 4, Z, EA + "pump_pipe_electric"); + c.block(x, 3, Z, EA + "pump_pipe_electric"); + c.block(x, 2, Z, "minecraft:stone"); + for (int dx = -1; dx <= 1; dx++) { + c.block(x + dx, 1, Z - 1, "minecraft:stone"); + c.block(x + dx, 1, Z + 1, "minecraft:stone"); + c.block(x + dx, 3, Z - 1, "minecraft:stone"); + c.block(x + dx, 3, Z + 1, "minecraft:stone"); + } + c.block(x, 1, Z, "minecraft:stone"); + c.block(x - 1, 2, Z, "minecraft:stone"); + c.block(x + 1, 2, Z, "minecraft:stone"); + c.block(x, 2, Z - 1, i == 2 ? "minecraft:stone" : "minecraft:water"); + c.block(x - 1, 2, Z - 1, "minecraft:stone"); + c.block(x + 1, 2, Z - 1, "minecraft:stone"); + c.block(x, 2, Z - 2, "minecraft:stone"); + if (i != 1) c.electricSupply(x - 1, 5, Z, i == 4 ? 1095 : 3000); + if (i == 3) reserve(c, x, 5, Z, "getTank", "minecraft:water", 1000); + } + }, c -> { + equal(c, "positive.water", fluidAmount(c, CELLS[0], 5, Z), 1000); + c.require(NativeWorldFluids.isEmpty(c.world, c.at(CELLS[0], 2, Z - 1)), "Pump removed the real source block"); + close(c, "positive.energyDebit", 3000 - energyPair(c, CELLS[0], 5, Z), 1096, 0.01f); + for (int i = 1; i < CELLS.length; i++) { + equal(c, "negative" + i + ".water", fluidAmount(c, CELLS[i], 5, Z), i == 3 ? 1000 : 0); + if (i != 2) c.require(NativeWorldFluids.isSource(c.world, c.at(CELLS[i], 2, Z - 1)), "Negative pump preserves source " + i); + if (i != 1) close(c, "negative" + i + ".energy", energyPair(c, CELLS[i], 5, Z), i == 4 ? 1095 : 3000, 0.01f); + } + })); + } + + private static void growth(List cases) { + cases.add(new MachineScenario("electric/growth-chain", + "Controller converts finite dirt, water and electricity into greenhouse power and the chamber " + + "turns one cane into two real products. Missing electricity, water or dirt and an invalid seed " + + "cannot grow products; all resource conversions are conserved.", 900, c -> { + for (int i = 0; i < CELLS.length; i++) { + int x = CELLS[i]; + c.block(x, Y, Z, EA + "growth_chamber_controller"); + c.block(x + 1, Y, Z, EA + "growth_chamber"); + c.put(x + 1, Y, Z, 0, i == 4 ? "minecraft:bedrock" : "minecraft:reeds", 1); + if (i != 1) c.electricSupply(x - 1, Y, Z, 7200); + if (i != 2) reserve(c, x, Y, Z, "getTank", "minecraft:water", 1800); + if (i != 3) c.put(x, Y, Z, 0, "minecraft:dirt", 1); + } + }, c -> { + equal(c, "positive.caneOutput", output(c, CELLS[0] + 1, 3, 6, "minecraft:reeds"), 2); + equal(c, "positive.seed", slotCount(c, CELLS[0] + 1, 0, "minecraft:reeds"), 0); + for (int i = 0; i < CELLS.length; i++) { + int x = CELLS[i]; + float soil = ((Number)c.invoke(x, Y, Z, "getSoil", new Class[0])).floatValue(); + float converted = (i == 3 ? 0 : 1000 - soil); + float electricLeft = c.energy(x, Y, Z, ELECTRICITY) + (i == 1 ? 0 : c.energy(x - 1, Y, Z, ELECTRICITY)); + close(c, "cell" + i + ".electricDebit", (i == 1 ? 0 : 7200) - electricLeft, 8 * converted, 0.1f); + // Each 12.5-unit controller batch uses exactly 25 mB with this fixture. + close(c, "cell" + i + ".waterDebit", (i == 2 ? 0 : 1800) - fluidAmount(c, x, Y, Z), 2 * converted, 0.1f); + float growthStored = c.energy(x, Y, Z, GREENHOUSE) + c.energy(x + 1, Y, Z, GREENHOUSE); + c.record("cell" + i + ".soilRemaining", soil); + c.record("cell" + i + ".growthStored", growthStored); + c.require(soil >= 0 && converted >= 0 && growthStored >= 0, "Growth resources stay nonnegative " + i); + if (i == 0) close(c, "positive.growthConsumed", converted - growthStored, 800, 0.1f); + if (i > 0) { + equal(c, "negative" + i + ".seed", slotCount(c, x + 1, 0, i == 4 ? "minecraft:bedrock" : "minecraft:reeds"), 1); + equal(c, "negative" + i + ".output", output(c, x + 1, 3, 6, "minecraft:reeds"), 0); + close(c, "negative" + i + ".growthConsumption", converted - growthStored, 0, 0.1f); + } + } + })); + cases.add(new MachineScenario("electric/growth-scarcity-full", + "799 finite greenhouse units cannot finish an 800-tick cane crop; full outputs retain the seed " + + "and all existing products without spawning overflow items.", 900, c -> { + growthCell(c, 8, 6392, false); + growthCell(c, 22, 7200, true); + }, c -> { + equal(c, "scarce.seed", slotCount(c, 9, 0, "minecraft:reeds"), 1); + equal(c, "scarce.output", output(c, 9, 3, 6, "minecraft:reeds"), 0); + equal(c, "full.seed", slotCount(c, 23, 0, "minecraft:reeds"), 1); + equal(c, "full.output", output(c, 23, 3, 6, "minecraft:reeds"), 384); + AxisAlignedBB bay = new AxisAlignedBB(c.at(0, 0, 0), c.at(31, 31, 31)); + equal(c, "overflowItems", c.world.getEntitiesWithinAABB(EntityItem.class, bay).size(), 0); + float soil = ((Number)c.invoke(8, Y, Z, "getSoil", new Class[0])).floatValue(); + close(c, "scarce.convertedGrowth", 1000 - soil, 799, 0.1f); + close(c, "scarce.electricRemaining", pairEnergy(c, 8), 0, 0.1f); + })); + } + + private static void growthCell(MachineContext c, int x, float reserve, boolean full) { + c.block(x, Y, Z, EA + "growth_chamber_controller"); + c.block(x + 1, Y, Z, EA + "growth_chamber"); + c.electricSupply(x - 1, Y, Z, reserve); + c.put(x, Y, Z, 0, "minecraft:dirt", 1); + reserve(c, x, Y, Z, "getTank", "minecraft:water", 1800); + c.put(x + 1, Y, Z, 0, "minecraft:reeds", 1); + if (full) for (int slot = 3; slot < 9; slot++) c.put(x + 1, Y, Z, slot, "minecraft:reeds", 64); + } + + private static void led(List cases) { + cases.add(new MachineScenario("electric/led-rate", + "LED consumes 0.125 electricity per eight world ticks, lights with a finite battery, " + + "and stays dark with no electricity.", 80, c -> { + c.block(8, Y, Z, EA + "led_bar"); + c.electricSupply(7, Y, Z, 10); + c.block(22, Y, Z, EA + "led_bar"); + c.record("led.constructionGameTime", c.world.getTotalWorldTime()); + }, c -> { + c.require(c.world.getBlockState(c.at(8, Y, Z)).getValue(LED_LIT), "Powered LED is lit"); + c.require(!c.world.getBlockState(c.at(22, Y, Z)).getValue(LED_LIT), "Unpowered LED is dark"); + float debit = 10 - pairEnergy(c, 8); + recordSchedule(c, "led", c.activationTick, 8, Y, Z, 80); + recordSchedule(c, "battery", c.activationTick, 7, Y, Z, 80); + requireWindow(c, c.activationTick, 80); + float expectedDebit = ElectricOperationalRates.ledDebit(c.activationTick, + phase(c, 7, Y, Z), phase(c, 8, Y, Z), 80, 10); + c.record("led.expectedDebit", expectedDebit); + close(c, "led.debit", debit, expectedDebit, 0.001f); + })); + cases.add(new MachineScenario("electric/led-exhaustion", + "A half-unit finite reserve is exhausted and cannot leave the LED lit indefinitely.", 80, c -> { + c.block(8, Y, Z, EA + "led_bar"); + c.electricSupply(7, Y, Z, 0.5f); + }, c -> { + close(c, "exhausted.energy", pairEnergy(c, 8), 0, 0.001f); + c.require(!c.world.getBlockState(c.at(8, Y, Z)).getValue(LED_LIT), "Exhausted LED turns off"); + })); + } + + private static void hydro(List cases) { + cases.add(new MachineScenario("electric/hydro-rate", + "A naturally spawned turbine in a contained horizontal water channel generates four electricity " + + "per natural entity tick. A dry generator produces zero; a real empty battery receives the output.", 160, c -> { + // A finite source sustains a contained seven-block channel, not a scripted energy source. + for (int x = 4; x <= 13; x++) { + c.block(x, 0, Z, "minecraft:stone"); + c.block(x, 1, Z - 1, "minecraft:stone"); + c.block(x, 1, Z + 1, "minecraft:stone"); + } + c.block(4, 1, Z, "minecraft:stone"); + c.block(13, 1, Z, "minecraft:stone"); + // A native 1.12 bucket starts with the dynamic block so water schedules its spread. + c.block(5, 1, Z, "minecraft:flowing_water"); + c.block(8, Y, Z, EA + "hydroelectric_generator"); + c.electricSupply(8, Y + 1, Z, 0); + for (int x = 20; x <= 24; x++) for (int z = Z - 2; z <= Z + 2; z++) { + c.block(x, 0, z, "minecraft:stone"); + c.block(x, 1, z, x == 20 || x == 24 || z == Z - 2 || z == Z + 2 ? "minecraft:stone" : "minecraft:water"); + } + c.block(22, Y, Z, EA + "hydroelectric_generator"); + c.block(27, Y, 22, EA + "hydroelectric_generator"); + }, c -> { + c.require(!NativeWorldFluids.isEmpty(c.world, c.at(8, 1, Z)), "Water reached the turbine"); + net.minecraft.util.math.Vec3d flow = NativeWorldFluids.flow(c.world, c.at(8, 1, Z)); + c.require(flow.x * flow.x + flow.z * flow.z > 0, + "Turbine sees actual horizontal flow"); + float generated = c.energy(8, Y, Z, ELECTRICITY) + c.energy(8, Y + 1, Z, ELECTRICITY); + // Source propagation is five ticks per block; 25 ticks is a bounded startup allowance. + between(c, "hydro.generated", generated, 4 * 135, 4 * 160); + close(c, "hydro.fourUnitQuantum", generated / 4, Math.round(generated / 4), 0.001f); + c.require(c.energy(8, Y + 1, Z, ELECTRICITY) > 0, "Hydro charges a real battery through its network"); + c.require(NativeWorldFluids.isSource(c.world, c.at(22, 1, Z)), "Still-water control remains a real source"); + close(c, "stillWater.generated", c.energy(22, Y, Z, ELECTRICITY), 0, 0.001f); + close(c, "dry.generated", c.energy(27, Y, 22, ELECTRICITY), 0, 0.001f); + int turbines = 0; + for (Entity actor : c.world.getEntitiesWithinAABB(Entity.class, + new AxisAlignedBB(c.at(0, 0, 0), c.at(31, 31, 31)))) { + if (!new ResourceLocation(EA + "hydroturbine").equals(EntityList.getKey(actor))) continue; + turbines++; + c.record("hydro.motion/" + actor.getUniqueID(), new net.minecraft.util.math.Vec3d(actor.motionX, actor.motionY, actor.motionZ).toString()); + c.require(new net.minecraft.util.math.Vec3d(actor.motionX, actor.motionY, actor.motionZ).lengthSquared() == 0, + "Anchored turbine has no linear motion: " + actor.getUniqueID()); + } + equal(c, "hydro.anchoredActors", turbines, 3); + })); + final long[] previousTime = new long[1]; + final boolean[] daylightCycle = new boolean[1]; + final float[] weatherFactor = new float[1]; + cases.add(new MachineScenario("electric/solar-daylight-rate", + "An unroofed daylight panel generates at the retained sine-based rate every eight world ticks. " + + "This exclusive case restores global time after measurement, including on assertion failure.", 160, c -> { + c.lightHalo(); + c.block(8, Y, Z, EA + "photovoltaic_generator"); + c.require(c.world.canSeeSky(c.at(8, Y + 1, Z)), "Daylight panel top surface has actual sky access"); + previousTime[0] = c.world.getWorldTime(); + weatherFactor[0] = c.world.isThundering() ? 0.2f : c.world.isRaining() ? 0.35f : 1; + c.record("setup.previousDayTime", previousTime[0]); + c.record("setup.weatherFactor", weatherFactor[0]); + c.world.setWorldTime(6000); + daylightCycle[0] = c.world.getGameRules().getBoolean("doDaylightCycle"); + c.record("setup.daylightCycle", daylightCycle[0]); + c.record("daylight.constructionGameTime", c.world.getTotalWorldTime()); + }, c -> { + try { + float nowWeather = c.world.isThundering() ? 0.2f : c.world.isRaining() ? 0.35f : 1; + close(c, "daylight.weatherUnchanged", nowWeather, weatherFactor[0], 0); + recordSchedule(c, "daylight", c.activationTick, 8, Y, Z, 160); + requireWindow(c, c.activationTick, 160); + float expected = ElectricOperationalRates.daylightElectricity(c.activationTick, + phase(c, 8, Y, Z), 160, 6000, daylightCycle[0], weatherFactor[0]); + c.record("daylight.expectedGenerated", expected); + // Math.sin is independent of Minecraft's quantized sine table; allow only its rounding error. + close(c, "daylight.generated", c.energy(8, Y, Z, ELECTRICITY), expected, 0.01f); + c.record("daylight.lastDayTime", c.world.getWorldTime()); + } finally { + c.world.setWorldTime(previousTime[0]); + } + }).exclusive()); + } + + private static void solar(List cases) { + cases.add(new MachineScenario("electric/solar-blocklight-rate", + "A fully roofed panel uses the documented 0.1 blocklight efficiency and eight-tick generation " + + "cadence; an equally roofed, sealed dark panel produces no energy. No global time/weather mutation.", 160, c -> { + c.lightHalo(); + solarRoom(c, 8, true); + solarRoom(c, 22, false); + }, c -> { + c.require(!c.world.canSeeSky(c.at(8, Y + 1, Z)), "Lit panel is indoors"); + c.require(!c.world.canSeeSky(c.at(22, Y + 1, Z)), "Dark panel is indoors"); + int brightness = c.world.getLight(c.at(8, Y + 1, Z)); + equal(c, "lit.localBrightness", brightness, 14); + equal(c, "dark.localBrightness", c.world.getLight(c.at(22, Y + 1, Z)), 0); + float perUpdate = (float)(Math.PI * 12500 / (24000 / Math.PI / 8)) * 0.1f * 14 * 0.0666666f; + between(c, "solar.generated", c.energy(8, Y, Z, ELECTRICITY), perUpdate * 18, perUpdate * 20 + 0.01f); + close(c, "dark.generated", c.energy(22, Y, Z, ELECTRICITY), 0, 0.001f); + c.record("solar.expectedEnergyPerEightTicks", perUpdate); + })); + } + + private static void solarRoom(MachineContext c, int x, boolean lit) { + for (int dx = -2; dx <= 2; dx++) for (int dy = 0; dy <= 4; dy++) for (int dz = -2; dz <= 2; dz++) + if (Math.abs(dx) == 2 || Math.abs(dz) == 2 || dy == 0 || dy == 4) + c.block(x + dx, dy, Z + dz, "minecraft:stone"); + c.block(x, Y, Z, EA + "photovoltaic_generator"); + if (lit) c.block(x + 1, Y + 1, Z, "minecraft:glowstone"); + } + + private static void steamGeneration(List cases) { + cases.add(new MachineScenario("electric/steam-generator-rate", + "A finite 32-unit steam packet converts on a natural power update after 0.5 steam decay " + + "into 984.375 electricity; an empty generator produces none and full electrical output " + + "never creates extra energy.", 40, c -> { + for (int x : new int[] {8, 16, 24}) c.block(x, Y, Z, EA + "steam_powered_generator"); + c.power(8, Y, Z, "steam", 32); + c.power(24, Y, Z, "steam", 32); + c.power(24, Y, Z, ELECTRICITY, 1000); + c.record("generator.constructionGameTime", c.world.getTotalWorldTime()); + }, c -> { + for (int x : new int[] {8, 16, 24}) recordSchedule(c, "generator" + x, c.activationTick, x, Y, Z, 40); + requireWindow(c, c.activationTick, 40); + int updates = ElectricOperationalRates.updateDelays(c.activationTick, phase(c, 8, Y, Z), 40).size(); + float expected = ElectricOperationalRates.preloadedSteamElectricity(32, updates); + c.record("positive.expectedGenerated", expected); + close(c, "positive.generated", c.energy(8, Y, Z, ELECTRICITY), expected, 0.001f); + close(c, "empty.generated", c.energy(16, Y, Z, ELECTRICITY), 0, 0.001f); + close(c, "full.electricity", c.energy(24, Y, Z, ELECTRICITY), 1000, 0.001f); + c.record("positive.steamRemaining", c.nbt(8, Y, Z).getFloat("steam")); + close(c, "positive.steamExhausted", c.nbt(8, Y, Z).getFloat("steam"), 0, 0.001f); + int fullUpdates = ElectricOperationalRates.updateDelays(c.activationTick, phase(c, 24, Y, Z), 40).size(); + close(c, "full.steamAfterDecay", c.nbt(24, Y, Z).getFloat("steam"), + ElectricOperationalRates.fullGeneratorSteam(32, fullUpdates), 0.001f); + })); + cases.add(new MachineScenario("electric/steam-generator-supply", + "A finite adjacent steam tank supplies a generator over the real conduit network; produced " + + "electricity is bounded by 31.25 per supplied steam unit, including decay, and charges a battery.", 100, c -> { + c.block(8, Y, Z, EA + "steam_powered_generator"); + c.steamSupply(7, Y, Z, 96); + c.electricSupply(9, Y, Z, 0); + c.record("steamSupply.constructionGameTime", c.world.getTotalWorldTime()); + }, c -> { + recordSchedule(c, "steamTank", c.activationTick, 7, Y, Z, 100); + recordSchedule(c, "generator", c.activationTick, 8, Y, Z, 100); + requireWindow(c, c.activationTick, 100); + ElectricOperationalRates.SupplyLedger expected = ElectricOperationalRates.tankSupply( + c.activationTick, phase(c, 7, Y, Z), phase(c, 8, Y, Z), 100, 96); + c.record("network.expectedTransfers", expected.transfers); + c.record("network.expectedConversions", expected.conversions); + c.record("network.expectedTankLeakage", expected.tankLeakage); + c.record("network.expectedGeneratorLeakage", expected.generatorLeakage); + c.record("network.expectedGenerated", expected.generated); + float generated = c.energy(8, Y, Z, ELECTRICITY) + c.energy(9, Y, Z, ELECTRICITY); + close(c, "network.generated", generated, expected.generated, 0.001f); + c.require(c.energy(9, Y, Z, ELECTRICITY) > 0, "Steam generation charges a real battery"); + close(c, "network.steamSupplyExhausted", c.energy(7, Y, Z, "steam"), expected.remaining, 0.001f); + })); + } + + private static void batteries(List cases) { + cases.add(new MachineScenario("electric/battery-priority", + "A finite generator supplies high-priority LED and medium-priority furnace before backup " + + "battery charging. Its one-time LED allocation expires without further supply. Stored electricity " + + "plus actual load debit is conserved; batteries do not cycle " + + "into equal-priority batteries.", 80, c -> { + c.block(8, Y, Z, EA + "steam_powered_generator"); + c.power(8, Y, Z, ELECTRICITY, 100); + c.block(9, Y, Z, EA + "electric_furnace"); + c.block(8, Y, Z - 1, EA + "led_bar"); + c.electricSupply(8, Y, Z + 1, 0); + c.electricSupply(22, Y, Z, 500); + c.electricSupply(23, Y, Z, 0); + }, c -> { + float furnace = c.energy(9, Y, Z, ELECTRICITY); + float led = c.energy(8, Y, Z - 1, ELECTRICITY); + float source = c.energy(8, Y, Z, ELECTRICITY); + float backup = c.energy(8, Y, Z + 1, ELECTRICITY); + c.record("priority.furnaceStored", furnace); + c.record("priority.backupStored", backup); + close(c, "priority.furnaceAllocation", furnace, 99.5f, 0.001f); + close(c, "priority.backupAllocation", backup, 0, 0.001f); + c.require(!c.world.getBlockState(c.at(8, Y, Z - 1)).getValue(LED_LIT), "One-time half-unit LED allocation expires without creating energy"); + close(c, "priority.totalStored", source + furnace + led + backup, 99.5f, 0.001f); + close(c, "noCycle.charged", c.energy(22, Y, Z, ELECTRICITY), 500, 0.001f); + close(c, "noCycle.empty", c.energy(23, Y, Z, ELECTRICITY), 0, 0.001f); + })); + cases.add(new MachineScenario("electric/battery-redstone", + "A redstone-disabled battery retains its finite charge and cannot energize its adjacent oven.", 160, c -> { + c.electricSupply(7, Y, Z, 2000); + c.block(6, Y, Z, "minecraft:redstone_block"); + c.block(8, Y, Z, EA + "electric_oven"); + c.put(8, Y, Z, 0, "minecraft:potato", 1); + }, c -> { + close(c, "disabled.battery", c.energy(7, Y, Z, ELECTRICITY), 2000, 0.001f); + close(c, "disabled.ovenEnergy", c.energy(8, Y, Z, ELECTRICITY), 0, 0.001f); + equal(c, "disabled.rawInput", slotCount(c, 8, 0, "minecraft:potato"), 1); + equal(c, "disabled.product", output(c, 8, 1, 1, "minecraft:baked_potato"), 0); + })); + } + + private static void turret(List cases) { + for (int variant = 0; variant < 4; variant++) { + final int mode = variant; + final EntityHusk[] target = new EntityHusk[1]; + cases.add(new MachineScenario("electric/turret-" + new String[] {"hit", "no-power", "scarce", "occluded"}[mode], + "Natural turret acquisition must damage a real stationary hostile entity and debit a 1000-unit " + + "shot plus tracking energy. No electricity, sub-shot reserve and a solid wall cannot damage it.", 80, c -> { + turretRoom(c); + c.block(8, 1, Z, EA + "laser_turret"); + if (mode != 1) c.power(8, 1, Z, ELECTRICITY, mode == 2 ? 999 : 1500); + if (mode == 3) for (int y = 1; y <= 4; y++) for (int z = Z - 2; z <= Z + 2; z++) + c.block(12, y, z, "minecraft:stone"); + EntityHusk husk = new EntityHusk(c.world); + c.require(husk != null, "Created a real hostile target"); + husk.setPositionAndRotation(c.at(16, 1, Z).getX() + 0.5, c.at(16, 1, Z).getY(), c.at(16, 1, Z).getZ() + 0.5, 0, 0); + husk.setNoAI(true); + husk.enablePersistence(); + husk.setNoGravity(true); + c.require(husk.getEntityAttribute(SharedMonsterAttributes.ARMOR) != null, "Hostile target has an armor attribute"); + husk.getEntityAttribute(SharedMonsterAttributes.ARMOR).setBaseValue(0); + equal(c, "target.fixtureArmor", husk.getTotalArmorValue(), 0); + // Fire immunity isolates the seven-point laser impact from its burn side effect. + husk.addPotionEffect(new net.minecraft.potion.PotionEffect(net.minecraft.init.MobEffects.FIRE_RESISTANCE, 200, 0)); + c.require(c.world.spawnEntity(husk), "Spawned target into the real server world"); + target[0] = husk; + c.record("target.initialHealth", husk.getHealth()); + c.record("target.entityUuid", husk.getUniqueID().toString()); + }, c -> { + EntityHusk husk = target[0]; + try { + c.require(husk != null && husk.isEntityAlive(), "Hostile target survives for impact measurement"); + c.record("target.health", husk.getHealth()); + c.record("target.ticks", husk.ticksExisted); + c.record("target.turretEnergy", c.energy(8, 1, Z, ELECTRICITY)); + c.require(husk.ticksExisted >= 70, "Target received natural server entity ticks"); + if (mode == 0) { + close(c, "hit.health", husk.getHealth(), 13, 0.01f); + between(c, "hit.energyDebit", 1500 - c.energy(8, 1, Z, ELECTRICITY), 1024, 1032); + } else { + close(c, "negative.health", husk.getHealth(), 20, 0.01f); + if (mode == 1 || mode == 2) close(c, "negative.energy", c.energy(8, 1, Z, ELECTRICITY), mode == 1 ? 0 : 999, 0.01f); + if (mode == 3) between(c, "occluded.trackingDebit", 1500 - c.energy(8, 1, Z, ELECTRICITY), 70, 80); + } + } finally { + if (husk != null) husk.setDead(); + target[0] = null; + } + })); + } + final FakePlayer[] owner = new FakePlayer[1]; + final ScorePlayerTeam[] team = new ScorePlayerTeam[1]; + final EntityHusk[] victim = new EntityHusk[1]; + cases.add(new MachineScenario("electric/turret-owner-credit", + "A real naturally acquired hostile target is hit and its last attacking mob is the turret's " + + "registered owner, proving ownership has a gameplay effect beyond saved owner NBT.", 40, c -> { + turretRoom(c); + c.block(8, 1, Z, EA + "laser_turret"); + c.power(8, 1, Z, ELECTRICITY, 1500); + UUID identity = UUID.randomUUID(); + String name = "EOp" + identity.toString().substring(0, 8); + FakePlayer player = new FakePlayer(c.world, new GameProfile(identity, name)); + player.connection = new net.minecraft.network.NetHandlerPlayServer(c.world.getMinecraftServer(), + new net.minecraft.network.NetworkManager(net.minecraft.network.EnumPacketDirection.SERVERBOUND), player) { + @Override public void sendPacket(net.minecraft.network.Packet packet) { } + }; + player.setPositionAndRotation(c.at(8, 1, 16).getX() + 0.5, c.at(8, 1, 16).getY(), c.at(8, 1, 16).getZ() + 0.5, 0, 0); + owner[0] = player; + team[0] = c.world.getScoreboard().createTeam(name); + c.world.getScoreboard().addPlayerToTeam(player.getName(), team[0].getName()); + c.world.spawnEntity(player); + c.invoke(8, 1, Z, "setOwner", new Class[] {EntityPlayer.class}, player); + EntityHusk husk = new EntityHusk(c.world); + c.require(husk != null, "Created ownership target"); + victim[0] = husk; + husk.setPositionAndRotation(c.at(16, 1, Z).getX() + 0.5, c.at(16, 1, Z).getY(), c.at(16, 1, Z).getZ() + 0.5, 0, 0); + husk.setNoAI(true); + husk.setNoGravity(true); + husk.enablePersistence(); + c.require(husk.getEntityAttribute(SharedMonsterAttributes.ARMOR) != null, "Ownership target has an armor attribute"); + husk.getEntityAttribute(SharedMonsterAttributes.ARMOR).setBaseValue(0); + equal(c, "ownerCredit.fixtureArmor", husk.getTotalArmorValue(), 0); + husk.addPotionEffect(new net.minecraft.potion.PotionEffect(net.minecraft.init.MobEffects.FIRE_RESISTANCE, 200, 0)); + c.require(c.world.spawnEntity(husk), "Ownership target is in the real world"); + c.record("owner.profile", name); + }, c -> { + try { + c.require(c.world.playerEntities.contains(owner[0]), "Owner is registered in this server world"); + c.record("ownerCredit.turretEnergy", c.energy(8, 1, Z, ELECTRICITY)); + close(c, "ownerCredit.targetHealth", victim[0].getHealth(), 13, 0.01f); + c.require(victim[0].getRevengeTarget() == owner[0], "Real laser impact is credited to the configured owner"); + c.record("ownerCredit.attacker", victim[0].getRevengeTarget().getUniqueID().toString()); + between(c, "ownerCredit.energyDebit", 1500 - c.energy(8, 1, Z, ELECTRICITY), 1024, 1032); + } finally { + if (victim[0] != null) victim[0].setDead(); + if (owner[0] != null) { + owner[0].setDead(); + c.world.playerEntities.remove(owner[0]); + } + if (team[0] != null) c.world.getScoreboard().removeTeam(team[0]); + owner[0] = null; + team[0] = null; + victim[0] = null; + } + })); + } + + private static void turretRoom(MachineContext c) { + for (int x = 2; x <= 29; x++) for (int z = 2; z <= 29; z++) { + c.block(x, 0, z, "minecraft:stone"); + c.block(x, 5, z, "minecraft:stone"); + if (x == 2 || x == 29 || z == 2 || z == 29) + for (int y = 1; y < 5; y++) c.block(x, y, z, "minecraft:stone"); + } + } + + private static float pairEnergy(MachineContext c, int x) { return energyPair(c, x, Y, Z); } + + private static int phase(MachineContext c, int x, int y, int z) { + BlockPos pos = c.at(x, y, z); + return ElectricOperationalRates.offset(pos.getX(), pos.getY(), pos.getZ()); + } + + private static void recordSchedule(MachineContext c, String key, long activationTick, int x, int y, int z, int ticks) { + int offset = phase(c, x, y, z); + c.record(key + ".activationTick", activationTick); + c.record(key + ".position", c.at(x, y, z).toString()); + c.record(key + ".powerOffset", offset); + c.record(key + ".expectedUpdateDelays", ElectricOperationalRates.updateDelays(activationTick, offset, ticks)); + } + + private static void requireWindow(MachineContext c, long activationTick, int ticks) { + long elapsed = c.world.getTotalWorldTime() - activationTick; + c.record("rate.elapsedTicks", elapsed); + c.require(elapsed == ticks, "Rate oracle observed exactly " + ticks + " natural world ticks"); + } + + private static float energyPair(MachineContext c, int x, int y, int z) { + return c.energy(x, y, z, ELECTRICITY) + c.energy(x - 1, y, z, ELECTRICITY); + } + + private static int slotCount(MachineContext c, int x, int slot, String id) { + return stackCount(c.inventory(x, Y, Z).getStackInSlot(slot), id); + } + + private static int inputCount(MachineContext c, int x, int slots, String id) { + return output(c, x, 0, slots, id); + } + + private static int output(MachineContext c, int x, int first, int slots, String id) { + IInventory inventory = c.inventory(x, Y, Z); + int count = 0; + for (int slot = first; slot < first + slots; slot++) count += stackCount(inventory.getStackInSlot(slot), id); + return count; + } + + private static int stackCount(ItemStack stack, String id) { + return stack == null || stack.isEmpty() || !new ResourceLocation(id).equals(stack.getItem().getRegistryName()) ? 0 : stack.getCount(); + } + + private static NativeFluidPort fluids(MachineContext c, int x) { + return fluids(c, x, null); + } + + private static NativeFluidPort fluids(MachineContext c, int x, EnumFacing face) { + return new NativeFluidPort(c.tile(x, Y, Z), face); + } + + // These are finite fixture reserves, not automation acceptance tests. Electric's + // fluid machines are siblings of TileEntitySimpleFluidMachine, so use their public API. + private static void reserve(MachineContext c, int x, int y, int z, String method, String id, int amount) { + FluidTank tank = (FluidTank) + c.invoke(x, y, z, method, new Class[0]); + Fluid fluid = NativeFluids.get(id); + c.require(fluid != null && amount >= 0 && amount <= tank.getCapacity(), "Valid finite fluid reserve " + id); + tank.setFluid(new FluidStack(fluid, amount)); + c.tile(x, y, z).markDirty(); + c.record("setup.reserve/" + x + "/" + y + "/" + z + "/" + method, id + ":" + amount); + } + + private static int fluidAmount(MachineContext c, int x, int y, int z) { + int amount = ((FluidTank)c.invoke(x, y, z, "getTank", new Class[0])).getFluidAmount(); + c.record("fluid/" + x + "/" + y + "/" + z, amount); + return amount; + } + + private static int tankAmount(MachineContext c, int x, int tank) { + return fluids(c, x).amount(tank); + } + + private static void fluidIs(MachineContext c, int x, int tank, String id) { + FluidStack fluid = fluids(c, x).getFluidInTank(tank); + c.require(fluid != null && fluid.amount > 0, "Tank contains a fluid product " + id); + c.record("fluid." + x + "." + tank, fluid.getFluid().getName()); + c.require(fluid.getFluid() == NativeFluids.get(id), "Tank contains " + id); + } + + private static void rejectFluid(MachineContext c, int x, String id, int amount) { + Fluid fluid = NativeFluids.get(id); + c.require(fluid != null, "Negative-control fluid exists: " + id); + equal(c, "invalidFluid.accepted", fluids(c, x).fill(new FluidStack(fluid, amount), true), 0); + } + + private static void equal(MachineContext c, String key, int actual, int expected) { + c.record(key, actual); + c.require(actual == expected, key + " expected " + expected + ", got " + actual); + } + + private static void close(MachineContext c, String key, float actual, float expected, float tolerance) { + c.record(key, actual); + c.require(Float.isFinite(actual) && Math.abs(actual - expected) <= tolerance, + key + " expected " + expected + " +/- " + tolerance + ", got " + actual); + } + + private static void between(MachineContext c, String key, float actual, float low, float high) { + c.record(key, actual); + c.require(Float.isFinite(actual) && actual >= low && actual <= high, + key + " expected [" + low + ", " + high + "], got " + actual); + } +} diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/ElectricOperationalRates.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/ElectricOperationalRates.java new file mode 100644 index 0000000..136232a --- /dev/null +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/ElectricOperationalRates.java @@ -0,0 +1,97 @@ +package zone.moddev.mc.advantageworks; + +import java.util.ArrayList; +import java.util.List; + +/** Input-only rate oracles. No tile calls, energy reads, or machine tick simulation. */ +final class ElectricOperationalRates { + private ElectricOperationalRates() {} + + static int offset(int x, int y, int z) { + return ((z & 1) << 2) | ((x & 1) << 1) | (y & 1); + } + + static int firstUpdateDelay(long activationTick, int offset) { + if (offset < 0 || offset > 7) throw new IllegalArgumentException("Invalid power phase"); + int phase = (int)Math.floorMod(activationTick, 8L); + return 1 + Math.floorMod(-phase - offset - 1, 8); + } + + static List updateDelays(long activationTick, int offset, int ticks) { + if (ticks < 0) throw new IllegalArgumentException("Negative wait"); + List result = new ArrayList<>(); + for (int delay = firstUpdateDelay(activationTick, offset); delay <= ticks; delay += 8) result.add(delay); + return result; + } + + static float ledDebit(long activationTick, int sourceOffset, int ledOffset, int ticks, float reserve) { + int firstSupply = firstUpdateDelay(activationTick, sourceOffset); + int paidUpdates = 0; + for (int delay : updateDelays(activationTick, ledOffset, ticks)) if (delay > firstSupply) paidUpdates++; + return Math.min(reserve, paidUpdates * 0.125f); + } + + static float preloadedSteamElectricity(float steam, int updates) { + if (updates == 0) return 0; + // This generator's decay is per power update, not per world tick. + return (steam > 0.5f ? steam - 0.5f : steam) * 31.25f; + } + + static float fabricatorDebit(long activationTick, int supplyOffset, int ticks, boolean sourceTicksFirst) { + int firstSupply = firstUpdateDelay(activationTick, supplyOffset); + // READY, CRAFTING, 200 work ticks, COMPLETE, commit, READY, then the next craft. + // The two possibilities only cover order of adjacent tiles inside the first supply tick. + int secondWork = Math.max(0, ticks - firstSupply - (sourceTicksFirst ? 204 : 205)); + return Math.min(4000, 3200 + secondWork * 16); + } + + static float fullGeneratorSteam(float steam, int updates) { + for (int i = 0; i < updates; i++) if (steam > 0.5f) steam -= 0.5f; + return steam; + } + + static SupplyLedger tankSupply(long activationTick, int tankOffset, int generatorOffset, + int ticks, float reserve) { + if (tankOffset == generatorOffset) throw new IllegalArgumentException("Adjacent source phases must differ"); + SupplyLedger result = new SupplyLedger(); + float remaining = reserve; + int previousTransfer = 0; + // The tank's initial cached redstone gate is true; its first update only opens the gate. + for (int transfer = firstUpdateDelay(activationTick, tankOffset) + 8; + transfer <= ticks && remaining > 0; transfer += 8) { + float leakage = Math.min(remaining, (transfer - previousTransfer) * 0.0625f); + result.tankLeakage += leakage; + remaining -= leakage; + previousTransfer = transfer; + if (remaining == 0) break; + float packet = Math.min(32, remaining); + remaining -= packet; + int conversion = transfer + firstUpdateDelay(activationTick + transfer, generatorOffset); + if (conversion > ticks) throw new IllegalArgumentException("Wait ends before final conversion"); + result.transfers.add(transfer); + result.conversions.add(conversion); + result.generated += preloadedSteamElectricity(packet, 1); + result.generatorLeakage += packet > 0.5f ? 0.5f : 0; + } + result.remaining = Math.max(0, remaining - (ticks - previousTransfer) * 0.0625f); + return result; + } + + static float daylightElectricity(long activationTick, int offset, int ticks, + long setupDayTime, boolean daylightCycle, float weather) { + float total = 0; + float pi = (float)Math.PI; + float packet = pi * 12500 / (24000 / pi / 8); + for (int delay : updateDelays(activationTick, offset, ticks)) { + long day = setupDayTime + (daylightCycle ? delay : 0); + float angle = (float)day * 2.61799E-4f; + total += Math.max(0, packet * (float)Math.sin(angle) * weather); + } + return total; + } + + static final class SupplyLedger { + final List transfers = new ArrayList<>(), conversions = new ArrayList<>(); + float generated, tankLeakage, generatorLeakage, remaining; + } +} diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/LegacyPartialNetworkCases.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/LegacyPartialNetworkCases.java new file mode 100644 index 0000000..f303650 --- /dev/null +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/LegacyPartialNetworkCases.java @@ -0,0 +1,466 @@ +package zone.moddev.mc.advantageworks; + +import cyano.poweradvantage.api.ConduitType; +import cyano.poweradvantage.api.PoweredEntity; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.minecraft.inventory.IInventory; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.tileentity.TileEntity; +import net.minecraft.util.EnumFacing; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.ChunkPos; +import net.minecraft.world.chunk.Chunk; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.event.world.ChunkEvent; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; + +/** Native partial unloads. No world/tile getter is used for an absent chunk. */ +public final class LegacyPartialNetworkCases { + static final int TICKS = 1100, RELEASE = 240, ABSENT_TICKS = 240; + private LegacyPartialNetworkCases() {} + + static void register(List cases) { + for (String type : new String[]{"steam", "electric", "fluid"}) + for (String part : new String[]{"bridge", "source", "consumer"}) add(cases, type, part); + } + + static void registerBridges(List cases) { + for (String type : new String[]{"steam", "electric", "fluid"}) add(cases, type, "bridge"); + } + + private static void add(List cases, String type, String part) { + String id = "power/partial/" + type + (part.equals("bridge") ? "" : "-" + part); + Job[] job = new Job[1]; + cases.add(new MachineScenario(id, + "A finite " + type + " network works before its " + part + " chunk is unforced and queued " + + "through the native provider. A real unload and 240 consecutive absent ticks are required " + + "while other endpoints remain loaded and unchanged. The same ticket restores the chunk; " + + "event-time typed NBT and finite resources survive, and native processing resumes without rebuilding.", + TICKS, c -> { + String pipe = type.equals("steam") ? "steamadvantage:steam_pipe" + : type.equals("electric") ? "electricadvantage:electric_conduit" : "poweradvantage:fluid_pipe"; + if (type.equals("steam")) { + c.steamSupply(3, 4, 3, 6000); + c.block(20, 4, 20, "steamadvantage:steam_crusher"); + c.put(20, 4, 20, 0, "minecraft:stone", 8); + c.require(c.inventory(20, 4, 20).isItemValidForSlot(0, c.inventory(20, 4, 20).getStackInSlot(0)), + "Native stone crushing recipe is available"); + } else if (type.equals("electric")) { + c.electricSupply(3, 4, 3, 6000); + NativeStacks.tag(c.inventory(3, 4, 3).getStackInSlot(0)).setString("PartialNetworkWitness", id); + c.block(20, 4, 20, "electricadvantage:electric_oven"); + c.put(20, 4, 20, 0, "minecraft:potato", 8); + } else { + c.block(3, 4, 3, "poweradvantage:fluid_metal_tank"); + c.tank(3, 4, 3, "minecraft:water", 6000); + c.block(20, 4, 20, "steamadvantage:steam_boiler_coal"); + c.put(20, 4, 20, 0, "minecraft:coal", 1); + } + for (int x = 4; x <= 20; x++) c.block(x, 4, 3, pipe); + for (int z = 4; z < 20; z++) c.block(20, 4, z, pipe); + job[0] = new Job(c, type, part); + c.require(!job[0].sourceChunk.equals(job[0].consumerChunk) + && !job[0].bridgeChunk.equals(job[0].sourceChunk) + && !job[0].bridgeChunk.equals(job[0].consumerChunk), + "The route has genuinely separate source, bridge, and consumer chunks"); + job[0].begin(); + }, c -> job[0].verify()) + .timed(c -> job[0].tick()) + .cleanup(c -> { if (job[0] != null) job[0].cleanup(); }) + .exclusive()); + } + + public static final class Job { + final MachineContext c; + final String type, part; + final BlockPos source, consumer; + final ChunkPos sourceChunk, consumerChunk, bridgeChunk, selected; + final Gate gate = new Gate(); + final Isolation isolation; + final List> events = new ArrayList<>(), polls = new ArrayList<>(); + final TileEntity[] retainedTiles = new TileEntity[2]; + final Map frozenTags = new LinkedHashMap<>(); + Map frozenBlocks; + State frozenSource, frozenConsumer, previous, beforeRestore; + Throwable eventFailure; + boolean released, restoring, registered, ticketRestored; + int frames; + double paid, idle; + + Job(MachineContext c, String type, String part) { + this.c = c; this.type = type; this.part = part; + isolation = part.equals("source") ? null : new Isolation(type); + source = c.at(3, 4, 3); consumer = c.at(20, 4, 20); + sourceChunk = new ChunkPos(source); consumerChunk = new ChunkPos(consumer); + bridgeChunk = new ChunkPos(c.at(20, 4, 3)); + selected = part.equals("source") ? sourceChunk : part.equals("consumer") ? consumerChunk : bridgeChunk; + c.record("partial.type", type); c.record("partial.selectedPart", part); + c.record("partial.events", events); c.record("partial.observations", polls); + c.record("partial.selectedChunk", chunkCoordinates(selected)); + c.record("partial.isolationApplicable", isolation != null); + if (isolation != null) c.record("partial.isolationSamples", isolation.samples); + } + + void begin() { + retainedTiles[0] = tile(source); retainedTiles[1] = tile(consumer); + previous = state(); + checkResources(type, previous); + MinecraftForge.EVENT_BUS.register(this); registered = true; + poll(0); + } + + @SubscribeEvent public void unloaded(ChunkEvent.Unload event) { + if (event.getWorld() != c.world || !chunkPosition(event.getChunk()).equals(selected) || !released) return; + try { + gate.unloaded(elapsed()); + frozenBlocks = blocks(event.getChunk()); + for (BlockPos pos : new BlockPos[]{source, consumer}) if (new ChunkPos(pos).equals(selected)) { + TileEntity nativeTile = event.getChunk().getTileEntityMap().get(pos); + if (nativeTile == null) throw new AssertionError("Missing tile in actual unload callback"); + frozenTags.put(pos, nativeTile.writeToNBT(new NBTTagCompound()).copy()); + if (pos.equals(source)) frozenSource = read(nativeTile, true); + else frozenConsumer = read(nativeTile, false); + } + event("unload", elapsed(), event.getChunk(), false); + } catch (Throwable failure) { eventFailure = failure; event("unload", elapsed(), event.getChunk(), false); } + } + + @SubscribeEvent public void loaded(ChunkEvent.Load event) { + if (event.getWorld() != c.world || !chunkPosition(event.getChunk()).equals(selected) || !released) return; + try { + gate.loaded(elapsed()); + if (!restoring) throw new AssertionError("Production reloaded the released chunk before explicit recovery"); + if (frozenBlocks == null || !frozenBlocks.equals(blocks(event.getChunk()))) + throw new AssertionError("Reload changed the native network block states"); + for (Map.Entry entry : frozenTags.entrySet()) { + TileEntity nativeTile = event.getChunk().getTileEntityMap().get(entry.getKey()); + if (nativeTile == null || !entry.getValue().equals(nativeTile.writeToNBT(new NBTTagCompound()))) + throw new AssertionError("Actual unload/load callback typed NBT differs at " + entry.getKey()); + } + event("load", elapsed(), event.getChunk(), true); + } catch (Throwable failure) { eventFailure = failure; event("load", elapsed(), event.getChunk(), false); } + } + + void tick() { + int tick = elapsed(); + if (tick != frames + 1) throw new AssertionError("Partial fixture missed or duplicated a real world tick"); + frames = tick; + if (eventFailure != null) throw new AssertionError("Native partial callback failed", eventFailure); + boolean retained = retained(); + Chunk selectedChunk = c.readOnlyLoadedChunk(selected); + boolean present = selectedChunk != null, ready = present && selectedChunk.isLoaded(); + boolean restore = false; + if (tick >= RELEASE) { + restore = gate.observe(tick, present, ready, retained); + if (tick % 20 == 0 || tick == RELEASE) poll(tick); + } else if (!retained || !present || !ready) throw new AssertionError("Fixture was not fully loaded before interruption"); + State now = state(); + checkResources(type, now); + if (isolation != null && released && !restoring && !present && gate.unloadTick >= 0) { + Chunk loadedSource = c.readOnlyLoadedChunk(sourceChunk); + try { + isolation.observe(tick, now.source, loadedSource != null && loadedSource.isLoaded(), true); + } finally { + c.record("partial.isolationAnchorTick", isolation.anchorTick); + c.record("partial.isolationAnchorSource", isolation.anchorSource); + c.record("partial.isolationLastTick", isolation.lastTick); + c.record("partial.isolationLastSource", isolation.lastSource); + c.record("partial.isolationStableTicks", isolation.lastTick - isolation.anchorTick); + } + } + if (!type.equals("fluid")) { + int length = type.equals("steam") ? 125 : 100; + int delta = (now.output - previous.output) * length + now.progress - previous.progress; + if (now.output < previous.output || now.output - previous.output > 1 || delta < -1 || delta > 1) + throw new AssertionError("Finite work/product state changed outside native operation"); + double work = delta == 1 ? type.equals("steam") ? 1.5 : 4 : 0; + if (gate.recovered && delta < 0 && previous.consumer >= (type.equals("steam") ? 1.5 : 4)) + throw new AssertionError("Powered work lost progress after native recovery"); + double loss = previous.total - now.total - work; + double maximum = type.equals("steam") ? 0.125 : 0; + if (loss < 0 || loss > maximum || loss * 16 != Math.rint(loss * 16)) + throw new AssertionError("Partial unload lost or created power: " + loss); + paid += work; idle += loss; + } + previous = now; + if (restore) { + if (isolation != null) isolation.complete(); + beforeRestore = now; + restoring = true; + gate.restore(tick); + action("explicit-restore", tick); + c.restoreOwnedChunk(selected); + ticketRestored = true; + } + if (tick == RELEASE) { + c.require(type.equals("fluid") ? now.converted > 0 && now.source < 6000 : now.output >= 1, + "A real working connected route precedes the unload"); + gate.request(tick); + Chunk nativeChunk = c.readOnlyLoadedChunk(selected); + if (nativeChunk == null || !nativeChunk.isLoaded()) throw new AssertionError("Selected chunk was not loaded at release"); + released = true; + c.unforceOwnedChunk(selected); + action("native-unload-queued", tick); + c.world.getChunkProvider().queueUnload(nativeChunk); + } + } + + State state() { + State a = snapshot(source, true), b = snapshot(consumer, false); + return new State(a.source, b.consumer, b.input, b.output, b.progress, b.converted); + } + + State snapshot(BlockPos pos, boolean isSource) { + Chunk chunk = c.readOnlyLoadedChunk(new ChunkPos(pos)); + if (chunk == null || !chunk.isLoaded()) { + State frozen = isSource ? frozenSource : frozenConsumer; + if (!new ChunkPos(pos).equals(selected) || frozen == null) throw new AssertionError("No native unload-time state for absent endpoint"); + return frozen; + } + return read(tile(pos), isSource); + } + + State read(TileEntity nativeTile, boolean isSource) { + if (type.equals("fluid")) { + NativeFluidPort port = new NativeFluidPort(nativeTile, EnumFacing.UP); + net.minecraftforge.fluids.FluidStack water = port.getFluidInTank(0); + if (water != null && water.amount > 0 && water.getFluid() != net.minecraftforge.fluids.FluidRegistry.WATER) + throw new AssertionError("Fluid identity changed"); + int amount = water == null ? 0 : water.amount; + double steam = isSource ? 0 : ((PoweredEntity)nativeTile).getEnergy(new ConduitType("steam")); + return new State(isSource ? amount : 0, isSource ? 0 : amount, 0, 0, 0, steam); + } + double energy = ((PoweredEntity)nativeTile).getEnergy(new ConduitType(type.equals("steam") ? "steam" : "electricity")); + if (isSource) return new State(energy, 0, 0, 0, 0, 0); + IInventory inventory = (IInventory)nativeTile; + String in = type.equals("steam") ? "minecraft:stone" : "minecraft:potato"; + String out = type.equals("steam") ? "minecraft:cobblestone" : "minecraft:baked_potato"; + int inputs = count(inventory.getStackInSlot(0), in), outputs = 0; + for (int slot = 1; slot < inventory.getSizeInventory(); slot++) outputs += count(inventory.getStackInSlot(slot), out); + NBTTagCompound tag = nativeTile.writeToNBT(new NBTTagCompound()); + return new State(0, energy, inputs, outputs, tag.getShort(type.equals("steam") ? "progress" : "cookTime"), 0); + } + + TileEntity tile(BlockPos pos) { + Chunk loaded = c.readOnlyLoadedChunk(new ChunkPos(pos)); + if (loaded == null || !loaded.isLoaded()) throw new AssertionError("Observer refuses to load an absent chunk"); + TileEntity tile = loaded.getTileEntityMap().get(pos); + if (tile == null || !tile.getPos().equals(pos)) throw new AssertionError("Missing/moved native endpoint"); + return tile; + } + + boolean retained() { + for (ChunkPos chunk : new ChunkPos[]{sourceChunk, consumerChunk, bridgeChunk}) { + if (chunk.equals(selected)) continue; + Chunk current = c.readOnlyLoadedChunk(chunk); + if (current == null || !current.isLoaded()) return false; + } + if (!sourceChunk.equals(selected) && tile(source) != retainedTiles[0]) return false; + return consumerChunk.equals(selected) || tile(consumer) == retainedTiles[1]; + } + + Map blocks(Chunk chunk) { + Map result = new LinkedHashMap<>(); + for (BlockPos pos : c.positions) if (new ChunkPos(pos).equals(selected)) + result.put(pos, chunk.getBlockState(pos).toString()); + return result; + } + + void verify() { + gate.complete(); + c.require(frames == TICKS, "All declared native ticks were observed"); + c.require(gate.absentSamples >= ABSENT_TICKS && gate.recovered, + "Actual selected chunk absence lasted 240 ticks before a real later reload"); + if (isolation != null) { + isolation.complete(); + c.require(isolation.lastTick - isolation.anchorTick >= 220, + "Loaded source transferred nothing across the absent bridge/consumer for at least 220 measured ticks"); + } + c.require(beforeRestore != null && (type.equals("fluid") + ? previous.converted > beforeRestore.converted && previous.source < beforeRestore.source + : previous.output > beforeRestore.output), + "Restored native network resumes finite work and transfers without rebuilding or refill"); + if (!type.equals("fluid")) c.require(6000 - previous.total == paid + idle, + "Finite electrical/steam reserve pays only real work and native idle loss"); + c.record("partial.completed", true); c.record("partial.absentTicks", gate.absentSamples); + c.record("partial.unloadTick", gate.unloadTick); c.record("partial.loadTick", gate.loadTick); + c.record("partial.restoreTick", gate.restoreTick); + c.record("partial.measuredTicks", frames); c.record("partial.finalSource", previous.source); + c.record("partial.finalConsumer", previous.consumer); c.record("partial.finalOutput", previous.output); + c.record("partial.convertedWater", previous.converted); + c.record("partial.paidWork", paid); c.record("partial.idleLoss", idle); + poll(elapsed()); + } + + void cleanup() { + if (registered) { MinecraftForge.EVENT_BUS.unregister(this); registered = false; } + if (released && !ticketRestored) { + action("cleanup-ticket-restore", elapsed()); + restoring = true; c.restoreOwnedChunk(selected); ticketRestored = true; + } + c.record("partial.cleanupCalled", true); + } + + void event(String kind, int tick, Chunk chunk, boolean equal) { + Map row = new LinkedHashMap<>(); + row.put("operation", kind); row.put("tick", tick); row.put("chunk", chunkCoordinates(chunkPosition(chunk))); + row.put("explicitRecovery", restoring); row.put("typedStateEqual", equal); + List> tags = new ArrayList<>(); + for (Map.Entry entry : frozenTags.entrySet()) { + Map tag = new LinkedHashMap<>(); + tag.put("pos", new int[]{entry.getKey().getX(), entry.getKey().getY(), entry.getKey().getZ()}); + tag.put("nbtBase64", encode(entry.getValue())); tags.add(tag); + TileEntity observed = chunk.getTileEntityMap().get(entry.getKey()); + if (observed != null) tag.put("observedNbtBase64", encode(observed.writeToNBT(new NBTTagCompound()))); + } + row.put("unloadState", tags); + List> nativeBlocks = new ArrayList<>(); + for (Map.Entry block : blocks(chunk).entrySet()) { + Map nativeBlock = new LinkedHashMap<>(); + nativeBlock.put("pos", new int[]{block.getKey().getX(), block.getKey().getY(), block.getKey().getZ()}); + nativeBlock.put("state", block.getValue()); nativeBlocks.add(nativeBlock); + } + row.put("networkBlocks", nativeBlocks); + if (eventFailure != null) row.put("failure", eventFailure.toString()); + events.add(row); c.actions.add(row); + } + + void poll(int tick) { + Chunk found = c.readOnlyLoadedChunk(selected); + Map row = new LinkedHashMap<>(); + row.put("tick", tick); row.put("selectedPresent", found != null); + row.put("selectedLoaded", found != null && found.isLoaded()); + row.put("retainedLoaded", retained()); row.put("unloadTick", gate.unloadTick); + row.put("loadTick", gate.loadTick); row.put("explicitRecovery", restoring); + polls.add(row); + } + + void action(String name, int tick) { + Map row = new LinkedHashMap<>(); + row.put("operation", name); row.put("tick", tick); row.put("chunk", chunkCoordinates(selected)); + c.actions.add(row); + } + + int elapsed() { return Math.toIntExact(c.world.getTotalWorldTime() - c.activationTick); } + } + + /** Anchoring the first actual absent sample grants at most one native unload-phase grace tick. */ + static final class Isolation { + final String type; + final List> samples = new ArrayList<>(); + int anchorTick = -1, lastTick = -1; + double anchorSource, lastSource; + Isolation(String type) { + if (!type.equals("steam") && !type.equals("electric") && !type.equals("fluid")) + throw new IllegalArgumentException("Unknown isolation resource"); + this.type = type; + } + void observe(int tick, double source, boolean loaded, boolean absent) { + if (!loaded || !absent || !Double.isFinite(source) || source < 0) + throw new AssertionError("Isolation must observe an actually loaded finite source and absent target"); + if (anchorTick >= 0 && tick != lastTick + 1) + throw new AssertionError("Isolation source observations are not consecutive native ticks"); + if (anchorTick < 0) { anchorTick = tick; anchorSource = source; } + double expected = anchorSource - (type.equals("steam") ? (tick - anchorTick) * 0.0625 : 0); + Map row = new LinkedHashMap<>(); + row.put("tick", tick); row.put("source", source); row.put("sourceLoaded", loaded); + row.put("selectedAbsent", absent); row.put("expectedSource", expected); + samples.add(row); lastTick = tick; lastSource = source; + if (source != expected) throw new AssertionError("Ghost transfer across an absent network section: expected source " + + expected + ", observed " + source); + } + void complete() { + if (anchorTick < 0 || lastTick - anchorTick < 220 || samples.size() != lastTick - anchorTick + 1) + throw new AssertionError("Missing at least 220 consecutive isolation source ticks"); + } + } + + static final class Gate { + int requestTick = -1, unloadTick = -1, restoreTick = -1, loadTick = -1, absentStart = -1, absentSamples; + boolean unexpectedReload, recovered; + void request(int tick) { + if (requestTick >= 0) throw new AssertionError("Duplicate release request"); + requestTick = tick; + } + void unloaded(int tick) { + if (requestTick < 0 || restoreTick >= 0 || unloadTick >= 0) throw new AssertionError("Unexpected native unload event"); + unloadTick = tick; + } + void loaded(int tick) { + if (restoreTick < 0) { unexpectedReload = true; return; } + if (loadTick >= 0) throw new AssertionError("Unexpected duplicate native load event"); + loadTick = tick; + } + boolean observe(int tick, boolean present, boolean ready, boolean retained) { + if (!retained) throw new AssertionError("A retained network chunk/tile was lost"); + if (unexpectedReload) throw new AssertionError("Released network chunk reloaded itself"); + if (requestTick < 0) return false; + if (restoreTick < 0) { + if (present) { + if (absentStart >= 0) throw new AssertionError("Released chunk did not stay absent"); + if (tick - requestTick >= 80) throw new AssertionError("Native chunk never genuinely unloaded"); + return false; + } + if (unloadTick < 0) { + if (tick - requestTick >= 80) throw new AssertionError("Missing actual unload callback"); + return false; + } + if (absentStart < 0) absentStart = tick; + absentSamples = tick - absentStart; + return absentSamples >= ABSENT_TICKS; + } + if (tick - restoreTick > 80 && !recovered) throw new AssertionError("Native chunk did not reload within 80 ticks"); + if (present && ready && loadTick >= restoreTick && tick > loadTick) recovered = true; + return false; + } + void restore(int tick) { + if (restoreTick >= 0 || absentSamples < ABSENT_TICKS || unloadTick < 0) throw new AssertionError("Premature recovery"); + restoreTick = tick; + } + void complete() { + if (!recovered || unexpectedReload || absentSamples < ABSENT_TICKS || loadTick < restoreTick) + throw new AssertionError("Missing native partial-unload/recovery proof"); + } + } + + static final class State { + final double source, consumer, total, converted; + final int input, output, progress; + State(double source, double consumer, int input, int output, int progress, double converted) { + this.source = source; this.consumer = consumer; this.input = input; this.output = output; + this.progress = progress; this.converted = converted; total = source + consumer; + } + } + + static void checkResources(String type, State state) { + if (!Double.isFinite(state.source) || !Double.isFinite(state.consumer) || state.source < 0 || state.consumer < 0) + throw new AssertionError("Invalid finite native reserve"); + if (type.equals("fluid")) { + if (!Double.isFinite(state.converted) || state.converted < 0 || state.total + state.converted != 6000) + throw new AssertionError("Water/steam conservation failed"); + } else if (state.input < 0 || state.output < 0 || state.input + state.output != 8 + || state.progress < 0 || state.progress >= (type.equals("steam") ? 125 : 100) || state.total > 6000) { + throw new AssertionError("Finite native item/energy state changed"); + } + } + + static int[] chunkCoordinates(ChunkPos chunk) { return new int[]{chunk.x, chunk.z}; } + static ChunkPos chunkPosition(Chunk chunk) { return chunk.getPos(); } + private static int count(ItemStack stack, String name) { + int amount = stack == null ? 0 : stack.getCount(); + if (amount != 0 && !name.equals(String.valueOf(stack.getItem().getRegistryName()))) + throw new AssertionError("Unexpected material in finite processing fixture"); + return amount; + } + private static String encode(NBTTagCompound tag) { + try { + java.io.ByteArrayOutputStream output = new java.io.ByteArrayOutputStream(); + net.minecraft.nbt.CompressedStreamTools.writeCompressed(tag, output); + return java.util.Base64.getEncoder().encodeToString(output.toByteArray()); + } catch (java.io.IOException failure) { throw new IllegalStateException("Cannot record typed native NBT", failure); } + } +} diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/MachineContext.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/MachineContext.java new file mode 100644 index 0000000..357e79d --- /dev/null +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/MachineContext.java @@ -0,0 +1,319 @@ +package zone.moddev.mc.advantageworks; + +import cyano.poweradvantage.api.ConduitType; +import cyano.poweradvantage.api.PoweredEntity; +import cyano.poweradvantage.api.simple.TileEntitySimpleFluidMachine; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import java.lang.reflect.InvocationTargetException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import net.minecraft.block.Block; +import net.minecraft.block.state.IBlockState; +import net.minecraftforge.fluids.Fluid; +import net.minecraft.inventory.IInventory; +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.block.properties.IProperty; +import net.minecraft.tileentity.TileEntity; +import net.minecraft.util.ResourceLocation; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.WorldServer; +import net.minecraftforge.fml.common.registry.ForgeRegistries; + +final class MachineContext { + public final WorldServer world; + final BlockPos origin; + final Set positions = new LinkedHashSet<>(); + final List> actions = new ArrayList<>(), checks = new ArrayList<>(); + final Map measurements = new LinkedHashMap<>(); + boolean settingUp = true; + long activationTick; + int verified; + private net.minecraftforge.common.ForgeChunkManager.Ticket ticket; + private final Set releasedChunks = new LinkedHashSet<>(); + + private void holdChunk(int x, int z) { + if (!settingUp) throw new IllegalStateException("Chunk acquisition after fixture setup"); + if (ticket == null) ticket = net.minecraftforge.common.ForgeChunkManager.requestTicket( + AdvantageWorksMod.instance, world, net.minecraftforge.common.ForgeChunkManager.Type.NORMAL); + if (ticket == null) throw new IllegalStateException("No fixture chunk ticket available"); + net.minecraftforge.common.ForgeChunkManager.forceChunk(ticket, new net.minecraft.util.math.ChunkPos(x, z)); + world.getChunk(x, z); + } + + MachineContext(WorldServer world, int index) { + this.world = world; + origin = new BlockPos(50000 + index * 64, 80, 50000); + } + + BlockPos at(int x, int y, int z) { + if (x < 0 || x > 31 || y < -1 || y > 31 || z < 0 || z > 31) { + throw new IllegalArgumentException("Operating fixture escaped its declared bay"); + } + return origin.add(x, y, z); + } + + private void mutation(String operation, BlockPos pos, Object value) { + if (!settingUp) throw new IllegalStateException("Fixture mutation after setup: " + operation); + positions.add(pos.toImmutable()); + Map row = new LinkedHashMap<>(); + row.put("operation", operation); + row.put("pos", new int[]{pos.getX(), pos.getY(), pos.getZ()}); + row.put("value", value); + actions.add(row); + } + + + static boolean ownsBayChunk(BlockPos origin, net.minecraft.util.math.ChunkPos chunk) { + return chunk.x >= (origin.getX() >> 4) && chunk.x <= ((origin.getX() + 31) >> 4) + && chunk.z >= (origin.getZ() >> 4) && chunk.z <= ((origin.getZ() + 31) >> 4); + } + + net.minecraft.world.chunk.Chunk readOnlyLoadedChunk(net.minecraft.util.math.ChunkPos chunk) { + if (!ownsBayChunk(origin, chunk)) throw new IllegalArgumentException("Chunk outside the declared fixture bay"); + // getLoadedChunk(x,z) cancels native queued unloading; values() does not. + for (net.minecraft.world.chunk.Chunk loaded : world.getChunkProvider().getLoadedChunks()) { + if (loaded.x == chunk.x && loaded.z == chunk.z) return loaded; + } + return null; + } + + void unforceOwnedChunk(net.minecraft.util.math.ChunkPos chunk) { + if (settingUp || !ownsBayChunk(origin, chunk) || ticket == null || !ticket.getChunkList().contains(chunk)) + throw new IllegalStateException("Cannot release an unowned fixture chunk"); + java.util.Collection owners = + net.minecraftforge.common.ForgeChunkManager.getPersistentChunksFor(world).get(chunk); + if (owners.size() != 1 || !owners.contains(ticket)) throw new IllegalStateException("Another ticket holds the selected fixture chunk"); + releasedChunks.add(chunk); + net.minecraftforge.common.ForgeChunkManager.unforceChunk(ticket, chunk); + if (net.minecraftforge.common.ForgeChunkManager.getPersistentChunksFor(world).containsKey(chunk)) + throw new IllegalStateException("Selected chunk remains forced"); + Map row = new LinkedHashMap<>(); + row.put("operation", "unforce-owned-chunk"); row.put("chunk", new int[]{chunk.x, chunk.z}); + row.put("worldTime", world.getTotalWorldTime()); actions.add(row); + } + + void restoreOwnedChunk(net.minecraft.util.math.ChunkPos chunk) { + if (!ownsBayChunk(origin, chunk) || ticket == null || !releasedChunks.contains(chunk)) + throw new IllegalStateException("Cannot restore a chunk not released by this context"); + net.minecraftforge.common.ForgeChunkManager.forceChunk(ticket, chunk); + if (!ticket.getChunkList().contains(chunk)) throw new IllegalStateException("Fixture ticket was not restored"); + Map row = new LinkedHashMap<>(); + row.put("operation", "restore-owned-chunk"); row.put("chunk", new int[]{chunk.x, chunk.z}); + row.put("worldTime", world.getTotalWorldTime()); actions.add(row); + world.getChunk(chunk.x, chunk.z); + releasedChunks.remove(chunk); + } + + void prepareChunks() { + if (!settingUp) throw new IllegalStateException("Chunk preparation after setup"); + for (int x = origin.getX() >> 4; x <= (origin.getX() + 31) >> 4; x++) { + for (int z = origin.getZ() >> 4; z <= (origin.getZ() + 31) >> 4; z++) { + holdChunk(x, z); + } + } + } + + boolean chunksReady() { + for (int x = origin.getX() >> 4; x <= (origin.getX() + 31) >> 4; x++) + for (int z = origin.getZ() >> 4; z <= (origin.getZ() + 31) >> 4; z++) + if (!world.getChunk(x, z).isLoaded()) return false; + return true; + } + + void floor() { + if (!settingUp) throw new IllegalStateException("Foundation construction after setup"); + if (!chunksReady()) throw new IllegalStateException("Construction before native chunk load completed"); + for (int x = 0; x < 32; x++) for (int z = 0; z < 32; z++) { + world.setBlockState(at(x, -1, z), net.minecraft.init.Blocks.BEDROCK.getDefaultState(), 2); + } + } + + void lightHalo() { + if (!settingUp) throw new IllegalStateException("Light neighbourhood loading after setup"); + // World.checkLightFor refuses to propagate without its native 16/18-block neighbourhood. + for (int x = (origin.getX() >> 4) - 1; x <= ((origin.getX() + 31) >> 4) + 1; x++) + for (int z = (origin.getZ() >> 4) - 1; z <= ((origin.getZ() + 31) >> 4) + 1; z++) { + holdChunk(x, z); + } + } + + void validateTickRegistration() { + for (BlockPos pos : positions) { + TileEntity tile = world.getTileEntity(pos); + if (!(tile instanceof net.minecraft.util.ITickable)) continue; + int references = 0; + for (TileEntity ticking : world.tickableTileEntities) if (ticking == tile) references++; + require(references == 1, "Exactly one native tick registration at " + pos + "; observed " + references); + } + } + + void block(int x, int y, int z, String id) { + ResourceLocation name = new ResourceLocation(id); + if (!ForgeRegistries.BLOCKS.containsKey(name)) throw new IllegalArgumentException("Missing block " + id); + mutation("block", at(x, y, z), id); + world.setBlockState(at(x, y, z), ForgeRegistries.BLOCKS.getValue(name).getDefaultState(), 3); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + void facing(int x, int y, int z, String facing) { + BlockPos pos = at(x, y, z); + mutation("facing", pos, facing); + IBlockState state = world.getBlockState(pos); + for (IProperty property : state.getPropertyKeys()) if (property.getName().equals("facing")) { + com.google.common.base.Optional parsed = property.parseValue(facing); + if (!parsed.isPresent()) throw new IllegalArgumentException("Invalid facing " + facing); + Comparable value = (Comparable) parsed.get(); + world.setBlockState(pos, state.withProperty(property, value), 3); + return; + } + throw new IllegalArgumentException("No facing on " + state); + } + + TileEntity tile(int x, int y, int z) { + BlockPos pos = at(x, y, z); + positions.add(pos.toImmutable()); + TileEntity tile = world.getTileEntity(pos); + if (tile == null) throw new IllegalArgumentException("Missing fixture tile at " + pos); + return tile; + } + + IInventory inventory(int x, int y, int z) { + TileEntity value = tile(x, y, z); + if (!(value instanceof IInventory)) throw new IllegalArgumentException("Not an inventory: " + value); + return (IInventory) value; + } + + void put(int x, int y, int z, int slot, String item, int count) { + ResourceLocation id = new ResourceLocation(item); + if (!ForgeRegistries.ITEMS.containsKey(id) || count < 0 || count > 64) throw new IllegalArgumentException("Invalid stock " + item); + mutation("inventory", at(x, y, z), slot + ":" + item + ":" + count); + IInventory inventory = inventory(x, y, z); + if (slot < 0 || slot >= inventory.getSizeInventory()) throw new IllegalArgumentException("Invalid fixture slot"); + inventory.setInventorySlotContents(slot, count == 0 ? ItemStack.EMPTY : new ItemStack(ForgeRegistries.ITEMS.getValue(id), count)); + inventory.markDirty(); + } + + int count(int x, int y, int z, String item) { + Item expected = ForgeRegistries.ITEMS.getValue(new ResourceLocation(item)); + if (!ForgeRegistries.ITEMS.containsKey(new ResourceLocation(item))) throw new IllegalArgumentException("Missing item " + item); + IInventory inventory = inventory(x, y, z); + int total = 0; + for (int slot = 0; slot < inventory.getSizeInventory(); slot++) { + ItemStack stack = inventory.getStackInSlot(slot); + if (!stack.isEmpty() && stack.getItem() == expected) total += stack.getCount(); + } + record("count/" + x + "/" + y + "/" + z + "/" + item, total); + return total; + } + + void power(int x, int y, int z, String type, float value) { + mutation("energy", at(x, y, z), type + ":" + value); + PoweredEntity tile = (PoweredEntity) tile(x, y, z); + ConduitType conduit = new ConduitType(type); + if (!Float.isFinite(value) || value < 0 || value > tile.getEnergyCapacity(conduit)) throw new IllegalArgumentException("Invalid reserve"); + tile.setEnergy(value, conduit); + tile.markDirty(); + } + + float energy(int x, int y, int z, String type) { + float energy = ((PoweredEntity) tile(x, y, z)).getEnergy(new ConduitType(type)); + if (!Float.isFinite(energy) || energy < 0) throw new AssertionError("Invalid live energy"); + record("energy/" + x + "/" + y + "/" + z + "/" + type, energy); + return energy; + } + + void steamSupply(int x, int y, int z, float reserve) { + block(x, y, z, "steamadvantage:steam_tank"); + power(x, y, z, "steam", reserve); + } + + void electricSupply(int x, int y, int z, float reserve) { + if (!Float.isFinite(reserve) || reserve < 0 || reserve > 8000) throw new IllegalArgumentException("Invalid battery reserve"); + block(x, y, z, "electricadvantage:electric_battery_array"); + put(x, y, z, 0, "electricadvantage:lithium_battery", 1); + NativeStacks.tag(inventory(x, y, z).getStackInSlot(0)).setFloat("energy", reserve); + inventory(x, y, z).markDirty(); + } + + void tank(int x, int y, int z, String fluid, int amount) { + net.minecraftforge.fluids.Fluid registered = NativeFluids.get(fluid); + if (amount < 0) throw new IllegalArgumentException("Invalid fluid reserve"); + mutation("tank", at(x, y, z), fluid + ":" + amount); + net.minecraftforge.fluids.FluidTank tank = ((TileEntitySimpleFluidMachine) tile(x, y, z)).getTank(); + if (amount > tank.getCapacity()) throw new IllegalArgumentException("Tank reserve exceeds capacity"); + tank.setFluid(new net.minecraftforge.fluids.FluidStack(registered, amount)); + tile(x, y, z).markDirty(); + } + + int fluidAmount(int x, int y, int z) { + int amount = ((TileEntitySimpleFluidMachine) tile(x, y, z)).getTank().getFluidAmount(); + record("fluid/" + x + "/" + y + "/" + z, amount); + return amount; + } + + NBTTagCompound nbt(int x, int y, int z) { return tile(x, y, z).writeToNBT(new NBTTagCompound()); } + + void seed(int x, int y, int z, NBTTagCompound patch) { + mutation("nbt", at(x, y, z), patch.toString()); + TileEntity tile = tile(x, y, z); + NBTTagCompound nbt = tile.writeToNBT(new NBTTagCompound()); + nbt.merge(patch.copy()); + tile.readFromNBT(nbt); + tile.markDirty(); + } + + Object invoke(int x, int y, int z, String method, Class[] types, Object... arguments) { + TileEntity tile = tile(x, y, z); + try { return tile.getClass().getMethod(method, types).invoke(tile, arguments); } + catch (InvocationTargetException error) { throw new IllegalStateException("Fixture method failed: " + method, error.getCause()); } + catch (ReflectiveOperationException error) { throw new IllegalStateException("Missing fixture method: " + method, error); } + } + + void require(boolean condition, String message) { + Map row = new LinkedHashMap<>(); + row.put("phase", settingUp ? "setup" : "verify"); + row.put("assertion", message); + row.put("status", condition ? "PASS" : "FAIL"); + checks.add(row); + if (!settingUp) verified++; + if (!condition) throw new AssertionError(message); + } + + void record(String key, Object value) { measurements.put(key, value); } + + JsonArray snapshot() { + JsonArray rows = new JsonArray(); + for (BlockPos pos : positions) { + JsonObject row = new JsonObject(); + row.addProperty("x", pos.getX()); row.addProperty("y", pos.getY()); row.addProperty("z", pos.getZ()); + IBlockState state = world.getBlockState(pos); + row.addProperty("block", state.getBlock().getRegistryName().toString()); + row.addProperty("state", state.toString()); + TileEntity tile = world.getTileEntity(pos); + if (tile != null) row.addProperty("nbt", tile.writeToNBT(new NBTTagCompound()).toString()); + rows.add(row); + } + return rows; + } + + JsonArray entities() { + JsonArray result = new JsonArray(); + net.minecraft.util.math.AxisAlignedBB bounds = new net.minecraft.util.math.AxisAlignedBB(at(0, -1, 0), at(31, 31, 31)).grow(.5); + for (net.minecraft.entity.Entity entity : world.getEntitiesWithinAABB(net.minecraft.entity.Entity.class, bounds)) { + if (entity instanceof net.minecraft.entity.player.EntityPlayer) continue; + NBTTagCompound nbt = new NBTTagCompound(); + if (!entity.writeToNBTOptional(nbt)) throw new IllegalStateException("Test actor could not be saved: " + entity); + JsonObject row = new JsonObject(); + row.addProperty("uuid", entity.getUniqueID().toString()); row.addProperty("nbt", nbt.toString()); + result.add(row); + } + return result; + } +} diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/MachineOperationalProbe.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/MachineOperationalProbe.java new file mode 100644 index 0000000..458c01d --- /dev/null +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/MachineOperationalProbe.java @@ -0,0 +1,520 @@ +package zone.moddev.mc.advantageworks; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import net.minecraft.command.CommandBase; +import net.minecraft.command.ICommandSender; +import net.minecraft.command.CommandException; +import net.minecraft.server.MinecraftServer; +import net.minecraft.entity.Entity; +import net.minecraft.tileentity.TileEntity; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.GameRules; +import net.minecraft.world.WorldServer; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.common.util.FakePlayer; +import net.minecraftforge.common.util.FakePlayerFactory; +import net.minecraftforge.fml.common.event.FMLServerStartingEvent; +import net.minecraftforge.fml.common.gameevent.TickEvent; + +/** Natural world ticks and recorded finite resources; never drives tickUpdate directly. */ +final class MachineOperationalProbe { + private static final File MARKER = new File("advantage-machine-operation.allow"); + private static final File FIXTURE = new File("machine-operation-fixture.json"); + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + private static MachineOperationalProbe instance; + private final List runs = new ArrayList<>(); + private final List held = new ArrayList<>(); + private final NativeEntityHold heldEntities = new NativeEntityHold(); + private WorldServer world; + private boolean paused = true; + private boolean preparing; + private int prepareIndex; + private String label; + private long campaignStart; + private long nextPreparation; + private JsonArray inspectionCases, inspectionResults; + private int inspectionIndex; + private String inspectionLabel; + private InspectionPlan inspectionPending; + + static void register() { + if (!MARKER.isFile()) return; + MachineOperationalProbe probe = new MachineOperationalProbe(); + instance = probe; + MinecraftForge.EVENT_BUS.register(probe); + } + + @net.minecraftforge.fml.common.eventhandler.SubscribeEvent + public void loadedChunk(net.minecraftforge.event.world.ChunkEvent.Load event) { + if (!paused || !FIXTURE.isFile() || !(event.getWorld() instanceof WorldServer)) return; + WorldServer current = (WorldServer)event.getWorld(); + if (current.provider.getDimension() != 0) return; + // Chunk.onLoad adds its tiles before this event, including during startup and world.tick. + held.addAll(current.tickableTileEntities); + current.tickableTileEntities.clear(); + for (Entity entity : new ArrayList<>(current.loadedEntityList)) + if (!(entity instanceof net.minecraft.entity.player.EntityPlayer)) heldEntities.hold(entity); + } + + static void registerCommands(FMLServerStartingEvent event) { + if (instance == null) return; + event.registerServerCommand(new CommandBase() { + @Override public String getName() { return "advmachines"; } + @Override public String getUsage(ICommandSender sender) { return "/advmachines run