From 9a033b6381a794446d3acfbfe1cae008f8ff6487 Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Fri, 30 Oct 2026 20:52:02 +0000 Subject: [PATCH] Fix Minecraft 1.13.2 machine state and strengthen regression tests --- .gitignore | 1 + CHANGELOG.txt | 11 + README.md | 31 + build.gradle | 4 +- docs/1.13/CURRENT-QUALIFICATION.md | 5 + docs/1.13/TEST-COVERAGE-BACKPORT.md | 341 ++++++++ .../poweradvantage/api/PoweredEntity.java | 5 + .../api/simple/SimpleMachineGUI.java | 65 +- .../mc/poweradvantage/blocks/BlockFrame.java | 5 + .../conduitnetwork/ConduitRegistry.java | 28 +- .../gui/FilteredFluidTankGUI.java | 1 + .../mc/poweradvantage/gui/FluidTankGUI.java | 1 + .../conveyors/TileEntityConveyor.java | 10 +- .../conveyors/TileEntityOverflowFilter.java | 16 +- .../FluidDischargeTileEntity.java | 4 +- .../fluidmachines/FluidDrainTileEntity.java | 19 +- .../registry/MachineGUIRegistry.java | 22 +- .../blocks/BlockFrameOpacityTest.java | 21 + .../PowerTransferAccountingTest.java | 52 ++ .../conveyors/ConveyorTransferTest.java | 43 + .../fluidmachines/WorldFluidPickupTest.java | 53 ++ .../registry/MachineGuiTitleTest.java | 44 + .../support/PowerNativeTestBootstrap.java | 35 + test-rig/build.gradle | 4 + test-rig/client-resources/README.md | 46 +- .../client/ClientResourceProbe.java | 13 +- .../client/MachineGuiLayoutProbe.java | 479 +++++++++++ .../resources/gui-label-expectations.json | 46 + test-rig/client-resources/run.ps1 | 189 ++++- .../test-gui-contract-negative-controls.ps1 | 34 + .../client-resources/test-gui-contract.ps1 | 193 +++++ test-rig/client-resources/test-launcher.ps1 | 149 ++++ .../test-probe-contract-negative-controls.ps1 | 30 + .../client-resources/test-probe-contract.ps1 | 48 ++ test-rig/migration/BROAD-RESULT-AUDIT.md | 59 ++ .../migration/EXTENDED-ABSENCE-CORRECTION.md | 89 ++ test-rig/migration/audit_extended_absence.py | 202 +++++ test-rig/migration/broad_campaign_status.py | 68 ++ test-rig/migration/broad_result_audit.py | 386 +++++++++ test-rig/migration/extended_lifecycle.py | 55 +- test-rig/migration/native113.py | 72 ++ test-rig/migration/native113_broad.py | 342 ++++++++ .../migration/native113_campaign_support.py | 51 ++ .../migration/native113_chunk_readiness.py | 38 + test-rig/migration/native113_code.py | 50 ++ test-rig/migration/native113_connected.py | 270 ++++++ test-rig/migration/native113_diagnostics.py | 48 ++ test-rig/migration/native113_endurance.py | 272 ++++++ test-rig/migration/native113_harvest.py | 157 ++++ test-rig/migration/native113_legacy_snbt.py | 66 ++ test-rig/migration/native113_lifecycle.py | 41 + .../migration/native113_machine_operations.py | 258 ++++++ test-rig/migration/native113_oil.py | 367 ++++++++ test-rig/migration/native113_recipes.py | 322 +++++++ .../migration/native113_redstone_reload.py | 381 +++++++++ test-rig/migration/native113_refresh.py | 136 +++ test-rig/migration/native113_replay.py | 119 +++ .../migration/native113_resource_audit.py | 280 ++++++ .../migration/native113_sealed_transport.py | 32 + test-rig/migration/semantic_machine_audit.py | 305 +++++++ test-rig/migration/steam_control_audit.py | 318 +++++++ .../migration/test_broad_campaign_status.py | 83 ++ test-rig/migration/test_broad_result_audit.py | 377 +++++++++ .../migration/test_extended_absence_audit.py | 48 ++ .../test_extended_absence_continuity.py | 128 +++ .../test_native113_chunk_readiness.py | 19 + test-rig/migration/test_native113_code.py | 63 ++ .../migration/test_native113_diagnostics.py | 43 + test-rig/migration/test_native113_harvest.py | 142 ++++ .../migration/test_native113_legacy_snbt.py | 32 + .../test_native113_machine_operations.py | 78 ++ test-rig/migration/test_native113_oil.py | 89 ++ .../test_native113_redstone_reload.py | 189 +++++ .../test_native113_resource_audit.py | 296 +++++++ .../migration/test_semantic_machine_audit.py | 154 ++++ .../migration/test_steam_control_audit.py | 412 +++++++++ .../mc/advantageworks/AdvantageWorksMod.java | 4 + .../mc/advantageworks/AssertionChecks.java | 62 ++ .../BatteryOperationalCases.java | 36 + .../mc/advantageworks/BayDrillAssertions.java | 40 + .../advantageworks/ChunkReadinessProbe.java | 61 ++ .../ElectricOperationalCases.java | 796 ++++++++++++++++++ .../ElectricOperationalRates.java | 97 +++ .../mc/advantageworks/MachineContext.java | 268 ++++++ .../MachineOperationalProbe.java | 348 ++++++++ .../mc/advantageworks/MachineScenario.java | 25 + .../mc/advantageworks/NativeEntityHold.java | 41 + .../mc/advantageworks/NativeFluidPort.java | 26 + .../mc/advantageworks/NativeHarvestProbe.java | 233 +++++ .../advantageworks/PowerOperationalCases.java | 220 +++++ .../advantageworks/RedstoneReloadProbe.java | 350 ++++++++ .../SourcePickupOperationalCases.java | 54 ++ .../SteamOperationalActors.java | 214 +++++ .../advantageworks/SteamOperationalCases.java | 354 ++++++++ .../SteamOperationalSupplementalCases.java | 260 ++++++ .../SteamReloadControlCases.java | 139 +++ .../mc/advantageworks/WorksController.java | 72 +- .../advantageworks/WorksDefinitionLoader.java | 15 + .../mc/advantageworks/runner/WorksRunner.java | 94 ++- .../advantageworks/AssertionChecksTest.java | 59 ++ .../BayDrillAssertionsTest.java | 58 ++ .../ElectricOperationalCasesTest.java | 45 + .../ElectricOperationalRatesTest.java | 100 +++ .../MeasurementDefinitionTest.java | 34 + .../NativeHarvestIsolationTest.java | 149 ++++ .../SteamOperationalSourceContractTest.java | 92 ++ .../SteamReloadControlHoldTest.java | 57 ++ .../WorksNumericAssertionTest.java | 23 + .../runner/StationResultValidationTest.java | 107 +++ 109 files changed, 13388 insertions(+), 130 deletions(-) create mode 100644 docs/1.13/TEST-COVERAGE-BACKPORT.md create mode 100644 src/test/java/zone/moddev/mc/poweradvantage/blocks/BlockFrameOpacityTest.java create mode 100644 src/test/java/zone/moddev/mc/poweradvantage/conduitnetwork/PowerTransferAccountingTest.java create mode 100644 src/test/java/zone/moddev/mc/poweradvantage/machines/conveyors/ConveyorTransferTest.java create mode 100644 src/test/java/zone/moddev/mc/poweradvantage/machines/fluidmachines/WorldFluidPickupTest.java create mode 100644 src/test/java/zone/moddev/mc/poweradvantage/registry/MachineGuiTitleTest.java create mode 100644 src/test/java/zone/moddev/mc/poweradvantage/support/PowerNativeTestBootstrap.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/test-gui-contract-negative-controls.ps1 create mode 100644 test-rig/client-resources/test-gui-contract.ps1 create mode 100644 test-rig/client-resources/test-launcher.ps1 create mode 100644 test-rig/client-resources/test-probe-contract-negative-controls.ps1 create mode 100644 test-rig/client-resources/test-probe-contract.ps1 create mode 100644 test-rig/migration/BROAD-RESULT-AUDIT.md create mode 100644 test-rig/migration/EXTENDED-ABSENCE-CORRECTION.md create mode 100644 test-rig/migration/audit_extended_absence.py create mode 100644 test-rig/migration/broad_campaign_status.py create mode 100644 test-rig/migration/broad_result_audit.py create mode 100644 test-rig/migration/native113.py create mode 100644 test-rig/migration/native113_broad.py create mode 100644 test-rig/migration/native113_campaign_support.py create mode 100644 test-rig/migration/native113_chunk_readiness.py create mode 100644 test-rig/migration/native113_code.py create mode 100644 test-rig/migration/native113_connected.py create mode 100644 test-rig/migration/native113_diagnostics.py create mode 100644 test-rig/migration/native113_endurance.py create mode 100644 test-rig/migration/native113_harvest.py create mode 100644 test-rig/migration/native113_legacy_snbt.py create mode 100644 test-rig/migration/native113_lifecycle.py create mode 100644 test-rig/migration/native113_machine_operations.py create mode 100644 test-rig/migration/native113_oil.py create mode 100644 test-rig/migration/native113_recipes.py create mode 100644 test-rig/migration/native113_redstone_reload.py create mode 100644 test-rig/migration/native113_refresh.py create mode 100644 test-rig/migration/native113_replay.py create mode 100644 test-rig/migration/native113_resource_audit.py create mode 100644 test-rig/migration/native113_sealed_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_broad_campaign_status.py create mode 100644 test-rig/migration/test_broad_result_audit.py create mode 100644 test-rig/migration/test_extended_absence_audit.py create mode 100644 test-rig/migration/test_extended_absence_continuity.py create mode 100644 test-rig/migration/test_native113_chunk_readiness.py create mode 100644 test-rig/migration/test_native113_code.py create mode 100644 test-rig/migration/test_native113_diagnostics.py create mode 100644 test-rig/migration/test_native113_harvest.py create mode 100644 test-rig/migration/test_native113_legacy_snbt.py create mode 100644 test-rig/migration/test_native113_machine_operations.py create mode 100644 test-rig/migration/test_native113_oil.py create mode 100644 test-rig/migration/test_native113_redstone_reload.py create mode 100644 test-rig/migration/test_native113_resource_audit.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/src/main/java/zone/moddev/mc/advantageworks/AssertionChecks.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/BayDrillAssertions.java create mode 100644 test-rig/src/main/java/zone/moddev/mc/advantageworks/ChunkReadinessProbe.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/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/NativeHarvestProbe.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/RedstoneReloadProbe.java create mode 100644 test-rig/src/main/java/zone/moddev/mc/advantageworks/SourcePickupOperationalCases.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/test/java/zone/moddev/mc/advantageworks/AssertionChecksTest.java create mode 100644 test-rig/src/test/java/zone/moddev/mc/advantageworks/BayDrillAssertionsTest.java create mode 100644 test-rig/src/test/java/zone/moddev/mc/advantageworks/ElectricOperationalCasesTest.java create mode 100644 test-rig/src/test/java/zone/moddev/mc/advantageworks/ElectricOperationalRatesTest.java create mode 100644 test-rig/src/test/java/zone/moddev/mc/advantageworks/MeasurementDefinitionTest.java create mode 100644 test-rig/src/test/java/zone/moddev/mc/advantageworks/NativeHarvestIsolationTest.java create mode 100644 test-rig/src/test/java/zone/moddev/mc/advantageworks/SteamOperationalSourceContractTest.java create mode 100644 test-rig/src/test/java/zone/moddev/mc/advantageworks/SteamReloadControlHoldTest.java create mode 100644 test-rig/src/test/java/zone/moddev/mc/advantageworks/WorksNumericAssertionTest.java create mode 100644 test-rig/src/test/java/zone/moddev/mc/advantageworks/runner/StationResultValidationTest.java diff --git a/.gitignore b/.gitignore index f5eaedc..1a2d9b2 100644 --- a/.gitignore +++ b/.gitignore @@ -59,6 +59,7 @@ /test-rig/.gradle/ /test-rig/.cache/ /test-rig/build/ +/test-rig/logs/ /test-rig/migration-probe/.gradle/ /test-rig/migration-probe/build/ /test-rig/migration/.venv/ diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 256e545..a314f26 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,14 @@ +Unreleased test-coverage repairs for 1.13.2 + +- Keep steel frames non-opaque on dedicated servers as well as clients. + +- Keep machine timers, stored power and progress in the next world save, even when no inventory changes. +- Charge energy sources only for power that another machine actually accepts. +- Preserve item NBT and check every destination slot when moving conveyor items. +- Drain waterlogged blocks without destroying their host, and do not discharge fluid through solid blocks. +- Restore machine names, readable inventory layouts and bounded fluid gauges. +- Add measured operating jobs, negative controls and exact saved-state restart checks. Keep visual acceptance separate from automated checks. + Power Advantage 2.4.4.113021 - Exclude empty directories so development jars match between existing workspaces and fresh CI checkouts. diff --git a/README.md b/README.md index a59588c..54ea130 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,9 @@ The [client resource probe](test-rig/client-resources/README.md) checks native b ## Compatibility +The [stronger regression-test backport](docs/1.13/TEST-COVERAGE-BACKPORT.md) +records the local repairs, tested jar hashes and remaining manual checks. + The implementation namespace is `zone.moddev.mc.poweradvantage`. The historical `cyano.poweradvantage.api` surface, including `FluidNetworkApi`, remains available to dependent mods. Registry names, fluids, tile IDs, NBT keys, OreSpawn provider IDs, and configuration meaning remain stable. PowerAdvantage owns the shared pre-flattening migration hook used by all three Advantage mods. See [the 1.13 migration guide](docs/1.13/MIGRATION.md) before opening an irreplaceable 1.10 or 1.12 world. The [Advantage Works rig](test-rig/README.md) provides repeatable machine, restart, world-generation, and migration checks. @@ -73,3 +76,31 @@ normal chunk loading reconnects them without replacing pipes. The same protocol records acquisition-stack diagnostics and pipe-only bridge regressions. The historical 1.10 handbook remains on the [1.10.2 branch](https://github.com/MinecraftModDevelopmentMods/PowerAdvantage/blob/master-1.10.2/docs/1.10/README.md). + +## Generator Follow-Up + +The [generator follow-up](docs/1.13/TEST-COVERAGE-BACKPORT.md#generator-follow-up) records the older `pa113-candidate-g` inputs. +Its 126 operating jobs and 126 cold checks, 39 GUI windows, 351 caption cases, +355 rejected broken controls, eight frozen routes, five connected routes and +paired 30-minute runs passed. This is a pre-final Steam-control checkpoint, +not qualification of the newer SteamAdvantage jars or harness. Those still need +the final sweep. Manual play-testing and release review remain separate. + +## Final T Candidate + +The [final-t checkpoint](docs/1.13/TEST-COVERAGE-BACKPORT.md#final-t-candidate) +records profile `pa113-candidate-i` and its exact jar hashes. All 132 operating +jobs and 132 cold-restart comparisons pass, including an independent audit of +six Steam controls and their nine tile-hold records. GUI checks pass for 39 +windows, 351 caption cases and 355 rejected broken controls. +Frozen migration passed on 8 routes and connected-source operation passed on +all five declared routes. +Fresh and migrated-copy 30-minute runs pass. Power and Electric retain their +exact-hash release and reproducibility proof; final-t Steam passes its full suite +and two independent export builds. All 18 mod/version release and reproducibility +gates pass across the six maintained Minecraft lines. Both requested broad +profiles and their independent audit pass; the reporter-only unit/replay +qualification preserves the original native proof. Survival harvest checks pass +before and after restart. The report's extra network/drill scopes are now +qualified; manual acceptance remains separate. Earlier checkpoints keep their original scope; +nothing has been published by this local test work. diff --git a/build.gradle b/build.gradle index 3c74089..9089ac9 100644 --- a/build.gradle +++ b/build.gradle @@ -1144,7 +1144,9 @@ tasks.register('prepareCombinedEclipseLaunch') { description = 'Creates an additional production-only Eclipse client launch with all three Advantage mods.' dependsOn tasks.named('verifyEclipseProductionClasspath') doLast { - def profile = new JsonSlurper().parse(file('test-rig/profiles/dev-core.json')) + File inputProfile = file(providers.gradleProperty('advantageDevelopmentProfile').getOrElse( + 'test-rig/profiles/dev-core.json')) + def profile = new JsonSlurper().parse(inputProfile) advantageExtensionJars.each { String id, File jar -> def pin = profile.mods.find { it.id == id } if (!jar.isFile()) throw new GradleException("Build ${id} first, or override its *AdvantageDeobfJar property: ${jar}") diff --git a/docs/1.13/CURRENT-QUALIFICATION.md b/docs/1.13/CURRENT-QUALIFICATION.md index db655b3..ca5d969 100644 --- a/docs/1.13/CURRENT-QUALIFICATION.md +++ b/docs/1.13/CURRENT-QUALIFICATION.md @@ -1,5 +1,10 @@ # Current Candidate Qualification and Remaining Tests +> The [stronger local regression candidate](TEST-COVERAGE-BACKPORT.md) has its +> own jar hashes, measured results and limits. The archived results below apply +> to the previous jars, not the modified sources. Do not combine their coverage +> into a claim that every scenario has been rerun on the new candidate. + ## Scope This is the 23 October 2026 rerun against the current, unpublished 1.13.2 jars. diff --git a/docs/1.13/TEST-COVERAGE-BACKPORT.md b/docs/1.13/TEST-COVERAGE-BACKPORT.md new file mode 100644 index 0000000..afd8234 --- /dev/null +++ b/docs/1.13/TEST-COVERAGE-BACKPORT.md @@ -0,0 +1,341 @@ +# Stronger 1.13.2 Regression Tests + +This local, unpublished candidate backports the stricter 1.16 operating and GUI +checks. Earlier acceptance applies to the archived jars, not these repairs. +Versions, registry names and saved-world identities are unchanged. + +## What Changed + +The tests use finite inputs, completed output, refused transfers, negative +controls and exact state after a full process restart. An animation, a built +bay or an open window is not counted as completed work. + +The checks found faults in accepted-energy accounting, conveyor item data and +blocked slots, fluid conservation, growth demand, light sampling, turbine +anchoring and targeting. They also caught unsaved machine timers and energy, +and a steel-frame solidity difference on dedicated servers. Machine windows +retain their names and required captions, with bounded gauges. + +Base Metals' leaves data-fixer transformer also rejected an already-correct +Forge method. A separate local repair accepts that exact valid method, retains +the older repair path and still rejects unknown structures. Its original +failing tests remain available. This dependency repair is not published. + +## Tested Jars + +| Jar | SHA-256 | +| --- | --- | +| PowerAdvantage-2.4.4.113021.jar | `8B02FF2B4477F7694A871F6BCA198DA1F852407C6ABB5024DB71C2291E88B977` | +| SteamAdvantage-2.2.5.113021.jar | `5344B4B898C29BDC3B7E1D3E00AD6ED6F00DEA732693B7D6D67660B9E4529EBE` | +| ElectricAdvantage-2.2.5.113021.jar | `FE2FA41F38EA7FDFCEFA4A9FE0C8E2956D22AC483B9E9078370456B003BC5498` | + +Migration and later runtime profiles use the local repaired Base Metals jar, +SHA-256 `C466B1F966399599EBE5AF47DFB97898DB98541C782650B01A9C5D6E560C0547`. +Compile-only API pins remain unchanged. Each receipt records its exact harness +and dependency hashes; the operating, readiness and bounded drill-observation +extensions are not presented as one identical harness binary. + +## Completed Checks + +- Full build, release, dependency, Java 8 bytecode, archive and Eclipse audits. +- 277 Python protocol tests, including deliberately broken measurements and oil-transfer safety controls. +- 126 natural-tick operating jobs and 126 exact-state restart comparisons. +- 271 survival-harvest cases on each of two boots. +- Eight frozen direct/chained migration routes and five connected source profiles, each running two 2,048-tick intervals across restart without rebuilding imported machines. +- Thirty-minute repeated-batch runs in fresh and migrated-copy worlds, followed by restart and resumed operation. These commission Works bays separately from the imported-machine tests. +- Real chunk unloading, partial networks, eighteen extended drills, repeated moves, scarcity, blocked output and redstone interruption. +- Three pipe-only bridge interruption/recovery cycles. +- All 21 walkable bays in packaged-current and decoupled profiles; remaining manual checks are not relabelled as automated passes. +- Five recipe-unlock boots and eighteen manufacturing checks across the three recipe modes. +- Nine redstone witnesses and four native oil bucket/drain/descending-pipe/tank lanes through two complete restarts. +- Native GUI captures: 39 windows, 351 required-caption cases and 355 rejected broken controls. +- Two independent source exports per mod in paths containing spaces; all twelve artifact pairs match. This used the existing pinned cache, not a new cold-cache qualification. + +The old all-bay drill finding was an assertion fixed to the original position. +The replacement checks exactly one drill at either the original position or the +declared adjacent track destination, including facing. Missing, duplicate or +arbitrarily relocated drills still fail. No production drill behaviour was +changed to satisfy that check. + +## Evidence And Limits + +Receipts are retained under +`D:/PowerAdvantage/_verification/backport-coverage-20261030/` and +`test-rig/build/coverage-20261030-candidate-{e,f}-*`. +Oil evidence is `test-rig/build/native113-oil-coverage-f`. +GUI evidence is `build/client-resource-runs/20261030-132855-8450c657`; +it uses the same three Advantage production builds but the earlier Base Metals +input. The dependency transformer repair is proved by separate server runs. +Old failures and superseded candidates are retained, not overwritten. + +These are bounded scenarios, not proof of every network or third-party mod. +No new player/entity/dimension matrix, world-generation, optional-power, +Mineralogy-oil or cold-cache result is claimed by this pass. Historical results +in the other reports remain historical until rerun on these exact jars. +Worldless GUI checks do not prove in-world animation or multiplayer behaviour. +Manual acceptance of changed windows and release review remain separate. +Nothing has been committed, pushed or published as part of this backport. + +## Generator Follow-Up + +This is a pre-final Steam-control checkpoint. It covers only the exact profile +and hashes below, before the later Steam first-tick and per-case freeze repairs. +It does not qualify newer SteamAdvantage jars or harness builds, even when the +version and filename are unchanged. Those candidates still need the final sweep. + +This 30 October checkpoint covers profile `pa113-candidate-g`. The generator now +reads redstone inhibition before its first power update, preventing one unwanted +startup pulse. Generation rates are unchanged. Power and Steam retain their +earlier hashes; Electric and the harness have new hashes. The earlier results +above remain attached to their original inputs, not automatically to this set. + +The input manifest is +`D:/PowerAdvantage/_verification/backport-coverage-20261030/pa113-candidate-g/inputs.json`, +SHA-256 `4AFC26B766670745B3C9E503B184F704E6A5227D6D102D5FD244163911892AC4`. + +| Follow-up input | SHA-256 | +| --- | --- | +| PowerAdvantage-2.4.4.113021.jar | `8B02FF2B4477F7694A871F6BCA198DA1F852407C6ABB5024DB71C2291E88B977` | +| SteamAdvantage-2.2.5.113021.jar | `5344B4B898C29BDC3B7E1D3E00AD6ED6F00DEA732693B7D6D67660B9E4529EBE` | +| ElectricAdvantage-2.2.5.113021.jar | `E7599456DE4AC44D52F3F4A43CDA01768C7B136DFE5C3CAEC1634F2FEF769EEA` | +| AdvantageWorksTestHarness-1.1.0.113021.jar | `A5B1AD1F3BC73E8431DC16757ED310946631B475AA761507847EB76E1AC19D1C` | +| BaseMetals-3.0.1.113021.jar | `C466B1F966399599EBE5AF47DFB97898DB98541C782650B01A9C5D6E560C0547` | +| OreSpawn-4.1.0.113021.jar | `66C9CC5F8BE8F08F3DD5FA26EBEA79B52972B2FB86CB4DADBEEBC29AC01D3435` | + +Base Metals is still the local, unpublished transformer repair described above. + +| Check | Follow-up result and scope | +| --- | --- | +| Electric release suite | PASS: build, unit, release, dependency, bytecode, archive and Eclipse checks. | +| Operating jobs | PASS: all 126 natural-tick jobs and all 126 exact cold-restart comparisons. | +| Solar startup control | PASS within those 126 jobs: lit and dark controls plus a lit, redstone-inhibited panel with zero energy from its first pulse. | +| GUI captures | PASS: 39 windows, 351 required-caption cases and 355 rejected broken controls. | +| Frozen migration | PASS: eight routes, including conversion and restart state/resource comparisons. Frozen state is not proof of operating imported networks. | +| Connected source machinery | PASS confirmed: all 5/5 routes on profile g. This covers the declared connected fixtures, not every player-built network. | +| Paired endurance | PASS: fresh and migrated-copy worlds, each running 1,800 seconds, 30 batches and 33 verified checkpoints, then full restart and resumed processing. | + +Cold comparisons include block states and typed NBT, including unexpected extra +keys. Only the declared zero-volume tank representation is canonicalized; the +tests do not write expected state to make a comparison pass. + +Endurance covers commissioned Works bays in `fresh113` and +`original110-chained113-native113`, not untouched imported machinery or every +machine. Outstanding station checks remain `MANUAL_REQUIRED`. + +Packaged-server receipts are under `test-rig/build/`: + +- `coverage-20261030-candidate-g-126/native113-machine-operation-results.json` +- `coverage-20261030-candidate-g-frozen/native113-refresh-results.json` +- `coverage-20261030-candidate-g-connected-current-2/native113-refresh-results.json` +- `coverage-20261030-candidate-g-endurance/native113-endurance-results.json` + +The Electric release log is +`D:/PowerAdvantage/_verification/backport-coverage-20261030/pa113-ElectricAdvantage-generator-release-f.log`. +GUI evidence is under `run-client-resource-runs/20261030-154705-e6a26076`. +Its `client-input.json` SHA-256 is +`26A95AC765863D9A4BB0B70BE442BA363C94974D812A2C872FFDB6602884E819`; +its `production-files.json` SHA-256 is +`4F77B90E7ADF69A4ED63F350BF43CE699A74A10C59DE1DC800CFC3DCB7590291`. +The GUI run uses separately hashed development production classes/resources and +deobf dependencies, not the packaged release jars as its runtime classpath. + +Only the connected routes listed here were rerun on these inputs. Older oil, +world-generation, optional-power, reproducibility and cold-cache results are not +carried forward as new proof for the changed Electric jar. The newer Steam-control +candidate remains pending its own final sweep. Manual play-testing and release +review remain separate; these local results do not imply a push, merge or publication. + +## Final T Candidate + +This separate 30 October checkpoint covers the final Steam-control candidate +for Minecraft 1.13.2, profile `pa113-candidate-i`. Power and Electric have +exactly the same release hashes as the generator follow-up; Steam and the +harness have new hashes. Earlier receipts keep their original inputs and scope. +Passing a later candidate does not turn an earlier failed run into a pass. + +The campaign pin file is +`D:/PowerAdvantage/_verification/backport-coverage-20261030/final-production-pins-t.json`, +SHA-256 `48CF5EE9B6A4FC4E285DB26B977F945D3673EF6278E3188348C6A74880E50346`. +The packaged-server input manifest is +`D:/PowerAdvantage/_verification/backport-coverage-20261030/pa113-candidate-i/inputs.json`, +SHA-256 `28E9D319A435A22EC0A72B33E30A9B742999E29770A396E4F59C6F1972AC9158`. +These identify the actual jar bytes, not just filenames, versions or a source +commit that may predate local changes. + +| Final-t input | SHA-256 | +| --- | --- | +| PowerAdvantage-2.4.4.113021.jar | `8B02FF2B4477F7694A871F6BCA198DA1F852407C6ABB5024DB71C2291E88B977` | +| SteamAdvantage-2.2.5.113021.jar | `390B112711565106999A30830A008E9E9E1B3457F2646B5B10673BA6A6922E67` | +| ElectricAdvantage-2.2.5.113021.jar | `E7599456DE4AC44D52F3F4A43CDA01768C7B136DFE5C3CAEC1634F2FEF769EEA` | +| AdvantageWorksTestHarness-1.1.0.113021.jar | `B46CE658B5E6226BE3B1E17299AD0BAFF74ABCCDA79A448DE62321FD7F1371FC` | +| BaseMetals-3.0.1.113021.jar | `C466B1F966399599EBE5AF47DFB97898DB98541C782650B01A9C5D6E560C0547` | +| OreSpawn-4.1.0.113021.jar | `66C9CC5F8BE8F08F3DD5FA26EBEA79B52972B2FB86CB4DADBEEBC29AC01D3435` | + +Base Metals remains the local, unpublished transformer repair recorded above. + +| Check | Final-t result and scope | +| --- | --- | +| Release gates | PASS for all three mods. The campaign's 18 mod/version release gates pass across 1.10, 1.12, 1.13, 1.14, 1.15 and 1.16; this checkpoint uses the existing exact-hash Power/Electric results and the new Steam full suite. | +| Operating jobs | PASS: 132 finite jobs on normal server ticks, followed by 132 exact cold-process comparisons. | +| Independent Steam-control audit | PASS: all six control cases, all nine explicit tile-hold records and recomputed full operation/cold checks. | +| GUI checks | PASS: 39 windows, 351 required-caption cases and 355 rejected broken controls. | +| Frozen migration | PASS: 8/8 declared routes, with all route receipts passing against this manifest. Frozen preservation is not operating-network proof. | +| Connected source machinery | PASS: 5/5 declared connected-source routes; imported machines operate and survive a full restart without rebuilding. | +| Endurance | PASS: fresh and migrated-copy profiles, each 1,800 seconds, 30 batches and 33 verified checkpoints, followed by full restart and resumed processing. | +| Reproducibility | PASS: Power/Electric retain their previously proved exact bytes; two independent source exports of final-t Steam match for all four artifacts. | +| Broad campaign | PASS: both requested profiles, `packaged-current` and `packaged-decoupled`, complete before and after restart and pass the independent audit. The separate writer-only qualification also passes. | +| Survival harvesting | PASS: 271 native checks before restart and the same 271 after restart, including failed tools, exact drop quantities, silk touch and solar solidity. | + +The independent control audit checks the crusher comparator matrix, first-loaded +crusher with no steam, signalled and unpowered crushers, and signalled and +unpowered furnaces. The comparator case holds four tiles; each other case holds +one. All six cases must record `heldAtMeasuredEnd` and tile identity, removal +and unchanged-NBT evidence. Raw quantities and work are checked against typed +state, not accepted merely because a case reports `PASS`. Holding a measured +tile stops further native ticking; it does not rewrite its state. + +Cold comparisons include all block states and typed NBT, including unexpected +extra keys. Only the declared empty named-tank representation is canonicalized. +There are no expected-state writes. The independent audit also binds its four +raw input files by SHA-256. Its auditor hash is +`390460F58C0A907F41EEA878F6DAB64DC2F901DE4385712E2A4CC5F42105AA12`. + +Packaged-server receipts are under `test-rig/build/`: + +- `coverage-20261030-candidate-i-final-all/native113-machine-operation-results.json` +- `coverage-20261030-candidate-i-final-frozen/native113-refresh-results.json` +- `coverage-20261030-candidate-i-final-connected/native113-refresh-results.json` +- `coverage-20261030-candidate-i-final-endurance/native113-endurance-results.json` + +The independent control receipt is +`D:/PowerAdvantage/_verification/backport-coverage-20261030/pa113-candidate-i-final-steam-controls-audit.json`. + +Endurance covers commissioned Works bays in `fresh113` and +`original110-chained113-native113`. Across 1.13, 1.14 and 1.15, all six +fresh/migrated-copy profiles pass. This is not a claim about every machine or +untouched imported network; station checks marked `MANUAL_REQUIRED` remain +manual. + +Release logs are under +`D:/PowerAdvantage/_verification/backport-coverage-20261030/`: + +- `pa113-PowerAdvantage-repaired-release-c.log` +- `pa113-SteamAdvantage-final-owner-callbacks-t.log` +- `pa113-ElectricAdvantage-generator-release-f.log` + +Each full suite includes `clean check build javadoc verifyReleaseDependencies +verifyReleaseArtifacts writeReleaseChecksums verifyEclipseProductionClasspath`. +Reproducibility receipts are `qualification.json` in these sibling directories +under the same verification root: + +- `PA113 reproducibility a with spaces` (unchanged Power) +- `PA113 reproducibility final-electric with spaces` (unchanged Electric) +- `PA113 reproducibility final-steam-t with spaces` (new Steam) + +The release, deobf, sources and Javadoc artifact pairs match in each applicable +record. These use independent clean source exports and paths containing spaces, +with the existing pinned dependency cache. They are not fresh-cache proof. + +Final GUI evidence is under +`run-client-resource-runs/20261030-174831-1918e625`. +Its `client-input.json` SHA-256 is +`8C864FDC8169285F3B084862928037E9762CD4C13A226F6230D9FB6912D2830D`; +its `production-files.json` SHA-256 is +`96724253F62EA1A4F5BA0968EAEE1816501AEE192524341686E94B9715973409`. +These are separately hashed development production classes/resources and deobf +dependencies, not the reobfuscated server jars on a client classpath. Passing +these captures does not replace in-world visual or multiplayer acceptance. + +Checks outside the declared fixtures and manual acceptance remain separate. +The final extra lifecycle/network/drill scopes below are now qualified. +Earlier oil, world-generation, optional-power and +cold-cache proof is not relabelled for the new Steam/harness inputs. All failed +and partial receipts are retained. This documentation update changes no frozen +code or test protocol, and does not imply a commit, push, merge or publication. + +### Final Broad And Harvest Follow-Up + +The four final broad campaigns for 1.13, 1.14, 1.15 and 1.16 are now complete. +Each has two requested profiles, not four profiles per version. Both profiles +cover 21 stations before and after restart. On each boot, `packaged-current` +records 96 passing assertions and three skips; `packaged-decoupled` records +95 passing assertions and four skips. Skips remain skips, and all 21 station +manual procedures remain `MANUAL_REQUIRED`. + +The original broad reporter could mark a completed prefix as `PASS` while the +other profile was missing. The independent audit requires the complete requested +set, checks the sealed inputs and raw station evidence, and verifies save and +all-dimension shutdown records. Its historical shutdown evidence does not +independently record the process exit code or an explicit save command. +This is bounded broad coverage, not a replacement for the 132 operating jobs, +exact cold comparisons, endurance runs or manual play-testing. + +The later reporter-only change passes unit and replay checks: an empty or +incomplete run stays `RUNNING`, a second-profile exception yields `FAIL`, and +the complete run yields `PASS`. Across four versions, 211 controls pass and +one 1.13-only control is not applicable. No native world was rebuilt or rerun +for that reporter repair. Only reporter status/source metadata changes in the +replay; original native receipts and their original protocol hashes are retained. + +The summary is +`D:/PowerAdvantage/_verification/backport-coverage-20261030/broad-writer-applied-summary.json`, +SHA-256 `2395A4B113E57ED0F5914FEB784B5AF32DDD3D6EFD09D48984BDE405AC2904BE`. +The line-specific receipts, relative to that verification directory unless +noted, are: + +| Evidence | Path | SHA-256 | +| --- | --- | --- | +| Original native broad result | `test-rig/build/coverage-20261030-candidate-i-final-broad/native113-broad-results.json` (this repository) | `E77AB1B99889B46A27E9075282482E60F436D4AC62E5A21D1169DFCD6706DD5C` | +| Independent native audit | `broad-result-audit-113-final13i.json` | `B590F53B4B02078E157CCD4AFA504255E66ABF3DF69B26DDE4DD190BF51166EC` | +| Applied reporter replay | `broad-writer-113-applied-replay.json` | `F2D64202D2A0F5309F7396A7B9CEAA5B399377BF9B5E6B5E6190BB08E5D34CB7` | +| Reporter unit controls | `broad-all-113-controls-applied.log` | `0B23DE3FCAD9697CCF520E4F0FF5D6BF54598DB7CC197CBC4038BCA5D992DB81` | + +The broad runner changed from +`493864A0A752CAD617B56B616369E3050B248FCC8A3968694C6D1567FD94C983` to +`809F823E2D1022D164DCA56D85F6F09D3FCE071391FC1B2CEFC0894FA0A1D747` +for reporting only. The archived native proof still names the first hash; +it is not relabelled as a run with the new reporter. + +The final harvest receipt is +`test-rig/build/coverage-20261030-candidate-i-final-harvest/native113-harvest-results.json`. +Its target manifest matches the final-t manifest above. Final GUI, harvest and +endurance profiles pass across all six maintained Minecraft lines. The legacy +1.10/1.12 endurance jobs are bounded natural-tick/restart checks, not the paired +30-minute world runs used here. All 18 mod/version release and exact-artifact +reproducibility gates pass; the build scopes and warm-cache limitation above +still apply. Larger migration matrices, unrelated modpacks, third-party power +integrations and manual procedures are not newly qualified by this follow-up. + +### Final Network And Drill Follow-Up + +The following native receipts are `PASS` on the final-t inputs above. Paths +are relative to this repository's `test-rig/build/`; these qualify the declared +fresh/migrated fixture scopes, not every player-built network. + +| Scope | Native receipt | +| --- | --- | +| Lifecycle | `coverage-20261030-candidate-i-final-lifecycle/lifecycle-results.json` | +| Extended drills and partial networks | `coverage-20261030-candidate-i-final-extended/extended-results.json` | +| Pipe interruptions | `coverage-20261030-candidate-i-final-pipe/pipe-only-results.json` | + +The pure-validator continuity repair requires at least 40 continuous absent +native ticks with stable load/unload event counters. Observed or hidden reloads +still fail; disjoint absence intervals are not added together. Its 164 focused +controls and independent raw re-audits pass: 24 partial cases per version, +96 across 1.13-1.16. Those re-audits are offline qualification, not new native +runs; the original 1.13, 1.15 and 1.16 extended receipts remain their original +`PASS` results. No Java, production logic, fixtures or native transports changed. + +Repair evidence is under +`D:/PowerAdvantage/_verification/backport-coverage-20261030/extended-absence-repair/`. +`completed-summary.json` SHA-256 is +`B0CE16C439D0628F0D3141208BA6E07352CD4F62AEF8105049C6873634CF3E89`; +this version's `raw-audit-113-final.json` SHA-256 is +`89445966104837E4DAC7C0C42F1504B477945E33C36AB1882BEEDB11334B8611`. + +The final Python results are +`D:/PowerAdvantage/_verification/backport-coverage-20261030/final-python-units-w.json`, +SHA-256 `AFA8185EF3826E892F4B3F3AE1091698D77B917B1D543F1302D2D0D81213456A`. +All six lines pass: 3,274 tests in total, including one documented not-applicable +skip (3,273 passing tests). These offline tests do not replace native machine +or manual checks. Existing manual, modpack and migration-scope gaps remain open. diff --git a/src/main/java/cyano/poweradvantage/api/PoweredEntity.java b/src/main/java/cyano/poweradvantage/api/PoweredEntity.java index e6531eb..097b007 100644 --- a/src/main/java/cyano/poweradvantage/api/PoweredEntity.java +++ b/src/main/java/cyano/poweradvantage/api/PoweredEntity.java @@ -178,6 +178,11 @@ public final void tick() { if (this.isServer() && ((this.getWorld().getGameTime() + this.getTickOffset()) % powerUpdateInterval == 0)) { this.powerUpdate(); } + if (this.isServer()) { + // 1.13 skips clean chunks even on save-all; timers and energy change without inventory edits. + // Mark the chunk directly, without comparator notifications or network packets every tick. + 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 6bc2cef..85aef23 100644 --- a/src/main/java/cyano/poweradvantage/api/simple/SimpleMachineGUI.java +++ b/src/main/java/cyano/poweradvantage/api/simple/SimpleMachineGUI.java @@ -4,6 +4,7 @@ import zone.moddev.mc.poweradvantage.PowerAdvantage; import zone.moddev.mc.poweradvantage.math.Integer2D; import zone.moddev.mc.poweradvantage.registry.ITileEntityGUI; +import zone.moddev.mc.poweradvantage.registry.MachineGUIRegistry; import net.minecraft.client.gui.inventory.GuiContainer; import net.minecraft.entity.player.EntityPlayer; import net.minecraft.entity.player.InventoryPlayer; @@ -14,7 +15,8 @@ import net.minecraft.util.ResourceLocation; import net.minecraftforge.api.distmarker.Dist; import net.minecraftforge.api.distmarker.OnlyIn; -import org.lwjgl.opengl.GL11; +import net.minecraft.client.renderer.GlStateManager; +import net.minecraft.util.text.ITextComponent; 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. @@ -38,7 +44,7 @@ public class SimpleMachineGUI implements ITileEntityGUI { public final static ResourceLocation playerInventoryBox = new ResourceLocation(PowerAdvantage.MODID+":"+"textures/gui/container/player_inventory.png"); /** Texture resource for the machine's GUI */ protected final ResourceLocation guiDisplayImage; - /** Dimensions of the GUI texture image */ + /** Dimensions of the assembled screen, including its label rows. */ protected final int guiWidth, guiHeight; /** List of pixel coordinates for the upper-left corner of each inventory * slot that should appear on the screen */ @@ -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.addSlot(specialSlots.get(index).apply(params)); } else { - this.addSlot(new Slot(entity, index, pt.X, pt.Y)); + this.addSlot(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)) { @OnlyIn(Dist.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 render(int mouseX, int mouseY, float partialTicks) { + this.drawDefaultBackground(); + super.render(mouseX, mouseY, partialTicks); + this.renderHoveredToolTip(mouseX, mouseY); + if (fontRenderer.getStringWidth(machineTitle.getString()) > width - 8 + && mouseY >= guiTop + 1 && mouseY < guiTop + MACHINE_TOP) { + this.drawHoveringText(machineTitle.getString(), mouseX, mouseY); + } } + + @Override + protected void drawGuiContainerForegroundLayer(int mouseX, int mouseY) { + String machineName = fitLabel(machineTitle.getString(), width - 8); + fontRenderer.drawStringWithShadow(machineName, (xSize - fontRenderer.getStringWidth(machineName)) / 2, 1, 0xFFFFFF); + fontRenderer.drawStringWithShadow(fitLabel(playerInventory.getDisplayName().getString(), 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.blendFuncSeparate(770, 771, 1, 0); + GlStateManager.color4f(1.0F, 1.0F, 1.0F, 1.0F); this.mc.getTextureManager().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.getTextureManager().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/main/java/zone/moddev/mc/poweradvantage/blocks/BlockFrame.java b/src/main/java/zone/moddev/mc/poweradvantage/blocks/BlockFrame.java index e43ea70..0cdeb7f 100644 --- a/src/main/java/zone/moddev/mc/poweradvantage/blocks/BlockFrame.java +++ b/src/main/java/zone/moddev/mc/poweradvantage/blocks/BlockFrame.java @@ -36,6 +36,11 @@ public boolean isFullCube(IBlockState bs) { return false; } + @Override + public boolean isSolid(IBlockState state) { + return false; + } + @Override public boolean canSilkHarvest(IBlockState state, IWorldReader world, BlockPos pos, EntityPlayer player) { return true; diff --git a/src/main/java/zone/moddev/mc/poweradvantage/conduitnetwork/ConduitRegistry.java b/src/main/java/zone/moddev/mc/poweradvantage/conduitnetwork/ConduitRegistry.java index 1c32a0b..7e459ef 100644 --- a/src/main/java/zone/moddev/mc/poweradvantage/conduitnetwork/ConduitRegistry.java +++ b/src/main/java/zone/moddev/mc/poweradvantage/conduitnetwork/ConduitRegistry.java @@ -183,27 +183,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; } diff --git a/src/main/java/zone/moddev/mc/poweradvantage/gui/FilteredFluidTankGUI.java b/src/main/java/zone/moddev/mc/poweradvantage/gui/FilteredFluidTankGUI.java index 2f13f20..e8cae58 100644 --- a/src/main/java/zone/moddev/mc/poweradvantage/gui/FilteredFluidTankGUI.java +++ b/src/main/java/zone/moddev/mc/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/zone/moddev/mc/poweradvantage/gui/FluidTankGUI.java b/src/main/java/zone/moddev/mc/poweradvantage/gui/FluidTankGUI.java index e21c64c..5abef14 100644 --- a/src/main/java/zone/moddev/mc/poweradvantage/gui/FluidTankGUI.java +++ b/src/main/java/zone/moddev/mc/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/zone/moddev/mc/poweradvantage/machines/conveyors/TileEntityConveyor.java b/src/main/java/zone/moddev/mc/poweradvantage/machines/conveyors/TileEntityConveyor.java index acee3c2..87bae7c 100644 --- a/src/main/java/zone/moddev/mc/poweradvantage/machines/conveyors/TileEntityConveyor.java +++ b/src/main/java/zone/moddev/mc/poweradvantage/machines/conveyors/TileEntityConveyor.java @@ -138,8 +138,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; } } } @@ -162,10 +163,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; } } @@ -176,6 +180,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/zone/moddev/mc/poweradvantage/machines/conveyors/TileEntityOverflowFilter.java b/src/main/java/zone/moddev/mc/poweradvantage/machines/conveyors/TileEntityOverflowFilter.java index 6e5757c..ef8c74b 100644 --- a/src/main/java/zone/moddev/mc/poweradvantage/machines/conveyors/TileEntityOverflowFilter.java +++ b/src/main/java/zone/moddev/mc/poweradvantage/machines/conveyors/TileEntityOverflowFilter.java @@ -28,21 +28,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/zone/moddev/mc/poweradvantage/machines/fluidmachines/FluidDischargeTileEntity.java b/src/main/java/zone/moddev/mc/poweradvantage/machines/fluidmachines/FluidDischargeTileEntity.java index 6184efd..cf3844d 100644 --- a/src/main/java/zone/moddev/mc/poweradvantage/machines/fluidmachines/FluidDischargeTileEntity.java +++ b/src/main/java/zone/moddev/mc/poweradvantage/machines/fluidmachines/FluidDischargeTileEntity.java @@ -53,7 +53,9 @@ private static BlockPos findPlacement(World world, BlockPos initial, Fluid fluid EnumFacing[] directions = { EnumFacing.DOWN, EnumFacing.NORTH, EnumFacing.WEST, EnumFacing.SOUTH, EnumFacing.EAST }; while (range-- > 0) { IFluidState state = world.getFluidState(position); - if (state.isEmpty()) return position; + if (world.isAirBlock(position) || (!state.isEmpty() + && state.getFluid().isEquivalentTo(fluid) && !state.isSource())) return position; + if (state.isEmpty() || !state.getFluid().isEquivalentTo(fluid)) return null; java.util.List candidates = new java.util.ArrayList<>(); for (EnumFacing direction : directions) { BlockPos candidate = position.offset(direction); diff --git a/src/main/java/zone/moddev/mc/poweradvantage/machines/fluidmachines/FluidDrainTileEntity.java b/src/main/java/zone/moddev/mc/poweradvantage/machines/fluidmachines/FluidDrainTileEntity.java index 304a7e3..0a15e02 100644 --- a/src/main/java/zone/moddev/mc/poweradvantage/machines/fluidmachines/FluidDrainTileEntity.java +++ b/src/main/java/zone/moddev/mc/poweradvantage/machines/fluidmachines/FluidDrainTileEntity.java @@ -8,7 +8,9 @@ import cyano.poweradvantage.api.simple.TileEntitySimpleFluidMachine; import net.minecraft.fluid.Fluid; import net.minecraft.fluid.IFluidState; -import net.minecraft.init.Blocks; +import net.minecraft.block.state.IBlockState; +import net.minecraft.block.IBucketPickupHandler; +import net.minecraft.world.IWorld; import net.minecraft.item.ItemStack; import net.minecraft.tileentity.TileEntity; import net.minecraft.util.EnumFacing; @@ -51,14 +53,20 @@ public void powerUpdate() { if (getTank().getFluidAmount() == 0) { BlockPos source = scanFluidSpaceForSourceBlock(getWorld(), getPos().up(), 32); if (source != null) { - Fluid fluid = getWorld().getFluidState(source).getFluid(); - getTank().fill(new FluidStack(fluid, FluidTank.BUCKET_VOLUME), true); - getWorld().setBlockState(source, Blocks.AIR.getDefaultState()); + Fluid fluid = pickupSource(getWorld(), source, getWorld().getBlockState(source)); + if (fluid != net.minecraft.init.Fluids.EMPTY) + getTank().fill(new FluidStack(fluid, FluidTank.BUCKET_VOLUME), true); } } super.powerUpdate(); } + static Fluid pickupSource(IWorld world, BlockPos position, IBlockState state) { + if (!(state.getBlock() instanceof IBucketPickupHandler)) return net.minecraft.init.Fluids.EMPTY; + Fluid picked = ((IBucketPickupHandler)state.getBlock()).pickupFluid(world, position, state); + return picked == null ? net.minecraft.init.Fluids.EMPTY : picked; + } + private void tryPushFluid(BlockPos position, EnumFacing face) { IAdvantageFluidHandler handler = FluidHandlerHelper.getHandler(getWorld().getTileEntity(position), face); if (handler == null || getTank().getFluid() == null) return; @@ -85,7 +93,8 @@ public static BlockPos scanFluidSpaceForSourceBlock(World world, BlockPos initia EnumFacing[] directions = { EnumFacing.UP, EnumFacing.NORTH, EnumFacing.WEST, EnumFacing.SOUTH, EnumFacing.EAST }; while (range-- > 0) { IFluidState state = world.getFluidState(position); - if (!state.isEmpty() && state.isSource()) return position; + if (!state.isEmpty() && state.isSource() + && world.getBlockState(position).getBlock() instanceof IBucketPickupHandler) return position; java.util.List candidates = new java.util.ArrayList<>(); for (EnumFacing direction : directions) { BlockPos candidate = position.offset(direction); diff --git a/src/main/java/zone/moddev/mc/poweradvantage/registry/MachineGUIRegistry.java b/src/main/java/zone/moddev/mc/poweradvantage/registry/MachineGUIRegistry.java index 0fe16d2..832c3af 100644 --- a/src/main/java/zone/moddev/mc/poweradvantage/registry/MachineGUIRegistry.java +++ b/src/main/java/zone/moddev/mc/poweradvantage/registry/MachineGUIRegistry.java @@ -1,5 +1,6 @@ package zone.moddev.mc.poweradvantage.registry; +import net.minecraft.block.Block; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.GuiScreen; import net.minecraft.client.gui.inventory.GuiContainer; @@ -11,7 +12,7 @@ import net.minecraft.tileentity.TileEntity; import net.minecraft.util.math.BlockPos; import net.minecraft.util.text.ITextComponent; -import net.minecraft.util.text.TextComponentString; +import net.minecraft.util.text.TextComponentTranslation; import net.minecraft.world.IInteractionObject; import net.minecraft.world.World; import net.minecraftforge.fml.network.FMLPlayMessages; @@ -139,15 +140,28 @@ public String getGuiID() { return "poweradvantage:machine"; } - @Override public ITextComponent getName() { return new TextComponentString("Power Advantage"); } - @Override public boolean hasCustomName() { return false; } - @Override public ITextComponent getCustomName() { return null; } + @Override public ITextComponent getName() { return menuTitle(tile, world.getBlockState(pos).getBlock()); } + @Override public boolean hasCustomName() { + return tile instanceof net.minecraft.util.INameable && ((net.minecraft.util.INameable) tile).hasCustomName(); + } + @Override public ITextComponent getCustomName() { + return tile instanceof net.minecraft.util.INameable ? ((net.minecraft.util.INameable) tile).getCustomName() : null; + } }, buffer -> { buffer.writeBlockPos(pos); buffer.writeVarInt(guiId); }); } + /** Shares equipment/custom names between the native window and legacy screen. */ + public static ITextComponent menuTitle(TileEntity tile, Block block) { + if (tile instanceof net.minecraft.util.INameable) { + ITextComponent customName = ((net.minecraft.util.INameable) tile).getCustomName(); + if (customName != null) return customName; + } + return new TextComponentTranslation(block.getTranslationKey()); + } + @OnlyIn(Dist.CLIENT) public static GuiScreen createClientGui(FMLPlayMessages.OpenContainer message) { PacketBuffer data = message.getAdditionalData(); diff --git a/src/test/java/zone/moddev/mc/poweradvantage/blocks/BlockFrameOpacityTest.java b/src/test/java/zone/moddev/mc/poweradvantage/blocks/BlockFrameOpacityTest.java new file mode 100644 index 0000000..7edab28 --- /dev/null +++ b/src/test/java/zone/moddev/mc/poweradvantage/blocks/BlockFrameOpacityTest.java @@ -0,0 +1,21 @@ +package zone.moddev.mc.poweradvantage.blocks; + +import net.minecraft.block.material.Material; +import org.junit.BeforeClass; +import org.junit.Test; +import static org.junit.Assert.*; + +public class BlockFrameOpacityTest { + @BeforeClass public static void bootstrap() { zone.moddev.mc.poweradvantage.support.PowerNativeTestBootstrap.register(); } + + @Test public void frameRemainsNonSolidOnTheDedicatedServer() { + BlockFrame frame = new BlockFrame(Material.PISTON, .75F, 10) { + // Dedicated-server stripping removes the client-only CUTOUT override. + @Override public net.minecraft.util.BlockRenderLayer getRenderLayer() { + return net.minecraft.util.BlockRenderLayer.SOLID; + } + }; + assertFalse(frame.isSolid(frame.getDefaultState())); + assertFalse(frame.getDefaultState().isSolid()); + } +} diff --git a/src/test/java/zone/moddev/mc/poweradvantage/conduitnetwork/PowerTransferAccountingTest.java b/src/test/java/zone/moddev/mc/poweradvantage/conduitnetwork/PowerTransferAccountingTest.java new file mode 100644 index 0000000..070739c --- /dev/null +++ b/src/test/java/zone/moddev/mc/poweradvantage/conduitnetwork/PowerTransferAccountingTest.java @@ -0,0 +1,52 @@ +package zone.moddev.mc.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/zone/moddev/mc/poweradvantage/machines/conveyors/ConveyorTransferTest.java b/src/test/java/zone/moddev/mc/poweradvantage/machines/conveyors/ConveyorTransferTest.java new file mode 100644 index 0000000..3aad59d --- /dev/null +++ b/src/test/java/zone/moddev/mc/poweradvantage/machines/conveyors/ConveyorTransferTest.java @@ -0,0 +1,43 @@ +package zone.moddev.mc.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 zone.moddev.mc.poweradvantage.util.InventoryWrapper; +import static org.junit.Assert.*; + +public class ConveyorTransferTest { + @BeforeClass public static void bootstrap() { zone.moddev.mc.poweradvantage.support.PowerNativeTestBootstrap.register(); } + + @Test public void laterEmptySlotRemainsAnAvailableDestination() { + InventoryBasic destination = new InventoryBasic(new net.minecraft.util.text.TextComponentString("test"), 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(new net.minecraft.util.text.TextComponentString("test"), 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(new net.minecraft.util.text.TextComponentString("source"), 2), + target = new InventoryBasic(new net.minecraft.util.text.TextComponentString("target"), 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).getTag().getString("Witness")); + assertEquals(3, target.getStackInSlot(1).getCount()); assertEquals("B", target.getStackInSlot(1).getTag().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); + stack.getOrCreateTag().setString("Witness", value); + return stack; + } +} diff --git a/src/test/java/zone/moddev/mc/poweradvantage/machines/fluidmachines/WorldFluidPickupTest.java b/src/test/java/zone/moddev/mc/poweradvantage/machines/fluidmachines/WorldFluidPickupTest.java new file mode 100644 index 0000000..53a8065 --- /dev/null +++ b/src/test/java/zone/moddev/mc/poweradvantage/machines/fluidmachines/WorldFluidPickupTest.java @@ -0,0 +1,53 @@ +package zone.moddev.mc.poweradvantage.machines.fluidmachines; + +import java.lang.reflect.Proxy; +import java.util.concurrent.atomic.AtomicReference; +import net.minecraft.block.state.IBlockState; +import net.minecraft.init.Blocks; +import net.minecraft.block.BlockFlowingFluid; +import net.minecraft.init.Fluids; +import net.minecraft.state.properties.BlockStateProperties; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.IWorld; +import org.junit.BeforeClass; +import org.junit.Test; +import static org.junit.Assert.*; + +public class WorldFluidPickupTest { + @BeforeClass public static void bootstrap() { zone.moddev.mc.poweradvantage.support.PowerNativeTestBootstrap.register(); } + + @Test public void waterloggedHostSurvivesTheNativePickup() { + AtomicReference state = new AtomicReference<>(Blocks.OAK_SLAB.getDefaultState() + .with(BlockStateProperties.WATERLOGGED, true)); + assertSame(Fluids.WATER, FluidDrainTileEntity.pickupSource(world(state), BlockPos.ORIGIN, state.get())); + assertSame(Blocks.OAK_SLAB, state.get().getBlock()); + assertFalse(state.get().get(BlockStateProperties.WATERLOGGED)); + } + + @Test public void ordinaryWaterSourceBecomesAir() { + AtomicReference state = new AtomicReference<>(Blocks.WATER.getDefaultState()); + assertSame(Fluids.WATER, FluidDrainTileEntity.pickupSource(world(state), BlockPos.ORIGIN, state.get())); + assertTrue(state.get().isAir()); + } + + @Test public void flowingWaterDoesNotProduceABucket() { + AtomicReference state = new AtomicReference<>(Blocks.WATER.getDefaultState().with(BlockFlowingFluid.LEVEL, 3)); + IBlockState original = state.get(); + assertSame(Fluids.EMPTY, FluidDrainTileEntity.pickupSource(world(state), BlockPos.ORIGIN, state.get())); + assertSame(original, state.get()); + } + + @Test public void nonPickupBlockIsNeverRemoved() { + AtomicReference state = new AtomicReference<>(Blocks.BEDROCK.getDefaultState()); + assertSame(Fluids.EMPTY, FluidDrainTileEntity.pickupSource(world(state), BlockPos.ORIGIN, state.get())); + assertSame(Blocks.BEDROCK, state.get().getBlock()); + } + + private static IWorld world(AtomicReference state) { + return (IWorld)Proxy.newProxyInstance(IWorld.class.getClassLoader(), new Class[]{IWorld.class}, (proxy, method, args) -> { + if (method.getName().equals("setBlockState")) { state.set((IBlockState)args[1]); return true; } + if (method.getName().equals("getBlockState")) return state.get(); + throw new AssertionError("Unexpected world operation " + method.getName()); + }); + } +} diff --git a/src/test/java/zone/moddev/mc/poweradvantage/registry/MachineGuiTitleTest.java b/src/test/java/zone/moddev/mc/poweradvantage/registry/MachineGuiTitleTest.java new file mode 100644 index 0000000..414a8a4 --- /dev/null +++ b/src/test/java/zone/moddev/mc/poweradvantage/registry/MachineGuiTitleTest.java @@ -0,0 +1,44 @@ +package zone.moddev.mc.poweradvantage.registry; + +import java.lang.reflect.Method; +import net.minecraft.block.Block; +import net.minecraft.init.Blocks; +import net.minecraft.tileentity.TileEntityChest; +import net.minecraft.tileentity.TileEntity; +import net.minecraft.tileentity.TileEntityType; +import net.minecraft.util.text.ITextComponent; +import net.minecraft.util.text.TextComponentString; +import net.minecraft.util.text.TextComponentTranslation; +import org.junit.BeforeClass; +import org.junit.Test; +import static org.junit.Assert.*; + +public class MachineGuiTitleTest { + @BeforeClass public static void bootstrap() { + zone.moddev.mc.poweradvantage.support.PowerNativeTestBootstrap.register(); + } + + @Test public void defaultTitleUsesTheBlockTranslationInsteadOfAnInternalInventoryName() throws Exception { + assertEquals("block.minecraft.chest", ((TextComponentTranslation) title( + new TileEntityChest(), Blocks.CHEST)).getKey()); + } + + @Test public void renamedMachinesKeepThePlayersFullName() throws Exception { + TileEntityChest tile = new TileEntityChest(); + tile.setCustomName(new TextComponentString("John's oil tank")); + assertEquals("John's oil tank", title(tile, Blocks.CHEST).getString()); + } + + @Test public void unnamedTilesWithoutInventoryNamesStillHaveATranslatableTitle() throws Exception { + TileEntity tile = new TileEntity(TileEntityType.CHEST) {}; + assertEquals("block.minecraft.chest", ((TextComponentTranslation) title(tile, Blocks.CHEST)).getKey()); + } + + private static ITextComponent title(TileEntity tile, Block block) throws Exception { + Method method; + try { method = MachineGUIRegistry.class.getDeclaredMethod("menuTitle", TileEntity.class, Block.class); } + catch (NoSuchMethodException missing) { throw new AssertionError("Machine titles need a shared, testable equipment-name resolver", missing); } + method.setAccessible(true); + return (ITextComponent) method.invoke(null, tile, block); + } +} diff --git a/src/test/java/zone/moddev/mc/poweradvantage/support/PowerNativeTestBootstrap.java b/src/test/java/zone/moddev/mc/poweradvantage/support/PowerNativeTestBootstrap.java new file mode 100644 index 0000000..5de2e93 --- /dev/null +++ b/src/test/java/zone/moddev/mc/poweradvantage/support/PowerNativeTestBootstrap.java @@ -0,0 +1,35 @@ +package zone.moddev.mc.poweradvantage.support; + +import java.io.InputStream; +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.Properties; +import static org.junit.Assert.*; + +/** Establishes Forge 25 launcher metadata for headless native-object unit fixtures only. */ +public final class PowerNativeTestBootstrap { + private PowerNativeTestBootstrap() {} + + public static synchronized void register() { + Properties platform = new Properties(); + try (InputStream input = Files.newInputStream(Paths.get("gradle.properties"))) { + platform.load(input); + Class loader = Class.forName("net.minecraftforge.fml.loading.FMLLoader"); + version(loader, "mcVersion", platform.getProperty("minecraft_version")); + version(loader, "forgeVersion", platform.getProperty("forge_version")); + } catch (java.io.IOException | ReflectiveOperationException failure) { + throw new AssertionError("Cannot establish the configured Forge unit-test platform", failure); + } + net.minecraft.init.Bootstrap.register(); + } + + private static void version(Class loader, String name, String expected) throws ReflectiveOperationException { + assertNotNull("Missing configured version " + name, expected); + Field field = loader.getDeclaredField(name); + field.setAccessible(true); + Object actual = field.get(null); + if (actual == null) field.set(null, expected); + else assertEquals("Conflicting test platform " + name, expected, actual); + } +} diff --git a/test-rig/build.gradle b/test-rig/build.gradle index a83dd96..acb44ac 100644 --- a/test-rig/build.gradle +++ b/test-rig/build.gradle @@ -6,6 +6,9 @@ plugins { id 'net.minecraftforge.gradle' version '7.0.34' } +// Keep compiler cleanup separate from disposable worlds and retained evidence. +layout.buildDirectory = layout.projectDirectory.dir('build/gradle') + group = 'zone.moddev.mc.advantageworks' version = project.harness_version base.archivesName = 'AdvantageWorksTestHarness' @@ -56,6 +59,7 @@ dependencies { "net.minecraftforge:forge:${project.minecraft_version}-${project.forge_version}") compileOnly 'org.lwjgl.lwjgl:lwjgl_util:2.9.4-nightly-20150209' runnerImplementation 'com.google.code.gson:gson:2.8.0' + testImplementation sourceSets.runner.output testImplementation 'junit:junit:4.13.2' compileOnly files(project.findProperty('powerAdvantageDeobfJar') ?: '../build/libs-dev/PowerAdvantage-2.4.4.113021-deobf.jar') diff --git a/test-rig/client-resources/README.md b/test-rig/client-resources/README.md index b76729b..abe445d 100644 --- a/test-rig/client-resources/README.md +++ b/test-rig/client-resources/README.md @@ -1,5 +1,47 @@ # Native Client Resource Check +## GUI Coverage Backport + +The new `-MachineGuiLayouts` option tests all 39 machine screens through the +native 1.13 renderer. The independent caption-v2 specification requires 351 +empty/half/full viewport cases and 355 rejected broken-label controls, alongside +native item hover, slot alignment, dedicated label rows and long custom names. +The expected captions are not copied from production label output. + +The tests first rejected all 39 old screens in a native red capture. Minimal +caption, slot, tooltip and bounded-display repairs are now staged for a fresh +green build and native client run. Static checks and earlier-version results +do not qualify 1.13 screen rendering. + +Build and run only through the coordinated parent build/runtime lease: + +```powershell +./gradlew.bat clientResourceProbeJar +./test-rig/client-resources/run.ps1 -JavaHome '' -LaunchProfile runClientAllAdvantage.launch -RequireExtensions -MachineGuiLayouts -TimeoutSeconds 600 +``` + +The fixture uses 1.13's real GuiContainer, InventoryPlayer, fontRenderer and +setWorldAndResolution APIs. Its long-name case names the real tank through +setCustomInventoryName and restores that name afterward. It never injects +expected glyphs into production rendering. Old oil, tank and recipe probes +retain their native 1.13 source and thresholds. + +The following checks open no world and start no Java process: + +```powershell +./test-rig/client-resources/test-launcher.ps1 -StaticOnly +./test-rig/client-resources/test-gui-contract.ps1 +./test-rig/client-resources/test-probe-contract.ps1 +./test-rig/client-resources/test-gui-contract-negative-controls.ps1 +./test-rig/client-resources/test-probe-contract-negative-controls.ps1 +``` + +The runner requires exact native target/candidate identities, snapshots mutable +development dependencies, and retains failed images and partial native reports +separately from launcher failures. The normal Eclipse launch remains unchanged. + +## Existing Resource Checks + This build-only Forge 1.13.2 probe checks PowerAdvantage, or all three Advantage mods in the combined profile, after Minecraft bakes its models and stitches its textures. It registers no gameplay content, opens no world, and shuts down its own disposable client when finished. It is not included in release jars or ordinary Eclipse launches. For native recipe-book grouping, first complete the packaged `recipe_unlocks.py` @@ -29,7 +71,9 @@ Run Gradle with Java 17, then supply a Java 8 runtime to the launcher: ./test-rig/client-resources/run.ps1 -JavaHome '' ``` -Each run gets a new directory under `build/client-resource-runs/`. The runner snapshots production classes/resources, uses the audited launch dependencies, and loads only the probe as an additional mod. It retains logs and `client-resources.json`; missing reports, failed assertions, and model/texture errors fail the run. Existing saves, configuration, and prior reports are untouched. +Each run gets a new directory under `run-client-resource-runs/`. The runner snapshots production classes/resources, uses the audited launch dependencies, and loads only the probe as an additional mod. It retains logs and `client-resources.json`; missing reports, failed assertions, and model/texture errors fail the run. Existing saves, configuration, and prior reports are untouched. + +New GUI captures are kept outside Gradle's `build/` directory so `clean` cannot remove them. This does not recreate any historical captures already lost from the former build-based location. The default `-OutputSource Gradle` merges Gradle's classes and processed resources. To test the actual Eclipse output instead, pass `-OutputSource Eclipse`; this copies `bin/main` verbatim and does not overlay Gradle resources. `client-input.json` records the selected output, launch and metadata hash. Missing/unexpanded metadata fails the preflight before Minecraft opens, and the native result records the versions Forge actually discovers. The [Eclipse metadata regression](../../docs/1.13/ECLIPSE-METADATA.md) explains why this is a separate acceptance gate. 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 index 09195a8..78414bf 100644 --- 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 @@ -43,6 +43,12 @@ public final class ClientResourceProbe { finished = true; JsonObject result = new JsonObject(); try { + result.addProperty("minecraft", net.minecraftforge.versions.mcp.MCPVersion.getMCVersion()); + result.addProperty("forge", net.minecraftforge.versions.forge.ForgeVersion.getVersion()); + require("1.13.2".equals(result.get("minecraft").getAsString()) + && "25.0.223".equals(result.get("forge").getAsString()), "Wrong native client target"); + require(minecraft.world == null && minecraft.player == null && !minecraft.isIntegratedServerRunning(), + "Resource probe must not open a world or connect to a server"); com.google.gson.JsonArray mods = new com.google.gson.JsonArray(); JsonObject versions = new JsonObject(); for (String id : new String[]{"poweradvantage", "steamadvantage", "electricadvantage"}) { @@ -58,6 +64,10 @@ public final class ClientResourceProbe { result.add("mod_versions", versions); require(versions.has("poweradvantage") && zone.moddev.mc.poweradvantage.PowerAdvantage.VERSION .equals(versions.get("poweradvantage").getAsString()), "Power metadata does not match the compiled version"); + require("2.4.4.113021".equals(versions.get("poweradvantage").getAsString()), "Wrong native Power candidate"); + for (String id : new String[]{"steamadvantage", "electricadvantage"}) { + if (versions.has(id)) require("2.2.5.113021".equals(versions.get(id).getAsString()), "Wrong native expansion candidate: " + id); + } require(minecraft.getModelManager().getMissingModel() != null, "Timed out before model baking"); int items = 0; int states = 0; @@ -89,6 +99,7 @@ public final class ClientResourceProbe { require(I18n.format("block.poweradvantage.still").equals(I18n.format("tile.poweradvantage.still.name")), "Legacy GUI translation differs from modern block name"); TankVisualProbe.verify(minecraft, result); + MachineGuiLayoutProbe.verify(minecraft, result); OilRenderProbe.verify(minecraft, result); RecipeBookGroupingProbe.verify(minecraft, result); result.addProperty("status", "PASS"); @@ -97,7 +108,7 @@ public final class ClientResourceProbe { result.addProperty("creative_entries", contents.size()); result.addProperty("screen", minecraft.currentScreen == null ? "none" : minecraft.currentScreen.getClass().getName()); LogManager.getLogger(MODID).info("ADVANTAGE_CLIENT_RESOURCES PASS items={} block_states={} creative_entries={}", items, states, contents.size()); - } catch (RuntimeException | AssertionError failure) { + } catch (RuntimeException | LinkageError | AssertionError failure) { result.addProperty("status", "FAIL"); result.addProperty("error", failure.toString()); LogManager.getLogger(MODID).error("ADVANTAGE_CLIENT_RESOURCES FAIL", failure); 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..be13995 --- /dev/null +++ b/test-rig/client-resources/java/zone/moddev/mc/advantageworks/client/MachineGuiLayoutProbe.java @@ -0,0 +1,479 @@ +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 cyano.poweradvantage.api.fluid.FluidStack; +import cyano.poweradvantage.api.fluid.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.registries.ForgeRegistries; +import zone.moddev.mc.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.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(block.getDefaultState(), null); + 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.getString()); + 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.state.IProperty property : block.getDefaultState().getProperties()) { + if (property instanceof net.minecraft.state.BooleanProperty && "active".equals(property.getName())) { + world.state = block.getDefaultState().with((net.minecraft.state.BooleanProperty) 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.getString().equals(expectedTitle), "Menu title differs from the equipment name: " + title.getString()); + 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 == zone.moddev.mc.poweradvantage.init.Blocks.metal_storage_tank) { + negativeControls += verifyMissingLabelControls(minecraft, screen); + verifyLongName(minecraft, (SimpleMachineGUI) definition, menu, inventory); + } + if (block == zone.moddev.mc.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) { + // Match the uncorrected 1.15 menu provider for the retained red run. + return tile instanceof net.minecraft.util.INameable ? ((net.minecraft.util.INameable) tile).getDisplayName() + : new TextComponentTranslation(block.getTranslationKey()); + } + } + + 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.minecraft.init.Fluids.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.translatef(0, 0, -2000); + GlStateManager.enableAlphaTest(); + GlStateManager.enableDepthTest(); + GlStateManager.depthFunc(515); + GlStateManager.disableBlend(); + GlStateManager.color4f(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.render(-100, -100, 0F); + screen.labels = original; + } else { + Slot hotbar = screen.inventorySlots.inventorySlots.get(screen.inventorySlots.inventorySlots.size() - 9); + screen.render(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.enableDepthTest(); + GlStateManager.color4f(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); + net.minecraft.util.text.ITextComponent previousName = named.getCustomName(); + 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 == null ? null : previousName.getString()); + } + } + + 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[]{"zone.moddev.mc.electricadvantage.gui.GUIHelper", + "zone.moddev.mc.steamadvantage.gui.GUIHelper"}) { + if (!net.minecraftforge.fml.ModList.get().isLoaded(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, net.minecraft.world.dimension.DimensionType.OVERWORLD, 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 render(int x, int y, float ticks) { + draws.clear(); tooltipCalls = 0; itemTooltipCalls = 0; titleTooltip = null; + super.render(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() { return hoveredSlot; } + @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/run.ps1 b/test-rig/client-resources/run.ps1 index 1329166..44d740a 100644 --- a/test-rig/client-resources/run.ps1 +++ b/test-rig/client-resources/run.ps1 @@ -2,17 +2,83 @@ param([Parameter(Mandatory = $true)][string]$JavaHome, [ValidateSet('runClient.launch', 'runClientAllAdvantage.launch')][string]$LaunchProfile = 'runClient.launch', [ValidateSet('Gradle', 'Eclipse')][string]$OutputSource = 'Gradle', [switch]$RequireExtensions, - [string]$RecipeBookFixtures) + [switch]$MachineGuiLayouts, + [string]$RecipeBookFixtures, + [string]$PowerCandidate, + [switch]$PreflightOnly, + [ValidateRange(30, 1800)][int]$TimeoutSeconds = 300) $ErrorActionPreference = 'Stop' $project = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path $java = Join-Path $JavaHome 'bin/java.exe' -if (-not (Test-Path -LiteralPath $java)) { throw "Java 8 runtime not found: $java" } +Add-Type -AssemblyName System.IO.Compression.FileSystem +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-JarMetadata([string]$Path) { + $jar = [IO.Compression.ZipFile]::OpenRead($Path) + try { + $entry = $jar.GetEntry('META-INF/mods.toml') + if (-not $entry) { return $null } + $reader = [IO.StreamReader]::new($entry.Open()) + try { $reader.ReadToEnd() } finally { $reader.Dispose() } + } finally { $jar.Dispose() } +} +function Assert-NativeMetadata([string]$Metadata, [string]$Id) { + if (-not $Metadata -or $Metadata -match '\$\{[^}]+\}') { throw "Missing/unexpanded metadata for $Id" } + # Match the known single-mod header, not dependency modId/versionRange fields. + $header = [regex]::Match($Metadata, '(?ms)^\[\[mods\]\]\s*(.*?)(?=^\[|\z)').Groups[1].Value + if ($header -notmatch ('(?m)^modId\s*=\s*"' + [regex]::Escape($Id) + '"[ \t]*(?:#[^\r\n]*)?\r?$') -or + $header -notmatch '(?m)^version\s*=\s*"[^"\r\n]*\.113\d+"[ \t]*(?:#[^\r\n]*)?\r?$' -or + $Metadata -notmatch '(?m)^loaderVersion\s*=\s*"\[25,') { throw "Not a native Forge 25 candidate: $Id" } + $expected = @{ poweradvantage = '2.4.4.113021'; steamadvantage = '2.2.5.113021'; electricadvantage = '2.2.5.113021' } + if ($expected.ContainsKey($Id) -and $header -notmatch ('(?m)^version\s*=\s*"' + [regex]::Escape($expected[$Id]) + '"[ \t]*(?:#[^\r\n]*)?\r?$')) { + throw "Unexpected native candidate version for $Id; expected $($expected[$Id])" + } +} +function Assert-NativeLaunch([hashtable]$Environment, [hashtable]$Attributes) { + if ($Environment.MC_VERSION -ne '1.13.2' -or $Environment.FORGE_VERSION -ne '25.0.223' -or + $Environment.MCP_MAPPINGS -ne 'snapshot_20180921-1.13' -or $Environment.MCP_VERSION -ne '20190213.203750' -or + $Environment.target -ne 'fmluserdevclient') { throw 'Regenerate the native 1.13.2 / Forge 25.0.223 snapshot 20180921-1.13 client launch.' } + if ($Attributes['org.eclipse.jdt.launching.PROGRAM_ARGUMENTS'] -notmatch '1\.13\.2-25\.0\.223' -or + $Attributes['org.eclipse.jdt.launching.PROGRAM_ARGUMENTS'] -match '1\.(?:14\.4|15\.2|16\.5)|--(?:server|port|gameDir|world)\b' -or + $Attributes['org.eclipse.jdt.launching.MAIN_TYPE'] -ne 'net.minecraftforge.launcher.Main') { + throw 'Unexpected native launch arguments; no server connection or existing game directory is allowed.' + } +} +function Assert-NativeArchive([string]$Path) { + if ($Path -match '(?:forge|client-extra)-1\.(?:14\.4|15\.2|16\.5)|[\\/]PA11[456][\\/]') { throw "Legacy runtime archive: $Path" } +} +function Quote-Argument([string]$Value) { + '"' + [regex]::Replace([regex]::Replace($Value, '(\\*)"', '$1$1\"'), '(\\+)$', '$1$1') + '"' +} $probe = Join-Path $project 'build/client-resource-probe/advantageclientresources-deobf.jar' +$runtime = Join-Path $project ('run-client-resource-runs/' + [DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss') + '-' + [guid]::NewGuid().ToString('N').Substring(0, 8)) +$inputReport = [ordered]@{ minecraft = '1.13.2'; forge = '25.0.223'; mappings = 'snapshot_20180921-1.13'; mcp = '20190213.203750'; qualification = 'UNQUALIFIED'; output_source = $OutputSource; launch_profile = $LaunchProfile; require_extensions = [bool]$RequireExtensions } +$inputReport.machine_gui_layouts = [bool]$MachineGuiLayouts +$phase = 'launch-preflight' +New-Item -ItemType Directory -Path $runtime | Out-Null +try { +if (-not (Test-Path -LiteralPath $java)) { throw "Java 8 runtime not found: $java" } +if ((Get-Content -LiteralPath (Join-Path $JavaHome 'release') -Raw) -notmatch '(?m)^JAVA_VERSION="1\.8\.') { throw 'Supply Java 8 for the native client.' } if (-not (Test-Path -LiteralPath $probe)) { throw 'Run gradlew.bat clientResourceProbeJar verifyEclipseProductionClasspath first.' } [xml]$launch = Get-Content -LiteralPath (Join-Path $project $LaunchProfile) -Raw $attributes = @{} foreach ($attribute in $launch.launchConfiguration.stringAttribute) { $attributes[$attribute.key] = $attribute.value } -$runtime = Join-Path $project ('build/client-resource-runs/' + [DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss') + '-' + [guid]::NewGuid().ToString('N').Substring(0, 8)) +$inputReport.java = Get-Record $java +$inputReport.launch = Get-Record (Join-Path $project $LaunchProfile) +$inputReport.probe = Get-Record $probe +$probeMetadata = Read-JarMetadata $probe +if ($probeMetadata -notmatch 'loaderVersion="\[25,\)"' -or + $probeMetadata -notmatch 'versionRange="\[2\.4\.4\.113021,\)"') { throw 'Rebuild the native 1.13 probe; do not substitute a legacy probe.' } +Copy-Item -LiteralPath (Join-Path $project $LaunchProfile) -Destination (Join-Path $runtime 'launch.xml') +$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 } + } +} +Assert-NativeLaunch $environment $attributes $modOutput = Join-Path $runtime 'poweradvantage' New-Item -ItemType Directory -Path $modOutput,(Join-Path $runtime 'mods') -Force | Out-Null if ($OutputSource -eq 'Eclipse') { @@ -22,7 +88,6 @@ if ($OutputSource -eq 'Eclipse') { Copy-Item -Path (Join-Path $project 'build/resources/main/*') -Destination $modOutput -Recurse -Force } $metadataPath = Join-Path $modOutput 'META-INF/mods.toml' -$inputReport = [ordered]@{ output_source = $OutputSource; launch_profile = $LaunchProfile; require_extensions = [bool]$RequireExtensions } if (Test-Path -LiteralPath $metadataPath) { $inputReport.metadata_sha256 = (Get-FileHash -LiteralPath $metadataPath).Hash } $inputReport | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $runtime 'client-input.json') -Encoding utf8 if (-not (Test-Path -LiteralPath $metadataPath) -or (Get-Content -LiteralPath $metadataPath -Raw) -match '\$\{[^}]+\}') { @@ -31,39 +96,109 @@ if (-not (Test-Path -LiteralPath $metadataPath) -or (Get-Content -LiteralPath $m Set-Content -LiteralPath (Join-Path $runtime 'client-resources.json') -Encoding utf8 throw $failure } +Assert-NativeMetadata (Get-Content -LiteralPath $metadataPath -Raw) 'poweradvantage' +$outputFiles = @(Get-ChildItem -LiteralPath $modOutput -Recurse -File | Sort-Object FullName | ForEach-Object { Get-Record $_.FullName }) +if (@($outputFiles | Where-Object { $_.path -like '*.class' }).Count -eq 0) { throw 'Selected output has no compiled production classes.' } +$outputFiles | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath (Join-Path $runtime 'production-files.json') -Encoding utf8 +$inputReport.production_files = Get-Record (Join-Path $runtime 'production-files.json') +if (-not $PowerCandidate) { $PowerCandidate = Join-Path $project 'build/libs-dev/PowerAdvantage-2.4.4.113021-deobf.jar' } +$PowerCandidate = (Resolve-Path -LiteralPath $PowerCandidate).Path +Assert-NativeMetadata (Read-JarMetadata $PowerCandidate) 'poweradvantage' +$inputReport.power_candidate = Get-Record $PowerCandidate +$inputReport.candidate_note = 'Jar provenance only; runtime uses the separately hashed selected production classes/resources.' Copy-Item -LiteralPath $probe -Destination (Join-Path $runtime 'mods/advantageclientresources.jar') +if ((Get-FileHash -LiteralPath (Join-Path $runtime 'mods/advantageclientresources.jar')).Hash -ne $inputReport.probe.sha256) { throw 'Probe changed during snapshot.' } if ($RecipeBookFixtures) { + if (-not $RequireExtensions) { throw 'The thirteen recipe-cell rendering assertions require the combined profile.' } $fixtures = (Resolve-Path -LiteralPath $RecipeBookFixtures).Path $destination = Join-Path $runtime 'recipe-book-fixtures' New-Item -ItemType Directory -Path $destination | Out-Null $packets = @(Get-ChildItem -LiteralPath $fixtures -Filter 'recipes-*-sweep.bin') if ($packets.Count -ne 3) { throw 'Recipe grouping requires Normal, Tech and Apocalyptic sweep packets.' } + foreach ($mode in 'normal','tech','apocalyptic') { + if (-not (Test-Path -LiteralPath (Join-Path $fixtures "recipes-$mode-sweep.bin"))) { throw "Missing $mode recipe packet." } + } + $fixtureRecords = @() foreach ($packet in $packets) { + $fixtureRecords += Get-Record $packet.FullName + $fixtureRecords += Get-Record ([IO.Path]::ChangeExtension($packet.FullName, '.json')) Copy-Item -LiteralPath $packet.FullName -Destination $destination Copy-Item -LiteralPath ([IO.Path]::ChangeExtension($packet.FullName, '.json')) -Destination $destination } $registrySource = Join-Path $fixtures 'AdvantageWorksMigration/level.dat' + $fixtureRecords += Get-Record $registrySource + $inputReport.recipe_fixtures = $fixtureRecords Copy-Item -LiteralPath $registrySource -Destination (Join-Path $destination 'server-registries.dat') } -$archives = foreach ($entry in $launch.launchConfiguration.listAttribute.listEntry) { +$classpathNode = @($launch.launchConfiguration.listAttribute | Where-Object { $_.key -eq 'org.eclipse.jdt.launching.CLASSPATH' }) +$archives = @(foreach ($entry in $classpathNode.listEntry) { [xml]$classpathEntry = $entry.value if ($classpathEntry.runtimeClasspathEntry.externalArchive) { $classpathEntry.runtimeClasspathEntry.externalArchive } +}) +$archiveRecords = @() +$launchArchives = @() +$runtimeCandidates = @{} +foreach ($archive in $archives) { + Assert-NativeArchive $archive + $record = Get-Record $archive + $metadata = if ([IO.Path]::GetExtension($archive) -eq '.jar') { Read-JarMetadata $archive } + $modHeader = if ($metadata) { [regex]::Match($metadata, '(?ms)^\[\[mods\]\]\s*(.*?)(?=^\[|\z)').Groups[1].Value } + if ($modHeader -match '(?m)^modId\s*=\s*"(?:steamadvantage|electricadvantage|basemetals|orespawn)"') { + # Never read mutable sibling build outputs while the client is running. + $snapshotDirectory = Join-Path $runtime 'runtime-archives' + New-Item -ItemType Directory -Path $snapshotDirectory -Force | Out-Null + $snapshot = Join-Path $snapshotDirectory ([IO.Path]::GetFileName($archive)) + Copy-Item -LiteralPath $archive -Destination $snapshot + $snapshotRecord = Get-Record $snapshot + if ($snapshotRecord.sha256 -ne $record.sha256) { throw "Dependency changed during snapshot: $archive" } + $snapshotRecord.origin_path = $record.path + $record = $snapshotRecord + } + foreach ($id in 'poweradvantage','steamadvantage','electricadvantage','basemetals','orespawn') { + if ($modHeader -match ('(?m)^modId\s*=\s*"' + $id + '"[ \t]*(?:#[^\r\n]*)?\r?$')) { + Assert-NativeMetadata $metadata $id + if ($id -eq 'poweradvantage' -or $runtimeCandidates.ContainsKey($id)) { throw "Duplicate runtime candidate: $id" } + $runtimeCandidates[$id] = $record + } + } + $archiveRecords += $record + $launchArchives += $record.path } +$archives = $launchArchives +$inputReport.archives = $archiveRecords +$inputReport.runtime_candidates = $runtimeCandidates +if (-not ($archives -match 'forge-1\.13\.2-25\.0\.223\.jar$')) { throw 'Missing actual native mapped Forge runtime.' } +if ($RequireExtensions -and (-not $runtimeCandidates.ContainsKey('steamadvantage') -or -not $runtimeCandidates.ContainsKey('electricadvantage'))) { throw 'Combined launch must explicitly contain both native expansion candidates.' } $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('"') }) if ($RequireExtensions) { $vmArguments += '-Dadvantageworks.requireExtensions=true' } +if ($MachineGuiLayouts) { $vmArguments += '-Dadvantageworks.machineGuiLayouts=true' } +$environment.MOD_CLASSES = "poweradvantage%%$modOutput;poweradvantage%%$modOutput" +$inputReport.environment = $environment +$inputReport.program_arguments = $arguments +$inputReport.vm_arguments = $vmArguments +$inputReport | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath (Join-Path $runtime 'client-input.json') -Encoding utf8 +if ($PreflightOnly) { + $inputReport.qualification = 'PREFLIGHT_ONLY_NOT_CLIENT_EVIDENCE' + Write-Output "Native launch preflight passed; no client was started. Evidence: $runtime" + return +} $previous = @{} +$phase = 'native-client' try { - foreach ($entry in $launch.launchConfiguration.mapAttribute.mapEntry) { - $previous[$entry.key] = [Environment]::GetEnvironmentVariable($entry.key, 'Process') - [Environment]::SetEnvironmentVariable($entry.key, $entry.value, 'Process') + foreach ($key in $environment.Keys) { + $previous[$key] = [Environment]::GetEnvironmentVariable($key, 'Process') + [Environment]::SetEnvironmentVariable($key, $environment[$key], 'Process') } - $env:MOD_CLASSES = "poweradvantage%%$modOutput;poweradvantage%%$modOutput" - Push-Location -LiteralPath $runtime - try { - & $java @vmArguments '-Dadvantageworks.clientResources=true' '-Xmx2G' '-cp' ((@($modOutput) + $archives) -join [IO.Path]::PathSeparator) $attributes['org.eclipse.jdt.launching.MAIN_TYPE'] @arguments - if ($LASTEXITCODE -ne 0) { throw "Client exited with code $LASTEXITCODE; inspect $runtime/logs" } - } finally { Pop-Location } + $command = @($vmArguments) + @('-Dadvantageworks.clientResources=true', '-Xmx2G', '-cp', ((@($modOutput) + $archives) -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; evidence retained." + } + $process.WaitForExit() + $process.Refresh() + if ($process.ExitCode -ne 0) { throw "Client exited with code $($process.ExitCode); inspect $runtime/logs" } } finally { foreach ($key in $previous.Keys) { [Environment]::SetEnvironmentVariable($key, $previous[$key], 'Process') } } @@ -71,9 +206,35 @@ $resultPath = Join-Path $runtime 'client-resources.json' if (-not (Test-Path -LiteralPath $resultPath)) { throw "Client did not produce a result; inspect $runtime/logs" } $result = Get-Content -LiteralPath $resultPath -Raw | ConvertFrom-Json if ($result.status -ne 'PASS') { throw "Client resource check failed: $($result.error); inspect $runtime" } +if ($result.minecraft -ne '1.13.2' -or $result.forge -ne '25.0.223') { throw 'Result is not native 1.13.2 / Forge 25.0.223 evidence.' } +if ($MachineGuiLayouts -and $result.machine_gui_layout_status -ne 'PASS') { throw 'Machine GUI layouts did not pass; inspect the retained images and JSON.' } +$expectedGuiTextChecks = if ($RequireExtensions) { 351 } else { 126 } +if ($MachineGuiLayouts -and ($result.machine_gui_text_spec -ne 2 -or $result.machine_gui_text_status -ne 'PASS' -or + $result.machine_gui_text_checks -ne $expectedGuiTextChecks -or + $result.machine_gui_negative_controls_rejected -ne ($expectedGuiTextChecks + 4))) { + throw 'Required-caption GUI contract v2 did not pass; an older no-label probe is not acceptable.' +} +if ($RecipeBookFixtures -and $result.recipe_book_grouping_status -ne 'PASS') { throw 'Native recipe fixtures were supplied but recipe-book grouping did not pass.' } +foreach ($record in $archiveRecords) { + if ((Get-FileHash -LiteralPath $record.path).Hash -ne $record.sha256) { throw "Dependency changed during the run: $($record.path)" } +} $log = Get-Content -LiteralPath (Join-Path $runtime 'logs/latest.log') -Raw $namespaces = if ($RequireExtensions) { '(?:poweradvantage|steamadvantage|electricadvantage)' } else { 'poweradvantage' } if ($log -match "Unable to load model:.*${namespaces}:|Exception loading blockstate definition:.*${namespaces}:|\[FML\.TEXTURE_ERRORS/\]") { throw "Client logged a model or texture error; inspect $runtime/logs/latest.log" } Write-Output "PASS: $($result.items) items, $($result.block_states) block states, $($result.creative_entries) creative entries. Evidence: $runtime" +if ($MachineGuiLayouts) { Write-Output "Required-caption GUI contract v2: $($result.machine_gui_text_checks) text cases passed; $($result.machine_gui_negative_controls_rejected) deliberately broken controls rejected. Images retained." } +$inputReport.qualification = 'NATIVE_CLIENT_RESOURCE_PASS' +} catch { + # Preserve native partial results and all images; record launcher failures separately. + [ordered]@{ status = 'FAIL'; phase = $phase; error = $_.Exception.Message } | ConvertTo-Json | + Set-Content -LiteralPath (Join-Path $runtime 'runner-failure.json') -Encoding utf8 + if (-not (Test-Path -LiteralPath (Join-Path $runtime 'client-resources.json'))) { + [ordered]@{ status = 'FAIL'; phase = $phase; error = $_.Exception.Message } | ConvertTo-Json | + Set-Content -LiteralPath (Join-Path $runtime 'client-resources.json') -Encoding utf8 + } + throw "Client resource check failed; inspect $runtime. $($_.Exception.Message)" +} finally { + $inputReport | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath (Join-Path $runtime 'client-input.json') -Encoding utf8 +} diff --git a/test-rig/client-resources/test-gui-contract-negative-controls.ps1 b/test-rig/client-resources/test-gui-contract-negative-controls.ps1 new file mode 100644 index 0000000..8afabcd --- /dev/null +++ b/test-rig/client-resources/test-gui-contract-negative-controls.ps1 @@ -0,0 +1,34 @@ +param([string]$ReferenceRoot = (Join-Path $PSScriptRoot '../../../../PA114/PowerAdvantage/test-rig/client-resources')) +$ErrorActionPreference = 'Stop' +$project = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path +$reference = (Resolve-Path -LiteralPath $ReferenceRoot).Path +$output = Join-Path $project ('build/client-gui-negative-controls/' + [guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $output | Out-Null +$gui = 'java/zone/moddev/mc/advantageworks/client/MachineGuiLayoutProbe.java' +$controls = @( + @{ name='glyph-count'; file=$gui; before='ink < 20'; after='ink < 0' }, + @{ name='glyph-mismatch'; file=$gui; before='missing != 0'; after='missing > 100' }, + @{ name='missing-label-control'; file=$gui; before='require(labelError(background, expected, background, screen, viewport[2]) != null,'; after='require(labelError(background, expected, background, screen, viewport[2]) == null,' }, + @{ name='inventory-hitbox'; file=$gui; before='158 + (i / 9) * 18'; after='140 + (i / 9) * 18' }, + @{ name='required-caption-version'; file='run.ps1'; before='$result.machine_gui_text_spec -ne 2'; after='$result.machine_gui_text_spec -ne 1' }, + @{ name='incomplete-caption-cases'; file='run.ps1'; before='if ($RequireExtensions) { 351 } else { 126 }'; after='if ($RequireExtensions) { 1 } else { 1 }' }, + @{ name='skipped-native-probe'; file='java/zone/moddev/mc/advantageworks/client/ClientResourceProbe.java'; before='MachineGuiLayoutProbe.verify(minecraft, result);'; after='' }, + @{ name='wrong-machine-caption'; file='resources/gui-label-expectations.json'; before='Metal Fluid Tank'; after='Machine' } +) +$results = @() +foreach ($control in $controls) { + $fixture = Join-Path $output $control.name + Copy-Item -LiteralPath $PSScriptRoot -Destination $fixture -Recurse + $path = Join-Path $fixture $control.file + $source = [IO.File]::ReadAllText($path) + if (-not $source.Contains($control.before)) { throw "Negative control has no exact mutation point: $($control.name)" } + [IO.File]::WriteAllText($path, $source.Replace($control.before, $control.after), [Text.UTF8Encoding]::new($false)) + $rejected = $false + $failure = $null + try { & (Join-Path $fixture 'test-gui-contract.ps1') -ReferenceRoot $reference | Out-Null } + catch { $rejected = $true; $failure = $_.Exception.Message } + $results += [ordered]@{ control=$control.name; expected='REJECT'; rejected=$rejected; error=$failure } + if (-not $rejected) { throw "A weakened GUI oracle was accepted: $($control.name). Evidence: $output" } +} +$results | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath (Join-Path $output 'results.json') -Encoding utf8 +Write-Output "PASS: $($controls.Count) weakened GUI oracles rejected. Original source and worlds unchanged. Evidence: $output" 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..097df16 --- /dev/null +++ b/test-rig/client-resources/test-gui-contract.ps1 @@ -0,0 +1,193 @@ +param([string]$ReferenceRoot = (Join-Path $PSScriptRoot '../../../../PA114/PowerAdvantage/test-rig/client-resources')) +$ErrorActionPreference = 'Stop' +$ReferenceRoot = (Resolve-Path -LiteralPath $ReferenceRoot).Path +$relative = 'java/zone/moddev/mc/advantageworks/client/MachineGuiLayoutProbe.java' +$checks = 0 +function Assert-Contract([bool]$Condition, [string]$Message) { + if (-not $Condition) { throw $Message } + $script:checks++ +} +function Convert-Gui([string]$Source) { + # Only native API names and constructor setup differ. Every oracle remains. + $pairs = @' +[ + [ + "import com.mojang.blaze3d.platform.GlStateManager;", + "import net.minecraft.client.renderer.GlStateManager;\nimport org.lwjgl.opengl.GL11;" + ], + [ + "net.minecraft.client.entity.player.ClientPlayerEntity", + "net.minecraft.client.entity.EntityPlayerSP" + ], + [ + "ClientPlayerEntity", + "EntityPlayerSP" + ], + [ + "net.minecraft.client.world.ClientWorld", + "net.minecraft.client.multiplayer.WorldClient" + ], + [ + "ClientWorld", + "WorldClient" + ], + [ + "PlayerEntity", + "EntityPlayer" + ], + [ + "PlayerInventory", + "InventoryPlayer" + ], + [ + "net.minecraft.inventory.container.Slot", + "net.minecraft.inventory.Slot" + ], + [ + "TranslationTextComponent", + "TextComponentTranslation" + ], + [ + "StringTextComponent", + "TextComponentString" + ], + [ + "net.minecraft.block.BlockState", + "net.minecraft.block.state.IBlockState" + ], + [ + "net.minecraft.fluid.Fluids", + "net.minecraft.init.Fluids" + ], + [ + "net.minecraft.item.Items", + "net.minecraft.init.Items" + ], + [ + "BlockPos.ZERO", + "BlockPos.ORIGIN" + ], + [ + "getDeclaredField(\"DEFINITIONS\")", + "getDeclaredField(\"guiTable\")" + ], + [ + ".init(minecraft,", + ".setWorldAndResolution(minecraft," + ], + [ + "screen.getContainer()", + "screen.inventorySlots" + ], + [ + "new Framebuffer(width, height, true, Minecraft.IS_RUNNING_ON_MAC)", + "new Framebuffer(width, height, true)" + ], + [ + "GlStateManager.clear(16384 | 256, Minecraft.IS_RUNNING_ON_MAC)", + "GlStateManager.clear(16384 | 256)" + ], + [ + "GlStateManager.readPixels(0, 0, width, height, 6408, 5121, pixels)", + "GL11.glReadPixels(0, 0, width, height, 6408, 5121, pixels)" + ], + [ + "super(null, null, net.minecraft.world.dimension.DimensionType.OVERWORLD, 0, net.minecraft.profiler.EmptyProfiler.INSTANCE, null);", + "super(null, null, net.minecraft.world.dimension.DimensionType.OVERWORLD, net.minecraft.world.EnumDifficulty.NORMAL, new net.minecraft.profiler.Profiler());" + ], + [ + "font.", + "fontRenderer." + ], + [ + "public void renderTooltip(net.minecraft.item.ItemStack item, int x, int y)", + "public void renderToolTip(net.minecraft.item.ItemStack item, int x, int y)" + ], + [ + "super.renderTooltip(item, x, y)", + "super.renderToolTip(item, x, y)" + ], + [ + "public void renderTooltip(String text, int x, int y)", + "public void drawHoveringText(String text, int x, int y)" + ], + [ + "super.renderTooltip(text, x, y)", + "super.drawHoveringText(text, x, y)" + ], + [ + "public void blit(int x, int y, int u, int v, int width, int height)", + "public void drawTexturedModalRect(int x, int y, int u, int v, int width, int height)" + ], + [ + "super.blit(x, y, u, v, width, height)", + "super.drawTexturedModalRect(x, y, u, v, width, height)" + ], + [ + "((SimpleMachineGUI) definition).createContainer(1, inventory, (IInventory) tile)", + "((SimpleMachineGUI) definition).new Container(inventory, (IInventory) tile)" + ], + [ + "ITextComponent title, String expectedTitle, String expectedInventory) {", + "ITextComponent title, String expectedTitle, String expectedInventory) throws Exception {" + ], + [ + "definition.super(menu, inventory, title);", + "definition.super(inventory, inventoryOf(menu));\n this.inventorySlots = menu;" + ] +] +'@ | ConvertFrom-Json + $Source = $Source.Replace("`r`n", "`n") + foreach ($pair in $pairs) { $Source = $Source.Replace($pair[0], $pair[1]) } + $Source = $Source.Replace(' private static net.minecraft.util.text.ITextComponent menuTitle(', ' 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(') + # 1.13 has no named-screen constructor. The production constructor receives + # the same real inventory; long names belong to its tile, not an oracle label. + $Source = $Source.Replace(' RecordingScreen screen = new RecordingScreen(definition, menu, inventory, + new net.minecraft.util.text.TextComponentString(name), name, "Inventory");', ' cyano.poweradvantage.api.simple.TileEntitySimpleFluidMachine named = + (cyano.poweradvantage.api.simple.TileEntitySimpleFluidMachine) inventoryOf(menu); + net.minecraft.util.text.ITextComponent previousName = named.getCustomName(); + named.setCustomInventoryName(name); + try { + RecordingScreen screen = new RecordingScreen(definition, menu, inventory, + new net.minecraft.util.text.TextComponentString(name), name, "Inventory");') + $Source.Replace(' ImageIO.write(hover, "png", new File("machine-gui-layouts/metal-tank-long-name-tooltip.png")); + }', ' ImageIO.write(hover, "png", new File("machine-gui-layouts/metal-tank-long-name-tooltip.png")); + } finally { + named.setCustomInventoryName(previousName == null ? null : previousName.getString()); + } + }') +} +function Canonical-Source([string]$Source) { + $Source = $Source.Replace("`r`n", "`n") + $Source = [regex]::Replace($Source, '(?m)^import [^\r\n]+\r?\n', '') + $Source = [regex]::Replace($Source, '/\*[\s\S]*?\*/|//[^\r\n]*', '') + [regex]::Replace($Source, '\s+', '') +} +$reference = Join-Path $ReferenceRoot $relative +Assert-Contract ((Get-FileHash -LiteralPath $reference -Algorithm SHA256).Hash -eq 'E669E62922A7F5008498069E778808BEA28BDCB1E0FB933FD15845A5D9AE810A') 'GUI reference changed; do not substitute another accepted oracle.' +$expected = Canonical-Source (Convert-Gui (Get-Content -LiteralPath $reference -Raw)) +$actual = Canonical-Source (Get-Content -LiteralPath (Join-Path $PSScriptRoot $relative) -Raw) +Assert-Contract ($actual -ceq $expected) 'GUI fixtures, captions, pixels, slot coordinates, helper bounds or controls changed beyond the declared 1.13 native adaptation.' +$labels = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'resources/gui-label-expectations.json') -Raw | ConvertFrom-Json +$referenceLabels = Get-Content -LiteralPath (Join-Path $ReferenceRoot 'resources/gui-label-expectations.json') -Raw | ConvertFrom-Json +Assert-Contract (($labels | ConvertTo-Json -Depth 6 -Compress) -ceq ($referenceLabels | ConvertTo-Json -Depth 6 -Compress)) 'Independent equipment names or label rows changed.' +Assert-Contract (@($labels.machines.PSObject.Properties).Count -eq 39) 'Exactly 39 human-reviewed equipment names are required.' +Assert-Contract ($labels.locale -eq 'en_us' -and $labels.inventory -ceq 'Inventory') 'Required literal captions are missing.' +$probe = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'java/zone/moddev/mc/advantageworks/client/ClientResourceProbe.java') -Raw +Assert-Contract ($probe.Contains('MachineGuiLayoutProbe.verify(minecraft, result);')) 'Native client must dispatch the complete GUI probe.' +$runner = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'run.ps1') -Raw +foreach ($literal in '[switch]$MachineGuiLayouts', '-Dadvantageworks.machineGuiLayouts=true', + '$result.machine_gui_layout_status -ne ''PASS''', '$result.machine_gui_text_spec -ne 2', + '$result.machine_gui_text_status -ne ''PASS''', '$result.machine_gui_text_checks -ne $expectedGuiTextChecks', + '$result.machine_gui_negative_controls_rejected -ne ($expectedGuiTextChecks + 4)', + '$expectedGuiTextChecks = if ($RequireExtensions) { 351 } else { 126 }') { + Assert-Contract ($runner.Contains($literal)) "Native runner lost a required GUI acceptance gate: $literal" +} +Assert-Contract ($runner.Contains('runtime-archives') -and $runner.Contains('Dependency changed during snapshot:')) 'Combined client must snapshot mutable development dependencies.' +Write-Output "PASS: $checks GUI oracle preservation and native runner gates. Native caption/layout rendering NOT RUN." diff --git a/test-rig/client-resources/test-launcher.ps1 b/test-rig/client-resources/test-launcher.ps1 new file mode 100644 index 0000000..25f1302 --- /dev/null +++ b/test-rig/client-resources/test-launcher.ps1 @@ -0,0 +1,149 @@ +param([switch]$StaticOnly) +$ErrorActionPreference = 'Stop' +$tokens = $null +$errors = $null +$script = Join-Path $PSScriptRoot 'run.ps1' +$ast = [Management.Automation.Language.Parser]::ParseFile($script, [ref]$tokens, [ref]$errors) +if ($errors.Count) { throw ($errors | Out-String) } +# Load helper definitions only. Never execute the runner or start Java here. +foreach ($definition in $ast.FindAll({ param($node) $node -is [Management.Automation.Language.FunctionDefinitionAst] }, $false)) { + . ([scriptblock]::Create($definition.Extent.Text)) +} +Add-Type -AssemblyName System.IO.Compression.FileSystem +$checks = 1 +function Assert-Throws([scriptblock]$Action) { + $threw = $false + try { & $Action } catch { $threw = $true } + if (-not $threw) { throw 'Expected invalid native metadata to be rejected.' } + $script:checks++ +} +function Assert-GuiEvidenceRoot([Management.Automation.Language.ScriptBlockAst]$RunnerAst) { + $assignments = @($RunnerAst.FindAll({ param($node) $node -is [Management.Automation.Language.AssignmentStatementAst] }, $true)) + $projects = @($assignments | Where-Object { $_.Left.Extent.Text -eq '$project' }) + $runtimes = @($assignments | Where-Object { $_.Left.Extent.Text -eq '$runtime' }) + if ($projects.Count -ne 1 -or $runtimes.Count -ne 1) { throw 'GUI evidence needs one unambiguous project/runtime assignment.' } + $projectExpression = [regex]::Replace($projects[0].Right.Extent.Text, '\s+', '') + if ($projectExpression -cne "(Resolve-Path(Join-Path`$PSScriptRoot'../..')).Path") { + throw 'GUI evidence must be based at the runner project root, not a build directory.' + } + $runtimeExpression = [regex]::Replace($runtimes[0].Right.Extent.Text, '\s+', '') + if (-not $runtimeExpression.StartsWith("Join-Path`$project('run-client-resource-runs/'+", [StringComparison]::Ordinal)) { + throw 'GUI captures must use project/run-client-resource-runs, outside Gradle clean output.' + } + $variables = @($runtimes[0].Right.FindAll({ param($node) $node -is [Management.Automation.Language.VariableExpressionAst] }, $true)) + if (@($variables | Where-Object { $_.VariablePath.UserPath -ne 'project' }).Count) { + throw 'GUI evidence suffix must not introduce a different root variable.' + } + $literals = @($runtimes[0].Right.FindAll({ param($node) $node -is [Management.Automation.Language.StringConstantExpressionAst] }, $true)) + foreach ($literal in $literals) { + if ($literal.Value -ceq 'run-client-resource-runs/') { continue } + if ($literal.Value -match '[\\/:]|\.\.') { throw 'GUI evidence suffix contains a path or traversal component.' } + } +} + +function Test-GuiEvidenceRootContract([string]$RunnerSource) { + $rootTokens = $null; $rootErrors = $null + $runnerAst = [Management.Automation.Language.Parser]::ParseInput($RunnerSource, [ref]$rootTokens, [ref]$rootErrors) + if ($rootErrors.Count) { throw 'GUI evidence source must parse before its path is audited.' } + Assert-GuiEvidenceRoot $runnerAst + $script:checks++ + foreach ($replacement in "'build/client-resource-runs/'", "'run-client-resource-runs/../build/'", "'C:/build/client-resource-runs/'") { + $broken = $RunnerSource.Replace("'run-client-resource-runs/'", $replacement) + if ($broken -ceq $RunnerSource) { throw 'Evidence-root negative control did not mutate the source.' } + $brokenAst = [Management.Automation.Language.Parser]::ParseInput($broken, [ref]$rootTokens, [ref]$rootErrors) + if ($rootErrors.Count) { throw 'Evidence-root negative control must remain valid PowerShell.' } + Assert-Throws { Assert-GuiEvidenceRoot $brokenAst } + } + $broken = $RunnerSource.Replace("'../..'", "'../../build'") + if ($broken -ceq $RunnerSource) { throw 'Project-root negative control did not mutate the source.' } + $brokenAst = [Management.Automation.Language.Parser]::ParseInput($broken, [ref]$rootTokens, [ref]$rootErrors) + if ($rootErrors.Count) { throw 'Project-root negative control must remain valid PowerShell.' } + Assert-Throws { Assert-GuiEvidenceRoot $brokenAst } +} + +Test-GuiEvidenceRootContract (Get-Content -LiteralPath $script -Raw) + +$native = @' +modLoader="javafml" +loaderVersion="[25,)" # native Forge +[[mods]] # mandatory header +modId="poweradvantage" # inline comment +version="2.4.4.113021" # native candidate +[[dependencies.poweradvantage]] +modId="minecraft" +mandatory=true +versionRange="[1.13.2]" +'@ +Assert-NativeMetadata $native 'poweradvantage' +$checks++ +Assert-Throws { Assert-NativeMetadata ($native.Replace('2.4.4.113021','2.4.3.113021')) 'poweradvantage' } +Assert-Throws { Assert-NativeMetadata ($native.Replace('2.4.4.113021','2.4.4.114041')) 'poweradvantage' } +Assert-Throws { Assert-NativeMetadata ($native.Replace('2.4.4.113021','2.4.4.115021')) 'poweradvantage' } +Assert-Throws { Assert-NativeMetadata ($native.Replace('[25,)','[28,)')) 'poweradvantage' } +Assert-Throws { Assert-NativeMetadata ($native.Replace('2.4.4.113021','${version}')) 'poweradvantage' } +Assert-Throws { Assert-NativeMetadata $native 'electricadvantage' } +Assert-Throws { Assert-NativeMetadata '' 'poweradvantage' } +foreach ($id in 'steamadvantage','electricadvantage') { + $expansion = $native.Replace('poweradvantage', $id).Replace('2.4.4.113021','2.2.5.113021') + Assert-NativeMetadata $expansion $id + $checks++ + Assert-Throws { Assert-NativeMetadata ($expansion.Replace('2.2.5.113021','2.2.5.114041')) $id } +} +$environment = @{ MC_VERSION = '1.13.2'; FORGE_VERSION = '25.0.223'; MCP_MAPPINGS = 'snapshot_20180921-1.13'; MCP_VERSION = '20190213.203750'; target = 'fmluserdevclient' } +$attributes = @{ 'org.eclipse.jdt.launching.PROGRAM_ARGUMENTS' = '--main net.minecraftforge.userdev.LaunchTesting --cache D:\native\1.13.2-25.0.223 --'; 'org.eclipse.jdt.launching.MAIN_TYPE' = 'net.minecraftforge.launcher.Main' } +Assert-NativeLaunch $environment $attributes +$checks++ +foreach ($key in 'MC_VERSION','FORGE_VERSION','MCP_MAPPINGS','MCP_VERSION','target') { + $invalid = $environment.Clone() + $invalid[$key] = 'legacy' + Assert-Throws { Assert-NativeLaunch $invalid $attributes } +} +foreach ($argument in '--server localhost','--port 25565','--gameDir D:\protected','--world protected','--cache D:\native\1.15.2-31.2.57') { + $invalid = $attributes.Clone() + $invalid['org.eclipse.jdt.launching.PROGRAM_ARGUMENTS'] += ' ' + $argument + Assert-Throws { Assert-NativeLaunch $environment $invalid } +} +$invalid = $attributes.Clone() +$invalid['org.eclipse.jdt.launching.MAIN_TYPE'] = 'net.minecraft.client.main.Main' +Assert-Throws { Assert-NativeLaunch $environment $invalid } +Assert-NativeArchive 'D:\native\forge-1.13.2-25.0.223.jar' +$checks++ +foreach ($path in 'D:\PA115\client.jar','D:\PA114\client.jar','D:\native\forge-1.16.5-36.2.34.jar','D:\native\client-extra-1.14.4.jar') { + Assert-Throws { Assert-NativeArchive $path } +} +if ((Quote-Argument 'D:\native path\') -ne '"D:\native path\\"') { throw 'Trailing-backslash command-line quoting changed.' } +$checks++ +if ((Quote-Argument 'text"quoted') -ne '"text\"quoted"') { throw 'Embedded-quote command-line quoting changed.' } +$checks++ +$starts = @($ast.FindAll({ param($node) $node -is [Management.Automation.Language.CommandAst] -and $node.GetCommandName() -eq 'Start-Process' }, $true)) +if ($starts.Count -ne 1 -or $starts[0].Extent.Text -notmatch '-WindowStyle Hidden') { throw 'Native helper must launch hidden.' } +$checks++ +if ($StaticOnly) { + $reports = @($ast.FindAll({ param($node) $node -is [Management.Automation.Language.AssignmentStatementAst] -and $node.Left.Extent.Text -eq '$inputReport' }, $true)) + if ($reports.Count -ne 1 -or $reports[0].Right.Extent.Text -notmatch "minecraft = '1\.13\.2'" -or $reports[0].Right.Extent.Text -notmatch "mcp = '20190213\.203750'") { throw 'The native input report must be initialized with the exact target before preflight.' } + $checks++ + Write-Output "PASS: $checks static launcher parser, target guards and quoting checks. Native candidate/launch checks NOT RUN. No Java/client/world was started." + return +} +$project = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path +$power = Join-Path $project 'build/libs-dev/PowerAdvantage-2.4.4.113021-deobf.jar' +Assert-NativeMetadata (Read-JarMetadata $power) 'poweradvantage' +$checks++ +[xml]$launch = Get-Content -LiteralPath (Join-Path $project 'runClientAllAdvantage.launch') -Raw +$found = @{} +foreach ($entry in ($launch.launchConfiguration.listAttribute | Where-Object { $_.key -eq 'org.eclipse.jdt.launching.CLASSPATH' }).listEntry) { + [xml]$classpath = $entry.value + $path = $classpath.runtimeClasspathEntry.externalArchive + if (-not $path -or [IO.Path]::GetExtension($path) -ne '.jar') { continue } + $metadata = Read-JarMetadata $path + $header = if ($metadata) { [regex]::Match($metadata, '(?ms)^\[\[mods\]\]\s*(.*?)(?=^\[|\z)').Groups[1].Value } + foreach ($id in 'basemetals','orespawn','steamadvantage','electricadvantage') { + if ($header -match ('(?m)^modId\s*=\s*"' + $id + '"[ \t]*(?:#[^\r\n]*)?\r?$')) { + Assert-NativeMetadata $metadata $id + $found[$id] = $true + $checks++ + } + } +} +if ($found.Count -ne 4) { throw 'The actual combined launch must expose all four native dependency/expansion candidates.' } +Write-Output "PASS: $checks launcher parser, metadata, native input and quoting checks. No Java/client/world was started." diff --git a/test-rig/client-resources/test-probe-contract-negative-controls.ps1 b/test-rig/client-resources/test-probe-contract-negative-controls.ps1 new file mode 100644 index 0000000..f573ed4 --- /dev/null +++ b/test-rig/client-resources/test-probe-contract-negative-controls.ps1 @@ -0,0 +1,30 @@ +param([string]$ReferenceRoot = (Join-Path $PSScriptRoot '../..')) +$ErrorActionPreference = 'Stop' +$project = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path +$reference = (Resolve-Path -LiteralPath $ReferenceRoot).Path +$output = Join-Path $project ('build/client-probe-negative-controls/' + [guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $output | Out-Null +$relative = 'java/zone/moddev/mc/advantageworks/client' +$controls = @( + @{ name='recipe-pixel-threshold'; file='RecipeBookGroupingProbe'; before='colored > 1000'; after='colored > 100' }, + @{ name='tank-packet-quantity'; file='TankVisualProbe'; before='clientTank.getTank().getFluidAmount() == 10000'; after='clientTank.getTank().getFluidAmount() == 1000' }, + @{ name='bucket-fluid-identity'; file='OilRenderProbe'; before='field.get(bucket) == expected'; after='field.get(bucket) != expected' }, + @{ name='missing-model'; file='ClientResourceProbe'; before='require(model != missing,'; after='require(model == missing,' } +) +$results = @() +foreach ($control in $controls) { + $fixture = Join-Path $output $control.name + Copy-Item -LiteralPath $PSScriptRoot -Destination $fixture -Recurse + $path = Join-Path $fixture "$relative/$($control.file).java" + $source = [IO.File]::ReadAllText($path) + if (-not $source.Contains($control.before)) { throw "Negative control has no exact mutation point: $($control.name)" } + [IO.File]::WriteAllText($path, $source.Replace($control.before, $control.after), [Text.UTF8Encoding]::new($false)) + $rejected = $false + $failure = $null + try { & (Join-Path $fixture 'test-probe-contract.ps1') -ReferenceRoot $reference | Out-Null } + catch { $rejected = $true; $failure = $_.Exception.Message } + $results += [ordered]@{ control=$control.name; expected='REJECT'; rejected=$rejected; error=$failure } + if (-not $rejected) { throw "A weakened original probe was accepted: $($control.name). Evidence: $output" } +} +$results | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath (Join-Path $output 'results.json') -Encoding utf8 +Write-Output "PASS: $($controls.Count) weakened original-probe controls rejected. Source/worlds unchanged. Evidence: $output" diff --git a/test-rig/client-resources/test-probe-contract.ps1 b/test-rig/client-resources/test-probe-contract.ps1 new file mode 100644 index 0000000..e79989f --- /dev/null +++ b/test-rig/client-resources/test-probe-contract.ps1 @@ -0,0 +1,48 @@ +param([string]$ReferenceRoot = (Join-Path $PSScriptRoot '../..')) +$ErrorActionPreference = 'Stop' +$ReferenceRoot = (Resolve-Path -LiteralPath $ReferenceRoot).Path +$referenceCommit = '08a002787f149e1bf7c85e9974cd281c167c212c' +$checks = 0 +function Assert-Contract([bool]$Condition, [string]$Message) { + if (-not $Condition) { throw $Message } + $script:checks++ +} +function Canonical-Source([string]$Source) { + $Source = $Source.Replace("`r`n", "`n") + $Source = [regex]::Replace($Source, '(?m)^import [^\r\n]+\r?\n', '') + $Source = [regex]::Replace($Source, '/\*[\s\S]*?\*/|//[^\r\n]*', '') + [regex]::Replace($Source, '\s+', '') +} +$resolved = & git -C $ReferenceRoot rev-parse "$referenceCommit`^{commit}" +if ($LASTEXITCODE -ne 0) { throw 'The frozen native 1.13 source commit is unavailable.' } +Assert-Contract ($resolved.Trim() -eq $referenceCommit) 'Do not silently replace the native 1.13 reference.' +$relative = 'java/zone/moddev/mc/advantageworks/client' +foreach ($name in 'ClientResourceProbe','TankVisualProbe','OilRenderProbe','RecipeBookGroupingProbe') { + $lines = & git -C $ReferenceRoot show "$referenceCommit`:test-rig/client-resources/$relative/$name.java" + if ($LASTEXITCODE -ne 0) { throw "Missing frozen native probe: $name" } + $source = ($lines -join "`n") + $target = Get-Content -LiteralPath (Join-Path $PSScriptRoot "$relative/$name.java") -Raw + $expected = Canonical-Source $source + $actual = Canonical-Source $target + if ($name -eq 'ClientResourceProbe') { + foreach ($assertion in [regex]::Matches($source, '(?s)\brequire\(.*?\);')) { + Assert-Contract ($actual.Contains((Canonical-Source $assertion.Value))) "$name lost an original assertion: $($assertion.Value)" + } + foreach ($literal in [regex]::Matches($source, '"[^"\r\n]*(?::|\.png|\.bin|\.dat)[^"\r\n]*"')) { + Assert-Contract ($actual.Contains((Canonical-Source $literal.Value))) "$name changed a resource or diagnostic literal: $($literal.Value)" + } + } else { + Assert-Contract ($actual -ceq $expected) "$name changed its original native 1.13 fixtures, pixels, packet quantities or resource identities." + } +} +$pack = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'resources/pack.mcmeta') -Raw | ConvertFrom-Json +Assert-Contract ($pack.pack.pack_format -eq 4) 'The native 1.13 probe resource pack must keep format 4.' +$metadata = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'resources/META-INF/mods.toml') -Raw +foreach ($pin in 'loaderVersion="[25,)"','versionRange="[2.4.4.113021,)"') { + Assert-Contract ($metadata.Contains($pin)) "Missing original native probe metadata: $pin" +} +$runner = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'run.ps1') -Raw +$failure = [regex]::Match($runner, '(?s)\} catch \{\s*# Preserve native partial results.*?\} finally \{').Value +Assert-Contract ($failure.Contains('runner-failure.json') -and $failure.Contains('if (-not (Test-Path')) 'Retain partial native results; do not replace them with a runner failure.' +Assert-Contract ($runner -notmatch '\b(?:Remove-Item|Clear-Content)\b') 'The runner must not delete old or failed evidence.' +Write-Output "PASS: $checks original native 1.13 probe preservation checks. Compilation and graphical acceptance NOT RUN." diff --git a/test-rig/migration/BROAD-RESULT-AUDIT.md b/test-rig/migration/BROAD-RESULT-AUDIT.md new file mode 100644 index 0000000..1e524a6 --- /dev/null +++ b/test-rig/migration/BROAD-RESULT-AUDIT.md @@ -0,0 +1,59 @@ +# Broad Campaign Result Audit + +Historical `native113_broad.py` through `native116_broad.py` progress writers +could write top-level `PASS` after the first profile finished, even when both +profiles were requested. A later crash could leave that misleading subset report. +The maintained writers now record `RUNNING` until the requested profiles are +complete, and retain `FAIL` plus earlier evidence if a later profile crashes. +The independent offline auditor is still required: a writer status alone does +not prove complete raw checks, artifact custody, or shutdown. + +Run from the PowerAdvantage checkout, using the migration Python environment: + +```powershell +python -B test-rig/migration/broad_result_audit.py --runtime --line 113 +``` + +Use line `114`, `115`, or `116` for the corresponding engine. Both +`packaged-current` and `packaged-decoupled` are required by default. Only use +`--profiles packaged-current` or `--profiles packaged-decoupled` when that was +the explicitly requested campaign. The auditor never infers intended coverage +from the subset of results it happens to find. Missing, extra, unfinished, or +failed profiles return exit code 1. + +The auditor reads but does not modify results, worlds, jars, or runner scripts. +Its JSON output binds the inspected evidence by SHA-256. It checks: + +- the requested profile set, local results, all five phases, and unmasked errors; +- all 21 stations against the definition inside the pinned harness jar; +- every raw assertion ID, type, result, available-mod condition, and summary count; +- retained manual instructions and truthful `MANUAL_REQUIRED` versus `SKIP`; +- raw drill SNBT, six facings per drill, inventory witnesses, movement and cold state; +- loaded mod identities, staged jar bytes, launcher and sealed engine inventory when supplied; +- native startup and all-dimension save/shutdown evidence for both boots; +- unchanged evidence throughout the audit, with existing narrow native fatal classifications. + +Station resource hashes may differ only by LF/CRLF normalization during Gradle +resource processing. No semantic changes or omitted definitions are accepted. + +This is bounded broad-sweep proof, not a replacement for operating, conservation, +harvest, migration, endurance, GUI or manual gameplay tests. Manual procedures +are not automated passes. Historical broad output does not independently retain +the operating-system process exit code; the auditor states that gap explicitly +and verifies the available save/shutdown logs instead of inventing an exit receipt. +Forge 25 issues an explicit `save-all flush`; the later broad protocols use the +native stop autosave and retain completed chunk-storage saves for all dimensions. +The audit follows those actual protocols instead of inventing an extra command. + +Focused controls: + +```powershell +python -B -m unittest discover -s test-rig/migration -p test_broad_result_audit.py -v +``` + +The writer-only status repair was applied after all in-flight broad campaigns +finished and the coordinating agent cleared those files. Offline replay of the +archived native evidence verifies the repaired JSON writer without rerunning or +rebuilding worlds. Historical native receipts keep their original protocol hashes; +separate replay receipts bind the old and new hashes. Geometry, actors and native +assertions are unchanged. diff --git a/test-rig/migration/EXTENDED-ABSENCE-CORRECTION.md b/test-rig/migration/EXTENDED-ABSENCE-CORRECTION.md new file mode 100644 index 0000000..fb1ece6 --- /dev/null +++ b/test-rig/migration/EXTENDED-ABSENCE-CORRECTION.md @@ -0,0 +1,89 @@ +# Continuous Absence Validation Correction + +The extended-network validator had two test defects. It checked only the gap +between the final two observations, so a continuously absent endpoint observed +for 189 native ticks failed when the final gap was 38 ticks. It also failed to +compare load/unload counters between absent observations, allowing an unseen +reload to escape that check. + +The corrected validator requires at least **40 native ticks** from the first +genuine absent observation through the terminal confirmation. Every observation +in that segment must remain absent with identical native load/unload counters. +Observed reloads, hidden reloads, malformed clocks and missing observations fail. +A later stable suffix cannot erase an intervening reload. + +Retained endpoints must stay loaded with unchanged counters and tile identities. +The subsequent reload requires a new load callback after the absence interval. +Existing block/state, inventory, fluid, movement, energy and restart expectations +are unchanged. No Java, gameplay, fixture, timing, or native transport change was +made. + +## Evidence And Scope + +The preserved 1.14 holder-extended failure records these native times: + +```text +1315, 1336, 1357, 1378, 1399, 1422, 1444, 1466, 1504 +``` + +All nine observations show the three selected endpoints absent, each with +`loads=1, unloads=1`. Their retained endpoints stay loaded at +`loads=1, unloads=0`. Recovery produces real `loads=2, unloads=1` callbacks. +The span is 189 ticks, not merely the last adjacent gap of 38. + +Twenty new continuity controls first reproduced nine failures and one missing +sample error per line against the old helper. All 33 continuity/existing +extended controls then passed per line. Eight additional independent event-vector +controls bring the final focused set to **41 passing tests per line**. + +Separate exact-pin raw re-audits cover the combined 1.13 extended campaign and +the 1.14, 1.15 and 1.16 holder-extended campaigns. Each includes fresh and imported +worlds, eighteen drill routes, twelve partial-network cases, reload ordering, +blocked output, restart, redstone, processing and fluid conservation. + +Only three historical verdicts change: the fresh 1.14 first machine-only unload +cases. Their amended qualification passes; the original native report remains +`FAIL`. All other original verdicts remain unchanged. These are amended-validator +replays of existing measurements, not new native runs. + +The later `extended-reads` campaigns are a distinct eighteen-route suite. +Their original reports are retained and are not relabelled by this correction. +A separate native 1.14 rerun is coordinated independently. + +## Frozen Helpers + +| Lines | Original SHA-256 | Corrected SHA-256 | +|---|---|---| +| 113 / 114 | `4EC8EBA4B808C81DCCE6B09C784B92CA7E770022F8E38FE760D60665B1F64934` | `9CE7D28FD5DFFD8BD65212F286055FB890E15BFB9C5432F5E8265FD99BE6E70B` | +| 115 / 116 | `8A1E84C3E696EFAC711D3D0D847DD532253F57BFEF1A7BC93EF5529605A6280B` | `EF746EEC9B21E5D9757684A5B1AD6387706EBCD7E2CB15D020234E9B4D57DC82` | + +The validator function AST is identical across these four copies. Existing +line-ending differences account for the separate byte hashes. The 1.10 and +1.12 adapters do not contain this helper: their event-driven validators require +240-tick absence and separate 220-tick observations/isolation. They were not +changed. + +## Offline Re-Audit + +```powershell +python -B test-rig/migration/audit_extended_absence.py --runtime --line 114 --manifest --old-helper +``` + +The auditor prints a new receipt; it never edits a native result, world or +expected state. It binds old/new helper hashes, its own hash, every raw input, +the exact manifest, staged jars and sealed engine inventory. It reproduces the +old verdicts before evaluating the correction. Its independent interval proof +does not call the amended helper. + +Original native source hashes remain authoritative for the historical runs. +Do not replace them with the corrected helper hash or claim byte-identical +historical behavior. In 1.15/1.16, `audited-semantic-corrections.json` records the +narrow approved semantic correction and guard-only test update while preserving +the original historical-input ledgers. Other historical byte checks remain exact; +unchanged-code and corrected-function AST fingerprints prevent a byte-hash update +alone from approving a lower threshold or an unrelated oracle edit. + +All retained failed and intermediate audit receipts remain available. An initial +offline auditor assumption required two logs for every profile; native imported +holder worlds legitimately have a third frozen-import boot. The corrected auditor +requires the exact two-or-three boot set rather than discarding the import log. diff --git a/test-rig/migration/audit_extended_absence.py b/test-rig/migration/audit_extended_absence.py new file mode 100644 index 0000000..4e76c9a --- /dev/null +++ b/test-rig/migration/audit_extended_absence.py @@ -0,0 +1,202 @@ +"""Offline amended-validator audit. Original native reports are never rewritten.""" +import argparse +import ast +from collections import Counter +import json +import math +from pathlib import Path + +import extended_lifecycle as current +from broad_result_audit import Evidence, VERSIONS, require, sha, staged_pins +from lifecycle import at, counts, parse + + +def old_partial(evidence, path, expected): + data = evidence.read(path) + require(sha(data) == expected, "Archived old helper hash mismatch") + old_tree = ast.parse(data.decode("utf-8-sig")) + new_path = Path(current.__file__) + new_tree = ast.parse(evidence.read(new_path).decode("utf-8-sig")) + def unchanged(tree): + return ast.dump(ast.Module(body=[n for n in tree.body if not ( + isinstance(n, ast.FunctionDef) and n.name in ("partial_result", "continuous_absence"))], type_ignores=[])) + require(unchanged(old_tree) == unchanged(new_tree), "Changes outside pure absence validators") + function = next(n for n in old_tree.body if isinstance(n, ast.FunctionDef) and n.name == "partial_result") + namespace = dict(vars(current)) + exec(compile(ast.Module(body=[function], type_ignores=[]), str(path), "exec"), namespace) + return namespace["partial_result"] + + +def interval_proof(fixture, before, samples, group): + """Independent event-vector proof; do not call the amended validator.""" + selected = [tuple(p) for p in fixture["groups"][group]] + baseline = {tuple(c["pos"]): c for c in before["chunks"]} + points = [] + for sample in samples: + rows = {tuple(c["pos"]): c for c in sample["chunks"]} + require(all(p in rows for p in selected), "Missing selected raw chunk") + points.append(dict(tick=sample["worldTime"], absent=all(rows[p]["loaded"] is False + and rows[p]["unloads"] > baseline[p]["unloads"] for p in selected), + counters=[(rows[p]["loads"], rows[p]["unloads"]) for p in selected])) + start = next((i for i, point in enumerate(points) if point["absent"]), None) + ordered = all(type(point["tick"]) is int for point in points) and all(a["tick"] <= b["tick"] for a, b in zip(points, points[1:])) + segment = points[start:] if start is not None else [] + event_stable = bool(segment) and all(point["absent"] and point["counters"] == segment[0]["counters"] for point in segment) + span = segment[-1]["tick"] - segment[0]["tick"] if segment else 0 + return dict(status="PASS" if ordered and event_stable and span >= 40 else "FAIL", + firstAbsentTick=segment[0]["tick"] if segment else None, confirmedTick=points[-1]["tick"], + nativeTicks=span, lastAdjacentTicks=points[-1]["tick"]-points[-2]["tick"], + observedEventCountsUnchanged=event_stable, selectedChunks=[list(p) for p in selected], + anchorEventCounts=segment[0]["counters"] if segment else [], samples=len(points)) + + +def compute(definition, snapshot, partial): + result = dict(insufficient_energy=current.reserve_results(definition, snapshot("low-before"), snapshot("low-stalled")), + moves=current.movement_results(definition, snapshot("step-4-complete")), + blocked_output=current.output_results(definition, snapshot("blocked-output"), True), + restart=current.restart_results(definition, snapshot("blocked-output"), snapshot("blocked-retained")), + blocked_after_restart=current.output_results(definition, snapshot("blocked-retained"), True), + resumed_output=current.output_results(definition, snapshot("freed-output"), False), + redstone=current.redstone_results(definition, snapshot("redstone-before"), snapshot("redstone-held"), snapshot("redstone-resumed")), + partial_networks=[], reload_orders=[], network_processing="PASS") + for cycle, group in enumerate(("machine", "supply", "supply", "machine"), 1): + label = f"partial-{cycle}-{group}" + samples = [snapshot(f"{label}-sample-{n}") for n in range(8)] + # This flag is runner metadata, not an edited native measurement. + samples.append(dict(snapshot(label+"-confirmed"), confirmed=True)) + result["partial_networks"].append(dict(label=label, group=group, + cases=partial(definition, snapshot(label+"-before"), samples, snapshot(label+"-loaded"), group))) + for order in (("machine", "supply"), ("supply", "machine")): + label="order-"+"-".join(order) + result["reload_orders"].append(dict(label=label, order=list(order), cases=current.order_results(definition, + snapshot(label+"-before"),snapshot(label+"-absent"),snapshot(label+"-first"),snapshot(label+"-settled"),snapshot(label+"-loaded"),order))) + # Re-prove processing and conservation instead of trusting a summary PASS. + for label, volume in [("network-commissioned",1000)]+[(f"partial-resumed-{n}",(n+1)*1000) for n in range(1,5)]+[ + ("order-machine-supply-resumed",5000),("order-supply-machine-resumed",5000)]: + raw=snapshot(label) + for fixture in definition: + if fixture["id"] not in ("C-STEAM","C-ELECTRIC","C-FLUID"): continue + machine=at(raw,fixture["id"],fixture["witnesses"][4]) + if fixture["id"]=="C-FLUID": + tank=parse(machine).get("Tank",{}) + require(tank.get("FluidName")=="minecraft:water" and tank.get("Amount")==volume,"Raw fluid conservation failed: "+label) + else: + items=counts([machine]); energy=list(machine["energy"].values()) + require(items["minecraft:iron_ingot"]==4 and items["minecraft:iron_ore"]==0 + and all(math.isfinite(v) for v in energy) and sum(energy)>0,"Raw processing/recovery failed: "+label) + return result + + +def log_check(evidence, path, line): + text=evidence.read(path).decode("utf-8",errors="replace") + minecraft,forge=VERSIONS[line] + require("Starting minecraft server version "+minecraft in text and "Forge mod loading, version "+forge+", for MC "+minecraft in text, + "Missing native engine boot identity") + require("Stopping server" in text and "Saving players" in text and "Saving worlds" in text,"Missing full native shutdown saves") + for row in text.splitlines(): + require("/ERROR]" not in row and not any(marker in row for marker in ( + "NoSuchFieldError","NoSuchMethodError","Encountered an unexpected exception","Could not replace slot","Unknown item slot")), + "Native error or failed command: "+row) + if line=="113": + from native113_diagnostics import audit_lines + audit_lines(text.splitlines()) + elif line=="114": + notice="[ne.mi.co.ForgeConfig/CORE]: Forge config just got changed on the file system!" + require(not any("/FATAL]" in row and not row.endswith(notice) for row in text.splitlines()),"Unclassified fatal native diagnostic") + else: + require("/FATAL]" not in text,"Fatal native diagnostic") + + +def audit(args): + evidence=Evidence(); root=args.runtime.resolve();line=args.line + report_path=root/("extended-results.json" if line=="113" else f"native{line}-holder-extended-results.json") + original=evidence.json(report_path) + manifest=args.manifest.resolve();manifest_data=evidence.read(manifest) + pins=json.loads(manifest_data)["target"+line] + if line=="113": + custody=evidence.json(root/"native113-replay-audit.json") + require(custody["status"]=="PASS" and custody["protocolUnchanged"] and custody["sourceUnchanged"],"Original 113 source custody failed") + require(custody["targetManifestSha256"]==sha(manifest_data),"Original 113 manifest hash mismatch") + expected=next(v for k,v in custody["protocolSha256"].items() if Path(k).name=="extended_lifecycle.py") + require(evidence.json(root/"source-fingerprint-before.json")==evidence.json(root/"source-fingerprint-after.json"),"Protected source fingerprints differ") + require({p["file"]:p["sha256"] for p in original["artifacts"]}=={Path(p["path"]).name:p["sha256"] for p in pins["jars"]},"Original artifact pins differ") + else: + require(original["helpers_unchanged"] and original["helpersBefore"]==original["helpersAfter"],"Original helper custody failed") + require(original["targetManifestSha256"]==sha(manifest_data) and original["targetProfile"]==pins,"Original target pins differ") + expected=original["helpersBefore"]["extended_lifecycle.py"] + require(sha(evidence.read(root/"protocol-sources/extended_lifecycle.py"))==expected,"Archived native helper changed") + require(original["sourceRuntimeBefore"]==original["sourceRuntimeAfter"],"Protected source runtime fingerprints differ") + require(original["source_fixture_unchanged"] is True,"Protected source was modified") + previous=old_partial(evidence,args.old_helper,expected) + names=("fresh113","original110-chained113") if line=="113" else ("fresh"+line,"original110-chained113-native"+line) + require(set(original["profiles"])==set(names),"Incomplete requested profile set") + profiles={}; changed=[] + minecraft,forge=VERSIONS[line] + mods={row["id"]:row["version"] for row in pins["jars"]};mods.update(minecraft=minecraft,forge=forge) + for name in names: + runtime=root/name;summary=original["profiles"][name] + staged_pins(evidence,runtime,pins,name) + definition=evidence.json(runtime/"lifecycle-fixture.json") + require(len(definition)==21 and len({f["id"] for f in definition})==21 + and sum("route" in f for f in definition)==18,"Incomplete native extended fixture") + cache={} + def snapshot(label): + if label not in cache: + row=evidence.json(runtime/"advantage-works-results"/("lifecycle-"+label+".json")) + require(row["minecraft"]==minecraft and row["forge"]==forge and row["mods"]==mods,"Wrong raw engine/mod identity") + cache[label]=row + return cache[label] + old=compute(definition,snapshot,previous) + new=compute(definition,snapshot,current.partial_result) + for key,value in old.items(): + require(value==summary[key],"Original verdict cannot be reproduced from raw measurements: "+name+"/"+key) + require(current.qualification(old)==summary["status"],"Original profile status differs from raw proof") + proofs=[] + for first,second in zip(old["partial_networks"],new["partial_networks"]): + label,group=first["label"],first["group"] + samples=[snapshot(f"{label}-sample-{n}") for n in range(8)]+[snapshot(label+"-confirmed")] + for fixture in definition: + if "groups" not in fixture:continue + proof=interval_proof(fixture,snapshot(label+"-before"),samples,group) + case=second["cases"][fixture["id"]] + require(case["stable_unload"]==(proof["status"]=="PASS"),"Amended verdict disagrees with independent event-vector proof") + proofs.append(dict(label=label,case=fixture["id"],**proof)) + if first["cases"][fixture["id"]]!=case: + changed.append(dict(profile=name,label=label,case=fixture["id"],old=first["cases"][fixture["id"]],amended=case)) + logs=sorted(runtime.glob("console-*.log")) + expected_logs={"console-extended.log","console-blocked-restart.log"} + if line!="113" and name.startswith("original"): + expected_logs.add("console-retained13-import.log") + require({p.name for p in logs}==expected_logs,"Native operation/restart/import boot set incomplete or unexpected") + for log in logs:log_check(evidence,log,line) + profiles[name]=dict(originalQualification=summary["status"],amendedQualification=current.qualification(new), + partialCases=12,drillRoutes=18,intervalProofs=proofs,nonPartialVerdictsUnchanged=all(old[k]==new[k] for k in old if k!="partial_networks")) + require(original["status"]==("PASS" if all(p["originalQualification"]=="PASS" for p in profiles.values()) else "FAIL"),"Original top status masks a profile") + evidence.unchanged() + amended="PASS" if all(p["amendedQualification"]=="PASS" for p in profiles.values()) else "FAIL" + return dict(status=amended,evidenceAuditStatus="PASS",scope="Independent raw replay under amended pure validator; NOT a new native world run", + auditorSha256=sha(evidence.read(Path(__file__))), + originalReport=str(report_path),originalReportSha256=sha(evidence.read(report_path)),originalNativeStatus=original["status"], + oldHelperSha256=expected,newHelperSha256=sha(evidence.read(Path(current.__file__))), + targetManifest=str(manifest),targetManifestSha256=sha(manifest_data),profiles=profiles,changedCases=changed, + amendedQualification=amended, + originalEvidenceUnchanged=True,evidenceSha256=evidence.hashes) + + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument("--runtime",type=Path,required=True) + parser.add_argument("--line",choices=("113","114","115","116"),required=True) + parser.add_argument("--manifest",type=Path,required=True) + parser.add_argument("--old-helper",type=Path,required=True) + args=parser.parse_args() + try: + result=audit(args) + except (AssertionError,OSError,ValueError,KeyError,TypeError) as error: + result=dict(status="FAIL",error=str(error),runtime=str(args.runtime)) + print(json.dumps(result,indent=2,sort_keys=True)) + return 0 if result["status"]=="PASS" and result["amendedQualification"]=="PASS" else 1 + + +if __name__=="__main__": + raise SystemExit(main()) diff --git a/test-rig/migration/broad_campaign_status.py b/test-rig/migration/broad_campaign_status.py new file mode 100644 index 0000000..e36a170 --- /dev/null +++ b/test-rig/migration/broad_campaign_status.py @@ -0,0 +1,68 @@ +"""Pure campaign status builder, staged for a cleared writer-only repair. + +Adding this module does not change any live runner. The independent offline +auditor remains authoritative for raw checks, pins, and shutdown evidence. +""" + +from copy import deepcopy + + +PROFILES = {"packaged-current", "packaged-decoupled"} +PHASES = {"initial", "first", "operation", "restarted", "persistence"} + + +def _profile_complete(name, result): + if not isinstance(result, dict) or result.get("profile") != name or result.get("status") != "PASS": + return False + if result.get("error") or result.get("shutdownError"): + return False + phases = result.get("phases", {}) + if set(phases) != PHASES or any(not isinstance(row, dict) or row.get("status") != "PASS" for row in phases.values()): + return False + before, after = result.get("beforeRestart"), result.get("afterRestart") + if not isinstance(before, dict) or len(before) != 12 or before != after: + return False + if len(phases["initial"].get("drills", {})) != 12: + return False + if phases["operation"].get("drills") != 12 or phases["operation"].get("moved", 0) < 2: + return False + if phases["persistence"].get("drills") != 12 or phases["persistence"].get("exactEnergy") is not True: + return False + for phase in ("first", "restarted"): + counts = phases[phase].get("assertions", {}) + if (not isinstance(counts, dict) or not counts or set(counts) - {"PASS", "SKIP"} + or any(type(value) is not int or value < 0 for value in counts.values()) or counts.get("PASS", 0) <= 0): + return False + manual = phases[phase].get("manualRequired") + if not isinstance(manual, list) or len(manual) != 21 or len(set(manual)) != 21: + return False + return phases["first"] == phases["restarted"] + + +def profile_complete(name, result): + try: + return _profile_complete(name, result) + except (TypeError, KeyError, ValueError, AttributeError): + return False + + +def campaign_status(requested, results, finished=False): + requested = tuple(requested) + if not requested or len(set(requested)) != len(requested) or not set(requested) <= PROFILES: + raise ValueError("An explicit, unique requested profile set is required") + if not isinstance(results, dict) or set(results) - set(requested): + return "FAIL" + if any(not profile_complete(name, result) for name, result in results.items()): + return "FAIL" + if set(results) != set(requested): + return "FAIL" if finished else "RUNNING" + return "PASS" + + +def campaign_document(requested, results, metadata, finished=False): + """Only status is recomputed; immutable metadata and profile evidence remain exact.""" + if set(metadata) & {"status", "profiles"}: + raise ValueError("Metadata may not supply status or profile results") + document = deepcopy(metadata) + document.update(status=campaign_status(requested, results, finished), profiles=deepcopy(results)) + return document diff --git a/test-rig/migration/broad_result_audit.py b/test-rig/migration/broad_result_audit.py new file mode 100644 index 0000000..85b43e3 --- /dev/null +++ b/test-rig/migration/broad_result_audit.py @@ -0,0 +1,386 @@ +"""Offline, fail-closed audit of a complete native broad campaign. + +The historical writer can say PASS after only its first requested profile. +Never use that progress status alone as a campaign result. This auditor does +not import, modify, launch, or repair a broad runner or a Minecraft world. +""" + +import argparse +from collections import Counter +import hashlib +import json +import math +from pathlib import Path +import re +import struct +import zipfile + + +PROFILES = ("packaged-current", "packaged-decoupled") +VERSIONS = {"113": ("1.13.2", "25.0.223"), "114": ("1.14.4", "28.2.26"), + "115": ("1.15.2", "31.2.57"), "116": ("1.16.5", "36.2.34")} +STATIONS = tuple(["W-%02d" % n for n in range(1, 5)] + + ["S-%02d" % n for n in range(1, 9)] + + ["E-%02d" % n for n in range(1, 9)] + ["P-API-01"]) +RESOURCE = "assets/advantageworkstest/works/works-1.13.2.json" +DIRECTIONS = ("down", "up", "north", "south", "west", "east") +REAR = ((0, 1, 0), (0, -1, 0), (0, 0, 1), (0, 0, -1), (1, 0, 0), (-1, 0, 0)) +PHASES = {"initial", "first", "operation", "restarted", "persistence"} + + +def require(condition, message): + if not condition: + raise AssertionError(message) + + +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 decode(data): + return json.loads(data.decode("utf-8-sig"), object_pairs_hook=unique_object) + + +def sha(data): + return hashlib.sha256(data).hexdigest().upper() + + +class Evidence: + def __init__(self): + self.hashes = {} + + def read(self, path): + path = Path(path).resolve() + data = path.read_bytes() + digest = sha(data) + previous = self.hashes.setdefault(str(path), digest) + require(previous == digest, "Evidence changed while auditing: " + str(path)) + return data + + def json(self, path): + return decode(self.read(path)) + + def unchanged(self): + for path, expected in self.hashes.items(): + require(sha(Path(path).read_bytes()) == expected, "Evidence changed while auditing: " + path) + + +def indexed(rows, key, label): + require(isinstance(rows, list), label + ": missing list") + result = {} + for row in rows: + require(isinstance(row, dict) and key in row, label + ": malformed row") + require(row[key] not in result, label + ": duplicate " + str(row[key])) + result[row[key]] = row + return result + + +def require_profiles(actual, requested): + require(set(actual) == set(requested), + "Requested profile set incomplete or unexpected: requested=" + str(sorted(requested)) + + "; reported=" + str(sorted(actual))) + + +def raw_checks(records, definitions, mods, minecraft, forge): + require(set(records) == set(STATIONS), "Raw station set must contain all 21 bays") + counts, manual = Counter(), [] + for sid in STATIONS: + spec, row = definitions[sid], records[sid] + require(row["station"] == sid and row["minecraft"] == minecraft and row["forge"] == forge, + sid + ": wrong native station/engine identity") + require(row["mods"] == mods, sid + ": native loaded mod set differs from pins") + require(row.get("evidenceScope") == "declared_assertions_only; manual procedures are not automated passes", + sid + ": missing bounded evidence scope") + require(row["manualChecks"] == spec.get("manualChecks", []), sid + ": manual checks omitted or changed") + expected = indexed(spec["assertions"], "id", sid + " definition") + actual = indexed(row["assertions"], "id", sid + " raw assertions") + require(set(actual) == set(expected), sid + ": raw assertion set incomplete") + require(not set(spec.get("requiredMods", [])) - set(mods), sid + ": required station mod absent") + passed = 0 + for aid, assertion in expected.items(): + result = actual[aid] + require(result["type"] == assertion["type"], sid + "/" + aid + ": assertion type changed") + missing = set(assertion.get("requiredMods", [])) - set(mods) + status = "SKIP" if missing else "PASS" + require(result["status"] == status, sid + "/" + aid + ": wrong raw result or unexplained skip") + detail = "optional integration is absent" if missing else "matched" + require(result.get("detail") == detail, sid + "/" + aid + ": raw check detail contradicts result") + counts[status] += 1 + passed += status == "PASS" + classification = ("MANUAL_REQUIRED" if spec.get("manualChecks") else "PASS") if passed else "SKIP" + require(row["status"] == classification, sid + ": manual/skip classification masked") + if spec.get("manualChecks"): + manual.append(sid) + snapshots = row.get("snapshot") + require(isinstance(snapshots, list) and snapshots, sid + ": missing raw snapshot") + positions = [tuple(item["pos"]) for item in snapshots] + require(len(set(positions)) == len(positions), sid + ": duplicate raw snapshot position") + return dict(status="PASS", assertions=dict(counts), manualRequired=manual) + + +def drill_snapshot(records, definitions, initial=False): + # Recompute the twelve witnesses from native SNBT, not summary PASS counts. + import nbtlib + result = {} + for sid, block, track, capacity in ( + ("S-05", "steamadvantage:steam_drill", "steamadvantage:steam_track", 50), + ("E-06", "electricadvantage:electric_drill", "electricadvantage:electric_track", 500)): + spec = definitions[sid] + snapshot = records[sid]["snapshot"] + rows = {tuple(row["pos"]): row for row in snapshot} + require(len(rows) == len(snapshot), sid + ": duplicate drill position") + stock = Counter() + for row in snapshot: + data = nbtlib.parse_nbt(row["nbt"]) if "nbt" in row else {} + for item in data.get("Items", []): + marker = item.get("tag", {}).get("AdvBayStock") + if marker: + require(str(item["id"]) == "minecraft:diamond", sid + ": witness item identity changed") + stock[str(marker)] += int(item["Count"]) + found, facings = set(), set() + for assertion in spec["assertions"]: + if assertion.get("block") != block: + continue + facing = (DIRECTIONS[assertion["meta"]] if "meta" in assertion + else assertion.get("expectedText", "down")) + direction = DIRECTIONS.index(facing) + origin = tuple(a + b for a, b in zip(spec["origin"], assertion["pos"])) + rear = tuple(a + b for a, b in zip(origin, REAR[direction])) + matches = [p for p in (origin, rear) if rows.get(p, {}).get("block") == block] + key = sid + ":" + assertion["id"] + require(len(matches) == 1, key + ": missing/duplicate native drill") + pos = matches[0] + if initial: + require(pos == origin and rows.get(rear, {}).get("block") == track, + key + ": initial origin/track changed") + row = rows[pos] + require(re.search(r"(?:\[|,|^)facing=" + facing + r"(?:,|\]|$)", row["state"]), + key + ": native facing changed") + data = nbtlib.parse_nbt(row["nbt"]) + require(str(data.get("ForgeData", {}).get("AdvBayWitness")) == key, + key + ": native tile witness missing") + require(tuple(int(data[a]) for a in ("x", "y", "z")) == pos, key + ": native coordinates changed") + require(stock[key] == 7, key + ": native stock lost/duplicated") + bits = [int(value) for value in data["Energy"]] + require(len(bits) == 1, key + ": missing native energy buffer") + energy = struct.unpack("!f", struct.pack("!I", bits[0] & 0xffffffff))[0] + require(math.isfinite(energy) and 0 <= energy <= capacity, key + ": invalid native energy") + result[key] = dict(pos=list(pos), facing=facing, block=block, tile=str(data["id"]), + witness=key, stock=7, energyBits=bits, energy=energy, moved=pos == rear) + found.add(pos) + facings.add(facing) + require(facings == set(DIRECTIONS) and len(found) == 6, sid + ": six facings missing") + require({p for p, row in rows.items() if row["block"] == block} == found, + sid + ": extra off-track drill") + require(set(stock) == {key for key in result if key.startswith(sid + ":")}, + sid + ": unexpected stock witness") + require(len(result) == 12, "Twelve native drill witnesses required") + return result + + +def log_evidence(evidence, path, line, phase, records): + text = evidence.read(path).decode("utf-8", errors="replace") + minecraft, forge = VERSIONS[line] + require("Starting minecraft server version " + minecraft in text + and "Forge mod loading, version " + forge + ", for MC " + minecraft in text, + str(path) + ": missing native boot identity") + require(re.search(r"Done \([^\n]+For help", text), str(path) + ": native boot incomplete") + summaries = re.findall(r"Advantage Works check: \{([^}]+)\}", text) + require(len(summaries) == 1, str(path) + ": missing or duplicate native all-station check") + native_counts = dict((key, int(value)) for key, value in re.findall(r"([A-Z_]+)=(\d+)", summaries[0])) + require(native_counts == dict(Counter(row["status"] for row in records.values())), + str(path) + ": native check summary differs from raw station classifications") + if phase == "first": + require("Advantage Works built 21 station(s); skipped 0" in text, str(path) + ": native bay construction incomplete") + for sid in STATIONS: + for marker in ("Started " + sid + " -", "Stopped " + sid + " -", sid + "-checkpoint-operated.json"): + require(text.count(marker) == 1, str(path) + ": missing/duplicate native station activity " + marker) + else: + require(not any(marker in text for marker in ("Advantage Works built", "Modified block data of")) + and not re.search(r"(?:Started|Reset) (?:W-|S-|E-|P-API-)", text), + str(path) + ": construction/refill/start activity after cold restart") + saved = text.rfind("Saved the game") + stopped = text.find("Stopping server", max(saved, text.find("Done ("))) + require(stopped >= 0 and (line != "113" or saved >= 0), str(path) + ": missing native save and shutdown") + require(text.find("Done (") < text.find("Advantage Works check:") < stopped, + str(path) + ": native check is outside the running-server interval") + # Forge 25 uses an explicit flush. Later broad protocols rely on the native + # stop path, which logs per-dimension chunk-storage completion instead. + shutdown = text[stopped:] + require("Saving players" in shutdown and "Saving worlds" in shutdown, str(path) + ": incomplete shutdown saves") + for dimension in ("minecraft:overworld", "minecraft:the_nether", "minecraft:the_end"): + require(any("Saving chunks for level" in row and dimension in row for row in shutdown.splitlines()), + str(path) + ": missing saved dimension " + dimension) + if line != "113": + completed = set(re.findall(r"ThreadedAnvilChunkStorage \(([^)]+)\): All chunks are saved", shutdown)) + require({"DIM-1", "DIM1"} <= completed and bool(completed - {"DIM-1", "DIM1"}), + str(path) + ": missing completed chunk-storage shutdown") + errors = ("NoSuchFieldError", "NoSuchMethodError", "Failed to reload data packs", + "Encountered an unexpected exception", "Exception in server tick loop") + for row in text.splitlines(): + require("/ERROR]" not in row and not any(marker in row for marker in errors), + str(path) + ": native error: " + row) + # Reuse only existing, narrow engine diagnostics; never trust a diagnostic + # PASS without validating its raw log and classified line identities. + if line == "113": + from native113_diagnostics import audit_lines + diagnostics = audit_lines(text.splitlines()) + recorded = evidence.json(path.with_suffix(".diagnostics.json")) + require(recorded.get("status") == "PASS" and recorded.get("logSha256") == sha(text.encode("utf-8")) + and recorded.get("classifiedDiagnostics") == diagnostics["classifiedDiagnostics"], + str(path) + ": stale or masked native diagnostics") + elif line == "114": + notice = "[ne.mi.co.ForgeConfig/CORE]: Forge config just got changed on the file system!" + require(not any("/FATAL]" in row and not row.endswith(notice) for row in text.splitlines()), + str(path) + ": unclassified fatal") + else: + require("/FATAL]" not in text, str(path) + ": fatal native runtime error") + return dict(status="PASS", saveAndAllDimensionShutdown=True, explicitSaveCommand=saved >= 0, + processExit="NOT_INDEPENDENTLY_RECORDED_BY_HISTORICAL_BROAD_WRITER") + + +def staged_pins(evidence, runtime, pins, name): + jars = [row for row in pins["jars"] if name != "packaged-decoupled" or row["id"] != "basemetals"] + expected = {Path(row["path"]).name: row["sha256"].upper() for row in jars} + require(len(expected) == len(jars), "Duplicate pinned jar filenames") + actual = {path.name: sha(evidence.read(path)) for path in (runtime / "mods").glob("*.jar")} + require(actual == expected, name + ": staged mod bytes differ from pins") + recorded = indexed(evidence.json(runtime / "input-jars.json"), "file", name + " staged input receipt") + require({key: row["sha256"].upper() for key, row in recorded.items()} == expected, + name + ": staged input receipt differs from pins") + require(sha(evidence.read(runtime / pins["launcher"])) == pins["launcher_sha256"].upper(), + name + ": staged launcher changed") + sealed = pins.get("serverCode", {}).get("files") + if sealed: + paths = list(runtime.glob("*.jar")) + list((runtime / "libraries").rglob("*")) + actual = {path.relative_to(runtime).as_posix(): dict(sha256=sha(evidence.read(path)), size=path.stat().st_size) + for path in paths if path.is_file()} + require(actual == sealed, name + ": sealed native engine bytes changed") + return dict(modJars=expected, sealedEngine=bool(sealed)) + + +def profile_audit(evidence, runtime, name, report, pins, definitions, line): + local = evidence.json(runtime / "broad-results.json") + require(local == report, name + ": local result differs from top-level result") + require(report["profile"] == name and report["status"] == "PASS", name + ": profile did not pass") + require(not report.get("error") and not report.get("shutdownError"), name + ": failure masked by status") + for key in ("exitCode", "processExitCode", "returnCode"): + if key in report: + require(report[key] == 0, name + ": recorded process exit failed") + require(set(report["phases"]) == PHASES, name + ": required phases incomplete") + require(all(row.get("status") == "PASS" for row in report["phases"].values()), name + ": phase failure masked") + minecraft, forge = VERSIONS[line] + mods = {row["id"]: row["version"] for row in pins["jars"] + if name != "packaged-decoupled" or row["id"] != "basemetals"} + mods.update(minecraft=minecraft, forge=forge) + require(report["expectedMods"] == mods, name + ": expected mod identities changed") + custody = staged_pins(evidence, runtime, pins, name) + directory = runtime / "advantage-works-results" + checks, records = {}, {} + for phase in ("first", "restarted"): + raw = evidence.json(directory / (phase + "-all-stations.json")) + records[phase] = raw + checked = raw_checks(raw, definitions, mods, minecraft, forge) + require(report["phases"][phase] == checked, name + ": summary counts/manual list differs from raw " + phase) + checks[phase] = checked + for sid in STATIONS: + operated = evidence.json(directory / (sid + "-checkpoint-operated.json")) + require(operated["station"] == sid and operated["minecraft"] == minecraft and operated["forge"] == forge + and operated["mods"] == mods and operated["checkpoint"] == "operated" and operated["snapshot"], + sid + ": operated checkpoint identity/snapshot missing") + require(operated["worldTime"] <= records["first"][sid]["worldTime"] <= records["restarted"][sid]["worldTime"], + sid + ": raw checkpoints are not ordered across operation and cold restart") + initial_records = {sid: evidence.json(directory / (sid + "-checkpoint-initial.json")) + for sid in ("S-05", "E-06")} + initial = drill_snapshot(initial_records, definitions, initial=True) + before = drill_snapshot(records["first"], definitions) + after = drill_snapshot(records["restarted"], definitions) + require(report["phases"]["initial"] == dict(status="PASS", drills=initial), name + ": initial drill summary masked") + require(report["beforeRestart"] == before and report["afterRestart"] == after, + name + ": drill summary differs from native snapshots") + for key in initial: + require(all(initial[key][field] == before[key][field] for field in ("facing", "block", "tile", "witness", "stock")), + key + ": moved identity changed") + require(before["S-05:down-facing"]["moved"] and before["E-06:up-facing"]["moved"], "Required native track movement absent") + require(report["phases"]["operation"] == dict(status="PASS", drills=12, moved=sum(row["moved"] for row in before.values())), + name + ": drill movement count masked") + require(before == after, name + ": native cold restart changed drill state") + require(report["phases"]["persistence"] == dict(status="PASS", drills=12, exactEnergy=True), + name + ": persistence summary changed") + shutdown = {phase: log_evidence(evidence, runtime / ("console-" + phase + ".log"), line, phase, records[phase]) + for phase in ("first", "restarted")} + return dict(status="PASS", stations=21, checks=checks, pins=custody, shutdown=shutdown, + manualProcedures="NOT_AUTOMATED_BY_BROAD_SWEEP") + + +def audit(runtime, line, requested=PROFILES): + runtime, line = Path(runtime).resolve(), str(line) + evidence = Evidence() + result = dict(status="FAIL", line=line, runtime=str(runtime), requestedProfiles=list(requested), + profiles={}, errors=[], writerWarning="Historical broad writer can emit PASS for a completed subset; require this independent audit.") + try: + require(line in VERSIONS, "Unsupported native line") + require(requested and len(set(requested)) == len(requested) and set(requested) <= set(PROFILES), "Invalid requested profile set") + report = evidence.json(runtime / ("native" + line + "-broad-results.json")) + require_profiles(report["profiles"], requested) + require(report["status"] == "PASS", "Top-level campaign did not finish PASS") + require(not report.get("error") and not report.get("shutdownError"), "Top-level failure masked by PASS") + for key in ("exitCode", "processExitCode", "returnCode"): + if key in report: + require(report[key] == 0, "Top-level recorded process exit failed") + manifest_path = Path(report["targetManifest"]) + manifest_data = evidence.read(manifest_path) + require(sha(manifest_data) == report["targetManifestSha256"], "Pinned target manifest changed") + pins = decode(manifest_data)["target" + line] + require(sha(evidence.read(runtime / "runner-source.py")) == report["runnerSha256"], "Archived broad runner changed") + jars = indexed(pins["jars"], "id", "target mod pins") + require(set(jars) == {"poweradvantage", "steamadvantage", "electricadvantage", "advantageworkstest", "basemetals", "orespawn"}, + "Complete pinned current-profile inputs required") + harness = Path(jars["advantageworkstest"]["path"]) + harness_bytes = evidence.read(harness) + require(sha(harness_bytes) == jars["advantageworkstest"]["sha256"], "Pinned harness bytes changed") + with zipfile.ZipFile(harness) as archive: + definition = archive.read(RESOURCE) + # Gradle normalizes line endings; only that byte normalization is allowed. + lf = definition.replace(b"\r\n", b"\n") + require(report["stationDefinitionSha256"] in (sha(definition), sha(lf), sha(lf.replace(b"\n", b"\r\n"))), + "Reported station definition differs from pinned harness resource") + definitions = indexed(decode(definition)["stations"], "id", "station definitions") + require(set(definitions) == set(STATIONS), "Pinned definition does not describe all 21 stations") + for name in requested: + try: + result["profiles"][name] = profile_audit(evidence, runtime / name, name, report["profiles"][name], pins, definitions, line) + except (AssertionError, OSError, ValueError, KeyError, TypeError, IndexError, ImportError) as error: + result["profiles"][name] = dict(status="FAIL", error=str(error)) + result["errors"].append(name + ": " + str(error)) + evidence.unchanged() + if not result["errors"]: + result["status"] = "PASS" + except (AssertionError, OSError, ValueError, KeyError, TypeError, IndexError, zipfile.BadZipFile) as error: + result["errors"].append(str(error)) + result["evidenceSha256"] = evidence.hashes + result["scope"] = "Complete requested broad profiles and declared native checks only; not manual acceptance or operating/endurance coverage." + return result + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--runtime", type=Path, required=True, help="Broad output root containing nativeNNN-broad-results.json") + parser.add_argument("--line", choices=tuple(VERSIONS), required=True) + parser.add_argument("--profiles", nargs="+", choices=("both",) + PROFILES, default=["both"], + help="Explicit requested set; defaults to both, never inferred from existing results") + args = parser.parse_args(argv) + requested = PROFILES if args.profiles == ["both"] else tuple(args.profiles) + if "both" in requested: + parser.error("both cannot be combined with a named profile") + result = audit(args.runtime, args.line, requested) + print(json.dumps(result, indent=2, sort_keys=True)) + return 0 if result["status"] == "PASS" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test-rig/migration/extended_lifecycle.py b/test-rig/migration/extended_lifecycle.py index 2a8af80..78235d3 100644 --- a/test-rig/migration/extended_lifecycle.py +++ b/test-rig/migration/extended_lifecycle.py @@ -64,6 +64,40 @@ def verdict(errors, **details): return {"status": "FAIL" if errors else "PASS", "failures": errors, **details} +def continuous_absence(old_chunks, samples, selected): + """Prove one event-stable absence segment ending at the confirmed sample.""" + anchor_tick, anchor_counts, previous_tick = None, None, None + errors = [] + for index, sample in enumerate(samples): + tick = sample.get("worldTime") + if type(tick) is not int or tick < 0 or (previous_tick is not None and tick < previous_tick): + errors.append("invalid or backwards native observation clock") + continue + previous_tick = tick + states = {tuple(c["pos"]): c for c in sample["chunks"]} + if len(states) != len(sample["chunks"]) or any(p not in states for p in selected): + errors.append("missing or duplicate selected chunk observation") + continue + counters = tuple((states[p].get("loads"), states[p].get("unloads")) for p in selected) + if any(type(value) is not int or value < 0 for pair in counters for value in pair): + errors.append("invalid native chunk event counter") + continue + absent = all(states[p].get("loaded") is False + and states[p]["unloads"] > old_chunks[p]["unloads"] + and states[p]["loads"] >= old_chunks[p]["loads"] for p in selected) + if anchor_tick is None and absent: + anchor_tick, anchor_counts = tick, counters + if anchor_tick is not None and (not absent or counters != anchor_counts): + errors.append("selected endpoint reloaded during observed absence") + if sample.get("confirmed") and index != len(samples) - 1: + errors.append("absence confirmation is not the terminal observation") + terminal = samples[-1] if samples else {} + tick = terminal.get("worldTime") + stable = (not errors and anchor_tick is not None and terminal.get("confirmed") is True + and type(tick) is int and tick - anchor_tick >= 40) + return stable, errors + + def partial_result(definition, before, samples, loaded, group): result = {} old_chunks = {tuple(c["pos"]): c for c in before["chunks"]} @@ -73,15 +107,15 @@ def partial_result(definition, before, samples, loaded, group): selected = [tuple(p) for p in f["groups"][group]] other = "machine" if group == "supply" else "supply" retained = [tuple(p) for p in f["groups"][other]] - errors, stable, transient = [], False, False - for sample_index, sample in enumerate(samples): + stable, errors = continuous_absence(old_chunks, samples, selected) + transient = False + for sample in samples: states = {tuple(c["pos"]): c for c in sample["chunks"]} - absent = all(not states[p]["loaded"] and states[p]["unloads"] > old_chunks[p]["unloads"] for p in selected) - previous_states = {tuple(c["pos"]): c for c in samples[max(0, sample_index - 1)]["chunks"]} - duration = sample.get("worldTime", 0) - samples[max(0, sample_index - 1)].get("worldTime", 0) - stable |= absent and sample.get("confirmed", False) and duration >= 40 and all(not previous_states[p]["loaded"] for p in selected) - transient |= any(states[p]["unloads"] > old_chunks[p]["unloads"] for p in selected) - if not all(states[p]["loaded"] and states[p]["unloads"] == old_chunks[p]["unloads"] for p in retained): + transient |= any(type(states.get(p, {}).get("unloads")) is int + and states[p]["unloads"] > old_chunks[p]["unloads"] for p in selected) + if not all(states.get(p, {}).get("loaded") is True + and states[p].get("loads") == old_chunks[p]["loads"] + and states[p].get("unloads") == old_chunks[p]["unloads"] for p in retained): errors.append("retained endpoint also unloaded") for entry in before["cases"][f["id"]]: if (entry["pos"][0] >> 4, entry["pos"][2] >> 4) not in retained or "tileIdentity" not in entry: continue @@ -89,7 +123,10 @@ def partial_result(definition, before, samples, loaded, group): if now.get("tileIdentity") != entry["tileIdentity"]: errors.append("retained tile identity changed") if not stable: errors.append("selected endpoint did not stay genuinely unloaded for two seconds") for p in selected: - if not new_chunks[p]["loaded"] or new_chunks[p]["loads"] <= old_chunks[p]["loads"]: + observed_loads = samples[-1]["chunks"] if samples else [] + last_loads = next((c.get("loads") for c in observed_loads if tuple(c["pos"]) == p), old_chunks[p]["loads"]) + minimum_loads = max(old_chunks[p]["loads"], last_loads) if type(last_loads) is int else old_chunks[p]["loads"] + if not new_chunks[p]["loaded"] or new_chunks[p]["loads"] <= minimum_loads: errors.append("missing real endpoint reload event") for entry in before["cases"][f["id"]]: now = at(loaded, f["id"], entry["pos"]) diff --git a/test-rig/migration/native113.py b/test-rig/migration/native113.py new file mode 100644 index 0000000..9349b5b --- /dev/null +++ b/test-rig/migration/native113.py @@ -0,0 +1,72 @@ +"""Strict native Forge 25 inputs for the expanded operating checks; never opens source worlds.""" + +import json +from pathlib import Path +import tomllib +import zipfile +from matrix import WORLD, digest, stage, write_json + +RIG = Path(__file__).resolve().parents[1] +LAUNCHER = "forge-1.13.2-25.0.223.jar" + + +def output_path(path): + path = path.resolve() + if not path.is_relative_to((RIG / "build").resolve()): + raise ValueError("Native113 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 = tomllib.loads(archive.read("META-INF/mods.toml").decode("utf-8")) + mods = document.get("mods", []) + 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 native113_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["target113"] + 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(target113=target, baseManifest=str(source), baseSha256=digest(source))) + pinned_profile(output, "target113") + + +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"]) or not jar["version"].endswith(".113021"): + raise ValueError("Wrong native mod identity/version: " + str(path)) + if jar["id"] in ids: raise ValueError("Duplicate mod identity") + ids.add(jar["id"]); jars.append(path) + if key != "target113" or target["launcher"] != LAUNCHER or not {"poweradvantage", "advantageworkstest", "orespawn"} <= ids: + raise ValueError("Incomplete/wrong native113 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/native113_broad.py b/test-rig/migration/native113_broad.py new file mode 100644 index 0000000..6144efb --- /dev/null +++ b/test-rig/migration/native113_broad.py @@ -0,0 +1,342 @@ +"""Bounded native113 walkable-bay sweep with initial, movement and restart oracles. + +Production jars come only from a new immutable manifest. Historical station +definitions and failed broad runs remain unchanged. No source world is launched. +""" + +import argparse +from collections import Counter +import json +import math +from pathlib import Path +import shutil +import struct +import time +import traceback +from broad_campaign_status import campaign_document, campaign_status + +import nbtlib +import native113_chunk_readiness +from native113_sealed_transport import configure as configure_transport, stage, Server +from matrix import digest, plain, write_json +from native113 import RIG +from native113_campaign_support import output_path, pinned_profile, require_disjoint +from native113_lifecycle import require_native_boot, verify_staged + + +DIRECTIONS = ("down", "up", "north", "south", "west", "east") +REAR = ((0, 1, 0), (0, -1, 0), (0, 0, 1), (0, 0, -1), (1, 0, 0), (-1, 0, 0)) +DRILL_BAYS = ("S-05", "E-06") + + +def read_json(path): + return json.loads(path.read_text(encoding="utf-8")) + + +def drill_cases(station): + result = [] + for assertion in station["assertions"]: + if assertion.get("block") not in ("steamadvantage:steam_drill", "electricadvantage:electric_drill"): + continue + direction = assertion.get("meta", DIRECTIONS.index(assertion.get("expectedText", "down"))) + origin = tuple(a + b for a, b in zip(station["origin"], assertion["pos"])) + rear = tuple(a + b for a, b in zip(origin, REAR[direction])) + result.append(dict(id=station["id"] + ":" + assertion["id"], origin=origin, rear=rear, + facing=DIRECTIONS[direction], block=assertion["block"])) + if len(result) != 6 or {r["facing"] for r in result} != set(DIRECTIONS): + raise AssertionError("The original six-facing bay definition changed") + return result + + +def nbt(row): + return plain(nbtlib.parse_nbt(row["nbt"])) if "nbt" in row else {} + + +def drill_state(station, document, *, initial=False): + rows = {tuple(row["pos"]): row for row in document["snapshot"]} + if len(rows) != len(document["snapshot"]): + raise AssertionError("Duplicate snapshot positions") + cases = drill_cases(station) + stock = Counter() + for row in rows.values(): + for stack in nbt(row).get("Items", []): + marker = stack.get("tag", {}).get("AdvBayStock") + if marker: + if stack.get("id") != "minecraft:diamond": + raise AssertionError("Inventory witness item identity changed") + stock[marker] += int(stack["Count"]) + expected_positions, result = set(), {} + for case in cases: + candidates = [rows[pos] for pos in (case["origin"], case["rear"]) + if pos in rows and rows[pos]["block"] == case["block"]] + if len(candidates) != 1: + raise AssertionError(case["id"] + ": missing or duplicate drill in declared cells") + row = candidates[0] + pos = tuple(row["pos"]) + if initial and pos != case["origin"]: + raise AssertionError(case["id"] + ": initial placement moved before activation") + if initial: + track = "steamadvantage:steam_track" if station["id"] == "S-05" else "electricadvantage:electric_track" + if rows.get(case["rear"], {}).get("block") != track: + raise AssertionError(case["id"] + ": initial declared rear track is missing") + if "facing=" + case["facing"] not in row["state"]: + raise AssertionError(case["id"] + ": block facing changed") + data = nbt(row) + if data.get("ForgeData", {}).get("AdvBayWitness") != case["id"]: + raise AssertionError(case["id"] + ": persistent moved tile witness lost/replaced") + if tuple(int(data[axis]) for axis in ("x", "y", "z")) != pos: + raise AssertionError(case["id"] + ": tile coordinates do not match its block") + if stock[case["id"]] != 7: + raise AssertionError(case["id"] + ": inventory stock lost or duplicated") + energy = data.get("Energy", []) + if len(energy) != 1: + raise AssertionError(case["id"] + ": energy witness missing") + value = struct.unpack("!f", struct.pack("!I", int(energy[0]) & 0xffffffff))[0] + capacity = 50 if station["id"] == "S-05" else 500 + if not math.isfinite(value) or not 0 <= value <= capacity: + raise AssertionError(case["id"] + ": invalid energy") + expected_positions.add(pos) + result[case["id"]] = dict(pos=list(pos), facing=case["facing"], block=row["block"], + tile=data["id"], witness=case["id"], stock=7, + energyBits=[int(e) for e in energy], energy=value, + moved=pos == case["rear"]) + actual_positions = {pos for pos, row in rows.items() + if row["block"] in ("steamadvantage:steam_drill", "electricadvantage:electric_drill")} + if actual_positions != expected_positions or set(stock) != {case["id"] for case in cases}: + raise AssertionError("Extra/off-track drill or inventory witness in bay") + return result + + +def operating_state(initial, operated): + if initial.keys() != operated.keys() or len(initial) != 12: + raise AssertionError("Twelve unique drill witnesses are required") + for key in initial: + for field in ("facing", "block", "tile", "witness", "stock"): + if initial[key][field] != operated[key][field]: + raise AssertionError(key + ": movement changed " + field) + # These two original connected lanes are the Q-RIG-001 reproduction. + for key in ("S-05:down-facing", "E-06:up-facing"): + if not operated[key]["moved"]: + raise AssertionError(key + ": the broad run did not exercise actual track movement") + return dict(status="PASS", drills=12, moved=sum(v["moved"] for v in operated.values())) + + +def restarted_state(before, after): + if before != after: + changed = [key for key in set(before) | set(after) if before.get(key) != after.get(key)] + raise AssertionError("Restart changed position, identity, facing, stock or energy: " + str(changed)) + return dict(status="PASS", drills=len(after), exactEnergy=True) + + +def assertions_state(records, stations): + if set(records) != {station["id"] for station in stations}: + raise AssertionError("The broad sweep omitted a bay") + counts = Counter() + manual = [] + for key, document in records.items(): + if document["station"] != key or document["minecraft"] != "1.13.2": + raise AssertionError("Wrong bay/target report") + if document["status"] not in ("PASS", "MANUAL_REQUIRED"): + raise AssertionError(key + ": " + document["status"]) + expected = next(s for s in stations if s["id"] == key) + if (len(document["assertions"]) != len(expected["assertions"]) + or {a["id"] for a in document["assertions"]} != {a["id"] for a in expected["assertions"]}): + raise AssertionError(key + ": assertion set changed") + for assertion in document["assertions"]: + counts[assertion["status"]] += 1 + if assertion["status"] not in ("PASS", "SKIP"): + raise AssertionError(key + "/" + assertion["id"] + ": " + assertion["status"]) + if assertion["status"] == "SKIP": + spec = next(a for a in expected["assertions"] if a["id"] == assertion["id"]) + if not set(spec.get("requiredMods", [])) - set(document["mods"]): + raise AssertionError(key + "/" + assertion["id"] + ": unexplained skip") + if document["manualChecks"]: + manual.append(key) + return dict(status="PASS", assertions=dict(counts), manualRequired=manual) + + +def loaded_mods(records, expected): + for key, record in records.items(): + if record["mods"] != expected: + raise AssertionError(key + ": loaded mod identities/versions differ from the pinned profile") + + +def authorize_broad(runtime): + # matrix.stage is shared with frozen conversion campaigns. A live works + # campaign must explicitly opt out of MigrationProbe's tile-tick freeze. + for name in ("advantage-works.allow", "migration-operate.allow"): + (runtime / name).touch() + + +def configure_broad(runtime, port): + # Match the walkable WorksRunner rather than matrix.stage's migration-only + # defaults. Clearing a freshly generated village can race native POI tasks. + overrides = {"server-port": str(port), "generator-settings": "", "generate-structures": "false", + "level-seed": "advantage-works-1.13.2-v1", "difficulty": "0", "gamemode": "creative", + "allow-flight": "true", "enable-command-block": "true", "force-gamemode": "true", + "spawn-animals": "false", "spawn-monsters": "false", "spawn-npcs": "false", "pvp": "false"} + path = runtime / "server.properties" + lines = [line for line in path.read_text().splitlines() if line.partition("=")[0] not in overrides] + path.write_text("\n".join(lines) + "\n" + "".join(f"{key}={value}\n" for key, value in overrides.items())) + + +class BroadServer(Server): + def command(self, command, token): + self.send(command) + self.wait(token, 150) + + def checkpoint(self, station, label): + self.command(f"advworks checkpoint {station} {label}", "Checkpoint written for " + station) + return read_json(Path(self.log.name).parent / "advantage-works-results" / f"{station}-checkpoint-{label}.json") + + def check_all(self, stations, phase): + self.command("advworks check all", "Advantage Works check:") + directory = Path(self.log.name).parent / "advantage-works-results" + records = {s["id"]: read_json(directory / (s["id"] + "-latest.json")) for s in stations} + write_json(directory / (phase + "-all-stations.json"), records) + return records + + +def run_profile(args, profile, output, name, port, stations): + if name == "packaged-decoupled": + profile = (profile[0], profile[1], [p for p in profile[2] if not p.name.startswith("BaseMetals-")]) + runtime = output / name + launcher = stage(runtime, profile) + configure_broad(runtime, port) + authorize_broad(runtime) + result = dict(status="FAIL", profile=name, port=port, phases={}) + pins = read_json(args.target_manifest)["target113"]["jars"] + expected_mods = {pin["id"]: pin["version"] for pin in pins + if name != "packaged-decoupled" or pin["id"] != "basemetals"} + expected_mods.update(minecraft="1.13.2", forge="25.0.223") + result["expectedMods"] = expected_mods + server = None + try: + verify_staged(runtime, profile) + server = BroadServer(runtime, args.java8, launcher, "first") + class Readiness: + definitions = {row["id"]: row for row in stations} + def __init__(self): self.runtime = runtime + def pos(self, key, relative): + return [a + b for a, b in zip(self.definitions[key]["origin"], relative)] + def command(self, command, token=None): + server.send(command) + if token is not None: server.wait(token, 150) + native113_chunk_readiness.acquire(Readiness()) + server.command("advworks build all", "Advantage Works built 21 station(s)") + # Named finite stock and tile data are commissioning witnesses, not repairs + # performed after operation or restart. Every destination is unchanged later. + for station in stations: + if station["id"] not in DRILL_BAYS: + continue + for case in drill_cases(station): + pos = " ".join(map(str, case["origin"])) + marker = json.dumps(case["id"]) + tag = ('{ForgeData:{AdvBayWitness:' + marker + '},Items:[{Slot:0b,id:"minecraft:diamond",' + 'Count:7b,tag:{AdvBayStock:' + marker + '}}]}') + server.command(f"data merge block {pos} {tag}", "Modified block data of") + initial = {} + for station in stations: + if station["id"] in DRILL_BAYS: + initial.update(drill_state(station, server.checkpoint(station["id"], "initial"), initial=True)) + result["phases"]["initial"] = dict(status="PASS", drills=initial) + write_json(runtime / "broad-results.json", result) + for station in stations: + sid = station["id"] + server.command("advworks start " + sid, "Started " + sid) + time.sleep(max(60, args.run_seconds) if sid == "W-03" else args.run_seconds) + server.checkpoint(sid, "operated") + server.command("advworks stop " + sid, "Stopped " + sid) + first = server.check_all(stations, "first") + loaded_mods(first, expected_mods) + result["phases"]["first"] = assertions_state(first, stations) + before = {} + for station in stations: + if station["id"] in DRILL_BAYS: + before.update(drill_state(station, first[station["id"]])) + result["phases"]["operation"] = operating_state(initial, before) + result["beforeRestart"] = before + server.command("save-all flush", "Saved the game") + server.close() + server = None + require_native_boot(runtime / "console-first.log") + verify_staged(runtime, profile) + server = BroadServer(runtime, args.java8, launcher, "restarted") + # No start, reset, data merge, replacement, or refill on the second boot. + time.sleep(args.run_seconds) + second = server.check_all(stations, "restarted") + loaded_mods(second, expected_mods) + result["phases"]["restarted"] = assertions_state(second, stations) + after = {} + for station in stations: + if station["id"] in DRILL_BAYS: + after.update(drill_state(station, second[station["id"]])) + result["afterRestart"] = after + result["phases"]["persistence"] = restarted_state(before, after) + server.command("save-all flush", "Saved the game") + server.close() + server = None + require_native_boot(runtime / "console-restarted.log") + verify_staged(runtime, profile) + result["status"] = "PASS" + except Exception: + result["error"] = traceback.format_exc() + finally: + if server is not None: + try: + server.close() + except Exception: + result["shutdownError"] = traceback.format_exc() + write_json(runtime / "broad-results.json", result) + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--target-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, default=25588) + parser.add_argument("--run-seconds", type=int, default=8) + parser.add_argument("--profile", choices=("both", "packaged-current", "packaged-decoupled"), default="both") + args = parser.parse_args() + configure_transport(args.target_manifest) + if not 1 <= args.run_seconds <= 60 or not 1024 <= args.port < 65535: + parser.error("Bounded run duration and unprivileged consecutive ports required") + output = output_path(args.output) + profile = pinned_profile(args.target_manifest, "target113") + target_pins = read_json(args.target_manifest)["target113"]["jars"] + required = {"poweradvantage", "steamadvantage", "electricadvantage", "advantageworkstest", "basemetals", "orespawn"} + if {pin["id"] for pin in target_pins} != required: + raise AssertionError("Supply the complete current-profile manifest; the runner derives its decoupled subset") + for protected in (args.target_manifest, profile[0], *profile[2]): + require_disjoint(output, protected) + stations_path = RIG / "src/main/resources/assets/advantageworkstest/works/works-1.13.2.json" + stations = read_json(stations_path)["stations"] + if len(stations) != 21: + raise AssertionError("The complete 21-bay specification is required") + output.mkdir(parents=True) + shutil.copy2(Path(__file__), output / "runner-source.py") + source_hash = digest(output / "runner-source.py") + names = ("packaged-current", "packaged-decoupled") if args.profile == "both" else (args.profile,) + results = {} + def write_result(finished=False): + write_json(output / "native113-broad-results.json", campaign_document( + names, results, dict( + targetManifest=str(args.target_manifest.resolve()), targetManifestSha256=digest(args.target_manifest), + runnerSha256=source_hash, stationDefinitionSha256=digest(stations_path)), + finished=finished)) + write_result() + for index, name in enumerate(names): + try: + results[name] = run_profile(args, profile, output, name, args.port + index, stations) + except Exception: + results[name] = dict(status="FAIL", profile=name, phases={}, error=traceback.format_exc()) + write_result() + print(name, results[name]["status"], flush=True) + write_result(finished=True) + return int(campaign_status(names, results, finished=True) != "PASS") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test-rig/migration/native113_campaign_support.py b/test-rig/migration/native113_campaign_support.py new file mode 100644 index 0000000..a52596b --- /dev/null +++ b/test-rig/migration/native113_campaign_support.py @@ -0,0 +1,51 @@ +"""Explicit native destination and archived-source transport for wider 1.13 campaigns.""" + +from argparse import Namespace +from pathlib import Path + +from matrix import WORLD, digest, fingerprint, normalized as native_normalized, selected_profiles, compare as historical_compare +from native113 import LAUNCHER, output_path, require_disjoint, pinned_profile as native_target +from native113_lifecycle import prepare, read_json, require_native_boot, verify_staged +from native113_diagnostics import audit_lines + + +def pinned_profile(manifest, key): + manifest = manifest.resolve() + entry = read_json(manifest)[key] + if entry.get("qualificationStatus") is not None: + return native_target(manifest, key) + # Historical source profiles keep their original hashes and are never booted here. + return selected_profiles(Namespace(profile_manifest=manifest))[key] + + +def require_clean_log(path): + return audit_lines(Path(path).read_text(encoding="utf-8", errors="replace").splitlines()) + + +def compare(before, after, normalizer=None): + if normalizer is not None and normalizer is not native_normalized: + raise ValueError("The 1.13 comparison must retain its original normalizer") + return historical_compare(before, after) + + +def verify_source(args): + source = args.source.resolve() + before = fingerprint(source) + expected = {key.replace("\\", "/"): value.upper() for key, value in read_json(args.source_hashes).items()} + actual = {key.replace("\\", "/"): value.upper() for key, value in fingerprint(source / WORLD).items()} + if not expected or actual != expected: + raise AssertionError("Protected retained source world fingerprint mismatch") + profile = pinned_profile(args.source_manifest.resolve(), args.source_key) + if profile[1] != LAUNCHER: + raise ValueError("Supply the retained, already-converted native 1.13 source") + pins = sorted((jar.name, digest(jar)) for jar in profile[2]) + recorded = sorted((row["file"], row["sha256"].upper()) for row in read_json(source / "input-jars.json")) + if recorded != pins or sorted((jar.name, digest(jar)) for jar in (source / "mods").glob("*.jar")) != pins: + raise AssertionError("Source mod bytes do not match the explicit archived profile") + history = read_json(source / "state-comparison.json") + if any(history.get(gate, {}).get("status") != "PASS" for gate in ("conversion", "restart", "idempotence")): + raise AssertionError("Retained source lacks a passing preservation history") + for name in ("migration-fixture.json", "migration-restarted.json"): + if not (source / name).is_file(): raise FileNotFoundError(source / name) + if fingerprint(source) != before: raise AssertionError("Source changed during preflight") + return before, history diff --git a/test-rig/migration/native113_chunk_readiness.py b/test-rig/migration/native113_chunk_readiness.py new file mode 100644 index 0000000..e35cc20 --- /dev/null +++ b/test-rig/migration/native113_chunk_readiness.py @@ -0,0 +1,38 @@ +"""Observe native chunk load completion, never infer readiness from machine output.""" + +from pathlib import Path +import time +import json + + +def validate(document, label, low, high): + expected = {(x, z) for x in range(low[0] >> 4, (high[0] >> 4) + 1) + for z in range(low[2] >> 4, (high[2] >> 4) + 1)} + rows = document.get("chunks", []) + if (not expected or document.get("label") != label or type(document.get("worldTime")) is not int + or len(rows) != len(expected) or {(r["x"], r["z"]) for r in rows} != expected + or any(type(r.get("loaded")) is not bool for r in rows) + or type(document.get("ready")) is not bool + or document["ready"] != all(r["loaded"] for r in rows)): + raise AssertionError("Invalid or incomplete native chunk-readiness witness") + return document["ready"] + + +def acquire(rig): + bounds = {key: (rig.pos(key, value["boundsMin"]), rig.pos(key, value["boundsMax"])) + for key, value in rig.definitions.items()} + for low, high in bounds.values(): + rig.command(f"forceload add {low[0]} {low[2]} {high[0]} {high[2]}") + pending = set(bounds) + deadline = time.monotonic() + 30 + for attempt in range(80): + for key in sorted(pending): + low, high = bounds[key] + label = f"prebuild-{key}-{attempt:02}" + rig.command(f"advchunks {label} {low[0]} {low[2]} {high[0]} {high[2]}", "ADVCHUNKS OBSERVED " + label) + path = rig.runtime / "advantage-works-results" / ("chunk-readiness-" + label + ".json") + if validate(json.loads(path.read_text(encoding="utf-8")), label, low, high): pending.remove(key) + if not pending: return + if time.monotonic() >= deadline: break + time.sleep(.05) + raise TimeoutError("Native chunks did not finish loading before construction: " + str(sorted(pending))) diff --git a/test-rig/migration/native113_code.py b/test-rig/migration/native113_code.py new file mode 100644 index 0000000..d0ecee1 --- /dev/null +++ b/test-rig/migration/native113_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 matrix import Server as HistoricalServer, digest + +INVENTORY = "native113-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 native113 code inventory") + if code_files(root) != files: + raise AssertionError("Native113 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/native113_connected.py b/test-rig/migration/native113_connected.py new file mode 100644 index 0000000..cab532c --- /dev/null +++ b/test-rig/migration/native113_connected.py @@ -0,0 +1,270 @@ +"""Resume prebuilt, pinned connected source copies in two genuine native113 boots. + +Only staging and process adapters are injected into connected.prepare/resume; +the connected state, stock and operation oracles are reused unchanged. +""" + +import argparse +import json +from pathlib import Path +import re +import shutil +import socket +from unittest.mock import patch + +import connected +from matrix import WORLD, digest, fingerprint, write_json +from native113_campaign_support import (LAUNCHER, compare, native_normalized, output_path, pinned_profile, + require_clean_log, require_disjoint) +from native113_code import CodeCustody, INVENTORY, verify_code + + +def read_json(path): + return json.loads(path.read_text(encoding="utf-8")) + + +def require_command(command): + if command in ("stop", "save-all", "save-all flush"): + return + if re.fullmatch(r"advconnected snapshot [A-Za-z0-9_-]+", command): + return + match = re.fullmatch(r"advconnected step ([0-9]+) [A-Za-z0-9_-]+", command) + if match and 1 <= int(match[1]) <= 4000: + return + raise AssertionError("Forbidden native connected mutation: " + command) + + +def require_document(document, definition, *, destination): + expected = {entry["id"]: entry for entry in definition["blocks"]} + rows = document["snapshot"] + actual = {entry["id"]: entry for entry in rows} + if len(expected) != len(definition["blocks"]) or len(actual) != len(rows) or actual.keys() != expected.keys(): + raise AssertionError("Connected witness IDs/count changed") + for key, entry in actual.items(): + for field in ("station", "kind", "name", "x", "y", "z"): + if entry[field] != expected[key][field]: + raise AssertionError(f"Connected witness changed: {key}.{field}") + if not document.get("held"): + raise AssertionError("Connected snapshot is not frozen") + if destination and document.get("buildCallsThisProcess") != 0: + raise AssertionError("Destination construction is forbidden") + verdict = connected.state(document) + if verdict["status"] != "PASS": + raise AssertionError("Invalid connected stocks: " + json.dumps(verdict)) + return verdict + + +def require_native_boot(log): + require_clean_log(log) + text = log.read_text(encoding="utf-8", errors="replace") + for marker in ("Starting minecraft server version 1.13.2", + "Forge mod loading, version 25.0.223, for MC 1.13.2"): + if marker not in text: + raise AssertionError("Missing genuine native113 boot proof: " + marker) + + +def verify_source(args): + source = args.source.resolve() + definition = read_json(source / "connected-fixture.json") + if definition.get("format") != 1: + raise ValueError("Unsupported connected fixture format") + if definition.get("sourceProfile") != args.source_key: + raise ValueError("Source fixture profile does not match the explicit source key") + before = fingerprint(source) + expected = read_json(args.source_hashes) + if not expected or fingerprint(source / WORLD) != expected: + raise AssertionError("Protected connected source world fingerprint mismatch") + profile = pinned_profile(args.source_manifest.resolve(), args.source_key) + pins = sorted((jar.name, digest(jar)) for jar in profile[2]) + staged = read_json(source / "input-jars.json") + if sorted((entry["file"], entry["sha256"].upper()) for entry in staged) != pins: + raise AssertionError("Source fixture inputs differ from the explicit archived profile") + if sorted((jar.name, digest(jar)) for jar in (source / "mods").glob("*.jar")) != pins: + raise AssertionError("Source runtime jars differ from the explicit archived profile") + if not re.fullmatch(r"[A-Za-z0-9_-]+", args.source_checkpoint): + raise ValueError("Unsafe source checkpoint") + snapshot = connected.read(source, args.source_checkpoint) + if snapshot["snapshot"] != read_json(source / f"migration-{args.source_checkpoint}.json"): + raise AssertionError("Retained connected and migration checkpoints disagree") + verdict = require_document(snapshot, definition, destination=False) + if snapshot.get("requestedTicks", 0) <= 0 or snapshot.get("advancedTicks") != snapshot["requestedTicks"]: + raise AssertionError("Source checkpoint lacks an exact measured native interval") + if args.source_checkpoint == "source": + source_operation = connected.operation(connected.read(source, "cold"), snapshot, snapshot["requestedTicks"]) + if source_operation["status"] != "PASS": + raise AssertionError("Retained source did not operate: " + json.dumps(source_operation)) + history = source.parent / "connected-results.json" + historical = read_json(history) if history.is_file() else None + if historical is not None and historical.get("sources", {}).get(args.source_key, {}).get("status") != "PASS": + raise AssertionError("Retained source operation did not pass; do not manufacture a replacement") + if fingerprint(source) != before: + raise AssertionError("Source changed during preflight") + return definition, snapshot, verdict, before, historical + + +def prepare_target(runtime, profile, definition, port, source, pins): + if profile[1] != LAUNCHER: + raise ValueError("Only the genuine native113 launcher is permitted") + expected_code = pins.get("serverCode") + verify_code(profile[0], expected_code) + launcher = connected.prepare(runtime, profile, definition, port, source) + verify_code(runtime, expected_code) + write_json(runtime / INVENTORY, expected_code) + # Existing chunks are copied unchanged. Only the new server's launch settings + # replace the legacy semicolon flat-generator syntax with native defaults. + properties = runtime / "server.properties" + lines = [line for line in properties.read_text().splitlines() if not line.startswith("generator-settings=")] + properties.write_text("\n".join(lines) + "\ngenerator-settings=\n", encoding="utf-8") + for marker in ("connected-build.allow", "connected-built.json"): + if (runtime / marker).exists(): + raise AssertionError("Source construction authority leaked into the destination") + expected = sorted((Path(jar["path"]).name, jar["sha256"].upper()) for jar in pins["jars"]) + actual = sorted((jar.name, digest(jar)) for jar in (runtime / "mods").glob("*.jar")) + if actual != expected: + raise AssertionError("Staged native candidate jars differ from the pinned profile") + if digest(runtime / launcher) != pins["launcher_sha256"].upper(): + raise AssertionError("Staged native launcher differs from the pinned profile") + return launcher + + +class NativeConnectedServer(CodeCustody, connected.ConnectedServer): + def __init__(self, runtime, java, launcher, label, on_loaded): + if launcher != LAUNCHER: + raise AssertionError("Refusing a non-native113 process") + self.on_loaded = on_loaded + self.loaded_verified = False + super().__init__(runtime, java, launcher, label) + try: + require_native_boot(Path(self.log.name)) + except BaseException: + self.close() + raise + + def send(self, command): + require_command(command) + if command.startswith("advconnected step ") and not self.loaded_verified: + raise AssertionError("Preservation must pass before any destination operation") + super().send(command) + + def observe(self, operation_name, label, ticks=0): + super().observe(operation_name, label, ticks) + if operation_name == "snapshot" and ticks == 0: + self.on_loaded(label) + self.loaded_verified = True + + +def continued_output(first, last): + initial, final = connected.state(first), connected.state(last) + missing = [key for key, case in initial["cases"].items() + if key != "battery" and case["remaining"] > 0 + and final["cases"][key]["produced"] <= case["produced"]] + return dict(status="FAIL" if missing else "PASS", failures=missing) + + +def run(args): + from semantic_machine_audit import connected_interval + from native113_resource_audit import compare_snapshot_resources + if not 1 <= args.port <= 65535 or not 1 <= args.ticks <= 4000: + raise ValueError("Port/tick interval outside native command bounds") + output = output_path(args.output) + for protected in (args.source, args.source_manifest, args.source_hashes, args.target_manifest): + require_disjoint(output, protected) + definition, checkpoint, source_state, source_before, historical = verify_source(args) + target = pinned_profile(args.target_manifest.resolve(), "target113") + target_pins = read_json(args.target_manifest)["target113"] + identities = {entry["id"] for entry in target_pins["jars"]} + if not {"steamadvantage", "electricadvantage"}.issubset(identities): + raise ValueError("Connected qualification requires native Steam and Electric candidates") + require_disjoint(output, target[0]) + for jar in target[2]: + require_disjoint(output, jar) + with socket.socket() as listener: + listener.bind(("127.0.0.1", args.port)) + output.mkdir(parents=True, exist_ok=False) + runtime = output / "target113" + report_path = output / "native113-connected-results.json" + report = dict(status="RUNNING", scope="prebuilt-connected-source-to-native113-two-boots", + protocol="No source process; destination snapshot/step/save/stop only; no rebuilding/refilling/resetting", + source=str(args.source.resolve()), sourceKey=args.source_key, + sourceCheckpoint=args.source_checkpoint, sourceState=source_state, + sourceManifest=str(args.source_manifest.resolve()), sourceManifestSha256=digest(args.source_manifest), + sourceHashes=str(args.source_hashes.resolve()), sourceHashesSha256=digest(args.source_hashes), + sourceRuntimeBefore=source_before, sourceWorldExpected=read_json(args.source_hashes), + sourceCheckpointSha256=digest(args.source / f"connected-{args.source_checkpoint}.json"), + targetManifest=str(args.target_manifest.resolve()), targetManifestSha256=digest(args.target_manifest), + targetProfile=target_pins, port=args.port, ticks=args.ticks, + runnerSha256=digest(Path(__file__)), oracleSha256=digest(Path(connected.__file__)), + semanticOracleSha256=digest(Path(__file__).with_name("semantic_machine_audit.py")), + historicalCampaign=historical, results={}) + write_json(report_path, report) + try: + launcher = prepare_target(runtime, target, definition, args.port, args.source, target_pins) + report["stagedInputs"] = read_json(runtime / "input-jars.json") + for label, previous in (("converted", args.source / f"migration-{args.source_checkpoint}.json"), + ("restarted", runtime / "migration-converted-worked.json")): + def on_loaded(loaded_label): + loaded = connected.read(runtime, loaded_label) + require_document(loaded, definition, destination=True) + if loaded.get("requestedTicks") != 0 or loaded.get("advancedTicks") != 0: + raise AssertionError("Initial preservation requires a frozen zero-step snapshot") + result = compare(previous, runtime / f"migration-{loaded_label}.json", native_normalized) + report["results"][label + "_preservation"] = result + resources = compare_snapshot_resources(previous, runtime / f"migration-{loaded_label}.json") + report["results"][label + "_exact_resources"] = resources + write_json(report_path, report) + if result["status"] != "PASS" or resources["status"] != "PASS": + raise AssertionError(label + " native113 preservation failed") + + def boot(current, settings, selected_launcher, selected_label): + if current.resolve() != runtime.resolve(): + raise AssertionError("Only the newly staged target may be launched") + return NativeConnectedServer(current, settings.java8, selected_launcher, selected_label, on_loaded) + + # Dependency injection is confined to the process factory, never an oracle. + with patch.object(connected, "boot", boot): + verdict = connected.resume(runtime, args, launcher, label) + report["results"][label + "_operation"] = verdict + report["results"][label + "_completed_work"] = connected_interval( + connected.read(runtime, label + "-loaded"), connected.read(runtime, label + "-worked"), + definition, args.ticks) + require_document(connected.read(runtime, label + "-worked"), definition, destination=True) + require_native_boot(runtime / f"console-{label}.log") + report["results"][label + "_native_boot"] = dict(status="PASS", launcher=launcher) + write_json(report_path, report) + report["results"]["real_output_across_restarts"] = continued_output( + connected.read(runtime, "converted-loaded"), connected.read(runtime, "restarted-worked")) + report["status"] = "PASS" if all(result["status"] == "PASS" for result in report["results"].values()) else "FAIL" + except BaseException as error: + report.update(status="FAIL", failure=str(error)) + raise + finally: + source_after = fingerprint(args.source) + report["sourceRuntimeAfter"] = source_after + preserved = source_after == source_before + report["sourcePreservation"] = dict(status="PASS" if preserved else "FAIL") + if not preserved: + report.update(status="FAIL", failure="Protected source runtime changed") + write_json(report_path, report) + if not preserved: + raise AssertionError("Protected source runtime changed") + print(report["status"] + ": " + str(report_path), flush=True) + return int(report["status"] != "PASS") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source", type=Path, required=True) + parser.add_argument("--source-manifest", type=Path, required=True) + parser.add_argument("--source-key", required=True) + parser.add_argument("--source-hashes", type=Path, required=True) + parser.add_argument("--source-checkpoint", default="source") + 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=25576) + parser.add_argument("--ticks", type=int, default=512) + return run(parser.parse_args()) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test-rig/migration/native113_diagnostics.py b/test-rig/migration/native113_diagnostics.py new file mode 100644 index 0000000..4d0bfe9 --- /dev/null +++ b/test-rig/migration/native113_diagnostics.py @@ -0,0 +1,48 @@ +"""Strict native log audit with narrowly recorded Forge 25 diagnostics.""" + +from pathlib import Path +import re +import tempfile + + +TEMP_CLEANUP = re.compile( + r"^\[[^\]]+\] \[Thread-[0-9]+/FATAL\] \[ne\.mi\.fm\.pa\.ModFileResourcePack/\]: " + r"Failed to clean up tempdir (.+)$") +CONFIG_RELOAD = re.compile( + r"^\[[^\]]+\] \[Thread-[0-9]+/FATAL\] \[ne\.mi\.co\.ForgeConfig/CORE\]: " + r"Forge config just got changed on the file system!$") + + +def audit_lines(lines, temp_root=None): + """The caller must also require a clean process exit and native startup markers.""" + temporary = Path(temp_root if temp_root is not None else tempfile.gettempdir()).resolve() + saving_complete = False + stopping = False + classified = [] + for number, line in enumerate(lines, 1): + if "Stopping server" in line or "Stopping the server" in line: + stopping = True + if "All chunks are saved" in line or "Saved the game" in line: + saving_complete = True + if "/ERROR]" in line or any(text in line for text in ( + "NoSuchFieldError", "NoSuchMethodError", "Failed to reload data packs", + "Encountered an unexpected exception")): + raise AssertionError("Native runtime error at line " + str(number) + ": " + line) + if "/FATAL]" not in line: + continue + if CONFIG_RELOAD.fullmatch(line): + classified.append(dict(line=number, message=line, + classification="FORGE_25_CONFIG_RELOAD_NOTICE", + scope="ForgeConfig.onFileChange logs its normal reload event; subsequent errors are not exempt")) + continue + match = TEMP_CLEANUP.fullmatch(line) + path = Path(match[1]).resolve() if match else None + if (match and stopping and saving_complete and path.parent == temporary + and re.fullmatch(r"modpacktmp[0-9]+", path.name)): + classified.append(dict(line=number, message=line, + classification="FORGE_25_POST_SAVE_TEMP_DIRECTORY_CLEANUP", + scope="Forge ModFileResourcePack shutdown hook; not a world save or an Advantage error")) + else: + raise AssertionError("Unclassified fatal runtime error at line " + str(number) + ": " + line) + return dict(status="PASS", classifiedDiagnostics=classified, + scope="Error/fatal audit only; native startup, process exit and machine outcomes are separate gates") diff --git a/test-rig/migration/native113_endurance.py b/test-rig/migration/native113_endurance.py new file mode 100644 index 0000000..6c9ccb8 --- /dev/null +++ b/test-rig/migration/native113_endurance.py @@ -0,0 +1,272 @@ +"""Measured native113 endurance with historical and independent semantic checks.""" + +import argparse +from collections import Counter +from datetime import datetime, timezone +import json +from pathlib import Path +import shutil +import socket +import time +import traceback + +import endurance +import semantic_machine_audit +import native113_chunk_readiness +from native113_code import INVENTORY, verify_code +from audit_endurance import expected_inventory_acknowledgements +from matrix import WORLD, digest, fingerprint, write_json +from native113_campaign_support import compare, native_normalized, output_path, pinned_profile, require_disjoint +from native113_campaign_support import (prepare, read_json, require_native_boot, + verify_source, verify_staged) + + +NAMES = ("fresh113", "original110-chained113-native113") + + +class NativeEnduranceRig(endurance.Rig): + def start(self, build=False): + if build: native113_chunk_readiness.acquire(self) + return super().start(build) + + def shutdown(self): + try: + return super().shutdown() + finally: + verify_code(self.runtime) + + def batch(self, label): + measured = semantic_machine_audit.endurance_batch( + {station: self.documents[station] for station in ("S-04", "E-03", "W-04", "W-03")}, self.definitions) + evidence = self.runtime / ("semantic-batch-" + label + ".json") + with evidence.open("x", encoding="utf-8") as stream: + json.dump(measured, stream, indent=2) + endurance.require(measured["status"] == "PASS", "Independent batch measurements failed: " + str(measured)) + return super().batch(label) + + def boot(self, phase): + verify_code(self.runtime) + super().boot(phase) + if phase != "endurance-restart": + return + # Restore only chunk tickets, never station actions or machine NBT. + # Observing a real tick is transport readiness, not a desired output. + for station in endurance.CHECKED: + definition = self.definitions[station] + low = self.pos(station, definition["boundsMin"]) + high = self.pos(station, definition["boundsMax"]) + self.command(f"forceload add {low[0]} {low[2]} {high[0]} {high[2]}") + self.checkpoint("native-restart-immediate") + initial = self.documents["W-01"]["worldTime"] + started = time.monotonic() + for attempt in range(200): + self.checkpoint(f"native-restart-ready-{attempt:03}") + observed = self.documents["W-01"]["worldTime"] + if observed > initial: + write_json(self.runtime / "native-restart-readiness.json", { + "initial_world_time": initial, "selected_world_time": observed, + "selected_attempt": attempt, "elapsed_seconds": time.monotonic() - started, + "selection": "First measured tick advance; no inventory/output inspection", + }) + return + if time.monotonic() - started >= 20: + break + time.sleep(0.01) + raise TimeoutError("No actual native tick after restoring endurance tickets") + + +def audit(root, require_endurance=True): + report = read_json(root / "native113-endurance-results.json") + expected_status = "PASS" if require_endurance else "SMOKE_PASS_NOT_ENDURANCE" + endurance.require(report["status"] == expected_status, "No completed native endurance result") + endurance.require(report["interval_seconds"] == 60, "Not the one-minute batch protocol") + endurance.require(report["duration_seconds"] >= (1800 if require_endurance else 60), "Duration too short") + endurance.require(report["source_fixture_unchanged"] and report["helpers_unchanged"], "Source/protocol changed") + endurance.require(set(report["profiles"]) == set(NAMES), "Missing native environment") + result = dict(status="PASS", scope="endurance" if require_endurance else "smoke-only", profiles={}) + failures = ("Unknown slot", "Could not replace", "Unknown or incomplete command", "Incorrect argument", + "Unknown item", "There is no container") + for name, profile in report["profiles"].items(): + endurance.require(profile["status"] == "PASS" and profile["restart"] == "PASS" + and profile["resumed_processing"] == "PASS", "Incomplete profile") + endurance.require(profile["elapsed_seconds"] >= report["duration_seconds"], "Wall time too short") + endurance.require(profile["game_ticks"] >= report["duration_seconds"] * 18, "Tick rate too low") + runtime = root / name + counts = expected_inventory_acknowledgements(profile["endurance_batches"]) + for filename, count in zip(("console-endurance.log", "console-endurance-restart.log"), counts): + text = (runtime / filename).read_text(encoding="utf-8") + require_native_boot(runtime / filename) + endurance.require(not any(f in text for f in failures), "Native command failure") + endurance.require(text.count("Replaced a slot at") == count, "Inventory command acknowledgements differ") + if "restart" in filename: + endurance.require(not any(f"Started {s} " in text for s in ("S-04", "E-03", "E-02", "S-02", "E-01")), + "Restart replaced source machines") + endurance.require("Advantage Works built" not in text and "Reset 1 station" not in text, + "Restart rebuilt the rig") + pins = {item["file"]: item["sha256"] for item in report["artifacts"]} + actual = {jar.name: digest(jar) for jar in (runtime / "mods").glob("*.jar")} + endurance.require(actual == pins, "Staged native artifacts changed") + ledger = read_json(runtime / "endurance-ledger.json") + batches = [b for b in ledger if b["label"].startswith("cycle-")] + total = report["duration_seconds"] // report["interval_seconds"] + endurance.require(len(batches) == profile["endurance_batches"] == total, "Batch count differs") + endurance.require({b["label"] for b in batches} == {f"cycle-{i:03}" for i in range(1, total + 1)}, + "Repeated or missing batch") + endurance.require([b["label"] for b in ledger[-3:]] == ["before-restart", "after-restart", "restart-resumed"], + "Missing restart checkpoints") + result["profiles"][name] = dict(status="PASS", batches=total, inventory_acknowledgements=list(counts)) + return result + + +def run(args): + endurance.require(args.seconds >= 60 and args.seconds % 60 == 0, "Use whole one-minute batches") + output = output_path(args.output) + for protected in (args.source, args.source_manifest, args.source_hashes, args.target_manifest): + require_disjoint(output, protected) + source_before, history = verify_source(args) + target = pinned_profile(args.target_manifest.resolve(), "target113") + target_code = read_json(args.target_manifest)["target113"].get("serverCode") + verify_code(target[0], target_code) + for protected in (target[0], *target[2]): + require_disjoint(output, protected) + for port in (args.port, args.port + 1): + with socket.socket() as listener: + listener.bind(("127.0.0.1", port)) + definition_path = Path(__file__).parents[1] / "src/main/resources/assets/advantageworkstest/works/works-1.13.2.json" + definitions = {s["id"]: s for s in read_json(definition_path)["stations"]} + endurance.require(sum(bool(definitions[s].get("exclusive")) for s in endurance.ACTIVE) == 1, + "Exclusive stations interfere") + helpers = [Path(endurance.__file__), Path(__file__), Path(semantic_machine_audit.__file__), definition_path, + Path(native113_chunk_readiness.__file__), *[Path(__file__).with_name(name) for name in + ("native113_campaign_support.py", "native113_lifecycle.py", "native113_diagnostics.py", "native113_code.py", "native113.py")]] + before_helpers = {str(p): digest(p) for p in helpers} + output.mkdir(parents=True, exist_ok=False) + for path in helpers: + destination = output / "protocol-sources" / path.name + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(path, destination) + report_path = output / "native113-endurance-results.json" + report = dict(status="RUNNING", duration_seconds=args.seconds, interval_seconds=60, + feeding_protocol="IInventory replaceitem and fluid handlers; no inventory NBT writes", + source=str(args.source.resolve()), source_state_gates=history, + source_fingerprint=source_before, helper_sha256=before_helpers, + target_manifest=str(args.target_manifest.resolve()), target_manifest_sha256=digest(args.target_manifest), + artifacts=[dict(file=j.name, sha256=digest(j)) for j in target[2]], + active_stations=endurance.ACTIVE, checked_stations=endurance.CHECKED, profiles={}, + coverage="Rebuilt Works in fresh and migrated-copy worlds; not endurance of original source machinery", + manual_checks="Machine GUIs, weapons, sound and player interaction remain manual") + write_json(report_path, report) + rigs = [] + try: + for index, name in enumerate(NAMES): + runtime = output / name + launcher = prepare(runtime, target, [], args.port + index, args.source if index else None) + verify_code(runtime, target_code) + write_json(runtime / INVENTORY, target_code) + (runtime / "advantage-works.allow").touch() + rig = NativeEnduranceRig(name, runtime, args.java8, launcher, definitions) + rigs.append(rig) + if index: + rig.boot("frozen-import") + rig.server.snapshot("frozen-import") + rig.command("save-all flush") + rig.shutdown() + preserved = compare(args.source / "migration-restarted.json", runtime / "migration-frozen-import.json", + native_normalized) + write_json(runtime / "native113-import-comparison.json", preserved) + endurance.require(preserved["status"] == "PASS", "Copied source did not preserve frozen state") + require_native_boot(runtime / "console-frozen-import.log") + (runtime / "migration-operate.allow").touch() + rig.boot("endurance") + rig.start(build=True) + initial_ticks = {r.name: r.documents["W-01"]["worldTime"] for r in rigs} + started = time.monotonic() + report["started_at_utc"] = datetime.now(timezone.utc).isoformat() + cycles = args.seconds // 60 + for cycle in range(1, cycles + 1): + deadline = started + cycle * 60 + while time.monotonic() < deadline: + for rig in rigs: + endurance.require(rig.server.process.poll() is None, rig.name + ": server died") + time.sleep(min(1, max(0, deadline - time.monotonic()))) + for rig in rigs: + rig.checkpoint(f"cycle-{cycle:03}") + rig.batch(f"cycle-{cycle:03}") + if cycle < cycles: + rig.seed() + report["profiles"][rig.name] = dict(status="RUNNING", completed_batches=len(rig.ledger), + latest_world_time=rig.documents["W-01"]["worldTime"]) + write_json(report_path, report) + print(f"{cycle * 60}s: both native profiles passed batch {cycle}", flush=True) + elapsed = time.monotonic() - started + ending_ticks = {r.name: r.documents["W-01"]["worldTime"] for r in rigs} + for rig in rigs: + endurance.require(ending_ticks[rig.name] - initial_ticks[rig.name] >= args.seconds * 18, + rig.name + ": fewer than 18 game ticks per second") + rig.structure() + rig.checkpoint("before-restart") + rig.batch("before-restart") + rig.command("save-all flush") + rig.shutdown() + rig.boot("endurance-restart") + rig.checkpoint("after-restart") + rig.batch("after-restart") + rig.structure() + rig.seed() + time.sleep(60) + for rig in rigs: + rig.checkpoint("restart-resumed") + rig.batch("restart-resumed") + statuses = rig.structure() + rig.command("save-all flush") + rig.shutdown() + totals = Counter() + for batch in rig.ledger: + if batch["label"].startswith("cycle-"): + totals.update(batch["outputs"]) + report["profiles"][rig.name] = dict(status="PASS", elapsed_seconds=elapsed, + endurance_batches=cycles, total_verified_checkpoints=len(rig.ledger), + station_statuses=statuses, restart="PASS", resumed_processing="PASS", + game_ticks=ending_ticks[rig.name] - initial_ticks[rig.name], + endurance_outputs=dict(totals)) + report["status"] = "PASS" if args.seconds >= 1800 else "SMOKE_PASS_NOT_ENDURANCE" + except BaseException as error: + report.update(status="FAIL", failure=str(error), traceback=traceback.format_exc()) + raise + finally: + for rig in rigs: + try: + rig.shutdown() + verify_staged(rig.runtime, target) + for log in rig.runtime.glob("console-*.log"): + require_native_boot(log) + except Exception as error: + report.update(status="FAIL", shutdown_or_audit_error=str(error)) + report["source_fixture_unchanged"] = fingerprint(args.source.resolve()) == source_before + report["helpers_unchanged"] = {str(p): digest(p) for p in helpers} == before_helpers + if not report["source_fixture_unchanged"] or not report["helpers_unchanged"]: + report.update(status="FAIL", provenance_error="Protected source or protocol changed") + report["completed_at_utc"] = datetime.now(timezone.utc).isoformat() + write_json(report_path, report) + endurance.require(report["status"] != "FAIL", "See saved native endurance failure") + write_json(output / "qualification-audit.json", audit(output, require_endurance=args.seconds >= 1800)) + print(report["status"] + ": " + str(report_path), flush=True) + return 0 + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source", type=Path, required=True) + parser.add_argument("--source-manifest", type=Path, required=True) + parser.add_argument("--source-key", default="target113") + parser.add_argument("--source-hashes", type=Path, required=True) + 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("--seconds", type=int, default=1800) + parser.add_argument("--port", type=int, default=25590) + return run(parser.parse_args()) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test-rig/migration/native113_harvest.py b/test-rig/migration/native113_harvest.py new file mode 100644 index 0000000..108a383 --- /dev/null +++ b/test-rig/migration/native113_harvest.py @@ -0,0 +1,157 @@ +"""Actual survival mining and silk-touch drops in two clean native113 boots.""" + +import argparse +from datetime import datetime, timezone +from pathlib import Path +import traceback + +from matrix import Server, digest, write_json +from native113 import output_path, pinned_profile, require_disjoint +from native113_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 = "native113-live-drops-v1" + + +def require_checks(document, label): + rows = document.get("checks", []) + actual = {row["id"]: row for row in rows} + if (document.get("label") != label or document.get("minecraft") != "1.13.2" + or actual.keys() != EXPECTED.keys() | {"solar/native-solidity"} + or len(rows) != len(EXPECTED) + 1): + raise AssertionError("Incomplete native113 harvest witness set") + if document.get("status") != "PASS": + failures = [row["id"] + ": " + row.get("failure", row.get("status", "missing status")) + for row in rows if row.get("status") != "PASS"] + raise AssertionError("Native113 harvest witnesses failed: " + "; ".join(failures[:5])) + 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 + if (row.get("status") != "PASS" or row.get("historicalHarvestLevel") != level + or row.get("harvestAllowed") is not allowed or row.get("silkTouch") is not silk + or row.get("connectionMask") != mask + or row.get("actualSolid") is not name.startswith(("sulfur/", "lithium/", "wire/")) + or row.get("fullCollision") is not (not name.startswith("wire/")) + or row.get("trials") != trials 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) + if actual["solar/native-solidity"].get("status") != "PASS" or actual["solar/native-solidity"].get("actualSolid") is not True: + raise AssertionError("Historical photovoltaic solid=true property was lost") + + +def require_isolation(document): + if document.get("fixtureIsolation") != ISOLATION_PROTOCOL: + raise AssertionError("Missing native113 live-drop fixture isolation protocol") + seen_ids, seen_uuids = set(), set() + for row in document["checks"]: + if row["id"] == "solar/native-solidity": + continue + evidence = row.get("dropEvidence", []) + if len(evidence) != row["trials"]: + raise AssertionError("Incomplete per-trial drop isolation evidence: " + row["id"]) + for trial, entry in enumerate(evidence): + if (entry.get("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: " + row["id"]) + drops = entry.get("actualDrops") + if not isinstance(drops, list): + raise AssertionError("Missing actual native drop identities: " + row["id"]) + count = 0 + for drop in drops: + entity_id, uuid = drop.get("entityId"), drop.get("uuid") + if (type(entity_id) is not int or entity_id in seen_ids or not isinstance(uuid, str) + or not uuid or uuid in seen_uuids or drop.get("removed") is not False + or not isinstance(drop.get("item"), str) or not drop["item"] + or type(drop.get("count")) is not int or drop["count"] <= 0): + raise AssertionError("Removed, repeated or invalid native harvest drop: " + row["id"]) + 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: " + row["id"]) + + +def mark_runtime(output): + (output / "advantage-native-harvest.allow").touch() + (output / "advantage-works.allow").touch() + + +def run(args): + output = output_path(args.output) + require_disjoint(output, args.target_manifest) + target = pinned_profile(args.target_manifest.resolve(), "target113") + for protected in (target[0], *target[2]): + require_disjoint(output, protected) + launcher = prepare(output, target, [], args.port) + mark_runtime(output) + 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 PlayerInteractionManager harvesting, failed tools, exact legacy drop bounds, silk touch and full restart", + source_baseline="EA 1.10/1.12 sulfur pickaxe 0 quantity 1..4; lithium pickaxe 1 quantity 1; Power steel-frame normal harvesting") + helpers = [Path(__file__), *[Path(__file__).with_name(name) for name in + ("native113.py", "native113_lifecycle.py", "native113_code.py", "native113_diagnostics.py")]] + report["protocolSha256"] = {str(path): digest(path) for path in helpers} + server = None + try: + for label in ("exercise", "restored"): + server = Server(output, args.java8, launcher, label) + server.send("forceload add 1198 1022 1202 1026") + 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 + 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 the run") + write_json(output / "native113-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/native113_legacy_snbt.py b/test-rig/migration/native113_legacy_snbt.py new file mode 100644 index 0000000..31a8369 --- /dev/null +++ b/test-rig/migration/native113_legacy_snbt.py @@ -0,0 +1,66 @@ +"""Parse legacy diagnostic SNBT, requiring binary evidence for lossy byte summaries.""" + +from itertools import tee +import nbtlib +from nbtlib.literal.parser import Parser, tokenize, IncompatibleItemType + + +class ByteArraySummary(nbtlib.ByteArray): + def __new__(cls, length): + value = super().__new__(cls, []) + value.summary_length = length + return value + + +class LegacyParser(Parser): + def list_tokens(self): + self.next() + while self.current_token.type != "CLOSE_BRACKET": + yield self.current_token + self.next() + if self.current_token.type == "CLOSE_BRACKET": return + if self.current_token.type != "COMMA": raise self.error("Expected list comma") + self.next() + + def parse_list(self): + values, indexed = [], None + for token in self.list_tokens(): + self.token_stream, lookahead = tee(self.token_stream) + following = next(lookahead, None) + if not values and token.type == "NUMBER" and following is not None and following.value == "bytes": + if not token.value.isdigit(): raise self.error("Invalid byte summary length") + self.next().next() + if self.current_token.type != "CLOSE_BRACKET": raise self.error("Malformed byte summary") + return ByteArraySummary(int(token.value)) + has_index = token.type == "NUMBER" and following is not None and following.type == "COLON" + if indexed is None: indexed = has_index + if has_index != indexed: raise self.error("Mixed indexed and plain list") + if has_index: + if token.value != str(len(values)): raise self.error("Nonsequential legacy list index") + self.next().next() + values.append(self.parse()) + try: + return nbtlib.List(values) + except IncompatibleItemType as error: + raise self.error(str(error)) from None + + +def _resolve(value, binary): + if isinstance(value, ByteArraySummary): + if not isinstance(binary, nbtlib.ByteArray) or len(binary) != value.summary_length: + raise ValueError("Legacy byte summary requires matching binary array type and length") + return binary.copy() + if isinstance(value, nbtlib.Compound): + return nbtlib.Compound({key: _resolve(tag, binary.get(key) if isinstance(binary, nbtlib.Compound) else None) + for key, tag in value.items()}) + if isinstance(value, nbtlib.List): + peers = binary if isinstance(binary, (nbtlib.List, nbtlib.IntArray, nbtlib.LongArray)) else [] + return nbtlib.List([_resolve(tag, peers[index] if index < len(peers) else None) for index, tag in enumerate(value)]) + return value + + +def parse_legacy_snbt(literal, binary=None): + parser = LegacyParser(tokenize(literal)) + value = parser.parse() + if literal[parser.token_span[1]:].strip(): raise parser.error("Trailing NBT content") + return _resolve(value, binary) diff --git a/test-rig/migration/native113_lifecycle.py b/test-rig/migration/native113_lifecycle.py new file mode 100644 index 0000000..25a7ecb --- /dev/null +++ b/test-rig/migration/native113_lifecycle.py @@ -0,0 +1,41 @@ +"""Disposable-runtime transport for expanded 1.13 checks; no expected-state writes.""" + +import json +from pathlib import Path +from matrix import copy_fixture, digest, stage, write_json +from native113 import LAUNCHER +from native113_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") + diagnostics = audit_lines(text.splitlines()) + 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.13.2", "Forge mod loading, version 25.0.223, for MC 1.13.2"): + if marker not in text: raise AssertionError("Missing genuine native113 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")} + if actual != 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 native113 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/native113_machine_operations.py b/test-rig/migration/native113_machine_operations.py new file mode 100644 index 0000000..74f7239 --- /dev/null +++ b/test-rig/migration/native113_machine_operations.py @@ -0,0 +1,258 @@ +"""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 matrix import digest, write_json +from native113 import RIG, LAUNCHER, output_path, pinned_profile, require_disjoint +from native113_code import Server, code_files, verify_code, INVENTORY +from native113_lifecycle import prepare, read_json, require_native_boot, verify_staged + + +REQUIRED = { + "pickup": {"power-drain", "steam-pump", "electric-pump"}, + "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"}, +} + + +def require_case_set(catalog, group): + 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") + 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.4.113021.jar", + "steamadvantage": RIG.parent.parent / "SteamAdvantage/SteamAdvantage/build/libs/SteamAdvantage-2.2.5.113021.jar", + "electricadvantage": RIG.parent.parent / "ElectricAdvantage/ElectricAdvantage/build/libs/ElectricAdvantage-2.2.5.113021.jar", + "advantageworkstest": RIG / "build/gradle/libs/AdvantageWorksTestHarness-1.1.0.113021.jar", + } + if getattr(args, "original_production", False): + replacements = {key: value for key, value in replacements.items() if key == "advantageworkstest"} + target = copy.deepcopy(base["target113"]) + 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, {"target113": target, "baseManifest": str(args.base.resolve()), "baseSha256": digest(args.base)}) + pinned_profile(path, "target113") + print(str(path), flush=True) + return 0 + + +def run(args): + output = output_path(args.output) + target = pinned_profile(args.manifest.resolve(), "target113") + 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)["target113"].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("native113_lifecycle.py"), + Path(__file__).with_name("native113_diagnostics.py"), + Path(__file__).with_name("native113_code.py"), Path(__file__).with_name("native113.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", 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 / "native113-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 / "native113-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), 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/native113_oil.py b/test-rig/migration/native113_oil.py new file mode 100644 index 0000000..7a7c46c --- /dev/null +++ b/test-rig/migration/native113_oil.py @@ -0,0 +1,367 @@ +"""Qualify native113 bucket/drain/pipe transport in a disposable pinned runtime. + +The original oil_transfer geometry and quantity/identity oracles are unchanged. +Only the initial empty construction and two native bucket placements may mutate +the fixture; subsequent boots must retain exactly 1000, then 2000 mB per lane. +""" + +import argparse +from copy import deepcopy +from datetime import datetime, timezone +import hashlib +import json +from pathlib import Path +import re +import socket +import time +import traceback + +from lifecycle import LifecycleRun, at, parse +from matrix import digest, fingerprint, stage, write_json +from native113 import LAUNCHER, RIG, output_path, pinned_profile, require_disjoint +from native113_lifecycle import require_native_boot +from native113_code import INVENTORY, verify_code +from native113_chunk_readiness import validate as chunks_ready +import oil_transfer + + +BOOT_LABELS = ("oil-first-fill", "oil-restart-refill", "oil-second-restart") +CONFIG_NOTICE = "[ne.mi.co.ForgeConfig/CORE]: Forge config just got changed on the file system!" + + +def read_json(path): + return json.loads(path.read_text(encoding="utf-8")) + + +def fixtures(pins): + definition = deepcopy(oil_transfer.fixtures()) + mineralogy = [jar for jar in pins["jars"] if jar["id"] == "mineralogy"] + if mineralogy: + raise ValueError("This native 1.13 profile qualifies Advantage oils only; Mineralogy needs its own pinned profile") + require_bounds(definition) + return definition + + +def require_bounds(definition): + ids = set() + for lane in definition: + key, low, high = lane["id"], lane["min"], lane["max"] + if key in ids or not re.fullmatch(r"[A-Za-z0-9_-]+", key): + raise AssertionError("Invalid or duplicate oil lane ID") + ids.add(key) + if (len(low) != 3 or len(high) != 3 or low[0] < 1024 or low[2] < 1024 + or low[1] < 8 or high[1] > 240 + or any(not 0 <= b - a <= 31 for a, b in zip(low, high))): + raise AssertionError("Oil lane is outside original remote fixture bounds") + route = [lane["source"], lane["drain"], *lane["pipes"], lane["output"]] + if lane["witnesses"] != route or len({tuple(pos) for pos in route}) != len(route): + raise AssertionError("Oil lane witness geometry changed") + x, y, z = lane["source"] + walls = [[x + dx, y, z + dz] for dx, dz in ((-1, 0), (1, 0), (0, -1), (0, 1))] + for pos in [*route, *walls]: + if len(pos) != 3 or not all(a <= value <= b for a, value, b in zip(low, pos, high)): + raise AssertionError("Oil witness or containment wall outside fixture bounds") + if y >= high[1] or any(sum(abs(a - b) for a, b in zip(p, q)) != 1 + for p, q in zip(route, route[1:])): + raise AssertionError("Oil route disconnected or missing bucket headroom") + for other in definition: + if other is not lane and all(a <= d and c <= b for a, b, c, d in + zip(low, high, other["min"], other["max"])): + raise AssertionError("Oil lane bounds overlap") + + +def require_document(snapshot, definition): + cases = snapshot["cases"] + if set(cases) != {lane["id"] for lane in definition}: + raise AssertionError("Oil snapshot lane IDs/count changed") + for lane in definition: + positions = [tuple(entry["pos"]) for entry in cases[lane["id"]]] + if len(set(positions)) != len(positions) or set(positions) != {tuple(p) for p in lane["witnesses"]}: + raise AssertionError("Oil snapshot has missing, duplicate or moved witnesses") + + +def verify_staged(runtime, pins, seed_before): + verify_code(runtime, pins["serverCode"]) + expected = sorted((Path(jar["path"]).name, jar["sha256"].upper()) for jar in pins["jars"]) + actual = sorted((jar.name, digest(jar)) for jar in (runtime / "mods").glob("*.jar")) + recorded = sorted((jar["file"], jar["sha256"].upper()) for jar in read_json(runtime / "input-jars.json")) + if len({name for name, _ in expected}) != len(expected) or actual != expected or recorded != expected: + raise AssertionError("Staged native jars differ from captured manifest pins") + if digest(runtime / LAUNCHER) != pins["launcher_sha256"].upper(): + raise AssertionError("Staged native launcher differs from captured manifest pin") + # The installed seed's complete libraries and root launch jars, not its + # worlds/config/mods, are the only seed content matrix.stage may copy. + expected_libraries = {str(Path(name).relative_to("libraries")): value for name, value in seed_before.items() + if Path(name).parts[0] == "libraries"} + if not expected_libraries or fingerprint(runtime / "libraries") != expected_libraries: + raise AssertionError("Staged native libraries differ from seed fingerprint") + expected_roots = {name: value for name, value in seed_before.items() + if len(Path(name).parts) == 1 and name.endswith(".jar") and "installer" not in name} + if {jar.name: digest(jar) for jar in runtime.glob("*.jar")} != expected_roots: + raise AssertionError("Staged root jars differ from seed fingerprint") + + +def prepare(runtime, profile, definition, port, pins, seed_before): + if profile[1] != LAUNCHER: + raise ValueError("Refusing a non-native113 oil runtime") + launcher = stage(runtime, profile) + properties = runtime / "server.properties" + lines = [line for line in properties.read_text().splitlines() + if not line.startswith(("server-port=", "generator-settings="))] + properties.write_text("\n".join(lines) + f"\nserver-port={port}\ngenerator-settings=\n", encoding="utf-8") + for marker in ("migration-operate.allow", "advantage-works.allow", "advantage-lifecycle.allow"): + (runtime / marker).touch() + write_json(runtime / "lifecycle-fixture.json", definition) + write_json(runtime / INVENTORY, pins["serverCode"]) + verify_staged(runtime, pins, seed_before) + return launcher + + +def construction_commands(definition): + commands = [] + for lane in definition: + commands.append("fill " + " ".join(map(str, lane["min"] + lane["max"])) + " minecraft:air replace") + blocks = [(p, "poweradvantage:fluid_pipe") for p in lane["pipes"]] + blocks += [(lane["drain"], "poweradvantage:fluid_drain"), (lane["output"], lane["tank"])] + x, y, z = lane["source"] + blocks += [([x + dx, y, z + dz], "minecraft:glass") for dx, dz in ((-1, 0), (1, 0), (0, -1), (0, 1))] + commands.extend("setblock " + " ".join(map(str, pos)) + " " + block for pos, block in blocks) + return commands + + +class NativeOilRun(LifecycleRun): + def __init__(self, runtime, java, launcher, definition, pins, seed_before): + super().__init__(runtime, java, launcher, definition) + self.pins, self.seed_before = pins, seed_before + self.boots = [] + self.pending_construction = construction_commands(definition) + self.construction_sealed = False + self.bucket_permission = False + self.bucket_placements = 0 + + def boot(self, label): + if self.server is not None or self.launcher != LAUNCHER or self.boots != list(BOOT_LABELS[:len(self.boots)]): + raise AssertionError("Invalid native oil process lifecycle") + if len(self.boots) >= 3 or label != BOOT_LABELS[len(self.boots)]: + raise AssertionError("Only the three ordered native oil boots are permitted") + if self.boots and not self.construction_sealed: + raise AssertionError("Restart before initial construction was sealed") + verify_staged(self.runtime, self.pins, self.seed_before) + super().boot(label) + self.boots.append(label) + require_native_boot(self.runtime / f"console-{label}.log") + + def probe(self, operation, label): + result = super().probe(operation, label) + if operation == "load": + pending = list(self.definition) + for attempt in range(80): + for lane in list(pending): + low, high = lane["min"], lane["max"] + key = f"ready-{label}-{lane['id']}-{attempt:02}" + super().command(f"advchunks {key} {low[0]} {low[2]} {high[0]} {high[2]}") + self.server.wait("ADVCHUNKS OBSERVED " + key, 120) + observed = read_json(self.runtime / "advantage-works-results" / f"chunk-readiness-{key}.json") + if chunks_ready(observed, key, low, high): + pending.remove(lane) + if not pending: + return result + time.sleep(.05) + raise TimeoutError("Native oil chunks did not finish loading before construction") + return result + + def close(self): + try: + return super().close() + finally: + verify_staged(self.runtime, self.pins, self.seed_before) + for label in self.boots: + require_native_boot(self.runtime / f"console-{label}.log") + + def seal_construction(self): + if self.pending_construction or self.construction_sealed or len(self.boots) != 1: + raise AssertionError("Incomplete or repeated initial oil construction") + self.construction_sealed = True + + def permit_buckets(self): + if not self.construction_sealed or self.bucket_placements != len(self.boots) - 1 or len(self.boots) > 2: + raise AssertionError("Only one bucket placement after each preservation gate is permitted") + self.bucket_permission = True + + def command(self, command): + if self.server is None: + raise AssertionError("No native oil process is running") + if self.pending_construction and command == self.pending_construction[0]: + if len(self.boots) != 1 or self.construction_sealed: + raise AssertionError("Oil construction after initial baseline is forbidden") + self.pending_construction.pop(0) + elif command == "save-all flush": + super().command(command) + self.server.wait("Saved the game", 120) + return + else: + match = re.fullmatch(r"advlifecycle (load|snapshot|place-buckets) [A-Za-z0-9_-]+", command) + if not match: + raise AssertionError("Forbidden native oil mutation: " + command) + if match[1] == "place-buckets": + if not self.bucket_permission or self.bucket_placements >= 2: + raise AssertionError("Bucket placement requires the empty/restored preservation gate") + self.bucket_permission = False + self.bucket_placements += 1 + super().command(command) + + +def exercise(runner, seconds, progress): + definition = runner.definition + phases = [] + + def record(phase, snapshot, expected): + require_document(snapshot, definition) + phases.append(dict(phase=phase, cases=oil_transfer.inspect(snapshot, definition, expected))) + progress(dict(phases=phases, full_process_restarts=len(runner.boots) - 1, + bucket_placements=runner.bucket_placements)) + + runner.boot(BOOT_LABELS[0]) + runner.probe("load", "oil-loaded") + for command in construction_commands(definition): + runner.command(command) + runner.seal_construction() + empty = runner.probe("snapshot", "oil-empty") + require_document(empty, definition) + for lane in definition: + for pos in (lane["drain"], lane["output"]): + if parse(at(empty, lane["id"], pos)).get("Tank", {}).get("Amount", 0) != 0: + raise AssertionError("Fixture was not empty: " + lane["id"]) + oil_transfer.require_same_machines(empty, empty, definition) + runner.permit_buckets() + runner.probe("place-buckets", "oil-first-buckets") + time.sleep(seconds) + first = runner.probe("snapshot", "oil-first-fill") + record("first-fill", first, 1000) + oil_transfer.require_same_machines(empty, first, definition) + runner.command("save-all flush") + runner.close() + + runner.boot(BOOT_LABELS[1]) + runner.probe("load", "oil-restart-loaded") + time.sleep(seconds) + restored = runner.probe("snapshot", "oil-restored") + record("restored", restored, 1000) + runner.permit_buckets() + runner.probe("place-buckets", "oil-refill-buckets") + time.sleep(seconds) + refilled = runner.probe("snapshot", "oil-refilled") + record("refilled", refilled, 2000) + oil_transfer.require_same_machines(restored, refilled, definition) + runner.command("save-all flush") + runner.close() + + runner.boot(BOOT_LABELS[2]) + runner.probe("load", "oil-second-restart-loaded") + time.sleep(seconds) + record("retained", runner.probe("snapshot", "oil-retained"), 2000) + runner.command("save-all flush") + runner.close() + if runner.boots != list(BOOT_LABELS) or runner.bucket_placements != 2: + raise AssertionError("Incomplete native oil restart/refill protocol") + + +def run(args): + if not 1 <= args.port <= 65535 or not 2 <= args.seconds <= 120 or not args.java8.is_file(): + raise ValueError("Supply Java 8, a TCP port, and 2..120 seconds per observation interval") + output = output_path(args.output) + if not output.name.startswith("native113-oil-"): + raise ValueError("Use a new native113-oil-* output under test-rig/build") + manifest = args.target_manifest.resolve() + require_disjoint(output, manifest) + manifest_bytes = manifest.read_bytes() + manifest_hash = hashlib.sha256(manifest_bytes).hexdigest().upper() + pins = json.loads(manifest_bytes)["target113"] + profile = pinned_profile(manifest, "target113") + if digest(manifest) != manifest_hash: + raise AssertionError("Target manifest changed during preflight") + for protected in (profile[0], *profile[2]): + require_disjoint(output, protected) + definition = fixtures(pins) + with socket.socket() as listener: + listener.bind(("127.0.0.1", args.port)) + seed_before = fingerprint(profile[0]) + helpers = [Path(oil_transfer.__file__), Path(__file__), Path(__file__).with_name("lifecycle.py")] + helpers_before = {path.name: digest(path) for path in helpers} + output.mkdir(parents=True, exist_ok=False) + (output / "target-manifest.json").write_bytes(manifest_bytes) + report_path = output / "native113-oil-results.json" + runtime = output / "target113" + report = dict(status="RUNNING", started_at_utc=datetime.now(timezone.utc).isoformat(), + scope="native113-buckets-descending-pipes-two-full-restarts", + protocol="Initial empty construction only; native bucket sources twice; no tile.read() restores/API offers/manual tank refills/resets", + targetManifest=str(manifest), targetManifestSha256=manifest_hash, targetProfile=pins, + seed=str(profile[0]), seedFingerprintBefore=seed_before, helperSha256=helpers_before, + port=args.port, seconds=args.seconds, laneCount=len(definition), phases=[], full_process_restarts=0, + mineralogy="INCLUDED" if len(definition) == 6 else "ABSENT_FROM_MANIFEST") + write_json(output / "oil-fixture.json", definition) + write_json(report_path, report) + runner = None + try: + launcher = prepare(runtime, profile, definition, args.port, pins, seed_before) + report["stagedInputs"] = read_json(runtime / "input-jars.json") + runner = NativeOilRun(runtime, args.java8, launcher, definition, pins, seed_before) + + def progress(value): + report.update(value) + write_json(report_path, report) + print("Oil " + value["phases"][-1]["phase"] + ": PASS, " + str(len(definition)) + " lanes", flush=True) + + exercise(runner, args.seconds, progress) + report["status"] = "PASS" + except Exception as error: + report.update(status="FAIL", failure=str(error), traceback=traceback.format_exc()) + except BaseException as error: + report.update(status="FAIL", failure=repr(error), traceback=traceback.format_exc()) + raise + finally: + if runner is not None: + try: + runner.close() + except Exception as error: + report.update(status="FAIL", shutdown_failure=str(error)) + try: + verify_staged(runtime, pins, seed_before) + logs = sorted(runtime.glob("console-*.log")) + for log in logs: + require_native_boot(log) + report["nativeBoots"] = [log.name for log in logs] + report["forge_config_reload_notices"] = { + log.name: log.read_text(encoding="utf-8", errors="replace").count(CONFIG_NOTICE) for log in logs} + if report["status"] == "PASS" and len(logs) != 3: + raise AssertionError("Three complete native13 boot logs are required") + except Exception as error: + report.update(status="FAIL", runtime_audit_failure=str(error)) + try: + after = fingerprint(profile[0]) + report["seedFingerprintAfter"] = after + report["seed_unchanged"] = after == seed_before + report["helpers_unchanged"] = {path.name: digest(path) for path in helpers} == helpers_before + report["manifest_unchanged"] = digest(manifest) == manifest_hash + if not all(report[key] for key in ("seed_unchanged", "helpers_unchanged", "manifest_unchanged")): + raise AssertionError("Protected seed, helper or manifest changed") + except Exception as error: + report.update(status="FAIL", preservation_failure=str(error)) + report["completed_at_utc"] = datetime.now(timezone.utc).isoformat() + write_json(report_path, report) + print(report["status"] + ": " + str(report_path), flush=True) + return int(report["status"] != "PASS") + + +def 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=25576) + parser.add_argument("--seconds", type=int, default=8) + return run(parser.parse_args()) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test-rig/migration/native113_recipes.py b/test-rig/migration/native113_recipes.py new file mode 100644 index 0000000..1ea3fba --- /dev/null +++ b/test-rig/migration/native113_recipes.py @@ -0,0 +1,322 @@ +"""Native 1.14 recipe exports and progression on new hash-pinned disposable servers. + +Historical campaign modules supply assertion data only. No legacy profile, seed, +world, artifact fallback or historical result is used as native runtime evidence. +""" + +import argparse +from collections import Counter +from datetime import datetime, timezone +import json +from pathlib import Path +import re +import socket +import zipfile + +import nbtlib + +from native113_sealed_transport import configure as configure_transport, stage, Server +from matrix import WORLD, Server, digest, fingerprint, write_json +from native113 import RIG +from native113_campaign_support import output_path, pinned_profile, require_clean_log, require_disjoint +from progression import fixture as progression_fixture, validate_tags +from recipe_unlocks import MATERIALS + + +MOD_COUNTS = {"poweradvantage": 35, "steamadvantage": 24, "electricadvantage": 50} +RECIPE_TYPES = {"minecraft:crafting_shaped", "minecraft:crafting_shapeless", "minecraft:smelting"} +UNLOCK_BOOTS = ( + ("NORMAL", "normal", ("sweep", "persist")), + ("NORMAL", "normal-restart", ("restore",)), + ("TECH_PROGRESSION", "tech", ("restore", "sweep", "persist")), + ("APOCALYPTIC", "apocalyptic", ("restore", "sweep", "persist")), + ("NORMAL", "normal-return", ("restore",)), +) +MODES = ("NORMAL", "TECH_PROGRESSION", "APOCALYPTIC") + + +def read_json(path): + return json.loads(path.read_text(encoding="utf-8-sig")) + + +def recipe_fixture(jars): + recipes = [] + sources = {} + for path in jars: + with zipfile.ZipFile(path) as jar: + for mod in MOD_COUNTS: + prefix = f"data/{mod}/recipes/" + names = sorted(name for name in jar.namelist() if name.startswith(prefix) + and name.endswith(".json") and "/" not in name[len(prefix):]) + for name in names: + recipe = json.loads(jar.read(name)) + if recipe["type"] not in RECIPE_TYPES: + continue + stem = Path(name).stem + advancement_name = f"data/{mod}/advancements/recipes/{stem}.json" + advancement = json.loads(jar.read(advancement_name)) + predicates = {} + for key, criterion in advancement["criteria"].items(): + if criterion["trigger"] != "minecraft:inventory_changed": + continue + items = criterion["conditions"]["items"] + if len(items) != 1 or set(items[0]) not in ({"item"}, {"tag"}): + raise AssertionError(f"Unsupported inventory predicate: {advancement_name}/{key}") + predicates[key] = items[0] + if not predicates: + raise AssertionError(f"No ingredient-discovery predicates: {advancement_name}") + identity = f"{mod}:{stem}" + if identity in sources: + raise AssertionError(f"Duplicate packaged recipe: {identity}") + sources[identity] = {"jar": str(path), "recipe": name, "advancement": advancement_name} + recipes.append({"id": identity, "ingredients": predicates}) + counts = Counter(row["id"].split(":")[0] for row in recipes) + if counts != MOD_COUNTS: + raise AssertionError(f"Packaged recipe inventory changed: {dict(counts)}; expected {MOD_COUNTS}") + return {"recipes": recipes, "materials": MATERIALS}, sources + + +def native_target(manifest): + entry = read_json(manifest)["target113"] + required = {"advantageworkstest", "poweradvantage", "steamadvantage", "electricadvantage", "basemetals", "orespawn"} + if {jar["id"] for jar in entry["jars"]} != required: + raise ValueError("This campaign requires exactly the native harness and five packaged mods") + target = pinned_profile(manifest, "target113") + if any("deobf" in path.name.lower() for path in target[2]): + raise ValueError("Use packaged native jars, not development candidates") + return target + + +def require_free_port(port): + with socket.socket() as check: + check.bind(("127.0.0.1", port)) + + +def stage_native(runtime, target, port, campaign): + require_free_port(port) + launcher = stage(runtime, target) + # Replace only the disposable stage's legacy generator and port defaults. + props = dict(line.split("=", 1) for line in (runtime / "server.properties").read_text().splitlines() if "=" in line) + props.update({"server-port": str(port), "generator-settings": "", "level-type": "FLAT"}) + (runtime / "server.properties").write_text("".join(f"{key}={value}\n" for key, value in props.items()), encoding="utf-8") + marker = "advantage-recipes.allow" if campaign == "unlock" else "advantage-progression.allow" + (runtime / marker).touch() + if digest(runtime / launcher) != digest(target[0] / launcher): + raise AssertionError("Staged launcher differs from native input") + for jar in target[2]: + if digest(runtime / "mods" / jar.name) != digest(jar): + raise AssertionError(f"Staged mod differs from pinned input: {jar}") + if (runtime / WORLD).exists(): + raise AssertionError("Staging must not import an existing world") + return launcher + + +def configure(runtime, mode, rubber=True): + config = runtime / "config/poweradvantage-common.toml" + config.parent.mkdir(exist_ok=True) + config.write_text(f'[options]\nrecipeMode = "{mode}"\nplasticEqualsRubber = {str(rubber).lower()}\n', encoding="utf-8") + + +def validate_unlock(result, definition, operation): + expected = {row["id"] for row in definition["recipes"]} + rows = result["cases"] + if (result["status"] != "PASS" or len(rows) != len(expected) + or {row["id"] for row in rows} != expected or result["potatoCraftingCases"] != 59): + raise AssertionError("Incomplete native unlock/potato-crafting report") + if not result["bookRecipes"]: + raise AssertionError("Native server exported no recipe-book recipes") + if operation == "persist" and result["additionalMaterials"]: + raise AssertionError("Human materials checklist has gaps: " + str(result["additionalMaterials"])) + allowed = {"PASS", "SKIP_NO_INGREDIENT_PROVIDER"} + if any(row["status"] not in allowed for row in rows): + raise AssertionError("Failed native discovery case") + return {"cases": len(rows), "active": sum(row["recipeActive"] for row in rows), + "passed": sum(row["status"] == "PASS" for row in rows), + "skipped": sum(row["status"] != "PASS" for row in rows), "potatoCraftingCases": 59} + + +def validate_progression(result, mode, rubber): + validate_tags(result, mode, rubber) + if result["mode"] != mode or result["plasticEqualsRubber"] != rubber: + raise AssertionError("Native progression ignored its requested mode/rubber configuration") + if result["status"] != "PASS" or result["blocked"] != 0 or result["blockedRecipes"]: + raise AssertionError("Native manufacture blocked: " + str(result.get("blockedRecipes")) + "; " + + str([row["id"] for row in result["targets"] if row["status"] == "BLOCKED"])) + if not result["targets"] or not result["reachable"] or not result["executedRecipes"] or not result["processWitnesses"]: + raise AssertionError("Empty native manufacture evidence") + if not result["lootDataPackOverrideVerified"]: + raise AssertionError("Player loot override was not preserved") + + +def verify_level(runtime, entry): + level_path = runtime / WORLD / "level.dat" + level = nbtlib.load(level_path) + if str(level["Data"]["Version"]["Name"]) != "1.13.2": + raise AssertionError("Server did not write a native 1.13.2 level header") + mods = {str(row["ModId"]): str(row["ModVersion"]) for row in level["fml"]["LoadingModList"]} + if mods.get("forge") != "25.0.223" or any(mods.get(jar["id"]) != jar["version"] for jar in entry["jars"]): + raise AssertionError(f"Saved native mod identities differ from the target manifest: {mods}") + registry = level["fml"]["Registries"] + if any(not registry.get(name, {}).get("ids") for name in ("minecraft:items", "minecraft:blocks")): + raise AssertionError("Missing native Forge 25 item/block registry snapshot") + return {"path": str(level_path), "sha256": digest(level_path), "minecraft": "1.13.2", "mods": mods} + + +def audit_unlock_log(path, previous_recipes, current_recipes): + """Witness vanilla's exact mode-conditional saved-book pruning, not arbitrary errors.""" + removed = set(previous_recipes) - set(current_recipes) + observed = set() + events = [] + for original in path.read_text(encoding="utf-8", errors="replace").splitlines(): + line = re.sub(r"\x1b\[[0-9;]*m", "", original) + if "/ERROR]" not in line and not any(token in line for token in ( + "NoSuchFieldError", "NoSuchMethodError", "Failed to reload data packs", "Encountered an unexpected exception")): + continue + match = re.search(r"\[minecraft/ServerRecipeBook\]: Tried to load unrecognized recipe: ([a-z0-9_]+:[a-z0-9_./-]+) removed now\.$", line) + if not match or match[1] not in removed: + raise AssertionError(f"Unexpected native recipe runtime error: {original}") + observed.add(match[1]) + events.append(original) + if observed != removed: + raise AssertionError(f"Saved-book pruning did not match the native packet-set difference: expected {sorted(removed)}, saw {sorted(observed)}") + return {"expected_removed_ids": sorted(removed), "observed_removed_ids": sorted(observed), "events": events} + + +def unlock_campaign(root, target, entry, java, port, definition, report): + runtime = root / "unlock" + launcher = stage_native(runtime, target, port, "unlock") + write_json(runtime / "recipe-fixture.json", definition) + previous_recipes = set() + for mode, label, operations in UNLOCK_BOOTS: + configure(runtime, mode) + row = {"mode": mode, "label": label, "operations": [], "status": "RUNNING"} + report["unlock_boots"].append(row) + write_json(root / "native113-recipes-results.json", report) + print(f"Native114 unlock boot {label}: {', '.join(operations)}", flush=True) + server = Server(runtime, java, launcher, label) + try: + for operation in operations: + name = f"{label}-{operation}" + server.send(f"advrecipes {operation} {name}") + server.wait(f"ADVRECIPES COMPLETE {operation} {name}", 120) + result = read_json(runtime / f"recipes-{name}.json") + counts = validate_unlock(result, definition, operation) + packet = runtime / f"recipes-{name}.bin" + if not packet.is_file() or packet.stat().st_size == 0: + raise AssertionError("Missing native server recipe packet") + row["operations"].append({"operation": operation, "report": f"recipes-{name}.json", + "packet_sha256": digest(packet), **counts}) + server.send("save-all flush") + finally: + server.close() + current_recipes = {recipe["id"] for recipe in result["bookRecipes"]} + row["vanilla_recipe_pruning"] = audit_unlock_log(runtime / f"console-{label}.log", previous_recipes, current_recipes) + previous_recipes = current_recipes + verify_level(runtime, entry) + row["status"] = "PASS" + artifacts = [runtime / f"recipes-{mode}-sweep.{extension}" for mode in ("normal", "tech", "apocalyptic") + for extension in ("bin", "json")] + report["recipe_fixture"] = {"directory": str(runtime), "registry": verify_level(runtime, entry), + "files": [{"file": path.name, "sha256": digest(path)} for path in artifacts]} + + +def progression_campaign(root, target, entry, java, port, report): + runtime = root / "progression" + launcher = stage_native(runtime, target, port, "progression") + write_json(runtime / "progression-fixture.json", progression_fixture()) + pack = runtime / WORLD / "datapacks/progression-override" + write_json(pack / "pack.mcmeta", {"pack": {"pack_format": 4, "description": "Disposable loot-override witness"}}) + write_json(pack / "data/minecraft/loot_tables/chests/simple_dungeon.json", { + "pools": [{"name": "test_override", "rolls": 1, "entries": [{"type": "item", "name": "minecraft:dirt"}]}]}) + for mode in MODES: + for rubber in (True, False): + configure(runtime, mode, rubber) + for phase in ("initial", "restart"): + label = mode.lower() + ("-rubber" if rubber else "-no-rubber") + "-" + phase + print(f"Native114 progression boot {label}", flush=True) + server = Server(runtime, java, launcher, label) + try: + for reload in ((False, True) if phase == "initial" else (False,)): + name = label + ("-reload" if reload else "") + row = {"mode": mode, "rubber": rubber, "phase": "reload" if reload else phase, + "report": f"progression-{name}.json", "status": "RUNNING"} + report["progression_cases"].append(row) + write_json(root / "native113-recipes-results.json", report) + if reload: + server.send("reload") + server.wait("Reloading!", 60) + server.send(f"advprogression {name}") + server.wait(f"ADVPROGRESSION COMPLETE {name}", 240) + result = read_json(runtime / row["report"]) + validate_progression(result, mode, rubber) + row.update(status="PASS", targets=len(result["targets"]), + witnesses=len(result["processWitnesses"]), blocked=result["blocked"]) + server.send("save-all flush") + finally: + server.close() + require_clean_log(runtime / f"console-{label}.log") + verify_level(runtime, entry) + + +def run(args): + configure_transport(args.target_manifest) + manifest = args.target_manifest.resolve() + target = native_target(manifest) + entry = read_json(manifest)["target113"] + root = output_path(args.output) + for protected in (manifest, target[0], *target[2]): + require_disjoint(root, protected) + java = args.java8.resolve() + if not java.is_file() or 'JAVA_VERSION="1.8.' not in (java.parent.parent / "release").read_text(): + raise ValueError("Supply an existing Java 8 bin/java.exe") + require_free_port(args.port) + definition, sources = recipe_fixture(target[2]) + before = fingerprint(target[0]) + root.mkdir(parents=True, exist_ok=False) + report = {"status": "RUNNING", "minecraft": "1.13.2", "forge": "25.0.223", "campaign": args.campaign, + "target_manifest": str(manifest), "target_manifest_sha256": digest(manifest), + "target_jars": entry["jars"], "java": str(java), "java_sha256": digest(java), + "unlock_boots": [], "progression_cases": [], "recipe_inventory": dict(MOD_COUNTS), + "scope": "packaged native server assertions and recipe exports; not graphical/manual acceptance"} + write_json(root / "packaged-recipe-sources.json", sources) + write_json(root / "seed-fingerprint-before.json", before) + try: + if args.campaign in ("all", "unlock"): + unlock_campaign(root, target, entry, java, args.port, definition, report) + if args.campaign in ("all", "progression"): + progression_campaign(root, target, entry, java, args.port, report) + if args.campaign in ("all", "unlock") and len(report["unlock_boots"]) != 5: + raise AssertionError("The native unlock campaign did not complete five boots") + if args.campaign in ("all", "progression") and len(report["progression_cases"]) != 18: + raise AssertionError("The native progression campaign did not complete eighteen checks") + native_target(manifest) + if digest(manifest) != report["target_manifest_sha256"]: + raise AssertionError("Pinned manifest changed during the campaign") + report["status"] = "PASS" + except BaseException as failure: + report.update(status="FAIL", error=f"{type(failure).__name__}: {failure}") + raise + finally: + after = fingerprint(target[0]) + write_json(root / "seed-fingerprint-after.json", after) + report["seed_unchanged"] = before == after + if before != after: + report.update(status="FAIL", seed_error="Protected installed server seed changed") + write_json(root / "native113-recipes-results.json", report) + if before != after: + raise AssertionError("Protected installed server seed changed") + print(f"Native114 recipe campaign PASS: {root}", flush=True) + + +def 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, default=RIG / "build" / ("native113-recipes-" + datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S"))) + parser.add_argument("--port", type=int, default=25577) + parser.add_argument("--campaign", choices=("all", "unlock", "progression"), default="all") + run(parser.parse_args()) + + +if __name__ == "__main__": + main() diff --git a/test-rig/migration/native113_redstone_reload.py b/test-rig/migration/native113_redstone_reload.py new file mode 100644 index 0000000..d14e829 --- /dev/null +++ b/test-rig/migration/native113_redstone_reload.py @@ -0,0 +1,381 @@ +"""Finite mid-obsidian work, frozen holds, then per-tick operation after a real process restart. + +Separate protocol: never changes historical lifecycle helpers or reconstructs a restarted world. +Pin preserves every base production jar unless --electric-jar is explicitly supplied. +""" + +import argparse +import copy +from datetime import datetime, timezone +import json +import math +from pathlib import Path +import shutil +import socket +import struct +import traceback + +import nbtlib + +from matrix import WORLD, digest, fingerprint, write_json +from native113 import output_path, pinned_profile, require_disjoint +from native113_code import Server, INVENTORY, verify_code +from native113_lifecycle import prepare, read_json, require_native_boot, verify_staged + + +OBSERVATION_TICKS = 16 +READINESS_TICKS = 80 +TARGET_KEYS = {"targetX", "targetY", "targetZ", "TargetStates"} +MUTABLE_KEYS = TARGET_KEYS | {"progress", "Energy"} + + +def require(condition, message): + if not condition: + raise AssertionError(message) + + +def definitions(): + result = [] + for i in range(9): + offset = i if i < 8 else 0 + result.append(dict(id="held-" + str(i) if i < 8 else "unheld", held=i < 8, offset=offset, + pos=[60000 + i * 64 + 14 + ((offset >> 1) & 1), + 80 + (offset & 1), 60000 + ((offset >> 2) & 1)])) + return result + + +def typed(value): + if isinstance(value, nbtlib.Compound): + return {key: typed(tag) for key, tag in value.items()} + if isinstance(value, nbtlib.List): + return ["List", value.subtype.tag_id, [typed(tag) for tag in value]] + raw = value.unpack() + if hasattr(raw, "tolist"): + raw = raw.tolist() + return [type(value).__name__, raw] + + +def parsed(row): + try: + return nbtlib.parse_nbt(row["nbt"]) + except Exception as error: + raise AssertionError("Invalid raw tile NBT: " + str(error)) from error + + +def rows_by_id(rows, holds): + expected = definitions() + require(isinstance(rows, list) and len(rows) == 9, "Missing nine independent witnesses") + require(len({row["id"] for row in rows}) == 9, "Duplicate witness") + actual = {row["id"]: row for row in rows} + require(set(actual) == {case["id"] for case in expected}, "Wrong witness IDs") + for case in expected: + row = actual[case["id"]] + key = case["id"] + require(all(row.get(name) == value for name, value in case.items()), key + ": changed position/phase/declaration") + x, y, z = row["pos"] + require(((z & 1) << 2 | (x & 1) << 1 | (y & 1)) == row["offset"], key + ": false scheduler phase") + signal = holds and case["held"] + require(row.get("signal") is signal, key + ": actual neighbor signal differs") + require(row.get("holdBlock") == ("minecraft:redstone_block" if signal else "minecraft:air"), key + ": wrong hold block") + require(row.get("block") == "electricadvantage:electric_drill", key + ": stationary drill changed") + require(type(row.get("active")) is bool, key + ": missing actual ACTIVE") + state = "Block{electricadvantage:electric_drill}[active=" + str(row["active"]).lower() + ",facing=north]" + require(row.get("state") == state, key + ": raw block state disagrees with ACTIVE/facing") + require(isinstance(row.get("energy"), (int, float)) and math.isfinite(row["energy"]) + and 0 <= row["energy"] <= 500, key + ": invalid finite reserve") + require(type(row.get("progress")) is int and 0 <= row["progress"] < 14400, key + ": invalid mid-obsidian progress") + targets = row.get("targets", []) + expected_targets = {(x + dx, y + dy, z - 1): "Block{minecraft:obsidian}" + for dx in range(-1, 2) for dy in range(-1, 2)} + require(len(targets) == 9 and {tuple(t["pos"]): t["state"] for t in targets} == expected_targets, + key + ": real 3x3 obsidian target changed") + tag = parsed(row) + require(set(tag).issubset({"id", "x", "y", "z", "Items"} | MUTABLE_KEYS), key + ": unexpected saved state") + require(str(tag.get("id")) == row["block"] and [int(tag.get(axis, -1)) for axis in ("x", "y", "z")] == row["pos"], + key + ": serialized position/identity changed") + energy = tag.get("Energy") + require(isinstance(energy, nbtlib.IntArray) and len(energy) == 1, key + ": wrong energy storage") + value = struct.unpack(">f", struct.pack(">i", int(energy[0])))[0] + require(value == row["energy"], key + ": raw energy and measurement disagree") + require(isinstance(tag.get("progress"), nbtlib.Short) and int(tag["progress"]) == row["progress"], + key + ": raw progress and measurement disagree") + expected_items = nbtlib.parse_nbt('{Items:[{Slot:0b,id:"minecraft:dirt",Count:7b,tag:{AdvWorksWitness:"' + key + + '"}},{Slot:1b,id:"minecraft:apple",Count:13b}]}')["Items"] + require("Items" in tag and typed(tag["Items"]) == typed(expected_items), key + ": inventory/witness changed") + present = set(tag) & TARGET_KEYS + require(not present or present == TARGET_KEYS, key + ": partial target serialization") + if present: + require([int(tag[axis]) for axis in ("targetX", "targetY", "targetZ")] == [x, y, z - 1], key + ": target moved") + states = tag["TargetStates"] + require(isinstance(states, nbtlib.List) and len(states) == 9 + and all(typed(s) == {"Name": ["String", "minecraft:obsidian"]} for s in states), key + ": target oracle changed") + return actual + + +def same_saved(left, right): + a, b = dict(left), dict(right) + a["nbt"], b["nbt"] = typed(parsed(left)), typed(parsed(right)) + return a == b + + +def clock(frame, previous_time, index): + require(frame.get("index") == index, "Missing/reordered real tick index") + require(frame.get("tickStartWorldTime") == previous_time and frame.get("worldTime") == previous_time + 1, + "Missing, repeated or gapped real world tick") + + +def validate_readiness(document, phase, release_time): + samples = document.get("readiness", []) + require(1 <= len(samples) <= READINESS_TICKS, "Missing/beyond-budget native readiness observations") + expected = {case["id"]: case["pos"] for case in definitions()} + previous_time = None + for index, sample in enumerate(samples): + require(sample.get("phase") == phase, "Wrong readiness phase") + now = sample.get("worldTime") + require(type(now) is int and (previous_time is None or now == previous_time + 1), + "Readiness did not observe consecutive paused real ticks") + rows = sample.get("cases", []) + require(len(rows) == 9 and len({row["id"] for row in rows}) == 9 + and {row["id"]: row["pos"] for row in rows} == expected, "Incomplete readiness witness set") + require(all(type(row.get(key)) is bool for row in rows for key in ("blockTicking", "tickableListed")), + "Missing native/list readiness measurements") + ready = all(row["blockTicking"] and row["tickableListed"] for row in rows) + require(ready is (index == len(samples) - 1), "Release was not the first all-native-ready sample") + previous_time = now + require(previous_time == release_time, "Operated before/after the independently selected readiness boundary") + + +def validate_seed(fixture): + require(fixture.get("schema") == 1 and fixture.get("constructionCallsThisProcess") == 9, + "Not nine fresh-only constructed seeds") + require(fixture.get("initialEnergy") == 500 and fixture.get("processToken"), "Missing finite seed authority") + initial = fixture["initial"] + validate_readiness(fixture, "seed", initial["worldTime"]) + previous = rows_by_id(initial["cases"], False) + for row in previous.values(): + require(row["energy"] == 500 and row["progress"] == 0 and not row["active"], "Seed did not begin empty of work") + require(not (set(parsed(row)) & TARGET_KEYS), "Initial setup injected a mining target") + previous_time = initial["worldTime"] + trace = fixture.get("seedTrace", []) + require(1 <= len(trace) <= 16, "No bounded natural seed trace") + for index, frame in enumerate(trace, 1): + clock(frame, previous_time, index) + current = rows_by_id(frame["cases"], False) + for key, row in current.items(): + old = previous[key] + advance = row["progress"] - old["progress"] + require(advance in (0, 1) and old["energy"] - row["energy"] == 24 * advance, + key + ": seed work was not earned with real debit") + had_target, has_target = bool(set(parsed(old)) & TARGET_KEYS), bool(set(parsed(row)) & TARGET_KEYS) + if not had_target: + require(advance == 0, key + ": mining before target acquisition") + require(not has_target or (frame["worldTime"] + row["offset"]) % 8 == 0, + key + ": target appeared outside a native power-update slot") + else: + require(has_target and advance == 1, key + ": seed stopped operating") + previous, previous_time = current, frame["worldTime"] + before = rows_by_id(fixture["preHold"], False) + frozen = rows_by_id(fixture["cases"], True) + require(fixture.get("worldTime") == previous_time, "Freeze did not follow natural work immediately") + for key, row in before.items(): + require(same_saved(row, previous[key]), key + ": seed changed before the hold") + require(row["progress"] > 0 and row["energy"] > 24 and row["active"] + and set(parsed(row)) & TARGET_KEYS == TARGET_KEYS, key + ": not powered mid-obsidian") + expected = dict(row, signal=row["held"], holdBlock="minecraft:redstone_block" if row["held"] else "minecraft:air") + require(same_saved(expected, frozen[key]), key + ": adding hold altered saved work/resources") + return dict(status="PASS", cases=9, naturalSeedTicks=len(trace), phases=list(range(8))) + + +def validate_reload(fixture, report): + validate_seed(fixture) + require(report.get("schema") == 1 and report.get("constructionCallsThisProcess") == 0, + "Restart reconstructed the world") + require(report.get("processToken") and report["processToken"] != fixture["processToken"], "Not a full-process restart") + require(report.get("held") is True and report.get("observationTicks") == OBSERVATION_TICKS, + "Incomplete or nonquiescent final measurement") + frozen = rows_by_id(fixture["cases"], True) + restored = report["restored"] + validate_readiness(report, "restart", restored["worldTime"]) + previous = rows_by_id(restored["cases"], True) + require(restored["worldTime"] >= fixture["worldTime"], "World time moved backwards across restart") + for key in frozen: + require(same_saved(frozen[key], previous[key]), key + ": complete frozen block/NBT changed during restart") + ticks = report.get("ticks", []) + require(len(ticks) == OBSERVATION_TICKS, "Missing per-real-tick observations") + previous_time = restored["worldTime"] + for index, frame in enumerate(ticks, 1): + clock(frame, previous_time, index) + current = rows_by_id(frame["cases"], True) + for key, row in current.items(): + old = previous[key] + before_tag, after_tag = typed(parsed(old)), typed(parsed(row)) + require({k: v for k, v in before_tag.items() if k not in MUTABLE_KEYS} + == {k: v for k, v in after_tag.items() if k not in MUTABLE_KEYS}, key + ": saved resources changed") + if row["held"]: + require(row["energy"] == frozen[key]["energy"], key + f": held energy debit at tick {index}") + require(row["progress"] <= old["progress"], key + f": held work advanced at tick {index}") + require(row["active"] is False, key + f": held ACTIVE immediately true at tick {index}") + else: + powered = old["energy"] > 24 + require(set(parsed(row)) & TARGET_KEYS == TARGET_KEYS, "Unheld control lost its real mining target") + delta = 1 if powered else (-1 if old["progress"] else 0) + require(row["progress"] == old["progress"] + delta + and row["energy"] == old["energy"] - (24 if powered else 0), + f"Unheld control failed real progress/debit at tick {index}") + require(row["active"] is (row["energy"] > 24), "Unheld ACTIVE disagrees with finite reserve") + previous, previous_time = current, frame["worldTime"] + require(previous["unheld"]["energy"] < frozen["unheld"]["energy"], "Unheld control never consumed finite power") + require(any(frame["cases"] and next(row for row in frame["cases"] if row["id"] == "unheld")["progress"] + > frozen["unheld"]["progress"] for frame in ticks), "Unheld control never advanced work") + return dict(status="PASS", heldWitnesses=8, unheldControls=1, consecutiveRealTicks=OBSERVATION_TICKS, + heldDebit=0, firstTickInactive=True, unheldDebit=frozen["unheld"]["energy"] - previous["unheld"]["energy"]) + + +def pin(args): + base = args.base.resolve() + pinned_profile(base, "target113") + document = read_json(base) + destination = args.output.resolve() + require_disjoint(destination, base) + if destination.exists(): + raise FileExistsError(destination) + replacements = {"advantageworkstest": args.harness.resolve()} + if args.electric_jar: + replacements["electricadvantage"] = args.electric_jar.resolve() + target = copy.deepcopy(document["target113"]) + require(set(replacements).issubset({jar["id"] for jar in target["jars"]}), "Missing replaced mod ID in base") + sources = [(jar, replacements.get(jar["id"], (base.parent / jar["path"]).resolve())) for jar in target["jars"]] + for _, source in sources: + require_disjoint(destination, source) + destination.mkdir(parents=True) + for jar, source in sources: + checksum = digest(source) + path = destination / "artifacts" / checksum / source.name + path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, path) + require(digest(path) == checksum, "Archive copy differs") + jar.update(path=str(path), originPath=str(source), sha256=checksum) + if jar["id"] in replacements: + jar["sourceRef"] = "LOCAL_REDSTONE_RELOAD_" + args.label + target.update(seed=str((base.parent / target["seed"]).resolve()), qualificationStatus="UNQUALIFIED_REDSTONE_RELOAD", + scope=args.label) + from native113_code import code_files + target["serverCode"] = {"files": code_files(Path(target["seed"]))} + manifest = destination / "inputs.json" + write_json(manifest, dict(target113=target, baseManifest=str(base), baseSha256=digest(base))) + pinned_profile(manifest, "target113") + print(str(manifest), flush=True) + return 0 + + +def run(args): + output = output_path(args.output) + manifest = args.manifest.resolve() + target = pinned_profile(manifest, "target113") + expected_code = read_json(manifest)["target113"].get("serverCode") + verify_code(target[0], expected_code) + for protected in (manifest, target[0], *target[2]): + require_disjoint(output, protected) + require(args.java8.is_file() and 1024 <= args.port <= 65535, "Supply existing Java 8 and available nonprivileged port") + 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-redstone-reload.allow").touch() + (output / "migration-operate.allow").touch() + helpers = [Path(__file__), Path(__file__).with_name("test_native113_redstone_reload.py"), + Path(__file__).with_name("native113_lifecycle.py"), Path(__file__).with_name("native113_diagnostics.py"), + Path(__file__).with_name("native113_code.py"), Path(__file__).with_name("native113.py")] + helpers += [Path(__file__).with_name(name) for name in ("native113.py", "native113_code.py", "native113_lifecycle.py", "lifecycle.py", "matrix.py")] + java_root = Path(__file__).parents[1] / "src/main/java/zone/moddev/mc/advantageworks" + helpers += [java_root / "RedstoneReloadProbe.java", java_root / "AdvantageWorksMod.java"] + hashes = {str(path): digest(path) for path in helpers} + report = dict(status="RUNNING", manifest=str(manifest), manifestSha256=digest(manifest), protocolSha256=hashes, + startedAtUtc=datetime.now(timezone.utc).isoformat(), phases={}, + scope="Nine finite naturally earned mid-obsidian seeds; same frozen world, two server processes, 16 real ticks") + for path in helpers: + destination = output / "protocol-sources" / path.name + destination.parent.mkdir(exist_ok=True) + shutil.copy2(path, destination) + result_path = output / "native113-redstone-reload-results.json" + write_json(result_path, report) + server = None + try: + server = Server(output, args.java8, launcher, "redstone-seed") + for command in ("gamerule doMobSpawning false", "gamerule randomTickSpeed 0", "gamerule doWeatherCycle false", "weather clear"): + server.send(command) + server.send("advredstonereload seed seed") + server.wait("ADVREDSTONE COMPLETE seed", 180) + failure = output / "advantage-works-results/redstone-reload-failure-seed.json" + require(not failure.exists(), "Seed probe failed; retained " + str(failure)) + fixture_path = output / "redstone-reload-fixture.json" + fixture = read_json(fixture_path) + report["phases"]["seed"] = validate_seed(fixture) + report["fixtureSha256"] = digest(fixture_path) + server.send("save-all flush") + server.close() + server = None + require_native_boot(output / "console-redstone-seed.log") + frozen = output / "frozen-save" / WORLD + shutil.copytree(output / WORLD, frozen) + report["frozenSaveSha256"] = fingerprint(frozen) + write_json(result_path, report) + server = Server(output, args.java8, launcher, "redstone-restart") + server.send("advredstonereload resume restarted") + server.wait("ADVREDSTONE COMPLETE restarted", 180) + failure = output / "advantage-works-results/redstone-reload-failure-restarted.json" + require(not failure.exists(), "Restart probe failed; retained " + str(failure)) + measured = read_json(output / "advantage-works-results/redstone-reload-restarted.json") + # Store a failed semantic verdict before shutdown; never resample until green. + try: + report["phases"]["reload"] = validate_reload(fixture, measured) + except AssertionError as error: + report["phases"]["reload"] = dict(status="FAIL", failure=str(error)) + server.send("save-all flush") + server.close() + server = None + require_native_boot(output / "console-redstone-restart.log") + require(digest(fixture_path) == report["fixtureSha256"], "Frozen fixture authority changed") + require(fingerprint(frozen) == report["frozenSaveSha256"], "Archived pre-restart save changed") + 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"] = hashes == {str(path): digest(path) for path in helpers} + if not report["protocolUnchanged"]: + report.update(status="FAIL", protocolError="Protocol changed during execution") + if digest(manifest) != report["manifestSha256"]: + report.update(status="FAIL", manifestError="Input manifest changed during execution") + report["completedAtUtc"] = datetime.now(timezone.utc).isoformat() + write_json(result_path, 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("--harness", type=Path, required=True) + p.add_argument("--electric-jar", type=Path) + p.add_argument("--output", type=Path, required=True) + p.add_argument("--label", required=True) + 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) + 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/native113_refresh.py b/test-rig/migration/native113_refresh.py new file mode 100644 index 0000000..229f24e --- /dev/null +++ b/test-rig/migration/native113_refresh.py @@ -0,0 +1,136 @@ +"""Requalify immutable historical fixtures with current, sealed native 1.13 jars.""" + +import argparse +from argparse import Namespace +from pathlib import Path +import socket +import traceback +from unittest.mock import patch + +import matrix +import native113 +import native113_connected +from native113_code import INVENTORY, Server, verify_code +from native113_lifecycle import read_json, require_native_boot +from native113_resource_audit import compare_snapshot_resources + + +def run(args): + output = native113.output_path(args.output) + for path in (args.target_manifest, args.previous, args.fixtures, args.source_manifest): + if path is not None: + native113.require_disjoint(output, path) + target = native113.pinned_profile(args.target_manifest, "target113") + pins = read_json(args.target_manifest)["target113"] + helpers = [Path(__file__), Path(matrix.__file__), Path(native113_connected.__file__), + Path(__file__).with_name("native113_resource_audit.py"), + Path(__file__).with_name("semantic_machine_audit.py")] + protocol = {str(path): matrix.digest(path) for path in helpers} + with socket.socket() as listener: + listener.bind(("127.0.0.1", args.port)) + output.mkdir(parents=True, exist_ok=False) + report = dict(status="RUNNING", scope=args.kind, targetManifest=str(args.target_manifest.resolve()), + targetManifestSha256=matrix.digest(args.target_manifest), protocolSha256=protocol, + routes=[]) + receipt = output / "native113-refresh-results.json" + matrix.write_json(receipt, report) + try: + if args.kind == "frozen": + if args.source_manifest is None or args.fixtures is None: + raise ValueError("Frozen refresh requires archived profiles and immutable fixtures") + profiles = read_json(args.source_manifest) + profiles["target113"] = pins + manifest = output / "profiles.json" + matrix.write_json(manifest, profiles) + original_stage = matrix.stage + + def stage(runtime, profile): + launcher = original_stage(runtime, profile) + properties = runtime / "server.properties" + lines = [line for line in properties.read_text().splitlines() + if not line.startswith("server-port=") + and not (launcher == native113.LAUNCHER and line.startswith("generator-settings="))] + properties.write_text("\n".join(lines) + f"\nserver-port={args.port}\n", encoding="utf-8") + if launcher == native113.LAUNCHER: + verify_code(runtime, pins["serverCode"]) + matrix.write_json(runtime / INVENTORY, pins["serverCode"]) + return launcher + + def snapshot(runtime, java, launcher, label, build=False): + if build: + raise AssertionError("Historical source construction is forbidden") + server = Server(runtime, java, launcher, label) + try: + server.snapshot(label, False) + server.send("save-all flush") + finally: + server.close() + if launcher == native113.LAUNCHER: + require_native_boot(runtime / f"console-{label}.log") + + settings = Namespace(profile_manifest=manifest, workspace=None, output=output / "matrix", + sources=None, fixtures=args.fixtures, java8=args.java8) + with patch.object(matrix, "stage", stage), patch.object(matrix, "start_snapshot", snapshot): + code = matrix.run(settings) + original = read_json(settings.output / "matrix-results.json") + for label, comparisons in original.items(): + name = label.rsplit("-", 1)[0] + source = args.fixtures / name / "migration-source.json" + runtime = settings.output / (label + "-113") + resources = {phase: compare_snapshot_resources(source, runtime / f"migration-{phase}.json") + for phase in ("converted", "restarted")} + passed = all(row["status"] == "PASS" for row in comparisons.values()) and all( + row["status"] == "PASS" for row in resources.values()) + report["routes"].append(dict(id=label, status="PASS" if passed else "FAIL", + comparisons=comparisons, resources=resources)) + report["status"] = "PASS" if code == 0 and len(report["routes"]) == 8 and all( + row["status"] == "PASS" for row in report["routes"]) else "FAIL" + else: + if args.previous is None: + raise ValueError("Connected refresh requires exact retained source provenance") + previous = read_json(args.previous) + if previous.get("status") != "PASS": + raise AssertionError("Archived source provenance is not a completed campaign") + report["previousSha256"] = matrix.digest(args.previous) + for index, row in enumerate(previous["sources"]): + history = Path(row["report"]) + if matrix.digest(history) != row["sha256"]: + raise AssertionError("Archived source provenance changed") + provenance = read_json(history) + hashes = Path(provenance["sourceHashes"]) + if matrix.digest(hashes) != provenance["sourceHashesSha256"]: + raise AssertionError("Archived connected world fingerprint changed") + settings = Namespace(source=Path(provenance["source"]), + source_manifest=Path(provenance["sourceManifest"]), + source_key=provenance["sourceKey"], source_hashes=hashes, + source_checkpoint=provenance["sourceCheckpoint"], + target_manifest=args.target_manifest, java8=args.java8, + output=output / f"route-{index:02}", port=args.port, ticks=2048) + code = native113_connected.run(settings) + report["routes"].append(dict(source=str(settings.source), + status="PASS" if code == 0 else "FAIL", + runtime=str(settings.output))) + matrix.write_json(receipt, report) + report["status"] = "PASS" if len(report["routes"]) == 5 and all( + row["status"] == "PASS" for row in report["routes"]) else "FAIL" + except Exception as error: + report.update(status="FAIL", failure=str(error), traceback=traceback.format_exc()) + finally: + report["protocolUnchanged"] = protocol == {str(path): matrix.digest(path) for path in helpers} + if not report["protocolUnchanged"] or matrix.digest(args.target_manifest) != report["targetManifestSha256"]: + report["status"] = "FAIL" + matrix.write_json(receipt, report) + return int(report["status"] != "PASS") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("kind", choices=("frozen", "connected")) + parser.add_argument("--target-manifest", type=Path, required=True) + parser.add_argument("--source-manifest", type=Path) + parser.add_argument("--fixtures", type=Path) + parser.add_argument("--previous", type=Path) + parser.add_argument("--java8", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--port", type=int, required=True) + raise SystemExit(run(parser.parse_args())) diff --git a/test-rig/migration/native113_replay.py b/test-rig/migration/native113_replay.py new file mode 100644 index 0000000..aebbfc0 --- /dev/null +++ b/test-rig/migration/native113_replay.py @@ -0,0 +1,119 @@ +"""Run native 1.13 lifecycle oracles with sealed current jars, not old defaults.""" + +import argparse +from argparse import Namespace +from pathlib import Path +import time +from unittest.mock import patch +from contextlib import ExitStack + +import lifecycle +import extended_lifecycle +import pipe_only_lifecycle +import matrix +import native113_chunk_readiness +from native113 import LAUNCHER, output_path, pinned_profile, require_disjoint +from native113_campaign_support import verify_source +from native113_code import INVENTORY, Server as CustodyServer, verify_code +from native113_lifecycle import read_json, require_native_boot + + +class NativeServer(CustodyServer): + def close(self): + try: + return super().close() + finally: + require_native_boot(Path(self.log.name)) + + +def run(args): + output = output_path(args.output) + for path in (args.source, args.source_manifest, args.source_hashes, args.target_manifest): + require_disjoint(output, path) + source_before, _ = verify_source(args) + target = pinned_profile(args.target_manifest, "target113") + pins = read_json(args.target_manifest)["target113"] + transport = output.with_name(output.name + "-inputs") + if transport.exists(): + raise FileExistsError(transport) + matrix.write_json(transport / "profiles/packaged-current.json", dict(mods=[ + dict(path=str(jar), file=jar.name, sha256=matrix.digest(jar)) for jar in target[2]])) + original_stage = matrix.stage + original_probe = lifecycle.LifecycleRun.probe + + def stage(runtime, profile): + if profile != target or profile[1] != LAUNCHER: + raise AssertionError("Only the sealed native destination may be staged") + launcher = original_stage(runtime, profile) + properties = runtime / "server.properties" + lines = [line for line in properties.read_text().splitlines() + if not line.startswith(("server-port=", "generator-settings="))] + properties.write_text("\n".join(lines) + f"\nserver-port={args.port}\ngenerator-settings=\n", encoding="utf-8") + verify_code(runtime, pins["serverCode"]) + matrix.write_json(runtime / INVENTORY, pins["serverCode"]) + return launcher + + def probe(runner, operation, label): + result = original_probe(runner, operation, label) + if operation != "load": + return result + pending = list(runner.definition) + for attempt in range(80): + for fixture in list(pending): + low, high = fixture["min"], fixture["max"] + key = f"ready-{label}-{fixture['id']}-{attempt:02}" + runner.command(f"advchunks {key} {low[0]} {low[2]} {high[0]} {high[2]}") + runner.server.wait("ADVCHUNKS OBSERVED " + key) + observed = read_json(runner.runtime / "advantage-works-results" / f"chunk-readiness-{key}.json") + if native113_chunk_readiness.validate(observed, key, low, high): + pending.remove(fixture) + if not pending: + return result + time.sleep(.05) + raise TimeoutError("Lifecycle chunks did not complete native loading before construction") + + module = {"lifecycle": lifecycle, "extended": extended_lifecycle, "pipe": pipe_only_lifecycle}[args.kind] + helpers = [Path(__file__), Path(module.__file__), Path(lifecycle.__file__), + Path(native113_chunk_readiness.__file__)] + protocol = {str(path): matrix.digest(path) for path in helpers} + settings = Namespace(java8=args.java8, migrated=args.source, output=output, port=args.port) + status, error = "FAIL", None + try: + with ExitStack() as context: + for item in (lifecycle, extended_lifecycle, pipe_only_lifecycle): + context.enter_context(patch.object(item, "profiles", lambda _: {"target113": target})) + context.enter_context(patch.object(item, "stage", stage)) + context.enter_context(patch.object(item, "RIG", transport)) + context.enter_context(patch.object(lifecycle, "Server", NativeServer)) + context.enter_context(patch.object(lifecycle.LifecycleRun, "probe", probe)) + module.run(settings) + status = "PASS" + except Exception as failure: + error = repr(failure) + raise + finally: + intact = matrix.fingerprint(args.source) == source_before + unchanged = protocol == {str(path): matrix.digest(path) for path in helpers} + if not intact or not unchanged: + status = "FAIL" + matrix.write_json(output / "native113-replay-audit.json", dict( + status=status, failure=error, kind=args.kind, sourceUnchanged=intact, + protocolUnchanged=unchanged, protocolSha256=protocol, + targetManifestSha256=matrix.digest(args.target_manifest))) + if not intact or not unchanged: + raise AssertionError("Protected source or replay protocol changed") + return 0 + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("kind", choices=("lifecycle", "extended", "pipe")) + parser.add_argument("--source", type=Path, required=True) + parser.add_argument("--source-manifest", type=Path, required=True) + parser.add_argument("--source-key", default="target113") + parser.add_argument("--source-hashes", type=Path, required=True) + 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, required=True) + raise SystemExit(run(parser.parse_args())) diff --git a/test-rig/migration/native113_resource_audit.py b/test-rig/migration/native113_resource_audit.py new file mode 100644 index 0000000..a6665f8 --- /dev/null +++ b/test-rig/migration/native113_resource_audit.py @@ -0,0 +1,280 @@ +"""Read-only resource equality for frozen migration/restart snapshots, not operation. + +compare_resources accepts JSON paths, migration witness arrays, or connected +documents with a snapshot array. It does not launch anything or write evidence. +Root metadata is ignored; item identity, quantity, slot and item NBT are exact. +Pass each world's enchantment-id map when comparing legacy numeric enchantments. +""" + +import argparse +import base64 +import gzip +import io +import json +import math +from pathlib import Path +import struct + +import nbtlib + +from matrix import WORLD, plain +from native113_campaign_support import native_normalized +from native113_legacy_snbt import parse_legacy_snbt + + +# Counts are container sizes, not highest valid slot numbers. Unknown containers +# still receive structural/quantity checks; callers can supply additional limits. +SLOT_LIMITS = { + "minecraft:chest": 27, "minecraft:trapped_chest": 27, "minecraft:barrel": 27, + "minecraft:hopper": 5, "minecraft:furnace": 3, "minecraft:blast_furnace": 3, + "minecraft:smoker": 3, "minecraft:dispenser": 9, "minecraft:dropper": 9, + "electricadvantage:electric_furnace": 10, "electricadvantage:electric_crusher": 9, + "electricadvantage:electric_oven": 2, "electricadvantage:electric_fabricator": 15, + "electricadvantage:electric_battery_array": 8, "electricadvantage:electric_drill": 6, + "electricadvantage:growth_chamber": 9, "electricadvantage:growth_chamber_controller": 1, + "electricadvantage:plastic_refinery": 1, "electricadvantage:electric_pump": 0, + "electricadvantage:electric_still": 0, "electricadvantage:photovoltaic_generator": 0, + "electricadvantage:hydroelectric_generator": 0, "electricadvantage:steam_powered_generator": 0, + "electricadvantage:laser_turret": 0, "electricadvantage:laser_turret_evil": 0, + "steamadvantage:steam_furnace": 7, "steamadvantage:steam_crusher": 6, + "steamadvantage:steam_drill": 5, "steamadvantage:steam_boiler_coal": 1, + "poweradvantage:coal_fired_still": 1, + "poweradvantage:still": 1, "poweradvantage:fluid_discharge": 0, "poweradvantage:fluid_drain": 0, + "poweradvantage:fluid_metal_tank": 1, "poweradvantage:fluid_storage_tank": 0, + "poweradvantage:item_conveyor": 1, "poweradvantage:item_filter_inventory": 10, + "poweradvantage:item_filter_block": 1, "poweradvantage:item_filter_food": 1, + "poweradvantage:item_filter_fuel": 1, "poweradvantage:item_filter_ore": 1, + "poweradvantage:item_filter_overflow": 1, "poweradvantage:item_filter_plant": 1, + "poweradvantage:item_filter_smelt": 1, "steamadvantage:steam_boiler_electric": 0, + "steamadvantage:steam_boiler_geothermal": 0, "steamadvantage:steam_boiler_oil": 0, + "steamadvantage:steam_pump": 0, "steamadvantage:steam_still": 0, "steamadvantage:steam_tank": 0, + "steamadvantage:steam_elevator": 0, +} +BATTERIES = { + "electricadvantage:lead_acid_battery": 1000, + "electricadvantage:nickel_hydride_battery": 2000, + "electricadvantage:alkaline_battery": 4000, + "electricadvantage:lithium_battery": 8000, +} +SCALARS = frozenset(("Electricity", "energy", "steam", "soil", "Temperature", + "BurnTime", "burnTime", "Fuel", "fuel", "FuelTime", "fuelTime")) +TANKS = frozenset(("Tank", "InputTank", "OutputTank", "TankIn", "TankOut", "WaterTank", "FuelTank")) + + +def _integer(value, label, minimum=0): + if isinstance(value, bool) or not isinstance(value, int) or value < minimum: + raise ValueError(label + ": invalid integer") + return value + + +def _quantity(value, label): + if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or value < 0: + raise ValueError(label + ": invalid/nonfinite/negative quantity") + return value + + +def _rows(document): + if isinstance(document, (str, Path)): + document = json.loads(Path(document).read_text(encoding="utf-8")) + if isinstance(document, dict): + document = document.get("snapshot") + if not isinstance(document, list) or not document: + raise ValueError("Expected a nonempty migration witness array or connected snapshot") + return document + + +def _nbt(row): + values = [] + binary = None + if "nbtBase64" in row: + payload = gzip.decompress(base64.b64decode(row["nbtBase64"], validate=True)) + binary = nbtlib.File.parse(io.BytesIO(payload)) + values.append(plain(binary.unpack())) + if "nbt" in row: + # SNBT Float parsing keeps Python decimal precision until serialization. + # Round through its declared NBT type, not a quantity tolerance. + stream = io.BytesIO() + nbtlib.File(parse_legacy_snbt(row["nbt"], binary)).write(stream) + stream.seek(0) + values.append(plain(nbtlib.File.parse(stream).unpack())) + if not values: + return {} + if any(not isinstance(value, dict) for value in values) or any(value != values[0] for value in values[1:]): + raise ValueError("Conflicting or non-compound raw NBT encodings") + return values[0] + + +def _energy(value, label): + if not isinstance(value, list): + raise ValueError(label + ": expected float-bit int array") + result = {} + for channel, bits in enumerate(value): + if isinstance(bits, bool) or not isinstance(bits, int) or not -(1 << 31) <= bits < (1 << 31): + raise ValueError(label + ": invalid signed float-bit integer") + amount = _quantity(struct.unpack(">f", struct.pack(">I", bits & 0xffffffff))[0], label) + if amount != 0: + result[str(channel)] = amount + return result + + +def _tank(value, label): + if not isinstance(value, dict): + raise ValueError(label + ": expected tank compound") + amount = value.get("Amount") + if amount is not None: + _integer(amount, label + ".Amount") + if "Empty" in value: + # native_normalized retains the established presence-based Empty convention. + if amount not in (None, 0): + raise ValueError(label + ": contradictory empty marker and positive fluid") + return None + if amount is None: + raise ValueError(label + ": missing Amount/Empty evidence") + fluid = value.get("FluidName", value.get("FluidID")) + if not isinstance(fluid, str) or not fluid: + raise ValueError(label + ": missing fluid identity") + if "FluidID" in value and "FluidName" in value and value["FluidID"] != value["FluidName"]: + raise ValueError(label + ": conflicting fluid identities") + # A named zero-mB tank and native Empty are the same zero resource budget. + if amount == 0: + return None + result = {"fluid": fluid, "amount": amount} + if value.get("Tag"): + result["tag"] = value["Tag"] + return result + + +def _items(value, label, limit): + if not isinstance(value, list): + raise ValueError(label + ": expected inventory list") + result = {} + for entry in value: + if not isinstance(entry, dict): + raise ValueError(label + ": invalid stack compound") + slot = _integer(entry.get("Slot"), label + ".Slot") + if limit is not None and slot >= limit: + raise ValueError(label + ": slot outside container size " + str(limit)) + if str(slot) in result: + raise ValueError(label + ": duplicate inventory slot " + str(slot)) + count = _integer(entry.get("Count"), label + ".Count", 1) + item = entry.get("id") + if not isinstance(item, str) or not item or item == "minecraft:air" or count > 64: + raise ValueError(label + ": invalid item identity/count") + stack = {key: val for key, val in entry.items() if key != "Slot"} + if item in BATTERIES: + tag = stack.get("tag", {}) + if not isinstance(tag, dict): + raise ValueError(label + ": invalid battery tag") + # BatteryItem's established default is a fully charged new battery. + charge = _quantity(tag.get("energy", BATTERIES[item]), label + ".battery.energy") + if charge > BATTERIES[item]: + raise ValueError(label + ": battery charge exceeds chemistry capacity") + stack["tag"] = dict(tag, energy=charge) + if stack.get("tag") == {}: + stack.pop("tag") + result[str(slot)] = stack + return result + + +def _project(data, block, slot_limits): + result = {} + + def visit(node, prefix=""): + for key, value in node.items(): + path = prefix + key + if key == "Energy": + channels = _energy(value, path) + for channel, amount in channels.items(): result[path + "[" + channel + "]"] = amount + elif key in SCALARS: + amount = _quantity(value, path) + if amount != 0: result[path] = amount + elif key == "Items": + inventory = _items(value, path, slot_limits.get(block) if not prefix else None) + for slot, item in inventory.items(): result[path + "[" + slot + "]"] = item + elif key in TANKS or key.endswith("Tank"): + tank = _tank(value, path) + if tank is not None: result[path] = tank + elif isinstance(value, dict): + # Catch known resource fields inside capability compounds too, while + # ignoring ordinary ForgeData/witness/custom-name metadata. + visit(value, path + ".") + + visit(data) + return result + + +def canonical_resources(document, *, enchantments=None, slot_limits=None): + """Return per-position exact budgets; malformed evidence raises ValueError. + + No operation is permitted between the input snapshots. Zero optional reserves + and empty inventories/tanks may be omitted by native serialization. Channel, + tank, slot, fluid and item identities remain separate; station totals cannot + conceal a transfer, duplication or loss. Unknown root metadata is ignored. + """ + limits = dict(SLOT_LIMITS) + if slot_limits: + for name, limit in slot_limits.items(): + limits[name] = _integer(limit, "slot limit") + result = {} + for row in _rows(document): + if not isinstance(row, dict): raise ValueError("Invalid witness row") + pos = tuple(_integer(row.get(key), key, -(1 << 31)) for key in ("x", "y", "z")) + if pos in result: raise ValueError("Duplicate witness position " + str(pos)) + identity = row.get("actualName", row.get("block")) + if not isinstance(identity, str) or not identity: raise ValueError("Missing observed block identity") + block = native_normalized({"id": identity})["id"] + if block in limits and not ("nbtBase64" in row or "nbt" in row): + raise ValueError("Missing NBT for known resource container " + str(pos)) + data = native_normalized(_nbt(row), enchantments) + result[pos] = {"block": block, "resources": _project(data, block, limits)} + return result + + +def compare_resources(before, after, *, before_enchantments=None, after_enchantments=None, slot_limits=None): + """JSON-safe PASS/FAIL result. Does not mutate inputs or write any files.""" + frames, failures, differences = [], [], [] + for phase, document, enchantments in (("before", before, before_enchantments), ("after", after, after_enchantments)): + try: + frames.append(canonical_resources(document, enchantments=enchantments, slot_limits=slot_limits)) + except Exception as error: + # Evidence/normalization errors must fail closed, not become an empty budget. + frames.append(None) + failures.append(phase + ": " + type(error).__name__ + ": " + str(error)) + if not failures: + old, new = frames + for pos in sorted(old.keys() | new.keys()): + if pos not in old or pos not in new: + differences.append(dict(position=list(pos), resource="witness", before=old.get(pos), after=new.get(pos))) + continue + if old[pos]["block"] != new[pos]["block"]: + differences.append(dict(position=list(pos), resource="block", before=old[pos]["block"], after=new[pos]["block"])) + a, b = old[pos]["resources"], new[pos]["resources"] + for resource in sorted(a.keys() | b.keys()): + if a.get(resource) != b.get(resource): + differences.append(dict(position=list(pos), resource=resource, before=a.get(resource), after=b.get(resource))) + return dict(status="FAIL" if failures or differences else "PASS", failures=failures, differences=differences, + positions=len(frames[0]) if frames[0] is not None else 0, + scope="Exact per-position resource equality across a frozen migration/restart boundary; not operation") + + +def compare_snapshot_resources(before, after): + def enchantments(snapshot): + level = nbtlib.load(Path(snapshot).parent / WORLD / "level.dat") + entries = level.get("FML", {}).get("Registries", {}).get("minecraft:enchantments", {}).get("ids", []) + return {int(entry["V"]): str(entry["K"]) for entry in entries} + return compare_resources(before, after, before_enchantments=enchantments(before), + after_enchantments=enchantments(after)) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("before", type=Path) + parser.add_argument("after", type=Path) + args = parser.parse_args() + result = compare_resources(args.before, args.after) + print(json.dumps(result, indent=2, sort_keys=True)) + return int(result["status"] != "PASS") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test-rig/migration/native113_sealed_transport.py b/test-rig/migration/native113_sealed_transport.py new file mode 100644 index 0000000..15b5ad0 --- /dev/null +++ b/test-rig/migration/native113_sealed_transport.py @@ -0,0 +1,32 @@ +"""Current native code custody for fresh recipe and bay campaigns.""" +from pathlib import Path +import matrix +from native113 import pinned_profile +from native113_code import INVENTORY, Server as CustodyServer, verify_code +from native113_lifecycle import read_json, require_native_boot + +_profile = None +_pins = None + + +def configure(manifest): + global _profile, _pins + _profile = pinned_profile(Path(manifest), "target113") + _pins = read_json(Path(manifest))["target113"] + + +def stage(runtime, profile): + if _profile is None or profile[:2] != _profile[:2] or not set(profile[2]) <= set(_profile[2]): + raise AssertionError("Only an explicit subset of the current native profile may be staged") + launcher = matrix.stage(runtime, profile) + verify_code(runtime, _pins["serverCode"]) + matrix.write_json(runtime / INVENTORY, _pins["serverCode"]) + return launcher + + +class Server(CustodyServer): + def close(self): + try: + return super().close() + finally: + require_native_boot(Path(self.log.name)) 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_broad_campaign_status.py b/test-rig/migration/test_broad_campaign_status.py new file mode 100644 index 0000000..aac43b4 --- /dev/null +++ b/test-rig/migration/test_broad_campaign_status.py @@ -0,0 +1,83 @@ +"""Status-writer controls and offline prefix replay; no world or server activity.""" + +from copy import deepcopy +import unittest + +from broad_campaign_status import campaign_document, campaign_status + + +NAMES = ("packaged-current", "packaged-decoupled") + + +def passed(name): + drills = {str(n): {"native": n} for n in range(12)} + checks = dict(status="PASS", assertions={"PASS": 96, "SKIP": 3}, manualRequired=[str(n) for n in range(21)]) + return dict(profile=name, status="PASS", beforeRestart=drills, afterRestart=deepcopy(drills), phases={ + "initial": dict(status="PASS", drills=deepcopy(drills)), "first": checks, "restarted": deepcopy(checks), + "operation": dict(status="PASS", drills=12, moved=2), "persistence": dict(status="PASS", drills=12, exactEnergy=True)}) + + +class BroadCampaignStatusTests(unittest.TestCase): + def test_no_profiles_and_first_only_are_running_not_pass(self): + self.assertEqual("RUNNING", campaign_status(NAMES, {})) + self.assertEqual("RUNNING", campaign_status(NAMES, {NAMES[0]: passed(NAMES[0])})) + + def test_all_requested_profiles_required_for_final_pass(self): + self.assertEqual("PASS", campaign_status(NAMES, {name: passed(name) for name in NAMES}, finished=True)) + self.assertEqual("FAIL", campaign_status(NAMES, {NAMES[0]: passed(NAMES[0])}, finished=True)) + + def test_explicit_single_profile_completes_without_inferred_both(self): + self.assertEqual("PASS", campaign_status((NAMES[0],), {NAMES[0]: passed(NAMES[0])})) + + def test_failed_second_retains_first_evidence(self): + results = {NAMES[0]: passed(NAMES[0]), NAMES[1]: dict(profile=NAMES[1], status="FAIL", phases={}, error="stage failed")} + snapshot = deepcopy(results) + doc = campaign_document(NAMES, results, {"runnerSha256": "old pinned runner"}, finished=True) + self.assertEqual("FAIL", doc["status"]) + self.assertEqual(snapshot, doc["profiles"]) + self.assertEqual(snapshot, results) + + def test_masked_error_and_incomplete_phases_are_failures(self): + for mutation in ("error", "shutdown", "phase", "counts", "manual", "cold", "initial", "movement"): + with self.subTest(mutation=mutation): + result = passed(NAMES[0]) + if mutation == "error": result["error"] = "failed" + if mutation == "shutdown": result["shutdownError"] = "failed" + if mutation == "phase": result["phases"].pop("persistence") + if mutation == "counts": result["phases"]["first"]["assertions"] = {"PASS": 0} + if mutation == "manual": result["phases"]["first"]["manualRequired"] = [] + if mutation == "cold": result["afterRestart"] = {} + if mutation == "initial": result["phases"]["initial"]["drills"] = {} + if mutation == "movement": result["phases"]["operation"]["moved"] = 0 + self.assertEqual("FAIL", campaign_status((NAMES[0],), {NAMES[0]: result})) + + def test_invalid_or_extra_requested_profiles_are_not_accepted(self): + for names in ((), (NAMES[0], NAMES[0]), ("unknown",)): + with self.assertRaises(ValueError): + campaign_status(names, {}) + self.assertEqual("FAIL", campaign_status((NAMES[0],), {name: passed(name) for name in NAMES})) + + def test_malformed_profile_fails_closed_without_crashing_progress_writer(self): + for malformed in (None, [], {"status": "PASS"}): + self.assertEqual("FAIL", campaign_status((NAMES[0],), {NAMES[0]: malformed})) + result = passed(NAMES[0]) + result["phases"]["operation"]["moved"] = "not a count" + self.assertEqual("FAIL", campaign_status((NAMES[0],), {NAMES[0]: result})) + + def test_replay_completed_document_changes_no_evidence_or_hashes(self): + original = dict(status="PASS", runnerSha256="historical-sha", targetManifestSha256="target-sha", + profiles={name: passed(name) for name in NAMES}) + metadata = {key: value for key, value in original.items() if key not in ("status", "profiles")} + self.assertEqual(original, campaign_document(NAMES, original["profiles"], metadata, finished=True)) + + def test_partial_replay_diff_is_status_only(self): + original = dict(status="PASS", runnerSha256="historical-sha", targetManifestSha256="target-sha", + profiles={NAMES[0]: passed(NAMES[0])}) + metadata = {key: value for key, value in original.items() if key not in ("status", "profiles")} + result = campaign_document(NAMES, original["profiles"], metadata) + self.assertEqual(["status"], [key for key in original if original[key] != result[key]]) + self.assertEqual("RUNNING", result["status"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/test-rig/migration/test_broad_result_audit.py b/test-rig/migration/test_broad_result_audit.py new file mode 100644 index 0000000..2353916 --- /dev/null +++ b/test-rig/migration/test_broad_result_audit.py @@ -0,0 +1,377 @@ +"""Offline evidence-fixture controls, not additional Minecraft runs.""" + +from collections import Counter +from copy import deepcopy +import io +import json +from pathlib import Path +import tempfile +import unittest +from contextlib import redirect_stdout +import zipfile + +import broad_result_audit as audit + + +LINE = Path(__file__).resolve().parents[3].name.removeprefix("PA") + + +def write(path, data): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data), encoding="utf-8") + + +def load(path): + return json.loads(path.read_text(encoding="utf-8")) + + +class BroadResultAuditTests(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.root = Path(self.directory.name) + self.runtime = self.root / "broad" + self.runtime.mkdir() + source = Path(__file__).resolve().parents[1] / "src/main/resources" / audit.RESOURCE + definition = source.read_bytes() + self.stations = json.loads(definition)["stations"] + self.definitions = {s["id"]: s for s in self.stations} + jars = [] + for name in ("poweradvantage", "steamadvantage", "electricadvantage", "advantageworkstest", "basemetals", "orespawn"): + path = self.root / (name + ".jar") + if name == "advantageworkstest": + with zipfile.ZipFile(path, "w") as archive: + archive.writestr(audit.RESOURCE, definition.replace(b"\r\n", b"\n")) + else: + path.write_bytes(name.encode("ascii")) + jars.append(dict(id=name, path=str(path), sha256=audit.sha(path.read_bytes()), version="fixture")) + self.pins = dict(jars=jars, launcher="forge.jar", launcher_sha256=audit.sha(b"engine"), + serverCode=dict(files={"forge.jar": dict(sha256=audit.sha(b"engine"), size=6)})) + self.manifest = self.root / "inputs.json" + write(self.manifest, {"target" + LINE: self.pins}) + runner = b"# immutable historical broad source fixture\n" + (self.runtime / "runner-source.py").write_bytes(runner) + self.report = dict(status="PASS", targetManifest=str(self.manifest), + targetManifestSha256=audit.sha(self.manifest.read_bytes()), + runnerSha256=audit.sha(runner), stationDefinitionSha256=audit.sha(definition), profiles={}) + for name in audit.PROFILES: + self.make_profile(name) + self.save_top() + + def save_top(self): + write(self.runtime / ("native" + LINE + "-broad-results.json"), self.report) + + def profile(self, name="packaged-current"): + return self.runtime / name + + def save_profile(self, name="packaged-current"): + write(self.profile(name) / "broad-results.json", self.report["profiles"][name]) + self.save_top() + + def make_profile(self, name): + directory = self.profile(name) + directory.mkdir() + (directory / "forge.jar").write_bytes(b"engine") + staged = [] + mods = {} + for pin in self.pins["jars"]: + if name == "packaged-decoupled" and pin["id"] == "basemetals": + continue + path = directory / "mods" / Path(pin["path"]).name + path.parent.mkdir(exist_ok=True) + path.write_bytes(Path(pin["path"]).read_bytes()) + staged.append(dict(file=path.name, sha256=pin["sha256"], source=pin["path"])) + mods[pin["id"]] = pin["version"] + write(directory / "input-jars.json", staged) + minecraft, forge = audit.VERSIONS[LINE] + mods.update(minecraft=minecraft, forge=forge) + raw = {} + for spec in self.stations: + assertions = [dict(id=a["id"], type=a["type"], status="SKIP" if set(a.get("requiredMods", [])) - set(mods) else "PASS", + detail="optional integration is absent" if set(a.get("requiredMods", [])) - set(mods) else "matched") + for a in spec["assertions"]] + raw[spec["id"]] = dict(station=spec["id"], minecraft=minecraft, forge=forge, mods=deepcopy(mods), + assertions=assertions, status="MANUAL_REQUIRED", manualChecks=spec["manualChecks"], + evidenceScope="declared_assertions_only; manual procedures are not automated passes", + worldTime=3, snapshot=[dict(pos=[0, 0, 0], block="minecraft:air", state="air")]) + initial, moved = {}, {} + for sid in ("S-05", "E-06"): + start, end = self.drills(self.definitions[sid], False), self.drills(self.definitions[sid], True) + write(directory / "advantage-works-results" / (sid + "-checkpoint-initial.json"), dict(snapshot=start)) + initial[sid] = dict(snapshot=start) + raw[sid]["snapshot"] = end + for phase in ("first", "restarted"): + write(directory / "advantage-works-results" / (phase + "-all-stations.json"), raw) + for sid in audit.STATIONS: + write(directory / "advantage-works-results" / (sid + "-checkpoint-operated.json"), + dict(station=sid, minecraft=minecraft, forge=forge, mods=mods, checkpoint="operated", worldTime=1, + snapshot=raw[sid]["snapshot"])) + initial = audit.drill_snapshot(initial, self.definitions, initial=True) + moved = audit.drill_snapshot(raw, self.definitions) + counts = dict(Counter(a["status"] for record in raw.values() for a in record["assertions"])) + checks = dict(status="PASS", assertions=counts, manualRequired=list(audit.STATIONS)) + result = dict(profile=name, status="PASS", expectedMods=mods, beforeRestart=moved, afterRestart=deepcopy(moved), phases={ + "initial": dict(status="PASS", drills=initial), "first": checks, "restarted": deepcopy(checks), + "operation": dict(status="PASS", drills=12, moved=12), + "persistence": dict(status="PASS", drills=12, exactEnergy=True)}) + self.report["profiles"][name] = result + write(directory / "broad-results.json", result) + log = ("Starting minecraft server version " + minecraft + "\nForge mod loading, version " + forge + ", for MC " + minecraft + + '\nDone (1.0s)! For help, type "help"\nSaved the game\nStopping server\nSaving players\nSaving worlds\n' + + "".join("Saving chunks for level 'fixture'/minecraft:" + d + "\n" for d in ("overworld", "the_nether", "the_end")) + + "".join("ThreadedAnvilChunkStorage (" + label + "): All chunks are saved\n" for label in ("fixture", "DIM-1", "DIM1"))) + for phase in ("first", "restarted"): + path = directory / ("console-" + phase + ".log") + commissioning = ("Advantage Works built 21 station(s); skipped 0\n" + "".join( + "Started " + sid + " - fixture\n" + sid + "-checkpoint-operated.json\nStopped " + sid + " - fixture\n" + for sid in audit.STATIONS)) if phase == "first" else "" + rendered = log.replace("Saved the game", commissioning + "Advantage Works check: {MANUAL_REQUIRED=21}\nSaved the game") + path.write_bytes(rendered.encode("utf-8")) + if LINE == "113": + write(path.with_suffix(".diagnostics.json"), dict(status="PASS", logSha256=audit.sha(path.read_bytes()), classifiedDiagnostics=[])) + + def drills(self, spec, moved): + result = [] + for a in spec["assertions"]: + block = a.get("block") + if block not in ("steamadvantage:steam_drill", "electricadvantage:electric_drill"): + continue + direction = a.get("meta", audit.DIRECTIONS.index(a.get("expectedText", "down"))) + origin = [x + y for x, y in zip(spec["origin"], a["pos"])] + rear = [x + y for x, y in zip(origin, audit.REAR[direction])] + pos = rear if moved else origin + key = spec["id"] + ":" + a["id"] + tag = ('{id:"' + block + '",x:' + str(pos[0]) + ',y:' + str(pos[1]) + ',z:' + str(pos[2]) + + ',ForgeData:{AdvBayWitness:' + json.dumps(key) + '},Energy:[I;0],Items:[{id:"minecraft:diamond",Count:7b,' + + 'tag:{AdvBayStock:' + json.dumps(key) + '}}]}') + result.append(dict(pos=pos, block=block, state="facing=" + audit.DIRECTIONS[direction], nbt=tag)) + if not moved: + result.append(dict(pos=rear, block="steamadvantage:steam_track" if spec["id"] == "S-05" else "electricadvantage:electric_track", state="track")) + return result + + def assert_rejected(self, contains=None, requested=audit.PROFILES): + result = audit.audit(self.runtime, LINE, requested) + self.assertEqual("FAIL", result["status"], result) + if contains: + self.assertIn(contains, " ".join(result["errors"])) + + def mutate_raw(self, mutate, phase="first", name="packaged-current"): + path = self.profile(name) / "advantage-works-results" / (phase + "-all-stations.json") + data = load(path) + mutate(data) + write(path, data) + + def test_complete_both_profiles_accepts_bounded_proof_not_manual_pass(self): + result = audit.audit(self.runtime, LINE) + self.assertEqual("PASS", result["status"], result["errors"]) + self.assertEqual(2, len(result["profiles"])) + self.assertEqual("NOT_AUTOMATED_BY_BROAD_SWEEP", result["profiles"][audit.PROFILES[0]]["manualProcedures"]) + self.assertIn("NOT_INDEPENDENTLY_RECORDED", result["profiles"][audit.PROFILES[0]]["shutdown"]["first"]["processExit"]) + + def test_first_profile_pass_never_implies_both(self): + self.report["profiles"].pop("packaged-decoupled") + self.save_top() + self.assert_rejected("Requested profile set incomplete") + + def test_explicit_single_profile_is_distinct_from_default_both(self): + self.report["profiles"].pop("packaged-decoupled") + self.save_top() + self.assertEqual("PASS", audit.audit(self.runtime, LINE, ("packaged-current",))["status"]) + + def test_unrequested_extra_profile_rejected(self): + self.assert_rejected("Requested profile set incomplete", ("packaged-current",)) + + def test_second_profile_failure_cannot_be_masked(self): + self.report["profiles"]["packaged-decoupled"]["status"] = "FAIL" + self.save_profile("packaged-decoupled") + self.assert_rejected("profile did not pass") + + def test_missing_local_second_report_rejected(self): + (self.profile("packaged-decoupled") / "broad-results.json").unlink() + self.assert_rejected("broad-results.json") + + def test_top_running_is_not_final_pass(self): + self.report["status"] = "RUNNING" + self.save_top() + self.assert_rejected("did not finish PASS") + + def test_empty_or_duplicate_requested_set_rejected(self): + self.assert_rejected("Invalid requested", ()) + self.assert_rejected("Invalid requested", ("packaged-current", "packaged-current")) + + def test_missing_station_and_missing_raw_assertion_rejected(self): + self.mutate_raw(lambda rows: rows.pop("W-04")) + self.assert_rejected("all 21") + + def test_omitted_assertion_rejected_despite_summary_counts(self): + self.mutate_raw(lambda rows: rows["W-01"]["assertions"].pop()) + self.assert_rejected("assertion set incomplete") + + def test_duplicate_assertion_rejected(self): + self.mutate_raw(lambda rows: rows["W-01"]["assertions"].append(deepcopy(rows["W-01"]["assertions"][0]))) + self.assert_rejected("duplicate") + + def test_partial_pass_counts_rejected(self): + self.report["profiles"]["packaged-current"]["phases"]["first"]["assertions"]["PASS"] -= 1 + self.save_profile() + self.assert_rejected("summary counts/manual list") + + def test_manual_station_cannot_be_reported_as_pass(self): + self.mutate_raw(lambda rows: rows["W-01"].update(status="PASS")) + self.assert_rejected("classification masked") + + def test_manual_procedures_cannot_disappear(self): + self.mutate_raw(lambda rows: rows["W-01"].update(manualChecks=[])) + self.assert_rejected("manual checks omitted") + + def test_unexplained_skip_rejected(self): + self.mutate_raw(lambda rows: rows["W-01"]["assertions"][0].update(status="SKIP")) + self.assert_rejected("wrong raw result") + + def test_absent_mod_integration_cannot_be_pass(self): + def change(rows): + a = next(a for a in rows["S-04"]["assertions"] if a["id"] == "base-metals-steel-plate") + a["status"] = "PASS" + self.mutate_raw(change, name="packaged-decoupled") + self.assert_rejected("wrong raw result") + + def test_failed_assertion_cannot_hide_behind_manual(self): + self.mutate_raw(lambda rows: rows["W-01"]["assertions"][0].update(status="FAIL")) + self.assert_rejected("wrong raw result") + + def test_loaded_mod_version_mismatch_rejected(self): + self.mutate_raw(lambda rows: rows["W-01"]["mods"].update(poweradvantage="wrong")) + self.assert_rejected("loaded mod set") + + def test_error_and_shutdown_error_cannot_be_masked(self): + self.report["profiles"]["packaged-current"]["shutdownError"] = "process exit failed" + self.save_profile() + self.assert_rejected("failure masked") + + def test_top_level_error_cannot_hide_behind_pass(self): + self.report["error"] = "second profile crashed" + self.save_top() + self.assert_rejected("failure masked") + + def test_recorded_nonzero_process_exit_is_not_ignored(self): + self.report["profiles"]["packaged-current"]["processExitCode"] = 1 + self.save_profile() + self.assert_rejected("process exit failed") + + def test_pass_detail_must_not_say_did_not_match(self): + self.mutate_raw(lambda rows: rows["W-01"]["assertions"][0].update(detail="did not match")) + self.assert_rejected("detail contradicts result") + + def test_missing_restart_phase_rejected(self): + self.report["profiles"]["packaged-current"]["phases"].pop("persistence") + self.save_profile() + self.assert_rejected("required phases incomplete") + + def test_native_drift_cannot_be_masked_by_summary_pass(self): + self.mutate_raw(lambda rows: rows["S-05"]["snapshot"][0].update(nbt=rows["S-05"]["snapshot"][0]["nbt"].replace("Count:7b", "Count:6b")), phase="restarted") + self.assert_rejected("stock lost/duplicated") + + def test_native_drill_summary_is_not_the_measurement(self): + self.report["profiles"]["packaged-current"]["beforeRestart"]["S-05:down-facing"]["pos"][0] += 1 + self.save_profile() + self.assert_rejected("differs from native snapshots") + + def test_mod_bytes_and_staging_receipt_both_required(self): + (self.profile() / "mods/poweradvantage.jar").write_bytes(b"changed") + self.assert_rejected("mod bytes differ") + + def test_extra_mod_rejected(self): + (self.profile() / "mods/unpinned.jar").write_bytes(b"extra") + self.assert_rejected("mod bytes differ") + + def test_staging_receipt_cannot_lie(self): + path = self.profile() / "input-jars.json" + data = load(path) + data.pop() + write(path, data) + self.assert_rejected("input receipt differs") + + def test_manifest_and_runner_hashes_required(self): + self.manifest.write_text(self.manifest.read_text() + "\n") + self.assert_rejected("manifest changed") + + def test_archived_runner_changed_rejected(self): + (self.runtime / "runner-source.py").write_bytes(b"changed") + self.assert_rejected("runner changed") + + def test_staged_engine_bytes_required(self): + (self.profile() / "forge.jar").write_bytes(b"wrong") + self.assert_rejected("launcher changed") + + def test_missing_restart_log_rejected(self): + (self.profile() / "console-restarted.log").unlink() + self.assert_rejected("console-restarted.log") + + def test_native_check_log_must_correspond_to_all_raw_stations(self): + path = self.profile() / "console-restarted.log" + path.write_bytes(path.read_bytes().replace(b"MANUAL_REQUIRED=21", b"MANUAL_REQUIRED=20")) + self.assert_rejected("native check summary differs") + + def test_native_first_boot_must_run_each_station(self): + path = self.profile() / "console-first.log" + path.write_bytes(path.read_bytes().replace(b"Started W-04 -", b"omitted W-04 -")) + self.assert_rejected("station activity") + + def test_restart_cannot_rebuild_to_hide_state_loss(self): + path = self.profile() / "console-restarted.log" + path.write_bytes(path.read_bytes() + b"Advantage Works built 21 station(s)\n") + self.assert_rejected("activity after cold restart") + + def test_missing_operated_station_checkpoint_rejected(self): + (self.profile() / "advantage-works-results/W-04-checkpoint-operated.json").unlink() + self.assert_rejected("W-04-checkpoint-operated.json") + + def test_stale_restart_records_are_not_accepted(self): + self.mutate_raw(lambda rows: rows["W-01"].update(worldTime=0), phase="restarted") + self.assert_rejected("not ordered") + + def test_shutdown_without_save_rejected(self): + path = self.profile() / "console-restarted.log" + path.write_bytes(path.read_bytes().replace(b"Saved the game", b"not saved").replace(b"All chunks are saved", b"not completed")) + self.assert_rejected("save and shutdown" if LINE == "113" else "completed chunk-storage shutdown") + + def test_shutdown_player_saves_are_required(self): + path = self.profile() / "console-restarted.log" + path.write_bytes(path.read_bytes().replace(b"Saving players", b"not completed")) + self.assert_rejected("incomplete shutdown saves") + + def test_later_native_stop_can_prove_save_without_explicit_flush_command(self): + if LINE == "113": + self.skipTest("Forge 25 protocol explicitly flushes before stopping") + path = self.profile() / "console-restarted.log" + path.write_bytes(path.read_bytes().replace(b"Saved the game", b"native stop autosave")) + result = audit.audit(self.runtime, LINE) + self.assertEqual("PASS", result["status"], result["errors"]) + self.assertFalse(result["profiles"]["packaged-current"]["shutdown"]["restarted"]["explicitSaveCommand"]) + + def test_missing_dimension_shutdown_rejected(self): + path = self.profile() / "console-restarted.log" + path.write_bytes(path.read_bytes().replace(b"/minecraft:the_end", b"/missing")) + self.assert_rejected("saved dimension") + + def test_error_in_complete_log_rejected(self): + path = self.profile() / "console-restarted.log" + path.write_bytes(path.read_bytes() + b"[Server thread/ERROR] serious native failure\n") + self.assert_rejected("native error") + + def test_duplicate_json_keys_rejected(self): + with self.assertRaisesRegex(AssertionError, "Duplicate JSON"): + audit.decode(b'{"status":"FAIL","status":"PASS"}') + + def test_cli_default_rejects_partial_and_returns_nonzero(self): + self.report["profiles"].pop("packaged-decoupled") + self.save_top() + output = io.StringIO() + with redirect_stdout(output): + code = audit.main(["--runtime", str(self.runtime), "--line", LINE]) + self.assertEqual(1, code) + self.assertEqual(list(audit.PROFILES), json.loads(output.getvalue())["requestedProfiles"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/test-rig/migration/test_extended_absence_audit.py b/test-rig/migration/test_extended_absence_audit.py new file mode 100644 index 0000000..f0e303c --- /dev/null +++ b/test-rig/migration/test_extended_absence_audit.py @@ -0,0 +1,48 @@ +"""Independent raw event-vector proof controls; no native server activity.""" +from copy import deepcopy +import unittest +from audit_extended_absence import interval_proof +import test_extended_absence_continuity as fixture_source + + +class AbsenceAuditControls(unittest.TestCase): + def proof(self, fixture): + f,before,samples,_=fixture + return interval_proof(f,before,samples,"supply") + + def fixture(self,ticks=(10,50)): + return fixture_source.ContinuousAbsenceTests().fixture(ticks) + + def test_exact_40_native_ticks(self): + self.assertEqual("PASS",self.proof(self.fixture())["status"]) + + def test_dense_189_tick_segment_not_last_38_tick_gap(self): + proof=self.proof(self.fixture((1315,1336,1357,1378,1399,1422,1444,1466,1504))) + self.assertEqual(("PASS",189,38),(proof["status"],proof["nativeTicks"],proof["lastAdjacentTicks"])) + + def test_39_ticks_is_not_40(self): + self.assertEqual("FAIL",self.proof(self.fixture((10,30,49)))["status"]) + + def test_hidden_reload_rejected(self): + f=self.fixture();f[2][-1]["chunks"][0].update(loads=2,unloads=2) + self.assertEqual("FAIL",self.proof(f)["status"]) + + def test_observed_reload_rejected(self): + f=self.fixture((10,30,90));f[2][1]["chunks"][0]["loaded"]=True + self.assertEqual("FAIL",self.proof(f)["status"]) + + def test_late_stable_suffix_does_not_erase_reload(self): + f=self.fixture((10,30,90)) + for sample in f[2][1:]:sample["chunks"][0].update(loads=2,unloads=2) + self.assertEqual("FAIL",self.proof(f)["status"]) + + def test_clock_must_not_move_backwards(self): + self.assertEqual("FAIL",self.proof(self.fixture((10,5,50)))["status"]) + + def test_raw_inputs_are_not_changed(self): + f=self.fixture();before=deepcopy(f);self.proof(f) + self.assertEqual(before,f) + + +if __name__=="__main__": + unittest.main() diff --git a/test-rig/migration/test_extended_absence_continuity.py b/test-rig/migration/test_extended_absence_continuity.py new file mode 100644 index 0000000..0401f61 --- /dev/null +++ b/test-rig/migration/test_extended_absence_continuity.py @@ -0,0 +1,128 @@ +"""Native-tick continuity controls; these fixtures do not launch Minecraft.""" +from copy import deepcopy +import unittest + +from extended_lifecycle import fixtures, partial_result + + +class ContinuousAbsenceTests(unittest.TestCase): + def fixture(self, ticks=(10, 50)): + f = deepcopy(next(f for f in fixtures() if f["id"] == "C-FLUID")) + supply, machine = f["groups"]["supply"][0], f["groups"]["machine"][0] + entries = [dict(pos=p, block="pipe", state="pipe", nbt="{}") for p in f["witnesses"]] + entries[-1].update(tileIdentity=42, nbt='{Tank:{FluidName:"minecraft:water",Amount:1000}}') + before = dict(worldTime=0, chunks=[dict(pos=supply, loaded=True, loads=1, unloads=0), + dict(pos=machine, loaded=True, loads=1, unloads=0)], cases={f["id"]: entries}) + samples = [] + for tick in ticks: + sample = deepcopy(before) + sample["worldTime"] = tick + sample["chunks"][0].update(loaded=False, unloads=1) + samples.append(sample) + samples[-1]["confirmed"] = True + loaded = deepcopy(before) + loaded["worldTime"] = ticks[-1] + 1 + loaded["chunks"][0].update(loads=2, unloads=1) + return f, before, samples, loaded + + def status(self, fixture): + f, before, samples, loaded = fixture + return partial_result([f], before, samples, loaded, "supply")[f["id"]]["status"] + + def test_dense_189_tick_absence_with_final_gap_38_passes(self): + self.assertEqual("PASS", self.status(self.fixture((1315, 1336, 1357, 1378, 1399, 1422, 1444, 1466, 1504)))) + + def test_exactly_40_native_ticks_passes(self): + self.assertEqual("PASS", self.status(self.fixture())) + + def test_39_ticks_never_becomes_40(self): + self.assertEqual("FAIL", self.status(self.fixture((10, 30, 49)))) + + def test_single_absent_instant_is_insufficient(self): + self.assertEqual("FAIL", self.status(self.fixture((50,)))) + + def test_hidden_reload_between_absent_observations_fails(self): + f = self.fixture() + f[2][-1]["chunks"][0].update(loads=2, unloads=2) + self.assertEqual("FAIL", self.status(f)) + + def test_long_suffix_must_not_hide_a_reload_in_the_observed_segment(self): + f = self.fixture((10, 30, 90)) + for sample in f[2][1:]: + sample["chunks"][0].update(loads=2, unloads=2) + self.assertEqual("FAIL", self.status(f)) + + def test_observed_reload_fails(self): + f = self.fixture((10, 30, 90)) + f[2][1]["chunks"][0].update(loaded=True, loads=2) + f[2][-1]["chunks"][0].update(loads=2, unloads=2) + self.assertEqual("FAIL", self.status(f)) + + def test_initial_unload_latency_does_not_count_as_absence(self): + f = self.fixture((1, 10, 50)) + f[2][0]["chunks"][0].update(loaded=True, unloads=0) + self.assertEqual("PASS", self.status(f)) + + def test_real_unload_callback_required(self): + f = self.fixture() + for sample in f[2]: sample["chunks"][0]["unloads"] = 0 + self.assertEqual("FAIL", self.status(f)) + + def test_confirmation_required_at_terminal_observation(self): + f = self.fixture((10, 50, 90)) + f[2][1]["confirmed"] = True + f[2][-1].pop("confirmed") + self.assertEqual("FAIL", self.status(f)) + + def test_missing_native_clock_is_not_a_zero_tick_default(self): + f = self.fixture() + f[2][0].pop("worldTime") + self.assertEqual("FAIL", self.status(f)) + + def test_backwards_clock_does_not_create_a_longer_interval(self): + self.assertEqual("FAIL", self.status(self.fixture((10, 5, 50)))) + + def test_non_integer_clock_fails_closed(self): + f = self.fixture() + f[2][0]["worldTime"] = 10.0 + self.assertEqual("FAIL", self.status(f)) + + def test_retained_endpoint_must_remain_loaded(self): + f = self.fixture() + f[2][0]["chunks"][1]["loaded"] = False + self.assertEqual("FAIL", self.status(f)) + + def test_retained_event_counts_must_not_change(self): + f = self.fixture() + f[2][0]["chunks"][1]["loads"] = 2 + self.assertEqual("FAIL", self.status(f)) + + def test_retained_tile_identity_must_not_change(self): + f = self.fixture() + f[2][0]["cases"][f[0]["id"]][-1]["tileIdentity"] = 43 + self.assertEqual("FAIL", self.status(f)) + + def test_reload_requires_a_real_load_callback(self): + f = self.fixture() + f[3]["chunks"][0]["loads"] = 1 + self.assertEqual("FAIL", self.status(f)) + + def test_negative_event_counts_rejected(self): + f = self.fixture() + for sample in f[2]: sample["chunks"][0]["loads"] = -1 + self.assertEqual("FAIL", self.status(f)) + + def test_missing_selected_chunk_fails_closed(self): + f = self.fixture() + f[2][0]["chunks"].pop(0) + self.assertEqual("FAIL", self.status(f)) + + def test_inputs_are_not_rewritten(self): + f = self.fixture((10, 30, 70)) + expected = deepcopy(f) + self.assertEqual("PASS", self.status(f)) + self.assertEqual(expected, f) + + +if __name__ == "__main__": + unittest.main() diff --git a/test-rig/migration/test_native113_chunk_readiness.py b/test-rig/migration/test_native113_chunk_readiness.py new file mode 100644 index 0000000..ab0dac5 --- /dev/null +++ b/test-rig/migration/test_native113_chunk_readiness.py @@ -0,0 +1,19 @@ +import copy +import unittest +from native113_chunk_readiness import validate + + +class NativeReadinessTests(unittest.TestCase): + def test_complete_native_state_passes_but_incomplete_state_waits(self): + document = dict(label="case", worldTime=12, ready=True, chunks=[dict(x=0, z=0, loaded=True)]) + self.assertTrue(validate(document, "case", (0, 0, 0), (15, 0, 15))) + document.update(ready=False, chunks=[dict(x=0, z=0, loaded=False)]) + self.assertFalse(validate(document, "case", (0, 0, 0), (15, 0, 15))) + + def test_ready_label_cannot_override_missing_or_false_native_evidence(self): + base = dict(label="case", worldTime=12, ready=True, chunks=[dict(x=0, z=0, loaded=True)]) + for change in (dict(chunks=[]), dict(chunks=[dict(x=0, z=0, loaded=False)]), + dict(label="stale"), dict(chunks=[dict(x=1, z=0, loaded=True)]), + dict(chunks=[dict(x=0, z=0, loaded=1)])): + bad = copy.deepcopy(base); bad.update(change) + with self.assertRaises(AssertionError): validate(bad, "case", (0, 0, 0), (15, 0, 15)) diff --git a/test-rig/migration/test_native113_code.py b/test-rig/migration/test_native113_code.py new file mode 100644 index 0000000..2b4686e --- /dev/null +++ b/test-rig/migration/test_native113_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 native113_code import INVENTORY, Server, code_files, verify_code + + +class Native113CodeCustodyTest(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("matrix.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_native113_diagnostics.py b/test-rig/migration/test_native113_diagnostics.py new file mode 100644 index 0000000..2322aad --- /dev/null +++ b/test-rig/migration/test_native113_diagnostics.py @@ -0,0 +1,43 @@ +import tempfile +from pathlib import Path +import unittest + +from native113_diagnostics import audit_lines + + +class NativeFatalAuditTests(unittest.TestCase): + def setUp(self): + self.root = Path(tempfile.gettempdir()).resolve() + self.cleanup = ("[12:00:00.000] [Thread-7/FATAL] [ne.mi.fm.pa.ModFileResourcePack/]: " + "Failed to clean up tempdir " + str(self.root / "modpacktmp12345")) + self.shutdown = ["Saved the game", "Stopping the server"] + + def test_exact_post_save_cleanup_is_retained_not_hidden(self): + result = audit_lines(self.shutdown + [self.cleanup]) + self.assertEqual(1, len(result["classifiedDiagnostics"])) + self.assertEqual(self.cleanup, result["classifiedDiagnostics"][0]["message"]) + + def test_same_notice_during_boot_is_not_excused(self): + with self.assertRaises(AssertionError): audit_lines([self.cleanup]) + + def test_a_stop_without_save_evidence_is_not_excused(self): + with self.assertRaises(AssertionError): audit_lines(["Stopping server", self.cleanup]) + + def test_world_paths_are_never_temp_cleanup(self): + with self.assertRaises(AssertionError): + audit_lines(self.shutdown + [self.cleanup.replace(str(self.root), str(self.root / "world"))]) + + def test_other_fatal_messages_are_not_excused(self): + with self.assertRaises(AssertionError): + audit_lines(self.shutdown + [self.cleanup.replace("Failed to clean up tempdir", "Failed to create temporary directory")]) + + def test_other_errors_still_fail_after_classified_cleanup(self): + with self.assertRaises(AssertionError): + audit_lines(self.shutdown + [self.cleanup, "[Server thread/ERROR] bad state"]) + + def test_only_the_exact_native_configuration_reload_notice_is_classified(self): + notice = "[12:00:00] [Thread-1/FATAL] [ne.mi.co.ForgeConfig/CORE]: Forge config just got changed on the file system!" + self.assertEqual("FORGE_25_CONFIG_RELOAD_NOTICE", audit_lines([notice])["classifiedDiagnostics"][0]["classification"]) + for bad in (notice.replace("ForgeConfig/CORE", "Other/CORE"), notice + " error"): + with self.assertRaises(AssertionError): audit_lines([bad]) + with self.assertRaises(AssertionError): audit_lines([notice, "[Thread-1/ERROR] invalid config"]) diff --git a/test-rig/migration/test_native113_harvest.py b/test-rig/migration/test_native113_harvest.py new file mode 100644 index 0000000..6b2bb53 --- /dev/null +++ b/test-rig/migration/test_native113_harvest.py @@ -0,0 +1,142 @@ +"""Harvest fixture isolation and unchanged-oracle tests; never start a server.""" + +from copy import deepcopy +import hashlib +import json +from pathlib import Path +import unittest +from uuid import UUID + +import native113_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="unit:actual-drop", count=minimum, removed=False)) + identity += 1 + evidence.append(dict(trial=trial, worldTime=1, removedBeforeCleanup=trial, + liveBeforeCleanup=int(trial > 0), liveAfterCleanup=0, actualDrops=drops)) + rows.append(dict(id=name, status="PASS", historicalHarvestLevel=level, harvestAllowed=allowed, + silkTouch=silk, trials=trials, actualDropCounts=[minimum] * trials, + connectionMask=int(name.split("/")[1]) if name.startswith(("wire/", "scaffold/")) else -1, + actualSolid=name.startswith(("sulfur/", "lithium/", "wire/")), + fullCollision=not name.startswith("wire/"), dropEvidence=evidence)) + rows.append(dict(id="solar/native-solidity", status="PASS", actualSolid=True)) + return dict(label=label, minecraft="1.13.2", status="PASS", 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 HarvestIsolationTests(unittest.TestCase): + def test_original_270_harvest_oracles_are_exactly_preserved(self): + checksum = hashlib.sha256(json.dumps(native.EXPECTED, sort_keys=True, separators=(",", ":")).encode()).hexdigest().upper() + self.assertEqual("A43F316D748DFCCB4DA9B72AE771851FFBD30661C56E5609A0D04E0AC0050D5B", checksum) + self.assertEqual(270, len(native.EXPECTED)) + + def test_java_witness_inputs_masks_trial_counts_and_thresholds_are_unchanged(self): + source = (Path(__file__).resolve().parents[1] / + "src/main/java/zone/moddev/mc/advantageworks/NativeHarvestProbe.java").read_text() + region = source.split(" String sulfur =", 1)[1].split(" JsonObject solar =", 1)[0] + self.assertEqual("3FF0AC4C13CA1B737679F67128CE2F41C5A0873E89258D456628BA8A309BAA9A", + hashlib.sha256(region.encode()).hexdigest().upper()) + self.assertIn("Actual drop quantity changed: ", source) + self.assertIn('require(count >= minimum && count <= maximum,', source) + self.assertIn('equals(dropId), "Wrong actual drop: "', source) + self.assertIn('bounds, entity -> !entity.removed)', source) + + def test_new_protocol_accepts_all_original_cases_in_each_restart_phase(self): + for label in ("exercise", "restored"): + document = sample(label) + native.require_checks(document, label) + native.require_isolation(document) + + def test_old_or_missing_fixture_protocol_is_rejected(self): + for marker in (None, "native114", "native113-default-entity-query"): + document = sample() + document["fixtureIsolation"] = marker + with self.subTest(marker=marker), self.assertRaisesRegex(AssertionError, "isolation protocol"): + native.require_isolation(document) + + def test_incomplete_trial_audits_cannot_qualify(self): + document = sample() + row(document)["dropEvidence"].pop() + with self.assertRaisesRegex(AssertionError, "per-trial"): + native.require_isolation(document) + + def test_live_remainder_bad_sequence_or_noninteger_cleanup_is_rejected(self): + for field, value in (("liveAfterCleanup", 1), ("liveAfterCleanup", False), + ("removedBeforeCleanup", -1), ("liveBeforeCleanup", -1), + ("trial", 3), ("worldTime", -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_removed_entities_missing_identity_and_count_lies_are_rejected(self): + for field, value in (("removed", True), ("removed", None), ("entityId", "1"), + ("uuid", ""), ("item", ""), ("count", 0), ("count", True), ("count", 2)): + 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_same_entity_cannot_be_counted_in_two_trials_or_two_rows(self): + for across_rows in (False, True): + for field in ("entityId", "uuid"): + 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_no_drop_case_cannot_hide_an_unexpected_live_entity(self): + document = sample() + row(document, "lithium/wood-fails")["dropEvidence"][0]["actualDrops"] = [ + dict(entityId=99999, uuid=str(UUID(int=99999)), item="electricadvantage:sulfur_powder", count=4, removed=False)] + with self.assertRaisesRegex(AssertionError, "disagree"): + native.require_isolation(document) + + def test_failed_identity_witness_reports_actual_failure_not_missing_inventory(self): + document = sample() + document["status"] = "FAIL" + failed = row(document, "sulfur/silk") + failed.update(status="FAIL", failure="Wrong actual drop: 4 sulfur_powder") + with self.assertRaisesRegex(AssertionError, "sulfur/silk: Wrong actual drop: 4 sulfur_powder"): + native.require_checks(document, "exercise") + + def test_complete_inventory_with_failed_aggregate_status_still_fails(self): + document = sample() + document["status"] = "FAIL" + with self.assertRaisesRegex(AssertionError, "witnesses failed"): + native.require_checks(document, "exercise") + + def test_original_drop_bounds_wrong_tools_masks_shapes_and_solar_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), + ("wire/63/hand", "connectionMask", 0), + ("wire/0/hand", "actualSolid", False), + ("wire/0/hand", "fullCollision", True), + ("scaffold/0/hand", "actualSolid", True), + ("scaffold/0/hand", "fullCollision", False), + ("solar/native-solidity", "actualSolid", False)): + document = sample() + row(document, name)[field] = deepcopy(value) + with self.subTest(name=name, field=field), self.assertRaises(AssertionError): + native.require_checks(document, "exercise") + + +if __name__ == "__main__": + unittest.main() diff --git a/test-rig/migration/test_native113_legacy_snbt.py b/test-rig/migration/test_native113_legacy_snbt.py new file mode 100644 index 0000000..368c964 --- /dev/null +++ b/test-rig/migration/test_native113_legacy_snbt.py @@ -0,0 +1,32 @@ +import unittest +import nbtlib +from native113_legacy_snbt import parse_legacy_snbt + + +class LegacyDiagnosticSnbtTests(unittest.TestCase): + def test_summary_uses_actual_nonzero_binary_bytes(self): + binary = nbtlib.parse_nbt('{growth:[B;4b,-2b,7b],prog:[I;0,1,2]}') + parsed = parse_legacy_snbt('{growth:[3 bytes],prog:[0,1,2,]}', binary) + self.assertEqual([4, -2, 7], parsed["growth"].unpack().tolist()) + self.assertEqual([0, 1, 2], parsed["prog"].unpack()) + + def test_summary_without_binary_or_wrong_shape_fails(self): + for peer in (None, nbtlib.parse_nbt('{growth:[B;1b,2b]}'), nbtlib.parse_nbt('{growth:[I;1,2,3]}')): + with self.assertRaises(ValueError): parse_legacy_snbt('{growth:[3 bytes]}', peer) + + def test_nested_summary_and_quoted_text_are_distinct(self): + binary = nbtlib.parse_nbt('{Items:[{id:"minecraft:dirt",Count:3b,tag:{bytes:[B;8b],Name:"[3 bytes]"}}]}') + parsed = parse_legacy_snbt('{Items:[0:{id:"minecraft:dirt",Count:3b,tag:{bytes:[1 bytes],Name:"[3 bytes]"}}]}', binary) + self.assertEqual(8, int(parsed["Items"][0]["tag"]["bytes"][0])) + self.assertEqual("[3 bytes]", str(parsed["Items"][0]["tag"]["Name"])) + + def test_plain_and_indexed_lists_remain_supported(self): + self.assertEqual([0, 1], parse_legacy_snbt('[0,1,]').unpack()) + self.assertEqual(["a", "b"], parse_legacy_snbt('[0:"a",1:"b"]').unpack()) + + def test_malformed_legacy_forms_fail(self): + for text in ('[1:"a"]', '[0:"a",0:"b"]', '[0:1,2]', '[3 bytes,1]', '[3 bytes] extra'): + with self.assertRaises(ValueError): parse_legacy_snbt(text) + + +if __name__ == "__main__": unittest.main() diff --git a/test-rig/migration/test_native113_machine_operations.py b/test-rig/migration/test_native113_machine_operations.py new file mode 100644 index 0000000..91b8a52 --- /dev/null +++ b/test-rig/migration/test_native113_machine_operations.py @@ -0,0 +1,78 @@ +"""The new native suite must reject incomplete jobs and extra/lost restarted state.""" + +import copy +import unittest + +from native113_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]) + 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"]) + + +if __name__ == "__main__": unittest.main() diff --git a/test-rig/migration/test_native113_oil.py b/test-rig/migration/test_native113_oil.py new file mode 100644 index 0000000..b16db68 --- /dev/null +++ b/test-rig/migration/test_native113_oil.py @@ -0,0 +1,89 @@ +"""Native 1.13 oil protocol negative controls; no Minecraft process is mocked as gameplay proof.""" + +from copy import deepcopy +from pathlib import Path +from types import SimpleNamespace +import unittest +from unittest.mock import Mock, patch + +import native113_oil as native +import oil_transfer + + +class NativeOilTests(unittest.TestCase): + def runner(self): + runner = native.NativeOilRun(Path("unit-runtime"), Path("unit-java-not-executed"), + native.LAUNCHER, native.fixtures(dict(jars=[])), {}, {}) + runner.server = Mock() + runner.boots = [native.BOOT_LABELS[0]] + return runner + + def test_exact_original_geometry_and_no_implicit_mineralogy(self): + self.assertEqual(native.fixtures(dict(jars=[])), oil_transfer.fixtures()) + with self.assertRaises(ValueError): + native.fixtures(dict(jars=[dict(id="mineralogy")])) + + def test_bounds_overlap_and_disconnected_pipes_are_rejected(self): + original = native.fixtures(dict(jars=[])) + for mutate in (lambda lanes: lanes.append(deepcopy(lanes[0])), + lambda lanes: lanes[0]["pipes"][0].__setitem__(0, 0), + lambda lanes: lanes[0]["max"].__setitem__(1, 255)): + lanes = deepcopy(original) + mutate(lanes) + with self.assertRaises(AssertionError): + native.require_bounds(lanes) + + def test_no_bucket_before_empty_baseline_or_after_final_restart(self): + runner = self.runner() + with self.assertRaises(AssertionError): + runner.command("advlifecycle place-buckets premature") + runner.pending_construction.clear() + runner.seal_construction() + runner.permit_buckets() + runner.command("advlifecycle place-buckets first") + with self.assertRaises(AssertionError): + runner.command("advlifecycle place-buckets duplicate") + runner.boots = list(native.BOOT_LABELS) + with self.assertRaises(AssertionError): + runner.permit_buckets() + + def test_no_world_repair_or_late_reconstruction(self): + runner = self.runner() + command = runner.pending_construction[0] + runner.pending_construction.clear() + runner.seal_construction() + for forbidden in (command, "data merge block 1 2 3 {Tank:{Amount:1000}}", + "advlifecycle build hidden", "advworks reset all"): + with self.subTest(command=forbidden), self.assertRaises(AssertionError): + runner.command(forbidden) + + def test_save_acknowledgement_is_required(self): + runner = self.runner() + runner.command("save-all flush") + runner.server.send.assert_called_once_with("save-all flush") + runner.server.wait.assert_called_once_with("Saved the game", 120) + + def test_wrong_engine_or_out_of_order_boot_never_starts(self): + runner = self.runner() + runner.server = None + runner.launcher = "forge-1.14.4-28.2.26.jar" + with self.assertRaises(AssertionError): + runner.boot(native.BOOT_LABELS[1]) + + def test_staged_code_must_match_sealed_inventory(self): + with patch.object(native, "verify_code", side_effect=AssertionError("changed code")) as check: + with self.assertRaisesRegex(AssertionError, "changed code"): + native.verify_staged(Path("runtime"), dict(serverCode=dict(files={})), {}) + check.assert_called_once() + + def test_missing_duplicate_witnesses_fail_before_quantity_oracle(self): + lanes = native.fixtures(dict(jars=[])) + document = dict(cases={lane["id"]: [dict(pos=p) for p in lane["witnesses"]] for lane in lanes}) + native.require_document(document, lanes) + document["cases"][lanes[0]["id"]].pop() + with self.assertRaises(AssertionError): + native.require_document(document, lanes) + + +if __name__ == "__main__": + unittest.main() diff --git a/test-rig/migration/test_native113_redstone_reload.py b/test-rig/migration/test_native113_redstone_reload.py new file mode 100644 index 0000000..ab33dd3 --- /dev/null +++ b/test-rig/migration/test_native113_redstone_reload.py @@ -0,0 +1,189 @@ +"""Independent negative controls for the cold-reload redstone operating oracle.""" + +import copy +from pathlib import Path +import struct +import unittest + +from native113_redstone_reload import definitions, validate_readiness, validate_reload, validate_seed + + +def row(case, progress, held=False, energy=None, targeted=True): + energy = 500 - 24 * progress if energy is None else energy + x, y, z = case["pos"] + bits = struct.unpack(">i", struct.pack(">f", energy))[0] + states = ','.join('{Name:"minecraft:obsidian"}' for _ in range(9)) + items = '{Slot:0b,id:"minecraft:dirt",Count:7b,tag:{AdvWorksWitness:"' + case["id"] + '"}},{Slot:1b,id:"minecraft:apple",Count:13b}' + nbt = ('{id:"electricadvantage:electric_drill",x:%d,y:%d,z:%d,Energy:[I;%d],progress:%ds,' + 'Items:[%s],targetX:%d,targetY:%d,targetZ:%d,TargetStates:[%s]}') % ( + x, y, z, bits, progress, items, x, y, z - 1, states) + if not targeted: + nbt = nbt[:nbt.index(',targetX:')] + '}' + return dict(case, block="electricadvantage:electric_drill", active=True, + state="Block{electricadvantage:electric_drill}[active=true,facing=north]", + signal=held, holdBlock="minecraft:redstone_block" if held else "minecraft:air", + energy=energy, progress=progress, nbt=nbt, + targets=[dict(pos=[x + dx, y + dy, z - 1], state="Block{minecraft:obsidian}") + for dx in range(-1, 2) for dy in range(-1, 2)]) + + +def documents(): + initial = [row(case, 0, targeted=False) for case in definitions()] + for value in initial: + value.update(active=False, state=value["state"].replace("active=true", "active=false")) + trace = [] + for i in range(1, 10): + values = [] + for case in definitions(): + acquired = next(t for t in range(101, 109) if (t + case["offset"]) % 8 == 0) + values.append(row(case, max(0, 100 + i - acquired), targeted=100 + i >= acquired)) + trace.append(dict(index=i, tickStartWorldTime=99 + i, worldTime=100 + i, cases=values)) + before = trace[-1]["cases"] + fixture = dict(schema=1, processToken="seed-process", constructionCallsThisProcess=9, + initialEnergy=500, initial=dict(worldTime=100, cases=initial), seedTrace=trace, + preHold=copy.deepcopy(before), worldTime=109, cases=copy.deepcopy(before)) + for value in fixture["cases"]: + value.update(signal=value["held"], holdBlock="minecraft:redstone_block" if value["held"] else "minecraft:air") + restored = dict(worldTime=200, cases=copy.deepcopy(fixture["cases"])) + ticks = [] + previous = copy.deepcopy(fixture["cases"]) + for i in range(1, 17): + rows = [] + for case in definitions(): + if case["held"]: + old = next(r for r in fixture["cases"] if r["id"] == case["id"]) + value = row(case, 0, True, energy=old["energy"]) + value.update(active=False, state=value["state"].replace("active=true", "active=false")) + else: + old = previous[-1] + powered = old["energy"] > 24 + value = row(case, old["progress"] + (1 if powered else -1), energy=old["energy"] - (24 if powered else 0)) + if value["energy"] <= 24: + value.update(active=False, state=value["state"].replace("active=true", "active=false")) + rows.append(value) + ticks.append(dict(index=i, tickStartWorldTime=199 + i, worldTime=200 + i, cases=rows)) + previous = rows + report = dict(schema=1, processToken="restart-process", constructionCallsThisProcess=0, + observationTicks=16, restored=restored, ticks=ticks, held=True) + fixture["readiness"] = readiness("seed", 100) + report["readiness"] = readiness("restart", 200) + return fixture, report + + +def readiness(phase, release_time): + rows = [dict(id=case["id"], pos=case["pos"], blockTicking=True, tickableListed=True) for case in definitions()] + pending = copy.deepcopy(rows) + pending[-1].update(blockTicking=False, tickableListed=False) + return [dict(phase=phase, worldTime=release_time - 1, cases=pending), + dict(phase=phase, worldTime=release_time, cases=rows)] + + +class RedstoneReloadOracleTests(unittest.TestCase): + def assert_rejected(self, change): + fixture, report = documents() + change(fixture, report) + with self.assertRaises(AssertionError): + validate_reload(fixture, report) + + def test_all_eight_offsets_and_finite_unheld_control(self): + fixture, report = documents() + self.assertEqual(set(range(8)), {r["offset"] for r in fixture["cases"] if r["held"]}) + self.assertEqual("PASS", validate_seed(fixture)["status"]) + self.assertEqual("PASS", validate_reload(fixture, report)["status"]) + + def test_first_tick_active_transient_is_not_ignored(self): + self.assert_rejected(lambda f, r: r["ticks"][0]["cases"][0].update(active=True)) + + def test_one_tick_debit_fails_even_if_later_refunded(self): + self.assert_rejected(lambda f, r: r["ticks"][0]["cases"][0].update(energy=428)) + + def test_source_cache_bug_with_consistent_raw_nbt_is_rejected(self): + def actual_debit(fixture, report): + seed = fixture["cases"][0] + report["ticks"][0]["cases"][0] = row(definitions()[0], seed["progress"] + 1, + True, energy=seed["energy"] - 24) + self.assert_rejected(actual_debit) + + def test_active_transient_with_consistent_raw_blockstate_is_rejected(self): + def actual_flag(fixture, report): + value = report["ticks"][0]["cases"][0] + value.update(active=True, state=value["state"].replace("active=false", "active=true")) + self.assert_rejected(actual_flag) + + def test_progress_above_previous_not_only_above_seed_is_rejected(self): + self.assert_rejected(lambda f, r: r["ticks"][1]["cases"][0].update(progress=1)) + + def test_unheld_frozen_false_positive_is_rejected(self): + def freeze(fixture, report): + for tick in report["ticks"]: + tick["cases"][-1] = copy.deepcopy(fixture["cases"][-1]) + self.assert_rejected(freeze) + + def test_missing_duplicate_or_wrong_phase_witness_is_rejected(self): + self.assert_rejected(lambda f, r: r["ticks"][0]["cases"].pop()) + self.assert_rejected(lambda f, r: r["ticks"][0]["cases"].append(r["ticks"][0]["cases"][0])) + self.assert_rejected(lambda f, r: r["ticks"][0]["cases"][0].update(offset=7)) + + def test_missing_repeated_or_gapped_tick_is_rejected(self): + self.assert_rejected(lambda f, r: r["ticks"].pop()) + self.assert_rejected(lambda f, r: r["ticks"][1].update(worldTime=201)) + self.assert_rejected(lambda f, r: r["ticks"][1].update(tickStartWorldTime=202, worldTime=203)) + + def test_reconstruction_same_process_and_nonquiescence_are_rejected(self): + self.assert_rejected(lambda f, r: r.update(constructionCallsThisProcess=1)) + self.assert_rejected(lambda f, r: r.update(processToken=f["processToken"])) + self.assert_rejected(lambda f, r: r.update(held=False)) + + def test_restored_nbt_must_match_entire_seed_not_only_resources(self): + self.assert_rejected(lambda f, r: r["restored"]["cases"][0].update(nbt=f["cases"][0]["nbt"][:-1] + ',Hidden:1}')) + self.assert_rejected(lambda f, r: r["restored"]["cases"][0].update(nbt=f["cases"][0]["nbt"].replace('Count:13b', 'Count:12b'))) + + def test_real_signal_target_and_stationary_position_are_checked(self): + self.assert_rejected(lambda f, r: r["ticks"][0]["cases"][0].update(signal=False)) + self.assert_rejected(lambda f, r: r["ticks"][0]["cases"][0]["targets"][0].update(state="Block{minecraft:air}")) + self.assert_rejected(lambda f, r: r["ticks"][0]["cases"][0].update(pos=[0, 80, 0])) + + def test_seed_needs_natural_mid_target_work_and_original_finite_charge(self): + fixture, _ = documents() + for change in (lambda f: f.update(seedTrace=[]), lambda f: f.update(initialEnergy=501), + lambda f: f["cases"][0].update(progress=0), + lambda f: f["seedTrace"][-1]["cases"][0].update(energy=500)): + bad = copy.deepcopy(fixture) + change(bad) + with self.assertRaises(AssertionError): validate_seed(bad) + + def test_both_native_ticking_and_actual_tickable_registration_are_required(self): + for field in ("blockTicking", "tickableListed"): + def change(fixture, report): + report["readiness"][-1]["cases"][-1][field] = False + self.assert_rejected(change) + self.assert_rejected(lambda f, r: r.update(readiness=[])) + self.assert_rejected(lambda f, r: f.update(readiness=[])) + + def test_readiness_is_bounded_and_not_resampled_until_operating_verdict_passes(self): + data = dict(readiness=readiness("seed", 100)) + validate_readiness(data, "seed", 100) + for change in (lambda d: d["readiness"].append(copy.deepcopy(d["readiness"][-1])), + lambda d: d["readiness"][0]["cases"][-1].update(blockTicking=True, tickableListed=True), + lambda d: d["readiness"][-1].update(worldTime=101), + lambda d: d.update(readiness=d["readiness"] * 41)): + bad = copy.deepcopy(data) + change(bad) + with self.assertRaises(AssertionError): validate_readiness(bad, "seed", 100) + + def test_probe_never_drives_ticks_or_loads_expected_nbt(self): + source = (Path(__file__).parents[1] / "src/main/java/zone/moddev/mc/advantageworks/RedstoneReloadProbe.java").read_text() + for forbidden in (".tickUpdate(", ".powerUpdate(", ".tick();", ".load(", ".read("): + self.assertNotIn(forbidden, source) + self.assertIn("tickableTileEntities.clear()", source) + # Forge 25's native tile loop gates on a loaded block and tick-list membership, not later ChunkHolders. + self.assertIn("world.isBlockLoaded(witness.pos)", source) + self.assertIn("tickableTileEntities.contains(tile)", source) + self.assertIn('else if (ready("seed"))', source) + self.assertIn('else if (ready("restart"))', source) + self.assertIn("StandardOpenOption.CREATE_NEW", source) + mod = (Path(__file__).parents[1] / "src/main/java/zone/moddev/mc/advantageworks/AdvantageWorksMod.java").read_text() + self.assertEqual(1, mod.count("RedstoneReloadProbe.register();")) + + +if __name__ == "__main__": unittest.main() diff --git a/test-rig/migration/test_native113_resource_audit.py b/test-rig/migration/test_native113_resource_audit.py new file mode 100644 index 0000000..b02dbe3 --- /dev/null +++ b/test-rig/migration/test_native113_resource_audit.py @@ -0,0 +1,296 @@ +"""Mutation controls use the retained migration/connected row and NBT schemas.""" + +import base64 +import copy +import gzip +import hashlib +import io +import json +from pathlib import Path +import struct +import tempfile +import unittest + +import nbtlib + +from native113_resource_audit import BATTERIES, SLOT_LIMITS, canonical_resources, compare_resources, parse_legacy_snbt + + +def encode(row, snbt): + stream = io.BytesIO() + nbtlib.File(nbtlib.parse_nbt(snbt)).write(stream) + row["nbt"] = snbt + row["nbtBase64"] = base64.b64encode(gzip.compress(stream.getvalue())).decode("ascii") + return row + + +def witness(block="minecraft:chest", snbt="{Items:[]}", x=143): + return encode(dict(id="fixture-" + str(x), station="C-ELECTRIC", kind="battery", + name=block, actualName=block, x=x, y=80, z=47, + properties={}, witness="fixture-" + str(x), objectIdentity=123), snbt) + + +def bits(amount): + return struct.unpack(">i", struct.pack(">f", amount))[0] + + +class LegacySnbtGrammarTests(unittest.TestCase): + def test_nested_legacy_lists_preserve_quoted_text(self): + value = parse_legacy_snbt('{Items:[0:{id:"minecraft:dirt",Count:2b,tag:{Name:"[0:not an index]",Lore:[0:"first",1:"second"]}}]}') + self.assertEqual(2, int(value["Items"][0]["Count"])) + self.assertEqual("[0:not an index]", str(value["Items"][0]["tag"]["Name"])) + self.assertEqual(["first", "second"], value["Items"][0]["tag"]["Lore"].unpack()) + + def test_modern_number_lists_still_parse(self): + self.assertEqual([0, 1, 2], parse_legacy_snbt('[0,1,2]').unpack()) + + def test_legacy_int_array_trailing_comma_is_preserved(self): + self.assertEqual([1168297984], parse_legacy_snbt('{Energy:[1168297984,]}')["Energy"].unpack()) + + def test_mixed_skipped_duplicate_and_trailing_indices_fail(self): + for text in ('[0:1,2]', '[1:1]', '[0:1,0:2]', '[0:1] extra'): + with self.assertRaises(ValueError): parse_legacy_snbt(text) + + +def sample(): + return [ + witness("electricadvantage:electric_battery_array", + '{Energy:[I;1174011904],Items:[{Slot:0b,id:"electricadvantage:lithium_battery",Count:1b,tag:{energy:8000.0f}}]}'), + witness("steamadvantage:steam_boiler_coal", + '{Energy:[I;1088421888,0],BurnTime:802,BurnTimeTotal:1600,Items:[{Slot:0b,id:"minecraft:coal",Count:31b}],Tank:{FluidName:"minecraft:water",Amount:3992}}', 65), + witness("electricadvantage:steam_powered_generator", + '{Energy:[I;1147797504,0],Items:[],steam:29.452f}', 141), + ] + + +class ResourceAuditTest(unittest.TestCase): + def check(self, before, after, status="FAIL", **kwargs): + result = compare_resources(before, after, **kwargs) + self.assertEqual(status, result["status"], result) + return result + + def test_actual_row_schemas_accept_arrays_and_connected_wrappers(self): + before = sample() + wrapped = dict(held=True, requestedTicks=0, advancedTicks=0, + gameTime=807, buildCallsThisProcess=0, snapshot=copy.deepcopy(before)) + self.check(before, wrapped, "PASS") + self.assertEqual(3, compare_resources(wrapped, before)["positions"]) + + def test_read_only_path_api_does_not_change_inputs(self): + with tempfile.TemporaryDirectory() as directory: + a, b = Path(directory) / "migration-converted.json", Path(directory) / "connected-loaded.json" + a.write_text(json.dumps(sample()), encoding="utf-8") + b.write_text(json.dumps(dict(snapshot=sample(), held=True)), encoding="utf-8") + hashes = [hashlib.sha256(path.read_bytes()).hexdigest() for path in (a, b)] + self.check(a, b, "PASS") + self.assertEqual(hashes, [hashlib.sha256(path.read_bytes()).hexdigest() for path in (a, b)]) + + def test_inputs_and_override_maps_are_not_mutated(self): + before = sample() + after = copy.deepcopy(before) + backup = copy.deepcopy(before) + limits = {"electricadvantage:electric_battery_array": 8} + self.check(before, after, "PASS", slot_limits=limits) + self.assertEqual(before, backup) + self.assertEqual(after, backup) + self.assertEqual(limits, {"electricadvantage:electric_battery_array": 8}) + + def test_root_nonresource_native_metadata_is_not_blanket_nbt_equality(self): + before = [witness(snbt='{Items:[],Lock:""}')] + after = [witness(snbt='{Items:[],CustomName:"native",DataVersion:2586,ForgeData:{AdvConnectedWitness:"same",cache:42},initDone:1b,Facing:2b,progress:7s,BurnTimeTotal:1600}')] + after[0].update(objectIdentity=999, properties={"facing": "north"}) + self.check(before, after, "PASS") + + def test_target_only_energy_and_extra_positive_channel_fail(self): + before = [witness("electricadvantage:electric_furnace", "{Items:[]}")] + self.check(before, [witness("electricadvantage:electric_furnace", "{Items:[],Energy:[I;%d]}" % bits(100))]) + self.check([witness(snbt="{Energy:[I;%d,0]}" % bits(1))], + [witness(snbt="{Energy:[I;%d,%d]}" % (bits(1), bits(2)))]) + + def test_optional_zero_energy_arrays_and_scalars_are_inert(self): + self.check([witness(snbt="{}")], + [witness(snbt="{Items:[],Energy:[I;0,0,0],Electricity:0.0f,BurnTime:0,steam:0.0f}")], "PASS") + self.check([witness(snbt="{Energy:[I;%d]}" % bits(1))], + [witness(snbt="{Energy:[I;%d,0,0]}" % bits(1))], "PASS") + + def test_each_known_scalar_reserve_detects_target_only_additions(self): + for field in ("Electricity", "energy", "steam", "soil", "Temperature", "BurnTime", "burnTime", "Fuel", "fuel"): + with self.subTest(field=field): + self.check([witness(snbt="{}")], [witness(snbt="{%s:1}" % field)]) + + def test_target_only_positive_named_tanks_fail(self): + for field in ("Tank", "TankIn", "TankOut", "InputTank", "OutputTank", "WaterTank", "FuelTank", "AuxTank"): + with self.subTest(field=field): + self.check([witness(snbt="{}")], + [witness(snbt='{%s:{FluidName:"minecraft:water",Amount:1}}' % field)]) + + def test_exact_zero_named_fluid_and_native_empty_representations_match(self): + for field in ("Tank", "TankIn", "TankOut", "InputTank"): + with self.subTest(field=field): + self.check([witness(snbt='{%s:{FluidName:"poweradvantage:crude_oil",Amount:0}}' % field)], + [witness(snbt='{%s:{Empty:1b}}' % field)], "PASS") + self.check([witness(snbt="{}")], [witness(snbt='{%s:{Empty:0b}}' % field)], "PASS") + + def test_positive_fluid_cannot_be_normalized_to_empty(self): + self.check([witness(snbt='{Tank:{FluidName:"water",Amount:1}}')], + [witness(snbt='{Tank:{Empty:1b}}')]) + + def test_fluid_identity_amount_and_payload_are_exact(self): + before = [witness(snbt='{Tank:{FluidName:"water",Amount:25,Tag:{grade:1}}}')] + for tank in ('{FluidName:"lava",Amount:25,Tag:{grade:1}}', + '{FluidName:"water",Amount:24,Tag:{grade:1}}', + '{FluidName:"water",Amount:25,Tag:{grade:2}}'): + with self.subTest(tank=tank): self.check(before, [witness(snbt="{Tank:" + tank + "}")]) + + def test_known_fluid_namespace_normalizations_are_retained(self): + for old, new in (("water", "minecraft:water"), ("crude_oil", "poweradvantage:crude_oil")): + self.check([witness(snbt='{Tank:{FluidName:"%s",Amount:25}}' % old)], + [witness(snbt='{Tank:{FluidName:"%s",Amount:25}}' % new)], "PASS") + + def test_target_only_inventory_and_quantity_or_slot_mutations_fail(self): + empty = [witness(snbt="{}")] + one = [witness(snbt='{Items:[{Slot:0b,id:"minecraft:coal",Count:1b}]}')] + self.check(empty, one) + for slot, item, count in ((0, "minecraft:coal", 2), (1, "minecraft:coal", 1), (0, "minecraft:diamond", 1)): + self.check(one, [witness(snbt='{Items:[{Slot:%db,id:"%s",Count:%db}]}' % (slot, item, count))]) + + def test_inventory_and_witness_order_do_not_change_per_position_budgets(self): + a = witness(snbt='{Items:[{Slot:0b,id:"minecraft:coal",Count:1b},{Slot:3b,id:"minecraft:stone",Count:2b}]}') + b = witness(snbt='{Items:[{Slot:3b,id:"minecraft:stone",Count:2b},{Slot:0b,id:"minecraft:coal",Count:1b}]}') + other = witness(x=144) + self.check([a, other], [other, b], "PASS") + + def test_equal_global_total_cannot_hide_energy_moved_between_positions(self): + before = [witness(snbt="{Energy:[I;%d]}" % bits(5), x=1), witness(snbt="{Energy:[I;%d]}" % bits(7), x=2)] + after = [witness(snbt="{Energy:[I;%d]}" % bits(7), x=1), witness(snbt="{Energy:[I;%d]}" % bits(5), x=2)] + result = self.check(before, after) + self.assertEqual(2, len(result["differences"])) + + def test_missing_new_duplicate_or_moved_witnesses_fail(self): + before = [witness(), witness(x=144)] + for after in (before[:1], before + [witness(x=145)], before + [copy.deepcopy(before[0])], [witness(), witness(x=145)]): + self.check(before, after) + + def test_changed_block_identity_cannot_hide_behind_empty_resources(self): + self.check([witness()], [witness("minecraft:hopper")]) + + def test_missing_known_container_nbt_is_not_zero_stock_evidence(self): + row = witness() + row.pop("nbt"); row.pop("nbtBase64") + self.check([row], [row]) + + def test_duplicate_negative_and_out_of_range_slots_fail(self): + for items in ('[{Slot:0b,id:"minecraft:coal",Count:1b},{Slot:0b,id:"minecraft:coal",Count:1b}]', + '[{Slot:-1b,id:"minecraft:coal",Count:1b}]', + '[{Slot:27b,id:"minecraft:coal",Count:1b}]', + '[{id:"minecraft:coal",Count:1b}]'): + row = witness(snbt="{Items:" + items + "}") + self.check([row], [row]) + + def test_constructor_proven_furnace_and_other_slot_limits(self): + for block, limit in (("electricadvantage:electric_furnace", 10), + ("electricadvantage:electric_crusher", 9), + ("electricadvantage:electric_drill", 6), + ("steamadvantage:steam_furnace", 7), + ("steamadvantage:steam_drill", 5)): + self.assertEqual(limit, SLOT_LIMITS[block]) + valid = [witness(block, '{Items:[{Slot:%db,id:"minecraft:coal",Count:1b}]}' % (limit - 1))] + invalid = [witness(block, '{Items:[{Slot:%db,id:"minecraft:coal",Count:1b}]}' % limit)] + self.check(valid, valid, "PASS") + self.check(invalid, invalid) + + def test_caller_can_add_unknown_registry_container_limit(self): + rows = [witness("example:container", '{Items:[{Slot:2b,id:"minecraft:coal",Count:1b}]}')] + self.check(rows, rows, "PASS") + self.check(rows, rows, slot_limits={"example:container": 2}) + + def test_invalid_stack_quantities_fail_even_when_both_sides_match(self): + for count in (-1, 0, 65): + rows = [witness(snbt='{Items:[{Slot:0b,id:"minecraft:coal",Count:%d}]}' % count)] + self.check(rows, rows) + + def test_known_legacy_damage_bucket_and_empty_tag_representations_match(self): + old = [witness(snbt='{id:"Chest",Items:[{Slot:0b,id:"forge:bucketfilled",Count:1b,Damage:0s,tag:{FluidName:"crude_oil",Amount:1000}}]}')] + new = [witness(snbt='{id:"minecraft:chest",Items:[{Slot:0b,id:"poweradvantage:crude_oil_bucket",Count:1b,tag:{}}]}')] + self.check(old, new, "PASS") + damaged = [witness(snbt='{Items:[{Slot:0b,id:"steamadvantage:musket",Count:1b,Damage:5s}]}')] + modern = [witness(snbt='{Items:[{Slot:0b,id:"steamadvantage:musket",Count:1b,tag:{Damage:5}}]}')] + self.check(damaged, modern, "PASS") + + def test_enchantments_use_explicit_world_registry_maps(self): + old = [witness(snbt='{Items:[{Slot:0b,id:"steamadvantage:musket",Count:1b,tag:{ench:[{id:11s,lvl:2s}]}}]}')] + new = [witness(snbt='{Items:[{Slot:0b,id:"steamadvantage:musket",Count:1b,tag:{Enchantments:[{id:"steamadvantage:spreadshot",lvl:2s}]}}]}')] + self.check(old, new, "PASS", before_enchantments={11: "steamadvantage:spreadshot"}) + self.check(old, new, before_enchantments={11: "minecraft:binding_curse"}) + + def test_battery_defaults_match_native_full_charge_but_charge_creation_fails(self): + for item, capacity in BATTERIES.items(): + old = [witness(snbt='{Items:[{Slot:0b,id:"%s",Count:1b}]}' % item)] + full = [witness(snbt='{Items:[{Slot:0b,id:"%s",Count:1b,tag:{energy:%df}}]}' % (item, capacity))] + self.check(old, full, "PASS") + zero = [witness(snbt='{Items:[{Slot:0b,id:"%s",Count:1b,tag:{energy:0.0f}}]}' % item)] + self.check(zero, full) + invalid = [witness(snbt='{Items:[{Slot:0b,id:"%s",Count:1b,tag:{energy:%df}}]}' % (item, capacity + 1))] + self.check(invalid, invalid) + + def test_legacy_battery_stack_counts_are_preserved_not_repaired(self): + original = [witness(snbt='{Items:[{Slot:0b,id:"electricadvantage:lithium_battery",Count:3b,tag:{energy:123.0f}}]}')] + self.check(original, copy.deepcopy(original), "PASS") + for count, charge in ((1,123), (4,123), (3,124)): + changed = [witness(snbt='{Items:[{Slot:0b,id:"electricadvantage:lithium_battery",Count:%db,tag:{energy:%df}}]}' % (count, charge))] + self.check(original, changed) + + def test_lossy_legacy_byte_summaries_require_matching_binary_and_keep_real_values(self): + row = witness(snbt='{ForgeCaps:{custom:{Items:[],Fuel:2}},growth:[B;4b,-2b,7b]}') + row["nbt"] = '{ForgeCaps:{custom:{Items:[],Fuel:2}},growth:[3 bytes]}' + self.check([row], [copy.deepcopy(row)], "PASS") + for text in ('{ForgeCaps:{custom:{Items:[],Fuel:2}},growth:[2 bytes]}', + '{ForgeCaps:{custom:{Items:[],Fuel:3}},growth:[3 bytes]}'): + bad = copy.deepcopy(row); bad["nbt"] = text + self.check([bad], [bad]) + missing = copy.deepcopy(row); missing.pop("nbtBase64") + self.check([missing], [missing]) + + def test_negative_and_nonfinite_resource_encodings_fail_closed(self): + for snbt in ("{Energy:[I;%d]}" % bits(-1), "{Energy:[I;%d]}" % bits(float("nan")), + "{Energy:[I;%d]}" % bits(float("inf")), "{steam:-1.0f}", + '{Tank:{FluidName:"water",Amount:-1}}', '{Tank:{}}', + '{Tank:{Empty:1b,FluidName:"water",Amount:1}}'): + rows = [witness(snbt=snbt)] + self.check(rows, rows) + + def test_resource_fields_inside_capability_compounds_are_not_ignored(self): + self.check([witness(snbt="{}")], [witness(snbt="{ForgeCaps:{example:{Energy:[I;%d]}}}" % bits(5))]) + + def test_conflicting_base64_and_text_payloads_are_not_silently_selected(self): + row = witness(snbt="{Energy:[I;%d]}" % bits(5)) + row["nbt"] = "{Energy:[I;0]}" + result = self.check([row], [row]) + self.assertTrue(result["failures"]) + + def test_snbt_float_rounding_uses_declared_binary_type_not_tolerance(self): + row = witness(snbt="{steam:29.452f}") + self.check([row], [row], "PASS") + text_only = copy.deepcopy(row) + text_only.pop("nbtBase64") + self.check([row], [text_only], "PASS") + changed = witness(snbt="{steam:29.453f}") + self.check([row], [changed]) + double = witness(snbt="{steam:29.452d}") + self.check([row], [double]) + + def test_empty_or_unsupported_documents_fail_instead_of_vacuous_pass(self): + for document in ([], {}, {"snapshot": []}, {"cases": []}): + self.check(document, document) + + def test_snbt_only_operation_position_rows_can_be_compared_after_explicit_extraction(self): + row = witness(snbt='{TankIn:{FluidName:"crude_oil",Amount:0},TankOut:{FluidName:"refined_oil",Amount:20}}') + row["block"] = row.pop("actualName") + row.pop("nbtBase64") + other = copy.deepcopy(row) + other["nbt"] = '{TankIn:{Empty:1b},TankOut:{FluidName:"refined_oil",Amount:20}}' + self.check([row], [other], "PASS") + + +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..c5cf7ba --- /dev/null +++ b/test-rig/migration/test_semantic_machine_audit.py @@ -0,0 +1,154 @@ +"""Deliberately corrupt evidence in ways the frozen operating oracle accepted.""" + +import copy +import json +import unittest +from unittest.mock import patch + +from connected import fixture, operation as historical_operation +from semantic_machine_audit import BATCH_MACHINES, connected_interval, endurance_batch +from test_connected import nbt, sample, entries +import test_endurance + + +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): + fixture_test = test_endurance.EnduranceOracleTests() + fixture_test.setUp() + self.rig = fixture_test.rig + # 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}) + with patch("endurance.write_json"): self.rig.batch("test") + 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"] = [] + with patch("endurance.write_json"): self.rig.batch("test") + 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"] + with patch("endurance.write_json"): self.rig.batch("test") + 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" + with patch("endurance.write_json"): self.rig.batch("test") + 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/src/main/java/zone/moddev/mc/advantageworks/AdvantageWorksMod.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/AdvantageWorksMod.java index 8d45088..c3091c8 100644 --- a/test-rig/src/main/java/zone/moddev/mc/advantageworks/AdvantageWorksMod.java +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/AdvantageWorksMod.java @@ -17,6 +17,10 @@ public AdvantageWorksMod() { LifecycleProbe.register(); RecipeUnlockProbe.register(); ProgressionProbe.register(); + MachineOperationalProbe.register(); + ChunkReadinessProbe.register(); + RedstoneReloadProbe.register(); + NativeHarvestProbe.register(); WorksController.getInstance().loadDefinition(); MinecraftForge.EVENT_BUS.addListener(this::serverStarting); } diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/AssertionChecks.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/AssertionChecks.java new file mode 100644 index 0000000..bfd9e65 --- /dev/null +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/AssertionChecks.java @@ -0,0 +1,62 @@ +package zone.moddev.mc.advantageworks; + +import java.util.List; +import java.util.Map; +import net.minecraft.nbt.NBTTagCompound; + +/** Fail closed when a measurement is unavailable, malformed or not finite. */ +final class AssertionChecks { + private AssertionChecks() { } + + static String verdict(boolean matched, String knownBug, Exception error) { + if (error != null) return "FAIL"; + if (knownBug != null && !knownBug.isEmpty()) return matched ? "UNEXPECTED_PASS" : "EXPECTED_FAIL"; + return matched ? "PASS" : "FAIL"; + } + + static String aggregate(List> assertions, boolean manual) { + boolean tested = false, expected = false, unexpected = false; + for (Map assertion : assertions) { + String status = String.valueOf(assertion.get("status")); + if ("FAIL".equals(status)) return "FAIL"; + if ("EXPECTED_FAIL".equals(status)) expected = true; + else if ("UNEXPECTED_PASS".equals(status)) unexpected = true; + else if ("PASS".equals(status)) tested = true; + else if (!"SKIP".equals(status)) return "FAIL"; + } + if (unexpected) return "UNEXPECTED_PASS"; + if (expected) return "EXPECTED_FAIL"; + if (!tested) return "SKIP"; + return manual ? "MANUAL_REQUIRED" : "PASS"; + } + + static boolean compare(double actual, String operator, Number expected) { + if (expected == null || !Double.isFinite(actual) || !Double.isFinite(expected.doubleValue())) return false; + double value = expected.doubleValue(); + switch (operator == null ? "equals" : operator) { + case "equals": return Math.abs(actual - value) < 0.0001; + case "min": return actual >= value; + case "max": return actual <= value; + case "greater": return actual > value; + case "less": return actual < value; + default: throw new IllegalArgumentException("Unsupported comparison " + operator); + } + } + + static int fluidAmount(NBTTagCompound tile, String key, String expectedFluid) { + if (!tile.contains(key, 10)) throw new IllegalArgumentException("Missing tank compound " + key); + NBTTagCompound tank = tile.getCompound(key); + // Forge writes an explicit empty marker; a missing tank is not an empty tank. + if (tank.contains("Empty", 1) && tank.getBoolean("Empty") + && !tank.keySet().contains("Amount") && !tank.keySet().contains("FluidName")) return 0; + if (tank.keySet().contains("Empty")) throw new IllegalArgumentException("Contradictory empty tank marker at " + key); + if (!tank.contains("Amount", 3) || !tank.contains("FluidName", 8)) { + throw new IllegalArgumentException("Missing tank amount or fluid identity at " + key); + } + int amount = tank.getInt("Amount"); + if (amount < 0 || (amount > 0 && tank.getString("FluidName").isEmpty())) { + throw new IllegalArgumentException("Invalid tank contents at " + key); + } + return expectedFluid == null || expectedFluid.equals(tank.getString("FluidName")) ? amount : 0; + } +} 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..357dc98 --- /dev/null +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/BatteryOperationalCases.java @@ -0,0 +1,36 @@ +package zone.moddev.mc.advantageworks; + +import cyano.poweradvantage.api.ConduitType; +import cyano.poweradvantage.api.PoweredEntity; +import java.util.List; +import net.minecraft.state.properties.BlockStateProperties; + +final class BatteryOperationalCases { + 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); + c.inventory(7, 4, 8).getStackInSlot(0).getOrCreateTag().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)).get(BlockStateProperties.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/BayDrillAssertions.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/BayDrillAssertions.java new file mode 100644 index 0000000..0b82ebe --- /dev/null +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/BayDrillAssertions.java @@ -0,0 +1,40 @@ +package zone.moddev.mc.advantageworks; + +import java.util.Arrays; +import java.util.function.Predicate; + +/** The walkable bays permit one track step, not an arbitrary search for a drill. */ +final class BayDrillAssertions { + private static final int[][] REAR = {{0, 1, 0}, {0, -1, 0}, {0, 0, 1}, + {0, 0, -1}, {1, 0, 0}, {-1, 0, 0}}; + + private BayDrillAssertions() { } + + static boolean applies(WorksDefinition.Station station, WorksDefinition.Assertion assertion) { + return ("S-05".equals(station.id) && "steamadvantage:steam_drill".equals(assertion.block)) + || ("E-06".equals(station.id) && "electricadvantage:electric_drill".equals(assertion.block)); + } + + static int[] rearTrack(WorksDefinition.Station station, WorksDefinition.Assertion assertion) { + int direction = assertion.meta == null + ? Arrays.asList("down", "up", "north", "south", "west", "east").indexOf(assertion.expectedText) + : assertion.meta; + if (direction < 0 || direction >= REAR.length) throw new IllegalArgumentException("Invalid drill facing"); + int[] rear = assertion.pos.clone(); + for (int i = 0; i < 3; i++) rear[i] += REAR[direction][i]; + String track = assertion.block.startsWith("steamadvantage:") + ? "steamadvantage:steam_track" : "electricadvantage:electric_track"; + if (station.placements.stream().noneMatch(p -> track.equals(p.block) && Arrays.equals(p.pos, rear))) { + throw new IllegalArgumentException("Drill bay has no declared rear track"); + } + return rear; + } + + static boolean matches(WorksDefinition.Station station, WorksDefinition.Assertion assertion, + Predicate isDrill, Predicate hasFacing) { + int[] rear = rearTrack(station, assertion); + boolean initial = isDrill.test(assertion.pos); + boolean moved = isDrill.test(rear); + return initial != moved && hasFacing.test(initial ? assertion.pos : rear); + } +} diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/ChunkReadinessProbe.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/ChunkReadinessProbe.java new file mode 100644 index 0000000..7459a9d --- /dev/null +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/ChunkReadinessProbe.java @@ -0,0 +1,61 @@ +package zone.moddev.mc.advantageworks; + +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.mojang.brigadier.arguments.IntegerArgumentType; +import com.mojang.brigadier.arguments.StringArgumentType; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.StandardOpenOption; +import net.minecraft.command.Commands; +import net.minecraft.world.WorldServer; +import net.minecraft.world.chunk.Chunk; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.fml.event.server.FMLServerStartingEvent; + +/** Read-only readiness witnesses. This probe never acquires a chunk or changes machinery. */ +final class ChunkReadinessProbe { + static void register() { MinecraftForge.EVENT_BUS.addListener(ChunkReadinessProbe::commands); } + + private static void commands(FMLServerStartingEvent event) { + event.getCommandDispatcher().register(Commands.literal("advchunks").requires(source -> source.hasPermissionLevel(4)) + .then(Commands.argument("label", StringArgumentType.word()) + .then(Commands.argument("x1", IntegerArgumentType.integer(-1024, 100000)) + .then(Commands.argument("z1", IntegerArgumentType.integer(-1024, 100000)) + .then(Commands.argument("x2", IntegerArgumentType.integer(-1024, 100000)) + .then(Commands.argument("z2", IntegerArgumentType.integer(-1024, 100000)).executes(context -> { + observe(context.getSource().getWorld(), StringArgumentType.getString(context, "label"), + IntegerArgumentType.getInteger(context, "x1"), IntegerArgumentType.getInteger(context, "z1"), + IntegerArgumentType.getInteger(context, "x2"), IntegerArgumentType.getInteger(context, "z2")); + return 1; + }))))))); + } + + private static void observe(WorldServer world, String label, int x1, int z1, int x2, int z2) { + if (!new File("advantage-works.allow").isFile() + || !world.getWorldInfo().getWorldName().equals("AdvantageWorksMigration") + || !label.matches("[A-Za-z0-9_-]+") || x2 < x1 || z2 < z1 || x2 - x1 > 512 || z2 - z1 > 512) + throw new IllegalArgumentException("Unmarked world, unsafe label or unbounded chunk request"); + JsonArray rows = new JsonArray(); + boolean ready = true; + for (int x = x1 >> 4; x <= x2 >> 4; x++) for (int z = z1 >> 4; z <= z2 >> 4; z++) { + Chunk chunk = world.getChunkProvider().loadedChunks.get(net.minecraft.util.math.ChunkPos.asLong(x, z)); + boolean loaded = chunk != null && chunk.isLoaded(); + ready &= loaded; + JsonObject row = new JsonObject(); + row.addProperty("x", x); row.addProperty("z", z); row.addProperty("loaded", loaded); rows.add(row); + } + JsonObject result = new JsonObject(); + result.addProperty("label", label); result.addProperty("worldTime", world.getGameTime()); + result.addProperty("ready", ready); result.add("chunks", rows); + try { + File file = new File("advantage-works-results", "chunk-readiness-" + label + ".json"); + file.getParentFile().mkdirs(); + Files.write(file.toPath(), new GsonBuilder().setPrettyPrinting().create().toJson(result).getBytes(StandardCharsets.UTF_8), + StandardOpenOption.CREATE_NEW); + } catch (java.io.IOException failure) { throw new IllegalStateException("Cannot retain native readiness evidence", failure); } + System.out.println("ADVCHUNKS OBSERVED " + label); + } +} 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..840cfbb --- /dev/null +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/ElectricOperationalCases.java @@ -0,0 +1,796 @@ +package zone.moddev.mc.advantageworks; + +import java.util.List; +import java.util.UUID; +import com.mojang.authlib.GameProfile; +import net.minecraft.entity.EntityType; +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.state.properties.BlockStateProperties; +import net.minecraft.scoreboard.ScorePlayerTeam; +import net.minecraft.util.ResourceLocation; +import net.minecraft.util.math.AxisAlignedBB; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.GameRules; +import cyano.poweradvantage.api.fluid.FluidStack; +import net.minecraftforge.registries.ForgeRegistries; +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 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:oak_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.getGameTime()); + }, c -> { + equal(c, "recursive.sticks", output(c, CELLS[0], 12, 2, "minecraft:stick"), 4); + equal(c, "recursive.log", slotCount(c, CELLS[0], 0, "minecraft:oak_log"), 0); + equal(c, "recursive.surplusPlanks", inputCount(c, CELLS[0], 12, "minecraft:oak_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:oak_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); + net.minecraft.fluid.Fluid oil = net.minecraft.util.registry.IRegistry.field_212619_h.func_212608_b(new ResourceLocation("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(new ResourceLocation("poweradvantage:refined_oil").equals( + net.minecraft.util.registry.IRegistry.field_212619_h.getKey(simulated.getFluid())), + "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(c.world.getFluidState(c.at(CELLS[0], 2, Z - 1)).isEmpty(), "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(c.world.getFluidState(c.at(CELLS[i], 2, Z - 1)).isSource(), "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:sugar_cane", 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:sugar_cane"), 2); + equal(c, "positive.seed", slotCount(c, CELLS[0] + 1, 0, "minecraft:sugar_cane"), 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:sugar_cane"), 1); + equal(c, "negative" + i + ".output", output(c, x + 1, 3, 6, "minecraft:sugar_cane"), 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:sugar_cane"), 1); + equal(c, "scarce.output", output(c, 9, 3, 6, "minecraft:sugar_cane"), 0); + equal(c, "full.seed", slotCount(c, 23, 0, "minecraft:sugar_cane"), 1); + equal(c, "full.output", output(c, 23, 3, 6, "minecraft:sugar_cane"), 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:sugar_cane", 1); + if (full) for (int slot = 3; slot < 9; slot++) c.put(x + 1, Y, Z, slot, "minecraft:sugar_cane", 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.getGameTime()); + }, c -> { + c.require(c.world.getBlockState(c.at(8, Y, Z)).get(BlockStateProperties.LIT), "Powered LED is lit"); + c.require(!c.world.getBlockState(c.at(22, Y, Z)).get(BlockStateProperties.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)).get(BlockStateProperties.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"); + c.block(5, 1, Z, "minecraft: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(!c.world.getFluidState(c.at(8, 1, Z)).isEmpty(), "Water reached the turbine"); + net.minecraft.util.math.Vec3d flow = c.world.getFluidState(c.at(8, 1, Z)).getFlow(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(c.world.getFluidState(c.at(22, 1, Z)).isSource(), "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(actor.getType().getRegistryName())) 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.getDayTime(); + 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.setDayTime(6000); + daylightCycle[0] = c.world.getGameRules().getBoolean("doDaylightCycle"); + c.record("setup.daylightCycle", daylightCycle[0]); + c.record("daylight.constructionGameTime", c.world.getGameTime()); + }, 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.getDayTime()); + } finally { + c.world.setDayTime(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; equally roofed dark and redstone-inhibited lit panels produce no energy, including " + + "the first power update. No global time/weather mutation.", 160, c -> { + c.lightHalo(); + solarRoom(c, 8, true); + solarRoom(c, 15, true); + c.block(14, Y, Z, "minecraft:redstone_block"); + 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.require(c.world.isBlockPowered(c.at(15, Y, Z)), "Inhibited panel has a real redstone signal"); + equal(c, "inhibited.localBrightness", c.world.getLight(c.at(15, Y + 1, Z)), 14); + close(c, "inhibited.generated", c.energy(15, 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.getGameTime()); + }, 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.getGameTime()); + }, 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)).get(BlockStateProperties.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 = EntityType.HUSK.create(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.getAttribute(SharedMonsterAttributes.ARMOR) != null, "Hostile target has an armor attribute"); + husk.getAttribute(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.isAlive(), "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.remove(); + 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.getServer(), + 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.getScoreboardName(), team[0]); + c.world.spawnEntity(player); + c.invoke(8, 1, Z, "setOwner", new Class[] {EntityPlayer.class}, player); + EntityHusk husk = EntityType.HUSK.create(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.getAttribute(SharedMonsterAttributes.ARMOR) != null, "Ownership target has an armor attribute"); + husk.getAttribute(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].remove(); + if (owner[0] != null) { + owner[0].remove(); + 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.getGameTime() - 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) { + cyano.poweradvantage.api.fluid.FluidTank tank = (cyano.poweradvantage.api.fluid.FluidTank) + c.invoke(x, y, z, method, new Class[0]); + net.minecraft.fluid.Fluid fluid = net.minecraft.util.registry.IRegistry.field_212619_h.func_212608_b(new ResourceLocation(id)); + c.require(fluid != null && amount >= 0 && amount <= tank.getCapacity(), "Valid finite fluid reserve " + id); + tank.setFluid(new cyano.poweradvantage.api.fluid.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 = ((cyano.poweradvantage.api.fluid.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.record("fluid." + x + "." + tank, String.valueOf(net.minecraft.util.registry.IRegistry.field_212619_h.getKey(fluid.getFluid()))); + c.require(new ResourceLocation(id).equals(net.minecraft.util.registry.IRegistry.field_212619_h.getKey(fluid.getFluid())), "Tank contains " + id); + } + + private static void rejectFluid(MachineContext c, int x, String id, int amount) { + net.minecraft.fluid.Fluid fluid = net.minecraft.util.registry.IRegistry.field_212619_h.func_212608_b(new ResourceLocation(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/MachineContext.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/MachineContext.java new file mode 100644 index 0000000..e796687 --- /dev/null +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/MachineContext.java @@ -0,0 +1,268 @@ +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.minecraft.fluid.Fluid; +import net.minecraft.inventory.IInventory; +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.state.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.registries.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; + + 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); + } + + 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++) { + world.func_212414_b(x, z, true); + world.getChunk(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++) { + world.func_212414_b(x, z, true); + world.getChunk(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.getProperties()) if (property.getName().equals("facing")) { + java.util.Optional parsed = property.parseValue(facing); + if (!parsed.isPresent()) throw new IllegalArgumentException("Invalid facing " + facing); + Comparable value = (Comparable) parsed.get(); + world.setBlockState(pos, state.with(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); + inventory(x, y, z).getStackInSlot(0).getOrCreateTag().setFloat("energy", reserve); + inventory(x, y, z).markDirty(); + } + + void tank(int x, int y, int z, String fluid, int amount) { + ResourceLocation id = new ResourceLocation(fluid); + if (net.minecraft.util.registry.IRegistry.field_212619_h.func_212608_b(id) == null || amount < 0) throw new IllegalArgumentException("Invalid fluid reserve"); + mutation("tank", at(x, y, z), fluid + ":" + amount); + cyano.poweradvantage.api.fluid.FluidTank tank = ((TileEntitySimpleFluidMachine) tile(x, y, z)).getTank(); + if (amount > tank.getCapacity()) throw new IllegalArgumentException("Tank reserve exceeds capacity"); + tank.setFluid(new cyano.poweradvantage.api.fluid.FluidStack(net.minecraft.util.registry.IRegistry.field_212619_h.func_212608_b(id), 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).write(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.write(new NBTTagCompound()); + nbt.merge(patch.copy()); + tile.read(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.write(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.writeUnlessRemoved(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..e45f237 --- /dev/null +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/MachineOperationalProbe.java @@ -0,0 +1,348 @@ +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 com.mojang.brigadier.arguments.StringArgumentType; +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.Commands; +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.event.server.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 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; + + static void register() { + if (!MARKER.isFile()) return; + MachineOperationalProbe probe = new MachineOperationalProbe(); + MinecraftForge.EVENT_BUS.addListener(probe::commands); + MinecraftForge.EVENT_BUS.addListener(probe::tick); + MinecraftForge.EVENT_BUS.addListener(probe::loadedChunk); + } + + private 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.dimension.getType().getId() != 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); + } + + private void commands(FMLServerStartingEvent event) { + event.getCommandDispatcher().register(Commands.literal("advmachines").requires(source -> source.hasPermissionLevel(4)) + .then(Commands.literal("run").then(Commands.argument("group", StringArgumentType.string()) + .then(Commands.argument("label", StringArgumentType.word()).executes(context -> { + start(context.getSource().getWorld(), StringArgumentType.getString(context, "group"), + StringArgumentType.getString(context, "label")); return 1; + })))) + .then(Commands.literal("inspect").then(Commands.argument("label", StringArgumentType.word()).executes(context -> { + inspect(context.getSource().getWorld(), StringArgumentType.getString(context, "label")); return 1; + })))); + } + + private static List catalog() { + List scenarios = new ArrayList<>(); + PowerOperationalCases.register(scenarios); + BatteryOperationalCases.register(scenarios); + SourcePickupOperationalCases.register(scenarios); + for (String extension : new String[]{"SteamOperationalCases", "ElectricOperationalCases"}) { + try { + Class type = Class.forName("zone.moddev.mc.advantageworks." + extension); + java.lang.reflect.Method method = type.getDeclaredMethod("register", List.class); + method.setAccessible(true); + method.invoke(null, scenarios); + } catch (ReflectiveOperationException error) { + throw new IllegalStateException("Incomplete operating catalog: " + extension, error); + } + } + Set ids = new HashSet<>(); + for (MachineScenario scenario : scenarios) if (!ids.add(scenario.id)) throw new IllegalArgumentException("Duplicate case " + scenario.id); + if (scenarios.isEmpty()) throw new IllegalStateException("No operating tests"); + return scenarios; + } + + private void authorize(WorldServer current, String requestedLabel) { + if (!MARKER.isFile() || !current.getWorldInfo().getWorldName().equals("AdvantageWorksMigration") + || !requestedLabel.matches("[A-Za-z0-9_-]+")) throw new IllegalStateException("Unmarked or unsafe operating world"); + world = current; + FakePlayer sentinel = FakePlayerFactory.getMinecraft(world); + sentinel.setPosition(49900.5, 80, 49900.5); + if (!world.playerEntities.contains(sentinel)) world.playerEntities.add(sentinel); + } + + private void start(WorldServer current, String group, String requestedLabel) { + authorize(current, requestedLabel); + if (FIXTURE.exists() || !runs.isEmpty() || !paused) throw new IllegalStateException("Construction is single-use; never rebuild a restarted fixture"); + List catalog = catalog(); + JsonArray declarations = new JsonArray(); + for (int i = 0; i < catalog.size(); i++) { + MachineScenario scenario = catalog.get(i); + if (!group.equals("all") && !scenario.id.equals(group) && !scenario.id.startsWith(group + "/")) continue; + Run run = new Run(scenario, new MachineContext(world, i)); + runs.add(run); + JsonObject entry = new JsonObject(); + entry.addProperty("id", scenario.id); entry.addProperty("contract", scenario.contract); + entry.addProperty("ticks", scenario.ticks); entry.addProperty("exclusive", scenario.exclusive); + declarations.add(entry); + } + if (runs.isEmpty()) throw new IllegalArgumentException("Unknown or empty operating group " + group); + label = requestedLabel; + campaignStart = world.getGameTime(); + JsonObject description = new JsonObject(); + description.addProperty("group", group); + description.add("cases", declarations); + write("machine-catalog-" + label + ".json", description); + preparing = true; + System.out.println("ADVMACHINES START " + label + " cases=" + runs.size()); + } + + private void setup(Run run) { + run.started = true; + try { + run.context.floor(); + run.scenario.setup.accept(run.context); + run.context.validateTickRegistration(); + run.before = run.context.snapshot(); + run.startTick = world.getGameTime(); + run.context.activationTick = run.startTick; + run.context.settingUp = false; + } catch (Throwable failure) { fail(run, "setup", failure); } + } + + private void tick(TickEvent.WorldTickEvent event) { + if (event.world.isRemote || !(event.world instanceof WorldServer) + || event.world.dimension.getType() != net.minecraft.world.dimension.DimensionType.OVERWORLD) return; + WorldServer current = (WorldServer) event.world; + traceDaylight(current, event.phase); + if (event.phase == TickEvent.Phase.START && paused) { + 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); + } else if (event.phase == TickEvent.Phase.END) { + for (TileEntity tile : held) if (!tile.isRemoved() && !current.tickableTileEntities.contains(tile)) current.tickableTileEntities.add(tile); + held.clear(); heldEntities.restore(); + if (current != world) return; + if (inspectionCases != null) { + if (world.getGameTime() >= nextPreparation) inspectNext(); + return; + } + if (preparing) { + if (world.getGameTime() < nextPreparation) return; + while (prepareIndex < runs.size()) { + Run run = runs.get(prepareIndex); + if (!run.chunksRequested) { + run.context.prepareChunks(); + run.chunksRequested = true; + run.readinessDeadline = world.getGameTime() + 80; + nextPreparation = world.getGameTime() + 1; + return; + } + if (!run.context.chunksReady()) { + if (world.getGameTime() > run.readinessDeadline) { + fail(run, "chunk-readiness", new IllegalStateException("Native chunk load did not complete")); + prepareIndex++; + } + nextPreparation = world.getGameTime() + 1; + return; + } + prepareIndex++; + if (run.scenario.exclusive) continue; + setup(run); + nextPreparation = world.getGameTime() + 20; + System.out.println("ADVMACHINES PREPARED " + run.scenario.id); + return; + } + for (Run run : runs) if (run.started && !run.finished) { + run.startTick = world.getGameTime(); + run.context.activationTick = run.startTick; + } + preparing = false; + paused = false; + System.out.println("ADVMACHINES MEASURING " + label); + return; + } + if (paused) return; + for (Run run : runs) if (run.started && !run.finished && world.getGameTime() - run.startTick >= run.scenario.ticks) { + try { + run.scenario.verify.accept(run.context); + if (run.context.verified == 0) throw new AssertionError("No measured operating outcome"); + run.finished = true; + run.status = "PASS"; + } catch (Throwable failure) { fail(run, "verify", failure); } + run.finishTick = world.getGameTime(); + run.after = run.context.snapshot(); + } + if (runs.stream().anyMatch(run -> run.started && !run.finished)) return; + for (Run run : runs) if (!run.started) { setup(run); return; } + paused = true; + finish(); + } + } + + private void traceDaylight(WorldServer current, TickEvent.Phase phase) { + if (paused) return; + for (Run run : runs) if (run.started && !run.finished && run.scenario.id.equals("electric/solar-daylight-rate")) { + TileEntity tile = current.getTileEntity(run.context.at(8, 2, 8)); + Map sample = new LinkedHashMap<>(); + sample.put("phase", phase.toString()); + sample.put("time", current.getGameTime()); + sample.put("day", current.getDayTime()); + sample.put("identity", System.identityHashCode(tile)); + sample.put("energy", ((cyano.poweradvantage.api.PoweredEntity)tile).getEnergy( + new cyano.poweradvantage.api.ConduitType("electricity"))); + List ticking = new ArrayList<>(); + for (TileEntity active : current.tickableTileEntities) + if (active.getPos().equals(tile.getPos())) ticking.add(System.identityHashCode(active)); + sample.put("ticking", ticking); + @SuppressWarnings("unchecked") List> trace = (List>) + run.context.measurements.computeIfAbsent("daylight.tickTrace", key -> new ArrayList>()); + trace.add(sample); + } + } + + private void fail(Run run, String phase, Throwable failure) { + run.finished = true; run.status = "FAIL"; + run.failure = phase + ": " + failure; + java.io.StringWriter trace = new java.io.StringWriter(); + failure.printStackTrace(new java.io.PrintWriter(trace)); + run.trace = trace.toString(); + JsonObject failed = new JsonObject(); + failed.addProperty("id", run.scenario.id); failed.addProperty("phase", phase); + failed.addProperty("status", "FAIL"); failed.addProperty("failure", run.failure); + failed.addProperty("traceback", run.trace); + failed.add("actions", GSON.toJsonTree(run.context.actions)); + failed.add("assertions", GSON.toJsonTree(run.context.checks)); + write("machine-failure-" + label + "-" + run.scenario.id.replace('/', '_') + ".json", failed); + System.out.println("ADVMACHINES CASE_FAIL " + run.scenario.id + " phase=" + phase); + } + + private void finish() { + JsonArray rows = new JsonArray(), fixture = new JsonArray(); + for (Run run : runs) { + JsonObject row = GSON.toJsonTree(run.context.measurements).getAsJsonObject(); + row.addProperty("id", run.scenario.id); row.addProperty("contract", run.scenario.contract); + row.addProperty("status", run.status); row.addProperty("startTick", run.startTick); + row.addProperty("finishTick", run.finishTick); row.addProperty("requiredTicks", run.scenario.ticks); + row.addProperty("verifiedAssertions", run.context.verified); + if (run.failure != null) { row.addProperty("failure", run.failure); row.addProperty("traceback", run.trace); } + row.add("actions", GSON.toJsonTree(run.context.actions)); row.add("assertions", GSON.toJsonTree(run.context.checks)); + row.add("before", run.before); row.add("after", run.after); + rows.add(row); + JsonObject saved = new JsonObject(); saved.addProperty("id", run.scenario.id); + saved.addProperty("index", (run.context.origin.getX() - 50000) / 64); + saved.add("positions", run.context.snapshot()); saved.add("entities", run.context.entities()); fixture.add(saved); + } + JsonObject description = new JsonObject(); description.add("cases", fixture); + description.addProperty("freezeMethod", "Remove tickable entities during world ticks; never restore expected NBT"); + writeRoot(FIXTURE, description); + JsonObject report = new JsonObject(); report.addProperty("label", label); + report.addProperty("held", paused); report.addProperty("worldTime", world.getGameTime()); + report.addProperty("campaignTicks", world.getGameTime() - campaignStart); + report.addProperty("status", runs.stream().allMatch(run -> run.status.equals("PASS")) ? "PASS" : "FAIL"); + report.add("cases", rows); + write("machine-operation-" + label + ".json", report); + System.out.println("ADVMACHINES COMPLETE " + label); + } + + private void inspect(WorldServer current, String requestedLabel) { + authorize(current, requestedLabel); + if (!paused || !runs.isEmpty()) throw new IllegalStateException("Restart inspection must run before any construction or operation"); + try { + JsonObject fixture = GSON.fromJson(new String(Files.readAllBytes(FIXTURE.toPath()), StandardCharsets.UTF_8), JsonObject.class); + inspectionCases = fixture.getAsJsonArray("cases"); + inspectionResults = new JsonArray(); + inspectionIndex = 0; + inspectionLabel = requestedLabel; + } catch (java.io.IOException error) { throw new IllegalStateException("Cannot read operating fixture", error); } + } + + private void inspectNext() { + if (inspectionIndex < inspectionCases.size()) { + JsonObject source = inspectionCases.get(inspectionIndex++).getAsJsonObject(); + JsonObject row = new JsonObject(); row.addProperty("id", source.get("id").getAsString()); + JsonArray positions = new JsonArray(); + for (JsonElement point : source.getAsJsonArray("positions")) { + JsonObject saved = point.getAsJsonObject(), actual = new JsonObject(); + BlockPos pos = new BlockPos(saved.get("x").getAsInt(), saved.get("y").getAsInt(), saved.get("z").getAsInt()); + if (pos.getX() < 50000 || pos.getX() > 100000 || pos.getY() < 79 || pos.getY() > 111 + || pos.getZ() < 50000 || pos.getZ() > 50031) throw new IllegalArgumentException("Saved position escaped proving ground"); + actual.addProperty("x", pos.getX()); actual.addProperty("y", pos.getY()); actual.addProperty("z", pos.getZ()); + actual.addProperty("block", world.getBlockState(pos).getBlock().getRegistryName().toString()); + actual.addProperty("state", world.getBlockState(pos).toString()); + TileEntity tile = world.getTileEntity(pos); + if (tile != null) actual.addProperty("nbt", tile.write(new net.minecraft.nbt.NBTTagCompound()).toString()); + positions.add(actual); + } + row.add("positions", positions); inspectionResults.add(row); + int index = source.get("index").getAsInt(); + if (index < 0 || index > 780) throw new IllegalArgumentException("Saved actor bay escaped bounds"); + row.add("entities", new MachineContext(world, index).entities()); + nextPreparation = world.getGameTime() + 20; + return; + } + JsonObject report = new JsonObject(); report.addProperty("held", paused); + report.addProperty("constructionCallsThisProcess", 0); report.add("cases", inspectionResults); + write("machine-inspection-" + inspectionLabel + ".json", report); + System.out.println("ADVMACHINES INSPECTED " + inspectionLabel); + inspectionCases = null; + } + + private static void write(String name, JsonObject document) { writeRoot(new File("advantage-works-results", name), document); } + + private static void writeRoot(File file, JsonObject document) { + try { + if (file.exists()) throw new IllegalStateException("Refusing to overwrite operating evidence " + file); + if (file.getParentFile() != null) file.getParentFile().mkdirs(); + Files.write(file.toPath(), GSON.toJson(document).getBytes(StandardCharsets.UTF_8)); + } catch (java.io.IOException error) { throw new IllegalStateException("Cannot write operating evidence", error); } + } + + private static final class Run { + final MachineScenario scenario; + final MachineContext context; + boolean started, finished; + boolean chunksRequested; + long readinessDeadline; + long startTick, finishTick; + String status = "NOT_RUN", failure, trace; + JsonArray before = new JsonArray(), after = new JsonArray(); + Run(MachineScenario scenario, MachineContext context) { this.scenario = scenario; this.context = context; } + } +} diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/MachineScenario.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/MachineScenario.java new file mode 100644 index 0000000..06c3078 --- /dev/null +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/MachineScenario.java @@ -0,0 +1,25 @@ +package zone.moddev.mc.advantageworks; + +import java.util.function.Consumer; + +/** A finite fixture, actual world ticks, then an independently stated outcome. */ +final class MachineScenario { + final String id, contract; + final int ticks; + final Consumer setup, verify; + boolean exclusive; + + MachineScenario(String id, String contract, int ticks, Consumer setup, + Consumer verify) { + if (!id.matches("[a-z0-9][a-z0-9_/-]+") || contract.trim().isEmpty() || ticks < 1 || ticks > 12000) { + throw new IllegalArgumentException("Invalid operating scenario " + id); + } + this.id = id; + this.contract = contract; + this.ticks = ticks; + this.setup = setup; + this.verify = verify; + } + + MachineScenario exclusive() { exclusive = true; return this; } +} diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/NativeEntityHold.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/NativeEntityHold.java new file mode 100644 index 0000000..95ec548 --- /dev/null +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/NativeEntityHold.java @@ -0,0 +1,41 @@ +package zone.moddev.mc.advantageworks; + +import java.util.IdentityHashMap; +import java.util.Map; +import net.minecraft.entity.Entity; + +/** Holds native entity ticks without removing actors from chunk or entity-ID indexes. */ +final class NativeEntityHold { + private final Map held = new IdentityHashMap<>(); + + void hold(Entity entity) { + if (!held.containsKey(entity)) held.put(entity, new State(entity)); + entity.canUpdate(false); + // Forge 25 only consults canUpdate for weather entities and passengers. + // Ordinary actors must leave the global tick list while remaining queryable in their chunk. + entity.getEntityWorld().loadedEntityList.remove(entity); + } + + void restore() { + for (Map.Entry entry : held.entrySet()) { + Entity entity = entry.getKey(); + State state = entry.getValue(); + if (entity.ticksExisted != state.ticks) + throw new IllegalStateException("A frozen fixture actor received an unexpected entity tick"); + entity.canUpdate(state.updating); + if (!entity.removed && !entity.getEntityWorld().loadedEntityList.contains(entity)) + entity.getEntityWorld().loadedEntityList.add(entity); + } + held.clear(); + } + + private static final class State { + final boolean updating; + final int ticks; + + State(Entity entity) { + updating = entity.canUpdate(); + ticks = entity.ticksExisted; + } + } +} diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/NativeFluidPort.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/NativeFluidPort.java new file mode 100644 index 0000000..b1ae0e2 --- /dev/null +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/NativeFluidPort.java @@ -0,0 +1,26 @@ +package zone.moddev.mc.advantageworks; + +import cyano.poweradvantage.api.fluid.FluidStack; +import cyano.poweradvantage.api.fluid.IAdvantageFluidHandler; +import net.minecraft.tileentity.TileEntity; +import net.minecraft.util.EnumFacing; + +/** Forge 25 uses the Advantage native-fluid API, not Forge 28's later fluid capability. */ +final class NativeFluidPort { + private final IAdvantageFluidHandler handler; + private final EnumFacing face; + + NativeFluidPort(TileEntity tile, EnumFacing face) { + if (!(tile instanceof IAdvantageFluidHandler)) throw new IllegalStateException("Missing sided Advantage fluid handler"); + handler = (IAdvantageFluidHandler)tile; + this.face = face; + } + + int fill(FluidStack stack, boolean execute) { return handler.fill(face, stack, execute); } + FluidStack drain(int amount, boolean execute) { return handler.drain(face, amount, execute); } + FluidStack getFluidInTank(int index) { return handler.getTankInfo(face)[index].fluid; } + int amount(int index) { + FluidStack fluid = getFluidInTank(index); + return fluid == null ? 0 : fluid.amount; + } +} diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/NativeHarvestProbe.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/NativeHarvestProbe.java new file mode 100644 index 0000000..a15c02d --- /dev/null +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/NativeHarvestProbe.java @@ -0,0 +1,233 @@ +package zone.moddev.mc.advantageworks; + +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.mojang.authlib.GameProfile; +import com.mojang.brigadier.arguments.StringArgumentType; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.List; +import java.util.UUID; +import net.minecraft.block.Block; +import net.minecraft.block.state.IBlockState; +import net.minecraft.command.Commands; +import net.minecraft.init.Enchantments; +import net.minecraft.entity.item.EntityItem; +import net.minecraft.entity.player.EntityPlayerMP; +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; +import net.minecraft.init.Items; +import net.minecraft.network.Packet; +import net.minecraft.network.NetworkManager; +import net.minecraft.network.EnumPacketDirection; +import net.minecraft.network.NetHandlerPlayServer; +import net.minecraft.server.management.PlayerInteractionManager; +import net.minecraft.state.BooleanProperty; +import net.minecraft.util.EnumHand; +import net.minecraft.util.EnumFacing; +import net.minecraft.util.ResourceLocation; +import net.minecraft.util.math.AxisAlignedBB; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.shapes.IBooleanFunction; +import net.minecraft.util.math.shapes.VoxelShapes; +import net.minecraft.world.GameType; +import net.minecraft.world.WorldServer; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.common.ToolType; +import net.minecraftforge.fml.event.server.FMLServerStartingEvent; +import net.minecraftforge.registries.ForgeRegistries; + +/** Actual survival harvesting, not loot-JSON presence or a synthetic getDrops call. */ +final class NativeHarvestProbe { + private static final File MARKER = new File("advantage-native-harvest.allow"); + private static final BlockPos POS = new BlockPos(1200, 8, 1024); + + static void register() { + if (MARKER.isFile()) MinecraftForge.EVENT_BUS.addListener(new NativeHarvestProbe()::starting); + } + + private void starting(FMLServerStartingEvent event) { + event.getCommandDispatcher().register(Commands.literal("advharvest") + .requires(source -> source.hasPermissionLevel(4)) + .then(Commands.argument("label", StringArgumentType.word()).executes(context -> { + WorksController.getInstance().requireAuthorizedWorld(context.getSource().getServer()); + try { run(context.getSource().getWorld(), StringArgumentType.getString(context, "label")); } + catch (Exception error) { throw new IllegalStateException("Native harvest witness failed", error); } + return 1; + }))); + } + + private static void require(boolean condition, String message) { + if (!condition) throw new AssertionError(message); + } + + private static void witness(WorldServer world, EntityPlayerMP player, JsonArray rows, String id, + String blockId, int level, Item toolItem, boolean silk, boolean allowed, + String dropId, int minimum, int maximum, int trials) { + witness(world, player, rows, id, blockId, level, toolItem, silk, allowed, dropId, minimum, maximum, trials, -1); + } + + private static void witness(WorldServer world, EntityPlayerMP player, JsonArray rows, String id, + String blockId, int level, Item toolItem, boolean silk, boolean allowed, + String dropId, int minimum, int maximum, int trials, int connectionMask) { + JsonObject row = new JsonObject(); + row.addProperty("id", id); + JsonArray samples = new JsonArray(); + JsonArray dropEvidence = new JsonArray(); + row.add("dropEvidence", dropEvidence); + try { + ResourceLocation key = new ResourceLocation(blockId); + require(ForgeRegistries.BLOCKS.containsKey(key), "Missing real block: " + blockId); + Block block = ForgeRegistries.BLOCKS.getValue(key); + player.interactionManager.setGameType(GameType.SURVIVAL); + for (int trial = 0; trial < trials; trial++) { + JsonObject evidence = new JsonObject(); + evidence.addProperty("trial", trial); + evidence.addProperty("worldTime", world.getGameTime()); + List indexed = world.getEntitiesWithinAABB(EntityItem.class, new AxisAlignedBB(POS).grow(2)); + evidence.addProperty("removedBeforeCleanup", indexed.stream().filter(entity -> entity.removed).count()); + evidence.addProperty("liveBeforeCleanup", indexed.stream().filter(entity -> !entity.removed).count()); + dropEvidence.add(evidence); + harvestDrops(world, new AxisAlignedBB(POS).grow(2)).forEach(EntityItem::remove); + int remaining = harvestDrops(world, new AxisAlignedBB(POS).grow(2)).size(); + evidence.addProperty("liveAfterCleanup", remaining); + require(remaining == 0, "Harvest fixture retained a live item before placement"); + world.setBlockState(POS, net.minecraft.init.Blocks.AIR.getDefaultState(), 2); + IBlockState requested = block.getDefaultState(); + if (connectionMask >= 0) { + int bit = 0; + for (EnumFacing direction : EnumFacing.values()) { + net.minecraft.state.IProperty property = block.getStateContainer().getProperty(direction.getName()); + require(property instanceof BooleanProperty, "Missing inherited conduit connection state"); + boolean connected = (connectionMask & (1 << bit++)) != 0; + requested = requested.with((BooleanProperty) property, connected); + world.setBlockState(POS.offset(direction), connected ? block.getDefaultState() + : net.minecraft.init.Blocks.AIR.getDefaultState(), 2); + } + } + require(world.setBlockState(POS, requested, 2), "Cannot place harvest witness"); + IBlockState state = world.getBlockState(POS); + require(state.equals(requested), "Native connection state changed before mining"); + boolean expectedSolid = blockId.endsWith("_ore") || blockId.equals("electricadvantage:electric_conduit"); + require(state.isSolid() == expectedSolid, "Legacy solid/non-solid property changed"); + row.addProperty("actualSolid", state.isSolid()); + boolean fullCollision = !VoxelShapes.compare(state.getCollisionShape(world, POS), + VoxelShapes.fullCube(), IBooleanFunction.NOT_SAME); + require(fullCollision != blockId.equals("electricadvantage:electric_conduit"), + "Legacy wire/frame/scaffold collision geometry changed"); + row.addProperty("fullCollision", fullCollision); + if (level >= 0) require(state.getHarvestTool() == ToolType.PICKAXE + && state.getHarvestLevel() == level, "Historical harvest level/tool changed"); + ItemStack tool = toolItem == null ? ItemStack.EMPTY : new ItemStack(toolItem); + if (silk) tool.addEnchantment(Enchantments.SILK_TOUCH, 1); + player.setHeldItem(EnumHand.MAIN_HAND, tool); + require(state.canHarvestBlock(world, POS, player) == allowed, "Tool failure/success gate changed"); + require(player.interactionManager.tryHarvestBlock(POS) && world.isAirBlock(POS), + "Native PlayerInteractionManager did not remove the block"); + List drops = harvestDrops(world, new AxisAlignedBB(POS).grow(2)); + JsonArray actualDrops = new JsonArray(); + for (EntityItem entity : drops) { + JsonObject actual = new JsonObject(); + actual.addProperty("entityId", entity.getEntityId()); + actual.addProperty("uuid", entity.getUniqueID().toString()); + actual.addProperty("item", entity.getItem().getItem().getRegistryName().toString()); + actual.addProperty("count", entity.getItem().getCount()); + actual.addProperty("removed", entity.removed); + actualDrops.add(actual); + } + evidence.add("actualDrops", actualDrops); + int count = 0; + for (EntityItem entity : drops) { + ItemStack stack = entity.getItem(); + require(stack.getItem().getRegistryName().toString().equals(dropId), "Wrong actual drop: " + stack); + count += stack.getCount(); + } + require(count >= minimum && count <= maximum, "Actual drop quantity changed: " + count); + samples.add(count); + } + row.addProperty("status", "PASS"); + } catch (AssertionError | RuntimeException error) { + row.addProperty("status", "FAIL"); + row.addProperty("failure", error.toString()); + } + row.addProperty("block", blockId); + row.addProperty("historicalHarvestLevel", level); + row.addProperty("harvestAllowed", allowed); + row.addProperty("silkTouch", silk); + row.addProperty("trials", trials); + row.addProperty("connectionMask", connectionMask); + row.add("actualDropCounts", samples); + rows.add(row); + } + + private static void run(WorldServer world, String label) throws Exception { + require(MARKER.isFile() && label.matches("[A-Za-z0-9_-]+"), "Unmarked harvest runtime or unsafe label"); + File output = new File("advantage-works-results", "native-harvest-" + label + ".json"); + require(!output.exists(), "Refusing to overwrite harvest evidence"); + EntityPlayerMP player = new EntityPlayerMP(world.getServer(), world, + new GameProfile(UUID.fromString("c791aefe-a372-4536-9251-27032c01f115"), "AdvWorksHarvest"), + new PlayerInteractionManager(world)); + player.connection = new NetHandlerPlayServer(world.getServer(), new NetworkManager(EnumPacketDirection.SERVERBOUND), player) { + @Override public void sendPacket(Packet packet) {} + }; + player.setPosition(POS.getX() + 0.5, POS.getY() + 1, POS.getZ() + 0.5); + JsonArray rows = new JsonArray(); + String sulfur = "electricadvantage:sulfur_ore", lithium = "electricadvantage:li_ore"; + witness(world, player, rows, "sulfur/hand-fails", sulfur, 0, null, false, false, "", 0, 0, 1); + witness(world, player, rows, "sulfur/axe-fails", sulfur, 0, Items.IRON_AXE, false, false, "", 0, 0, 1); + witness(world, player, rows, "sulfur/wood", sulfur, 0, Items.WOODEN_PICKAXE, false, true, "electricadvantage:sulfur_powder", 1, 4, 8); + witness(world, player, rows, "sulfur/stone", sulfur, 0, Items.STONE_PICKAXE, false, true, "electricadvantage:sulfur_powder", 1, 4, 8); + witness(world, player, rows, "sulfur/silk", sulfur, 0, Items.WOODEN_PICKAXE, true, true, sulfur, 1, 1, 1); + witness(world, player, rows, "lithium/hand-fails", lithium, 1, null, false, false, "", 0, 0, 1); + witness(world, player, rows, "lithium/wood-fails", lithium, 1, Items.WOODEN_PICKAXE, false, false, "", 0, 0, 1); + witness(world, player, rows, "lithium/gold-fails", lithium, 1, Items.GOLDEN_PICKAXE, false, false, "", 0, 0, 1); + witness(world, player, rows, "lithium/axe-fails", lithium, 1, Items.IRON_AXE, false, false, "", 0, 0, 1); + witness(world, player, rows, "lithium/stone", lithium, 1, Items.STONE_PICKAXE, false, true, lithium, 1, 1, 1); + witness(world, player, rows, "lithium/diamond", lithium, 1, Items.DIAMOND_PICKAXE, false, true, lithium, 1, 1, 1); + witness(world, player, rows, "lithium/silk", lithium, 1, Items.STONE_PICKAXE, true, true, lithium, 1, 1, 1); + witness(world, player, rows, "frame/hand", "poweradvantage:steel_frame", -1, null, false, true, "poweradvantage:steel_frame", 1, 1, 1); + witness(world, player, rows, "frame/silk", "poweradvantage:steel_frame", -1, Items.DIAMOND_PICKAXE, true, true, "poweradvantage:steel_frame", 1, 1, 1); + for (int mask = 0; mask < 64; mask++) { + for (String family : new String[] {"wire", "scaffold"}) { + String identity = "electricadvantage:" + (family.equals("wire") ? "electric_conduit" : "electric_track"); + String prefix = family + "/" + mask; + witness(world, player, rows, prefix + "/hand", identity, -1, null, false, true, identity, 1, 1, 1, mask); + witness(world, player, rows, prefix + "/silk", identity, -1, Items.DIAMOND_PICKAXE, true, true, identity, 1, 1, 1, mask); + } + } + JsonObject solar = new JsonObject(); + solar.addProperty("id", "solar/native-solidity"); + try { + Block block = ForgeRegistries.BLOCKS.getValue(new ResourceLocation("electricadvantage:photovoltaic_generator")); + require(block != null && block != net.minecraft.init.Blocks.AIR, "Missing native photovoltaic block"); + world.setBlockState(POS, block.getDefaultState(), 2); + require(world.getBlockState(POS).isSolid(), "Legacy photovoltaic solid=true was lost"); + solar.addProperty("actualSolid", true); + solar.addProperty("status", "PASS"); + } catch (AssertionError | RuntimeException error) { + solar.addProperty("status", "FAIL"); solar.addProperty("failure", error.toString()); + } + rows.add(solar); + world.setBlockState(POS, net.minecraft.init.Blocks.AIR.getDefaultState(), 2); + for (EnumFacing direction : EnumFacing.values()) world.setBlockState(POS.offset(direction), net.minecraft.init.Blocks.AIR.getDefaultState(), 2); + harvestDrops(world, new AxisAlignedBB(POS).grow(2)).forEach(EntityItem::remove); + JsonObject report = new JsonObject(); + report.addProperty("label", label); + report.addProperty("minecraft", "1.13.2"); + report.addProperty("worldTime", world.getGameTime()); + report.addProperty("fixtureIsolation", "native113-live-drops-v1"); + report.addProperty("status", java.util.stream.StreamSupport.stream(rows.spliterator(), false) + .allMatch(row -> "PASS".equals(row.getAsJsonObject().get("status").getAsString())) ? "PASS" : "FAIL"); + report.add("checks", rows); + output.getParentFile().mkdirs(); + Files.write(output.toPath(), new GsonBuilder().setPrettyPrinting().create().toJson(report).getBytes(StandardCharsets.UTF_8)); + System.out.println("ADVHARVEST COMPLETE " + label); + } + + static List harvestDrops(net.minecraft.world.World world, AxisAlignedBB bounds) { + // remove() only marks an entity; the chunk index can retain it until the next tick. + return world.getEntitiesWithinAABB(EntityItem.class, bounds, entity -> !entity.removed); + } +} diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/PowerOperationalCases.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/PowerOperationalCases.java new file mode 100644 index 0000000..42666ae --- /dev/null +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/PowerOperationalCases.java @@ -0,0 +1,220 @@ +package zone.moddev.mc.advantageworks; + +import java.util.List; +import net.minecraft.entity.item.EntityItem; +import net.minecraft.item.ItemStack; +import net.minecraft.init.Items; +import net.minecraft.util.EnumFacing; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.AxisAlignedBB; + +final class PowerOperationalCases { + static void register(List cases) { + stills(cases); + for (boolean sourceLocked : new boolean[]{true, false}) cases.add(new MachineScenario( + "power/conveyor/locked-" + (sourceLocked ? "source" : "destination"), + "Automation must not bypass a vanilla chest lock, at either end of the belt", 160, + c -> { + conveyor(c, "poweradvantage:item_conveyor"); c.put(4, 2, 5, 0, "minecraft:dirt", 16); + net.minecraft.nbt.NBTTagCompound lock = new net.minecraft.nbt.NBTTagCompound(); lock.setString("Lock", "Works key"); + c.seed(sourceLocked ? 4 : 6, 2, 5, lock); + }, c -> { + c.require(c.count(6, 2, 5, "minecraft:dirt") == 0, "locked route delivers no stock"); + c.require(c.count(4, 2, 5, "minecraft:dirt") + c.count(5, 2, 5, "minecraft:dirt") == 16, + "locked route retains every input item"); + })); + for (EnumFacing direction : EnumFacing.values()) { + cases.add(new MachineScenario("power/conveyor/" + direction.getName(), + "A finite 16 dirt reaches the chest in the declared direction, without loss or extra items", 200, + c -> { + BlockPos offset = BlockPos.ORIGIN.offset(direction); + c.block(8, 6, 8, "poweradvantage:item_conveyor"); c.facing(8, 6, 8, direction.getName()); + c.block(8 - offset.getX(), 6 - offset.getY(), 8 - offset.getZ(), "minecraft:chest"); + c.block(8 + offset.getX(), 6 + offset.getY(), 8 + offset.getZ(), "minecraft:chest"); + c.put(8 - offset.getX(), 6 - offset.getY(), 8 - offset.getZ(), 0, "minecraft:dirt", 16); + }, c -> { + BlockPos offset = BlockPos.ORIGIN.offset(direction); + c.require(c.count(8 + offset.getX(), 6 + offset.getY(), 8 + offset.getZ(), "minecraft:dirt") == 16, "all dirt delivered"); + c.require(c.count(8 - offset.getX(), 6 - offset.getY(), 8 - offset.getZ(), "minecraft:dirt") == 0 + && c.count(8, 6, 8, "minecraft:dirt") == 0, "source and conveyor exhausted"); + })); + } + cases.add(new MachineScenario("power/conveyor/blocked", "Full output retains every dirt item in source or belt", 200, + c -> { conveyor(c, "poweradvantage:item_conveyor"); c.put(4, 2, 5, 0, "minecraft:dirt", 16); full(c, 6, 2, 5); }, + c -> { + c.require(c.count(4, 2, 5, "minecraft:dirt") + c.count(5, 2, 5, "minecraft:dirt") == 16, "blocked conveyor retains all inputs"); + c.require(c.count(6, 2, 5, "minecraft:cobblestone") == 1728, "full output contents unchanged"); + })); + cases.add(new MachineScenario("power/conveyor/item-entity", "Dropped dirt is collected and delivered without a duplicate entity", 200, + c -> { + c.block(5, 2, 5, "poweradvantage:item_conveyor"); c.facing(5, 2, 5, "east"); + c.block(6, 2, 5, "minecraft:chest"); c.block(4, 1, 5, "minecraft:stone"); + c.block(3, 2, 5, "minecraft:stone"); c.block(4, 2, 4, "minecraft:stone"); c.block(4, 2, 6, "minecraft:stone"); + BlockPos p = c.at(4, 2, 5); + EntityItem dropped = new EntityItem(c.world, p.getX() + .5, p.getY() + .01, p.getZ() + .5, new ItemStack(net.minecraft.item.Item.getItemFromBlock(net.minecraft.init.Blocks.DIRT), 16)); + dropped.motionX = dropped.motionY = dropped.motionZ = 0; + c.world.spawnEntity(dropped); + }, c -> { + c.require(c.count(6, 2, 5, "minecraft:dirt") == 16, "dropped input delivered"); + c.require(c.world.getEntitiesWithinAABB(EntityItem.class, new AxisAlignedBB(c.at(3, 0, 4), c.at(8, 5, 7))).isEmpty(), "no duplicate/lost item entity remains"); + })); + cases.add(new MachineScenario("power/conveyor/nbt-preserved", "Distinct custom item data must not merge into or overwrite another stack", 200, + c -> { + conveyor(c, "poweradvantage:item_conveyor"); + c.put(4, 2, 5, 0, "minecraft:dirt", 3); c.put(4, 2, 5, 1, "minecraft:dirt", 3); + c.inventory(4, 2, 5).getStackInSlot(0).getOrCreateTag().setString("Witness", "A"); + c.inventory(4, 2, 5).getStackInSlot(1).getOrCreateTag().setString("Witness", "B"); + c.put(6, 2, 5, 0, "minecraft:dirt", 1); + c.inventory(6, 2, 5).getStackInSlot(0).getOrCreateTag().setString("Witness", "A"); + }, c -> { + int a = 0, b = 0, unknown = 0; + for (int slot = 0; slot < c.inventory(6, 2, 5).getSizeInventory(); slot++) { + ItemStack stack = c.inventory(6, 2, 5).getStackInSlot(slot); + if (stack.isEmpty()) continue; + String witness = stack.hasTag() ? stack.getTag().getString("Witness") : ""; + if (witness.equals("A")) a += stack.getCount(); + else if (witness.equals("B")) b += stack.getCount(); + else unknown += stack.getCount(); + } + c.record("customA", a); c.record("customB", b); c.record("unknownTags", unknown); + c.require(a == 4 && b == 3 && unknown == 0, "exact custom-NBT stack identities and quantities retained"); + c.require(c.count(4, 2, 5, "minecraft:dirt") + c.count(5, 2, 5, "minecraft:dirt") == 0, "all custom stock delivered"); + })); + String[][] filters = { + {"block", "minecraft:dirt", "minecraft:apple"}, {"food", "minecraft:apple", "minecraft:dirt"}, + {"fuel", "minecraft:coal", "minecraft:dirt"}, {"ore", "minecraft:iron_ingot", "minecraft:dirt"}, + {"plant", "minecraft:wheat_seeds", "minecraft:dirt"}, {"smelt", "minecraft:iron_ore", "minecraft:dirt"} + }; + for (String[] filter : filters) cases.add(new MachineScenario("power/filter/" + filter[0], + "Eight matching items go down; eight nonmatching items go forward; bounded inputs exhaust", 300, + c -> { filter(c, "poweradvantage:item_filter_" + filter[0]); c.put(4, 2, 5, 0, filter[1], 8); c.put(4, 2, 5, 1, filter[2], 8); }, + c -> filterOutcome(c, filter[1], filter[2], true))); + for (int ghosts = 0; ghosts <= 3; ghosts++) { + final int slots = ghosts; + cases.add(new MachineScenario("power/filter/inventory-" + slots, + "Zero references pass all; one or several references sort matching dirt, retaining reference slots", 300, + c -> { + filter(c, "poweradvantage:item_filter_inventory"); + c.put(4, 2, 5, 0, "minecraft:dirt", 8); c.put(4, 2, 5, 1, "minecraft:apple", 8); + if (slots > 0) c.put(5, 2, 5, 1, "minecraft:dirt", 1); + if (slots > 1) { c.put(5, 2, 5, 2, "minecraft:cobblestone", 1); c.put(5, 2, 5, 3, "minecraft:coal", 1); } + }, c -> { + filterOutcome(c, "minecraft:dirt", "minecraft:apple", slots > 0); + c.require(c.inventory(5, 2, 5).getStackInSlot(1).getCount() == (slots > 0 ? 1 : 0), "reference dirt retained separately from throughput"); + })); + } + for (int flags = 0; flags < 4; flags++) { + final boolean lowerFull = (flags & 1) != 0, forwardFull = (flags & 2) != 0; + cases.add(new MachineScenario("power/filter/overflow-" + flags, + "Use lower output first, overflow forward when lower is full, retain input when both are full", 300, + c -> { + filter(c, "poweradvantage:item_filter_overflow"); c.put(4, 2, 5, 0, "minecraft:dirt", 16); + if (lowerFull) full(c, 5, 1, 5); if (forwardFull) full(c, 6, 2, 5); + }, c -> { + int lower = c.count(5, 1, 5, "minecraft:dirt"), forward = c.count(6, 2, 5, "minecraft:dirt"); + c.require(lower == (lowerFull ? 0 : 16), "overflow lower routing"); + c.require(forward == (lowerFull && !forwardFull ? 16 : 0), "overflow forward routing"); + c.require(c.count(4, 2, 5, "minecraft:dirt") + c.count(5, 2, 5, "minecraft:dirt") + lower + forward == 16, "overflow conservation even with both outputs blocked"); + })); + } + for (String fluid : new String[]{"minecraft:water", "poweradvantage:crude_oil", "poweradvantage:refined_oil"}) { + cases.add(new MachineScenario("power/drain/" + fluid.replace(':', '_'), + "Exactly one native fluid source travels through drain and pipe to storage with registry identity intact", 80, + c -> { drain(c, fluid, "poweradvantage:fluid_storage_tank", false); }, + c -> { + c.require(c.world.isAirBlock(c.at(5, 2, 5)), "native source collected"); + c.require(c.fluidAmount(7, 1, 5) == 1000 && c.fluidAmount(5, 1, 5) == 0, "exact source volume delivered"); + c.require(c.nbt(7, 1, 5).getCompound("Tank").getString("FluidName").equals(fluid), "fluid registry identity retained"); + })); + } + cases.add(new MachineScenario("power/drain/non-source", "Flowing water without a source cannot create a bucket", 80, + c -> { + drain(c, "minecraft:water", "poweradvantage:fluid_storage_tank", false); + c.world.setBlockState(c.at(5, 2, 5), net.minecraft.init.Blocks.WATER.getDefaultState() + .with(net.minecraft.block.BlockFlowingFluid.LEVEL, 7), 3); + }, c -> c.require(c.fluidAmount(5, 1, 5) + c.fluidAmount(7, 1, 5) == 0, "no source means no collected millibuckets"))); + for (boolean matching : new boolean[]{true, false}) cases.add(new MachineScenario("power/tank/filter-" + matching, + "A bucket reference admits matching water or rejects it without losing the source volume", 80, + c -> { drain(c, "minecraft:water", "poweradvantage:fluid_metal_tank", false); c.put(7, 1, 5, 0, matching ? "minecraft:water_bucket" : "minecraft:lava_bucket", 1); }, + c -> { + c.require(c.fluidAmount(7, 1, 5) == (matching ? 1000 : 0), "bucket filter controls actual network acceptance"); + c.require(c.fluidAmount(7, 1, 5) + c.fluidAmount(5, 1, 5) == 1000, "rejected water is retained rather than deleted"); + })); + cases.add(new MachineScenario("power/tank/full", "Full metal storage never overfills or deletes the extra source bucket", 80, + c -> { drain(c, "minecraft:water", "poweradvantage:fluid_metal_tank", false); c.tank(7, 1, 5, "minecraft:water", 10000); }, + c -> c.require(c.fluidAmount(7, 1, 5) == 10000 && c.fluidAmount(5, 1, 5) == 1000, "full output applies backpressure and conserves 11000 mB"))); + for (boolean signal : new boolean[]{true, false}) cases.add(new MachineScenario("power/fluid-switch/" + signal, + "Redstone isolates the fluid path; removing it permits transfer without replacing machinery", 100, + c -> { + drain(c, "minecraft:water", "poweradvantage:fluid_storage_tank", true); + if (signal) c.block(6, 2, 5, "minecraft:redstone_block"); + }, c -> { + c.require(c.fluidAmount(7, 1, 5) == (signal ? 0 : 1000), "redstone controls fluid delivery"); + c.require(c.fluidAmount(7, 1, 5) + c.fluidAmount(5, 1, 5) == 1000, "switch conserves fluid"); + })); + for (boolean blocked : new boolean[]{true, false}) cases.add(new MachineScenario("power/discharge/" + blocked, + "A discharge places one bucket into air, but must never replace solid bedrock", 80, + c -> { + c.block(5, 2, 5, "poweradvantage:fluid_discharge"); + for (EnumFacing direction : EnumFacing.values()) if (direction != EnumFacing.UP) { + BlockPos p = BlockPos.ORIGIN.offset(direction); + c.block(5 + p.getX(), 1 + p.getY(), 5 + p.getZ(), "minecraft:bedrock"); + } + if (blocked) c.block(5, 1, 5, "minecraft:bedrock"); + c.tank(5, 2, 5, "minecraft:water", 1000); + }, c -> { + c.require(c.fluidAmount(5, 2, 5) == (blocked ? 1000 : 0), "discharge respects available placement space"); + c.require(blocked ? c.world.getBlockState(c.at(5, 1, 5)).getBlock() == net.minecraft.init.Blocks.BEDROCK + : c.world.getFluidState(c.at(5, 1, 5)).isSource(), "solid host preserved or actual source placed"); + })); + } + + private static void stills(List cases) { + for (String mode : new String[]{"production", "no-fuel", "invalid", "full", "one-space", "redstone"}) { + final int output = mode.equals("full") ? 1000 : mode.equals("one-space") ? 999 : 0; + final int product = mode.equals("production") ? 500 : mode.equals("one-space") ? 1 : 0; + cases.add(new MachineScenario("power/still/" + mode, + "Finite crude oil produces refined oil at 2:1; fuel, valid input, capacity and redstone govern useful work", 620, + c -> { + c.block(8, 4, 8, "poweradvantage:still"); + zone.moddev.mc.poweradvantage.machines.fluidmachines.StillTileEntity still = + (zone.moddev.mc.poweradvantage.machines.fluidmachines.StillTileEntity)c.tile(8, 4, 8); + still.getInputTank().setFluid(new cyano.poweradvantage.api.fluid.FluidStack( + mode.equals("invalid") ? net.minecraft.init.Fluids.WATER + : net.minecraft.util.registry.IRegistry.field_212619_h.func_212608_b(new net.minecraft.util.ResourceLocation("poweradvantage:crude_oil")), 1000)); + if (!mode.equals("no-fuel")) c.put(8, 4, 8, 0, "minecraft:coal", 1); + if (output > 0) c.tank(8, 4, 8, "poweradvantage:refined_oil", output); + if (mode.equals("redstone")) c.block(9, 4, 8, "minecraft:redstone_block"); + }, c -> { + zone.moddev.mc.poweradvantage.machines.fluidmachines.StillTileEntity still = + (zone.moddev.mc.poweradvantage.machines.fluidmachines.StillTileEntity)c.tile(8, 4, 8); + c.require(still.getInputTank().getFluidAmount() == 1000 - product * 2, "exact raw input debit"); + c.require(c.fluidAmount(8, 4, 8) == output + product, "exact bounded distillation product"); + if (product == 0 && !mode.equals("no-fuel")) + c.require(c.count(8, 4, 8, "minecraft:coal") == 1, "blocked job does not consume fresh fuel"); + })); + } + } + + private static void conveyor(MachineContext c, String filter) { + c.block(4, 2, 5, "minecraft:chest"); c.block(5, 2, 5, filter); c.facing(5, 2, 5, "east"); c.block(6, 2, 5, "minecraft:chest"); + } + private static void filter(MachineContext c, String filter) { conveyor(c, filter); c.block(5, 1, 5, "minecraft:chest"); } + private static void full(MachineContext c, int x, int y, int z) { + for (int slot = 0; slot < c.inventory(x, y, z).getSizeInventory(); slot++) c.put(x, y, z, slot, "minecraft:cobblestone", 64); + } + private static void filterOutcome(MachineContext c, String matching, String other, boolean sorted) { + c.require(c.count(5, 1, 5, matching) == (sorted ? 8 : 0), "matching category routed down"); + c.require(c.count(6, 2, 5, other) == 8 && c.count(6, 2, 5, matching) == (sorted ? 0 : 8), "nonmatches routed forward"); + c.require(c.count(4, 2, 5, matching) + c.count(4, 2, 5, other) == 0 && c.inventory(5, 2, 5).getStackInSlot(0).isEmpty(), "finite source and filter buffer exhausted"); + } + private static void drain(MachineContext c, String fluid, String tank, boolean switched) { + c.block(5, 1, 5, "poweradvantage:fluid_drain"); + for (EnumFacing d : new EnumFacing[]{EnumFacing.NORTH, EnumFacing.SOUTH, EnumFacing.WEST, EnumFacing.EAST}) { + BlockPos p = BlockPos.ORIGIN.offset(d); c.block(5 + p.getX(), 2, 5 + p.getZ(), "minecraft:bedrock"); + } + c.block(5, 2, 5, fluid); + c.block(6, 1, 5, switched ? "poweradvantage:fluid_switch" : "poweradvantage:fluid_pipe"); + c.block(7, 1, 5, tank); + } +} diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/RedstoneReloadProbe.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/RedstoneReloadProbe.java new file mode 100644 index 0000000..7be0baf --- /dev/null +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/RedstoneReloadProbe.java @@ -0,0 +1,350 @@ +package zone.moddev.mc.advantageworks; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import com.mojang.brigadier.arguments.StringArgumentType; +import cyano.poweradvantage.api.ConduitType; +import cyano.poweradvantage.api.PoweredEntity; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import net.minecraft.block.Block; +import net.minecraft.block.state.IBlockState; +import net.minecraft.init.Blocks; +import net.minecraft.command.Commands; +import net.minecraft.inventory.IInventory; +import net.minecraft.item.ItemStack; +import net.minecraft.init.Items; +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.state.properties.BlockStateProperties; +import net.minecraft.tileentity.TileEntity; +import net.minecraft.util.EnumFacing; +import net.minecraft.util.ResourceLocation; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.registry.IRegistry; +import net.minecraft.world.World; +import net.minecraft.world.WorldServer; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.fml.event.server.FMLServerStartingEvent; +import net.minecraftforge.fml.common.gameevent.TickEvent; + +/** Separate cold-process experiment: finite earned work, never expected-state restoration. */ +final class RedstoneReloadProbe { + private static final File MARKER = new File("advantage-redstone-reload.allow"); + private static final File FIXTURE = new File("redstone-reload-fixture.json"); + private static final Gson JSON = new GsonBuilder().setPrettyPrinting().create(); + private static final int OBSERVATION_TICKS = 16; + private static final int READINESS_TICKS = 80; + private enum Phase { FROZEN, PREPARING, SEEDING, RESTORING, OPERATING, DONE } + + private final String processToken = UUID.randomUUID().toString(); + private final List witnesses = definitions(); + private final List suspended = new ArrayList<>(); + private final List> seedTrace = new ArrayList<>(); + private final List> ticks = new ArrayList<>(); + private final List> readiness = new ArrayList<>(); + private Phase phase = Phase.FROZEN; + private WorldServer world; + private String label; + private boolean used; + private int preparationIndex; + private int constructionCalls; + private long tickStart; + private Map initial; + private Map restored; + + static void register() { + if (!MARKER.isFile()) return; + RedstoneReloadProbe probe = new RedstoneReloadProbe(); + MinecraftForge.EVENT_BUS.addListener(probe::commands); + MinecraftForge.EVENT_BUS.addListener(probe::tick); + } + + private static List definitions() { + List result = new ArrayList<>(); + for (int i = 0; i < 9; i++) { + int offset = i == 8 ? 0 : i; + result.add(new Witness(i == 8 ? "unheld" : "held-" + i, i != 8, offset, + new BlockPos(60000 + i * 64 + 14 + ((offset >> 1) & 1), + 80 + (offset & 1), 60000 + ((offset >> 2) & 1)))); + } + return result; + } + + private void commands(FMLServerStartingEvent event) { + event.getCommandDispatcher().register(Commands.literal("advredstonereload") + .requires(source -> source.hasPermissionLevel(4)) + .then(Commands.argument("operation", StringArgumentType.word()) + .then(Commands.argument("label", StringArgumentType.word()).executes(context -> { + if (!MARKER.isFile() || used) throw new IllegalStateException("Missing marker or reused probe"); + WorldServer current = context.getSource().getWorld(); + if (!current.getWorldInfo().getWorldName().equals("AdvantageWorksMigration")) + throw new IllegalStateException("Not an authorized disposable world"); + String requested = StringArgumentType.getString(context, "label"); + if (!requested.matches("[A-Za-z0-9_-]+")) throw new IllegalArgumentException("Unsafe label"); + world = current; + label = requested; + used = true; + try { + String operation = StringArgumentType.getString(context, "operation"); + if ("seed".equals(operation)) { + if (FIXTURE.exists()) throw new IllegalStateException("Never rebuild a saved fixture"); + phase = Phase.PREPARING; + } else if ("resume".equals(operation)) { + JsonObject fixture = JSON.fromJson(new String(Files.readAllBytes(FIXTURE.toPath()), + StandardCharsets.UTF_8), JsonObject.class); + if (fixture.get("schema").getAsInt() != 1 || fixture.getAsJsonArray("cases").size() != 9 + || processToken.equals(fixture.get("processToken").getAsString())) + throw new IllegalStateException("Missing cold-process seed authority"); + phase = Phase.RESTORING; + } else throw new IllegalArgumentException("Expected seed or resume"); + } catch (Throwable failure) { fail(failure); } + return 1; + })))); + } + + private boolean paused() { return phase != Phase.SEEDING && phase != Phase.OPERATING; } + + private void tick(TickEvent.WorldTickEvent event) { + if (!(event.world instanceof WorldServer) || event.world.dimension.getType() != net.minecraft.world.dimension.DimensionType.OVERWORLD) return; + WorldServer current = (WorldServer) event.world; + if (event.phase == TickEvent.Phase.START) { + tickStart = current.getGameTime(); + if (paused()) { + suspended.addAll(current.tickableTileEntities); + current.tickableTileEntities.clear(); + } + return; + } + for (TileEntity tile : suspended) + if (!tile.isRemoved() && !current.tickableTileEntities.contains(tile)) current.tickableTileEntities.add(tile); + suspended.clear(); + if (current != world || phase == Phase.DONE) return; + try { + if (phase == Phase.PREPARING) { + if (preparationIndex < witnesses.size()) { + construct(witnesses.get(preparationIndex++)); + } else if (ready("seed")) { + initial = frame(0); + phase = Phase.SEEDING; + } + } else if (phase == Phase.SEEDING) { + Map observation = frame(seedTrace.size() + 1); + seedTrace.add(observation); + boolean ready = true; + for (Witness witness : witnesses) { + NBTTagCompound tag = tile(witness).write(new NBTTagCompound()); + ready &= tag.getShort("progress") > 0 && tag.contains("targetX", 99) + && ((PoweredEntity) tile(witness)).getEnergy(new ConduitType("electricity")) > 24; + } + if (ready) freezeSeed(); + else if (seedTrace.size() >= 16) throw new AssertionError("No finite natural mid-obsidian state within 16 ticks"); + } else if (phase == Phase.RESTORING) { + if (preparationIndex < witnesses.size()) { + force(witnesses.get(preparationIndex++)); + } else if (ready("restart")) { + restored = frame(0); + writeResult("redstone-reload-restored-" + label + ".json", restored); + phase = Phase.OPERATING; + } + } else if (phase == Phase.OPERATING) { + Map observation = frame(ticks.size() + 1); + ticks.add(observation); + writeResult(String.format(java.util.Locale.ROOT, "redstone-reload-tick-%s-%02d.json", label, ticks.size()), observation); + if (ticks.size() == OBSERVATION_TICKS) { + phase = Phase.DONE; + Map report = metadata(); + report.put("observationTicks", OBSERVATION_TICKS); + report.put("held", true); + report.put("restored", restored); + report.put("ticks", ticks); + report.put("readiness", readiness); + writeResult("redstone-reload-" + label + ".json", report); + complete(); + } + } + } catch (Throwable failure) { fail(failure); } + } + + private void force(Witness witness) { + // Include every neighbor and the 3x3 target, also at x=15 chunk edges. + for (int x = (witness.pos.getX() - 1) >> 4; x <= (witness.pos.getX() + 1) >> 4; x++) + for (int z = (witness.pos.getZ() - 1) >> 4; z <= (witness.pos.getZ() + 1) >> 4; z++) { + world.func_212414_b(x, z, true); + world.getChunk(x, z); + } + } + + private boolean ready(String stage) { + boolean all = true; + List> rows = new ArrayList<>(); + for (Witness witness : witnesses) { + TileEntity tile = tile(witness); + // Before ChunkHolder existed, native tile ticks required a loaded chunk and list membership. + boolean ticking = world.isBlockLoaded(witness.pos); + boolean listed = world.tickableTileEntities.contains(tile) && !tile.isRemoved(); + Map row = new LinkedHashMap<>(); + row.put("id", witness.id); + row.put("pos", coordinates(witness.pos)); + row.put("blockTicking", ticking); + row.put("tickableListed", listed); + rows.add(row); + all &= ticking && listed; + } + Map sample = new LinkedHashMap<>(); + sample.put("phase", stage); + sample.put("worldTime", world.getGameTime()); + sample.put("cases", rows); + readiness.add(sample); + if (!all && readiness.size() >= READINESS_TICKS) + throw new AssertionError("Native block-ticking/list readiness timed out after " + READINESS_TICKS + " paused ticks"); + return all; + } + + private void construct(Witness witness) { + if (phase != Phase.PREPARING || FIXTURE.exists()) throw new IllegalStateException("Construction outside fresh setup"); + force(witness); + for (int dx = -1; dx <= 1; dx++) for (int dy = -1; dy <= 1; dy++) for (int dz = -1; dz <= 1; dz++) + world.setBlockState(witness.pos.add(dx, dy, dz), Blocks.AIR.getDefaultState(), 2); + Block block = net.minecraftforge.registries.ForgeRegistries.BLOCKS.getValue(new ResourceLocation("electricadvantage:electric_drill")); + if (block == null || block == Blocks.AIR) throw new IllegalStateException("Electric drill registry absent"); + world.setBlockState(witness.pos, block.getDefaultState().with(BlockStateProperties.FACING, EnumFacing.NORTH), 2); + for (int dx = -1; dx <= 1; dx++) for (int dy = -1; dy <= 1; dy++) + world.setBlockState(witness.pos.add(dx, dy, -1), Blocks.OBSIDIAN.getDefaultState(), 2); + TileEntity tile = tile(witness); + ((PoweredEntity) tile).setEnergy(500, new ConduitType("electricity")); + ItemStack dirt = new ItemStack(net.minecraft.item.Item.getItemFromBlock(net.minecraft.init.Blocks.DIRT), 7); + dirt.getOrCreateTag().setString("AdvWorksWitness", witness.id); + ((IInventory) tile).setInventorySlotContents(0, dirt); + ((IInventory) tile).setInventorySlotContents(1, new ItemStack(Items.APPLE, 13)); + tile.markDirty(); + constructionCalls++; + } + + private TileEntity tile(Witness witness) { + TileEntity tile = world.getTileEntity(witness.pos); + if (!(tile instanceof PoweredEntity) || !(tile instanceof IInventory)) + throw new IllegalStateException("Missing powered inventory at " + witness.pos); + return tile; + } + + private void freezeSeed() { + // Freeze BEFORE adding redstone: no tick may erase the naturally earned target/progress. + phase = Phase.FROZEN; + List> preHold = snapshots(); + for (Witness witness : witnesses) if (witness.held) + world.setBlockState(witness.pos.up(), Blocks.REDSTONE_BLOCK.getDefaultState(), 3); + for (Witness witness : witnesses) tile(witness).markDirty(); + Map fixture = metadata(); + fixture.put("initialEnergy", 500); + fixture.put("initial", initial); + fixture.put("seedTrace", seedTrace); + fixture.put("readiness", readiness); + fixture.put("preHold", preHold); + fixture.put("worldTime", world.getGameTime()); + fixture.put("cases", snapshots()); + fixture.put("freezeMethod", "Suspend tickable list before adding holds; no expected NBT writes; restart the same world"); + writeNew(FIXTURE, fixture); + complete(); + } + + private Map frame(int index) { + Map result = new LinkedHashMap<>(); + result.put("index", index); + result.put("tickStartWorldTime", tickStart); + result.put("worldTime", world.getGameTime()); + result.put("cases", snapshots()); + return result; + } + + private List> snapshots() { + List> result = new ArrayList<>(); + for (Witness witness : witnesses) { + TileEntity tile = tile(witness); + NBTTagCompound nbt = tile.write(new NBTTagCompound()); + IBlockState state = world.getBlockState(witness.pos); + Map row = new LinkedHashMap<>(); + row.put("id", witness.id); + row.put("pos", coordinates(witness.pos)); + row.put("held", witness.held); + row.put("offset", witness.offset); + row.put("block", state.getBlock().getRegistryName().toString()); + row.put("state", state.toString()); + row.put("active", active(state)); + row.put("signal", world.isBlockPowered(witness.pos)); + row.put("holdBlock", world.getBlockState(witness.pos.up()).getBlock().getRegistryName().toString()); + row.put("energy", ((PoweredEntity) tile).getEnergy(new ConduitType("electricity"))); + row.put("progress", (int) nbt.getShort("progress")); + row.put("nbt", nbt.toString()); + List> targets = new ArrayList<>(); + for (int dx = -1; dx <= 1; dx++) for (int dy = -1; dy <= 1; dy++) { + BlockPos pos = witness.pos.add(dx, dy, -1); + Map target = new LinkedHashMap<>(); + target.put("pos", coordinates(pos)); + target.put("state", world.getBlockState(pos).toString()); + targets.add(target); + } + row.put("targets", targets); + result.add(row); + } + return result; + } + + private static boolean active(IBlockState state) { + for (net.minecraft.state.IProperty property : state.getProperties()) + if (property.getName().equals("active")) return Boolean.TRUE.equals(state.get(property)); + throw new IllegalStateException("Drill has no ACTIVE property"); + } + + private static int[] coordinates(BlockPos pos) { return new int[]{pos.getX(), pos.getY(), pos.getZ()}; } + + private Map metadata() { + Map result = new LinkedHashMap<>(); + result.put("schema", 1); + result.put("processToken", processToken); + result.put("constructionCallsThisProcess", constructionCalls); + return result; + } + + private void fail(Throwable failure) { + phase = Phase.DONE; + Map report = metadata(); + report.put("failure", failure.toString()); + java.io.StringWriter trace = new java.io.StringWriter(); + failure.printStackTrace(new java.io.PrintWriter(trace)); + report.put("traceback", trace.toString()); + report.put("seedTrace", seedTrace); + report.put("ticks", ticks); + report.put("readiness", readiness); + writeResult("redstone-reload-failure-" + label + ".json", report); + complete(); + } + + private void complete() { System.out.println("ADVREDSTONE COMPLETE " + label); } + + private static void writeResult(String name, Object value) { writeNew(new File("advantage-works-results", name), value); } + + private static void writeNew(File path, Object value) { + try { + if (path.getParentFile() != null) Files.createDirectories(path.getParentFile().toPath()); + Files.write(path.toPath(), JSON.toJson(value).getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE_NEW); + } catch (java.io.IOException error) { throw new IllegalStateException("Cannot retain immutable observation " + path, error); } + } + + private static final class Witness { + final String id; + final boolean held; + final int offset; + final BlockPos pos; + Witness(String id, boolean held, int offset, BlockPos pos) { + this.id = id; this.held = held; this.offset = offset; this.pos = pos; + } + } +} diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/SourcePickupOperationalCases.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/SourcePickupOperationalCases.java new file mode 100644 index 0000000..f7d4dc3 --- /dev/null +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/SourcePickupOperationalCases.java @@ -0,0 +1,54 @@ +package zone.moddev.mc.advantageworks; + +import java.util.List; +import net.minecraft.init.Blocks; +import net.minecraft.state.properties.BlockStateProperties; + +final class SourcePickupOperationalCases { + static void register(List cases) { + for (String machine : new String[]{"power-drain", "steam-pump", "electric-pump"}) { + cases.add(new MachineScenario("pickup/" + machine, + "Native pickup removes exactly one bucket of water, preserving the waterlogged slab and retaining the collected fluid after restart", 400, + c -> { + if (machine.equals("power-drain")) { + c.block(8, 2, 8, "poweradvantage:fluid_drain"); + c.block(9, 2, 8, "poweradvantage:fluid_pipe"); + c.block(10, 2, 8, "poweradvantage:fluid_metal_tank"); + for (int[] p : new int[][]{{7,3,8},{9,3,8},{8,3,7},{8,3,9},{8,4,8}}) + c.block(p[0],p[1],p[2],"minecraft:bedrock"); + slab(c, 8, 3, 8); + } else { + c.block(8, 4, 8, machine.equals("steam-pump") ? "steamadvantage:steam_pump" : "electricadvantage:electric_pump"); + c.block(9, 4, 8, "poweradvantage:fluid_metal_tank"); + if (machine.equals("steam-pump")) c.steamSupply(7, 4, 8, 600); + else c.electricSupply(7, 4, 8, 8000); + for (int[] p : new int[][]{{8,0,8},{8,0,7},{8,0,9},{7,0,8},{9,0,7},{9,0,9},{10,0,8},{9,1,8}}) + c.block(p[0],p[1],p[2],"minecraft:bedrock"); + slab(c, 9, 0, 8); + } + }, c -> { + boolean drain = machine.equals("power-drain"); + net.minecraft.util.math.BlockPos source = drain ? c.at(8, 3, 8) : c.at(9, 0, 8); + c.require(c.world.getBlockState(source).getBlock() == Blocks.OAK_SLAB, "Water pickup must never delete the host slab"); + c.require(!c.world.getBlockState(source).get(BlockStateProperties.WATERLOGGED), "Host is genuinely drained, not an unchanged water source"); + int stored = drain ? amount(c, 8, 2, 8) + amount(c, 10, 2, 8) + : amount(c, 8, 4, 8) + amount(c, 9, 4, 8); + c.require(stored == 1000, "Exactly one source bucket is conserved in the real machine and receiver"); + if (!drain) { + String type = machine.equals("steam-pump") ? "steam" : "electricity"; + float remaining = c.energy(8, 4, 8, type) + c.energy(7, 4, 8, type); + c.require(remaining <= (type.equals("steam") ? 549 : 6572), "Lift construction and fluid pickup debit real finite energy"); + } + })); + } + } + + private static int amount(MachineContext c, int x, int y, int z) { + return ((cyano.poweradvantage.api.fluid.FluidTank)c.invoke(x, y, z, "getTank", new Class[0])).getFluidAmount(); + } + + private static void slab(MachineContext c, int x, int y, int z) { + c.block(x, y, z, "minecraft:oak_slab"); + c.world.setBlockState(c.at(x,y,z), c.world.getBlockState(c.at(x,y,z)).with(BlockStateProperties.WATERLOGGED, true), 3); + } +} diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/SteamOperationalActors.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/SteamOperationalActors.java new file mode 100644 index 0000000..9bfab7a --- /dev/null +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/SteamOperationalActors.java @@ -0,0 +1,214 @@ +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.block.state.IBlockState; +import net.minecraft.enchantment.Enchantment; +import net.minecraft.entity.EntityType; +import net.minecraft.entity.SharedMonsterAttributes; +import net.minecraft.entity.item.EntityArmorStand; +import net.minecraft.entity.passive.EntityCow; +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.network.Packet; +import net.minecraft.network.NetworkManager; +import net.minecraft.network.EnumPacketDirection; +import net.minecraft.network.NetHandlerPlayServer; +import net.minecraft.state.IntegerProperty; +import net.minecraft.util.EnumActionResult; +import net.minecraft.util.EnumHand; +import net.minecraft.util.ResourceLocation; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.GameType; +import net.minecraftforge.common.util.FakePlayer; +import net.minecraftforge.registries.ForgeRegistries; + +/** Per-case entities: no shared fake-player factory identity or mutable counters. */ +final class SteamOperationalActors { + private SteamOperationalActors() {} + + static void register(List cases) { + elevator(cases); + musket(cases); + } + + private static void elevator(List cases) { + for (String mode : new String[]{"up", "down", "no-power", "scarce"}) { + final EntityArmorStand[] passenger = new EntityArmorStand[1]; + cases.add(new MachineScenario("steam/elevator-" + mode, + "A real passenger moves to the physical stop, one move costs exactly 32 steam, " + + "a steady signal never repeats the move, and insufficient steam leaves passenger/shaft unchanged.", + 80, c -> { + c.block(8, 0, 8, "steamadvantage:steam_elevator"); + c.block(8, 9, 8, "minecraft:stone"); + boolean down = mode.equals("down"); + if (down) { + for (int y = 1; y <= 6; y++) platform(c, y, y == 6 ? 4 : 0); + NBTTagCompound patch = new NBTTagCompound(); patch.setBoolean("up", true); + c.seed(8, 0, 8, patch); + } else c.block(9, 0, 8, "minecraft:redstone_block"); + if (!mode.equals("no-power")) c.power(8, 0, 8, "steam", mode.equals("scarce") ? 31 : 64); + BlockPos base = c.at(8, 0, 8); + EntityArmorStand rider = new EntityArmorStand(c.world, base.getX() + 0.5, + base.getY() + (down ? 7 : 1) + 0.00390625, base.getZ() + 0.5); + rider.setNoGravity(true); + rider.setInvulnerable(true); + c.require(c.world.spawnEntity(rider), "Passenger spawned into the real server world"); + passenger[0] = rider; + c.record("passenger/uuid", rider.getUniqueID().toString()); + c.record("passenger/startY", rider.posY); + }, c -> { + EntityArmorStand rider = passenger[0]; + c.require(rider != null && rider.isAlive(), "Passenger survives the movement test"); + c.record("passenger/endY", rider.posY); + c.record("passenger/naturalTicks", rider.ticksExisted); + c.require(rider.ticksExisted >= 75, "Passenger received natural server entity ticks"); + boolean movedUp = mode.equals("up"), down = mode.equals("down"); + double destination = c.at(8, 0, 8).getY() + (movedUp ? 7 : 1) + 0.00390625; + c.require(Math.abs(rider.posY - destination) < 0.001, + "Passenger reaches exactly the declared physical destination or stays put without power"); + c.require(rider.fallDistance == 0, "Elevator movement resets passenger fall distance"); + float expected = mode.equals("no-power") ? 0 : mode.equals("scarce") ? 31 : 32; + c.require(c.energy(8, 0, 8, "steam") == expected, + "Exactly one 32-steam move, or no debit below operation cost, after 80 ticks"); + for (int y = 1; y <= 8; y++) { + IBlockState state = c.world.getBlockState(c.at(8, y, 8)); + c.record("shaft/" + y, state.toString()); + if (movedUp && y <= 6) { + c.require("steamadvantage:platform".equals(String.valueOf(state.getBlock().getRegistryName())), + "Raised shaft contains physical platform segment " + y); + IntegerProperty height = (IntegerProperty) state.getBlock().getStateContainer().getProperty("height"); + c.require(height != null && state.get(height) == (y == 6 ? 4 : 0), + "Raised shaft/top collision height is preserved at " + y); + } else c.require(state.isAir(), "Shaft is clear at " + y + " after " + mode); + } + c.require(c.world.getBlockState(c.at(8, 9, 8)).getBlock() == net.minecraft.init.Blocks.STONE, + "Elevator preserves the real upper stop"); + c.require(c.nbt(8, 0, 8).getBoolean("up") == movedUp, + "Saved latch agrees with physical movement, not just an NBT flag"); + })); + } + } + + private static void platform(MachineContext c, int y, int height) { + c.block(8, y, 8, "steamadvantage:platform"); + IBlockState state = c.world.getBlockState(c.at(8, y, 8)); + IntegerProperty property = (IntegerProperty) state.getBlock().getStateContainer().getProperty("height"); + if (property == null) throw new IllegalStateException("Missing native platform height"); + c.world.setBlockState(c.at(8, y, 8), state.with(property, height), 3); + } + + private static final class Shot { + FakePlayer player; + EntityCow target; + ItemStack weapon; + float expectedDamage; + int expectedAmmo; + } + + private static void musket(List cases) { + for (String mode : new String[]{"last-cartridge", "no-ammo", "powderless", "infinity"}) { + Shot shot = new Shot(); + cases.add(new MachineScenario("steam/musket-" + mode, + "Native fake-player use/release interactions prove rapid reload, ammunition policy, " + + "last-cartridge firing, configured target damage and one durability debit. " + + "The fake player is not manually ticked; this does not claim timed-reload coverage.", + 40, c -> { + shootingRoom(c); + BlockPos muzzle = c.at(8, 0, 8); + UUID id = UUID.nameUUIDFromBytes(("SteamOperational/" + mode + "/" + muzzle) + .getBytes(StandardCharsets.UTF_8)); + shot.player = new FakePlayer(c.world, new GameProfile(id, "SteamOperational")); + shot.player.connection = new NetHandlerPlayServer(c.world.getServer(), + new NetworkManager(EnumPacketDirection.SERVERBOUND), shot.player) { + @Override public void sendPacket(Packet packet) {} + }; + shot.player.interactionManager.setGameType(GameType.SURVIVAL); + shot.player.setPositionAndRotation(muzzle.getX() + 0.5, muzzle.getY(), muzzle.getZ() + 0.5, 0, 0); + Item weapon = ForgeRegistries.ITEMS.getValue(new ResourceLocation("steamadvantage:musket")); + c.require(weapon != null && weapon != net.minecraft.init.Items.AIR, "Native musket is registered"); + shot.weapon = new ItemStack(weapon); + shot.player.setHeldItem(EnumHand.MAIN_HAND, shot.weapon); + if (!mode.equals("no-ammo")) enchant(c, shot.weapon, "steamadvantage:rapid_reload"); + if (mode.equals("powderless")) enchant(c, shot.weapon, "steamadvantage:powderless"); + if (mode.equals("infinity")) enchant(c, shot.weapon, "minecraft:infinity"); + if (mode.equals("last-cartridge") || mode.equals("infinity")) { + Item cartridge = ForgeRegistries.ITEMS.getValue(new ResourceLocation("steamadvantage:blackpowder_cartridge")); + c.require(cartridge != null && cartridge != net.minecraft.init.Items.AIR, "Cartridge is registered"); + shot.player.inventory.setInventorySlotContents(1, new ItemStack(cartridge)); + } + shot.expectedAmmo = mode.equals("infinity") ? 1 : 0; + shot.target = EntityType.COW.create(c.world); + c.require(shot.target != null, "Native living target created"); + shot.target.getAttribute(SharedMonsterAttributes.MAX_HEALTH).setBaseValue(1000); + shot.target.setHealth(1000); + shot.target.setPositionAndRotation(muzzle.getX() + 0.5, muzzle.getY() + 0.5, muzzle.getZ() + 4.5, 0, 0); + shot.target.setNoAI(true); shot.target.setNoGravity(true); shot.target.enablePersistence(); + c.require(c.world.spawnEntity(shot.target), "Living target added to the real server world"); + c.record("target/uuid", shot.target.getUniqueID().toString()); + c.record("target/initialHealth", shot.target.getHealth()); + EnumActionResult reload = weapon.onItemRightClick(c.world, shot.player, EnumHand.MAIN_HAND).getType(); + c.record("musket/reload-result", reload.toString()); + if (mode.equals("no-ammo")) { + c.require(reload == EnumActionResult.FAIL, "Unloaded musket rejects use without ammunition"); + shot.expectedDamage = 0; + } else { + c.require(reload == EnumActionResult.SUCCESS, "Rapid-reload interaction succeeds"); + c.require(shot.weapon.getOrCreateTag().getByte("loaded") == 1, + "Reload interaction loads the chamber before the shot"); + c.record("musket/ammo-after-reload", shot.player.inventory.getStackInSlot(1).getCount()); + c.require(shot.player.inventory.getStackInSlot(1).getCount() == shot.expectedAmmo, + "Reload consumes exactly one cartridge unless an ammunition-saving enchantment applies"); + EnumActionResult aim = weapon.onItemRightClick(c.world, shot.player, EnumHand.MAIN_HAND).getType(); + c.record("musket/aim-result", aim.toString()); + c.require(aim == EnumActionResult.SUCCESS, "Loaded musket can aim even without a spare cartridge"); + c.require(shot.player.isHandActive(), "Native player enters the use lifecycle"); + shot.player.stopActiveHand(); + shot.expectedDamage = shotDamage(); + } + }, c -> { + c.record("target/finalHealth", shot.target.getHealth()); + c.record("target/naturalTicks", shot.target.ticksExisted); + c.record("musket/durabilityDebit", shot.weapon.getDamage()); + c.record("musket/finalAmmo", shot.player.inventory.getStackInSlot(1).getCount()); + c.require(shot.target.isAlive() && shot.target.ticksExisted >= 35, + "Real target remains available after natural server ticks"); + c.require(Math.abs(shot.target.getHealth() - (1000 - shot.expectedDamage)) < 0.001F, + "Target damage is exactly the configured shot damage, or zero for rejected use"); + c.require(shot.weapon.getDamage() == (mode.equals("no-ammo") ? 0 : 1), + "Only a real shot debits one musket durability"); + c.require(shot.player.inventory.getStackInSlot(1).getCount() == shot.expectedAmmo, + "Firing does not charge ammunition a second time"); + c.require(shot.weapon.getOrCreateTag().getByte("loaded") == 0, + "The chamber is empty after firing or failed unladen use"); + c.require(!shot.player.isCreative(), "All ammunition and durability measurements use survival mode"); + })); + } + } + + private static float shotDamage() { + try { + float damage = Class.forName("zone.moddev.mc.steamadvantage.SteamAdvantage", false, + SteamOperationalActors.class.getClassLoader()).getDeclaredField("MUSKET_DAMAGE").getFloat(null); + if (!Float.isFinite(damage) || damage <= 0 || damage >= 1000) throw new IllegalStateException("Unsupported configured musket damage"); + return damage; + } catch (ReflectiveOperationException error) { + throw new IllegalStateException("Native musket damage contract unavailable", error); + } + } + + private static void enchant(MachineContext c, ItemStack weapon, String id) { + Enchantment enchantment = ForgeRegistries.ENCHANTMENTS.getValue(new ResourceLocation(id)); + c.require(enchantment != null, "Registered enchantment " + id); + weapon.addEnchantment(enchantment, 1); + } + + private static void shootingRoom(MachineContext c) { + for (int x = 6; x <= 10; x++) for (int y = 0; y <= 4; y++) for (int z = 6; z <= 16; z++) { + if (x == 6 || x == 10 || y == 4 || z == 6 || z == 16) c.block(x, y, z, "minecraft:stone"); + } + } +} diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/SteamOperationalCases.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/SteamOperationalCases.java new file mode 100644 index 0000000..a682a49 --- /dev/null +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/SteamOperationalCases.java @@ -0,0 +1,354 @@ +package zone.moddev.mc.advantageworks; + +import cyano.poweradvantage.api.fluid.FluidStack; +import cyano.poweradvantage.api.fluid.FluidTank; +import java.util.List; +import java.util.function.Consumer; +import net.minecraft.fluid.Fluid; +import net.minecraft.item.ItemStack; +import net.minecraft.util.EnumFacing; +import net.minecraft.util.ResourceLocation; +import net.minecraftforge.registries.ForgeRegistries; + +/** Independent finite inputs, ordinary world ticks, and material/energy outcomes. */ +final class SteamOperationalCases { + private static final int X = 8, Y = 4, Z = 8; + private static final Class[] NO_PARAMETERS = new Class[0]; + + private SteamOperationalCases() {} + + static void register(List cases) { + boilers(cases); + furnace(cases); + crusherAndStorage(cases); + still(cases); + pump(cases); + SteamOperationalActors.register(cases); + SteamOperationalSupplementalCases.register(cases); + SteamReloadControlCases.register(cases); + } + + private static void add(List cases, String id, String contract, int ticks, + Consumer setup, Consumer verify) { + cases.add(new MachineScenario("steam/" + id, contract, ticks, setup, verify)); + } + + private static void machine(MachineContext c, String id) { + c.block(X, Y, Z, "steamadvantage:" + id); + } + + private static void supply(MachineContext c, float amount) { + c.steamSupply(X - 1, Y, Z, amount); + } + + private static float steam(MachineContext c) { return c.energy(X, Y, Z, "steam"); } + + private static float remainingSteam(MachineContext c) { + return steam(c) + c.energy(X - 1, Y, Z, "steam"); + } + + private static FluidTank tank(MachineContext c, String getter) { + return (FluidTank) c.invoke(X, Y, Z, getter, NO_PARAMETERS); + } + + private static int amount(MachineContext c, String getter) { + int value = tank(c, getter).getFluidAmount(); + c.record(getter + "/amount", value); + return value; + } + + // Exercise the actual 1.13 sided fluid API, not direct reserve seeding. + private static void fill(MachineContext c, String id, int amount) { + Fluid fluid = net.minecraft.util.registry.IRegistry.field_212619_h.func_212608_b(new ResourceLocation(id)); + c.require(fluid != null, "Registered input fluid " + id); + NativeFluidPort handler = new NativeFluidPort(c.tile(X, Y, Z), EnumFacing.UP); + int accepted = handler.fill(new cyano.poweradvantage.api.fluid.FluidStack(fluid, amount), true); + c.record("capability/input/" + id, accepted); + c.require(accepted == amount, "UP fluid capability accepts exactly " + amount + " mB of " + id); + c.tile(X, Y, Z).markDirty(); + } + + private static void reserve(MachineContext c, String key, String fluid, int amount) { + String getter; + if (key.equals("TankIn")) getter = "getInputTank"; + else if (key.equals("TankOut")) getter = "getOutputTank"; + else throw new IllegalArgumentException("Unknown Steam reserve tank " + key); + FluidTank tank = tank(c, getter); + Fluid type = net.minecraft.util.registry.IRegistry.field_212619_h.func_212608_b(new ResourceLocation(fluid)); + c.require(type != null && amount > 0 && amount <= tank.getCapacity(), "Valid finite setup reserve for " + key); + // Replace the complete tank value: recursively merged NBT retains an old Empty marker. + tank.setFluid(new FluidStack(type, amount)); + c.tile(X, Y, Z).markDirty(); + c.record("setup/reserve/" + key, fluid + ":" + amount); + fluid(c, getter, fluid, amount); + c.require(!c.nbt(X, Y, Z).getCompound(key).keySet().contains("Empty"), "Finite reserve is serialized as nonempty before ticking"); + } + + private static void slot(MachineContext c, int index, String item, int count) { + ItemStack stack = c.inventory(X, Y, Z).getStackInSlot(index); + String actual = stack.isEmpty() ? "empty" : String.valueOf(stack.getItem().getRegistryName()); + c.record("slot/" + index, actual + ":" + stack.getCount()); + c.require(count == 0 ? stack.isEmpty() + : actual.equals(item) && stack.getCount() == count, + "Slot " + index + " is " + (count == 0 ? "empty" : count + " " + item)); + } + + private static void fluid(MachineContext c, String getter, String expected, int count) { + FluidTank tank = tank(c, getter); + String id = tank.getFluidAmount() == 0 ? "empty" + : String.valueOf(net.minecraft.util.registry.IRegistry.field_212619_h.getKey(tank.getFluid().getFluid())); + c.record(getter + "/fluid", id); + c.record(getter + "/amount", tank.getFluidAmount()); + c.require(tank.getFluidAmount() == count && (count == 0 || expected.equals(id)), + getter + " contains exactly " + count + " mB " + expected); + } + + private static void boilers(List cases) { + add(cases, "coal-production", "One coal and 64 mB water produce exactly 64 steam; both resources debit.", 160, + c -> { machine(c, "steam_boiler_coal"); fill(c, "minecraft:water", 64); + c.put(X, Y, Z, 0, "minecraft:coal", 1); }, + c -> { slot(c, 0, "minecraft:coal", 0); fluid(c, "getTank", "minecraft:water", 0); + c.require(steam(c) == 64, "64 water becomes exactly 64 steam during the finite coal burn"); }); + add(cases, "coal-invalid-fuel", "Stone cannot burn or consume water.", 120, + c -> { machine(c, "steam_boiler_coal"); fill(c, "minecraft:water", 64); + c.put(X, Y, Z, 0, "minecraft:stone", 1); }, + c -> { slot(c, 0, "minecraft:stone", 1); fluid(c, "getTank", "minecraft:water", 64); + c.require(steam(c) == 0, "Invalid fuel produces no steam"); }); + add(cases, "coal-dry", "A dry boiler does not consume its coal or create steam.", 120, + c -> { machine(c, "steam_boiler_coal"); c.put(X, Y, Z, 0, "minecraft:coal", 1); }, + c -> { slot(c, 0, "minecraft:coal", 1); c.require(steam(c) == 0, "No water means no steam"); }); + add(cases, "coal-redstone", "Redstone prevents a cold coal boiler igniting.", 120, + c -> { machine(c, "steam_boiler_coal"); fill(c, "minecraft:water", 64); + c.put(X, Y, Z, 0, "minecraft:coal", 1); c.block(X + 1, Y, Z, "minecraft:redstone_block"); }, + c -> { slot(c, 0, "minecraft:coal", 1); fluid(c, "getTank", "minecraft:water", 64); + c.require(steam(c) == 0, "Redstone inhibits ignition"); }); + + add(cases, "oil-production", "A finite 100 mB crude-oil charge converts 64 water to 80 steam at the historical 25-percent bonus.", 160, + c -> { machine(c, "steam_boiler_oil"); fill(c, "minecraft:water", 64); + fill(c, "poweradvantage:crude_oil", 100); }, + c -> { fluid(c, "getWaterTank", "minecraft:water", 0); + fluid(c, "getFuelTank", "poweradvantage:crude_oil", 0); + c.require(steam(c) == 80, "64 debited water produces exactly 80 steam at the oil boiler's 1.25 ratio"); }); + add(cases, "oil-water-not-fuel", "Water is not liquid fuel and cannot burn itself.", 120, + c -> { machine(c, "steam_boiler_oil"); fill(c, "minecraft:water", 64); }, + c -> { fluid(c, "getWaterTank", "minecraft:water", 64); + fluid(c, "getFuelTank", "poweradvantage:crude_oil", 0); + c.require(steam(c) == 0, "Nonflammable water does not produce steam without fuel"); }); + add(cases, "oil-dry", "Liquid fuel is preserved when no water is available.", 120, + c -> { machine(c, "steam_boiler_oil"); fill(c, "poweradvantage:crude_oil", 100); }, + c -> { fluid(c, "getFuelTank", "poweradvantage:crude_oil", 100); + c.require(steam(c) == 0, "Dry oil boiler does not ignite"); }); + add(cases, "oil-small-charge", "A 1 mB fuel aliquot can boil a finite three-water charge without inventing water.", 100, + c -> { machine(c, "steam_boiler_oil"); fill(c, "minecraft:water", 3); + fill(c, "poweradvantage:crude_oil", 1); }, + c -> { fluid(c, "getFuelTank", "poweradvantage:crude_oil", 0); + fluid(c, "getWaterTank", "minecraft:water", 0); + float value = steam(c); c.require(value >= 0 && value <= 3.75F, + "Three water cannot create more than 3.75 steam at the historical oil bonus (including idle loss)"); + c.require(c.nbt(X, Y, Z).getInt("BurnTime") == 0, "Small fuel aliquot burns out naturally"); }); + + add(cases, "geothermal-production", "A contained lava heat source boils finite water from a cold start.", 240, + c -> { machine(c, "steam_boiler_geothermal"); heat(c); fill(c, "minecraft:water", 256); }, + c -> { int left = amount(c, "getTank"); float output = steam(c); + c.require(left < 256 && output > 0 && output <= 256 - left, + "Geothermal output is positive and bounded by debited water"); + c.require(c.world.getFluidState(c.at(X, Y - 1, Z)).isSource(), + "Geothermal heat source is retained, not treated as consumable fuel"); }); + add(cases, "geothermal-cold", "Water without environmental heat cannot produce steam.", 240, + c -> { machine(c, "steam_boiler_geothermal"); fill(c, "minecraft:water", 16); }, + c -> { fluid(c, "getTank", "minecraft:water", 16); + c.require(steam(c) == 0, "Cold geothermal boiler produces nothing"); }); + add(cases, "geothermal-dry", "Environmental heat cannot create steam without water.", 240, + c -> { machine(c, "steam_boiler_geothermal"); heat(c); }, + c -> { c.require(amount(c, "getTank") == 0 && steam(c) == 0, "Hot dry boiler produces no steam"); }); + + add(cases, "electric-production", "Finite battery power converts water at 16 electricity per mB and 0.5 steam per mB.", 160, + c -> { machine(c, "steam_boiler_electric"); fill(c, "minecraft:water", 64); + c.electricSupply(X - 1, Y, Z, 4096); }, + c -> { int water = amount(c, "getTank"); + float electric = c.energy(X - 1, Y, Z, "electricity") + c.energy(X, Y, Z, "electricity"); + float output = steam(c); c.record("electricity/debited", 4096 - electric); + c.require(water == 0 && output > 0 && output <= 32, "64 water produces at most 32 steam after idle loss"); + c.require(Math.abs(4096 - electric - 16 * (64 - water)) < 0.001F, + "Every water conversion debits exactly 16 electricity from the finite battery/machine total"); }); + add(cases, "electric-no-power", "Water cannot boil without electricity.", 120, + c -> { machine(c, "steam_boiler_electric"); fill(c, "minecraft:water", 64); }, + c -> { fluid(c, "getTank", "minecraft:water", 64); c.require(steam(c) == 0, "No electrical power gives no steam"); }); + add(cases, "electric-dry", "A connected charged battery is not consumed by a dry boiler.", 120, + c -> { machine(c, "steam_boiler_electric"); c.electricSupply(X - 1, Y, Z, 4096); }, + c -> { float left = c.energy(X - 1, Y, Z, "electricity") + c.energy(X, Y, Z, "electricity"); + c.require(left == 4096 && steam(c) == 0, "Dry boiler conserves all finite electricity"); }); + add(cases, "electric-insufficient", "Less than one 16-electricity conversion cannot debit water.", 100, + c -> { machine(c, "steam_boiler_electric"); fill(c, "minecraft:water", 8); + c.electricSupply(X - 1, Y, Z, 15); }, + c -> { fluid(c, "getTank", "minecraft:water", 8); + c.require(steam(c) == 0 && c.energy(X, Y, Z, "electricity") == 15, + "Sub-operation electrical charge is retained without partial production"); }); + } + + private static void heat(MachineContext c) { + for (int dx = -1; dx <= 1; dx++) for (int dz = -1; dz <= 1; dz++) { + c.block(X + dx, Y - 2, Z + dz, "minecraft:stone"); + if (dx != 0 || dz != 0) c.block(X + dx, Y - 1, Z + dz, "minecraft:stone"); + } + c.block(X, Y - 1, Z, "minecraft:lava"); + } + + private static void furnace(List cases) { + add(cases, "furnace-three-lanes", "All three cold-start smelting lanes finish independently using one coal and finite steam.", 900, + c -> { machine(c, "steam_furnace"); supply(c, 1200); c.put(X, Y, Z, 0, "minecraft:coal", 1); + c.put(X, Y, Z, 1, "minecraft:sand", 3); c.put(X, Y, Z, 2, "minecraft:cobblestone", 3); + c.put(X, Y, Z, 3, "minecraft:potato", 3); }, + c -> { for (int i = 0; i <= 3; i++) slot(c, i, "", 0); + slot(c, 4, "minecraft:glass", 3); slot(c, 5, "minecraft:stone", 3); + slot(c, 6, "minecraft:baked_potato", 3); + c.require(remainingSteam(c) < 1200 - 100, "Steam heating debits more than idle leakage"); }); + add(cases, "furnace-no-fuel", "Steam is a heat bonus, not a substitute for combustible furnace fuel.", 300, + c -> { machine(c, "steam_furnace"); supply(c, 600); c.put(X, Y, Z, 1, "minecraft:sand", 1); }, + c -> { slot(c, 1, "minecraft:sand", 1); slot(c, 4, "", 0); + c.require(remainingSteam(c) >= 600 - 2 * 300 * 0.0625F, "Unlit furnace uses no steam heating bonus"); }); + add(cases, "furnace-full-outputs", "Blocked lanes neither consume input nor start a coal burn.", 300, + c -> { machine(c, "steam_furnace"); supply(c, 600); c.put(X, Y, Z, 0, "minecraft:coal", 1); + for (int i = 1; i <= 3; i++) { c.put(X, Y, Z, i, "minecraft:sand", 1); + c.put(X, Y, Z, i + 3, "minecraft:glass", 64); } }, + c -> { slot(c, 0, "minecraft:coal", 1); for (int i = 1; i <= 3; i++) { + slot(c, i, "minecraft:sand", 1); slot(c, i + 3, "minecraft:glass", 64); } }); + add(cases, "furnace-invalid-input", "A nonsmeltable input cannot consume coal or produce output.", 300, + c -> { machine(c, "steam_furnace"); supply(c, 600); c.put(X, Y, Z, 0, "minecraft:coal", 1); + c.put(X, Y, Z, 1, "minecraft:diamond", 1); }, + c -> { slot(c, 0, "minecraft:coal", 1); slot(c, 1, "minecraft:diamond", 1); slot(c, 4, "", 0); }); + add(cases, "furnace-without-steam", "Coal alone remains a valid slower heat source in the historical furnace.", 1000, + c -> { machine(c, "steam_furnace"); c.put(X, Y, Z, 0, "minecraft:coal", 1); + c.put(X, Y, Z, 1, "minecraft:sand", 1); }, + c -> { slot(c, 0, "", 0); slot(c, 1, "", 0); slot(c, 4, "minecraft:glass", 1); + c.require(steam(c) == 0, "Unboosted operation does not invent steam"); }); + } + + private static void crusherAndStorage(List cases) { + add(cases, "crusher-and-tank-discharge", "A finite steam tank drives two complete 125-tick crushing operations.", 360, + c -> { machine(c, "steam_crusher"); supply(c, 1000); c.put(X, Y, Z, 0, "minecraft:stone", 2); }, + c -> { slot(c, 0, "", 0); c.require(c.count(X, Y, Z, "minecraft:cobblestone") == 2, + "Two stone become exactly two cobblestone"); + c.require(remainingSteam(c) <= 1000 - 2 * 125 * 1.5F, + "Tank discharge funds at least the exact 375-steam productive work"); }); + add(cases, "crusher-no-power", "A crushable input cannot progress without steam.", 300, + c -> { machine(c, "steam_crusher"); c.put(X, Y, Z, 0, "minecraft:stone", 1); }, + c -> { slot(c, 0, "minecraft:stone", 1); c.require(c.count(X, Y, Z, "minecraft:cobblestone") == 0, + "Unpowered crusher produces nothing"); }); + add(cases, "crusher-full-outputs", "All five full outputs preserve the input and avoid productive steam debit.", 300, + c -> { machine(c, "steam_crusher"); supply(c, 600); c.put(X, Y, Z, 0, "minecraft:stone", 1); + for (int i = 1; i <= 5; i++) c.put(X, Y, Z, i, "minecraft:cobblestone", 64); }, + c -> { slot(c, 0, "minecraft:stone", 1); + c.require(c.count(X, Y, Z, "minecraft:cobblestone") == 320, "Full output stacks remain unchanged"); + c.require(remainingSteam(c) >= 600 - 2 * 300 * 0.0625F, "Blocked crusher loses only idle steam"); }); + add(cases, "crusher-invalid-input", "An invalid recipe does not consume input or productive steam.", 300, + c -> { machine(c, "steam_crusher"); supply(c, 600); c.put(X, Y, Z, 0, "minecraft:bedrock", 1); + c.require(!c.inventory(X, Y, Z).isItemValidForSlot(0, c.inventory(X, Y, Z).getStackInSlot(0)), + "Active recipe registry confirms bedrock is not a crushable input"); }, + c -> { slot(c, 0, "minecraft:bedrock", 1); for (int i = 1; i <= 5; i++) slot(c, i, "", 0); + c.require(remainingSteam(c) >= 600 - 2 * 300 * 0.0625F, "Invalid recipe spends no productive steam"); }); + add(cases, "tank-redstone-stop", "A redstone-disabled tank cannot discharge into a connected working consumer.", 300, + c -> { machine(c, "steam_crusher"); supply(c, 600); + c.block(X - 2, Y, Z, "minecraft:redstone_block"); c.put(X, Y, Z, 0, "minecraft:stone", 1); }, + c -> { slot(c, 0, "minecraft:stone", 1); slot(c, 1, "", 0); + c.require(steam(c) == 0 && c.energy(X - 1, Y, Z, "steam") >= 600 - 300 * 0.0625F, + "Disabled tank retains its reserve apart from specified idle leakage"); }); + add(cases, "tank-backup-no-cycling", "Backup-priority storage does not discharge pointlessly into other storage.", 160, + c -> { machine(c, "steam_tank"); supply(c, 600); }, + c -> { c.require(steam(c) == 0, "Empty backup storage receives no storage-to-storage transfer"); + c.require(c.energy(X - 1, Y, Z, "steam") == 590, "Source storage loses exactly 160 idle ticks, not a transfer"); }); + } + + private static void still(List cases) { + add(cases, "still-production", "Steam distills finite crude oil at the historical 2:1 ratio.", 180, + c -> { machine(c, "steam_still"); supply(c, 500); fill(c, "poweradvantage:crude_oil", 200); }, + c -> { fluid(c, "getInputTank", "poweradvantage:crude_oil", 0); + fluid(c, "getOutputTank", "poweradvantage:refined_oil", 100); + c.require(remainingSteam(c) <= 500 - 50 * 1.5F, "50 two-application distillations debit 75 steam"); }); + add(cases, "still-no-power", "Valid crude oil remains untouched without steam.", 160, + c -> { machine(c, "steam_still"); fill(c, "poweradvantage:crude_oil", 200); }, + c -> { fluid(c, "getInputTank", "poweradvantage:crude_oil", 200); + fluid(c, "getOutputTank", "poweradvantage:refined_oil", 0); }); + add(cases, "still-full-output", "A full output cannot destroy crude oil or spend productive steam.", 160, + c -> { machine(c, "steam_still"); supply(c, 500); fill(c, "poweradvantage:crude_oil", 200); + reserve(c, "TankOut", "poweradvantage:refined_oil", 1000); }, + c -> { fluid(c, "getInputTank", "poweradvantage:crude_oil", 200); + fluid(c, "getOutputTank", "poweradvantage:refined_oil", 1000); + c.require(remainingSteam(c) >= 500 - 2 * 160 * 0.0625F, "Full still spends no productive steam"); }); + add(cases, "still-one-output-space", "The final single output space accepts one recipe, never discarding a second recipe.", 160, + c -> { machine(c, "steam_still"); supply(c, 500); fill(c, "poweradvantage:crude_oil", 200); + reserve(c, "TankOut", "poweradvantage:refined_oil", 999); }, + c -> { fluid(c, "getInputTank", "poweradvantage:crude_oil", 198); + fluid(c, "getOutputTank", "poweradvantage:refined_oil", 1000); + c.require(remainingSteam(c) <= 498.5F, "The single accepted operation debits steam"); }); + add(cases, "still-incomplete-input", "An incomplete 2-mB recipe cannot turn one mB into product.", 160, + c -> { machine(c, "steam_still"); supply(c, 500); + reserve(c, "TankIn", "poweradvantage:crude_oil", 1); }, + c -> { fluid(c, "getInputTank", "poweradvantage:crude_oil", 1); + fluid(c, "getOutputTank", "poweradvantage:refined_oil", 0); }); + add(cases, "still-invalid-fluid", "A real capability rejects water as still feedstock.", 120, + c -> { machine(c, "steam_still"); supply(c, 500); + NativeFluidPort handler = new NativeFluidPort(c.tile(X, Y, Z), EnumFacing.UP); + int accepted = handler.fill(new cyano.poweradvantage.api.fluid.FluidStack(net.minecraft.init.Fluids.WATER, 100), true); + c.record("capability/rejected-water", accepted); + c.require(accepted == 0, "Water is rejected by the still input capability"); }, + c -> { fluid(c, "getInputTank", "minecraft:water", 0); + fluid(c, "getOutputTank", "poweradvantage:refined_oil", 0); }); + } + + private static void basin(MachineContext c) { + // Lava's nine sources are finite; a 3x3 water pool would regenerate sources. + for (int x = 6; x <= 10; x++) for (int z = 6; z <= 10; z++) { + if (x == 6 || x == 10 || z == 6 || z == 10) c.block(x, 0, z, "minecraft:stone"); + else c.block(x, 0, z, "minecraft:lava"); + } + c.require(sources(c) == 9, "Contained 3x3 intake begins with nine finite source blocks"); + } + + private static int sources(MachineContext c) { + int count = 0; + for (int x = 7; x <= 9; x++) for (int z = 7; z <= 9; z++) { + if (c.world.getFluidState(c.at(x, 0, z)).isSource()) count++; + } + c.record("intake/source-blocks", count); + return count; + } + + private static boolean blockIs(MachineContext c, int x, int y, int z, String id) { + String actual = String.valueOf(c.world.getBlockState(c.at(x, y, z)).getBlock().getRegistryName()); + c.record("block/" + x + "/" + y + "/" + z, actual); + return actual.equals(id); + } + + private static void pump(List cases) { + add(cases, "pump-3x3-intake-lift", "The pump builds its lift, removes finite real sources and delivers matching fluid at elevation.", 320, + c -> { machine(c, "steam_pump"); basin(c); supply(c, 600); + c.block(X + 1, Y, Z, "poweradvantage:fluid_storage_tank"); }, + c -> { int removed = 9 - sources(c); int stored = c.fluidAmount(X + 1, Y, Z); + int internal = amount(c, "getTank"); + c.require(removed > 0 && stored >= 1000, "Real intake removal delivers at least a bucket four blocks above the intake"); + c.require(stored + internal == removed * 1000, "Every removed source is conserved between receiver and pump"); + for (int y = 1; y <= 3; y++) c.require(blockIs(c, X, y, Z, "steamadvantage:pump_pipe_steam"), + "Natural pump ticks build lift segment " + y); + c.require(remainingSteam(c) <= 600 - 3 * 5 - removed * (32 + 4), + "Finite steam debits pipe placement and four-block lifting for every removed bucket"); }); + add(cases, "pump-no-power", "A real 3x3 intake remains untouched without steam, including lift construction.", 180, + c -> { machine(c, "steam_pump"); basin(c); + c.block(X + 1, Y, Z, "poweradvantage:fluid_storage_tank"); }, + c -> { c.require(sources(c) == 9 && amount(c, "getTank") == 0 && c.fluidAmount(X + 1, Y, Z) == 0, + "No power means no source removal or fluid output"); + c.require(blockIs(c, X, Y - 1, Z, "minecraft:air"), "No steam means no lift construction"); }); + add(cases, "pump-insufficient-steam", "A sub-five-steam reserve cannot construct a lift or remove liquid.", 120, + c -> { machine(c, "steam_pump"); basin(c); c.power(X, Y, Z, "steam", 4); }, + c -> { c.require(sources(c) == 9 && amount(c, "getTank") == 0, "Scarce steam does not pump any liquid"); + c.require(blockIs(c, X, Y - 1, Z, "minecraft:air"), "Insufficient steam cannot place the first pipe"); }); + add(cases, "pump-full-output", "A full connected receiver cannot delete pump fluid or debit a new source.", 160, + c -> { machine(c, "steam_pump"); basin(c); supply(c, 600); fill(c, "minecraft:lava", 1000); + c.block(X + 1, Y, Z, "poweradvantage:fluid_storage_tank"); + c.tank(X + 1, Y, Z, "minecraft:lava", 4000); }, + c -> { fluid(c, "getTank", "minecraft:lava", 1000); + c.require(c.fluidAmount(X + 1, Y, Z) == 4000 && sources(c) == 9, + "Full-output backpressure preserves receiver, pump and all nine intake sources"); + c.require(blockIs(c, X, Y - 1, Z, "minecraft:air"), "Full internal tank also prevents lift work"); }); + } +} diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/SteamOperationalSupplementalCases.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/SteamOperationalSupplementalCases.java new file mode 100644 index 0000000..a5af2a3 --- /dev/null +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/SteamOperationalSupplementalCases.java @@ -0,0 +1,260 @@ +package zone.moddev.mc.advantageworks; + +import com.mojang.authlib.GameProfile; +import cyano.poweradvantage.api.ConduitType; +import cyano.poweradvantage.api.PowerRequest; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.UUID; +import net.minecraft.enchantment.Enchantment; +import net.minecraft.entity.EntityType; +import net.minecraft.entity.SharedMonsterAttributes; +import net.minecraft.entity.passive.EntityCow; +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; +import net.minecraft.network.Packet; +import net.minecraft.network.NetworkManager; +import net.minecraft.network.EnumPacketDirection; +import net.minecraft.network.NetHandlerPlayServer; +import net.minecraft.network.play.server.SPacketEntityVelocity; +import net.minecraft.util.EnumActionResult; +import net.minecraft.util.EnumHand; +import net.minecraft.util.ResourceLocation; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.Vec3d; +import net.minecraft.world.GameType; +import net.minecraftforge.common.util.FakePlayer; +import net.minecraftforge.registries.ForgeRegistries; + +/** Eight supplemental cases included by the Steam operating catalog. */ +final class SteamOperationalSupplementalCases { + private SteamOperationalSupplementalCases() {} + + static void register(List cases) { + priority(cases); + for (int level : new int[]{0, 1, 5}) recoil(cases, level); + for (int level : new int[]{0, 1}) explosive(cases, level); + occlusion(cases); + } + + private static void priority(List cases) { + for (boolean consumerFull : new boolean[]{false, true}) { + cases.add(new MachineScenario("steam/extra-priority-" + (consumerFull ? "backup-eligible" : "consumer-first"), + "A finite source fills a normal consumer before backup storage. Disabling backup discharge " + + "prevents later relay masking the original allocation; a full-consumer control proves backup connectivity.", + 80, c -> { + c.block(8, 4, 8, "steamadvantage:steam_boiler_coal"); + c.block(9, 4, 8, "steamadvantage:steam_furnace"); + c.block(7, 4, 8, "steamadvantage:steam_pipe"); + c.block(6, 4, 8, "steamadvantage:steam_pipe"); + c.block(5, 4, 8, "steamadvantage:steam_tank"); + c.block(4, 4, 8, "minecraft:redstone_block"); + c.power(8, 4, 8, "steam", 40); + if (consumerFull) c.power(9, 4, 8, "steam", 200); + PowerRequest backup = (PowerRequest) c.invoke(5, 4, 8, "getPowerRequest", + new Class[]{ConduitType.class}, new ConduitType("steam")); + c.record("backup/requestPriority", backup.priority); + c.record("backup/requestAmount", backup.amount); + c.require(backup.amount == 10000, "Disabled discharge does not make empty backup ineligible to receive"); + c.require(c.world.isBlockPowered(c.at(5, 4, 8)), "Only backup discharge is redstone-inhibited"); + c.require(!c.world.isBlockPowered(c.at(8, 4, 8)) + && !c.world.isBlockPowered(c.at(9, 4, 8)), "Source and consumer are not redstone-inhibited"); + }, c -> { + float source = c.energy(8, 4, 8, "steam"); + float consumer = c.energy(9, 4, 8, "steam"); + float backup = c.energy(5, 4, 8, "steam"); + c.record("allocation/totalRemaining", source + consumer + backup); + c.require(source == 0, "Finite source actually discharges through the live network"); + if (consumerFull) { + c.require(backup > 30 && backup <= 40, "Connected backup receives genuine surplus from the finite source"); + c.require(consumer > 190 && consumer <= 200, "Full-consumer control remains near capacity, without fabricated steam"); + } else { + c.require(consumer > 30 && consumer <= 40, "Scarce finite steam reaches the normal consumer"); + c.require(backup == 0, "Larger backup request never preempts the higher-priority consumer"); + } + c.require(source + consumer + backup <= (consumerFull ? 240 : 40), + "Priority allocation conserves finite energy apart from idle leakage"); + })); + } + } + + private static final class Shot { + FakePlayer player; + EntityCow impact, collateral; + ItemStack weapon; + float configuredDamage; + int velocityPackets, immediateCollateralFire; + float immediateCollateralHealth; + } + + private static void recoil(List cases, int level) { + Shot shot = new Shot(); + cases.add(new MachineScenario("steam/extra-recoil-" + level, + "A real musket shot applies the declared horizontal recoil impulse and sends one native velocity packet " + + "only when enchanted. This measures impulse/packet delivery, not a manually ticked fake player's travel.", + 40, c -> { + prepareShot(c, shot, "recoil-" + level, false); + if (level > 0) enchant(c, shot.weapon, "steamadvantage:recoil", level); + fire(c, shot); + }, c -> { + verifyShot(c, shot, false, false); + Vec3d motion = new Vec3d(shot.player.motionX, shot.player.motionY, shot.player.motionZ); + c.record("recoil/deltaX", motion.x); c.record("recoil/deltaY", motion.y); c.record("recoil/deltaZ", motion.z); + c.record("recoil/velocityPackets", shot.velocityPackets); + c.require(Math.abs(motion.x) < 0.00001 && Math.abs(motion.y) < 0.00001 + && Math.abs(motion.z + 0.6 * level) < 0.00001, + "South-facing shot applies exactly -0.6 times recoil level on Z, without lateral or vertical drift"); + c.require(shot.velocityPackets == (level == 0 ? 0 : 1), + "Native recoil velocity packet count agrees with the enchantment"); + })); + } + + private static void explosive(List cases, int level) { + Shot shot = new Shot(); + cases.add(new MachineScenario("steam/extra-explosive-" + level, + "High Explosive must damage and ignite a real off-ray collateral target, with a nonexplosive control. " + + "Finite targets and the level-one blast remain inside the bay; no TNT or flammable terrain is seeded.", + 40, c -> { + prepareShot(c, shot, "explosive-" + level, true); + if (level > 0) enchant(c, shot.weapon, "steamadvantage:high_explosive", level); + fire(c, shot); + shot.immediateCollateralFire = savedFireTicks(shot.collateral); + shot.immediateCollateralHealth = shot.collateral.getHealth(); + c.record("collateral/immediateFireTicks", shot.immediateCollateralFire); + c.record("collateral/immediateHealth", shot.immediateCollateralHealth); + }, c -> { + verifyShot(c, shot, level > 0, false); + c.record("collateral/finalHealth", shot.collateral.getHealth()); + c.record("collateral/naturalTicks", shot.collateral.ticksExisted); + c.require(shot.collateral.isAlive() && shot.collateral.ticksExisted >= 35, + "Collateral target receives real natural server ticks"); + if (level > 0) { + c.require(shot.immediateCollateralHealth < 1000 && shot.collateral.getHealth() < 1000, + "Off-ray target takes real explosive damage, not merely an enchantment NBT marker"); + c.require(shot.immediateCollateralFire > 0, "Explosion also ignites the off-ray target"); + } else { + c.require(shot.immediateCollateralHealth == 1000 && shot.collateral.getHealth() == 1000, + "An ordinary shot cannot hurt the off-ray target"); + c.require(shot.immediateCollateralFire <= 0, "An ordinary shot cannot ignite collateral"); + } + inside(c, shot.collateral); + })); + } + + private static void occlusion(List cases) { + Shot shot = new Shot(); + cases.add(new MachineScenario("steam/extra-musket-solid-wall", + "A real solid wall before an aligned living target must stop the shot, while the round and durability " + + "are still spent. Do not accept target damage through the wall as historical intent.", + 40, c -> { + prepareShot(c, shot, "solid-wall", false); + for (int x = 15; x <= 17; x++) for (int y = 8; y <= 11; y++) c.block(x, y, 13, "minecraft:stone"); + fire(c, shot); + }, c -> { + verifyShot(c, shot, false, true); + c.require(c.world.getBlockState(c.at(16, 9, 13)).getBlock() == net.minecraft.init.Blocks.STONE, + "Nonexplosive shot preserves the real stone obstruction"); + })); + } + + private static void prepareShot(MachineContext c, Shot shot, String id, boolean collateral) { + BlockPos muzzle = c.at(16, 8, 10); + UUID uuid = UUID.nameUUIDFromBytes(("SteamOperationalExtra/" + id + "/" + muzzle).getBytes(StandardCharsets.UTF_8)); + shot.player = new FakePlayer(c.world, new GameProfile(uuid, "SteamOpExtras")); + shot.player.connection = new NetHandlerPlayServer(c.world.getServer(), new NetworkManager(EnumPacketDirection.SERVERBOUND), shot.player) { + @Override public void sendPacket(Packet packet) { + if (packet instanceof SPacketEntityVelocity) shot.velocityPackets++; + } + }; + shot.player.interactionManager.setGameType(GameType.SURVIVAL); + shot.player.setPositionAndRotation(muzzle.getX() + 0.5, muzzle.getY(), muzzle.getZ() + 0.5, 0, 0); + shot.player.motionX = shot.player.motionY = shot.player.motionZ = 0; + shot.weapon = new ItemStack(item(c, "steamadvantage:musket")); + shot.player.setHeldItem(EnumHand.MAIN_HAND, shot.weapon); + shot.player.inventory.setInventorySlotContents(1, new ItemStack(item(c, "steamadvantage:blackpowder_cartridge"), 2)); + enchant(c, shot.weapon, "steamadvantage:rapid_reload", 1); + try { + shot.configuredDamage = Class.forName("zone.moddev.mc.steamadvantage.SteamAdvantage", false, + SteamOperationalSupplementalCases.class.getClassLoader()).getDeclaredField("MUSKET_DAMAGE").getFloat(null); + } catch (ReflectiveOperationException error) { + throw new IllegalStateException("Musket configuration contract is unavailable", error); + } + c.require(Float.isFinite(shot.configuredDamage) && shot.configuredDamage > 0 && shot.configuredDamage < 900, + "Configured damage permits a surviving target for precise native measurements"); + shot.impact = target(c, 16.5, 8.5, 16.5); + c.record("impact/uuid", shot.impact.getUniqueID().toString()); + if (collateral) { + shot.collateral = target(c, 17.1, 8.5, 16.5); + c.record("collateral/uuid", shot.collateral.getUniqueID().toString()); + } + // Even a missed shot has an in-bay, nonflammable physical backstop. + for (int y = 8; y <= 11; y++) c.block(16, y, 24, "minecraft:obsidian"); + } + + private static EntityCow target(MachineContext c, double x, double y, double z) { + EntityCow target = EntityType.COW.create(c.world); + c.require(target != null, "Native living target is available"); + BlockPos origin = c.at(0, 0, 0); + target.getAttribute(SharedMonsterAttributes.MAX_HEALTH).setBaseValue(1000); + target.setHealth(1000); + target.setPositionAndRotation(origin.getX() + x, origin.getY() + y, origin.getZ() + z, 0, 0); + target.setNoAI(true); target.setNoGravity(true); target.enablePersistence(); + c.require(c.world.spawnEntity(target), "Target enters the actual server world"); + return target; + } + + private static void fire(MachineContext c, Shot shot) { + Item weapon = shot.weapon.getItem(); + c.require(weapon.onItemRightClick(c.world, shot.player, EnumHand.MAIN_HAND).getType() == EnumActionResult.SUCCESS, + "Native rapid reload interaction succeeds"); + c.require(shot.player.inventory.getStackInSlot(1).getCount() == 1 + && shot.weapon.getOrCreateTag().getByte("loaded") == 1, "Exactly one finite cartridge loads the chamber"); + c.require(weapon.onItemRightClick(c.world, shot.player, EnumHand.MAIN_HAND).getType() == EnumActionResult.SUCCESS + && shot.player.isHandActive(), "Native use interaction enters the aiming lifecycle"); + shot.player.stopActiveHand(); + } + + private static void verifyShot(MachineContext c, Shot shot, boolean explosive, boolean occluded) { + c.record("impact/finalHealth", shot.impact.getHealth()); + c.record("impact/naturalTicks", shot.impact.ticksExisted); + c.record("musket/ammo", shot.player.inventory.getStackInSlot(1).getCount()); + c.record("musket/durabilityDebit", shot.weapon.getDamage()); + c.require(shot.impact.isAlive() && shot.impact.ticksExisted >= 35, "Impact target survives real natural server ticks"); + if (occluded) c.require(shot.impact.getHealth() == 1000, "Solid wall completely prevents target damage"); + else if (explosive) c.require(shot.impact.getHealth() <= 1000 - shot.configuredDamage, + "Explosive shot delivers at least configured direct impact damage"); + else c.require(Math.abs(shot.impact.getHealth() - (1000 - shot.configuredDamage)) < 0.001F, + "Unenchanted damage is exactly the configured direct impact"); + c.require(shot.weapon.getDamage() == 1 && shot.player.inventory.getStackInSlot(1).getCount() == 1, + "Each shot consumes one durability and exactly one cartridge, without duplicating debit"); + c.require(shot.weapon.getOrCreateTag().getByte("loaded") == 0, "Shot leaves an empty chamber"); + inside(c, shot.impact); + } + + private static void inside(MachineContext c, EntityCow target) { + BlockPos min = c.at(0, 0, 0), max = c.at(31, 31, 31); + c.record("target/position/" + target.getUniqueID(), new double[]{target.posX, target.posY, target.posZ}); + c.require(target.posX >= min.getX() && target.posX < max.getX() + 1 + && target.posY >= min.getY() && target.posY < max.getY() + 1 + && target.posZ >= min.getZ() && target.posZ < max.getZ() + 1, + "Live target remains confined to its isolated bay"); + } + + private static int savedFireTicks(net.minecraft.entity.Entity entity) { + net.minecraft.nbt.NBTTagCompound saved = new net.minecraft.nbt.NBTTagCompound(); + entity.writeUnlessRemoved(saved); + return saved.getShort("Fire"); + } + + private static Item item(MachineContext c, String id) { + Item item = ForgeRegistries.ITEMS.getValue(new ResourceLocation(id)); + c.require(item != null && item != net.minecraft.init.Items.AIR, "Registered item " + id); + return item; + } + + private static void enchant(MachineContext c, ItemStack stack, String id, int level) { + Enchantment enchantment = ForgeRegistries.ENCHANTMENTS.getValue(new ResourceLocation(id)); + c.require(enchantment != null && level >= 1 && level <= enchantment.getMaxLevel(), "Registered supported enchantment " + id); + stack.addEnchantment(enchantment, level); + } +} diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/SteamReloadControlCases.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/SteamReloadControlCases.java new file mode 100644 index 0000000..9138de5 --- /dev/null +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/SteamReloadControlCases.java @@ -0,0 +1,139 @@ +package zone.moddev.mc.advantageworks; + +import java.util.List; +import net.minecraft.nbt.NBTTagCompound; + +/** One real loaded-machine tick, with exact work, control and resource observations. */ +final class SteamReloadControlCases { + private static final int X = 8, Y = 4, Z = 8; + private static final Class[] NONE = new Class[0]; + private SteamReloadControlCases() {} + + static void register(List cases) { + cases.add(new MachineScenario("steam/lifecycle/crusher-comparator-matrix", + "Actual empty, one-item, half-stack and full-stack crusher inputs produce comparator 0, 1, 8 and 15.", 1, + c -> { + int[] stock = {0, 1, 32, 64}; + for (int i = 0; i < stock.length; i++) { + int x = 3 + i * 6; + c.block(x, Y, Z, "steamadvantage:steam_crusher"); + c.put(x, Y, Z, 0, "minecraft:stone", stock[i]); + } + }, holding(new int[]{3, 9, 15, 21}, c -> { + int[] stock = {0, 1, 32, 64}, expected = {0, 1, 8, 15}, measured = new int[4]; + for (int i = 0; i < stock.length; i++) { + int x = 3 + i * 6; + c.require(c.count(x, Y, Z, "minecraft:stone") == stock[i], "Unpowered comparator stock remains exact: " + stock[i]); + measured[i] = ((Number)c.invoke(x, Y, Z, "getComparatorOutput", NONE)).intValue(); + c.require(measured[i] == expected[i], "Actual crusher comparator " + stock[i] + " items -> " + expected[i]); + } + c.record("comparator.inputs", stock); + c.record("comparator.actual", measured); + })).exclusive()); + crusher(cases, "unpowered", false, 40); + crusher(cases, "signalled", true, 40); + crusher(cases, "no-steam", false, 0); + furnace(cases, false); + furnace(cases, true); + } + + private static void crusher(List cases, String name, boolean signalled, float steam) { + cases.add(new MachineScenario("steam/lifecycle/crusher-first-loaded-" + name, + "One native tick observes live redstone before touching saved crusher progress; no reload grace or replacement.", 1, + c -> { + c.block(X, Y, Z, "steamadvantage:steam_crusher"); + if (signalled) c.block(X - 1, Y, Z, "minecraft:redstone_block"); + c.put(X, Y, Z, 0, "minecraft:stone", 1); + NBTTagCompound tag = new NBTTagCompound(); tag.setShort("progress", (short)106); c.seed(X, Y, Z, tag); + c.power(X, Y, Z, "steam", steam); + c.require(c.nbt(X, Y, Z).getShort("progress") == 106, "Loaded work is 106 before the first native tick"); + }, holding(new int[]{X}, c -> { + int expected = signalled || steam == 0 ? 105 : 107; + int actual = c.nbt(X, Y, Z).getShort("progress"); + float remaining = c.energy(X, Y, Z, "steam"); + float wanted = steam == 0 ? 0 : signalled ? 39.9375F : 38.4375F; + c.record("firstLoaded.progress", actual); + c.record("firstLoaded.steam", remaining); + c.record("firstLoaded.signalled", signalled); + c.require(actual == expected, "The first native tick gives exact progress " + expected); + c.require(remaining == wanted, "Only the intended first-tick work and steam leakage are charged"); + c.require(c.count(X, Y, Z, "minecraft:stone") == 1, "Incomplete work retains its one input"); + c.require(c.count(X, Y, Z, "minecraft:cobblestone") == 0, "One tick does not invent a complete output"); + })).exclusive()); + } + + private static void furnace(List cases, boolean signalled) { + cases.add(new MachineScenario("steam/lifecycle/furnace-first-loaded-" + (signalled ? "signalled" : "unpowered"), + "A burning, partly completed furnace uses the real control signal for its first steam heat bonus and work increment.", 1, + c -> { + c.block(X, Y, Z, "steamadvantage:steam_furnace"); + if (signalled) c.block(X - 1, Y, Z, "minecraft:redstone_block"); + c.put(X, Y, Z, 1, "minecraft:sand", 1); + NBTTagCompound tag = new NBTTagCompound(); + tag.setIntArray("Data", new int[]{Float.floatToRawIntBits(.25F), 0, 0, 10, 10, Float.floatToRawIntBits(1000)}); + c.seed(X, Y, Z, tag); c.power(X, Y, Z, "steam", 40); + c.require(c.nbt(X, Y, Z).getIntArray("Data")[3] == 10, "Loaded burning state exists before any tick"); + }, holding(new int[]{X}, c -> { + int[] data = c.nbt(X, Y, Z).getIntArray("Data"); + c.require(data.length == 6, "Furnace reports all native process fields"); + float heat = Float.intBitsToFloat(data[5]), progress = Float.intBitsToFloat(data[0]); + float expectedHeat = signalled ? 989.5F : 1027F; + float expectedProgress = .25F + 3.5E-6F * expectedHeat; + c.record("firstLoaded.heat", heat); c.record("firstLoaded.progress", progress); + c.record("firstLoaded.steam", c.energy(X, Y, Z, "steam")); + c.record("firstLoaded.signalled", signalled); + c.require(data[3] == 9 && data[4] == 10, "One real tick burns exactly one stored fuel tick"); + c.require(heat == expectedHeat, "First-tick heat uses 2.5 ordinary heat or 15 steam-assisted heat as appropriate"); + c.require(Math.abs(progress - expectedProgress) < .0000001F, "Saved quarter-cycle work receives the correct first-tick increment"); + c.require(c.energy(X, Y, Z, "steam") == (signalled ? 40 : 39.5F), "Only a permitted steam bonus costs its rated 0.5 steam"); + c.require(c.count(X, Y, Z, "minecraft:sand") == 1 && c.count(X, Y, Z, "minecraft:glass") == 0, + "One loaded tick retains the unfinished input and does not fabricate output"); + })).exclusive()); + } + private static java.util.function.Consumer holding(int[] columns, java.util.function.Consumer checks) { + return c -> { + Throwable problem = null; + try { + checks.accept(c); + } catch (RuntimeException | Error failure) { + problem = failure; + throw failure; + } finally { + try { + stopMeasuredTiles(c, columns); + } catch (RuntimeException | Error holdFailure) { + if (problem != null) problem.addSuppressed(holdFailure); + else throw holdFailure; + } + } + }; + } + + private static void stopMeasuredTiles(MachineContext c, int... columns) { + java.util.List> held = new java.util.ArrayList<>(); + for (int x : columns) { + net.minecraft.tileentity.TileEntity actual = c.tile(x, Y, Z); + Object before = c.nbt(x, Y, Z); + removeMeasuredTile(c.world.tickableTileEntities, actual); + c.require(!c.world.tickableTileEntities.contains(actual), "Measured control tile is no longer tickable"); + c.require(c.tile(x, Y, Z) == actual, "Holding preserves the same tile in the world and chunk indexes"); + c.require(before.equals(c.nbt(x, Y, Z)), "Holding writes no NBT or expected state"); + java.util.Map witness = new java.util.LinkedHashMap<>(); + witness.put("x", x); witness.put("y", Y); witness.put("z", Z); + witness.put("removed", true); witness.put("sameTile", true); witness.put("nbtUnchanged", true); + held.add(witness); + } + c.record("control.heldTiles", held); + c.record("control.heldAtMeasuredEnd", true); + } + + static void removeMeasuredTile(java.util.List tickables, T actual) { + int registrations = 0; + for (T entry : tickables) if (entry == actual) registrations++; + if (registrations != 1) throw new AssertionError("Expected one real measured tick registration, found " + registrations); + for (java.util.Iterator iterator = tickables.iterator(); iterator.hasNext();) { + if (iterator.next() == actual) { iterator.remove(); break; } + } + for (T entry : tickables) if (entry == actual) throw new AssertionError("Measured tile remained tickable"); + } +} diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/WorksController.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/WorksController.java index aa78b33..a7b20f6 100644 --- a/test-rig/src/main/java/zone/moddev/mc/advantageworks/WorksController.java +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/WorksController.java @@ -178,6 +178,7 @@ synchronized String check(MinecraftServer server, String target) throws CommandE } Map payload = new LinkedHashMap<>(); payload.put("summary", summary); + payload.put("evidenceScope", "declared_assertions_only; manual procedures are not automated passes"); payload.put("stationStatuses", new LinkedHashMap<>(statuses)); resultWriter.write("all", "summary", payload); return "Advantage Works check: " + summary; @@ -363,9 +364,6 @@ private String checkStation(WorldServer world, WorksDefinition.Station station) return "SKIP"; } List> assertionResults = new ArrayList<>(); - boolean failed = false; - boolean expectedFailure = false; - boolean unexpectedPass = false; for (WorksDefinition.Assertion assertion : station.assertions) { if (!requirementsPresent(assertion.requiredMods) || !optionalTargetPresent(assertion)) { Map result = new LinkedHashMap<>(); @@ -378,28 +376,16 @@ private String checkStation(WorldServer world, WorksDefinition.Station station) } boolean passed; String detail; + Exception evaluationError = null; try { passed = evaluateAssertion(world, station, assertion); detail = passed ? "matched" : "did not match"; } catch (Exception ex) { passed = false; + evaluationError = ex; detail = ex.getClass().getSimpleName() + ": " + ex.getMessage(); } - String assertionStatus; - if (assertion.expectedFailure != null && !assertion.expectedFailure.isEmpty()) { - if (passed) { - assertionStatus = "UNEXPECTED_PASS"; - unexpectedPass = true; - } else { - assertionStatus = "EXPECTED_FAIL"; - expectedFailure = true; - } - } else if (passed) { - assertionStatus = "PASS"; - } else { - assertionStatus = "FAIL"; - failed = true; - } + String assertionStatus = AssertionChecks.verdict(passed, assertion.expectedFailure, evaluationError); Map result = new LinkedHashMap<>(); result.put("id", assertion.id); result.put("type", assertion.type); @@ -409,12 +395,7 @@ private String checkStation(WorldServer world, WorksDefinition.Station station) assertionResults.add(result); } - String status; - if (failed) status = "FAIL"; - else if (unexpectedPass) status = "UNEXPECTED_PASS"; - else if (expectedFailure) status = "EXPECTED_FAIL"; - else if (!station.manualChecks.isEmpty()) status = "MANUAL_REQUIRED"; - else status = "PASS"; + String status = AssertionChecks.aggregate(assertionResults, !station.manualChecks.isEmpty()); statuses.put(station.id, status); setStatusBlock(world, station, statusColor(status)); writeStationResult(world, station, status, assertionResults); @@ -427,6 +408,8 @@ private void writeStationResult(WorldServer world, WorksDefinition.Station stati payload.put("name", station.name); payload.put("district", station.district); payload.put("status", status); + payload.put("evidenceScope", "declared_assertions_only; manual procedures are not automated passes"); + if ("SKIP".equals(status)) payload.put("skipReason", "required integration or all measurements unavailable"); payload.put("worldTime", world.getGameTime()); payload.put("assertions", assertions); payload.put("manualChecks", station.manualChecks); @@ -440,6 +423,7 @@ private List> snapshot(WorldServer world, WorksDefinition.St for (WorksDefinition.Placement placement : station.placements) positions.add(absolute(station, placement.pos)); for (WorksDefinition.Assertion assertion : station.assertions) { if (assertion.pos != null) positions.add(absolute(station, assertion.pos)); + for (int[] position : assertion.positions) positions.add(absolute(station, position)); } for (BlockPos pos : positions) { Map block = new LinkedHashMap<>(); @@ -457,11 +441,28 @@ private List> snapshot(WorldServer world, WorksDefinition.St private boolean evaluateAssertion(WorldServer world, WorksDefinition.Station station, WorksDefinition.Assertion assertion) { + if (assertion.block != null && block(assertion.block) == Blocks.AIR + && !"minecraft:air".equals(assertion.block)) { + throw new IllegalArgumentException("Unregistered assertion block " + assertion.block); + } + if (BayDrillAssertions.applies(station, assertion)) { + return BayDrillAssertions.matches(station, assertion, + relative -> world.getBlockState(absolute(station, relative)).getBlock() == block(assertion.block), + relative -> { + IBlockState state = world.getBlockState(absolute(station, relative)); + if (assertion.meta != null) { + return state.equals(legacyState(assertion.block, block(assertion.block), assertion.meta)); + } + return state.getValues().entrySet().stream().anyMatch(entry -> + assertion.property.equals(entry.getKey().getName()) + && assertion.expectedText.equals(String.valueOf(entry.getValue()))); + }); + } if ("registryBlock".equals(assertion.type)) { return block(assertion.block) != Blocks.AIR; } if ("registryItem".equals(assertion.type)) { - return item(assertion.item) != null; + return assertion.item != null && item(assertion.item) != null; } if ("modLoaded".equals(assertion.type)) { return ModList.get().isLoaded(assertion.item); @@ -471,11 +472,7 @@ private boolean evaluateAssertion(WorldServer world, WorksDefinition.Station sta for (int[] position : assertion.positions) { TileEntity source = world.getTileEntity(absolute(station, position)); if (source == null) return false; - NBTTagCompound tank = source.write(new NBTTagCompound()).getCompound(assertion.nbtPath); - if (tank.contains("Empty", 1)) continue; - int amount = tank.getInt("Amount"); - if (amount < 0) return false; - if (assertion.fluid == null || assertion.fluid.equals(tank.getString("FluidName"))) total += amount; + total += AssertionChecks.fluidAmount(source.write(new NBTTagCompound()), assertion.nbtPath, assertion.fluid); } return compare(total, assertion.operator, assertion.value); } @@ -544,6 +541,7 @@ && compare(source.getComparatorInputOverride(state, world, pos), private static int inventoryCount(IInventory inventory, String itemName) { Item expected = itemName == null ? null : item(itemName); + if (itemName != null && expected == null) throw new IllegalArgumentException("Unregistered assertion item " + itemName); int count = 0; for (int slot = 0; slot < inventory.getSizeInventory(); slot++) { ItemStack stack = inventory.getStackInSlot(slot); @@ -578,16 +576,7 @@ private static String stringAt(NBTTagCompound root, String path) { } private static boolean compare(double actual, String operator, Number expected) { - if (expected == null) return false; - double value = expected.doubleValue(); - switch (operator == null ? "equals" : operator) { - case "equals": return Math.abs(actual - value) < 0.0001; - case "min": return actual >= value; - case "max": return actual <= value; - case "greater": return actual > value; - case "less": return actual < value; - default: throw new IllegalArgumentException("Unsupported comparison " + operator); - } + return AssertionChecks.compare(actual, operator, expected); } private void resetStation(WorldServer world, WorksDefinition.Station station) { @@ -955,7 +944,8 @@ private static Item item(String name) { if (name == null) return null; String resolved = "minecraft:sapling".equals(name) ? "minecraft:oak_sapling" : "minecraft:planks".equals(name) ? "minecraft:oak_planks" : name; - return ForgeRegistries.ITEMS.getValue(new ResourceLocation(resolved)); + ResourceLocation key = new ResourceLocation(resolved); + return ForgeRegistries.ITEMS.containsKey(key) ? ForgeRegistries.ITEMS.getValue(key) : null; } private static CommandException commandError(String message) { diff --git a/test-rig/src/main/java/zone/moddev/mc/advantageworks/WorksDefinitionLoader.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/WorksDefinitionLoader.java index afb1225..5ebb197 100644 --- a/test-rig/src/main/java/zone/moddev/mc/advantageworks/WorksDefinitionLoader.java +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/WorksDefinitionLoader.java @@ -95,6 +95,9 @@ private static void validate(WorksDefinition definition) { } private static void validatePlacements(WorksDefinition.Station station) { + if (station.assertions == null || station.assertions.isEmpty()) { + throw new IllegalArgumentException("No measurements declared for " + station.id); + } for (WorksDefinition.Placement placement : station.placements) { validatePosition(station, placement.pos, "placement"); requireResource(placement.block, station.id + " placement block"); @@ -113,6 +116,17 @@ private static void validatePlacements(WorksDefinition.Station station) { throw new IllegalArgumentException("Unsupported assertion type " + assertion.type + " in " + station.id + "/" + assertion.id); } + if (!Arrays.asList("equals", "min", "max", "greater", "less").contains(assertion.operator) + || (assertion.value != null && !Double.isFinite(assertion.value))) { + throw new IllegalArgumentException("Invalid comparison for " + station.id + "/" + assertion.id); + } + if (!Arrays.asList("registryBlock", "registryItem", "modLoaded", "fluidAmount").contains(assertion.type) + && assertion.pos == null) { + throw new IllegalArgumentException("Missing measurement position for " + station.id + "/" + assertion.id); + } + if ("inventoryCount".equals(assertion.type) && (assertion.count == null || assertion.count < 0)) { + throw new IllegalArgumentException("Missing/invalid inventory count for " + station.id + "/" + assertion.id); + } if (assertion.pos != null) validatePosition(station, assertion.pos, "assertion"); if ("fluidAmount".equals(assertion.type)) { if (assertion.positions.isEmpty() || assertion.value == null) { @@ -142,6 +156,7 @@ private static void validatePlacements(WorksDefinition.Station station) { requireResource(assertion.item, station.id + "/" + assertion.id + " item"); } } + if ("registryItem".equals(assertion.type)) requireResource(assertion.item, "registry item"); if ("modLoaded".equals(assertion.type)) requireId(assertion.item, "mod ID"); if ("inventoryNbtNumber".equals(assertion.type)) { if (assertion.slot == null) throw new IllegalArgumentException("Missing inventory slot for " diff --git a/test-rig/src/runner/java/zone/moddev/mc/advantageworks/runner/WorksRunner.java b/test-rig/src/runner/java/zone/moddev/mc/advantageworks/runner/WorksRunner.java index 4fbd577..f7114cf 100644 --- a/test-rig/src/runner/java/zone/moddev/mc/advantageworks/runner/WorksRunner.java +++ b/test-rig/src/runner/java/zone/moddev/mc/advantageworks/runner/WorksRunner.java @@ -106,6 +106,7 @@ public static void main(String[] args) throws Exception { send(first, "advworks checkpoint " + station + " before-restart"); send(first, "advworks stop " + station); } + archiveStationResults(results, "first-run", lifecycleStations); checkStations(first, lifecycleStations, selectedLifecycle); } if (profile.worldgenSampleRadius > 0) { @@ -116,6 +117,7 @@ public static void main(String[] args) throws Exception { send(first, "stop"); requireExit(first, profile.shutdownTimeoutSeconds); requireNoCommandFailures(latestLog, "first-run"); + if (profile.buildWorks) requireAcceptableStations(results, lifecycleStations); copyIfPresent(latestLog, new File(results, "first-run.log")); File firstWorldgen = new File(results, "worldgen-first-run.json"); if (profile.worldgenSampleRadius > 0) { @@ -137,6 +139,7 @@ public static void main(String[] args) throws Exception { for (String station : lifecycleStations) { send(second, "advworks checkpoint " + station + " after-restart"); } + archiveStationResults(results, "restart-run", lifecycleStations); checkStations(second, lifecycleStations, selectedLifecycle); } if (profile.worldgenSampleRadius > 0) { @@ -162,11 +165,10 @@ public static void main(String[] args) throws Exception { if (profile.buildWorks) { if (!selectedLifecycle) { requireAcceptableSummary(new File(results, "all-summary.json")); - } else { - requireAcceptableStations(results, lifecycleStations); } + requireAcceptableStations(results, lifecycleStations); } - System.out.println("Automated Advantage Works run complete: " + results); + System.out.println("Automated assertion checks complete; manual procedures remain unverified: " + results); return; } @@ -818,12 +820,32 @@ private static void requireAcceptableSummary(File summary) throws Exception { try (InputStreamReader reader = new InputStreamReader( new FileInputStream(summary), StandardCharsets.UTF_8)) { JsonObject root = GSON.fromJson(reader, JsonObject.class); + if (root == null || !root.has("summary") || !root.get("summary").isJsonObject() + || !root.has("stationStatuses") || !root.get("stationStatuses").isJsonObject()) { + throw new IllegalStateException("Missing station summary or identities: " + summary); + } JsonObject counts = root.getAsJsonObject("summary"); - int failed = counts != null && counts.has("FAIL") ? counts.get("FAIL").getAsInt() : 0; - int unexpected = counts != null && counts.has("UNEXPECTED_PASS") - ? counts.get("UNEXPECTED_PASS").getAsInt() : 0; - if (failed > 0 || unexpected > 0) { - throw new IllegalStateException("Final Advantage Works result is not acceptable: " + counts); + JsonObject stations = root.getAsJsonObject("stationStatuses"); + Map observed = new LinkedHashMap<>(); + int completed = 0; + if (stations.entrySet().size() != DEFAULT_LIFECYCLE_STATIONS.size()) { + throw new IllegalStateException("Incomplete station summary: " + stations); + } + for (String station : DEFAULT_LIFECYCLE_STATIONS) { + if (!stations.has(station)) throw new IllegalStateException("Missing station " + station); + String status = stations.get(station).getAsString(); + requireKnownStatus(status); + observed.put(status, observed.getOrDefault(status, 0) + 1); + if (!"SKIP".equals(status)) completed++; + } + if (completed == 0 || counts.entrySet().size() != observed.size()) { + throw new IllegalStateException("No completed station checks or inconsistent counts: " + counts); + } + for (Map.Entry count : observed.entrySet()) { + if (!counts.has(count.getKey()) || !counts.get(count.getKey()).getAsJsonPrimitive().isNumber() + || counts.get(count.getKey()).getAsDouble() != count.getValue()) { + throw new IllegalStateException("Station counts disagree with identities: " + counts); + } } } } @@ -837,19 +859,67 @@ private static void checkStations(Process process, List stations, boolea } private static void requireAcceptableStations(File results, List stations) throws Exception { + if (stations.isEmpty()) throw new IllegalStateException("No stations requested"); + int completed = 0; for (String station : stations) { File result = new File(results, station + "-latest.json"); if (!result.isFile()) throw new IllegalStateException("Missing final station result " + result); try (InputStreamReader reader = new InputStreamReader( new FileInputStream(result), StandardCharsets.UTF_8)) { JsonObject root = GSON.fromJson(reader, JsonObject.class); - String status = root.has("status") ? root.get("status").getAsString() : ""; - if ("FAIL".equals(status) || "UNEXPECTED_PASS".equals(status)) { - throw new IllegalStateException("Final Advantage Works station result is not acceptable: " - + station + "=" + status); + String status = root != null && root.has("status") ? root.get("status").getAsString() : ""; + requireKnownStatus(status); + if (!root.has("station") || !station.equals(root.get("station").getAsString())) { + throw new IllegalStateException("Wrong station result: " + result); } + if ("SKIP".equals(status)) { + if (!root.has("skipReason") || root.get("skipReason").getAsString().trim().isEmpty()) { + throw new IllegalStateException("Skipped station needs an explicit reason: " + result); + } + continue; + } + if (!root.has("assertions") || !root.get("assertions").isJsonArray()) { + throw new IllegalStateException("Missing assertions: " + result); + } + Set ids = new LinkedHashSet<>(); + int passed = 0; + for (JsonElement element : root.getAsJsonArray("assertions")) { + JsonObject assertion = element.getAsJsonObject(); + if (!assertion.has("id") || assertion.get("id").getAsString().isEmpty() + || !ids.add(assertion.get("id").getAsString()) || !assertion.has("status")) { + throw new IllegalStateException("Missing or duplicate assertion identity: " + result); + } + String value = assertion.get("status").getAsString(); + if ("PASS".equals(value)) passed++; + else if (!"SKIP".equals(value)) throw new IllegalStateException("Unacceptable assertion " + assertion); + } + if (passed == 0) throw new IllegalStateException("No passing measurements: " + result); + boolean manual = root.has("manualChecks") && root.getAsJsonArray("manualChecks").size() > 0; + if (manual != "MANUAL_REQUIRED".equals(status)) { + throw new IllegalStateException("Manual checks/status disagree: " + result); + } + completed++; } } + if (completed == 0) throw new IllegalStateException("All requested stations skipped"); + } + + private static void requireKnownStatus(String status) { + if (!"PASS".equals(status) && !"MANUAL_REQUIRED".equals(status) && !"SKIP".equals(status)) { + throw new IllegalStateException("Unacceptable or incomplete station status: " + status); + } + } + + static void archiveStationResults(File results, String phase, List stations) throws Exception { + File previous = new File(results, "previous-" + phase); + if (!previous.mkdir()) throw new IllegalStateException("Cannot create result custody directory: " + previous); + List names = new ArrayList<>(); + names.add("all-summary.json"); + for (String station : stations) names.add(station + "-latest.json"); + for (String name : names) { + File stale = new File(results, name); + if (stale.isFile()) Files.move(stale.toPath(), new File(previous, name).toPath()); + } } private static String timestamp() { diff --git a/test-rig/src/test/java/zone/moddev/mc/advantageworks/AssertionChecksTest.java b/test-rig/src/test/java/zone/moddev/mc/advantageworks/AssertionChecksTest.java new file mode 100644 index 0000000..af32fac --- /dev/null +++ b/test-rig/src/test/java/zone/moddev/mc/advantageworks/AssertionChecksTest.java @@ -0,0 +1,59 @@ +package zone.moddev.mc.advantageworks; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Map; +import net.minecraft.nbt.NBTTagCompound; +import org.junit.Test; +import static org.junit.Assert.*; + +public class AssertionChecksTest { + @Test public void aHarnessErrorIsNotEvidenceOfAKnownGameplayBug() { + assertEquals("FAIL", AssertionChecks.verdict(false, "SA-110-001", new IllegalArgumentException("bad path"))); + assertEquals("EXPECTED_FAIL", AssertionChecks.verdict(false, "SA-110-001", null)); + assertEquals("UNEXPECTED_PASS", AssertionChecks.verdict(true, "SA-110-001", null)); + } + + @Test public void emptyAndWhollySkippedMeasurementsAreNotPasses() { + assertEquals("SKIP", AssertionChecks.aggregate(Collections.emptyList(), false)); + assertEquals("SKIP", AssertionChecks.aggregate(Collections.singletonList(row("SKIP")), true)); + assertEquals("FAIL", AssertionChecks.aggregate(Collections.singletonList(row("RUNNING")), false)); + } + + @Test public void mixedResultsCannotHideFailureOrManualWork() { + assertEquals("FAIL", AssertionChecks.aggregate(Arrays.asList(row("PASS"), row("FAIL")), true)); + assertEquals("MANUAL_REQUIRED", AssertionChecks.aggregate(Arrays.asList(row("PASS"), row("SKIP")), true)); + assertEquals("PASS", AssertionChecks.aggregate(Collections.singletonList(row("PASS")), false)); + } + + @Test(expected = IllegalArgumentException.class) public void missingTankCannotSatisfyZero() { + AssertionChecks.fluidAmount(new NBTTagCompound(), "Tank", null); + } + + @Test(expected = IllegalArgumentException.class) public void missingAmountCannotSatisfyZero() { + NBTTagCompound tile = new NBTTagCompound(); + tile.setTag("Tank", new NBTTagCompound()); + AssertionChecks.fluidAmount(tile, "Tank", null); + } + + @Test(expected = IllegalArgumentException.class) public void emptyFalseIsNotAnEmptyTank() { + NBTTagCompound tile = new NBTTagCompound(), tank = new NBTTagCompound(); + tank.setBoolean("Empty", false); + tile.setTag("Tank", tank); + AssertionChecks.fluidAmount(tile, "Tank", null); + } + + @Test public void explicitEmptyAndMeasuredFluidAreDifferentFromMissingEvidence() { + NBTTagCompound tile = new NBTTagCompound(), tank = new NBTTagCompound(); + tank.setBoolean("Empty", true); + tile.setTag("Tank", tank); + assertEquals(0, AssertionChecks.fluidAmount(tile, "Tank", null)); + tank.removeTag("Empty"); + tank.setInt("Amount", 1000); + tank.setString("FluidName", "minecraft:water"); + assertEquals(1000, AssertionChecks.fluidAmount(tile, "Tank", "minecraft:water")); + assertEquals(0, AssertionChecks.fluidAmount(tile, "Tank", "poweradvantage:crude_oil")); + } + + private Map row(String status) { return Collections.singletonMap("status", status); } +} diff --git a/test-rig/src/test/java/zone/moddev/mc/advantageworks/BayDrillAssertionsTest.java b/test-rig/src/test/java/zone/moddev/mc/advantageworks/BayDrillAssertionsTest.java new file mode 100644 index 0000000..5bb49be --- /dev/null +++ b/test-rig/src/test/java/zone/moddev/mc/advantageworks/BayDrillAssertionsTest.java @@ -0,0 +1,58 @@ +package zone.moddev.mc.advantageworks; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; +import org.junit.Test; +import static org.junit.Assert.*; + +public class BayDrillAssertionsTest { + @Test public void initialAndSingleMovedWitnessesKeepAllTwelveFacingChecks() { + int count = 0; + for (WorksDefinition.Station station : WorksDefinitionLoader.load().stations) { + for (WorksDefinition.Assertion assertion : station.assertions) { + if (!BayDrillAssertions.applies(station, assertion)) continue; + assertTrue(matches(station, assertion, assertion.pos)); + assertTrue(station.id + "/" + assertion.id + " on its declared rear track", + matches(station, assertion, BayDrillAssertions.rearTrack(station, assertion))); + count++; + } + } + assertEquals(12, count); + } + + @Test public void missingDuplicateWrongFacingAndOffTrackWitnessesFail() { + for (WorksDefinition.Station station : WorksDefinitionLoader.load().stations) { + for (WorksDefinition.Assertion assertion : station.assertions) { + if (!BayDrillAssertions.applies(station, assertion)) continue; + int[] rear = BayDrillAssertions.rearTrack(station, assertion); + assertFalse(matches(station, assertion)); + assertFalse(matches(station, assertion, assertion.pos, rear)); + assertFalse(BayDrillAssertions.matches(station, assertion, p -> true, p -> false)); + int[] stray = rear.clone(); + stray[0] += 2; + assertFalse(matches(station, assertion, stray)); + } + } + } + + @Test public void noUndeclaredTrackFallbackIsAllowed() { + WorksDefinition.Station station = WorksDefinitionLoader.load().stations.stream() + .filter(s -> "S-05".equals(s.id)).findFirst().get(); + station.placements.clear(); + try { + BayDrillAssertions.rearTrack(station, station.assertions.get(0)); + fail("Undeclared movement accepted"); + } catch (IllegalArgumentException expected) { + assertEquals("Drill bay has no declared rear track", expected.getMessage()); + } + } + + private static boolean matches(WorksDefinition.Station station, WorksDefinition.Assertion assertion, + int[]... present) { + Set positions = new HashSet<>(); + for (int[] p : present) positions.add(Arrays.toString(p)); + return BayDrillAssertions.matches(station, assertion, + p -> positions.contains(Arrays.toString(p)), p -> positions.contains(Arrays.toString(p))); + } +} diff --git a/test-rig/src/test/java/zone/moddev/mc/advantageworks/ElectricOperationalCasesTest.java b/test-rig/src/test/java/zone/moddev/mc/advantageworks/ElectricOperationalCasesTest.java new file mode 100644 index 0000000..fca276e --- /dev/null +++ b/test-rig/src/test/java/zone/moddev/mc/advantageworks/ElectricOperationalCasesTest.java @@ -0,0 +1,45 @@ +package zone.moddev.mc.advantageworks; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import org.junit.Test; + +import static org.junit.Assert.*; + +/** Registration is not runtime proof; these tests prevent silently missing case families. */ +public class ElectricOperationalCasesTest { + @Test public void allCaseFamiliesAreRegisteredWithFiniteWaits() { + List cases = new ArrayList<>(); + ElectricOperationalCases.register(cases); + Set ids = new HashSet<>(); + for (MachineScenario scenario : cases) { + assertTrue("Duplicate scenario " + scenario.id, ids.add(scenario.id)); + assertTrue(scenario.id, scenario.ticks >= 40 && scenario.ticks <= 1000); + assertNotNull(scenario.setup); + assertNotNull(scenario.verify); + assertFalse(scenario.contract.trim().isEmpty()); + } + assertEquals(25, cases.size()); + for (String family : new String[] {"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"}) + assertTrue("Missing operating family " + family, ids.contains("electric/" + family)); + } + + @Test public void globalDaylightChangeIsExplicitlyExclusive() { + List cases = new ArrayList<>(); + ElectricOperationalCases.register(cases); + int exclusive = 0; + for (MachineScenario scenario : cases) { + if (scenario.exclusive) { + exclusive++; + assertEquals("electric/solar-daylight-rate", scenario.id); + } + } + assertEquals(1, exclusive); + } +} diff --git a/test-rig/src/test/java/zone/moddev/mc/advantageworks/ElectricOperationalRatesTest.java b/test-rig/src/test/java/zone/moddev/mc/advantageworks/ElectricOperationalRatesTest.java new file mode 100644 index 0000000..8bd8302 --- /dev/null +++ b/test-rig/src/test/java/zone/moddev/mc/advantageworks/ElectricOperationalRatesTest.java @@ -0,0 +1,100 @@ +package zone.moddev.mc.advantageworks; + +import java.util.Arrays; +import java.util.List; +import org.junit.Test; +import static org.junit.Assert.*; + +public class ElectricOperationalRatesTest { + @Test public void everySetupPhaseAndPositionOffsetMatchesTheDeclaredEightTickSchedule() { + for (int start = 0; start < 8; start++) for (int offset = 0; offset < 8; offset++) { + int first = ElectricOperationalRates.firstUpdateDelay(start, offset); + assertTrue(first >= 1 && first <= 8); + assertEquals(0, (start + first + offset) % 8); + for (int earlier = 1; earlier < first; earlier++) assertNotEquals(0, (start + earlier + offset) % 8); + List updates = ElectricOperationalRates.updateDelays(start, offset, 40); + assertEquals(5, updates.size()); + for (int index = 0; index < updates.size(); index++) assertEquals(first + 8 * index, (int)updates.get(index)); + } + } + + @Test public void absolutePositionParityAndLongWorldEpochArePreserved() { + assertEquals(0, ElectricOperationalRates.offset(50008, 82, 50008)); + assertEquals(2, ElectricOperationalRates.offset(50007, 82, 50008)); + assertEquals(7, ElectricOperationalRates.offset(-1, -1, -1)); + for (int phase = 0; phase < 8; phase++) for (int offset = 0; offset < 8; offset++) + assertEquals(ElectricOperationalRates.updateDelays(phase, offset, 100), + ElectricOperationalRates.updateDelays((1L << 40) + phase, offset, 100)); + } + + @Test public void noUpdateIsInventedBeforeTheFirstEligibleWorldTick() { + assertTrue(ElectricOperationalRates.updateDelays(0, 0, 7).isEmpty()); + assertEquals(Arrays.asList(8), ElectricOperationalRates.updateDelays(0, 0, 8)); + assertEquals(Arrays.asList(1, 9), ElectricOperationalRates.updateDelays(7, 0, 9)); + assertEquals(0, ElectricOperationalRates.preloadedSteamElectricity(32, 0), 0); + } + + @Test public void directlyPreloadedGeneratorLosesOneHalfUnitPerUpdateNotPerWaitingTick() { + for (int start = 0; start < 8; start++) { + int count = ElectricOperationalRates.updateDelays(start, 0, 40).size(); + assertEquals(984.375f, ElectricOperationalRates.preloadedSteamElectricity(32, count), 0); + assertEquals(29.5f, ElectricOperationalRates.fullGeneratorSteam(32, count), 0); + } + } + + @Test public void smallGeneratorPacketsAreNotRoundedAwayOrChargedExcessDecay() { + assertEquals(0, ElectricOperationalRates.preloadedSteamElectricity(0, 5), 0); + assertEquals(7.8125f, ElectricOperationalRates.preloadedSteamElectricity(0.25f, 5), 0); + assertEquals(15.625f, ElectricOperationalRates.preloadedSteamElectricity(0.5f, 5), 0); + assertEquals(3.90625f, ElectricOperationalRates.preloadedSteamElectricity(0.625f, 5), 0); + } + + @Test public void suppliedSteamUsesTheActualFirstTransferPhaseForAllEightStartTimes() { + float[] expected = {2894.53125f, 2896.484375f, 2898.4375f, 2900.390625f, + 2902.34375f, 2904.296875f, 2890.625f, 2892.578125f}; + for (int start = 0; start < 8; start++) { + ElectricOperationalRates.SupplyLedger ledger = ElectricOperationalRates.tankSupply(start, 2, 0, 100, 96); + assertEquals(expected[start], ledger.generated, 0); + assertEquals(3, ledger.transfers.size()); + assertEquals(1.5f, ledger.generatorLeakage, 0); + assertEquals(96, ledger.generated / 31.25f + ledger.tankLeakage + ledger.generatorLeakage, 0); + assertEquals(0, ledger.remaining, 0); + } + ElectricOperationalRates.SupplyLedger zero = ElectricOperationalRates.tankSupply(0, 2, 0, 100, 96); + assertEquals(Arrays.asList(14, 22, 30), zero.transfers); + assertEquals(Arrays.asList(16, 24, 32), zero.conversions); + } + + @Test public void scarceTankSteamMayExpireBeforeTransferDependingOnSetupPhase() { + assertEquals(3.90625f, ElectricOperationalRates.tankSupply(0, 2, 0, 100, 1).generated, 0); + assertEquals(0, ElectricOperationalRates.tankSupply(6, 2, 0, 100, 1).generated, 0); + } + + @Test public void ledFirstSupplyPhaseDeterminesWhetherNineOrTenUpdatesArePaid() { + float[] expected = {1.25f, 1.25f, 1.25f, 1.25f, 1.25f, 1.25f, 1.125f, 1.125f}; + for (int start = 0; start < 8; start++) + assertEquals(expected[start], ElectricOperationalRates.ledDebit(start, 2, 0, 80, 10), 0); + assertEquals(0.5f, ElectricOperationalRates.ledDebit(0, 0, 4, 80, 0.5f), 0); + } + + @Test public void fabricatorAccountsForSurplusDrivenSecondWorkWithoutChangingProductCount() { + assertEquals(3952, ElectricOperationalRates.fabricatorDebit(0, 0, 260, false), 0); + assertEquals(3968, ElectricOperationalRates.fabricatorDebit(0, 0, 260, true), 0); + assertEquals(4000, ElectricOperationalRates.fabricatorDebit(7, 0, 260, false), 0); + for (int phase = 0; phase < 8; phase++) { + float early = ElectricOperationalRates.fabricatorDebit(phase, 0, 260, true); + float late = ElectricOperationalRates.fabricatorDebit(phase, 0, 260, false); + assertTrue(early >= late && early - late <= 16); + assertTrue(late >= 3200 && early <= 4000); + } + } + + @Test public void daylightOracleUsesScheduledDayTimesRatherThanPeakPowerTimesUpdateCount() { + float fixed = ElectricOperationalRates.daylightElectricity(0, 0, 160, 6000, false, 1); + float advancing = ElectricOperationalRates.daylightElectricity(0, 0, 160, 6000, true, 1); + assertTrue(advancing < fixed); + assertEquals(0, ElectricOperationalRates.daylightElectricity(0, 0, 160, 18000, false, 1), 0); + assertEquals(advancing * 0.35f, + ElectricOperationalRates.daylightElectricity(0, 0, 160, 6000, true, 0.35f), 0.001f); + } +} diff --git a/test-rig/src/test/java/zone/moddev/mc/advantageworks/MeasurementDefinitionTest.java b/test-rig/src/test/java/zone/moddev/mc/advantageworks/MeasurementDefinitionTest.java new file mode 100644 index 0000000..edb0757 --- /dev/null +++ b/test-rig/src/test/java/zone/moddev/mc/advantageworks/MeasurementDefinitionTest.java @@ -0,0 +1,34 @@ +package zone.moddev.mc.advantageworks; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.function.Consumer; +import org.junit.Test; +import static org.junit.Assert.*; + +public class MeasurementDefinitionTest { + @Test public void emptyAssertionsAreNotAValidTest() throws Exception { + rejects(station -> station.assertions.clear()); + } + + @Test public void aMisspelledOperatorCannotBecomeAKnownFailure() throws Exception { + rejects(station -> station.assertions.get(0).operator = "equlas"); + } + + @Test public void nonfiniteExpectationsAreNotValidMeasurements() throws Exception { + rejects(station -> station.assertions.get(0).value = Double.POSITIVE_INFINITY); + } + + @Test public void tileMeasurementNeedsAPosition() throws Exception { + rejects(station -> station.assertions.get(0).pos = null); + } + + private void rejects(Consumer corrupt) throws Exception { + WorksDefinition.Station station = WorksDefinitionLoader.load().stations.get(0); + corrupt.accept(station); + Method validate = WorksDefinitionLoader.class.getDeclaredMethod("validatePlacements", WorksDefinition.Station.class); + validate.setAccessible(true); + try { validate.invoke(null, station); fail("Malformed measurement accepted"); } + catch (InvocationTargetException expected) { assertTrue(expected.getCause() instanceof IllegalArgumentException); } + } +} diff --git a/test-rig/src/test/java/zone/moddev/mc/advantageworks/NativeHarvestIsolationTest.java b/test-rig/src/test/java/zone/moddev/mc/advantageworks/NativeHarvestIsolationTest.java new file mode 100644 index 0000000..2e38648 --- /dev/null +++ b/test-rig/src/test/java/zone/moddev/mc/advantageworks/NativeHarvestIsolationTest.java @@ -0,0 +1,149 @@ +package zone.moddev.mc.advantageworks; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.function.Predicate; +import net.minecraft.entity.Entity; +import net.minecraft.entity.item.EntityItem; +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; +import net.minecraft.init.Items; +import net.minecraft.util.EntitySelectors; +import net.minecraft.util.math.AxisAlignedBB; +import net.minecraft.init.Bootstrap; +import net.minecraft.world.WorldServer; +import org.junit.BeforeClass; +import org.junit.Test; +import static org.junit.Assert.*; + +public class NativeHarvestIsolationTest { + private static final AxisAlignedBB BOUNDS = new AxisAlignedBB(0, 0, 0, 1, 1, 1).grow(2); + + @BeforeClass public static void initializeNativeRegistries() throws Exception { + java.util.Properties platform = new java.util.Properties(); + try (java.io.InputStream input = java.nio.file.Files.newInputStream(java.nio.file.Paths.get("gradle.properties"))) { + platform.load(input); + } + Class loader = Class.forName("net.minecraftforge.fml.loading.FMLLoader"); + for (String[] mapping : new String[][]{{"mcVersion", "minecraft_version"}, {"forgeVersion", "forge_version"}}) { + String expected = platform.getProperty(mapping[1]); + assertNotNull("Missing configured native unit-test version", expected); + java.lang.reflect.Field field = loader.getDeclaredField(mapping[0]); + field.setAccessible(true); + if (field.get(null) == null) field.set(null, expected); + else assertEquals("Conflicting native unit-test version", expected, field.get(null)); + } + // Match Forge's instance-call metadata path without ModLauncher's injected constructor. + Class entityEvent = Class.forName("net.minecraftforge.event.entity.EntityEvent"); + java.lang.reflect.Method listeners = Class.forName("net.minecraftforge.eventbus.api.EventListenerHelper") + .getDeclaredMethod("getListenerListInternal", Class.class, boolean.class); + listeners.setAccessible(true); + Object nativeListeners = listeners.invoke(null, entityEvent, true); + assertNotNull("Native EntityEvent listener metadata was not established", nativeListeners); + assertSame("Real event instances must use the native listener metadata", nativeListeners, + new net.minecraftforge.event.entity.EntityEvent(null).getListenerList()); + Bootstrap.register(); + } + + private static EntityItem item(Item type, int count) { + return new EntityItem(null, 0.5, 0.5, 0.5, new ItemStack(type, count)); + } + + @Test public void nativeDefaultQueryReproducesFourThenSixThenEightBeforeTickCleanup() { + QueryReader reader = QueryReader.create(); + EntityItem first = item(Items.GUNPOWDER, 4); + reader.index.add(first); + assertEquals(4, count(reader.getEntitiesWithinAABB(EntityItem.class, BOUNDS))); + reader.getEntitiesWithinAABB(EntityItem.class, BOUNDS).forEach(EntityItem::remove); + assertTrue(first.removed); + assertTrue(EntitySelectors.NOT_SPECTATING.test(first)); + assertSame(first, reader.getEntitiesWithinAABB(EntityItem.class, BOUNDS).get(0)); + reader.index.add(item(Items.GUNPOWDER, 2)); + assertEquals(6, count(reader.getEntitiesWithinAABB(EntityItem.class, BOUNDS))); + reader.getEntitiesWithinAABB(EntityItem.class, BOUNDS).forEach(EntityItem::remove); + reader.index.add(item(Items.GUNPOWDER, 2)); + assertEquals(8, count(reader.getEntitiesWithinAABB(EntityItem.class, BOUNDS))); + } + + @Test public void removedEarlierDropCannotInflateTheNextTrial() { + QueryReader reader = QueryReader.create(); + reader.index.add(item(Items.GUNPOWDER, 4)); + NativeHarvestProbe.harvestDrops(reader, BOUNDS).forEach(EntityItem::remove); + assertTrue(NativeHarvestProbe.harvestDrops(reader, BOUNDS).isEmpty()); + reader.index.add(item(Items.GUNPOWDER, 2)); + assertEquals(2, count(NativeHarvestProbe.harvestDrops(reader, BOUNDS))); + } + + @Test public void removedSulfurLikeDropCannotContaminateSilkOrWrongToolCases() { + QueryReader reader = QueryReader.create(); + EntityItem powder = item(Items.GUNPOWDER, 4); + reader.index.add(powder); + powder.remove(); + assertTrue(NativeHarvestProbe.harvestDrops(reader, BOUNDS).isEmpty()); + EntityItem ore = item(Item.getItemFromBlock(net.minecraft.init.Blocks.IRON_ORE), 1); + reader.index.add(ore); + assertEquals(Collections.singletonList(ore), NativeHarvestProbe.harvestDrops(reader, BOUNDS)); + } + + @Test public void everyUnexpectedLiveIdentityAndQuantityRemainsVisibleToTheOracle() { + QueryReader reader = QueryReader.create(); + EntityItem expected = item(Item.getItemFromBlock(net.minecraft.init.Blocks.IRON_ORE), 1), unexpected = item(Items.GUNPOWDER, 5); + reader.index.add(expected); + reader.index.add(unexpected); + assertEquals(reader.index, NativeHarvestProbe.harvestDrops(reader, BOUNDS)); + assertEquals(6, count(NativeHarvestProbe.harvestDrops(reader, BOUNDS))); + } + + @Test public void fixtureQueryKeepsTheOriginalSpatialBoundary() { + QueryReader reader = QueryReader.create(); + EntityItem outside = item(Items.GUNPOWDER, 4); + outside.setPosition(32, 0.5, 0.5); + reader.index.add(outside); + assertTrue(NativeHarvestProbe.harvestDrops(reader, BOUNDS).isEmpty()); + } + + private static int count(List entities) { + return entities.stream().mapToInt(entity -> entity.getItem().getCount()).sum(); + } + + /** Models only Chunk's index and predicate dispatch; native entities/default queries are real. */ + private static final class QueryReader extends WorldServer { + List index; + + private QueryReader() { + super(null, null, null, null, net.minecraft.world.dimension.DimensionType.OVERWORLD, + new net.minecraft.profiler.Profiler()); + throw new AssertionError("Allocate the indexed native world without a server constructor"); + } + + static QueryReader create() { + try { + Class allocator = Class.forName("sun.misc.Unsafe"); + java.lang.reflect.Field singleton = allocator.getDeclaredField("theUnsafe"); + singleton.setAccessible(true); + QueryReader reader = (QueryReader) allocator.getMethod("allocateInstance", Class.class) + .invoke(singleton.get(null), QueryReader.class); + reader.index = new ArrayList<>(); + return reader; + } catch (ReflectiveOperationException failure) { + throw new AssertionError("Cannot allocate the native World query fixture", failure); + } + } + + @Override public List getEntitiesWithinAABB(Class type, + AxisAlignedBB bounds, Predicate filter) { + List found = new ArrayList<>(); + for (EntityItem entity : index) if (type.isInstance(entity) && entity.getBoundingBox().intersects(bounds)) { + T value = type.cast(entity); + if (filter == null || filter.test(value)) found.add(value); + } + return found; + } + + @Override public List getEntitiesInAABBexcluding(Entity except, AxisAlignedBB bounds, Predicate filter) { + throw new UnsupportedOperationException("Not used by harvest queries"); + } + + } +} diff --git a/test-rig/src/test/java/zone/moddev/mc/advantageworks/SteamOperationalSourceContractTest.java b/test-rig/src/test/java/zone/moddev/mc/advantageworks/SteamOperationalSourceContractTest.java new file mode 100644 index 0000000..71748f5 --- /dev/null +++ b/test-rig/src/test/java/zone/moddev/mc/advantageworks/SteamOperationalSourceContractTest.java @@ -0,0 +1,92 @@ +package zone.moddev.mc.advantageworks; + +import java.io.InputStream; +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.Set; +import org.junit.Test; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.tree.AbstractInsnNode; +import org.objectweb.asm.tree.ClassNode; +import org.objectweb.asm.tree.LdcInsnNode; +import org.objectweb.asm.tree.MethodInsnNode; +import org.objectweb.asm.tree.MethodNode; +import static org.junit.Assert.*; + +public class SteamOperationalSourceContractTest { + @Test public void finiteStillReservesReplaceTheTankInsteadOfMergingAnEmptyMarker() throws Exception { + MethodNode reserve = readClass("SteamOperationalCases").methods.stream() + .filter(method -> method.name.equals("reserve")).findFirst() + .orElseThrow(() -> new AssertionError("Missing finite still reserve setup")); + boolean replacement = false, setupMeasurement = false; + for (AbstractInsnNode instruction : reserve.instructions.toArray()) { + if (!(instruction instanceof MethodInsnNode)) continue; + MethodInsnNode call = (MethodInsnNode) instruction; + assertFalse("Recursive NBT merge preserves an old Empty marker", call.name.equals("seed")); + if (call.owner.equals("cyano/poweradvantage/api/fluid/FluidTank") && call.name.equals("setFluid")) replacement = true; + if (call.owner.equals("zone/moddev/mc/advantageworks/SteamOperationalCases") && call.name.equals("fluid")) setupMeasurement = true; + } + assertTrue("Finite tank reserve is directly initialized and measured before ticking", replacement && setupMeasurement); + } + + @Test public void supplementalFamiliesUseTheSteamGroupAndRegisterExactlyOnce() throws Exception { + int registrations = 0; + for (MethodNode method : readClass("SteamOperationalCases").methods) { + if (!method.name.equals("register")) continue; + for (AbstractInsnNode instruction : method.instructions.toArray()) { + if (!(instruction instanceof MethodInsnNode)) continue; + MethodInsnNode call = (MethodInsnNode) instruction; + if (call.owner.equals("zone/moddev/mc/advantageworks/SteamOperationalSupplementalCases") + && call.name.equals("register") && call.desc.equals("(Ljava/util/List;)V")) registrations++; + } + } + assertEquals("Steam registration must include supplementals once", 1, registrations); + Set families = new LinkedHashSet<>(); + for (MethodNode method : readClass("SteamOperationalSupplementalCases").methods) { + for (AbstractInsnNode instruction : method.instructions.toArray()) { + if (!(instruction instanceof LdcInsnNode)) continue; + Object value = ((LdcInsnNode) instruction).cst; + if (!(value instanceof String)) continue; + String text = (String) value; + assertFalse("Supplemental IDs must not escape the steam group", text.startsWith("steam-extra/")); + if (text.startsWith("steam/")) families.add(text); + } + } + assertEquals(new LinkedHashSet<>(Arrays.asList("steam/extra-priority-", "steam/extra-recoil-", + "steam/extra-explosive-", "steam/extra-musket-solid-wall")), families); + } + + @Test public void musketOraclesReadTheServerConfigurationFieldWithoutReflectingItemMethods() throws Exception { + for (String type : new String[]{"SteamOperationalActors", "SteamOperationalSupplementalCases"}) { + ClassNode node = readClass(type); + boolean fieldLookup = false, damageField = false, serverConfig = false; + for (MethodNode method : node.methods) { + for (AbstractInsnNode instruction : method.instructions.toArray()) { + if (instruction instanceof MethodInsnNode) { + MethodInsnNode call = (MethodInsnNode) instruction; + if (!call.owner.equals("java/lang/Class")) continue; + assertFalse(type + " must not enumerate Item method signatures on the dedicated server", + call.name.equals("getMethod") || call.name.equals("getMethods") + || call.name.equals("getDeclaredMethod") || call.name.equals("getDeclaredMethods")); + if (call.name.equals("getDeclaredField")) fieldLookup = true; + } else if (instruction instanceof LdcInsnNode) { + Object value = ((LdcInsnNode) instruction).cst; + if ("MUSKET_DAMAGE".equals(value)) damageField = true; + if ("zone.moddev.mc.steamadvantage.SteamAdvantage".equals(value)) serverConfig = true; + } + } + } + assertTrue(type + " must read the actual server damage configuration", fieldLookup && damageField && serverConfig); + } + } + + private static ClassNode readClass(String type) throws Exception { + ClassNode node = new ClassNode(); + try (InputStream input = SteamOperationalSourceContractTest.class.getResourceAsStream( + "/zone/moddev/mc/advantageworks/" + type + ".class")) { + assertNotNull(type, input); + new ClassReader(input).accept(node, 0); + } + return node; + } +} diff --git a/test-rig/src/test/java/zone/moddev/mc/advantageworks/SteamReloadControlHoldTest.java b/test-rig/src/test/java/zone/moddev/mc/advantageworks/SteamReloadControlHoldTest.java new file mode 100644 index 0000000..36d962c --- /dev/null +++ b/test-rig/src/test/java/zone/moddev/mc/advantageworks/SteamReloadControlHoldTest.java @@ -0,0 +1,57 @@ +package zone.moddev.mc.advantageworks; + +import static org.junit.Assert.*; +import java.util.*; +import org.junit.Test; + +public class SteamReloadControlHoldTest { + @Test public void holdRemovesOnlyTheMeasuredObjectAndKeepsOtherRegistrationsInOrder() { + Object before = new Object(), measured = new Object(), after = new Object(); + List tickables = new ArrayList<>(Arrays.asList(before, measured, after)); + SteamReloadControlCases.removeMeasuredTile(tickables, measured); + assertEquals(Arrays.asList(before, after), tickables); + } + + @Test public void missingRegistrationIsAFailureRatherThanAnUnobservedHold() { + Object other = new Object(); + List tickables = new ArrayList<>(Collections.singletonList(other)); + try { SteamReloadControlCases.removeMeasuredTile(tickables, new Object()); fail("Missing actual registration"); } + catch (AssertionError expected) { assertTrue(expected.getMessage().contains("found 0")); } + assertEquals(Collections.singletonList(other), tickables); + } + + @Test public void duplicateRegistrationFailsWithoutRemovingEvidence() { + Object measured = new Object(); + List tickables = new ArrayList<>(Arrays.asList(measured, measured)); + try { SteamReloadControlCases.removeMeasuredTile(tickables, measured); fail("Duplicate actual registrations"); } + catch (AssertionError expected) { assertTrue(expected.getMessage().contains("found 2")); } + assertEquals(2, tickables.size()); + } + + @Test public void equalButDifferentObjectsCannotBeRemovedInsteadOfTheMeasuredInstance() { + String other = new String("equal"), measured = new String("equal"); + List tickables = new ArrayList<>(Arrays.asList(other, measured)); + SteamReloadControlCases.removeMeasuredTile(tickables, measured); + assertEquals(1, tickables.size()); + assertSame(other, tickables.get(0)); + } + + @Test public void catalogContainsAllSixExclusiveOneTickControls() { + List cases = new ArrayList<>(); + SteamReloadControlCases.register(cases); + Set expected = new HashSet<>(Arrays.asList( + "steam/lifecycle/crusher-comparator-matrix", + "steam/lifecycle/crusher-first-loaded-unpowered", + "steam/lifecycle/crusher-first-loaded-signalled", + "steam/lifecycle/crusher-first-loaded-no-steam", + "steam/lifecycle/furnace-first-loaded-unpowered", + "steam/lifecycle/furnace-first-loaded-signalled")); + assertEquals(6, cases.size()); + for (MachineScenario scenario : cases) { + assertTrue(expected.remove(scenario.id)); + assertEquals(1, scenario.ticks); + assertTrue(scenario.exclusive); + } + assertTrue(expected.isEmpty()); + } +} diff --git a/test-rig/src/test/java/zone/moddev/mc/advantageworks/WorksNumericAssertionTest.java b/test-rig/src/test/java/zone/moddev/mc/advantageworks/WorksNumericAssertionTest.java new file mode 100644 index 0000000..5c81de6 --- /dev/null +++ b/test-rig/src/test/java/zone/moddev/mc/advantageworks/WorksNumericAssertionTest.java @@ -0,0 +1,23 @@ +package zone.moddev.mc.advantageworks; + +import org.junit.Test; +import static org.junit.Assert.*; + +public class WorksNumericAssertionTest { + @Test public void infiniteEnergyCannotSatisfyMinimum() throws Exception { + assertFalse(compare(Double.POSITIVE_INFINITY, "min", 1)); + } + + @Test public void negativeInfinityCannotSatisfyMaximum() throws Exception { + assertFalse(compare(Double.NEGATIVE_INFINITY, "max", 0)); + } + + @Test public void ordinaryFiniteComparisonsStillWork() throws Exception { + assertTrue(compare(1000, "equals", 1000)); + assertFalse(compare(999, "equals", 1000)); + } + + private boolean compare(double value, String operator, Number expected) throws Exception { + return AssertionChecks.compare(value, operator, expected); + } +} diff --git a/test-rig/src/test/java/zone/moddev/mc/advantageworks/runner/StationResultValidationTest.java b/test-rig/src/test/java/zone/moddev/mc/advantageworks/runner/StationResultValidationTest.java new file mode 100644 index 0000000..522ffbc --- /dev/null +++ b/test-rig/src/test/java/zone/moddev/mc/advantageworks/runner/StationResultValidationTest.java @@ -0,0 +1,107 @@ +package zone.moddev.mc.advantageworks.runner; + +import java.io.File; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.Collections; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import static org.junit.Assert.*; + +public class StationResultValidationTest { + @Rule public TemporaryFolder temporary = new TemporaryFolder(); + + @Test public void emptySummaryCannotPass() throws Exception { + rejects("requireAcceptableSummary", "{}", true); + } + + @Test public void zeroCompletedSummaryCannotPass() throws Exception { + rejects("requireAcceptableSummary", "{\"summary\":{\"SKIP\":21}}", true); + } + + @Test public void unknownStationStatusCannotPass() throws Exception { + rejects("requireAcceptableStations", "{\"status\":\"RUNNING\"}", false); + } + + @Test public void passWithoutAssertionsCannotPass() throws Exception { + rejects("requireAcceptableStations", "{\"station\":\"E-03\",\"status\":\"PASS\",\"assertions\":[]}", false); + } + + @Test public void passCannotHideFailedAssertion() throws Exception { + rejects("requireAcceptableStations", "{\"station\":\"E-03\",\"status\":\"PASS\"," + + "\"assertions\":[{\"id\":\"work\",\"status\":\"FAIL\"}]}", false); + } + + @Test public void allSkippedAssertionsCannotBecomeAPass() throws Exception { + rejects("requireAcceptableStations", "{\"station\":\"E-03\",\"status\":\"PASS\"," + + "\"assertions\":[{\"id\":\"work\",\"status\":\"SKIP\"}]}", false); + } + + @Test public void knownBugIsNotAnUnqualifiedOperatingPass() throws Exception { + rejects("requireAcceptableStations", "{\"station\":\"E-03\",\"status\":\"EXPECTED_FAIL\"}", false); + } + + @Test public void earlierBootCannotSupplyMissingRestartResults() throws Exception { + File root = temporary.newFolder(); + File stale = new File(root, "E-03-latest.json"); + byte[] bytes = "earlier pass".getBytes(StandardCharsets.UTF_8); + Files.write(stale.toPath(), bytes); + WorksRunner.archiveStationResults(root, "restart-run", Collections.singletonList("E-03")); + assertFalse(stale.exists()); + assertArrayEquals(bytes, Files.readAllBytes(new File(root, "previous-restart-run/E-03-latest.json").toPath())); + } + + @Test public void genuineMeasuredPassAndExplicitManualResultRemainDistinct() throws Exception { + for (String status : new String[]{"PASS", "MANUAL_REQUIRED"}) { + File root = temporary.newFolder(); + String json = "{\"station\":\"E-03\",\"status\":\"" + status + "\"," + + "\"assertions\":[{\"id\":\"work\",\"status\":\"PASS\"}],\"manualChecks\":" + + (status.equals("MANUAL_REQUIRED") ? "[\"open GUI\"]" : "[]") + "}"; + Files.write(new File(root, "E-03-latest.json").toPath(), json.getBytes(StandardCharsets.UTF_8)); + Method check = WorksRunner.class.getDeclaredMethod("requireAcceptableStations", File.class, java.util.List.class); + check.setAccessible(true); + check.invoke(null, root, Collections.singletonList("E-03")); + } + } + + @Test public void completeSummaryMustAgreeWithEveryStation() throws Exception { + com.google.gson.JsonObject summary = new com.google.gson.JsonObject(); + com.google.gson.JsonObject counts = new com.google.gson.JsonObject(); + com.google.gson.JsonObject stations = new com.google.gson.JsonObject(); + for (String id : new String[]{"W-01", "W-02", "W-03", "W-04", "S-01", "S-02", "S-03", "S-04", + "S-05", "S-06", "S-07", "S-08", "E-01", "E-02", "E-03", "E-04", "E-05", "E-06", "E-07", "E-08", "P-API-01"}) { + stations.addProperty(id, "MANUAL_REQUIRED"); + } + counts.addProperty("MANUAL_REQUIRED", 21); + summary.add("summary", counts); + summary.add("stationStatuses", stations); + File file = temporary.newFile(); + Method check = WorksRunner.class.getDeclaredMethod("requireAcceptableSummary", File.class); + check.setAccessible(true); + Files.write(file.toPath(), summary.toString().getBytes(StandardCharsets.UTF_8)); + check.invoke(null, file); + counts.addProperty("MANUAL_REQUIRED", 20); + Files.write(file.toPath(), summary.toString().getBytes(StandardCharsets.UTF_8)); + try { check.invoke(null, file); fail("Missing count accepted"); } + catch (InvocationTargetException expected) { assertTrue(expected.getCause() instanceof IllegalStateException); } + } + + private void rejects(String method, String json, boolean summary) throws Exception { + File root = temporary.newFolder(); + File result = new File(root, summary ? "summary.json" : "E-03-latest.json"); + Files.write(result.toPath(), json.getBytes(StandardCharsets.UTF_8)); + Method check = summary ? WorksRunner.class.getDeclaredMethod(method, File.class) + : WorksRunner.class.getDeclaredMethod(method, File.class, java.util.List.class); + check.setAccessible(true); + try { + if (summary) check.invoke(null, result); + else check.invoke(null, root, Collections.singletonList("E-03")); + fail("Incomplete or contradictory result accepted: " + json); + } catch (InvocationTargetException expected) { + assertTrue(expected.getCause().toString(), expected.getCause() instanceof IllegalStateException); + } + } +}