diff --git a/.gitignore b/.gitignore index 03f14c4..1566f49 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 59e968a..f63b5e6 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,13 @@ +Unreleased - Minecraft 1.14.4 + +- Keep steel frames non-opaque on dedicated servers without changing their collision or drops. + +- Keep power refused by one consumer available to the rest of its network. +- Check all accessible conveyor slots, respect stack limits and keep different item NBT separate. +- Route overflow into available slots and save both inventories after a transfer. +- Pick up source fluids without deleting waterlogged blocks or replacing solid blocks at a discharge. +- Backport measured machine jobs, strict restart checks and required GUI caption checks. + Power Advantage 2.4.5.114041 (Minecraft 1.14.4) - Move to Minecraft 1.14.4 and Forge 28.2.26, retaining existing registry names, saved-state keys and public API packages. diff --git a/README.md b/README.md index 71f188a..c76ac29 100644 --- a/README.md +++ b/README.md @@ -83,3 +83,35 @@ 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). + +The local [test-coverage backport](docs/1.14/TEST-COVERAGE-BACKPORT.md) records the +repaired candidate, measured machine jobs, restart checks and GUI captures. + +## Generator Follow-Up + +The [generator follow-up](docs/1.14/TEST-COVERAGE-BACKPORT.md#generator-follow-up) records the older `pa114-candidate-c` inputs. +Its 126 operating jobs and 126 cold checks, 39 GUI windows, 351 caption cases, +355 rejected broken controls, thirteen frozen routes and five connected routes +passed. The corrected paired 30-minute run also passed; the original POI failure +is retained. 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.14/TEST-COVERAGE-BACKPORT.md#final-t-candidate) +records profile `pa114-candidate-e` 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 13 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 13acf67..d18c50f 100644 --- a/build.gradle +++ b/build.gradle @@ -1141,14 +1141,24 @@ tasks.register('prepareCombinedEclipseLaunch') { group = 'ide' description = 'Creates an additional production-only Eclipse client launch with all three Advantage mods.' dependsOn tasks.named('verifyEclipseProductionClasspath') + dependsOn tasks.named('deobfJar') doLast { - def profile = new JsonSlurper().parse(file('test-rig/profiles/native114/dev-core.json')) + File inputProfile = file(providers.gradleProperty('advantageDevelopmentProfile').getOrElse( + 'test-rig/profiles/native114/dev-core.json')) + def profile = new JsonSlurper().parse(inputProfile) + if (profile.powerDeobfSha256) { + File powerJar = tasks.named('jar', Jar).get().archiveFile.get().asFile + String powerHash = MessageDigest.getInstance('SHA-256').digest(powerJar.bytes).encodeHex().toString() + if (!powerHash.equalsIgnoreCase(profile.powerDeobfSha256 as String)) { + throw new GradleException("PowerAdvantage does not match the dependency pin in ${inputProfile}: ${powerJar}") + } + } 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}") String hash = MessageDigest.getInstance('SHA-256').digest(jar.bytes).encodeHex().toString() if (pin == null || !hash.equalsIgnoreCase(pin.sha256 as String)) { - throw new GradleException("${id} does not match the audited dev-core dependency pin: ${jar}") + throw new GradleException("${id} does not match the dependency pin in ${inputProfile}: ${jar}") } } def launch = new groovy.xml.XmlParser().parse(file('runClient.launch')) diff --git a/docs/1.14/PORT-STATUS.md b/docs/1.14/PORT-STATUS.md index 6c260a2..ded0cc7 100644 --- a/docs/1.14/PORT-STATUS.md +++ b/docs/1.14/PORT-STATUS.md @@ -1,5 +1,9 @@ # Advantage 1.14.4 Port +> The earlier acceptance below applies to the archived candidate. See the +> [stronger regression record](TEST-COVERAGE-BACKPORT.md) for the locally repaired +> jars and their new machine, migration, restart and GUI results. + ## Starting Point The tested 1.14.4 candidate is accepted for merging. Visuals and recipe unlocks diff --git a/docs/1.14/TEST-COVERAGE-BACKPORT.md b/docs/1.14/TEST-COVERAGE-BACKPORT.md new file mode 100644 index 0000000..ea6204c --- /dev/null +++ b/docs/1.14/TEST-COVERAGE-BACKPORT.md @@ -0,0 +1,340 @@ +# Stronger 1.14.4 Regression Tests + +This local, unpublished candidate backports the stricter 1.16 tests. The earlier +port acceptance applies to its archived jars, not to this repaired candidate. +Versions, registry names and saved-world identities are unchanged. + +## What The Tests Prove + +The operating tests measure finite inputs, useful outputs, refused transfers, +negative controls and saved state after a full process restart. Building a bay, +seeing an animation or opening a window is not counted as completed work. + +The checks exposed faults in conveyor item data, blocked slots, accepted-energy +accounting, fluid conservation, growth demand, light sampling, turbine anchoring +and targeting. A real dedicated-server harvest test also caught the steel frame +being treated as solid. Its unit test now models the server's stripped rendering +method, rather than relying on the client-only render layer. + +Machine windows retain their names, fit required captions and clamp their gauges. +The changelogs describe the player-facing repairs. + +## Tested Jars + +| Jar | SHA-256 | +| --- | --- | +| PowerAdvantage-2.4.5.114041.jar | `797B904D22AE83BE311175564373DB177E12AC8057ACF67DF74111F73ED47832` | +| SteamAdvantage-2.2.6.114041.jar | `57245CB75A0BAF469BA26F03556FEFD4AF8113D522642ED2BB3A8FA6D0855814` | +| ElectricAdvantage-2.2.6.114041.jar | `DBF0A9DF1D8C0BA25B32E2A88B47BFF7A3ABD83CF99FBB15B86A1A6B8A63AE6B` | + +## Completed Checks + +- Full build, release, dependency, Java 8 bytecode, archive and Eclipse audits. +- 443 Python protocol tests, including deliberately broken measurements. +- 126 natural-tick operating jobs and 126 exact-state restart comparisons. +- Nine redstone witnesses, checking the first loaded tick and sixteen operating ticks. +- Thirteen frozen migration routes and five retained connected source profiles, with two 2,048-tick operating intervals across restart without rebuilding imported machines. +- Thirty-minute runs in fresh and migrated-copy worlds, then restart and resumed operation. These commission the Works bays separately from the imported-machine checks. +- Real chunk unloading, partial networks, bridge interruption and extended moving drills. +- All 21 walkable bays in packaged-current and decoupled profiles, with manual checks still marked as manual. +- Five recipe-unlock boots, eighteen manufacturing checks, capability tests and 271 survival-harvest cases on each of two boots. +- Six oil-transfer lanes, including Mineralogy oil, through two complete process restarts. +- Native GUI captures: 39 windows, 351 required-caption cases and 355 rejected broken controls. +- Two independent clean source exports per mod in paths containing spaces. All twelve artifact pairs match the tested jars. This used the existing pinned cache, not a new cold-cache qualification. + +## Evidence And Limits + +Local receipts are retained under +`D:/PowerAdvantage/_verification/backport-coverage-20261030/` and +`test-rig/build/coverage-20261030-candidate-b-*`. Oil evidence is under +`test-rig/build/native114-oil-coverage-20261030-candidate-b-mineralogy`. +GUI evidence is under `build/client-resource-runs/20261030-115616-4693ddb7`. +Failed old-jar tests and superseded candidates remain available for comparison. + +These are bounded scenarios, not proof of every network, third-party mod or +multiplayer interaction. Worldless renderer checks do not prove in-world +animation. No new world-generation or cold-cache result is claimed. Manual +acceptance of the 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 `pa114-candidate-c`. 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/pa114-candidate-c/inputs.json`, +SHA-256 `7C2BFE231B57D7ED54F7769C115DE5C6ED515A9AF39877D9D672338651ECAB43`. + +| Follow-up input | SHA-256 | +| --- | --- | +| PowerAdvantage-2.4.5.114041.jar | `797B904D22AE83BE311175564373DB177E12AC8057ACF67DF74111F73ED47832` | +| SteamAdvantage-2.2.6.114041.jar | `57245CB75A0BAF469BA26F03556FEFD4AF8113D522642ED2BB3A8FA6D0855814` | +| ElectricAdvantage-2.2.6.114041.jar | `94664B7F8156EFCAAD3ED2B17F9837E5B74649D459BAC79619C2F9AB93652655` | +| AdvantageWorksTestHarness-1.1.0.114041.jar | `9CADDD42A9263CE2A0C6009B505251E3170D2F23EBEA044DF8E692DD18AACE79` | +| BaseMetals-3.0.1.114041.jar | `8C5416A59F88C41883DB2B8DCD180E2BDC29AD00EF6DF964D67B6C49AEB6EB9E` | +| OreSpawn-4.1.1.114041.jar | `219AE0FA08CCE400C3952AECEA9EB93B8C193D4337B2ECC09A382E6B1D525294` | + +| 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 confirmed: all thirteen routes completed and the runner exited successfully. Frozen state is not proof of operating imported networks. | +| Connected source machinery | PASS confirmed: all 5/5 routes on profile c. This covers the declared connected fixtures, not every player-built network. | +| Paired endurance | PASS corrected: fresh and migrated-copy worlds each completed 1,800 seconds, 30 batches and 33 verified checkpoints, then full restart and resumed processing. The first POI-failed attempt remains retained as FAIL. | + +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. + +The first endurance attempt remains FAIL for `POI data mismatch: never registered`; +its completed machine batches do not override that failed runtime audit. The +separate no-structures run has now passed shutdown, audit, restart and resumed +processing in both `fresh114` and `original110-chained113-native114`. +These are 1,800-second commissioned Works-bay tests, not endurance of untouched +imported networks or every machine. This corrected pass belongs only to profile c, +not the newer Steam-control candidate. Outstanding station checks remain +`MANUAL_REQUIRED`. + +Packaged-server receipts are under `test-rig/build/`: + +- `coverage-20261030-candidate-c-126/native114-machine-operation-results.json` +- `coverage-20261030-candidate-c-frozen/native114-refresh-results.json` +- `coverage-20261030-candidate-c-connected-current/native114-refresh-results.json` +- `coverage-20261030-candidate-c-endurance/native114-endurance-results.json` (retained FAIL) +- `coverage-20261030-candidate-c-endurance-nostructures/native114-endurance-results.json` (corrected PASS) + +The Electric release log is +`D:/PowerAdvantage/_verification/backport-coverage-20261030/pa114-ElectricAdvantage-generator-release-h.log`. +GUI evidence is under `run-client-resource-runs/20261030-154731-384c5ce2`. +Its `client-input.json` SHA-256 is +`11CFFB7FAF9657CB1ADD6026F13FE64173350C4524601DD4CE198E70DA57DCBB`; +its `production-files.json` SHA-256 is +`D0230A2DA49048414072CC0D6215863E0CD937E191537DB77A4E1D38011FAD85`. +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.14.4, profile `pa114-candidate-e`. 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/pa114-candidate-e/inputs.json`, +SHA-256 `A749AC68E1AA93B67ED83F71EC034348675459F814450C7340719A14C1F572A6`. +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.5.114041.jar | `797B904D22AE83BE311175564373DB177E12AC8057ACF67DF74111F73ED47832` | +| SteamAdvantage-2.2.6.114041.jar | `F9A8EAB913859906A0477FACF32824F03C60374BA5C7F02A0E0BCDDF22CD31FB` | +| ElectricAdvantage-2.2.6.114041.jar | `94664B7F8156EFCAAD3ED2B17F9837E5B74649D459BAC79619C2F9AB93652655` | +| AdvantageWorksTestHarness-1.1.0.114041.jar | `0FF3D6DB8CC008B9D862116974DAF0A30AD61FC5502C5229AB34050948AA6743` | +| BaseMetals-3.0.1.114041.jar | `8C5416A59F88C41883DB2B8DCD180E2BDC29AD00EF6DF964D67B6C49AEB6EB9E` | +| OreSpawn-4.1.1.114041.jar | `219AE0FA08CCE400C3952AECEA9EB93B8C193D4337B2ECC09A382E6B1D525294` | + +The earlier POI fixture failure remains recorded against its original run; it +has not been relabelled as a pass. + +| 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: 13/13 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-e-final-all/native114-machine-operation-results.json` +- `coverage-20261030-candidate-e-final-frozen/native114-refresh-results.json` +- `coverage-20261030-candidate-e-final-connected/native114-refresh-results.json` +- `coverage-20261030-candidate-e-final-endurance/native114-endurance-results.json` + +The independent control receipt is +`D:/PowerAdvantage/_verification/backport-coverage-20261030/pa114-candidate-e-final-steam-controls-audit.json`. + +Endurance covers commissioned Works bays in `fresh114` and +`original110-chained113-native114`. 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/`: + +- `pa114-PowerAdvantage-repaired-release-b.log` +- `pa114-SteamAdvantage-final-owner-callbacks-t.log` +- `pa114-ElectricAdvantage-generator-release-h.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: + +- `PA114 reproducibility b with spaces` (unchanged Power) +- `PA114 reproducibility final-electric with spaces` (unchanged Electric) +- `PA114 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-174855-75524d89`. +Its `client-input.json` SHA-256 is +`BC0553B1A9B47F808DB832C22C5A5B8EB04870F6B916C3BB1DCE8F8614DFECF0`; +its `production-files.json` SHA-256 is +`BA25137C78E44A24306A35C18BC9E364B9A789EACD0D3AD3F1083A96438B7366`. +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-e-final-broad/native114-broad-results.json` (this repository) | `93E38D2620619608A65C0C43646F03FFE498B81CCDF17D8A0D8561A0F0FC6BA9` | +| Independent native audit | `broad-result-audit-114-final14e.json` | `AC6E000E42F704A840061E068F492B6C4A496B3E819AE26BFA5E5EC09DA8A806` | +| Applied reporter replay | `broad-writer-114-applied-replay.json` | `D15FCFA5C83D9C998F961FAF16FA8B6754B2F83FC754BACA5CD16581F37D5CA5` | +| Reporter unit controls | `broad-all-114-controls-applied.log` | `B52E35CA4CFC5EF4AA4D3A393C51C827D3CB0275B0972CDBFD1D0D4DD19A7E05` | + +The broad runner changed from +`C5AD9B69465DD59312F2686059688727D0836BEFA9F31788DC44C3DD08A6CF23` to +`DB6E4A401B69B7AC13B96764A6A9F1E7081451D559C9F9DDB988D3F854B6ADCB` +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-e-final-harvest/native114-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-e-final-lifecycle/native114-lifecycle-results.json` | +| Holder partial / bridge | `coverage-20261030-candidate-e-final-holder-partial-port25581/native114-holder-partial-results.json`; `coverage-20261030-candidate-e-final-holder-bridge/native114-holder-bridge-results.json` | +| Extended reads | `coverage-20261030-candidate-e-final-extended/native114-extended-reads-results.json` | +| New holder-extended native run | `coverage-20261030-candidate-e-final-holder-extended-continuity-v/native114-holder-extended-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. + +The original holder-extended receipt remains `FAIL`, SHA-256 +`D01C2EA29B1D80A237B227EE65EBD6FB14315AB416C6F607E89B961EE2F8030C`. +Its three fresh-world machine-absence findings used only the last adjacent +38-tick interval, although the event-stable absence lasted 189 ticks. The +amended offline audit passes without rewriting that failed native receipt. +The separate continuity-v native rerun is `PASS` with exit code 0, unchanged +helpers, jars and fixtures; its receipt SHA-256 is +`5770BCCAD5F92EF0C9B3C434A37F25CC93785CCECE8584478ECFA6289373EC54`. +The new independent audit `pa114-native-continuity-v-independent.json` also +passes with no changed cases, SHA-256 +`287A6F8C08310B08F245908D8A3838C2CACB04BFDA2DDAA21EA6ED28B808F8A2`. + +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-114-final.json` SHA-256 is +`5AF61EEBFB33C76FB9259927CE8C9024EB2C7CB35F22DF804AACEA38CFEDF6E6`. + +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/simple/SimpleMachineGUI.java b/src/main/java/cyano/poweradvantage/api/simple/SimpleMachineGUI.java index 1e26dfc..34220d2 100644 --- a/src/main/java/cyano/poweradvantage/api/simple/SimpleMachineGUI.java +++ b/src/main/java/cyano/poweradvantage/api/simple/SimpleMachineGUI.java @@ -34,6 +34,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. @@ -41,7 +45,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 */ @@ -66,7 +70,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 { @@ -161,14 +165,14 @@ public Container(int windowId, PlayerInventory 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); } @OnlyIn(Dist.CLIENT) @@ -251,18 +255,46 @@ public GUIContainer(Container container, PlayerInventory playerItems, ITextCompo this.ySize = guiHeight; this.entity = container.entity; } + + @Override + public void render(int mouseX, int mouseY, float partialTicks) { + this.renderBackground(); + super.render(mouseX, mouseY, partialTicks); + this.renderHoveredToolTip(mouseX, mouseY); + if (font.getStringWidth(title.getString()) > width - 8 + && mouseY >= guiTop + 1 && mouseY < guiTop + MACHINE_TOP) { + this.renderTooltip(title.getString(), mouseX, mouseY); + } + } + + @Override + protected void drawGuiContainerForegroundLayer(int mouseX, int mouseY) { + String machineName = fitLabel(title.getString(), width - 8); + font.drawStringWithShadow(machineName, (xSize - font.getStringWidth(machineName)) / 2, 1, 0xFFFFFF); + font.drawStringWithShadow(fitLabel(playerInventory.getDisplayName().getString(), xSize - 16), + 8, MACHINE_TOP + MACHINE_HEIGHT, 0xFFFFFF); + } + + private String fitLabel(String text, int availableWidth) { + return font.getStringWidth(text) <= availableWidth ? text + : font.trimStringToWidth(text, Math.max(0, availableWidth - font.getStringWidth("..."))) + "..."; + } + @Override protected void drawGuiContainerBackgroundLayer(float f, int i, int j) { + GlStateManager.enableBlend(); + GlStateManager.blendFuncSeparate(770, 771, 1, 0); GlStateManager.color4f(1.0F, 1.0F, 1.0F, 1.0F); this.minecraft.getTextureManager().bindTexture(playerInventoryBox); int x = (width - xSize) / 2; int y = (height - ySize) / 2; final int playerInventoryWidth = 176; - final int playerInventoryHeight = 222; - this.blit(x, y, 0, 0, playerInventoryWidth, playerInventoryHeight); + final int playerInventoryHeight = 90; + this.blit(x, y + PLAYER_PANEL_TOP, 0, 132, playerInventoryWidth, playerInventoryHeight); this.minecraft.getTextureManager().bindTexture(guiDisplayImage); - this.blit(x, y, 0, 0, guiWidth, guiHeight); - drawGUIDecorations(entity, this, x, y, this.blitOffset); + this.blit(x, y + MACHINE_TOP, 0, 0, guiWidth, MACHINE_HEIGHT); + drawGUIDecorations(entity, this, x, y + MACHINE_TOP, this.blitOffset); + 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 ce22941..bb9078c 100644 --- a/src/main/java/zone/moddev/mc/poweradvantage/blocks/BlockFrame.java +++ b/src/main/java/zone/moddev/mc/poweradvantage/blocks/BlockFrame.java @@ -31,7 +31,8 @@ public BlockRenderLayer getRenderLayer() { return BlockRenderLayer.CUTOUT; } - public boolean isFullCube(BlockState bs) { + @Override + public boolean isSolid(BlockState bs) { return false; } @@ -40,8 +41,4 @@ public boolean canSilkHarvest(BlockState state, IWorldReader world, BlockPos pos } - public boolean isOpaqueCube(BlockState bs) { - return false; - } - } 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 ee99d0d..7c01784 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 89bb5c9..5820f29 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 9595c8d..d1f9a18 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 277c9b1..c32b709 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 @@ -156,8 +156,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; } } } @@ -180,10 +181,13 @@ protected static boolean transferItem(ISidedInventory src, Direction srcFace, IS 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; } } @@ -194,6 +198,8 @@ protected static boolean transferItem(ISidedInventory src, Direction srcFace, IS 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 1cffecc..b199ef0 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, Direction f) { if (target instanceof IInventory) { Direction 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 444317a..1137046 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 Direction[] directions = { Direction.DOWN, Direction.NORTH, Direction.WEST, Direction.SOUTH, Direction.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 (Direction 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 1d26e44..b0acdaa 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.block.Blocks; +import net.minecraft.block.BlockState; +import net.minecraft.block.IBucketPickupHandler; +import net.minecraft.world.IWorld; import net.minecraft.item.ItemStack; import net.minecraft.tileentity.TileEntity; import net.minecraft.util.Direction; @@ -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.fluid.Fluids.EMPTY) + getTank().fill(new FluidStack(fluid, FluidTank.BUCKET_VOLUME), true); } } super.powerUpdate(); } + static Fluid pickupSource(IWorld world, BlockPos position, BlockState state) { + if (!(state.getBlock() instanceof IBucketPickupHandler)) return net.minecraft.fluid.Fluids.EMPTY; + Fluid picked = ((IBucketPickupHandler)state.getBlock()).pickupFluid(world, position, state); + return picked == null ? net.minecraft.fluid.Fluids.EMPTY : picked; + } + private void tryPushFluid(BlockPos position, Direction 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 Direction[] directions = { Direction.UP, Direction.NORTH, Direction.WEST, Direction.SOUTH, Direction.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 (Direction 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 77bb614..7542091 100644 --- a/src/main/java/zone/moddev/mc/poweradvantage/registry/MachineGUIRegistry.java +++ b/src/main/java/zone/moddev/mc/poweradvantage/registry/MachineGUIRegistry.java @@ -93,13 +93,19 @@ public static void open(ServerPlayerEntity player, World world, BlockPos pos, in return gui.createContainer(windowId, inventory, (IInventory) tile); } @Override public ITextComponent getDisplayName() { - return tile instanceof net.minecraft.util.INameable - ? ((net.minecraft.util.INameable) tile).getDisplayName() - : new TranslationTextComponent(world.getBlockState(pos).getBlock().getTranslationKey()); + return menuTitle(tile, world.getBlockState(pos).getBlock()); } }, pos); } + 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 TranslationTextComponent(block.getTranslationKey()); + } + public Object getServerGuiElement(int id, PlayerEntity player, World world, int x, int y, int z) { ITileEntityGUI gui = DEFINITIONS.get(id); TileEntity tile = world.getTileEntity(new BlockPos(x, y, z)); diff --git a/src/main/java/zone/moddev/mc/poweradvantage/util/InventoryLocks.java b/src/main/java/zone/moddev/mc/poweradvantage/util/InventoryLocks.java index de37540..21e7057 100644 --- a/src/main/java/zone/moddev/mc/poweradvantage/util/InventoryLocks.java +++ b/src/main/java/zone/moddev/mc/poweradvantage/util/InventoryLocks.java @@ -9,11 +9,13 @@ /** Keeps automation from bypassing vanilla container locks. */ public final class InventoryLocks { // Forge 1.14 has no lock getter. Resolve its SRG field once, not once per transfer. - private static final Field CODE = ObfuscationReflectionHelper.findField(LockableTileEntity.class, "field_174901_a"); - private static final Field FIRST = ObfuscationReflectionHelper.findField( + private static final class Fields { + static final Field CODE = ObfuscationReflectionHelper.findField(LockableTileEntity.class, "field_174901_a"); + static final Field FIRST = ObfuscationReflectionHelper.findField( net.minecraft.inventory.DoubleSidedInventory.class, "field_70477_b"); - private static final Field SECOND = ObfuscationReflectionHelper.findField( + static final Field SECOND = ObfuscationReflectionHelper.findField( net.minecraft.inventory.DoubleSidedInventory.class, "field_70478_c"); + } private InventoryLocks() {} @@ -21,9 +23,9 @@ public static boolean isLocked(Object inventory) { if (inventory instanceof InventoryWrapper) return ((InventoryWrapper) inventory).isLocked(); try { if (inventory instanceof net.minecraft.inventory.DoubleSidedInventory) - return isLocked(FIRST.get(inventory)) || isLocked(SECOND.get(inventory)); + return isLocked(Fields.FIRST.get(inventory)) || isLocked(Fields.SECOND.get(inventory)); return inventory instanceof LockableTileEntity - && !((LockCode) CODE.get(inventory)).func_219964_a(ItemStack.EMPTY); + && !((LockCode) Fields.CODE.get(inventory)).func_219964_a(ItemStack.EMPTY); } catch (IllegalAccessException failure) { throw new IllegalStateException("Cannot inspect vanilla inventory lock", failure); } 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..6d9f98f --- /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() { net.minecraft.util.registry.Bootstrap.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..7819bd6 --- /dev/null +++ b/src/test/java/zone/moddev/mc/poweradvantage/machines/conveyors/ConveyorTransferTest.java @@ -0,0 +1,42 @@ +package zone.moddev.mc.poweradvantage.machines.conveyors; + +import net.minecraft.inventory.Inventory; +import net.minecraft.item.ItemStack; +import net.minecraft.item.Items; +import net.minecraft.util.Direction; +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() { net.minecraft.util.registry.Bootstrap.register(); } + + @Test public void laterEmptySlotRemainsAnAvailableDestination() { + Inventory destination = new Inventory(2); + destination.setInventorySlotContents(0, new ItemStack(Items.COBBLESTONE, 64)); + assertTrue(TileEntityConveyor.canInsertItemInto(new ItemStack(Items.DIRT), InventoryWrapper.wrap(destination), Direction.UP)); + } + + @Test public void fullStackIsNotAnAvailableDestination() { + Inventory destination = new Inventory(1); + destination.setInventorySlotContents(0, new ItemStack(Items.DIRT, 64)); + assertFalse(TileEntityConveyor.canInsertItemInto(new ItemStack(Items.DIRT), InventoryWrapper.wrap(destination), Direction.UP)); + } + + @Test public void transfersPreserveDifferentCustomItemData() { + Inventory source = new Inventory(2), target = new Inventory(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), Direction.EAST, + InventoryWrapper.wrap(target), Direction.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(Items.DIRT, count); + stack.getOrCreateTag().putString("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..24b7178 --- /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.BlockState; +import net.minecraft.block.Blocks; +import net.minecraft.block.FlowingFluidBlock; +import net.minecraft.fluid.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() { net.minecraft.util.registry.Bootstrap.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.ZERO, 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.ZERO, state.get())); + assertTrue(state.get().isAir()); + } + + @Test public void flowingWaterDoesNotProduceABucket() { + AtomicReference state = new AtomicReference<>(Blocks.WATER.getDefaultState().with(FlowingFluidBlock.LEVEL, 3)); + BlockState original = state.get(); + assertSame(Fluids.EMPTY, FluidDrainTileEntity.pickupSource(world(state), BlockPos.ZERO, 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.ZERO, 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((BlockState)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..2da4ebe --- /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.block.Blocks; +import net.minecraft.tileentity.ChestTileEntity; +import net.minecraft.tileentity.TileEntity; +import net.minecraft.tileentity.TileEntityType; +import net.minecraft.util.text.ITextComponent; +import net.minecraft.util.text.StringTextComponent; +import net.minecraft.util.text.TranslationTextComponent; +import org.junit.BeforeClass; +import org.junit.Test; +import static org.junit.Assert.*; + +public class MachineGuiTitleTest { + @BeforeClass public static void bootstrap() { + net.minecraft.util.registry.Bootstrap.register(); + } + + @Test public void defaultTitleUsesTheBlockTranslationInsteadOfAnInternalInventoryName() throws Exception { + assertEquals("block.minecraft.chest", ((TranslationTextComponent) title( + new ChestTileEntity(), Blocks.CHEST)).getKey()); + } + + @Test public void renamedMachinesKeepThePlayersFullName() throws Exception { + ChestTileEntity tile = new ChestTileEntity(); + tile.setCustomName(new StringTextComponent("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", ((TranslationTextComponent) 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/test-rig/build.gradle b/test-rig/build.gradle index 962f3c7..5628920 100644 --- a/test-rig/build.gradle +++ b/test-rig/build.gradle @@ -6,6 +6,9 @@ plugins { id 'net.minecraftforge.gradle' version '7.0.34' } +// Compiler cleanup must never remove disposable worlds or retained reports. +layout.buildDirectory = layout.projectDirectory.dir('build/gradle') + group = 'zone.moddev.mc.advantageworks' version = project.harness_version base.archivesName = 'AdvantageWorksTestHarness' diff --git a/test-rig/client-resources/README.md b/test-rig/client-resources/README.md index ec34c09..535518e 100644 --- a/test-rig/client-resources/README.md +++ b/test-rig/client-resources/README.md @@ -1,5 +1,44 @@ # Native Client Resource Check +## GUI Coverage Backport + +The new `-MachineGuiLayouts` option draws all registered machine screens through +Minecraft 1.14.4's actual container renderer. Independent English captions, +dedicated title/Inventory rows, slot alignment, native item hover, long-name +tooltips and empty/half/full readings are checked at three viewport sizes. +The combined profile requires 39 screens, 351 caption cases and 355 rejected +broken-label controls. The caption list is separate from production rendering. + +The new tests first rejected the old 1.14 screens in a native client run, whose +failed reports/images are retained. Minimal caption/slot/tooltip and bounded +display corrections are now staged for a fresh green build and native run. +Static passes and earlier-version results are not native GUI acceptance. + +Under the coordinated build/runtime lease, build the probe and then run: + +```powershell +./gradlew.bat clientResourceProbeJar +./test-rig/client-resources/run.ps1 -JavaHome '' -LaunchProfile runClientAllAdvantage.launch -RequireExtensions -MachineGuiLayouts -TimeoutSeconds 600 +``` + +The following checks start no Java process and open no world: + +```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 GUI preservation check permits only the documented 1.14 API mappings from +the reviewed caption-v2 oracle. Existing native 1.14 oil, tank and recipe probes +are checked against their frozen Git source; their fixtures and thresholds are +not replaced with newer-version code. The runner snapshots mutable development +dependencies before starting and preserves partial failures separately. + +## Existing Resource Checks + This build-only Minecraft 1.14.4 / Forge 28.2.26 probe checks PowerAdvantage, or all three Advantage mods in the combined profile, after Minecraft bakes its models and stitches its textures. It compiles against the actual mapped snapshot `20190719-1.14.3` jar. 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, supply a **new native 1.14.4 server export** @@ -61,7 +100,9 @@ the disposable launcher. Do not launch the actual client until authorized: ./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 native launch dependencies, and loads only the probe as an additional mod. Helpers launch hidden, with separate stdout/stderr logs and a bounded timeout. It retains logs, images and `client-resources.json`; missing reports, failed assertions, and model/texture errors fail the run. A runner failure gets a separate `runner-failure.json`, without replacing partial native results. Existing saves, configuration, and prior reports are untouched. `-PreflightOnly` creates an input snapshot without starting Java or claiming a graphical pass. +Each run gets a new directory under `run-client-resource-runs/`. The runner snapshots production classes/resources, uses the audited native launch dependencies, and loads only the probe as an additional mod. Helpers launch hidden, with separate stdout/stderr logs and a bounded timeout. It retains logs, images and `client-resources.json`; missing reports, failed assertions, and model/texture errors fail the run. A runner failure gets a separate `runner-failure.json`, without replacing partial native results. Existing saves, configuration, and prior reports are untouched. `-PreflightOnly` creates an input snapshot without starting Java or claiming a graphical pass. + +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. `production-files.json` hashes every selected class/resource. `client-input.json` records that manifest's hash, the Java binary, probe, launch, production metadata, every dependency archive, expansion candidate hashes and optional fixture hashes. The current Power deobf candidate is recorded separately, defaulting to `build/libs-dev/PowerAdvantage-2.4.5.114041-deobf.jar`; `-PowerCandidate` selects an explicit native candidate. That jar hash is provenance, not a claim that Eclipse/Gradle output equals the jar. Missing/unexpanded metadata or a legacy target fails preflight. The native result records actual discovered mod versions and the exact game/Forge target. Refresh candidate hashes after any main-owned rebuild before final evidence is taken. The [historical Eclipse metadata regression](../../docs/1.13/ECLIPSE-METADATA.md) explains the original failure, not native 1.14 acceptance. 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 08d1b17..9c23d58 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 @@ -64,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.5.114041".equals(versions.get("poweradvantage").getAsString()), "Wrong native Power candidate"); + for (String id : new String[]{"steamadvantage", "electricadvantage"}) { + if (versions.has(id)) require("2.2.6.114041".equals(versions.get(id).getAsString()), "Wrong native expansion candidate: " + id); + } require(minecraft.getModelManager().getMissingModel() != null, "Timed out before model baking"); require(minecraft.getLoadingGui() == null, "Timed out before native resource loading completed"); int items = 0; @@ -96,6 +100,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"); 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..c939f72 --- /dev/null +++ b/test-rig/client-resources/java/zone/moddev/mc/advantageworks/client/MachineGuiLayoutProbe.java @@ -0,0 +1,463 @@ +package zone.moddev.mc.advantageworks.client; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.mojang.blaze3d.platform.GlStateManager; +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.player.ClientPlayerEntity; +import net.minecraft.client.shader.Framebuffer; +import net.minecraft.client.world.ClientWorld; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.entity.player.PlayerInventory; +import net.minecraft.inventory.IInventory; +import net.minecraft.inventory.container.Slot; +import net.minecraft.tileentity.TileEntity; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.text.TranslationTextComponent; +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<>(); + ClientPlayerEntity 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); + ClientPlayerEntity player = (ClientPlayerEntity) allocator.getMethod("allocateInstance", Class.class) + .invoke(singleton.get(null), ClientPlayerEntity.class); + PlayerInventory inventory = new PlayerInventory(player); + Field inventoryField = PlayerEntity.class.getDeclaredField("inventory"); + inventoryField.setAccessible(true); + inventoryField.set(player, inventory); + Field cooldowns = PlayerEntity.class.getDeclaredField("cooldownTracker"); + cooldowns.setAccessible(true); + cooldowns.set(player, new net.minecraft.util.CooldownTracker()); + minecraft.player = player; + Field definitionsField = MachineGUIRegistry.class.getDeclaredField("DEFINITIONS"); + 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.ZERO); + SimpleMachineGUI.Container menu = ((SimpleMachineGUI) definition).createContainer(1, 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.init(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.item.Items.DIAMOND, 2)); + screen.init(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 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 TranslationTextComponent(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.fluid.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, Minecraft.IS_RUNNING_ON_MAC); + 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, Minecraft.IS_RUNNING_ON_MAC); + if (backgroundOnly) { + LabelMode original = screen.labels; + screen.labels = LabelMode.HIDDEN; + screen.render(-100, -100, 0F); + screen.labels = original; + } else { + Slot hotbar = screen.getContainer().inventorySlots.get(screen.getContainer().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); + GlStateManager.readPixels(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, + PlayerInventory inventory) throws Exception { + String name = String.join(" / ", java.util.Collections.nCopies(16, "John's oil reserve")); + RecordingScreen screen = new RecordingScreen(definition, menu, inventory, + new net.minecraft.util.text.StringTextComponent(name), name, "Inventory"); + screen.init(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")); + } + + 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 ClientWorld { + private net.minecraft.block.BlockState state; + private GuiWorld() { + super(null, null, net.minecraft.world.dimension.DimensionType.OVERWORLD, 0, net.minecraft.profiler.EmptyProfiler.INSTANCE, null); + throw new AssertionError("Allocate the read-only GUI fixture without a world constructor"); + } + @Override public net.minecraft.block.BlockState 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, PlayerInventory inventory, + net.minecraft.util.text.ITextComponent title, String expectedTitle, String expectedInventory) { + definition.super(menu, inventory, title); + 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 (font.getStringWidth(text) > width - 8) { + while (font.getStringWidth(text + "...") > width - 8) text = text.substring(0, text.offsetByCodePoints(text.length(), -1)); + text += "..."; + } + int textX = (176 - font.getStringWidth(text)) / 2; + font.drawStringWithShadow(text, textX, labels == LabelMode.OVER_GAUGE ? 20 : 1, 0xFFFFFF); + } + if (labels != LabelMode.TITLE_ONLY) font.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 renderTooltip(String text, int x, int y) { + titleTooltip = text; + super.renderTooltip(text, x, y); + } + Slot hovered() { return hoveredSlot; } + @Override public void blit(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.blit(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 f1cbe84..b897dc4 100644 --- a/test-rig/client-resources/run.ps1 +++ b/test-rig/client-resources/run.ps1 @@ -2,6 +2,7 @@ param([Parameter(Mandatory = $true)][string]$JavaHome, [ValidateSet('runClient.launch', 'runClientAllAdvantage.launch')][string]$LaunchProfile = 'runClient.launch', [ValidateSet('Gradle', 'Eclipse')][string]$OutputSource = 'Gradle', [switch]$RequireExtensions, + [switch]$MachineGuiLayouts, [string]$RecipeBookFixtures, [string]$PowerCandidate, [switch]$PreflightOnly, @@ -30,13 +31,31 @@ function Assert-NativeMetadata([string]$Metadata, [string]$Id) { if ($header -notmatch ('(?m)^modId\s*=\s*"' + [regex]::Escape($Id) + '"[ \t]*(?:#[^\r\n]*)?\r?$') -or $header -notmatch '(?m)^version\s*=\s*"[^"\r\n]*\.114\d+"[ \t]*(?:#[^\r\n]*)?\r?$' -or $Metadata -notmatch '(?m)^loaderVersion\s*=\s*"\[28,') { throw "Not a native Forge 28 candidate: $Id" } + $expected = @{ poweradvantage = '2.4.5.114041'; steamadvantage = '2.2.6.114041'; electricadvantage = '2.2.6.114041' } + 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.14.4' -or $Environment.FORGE_VERSION -ne '28.2.26' -or + $Environment.MCP_MAPPINGS -ne 'snapshot_20190719-1.14.3' -or $Environment.MCP_VERSION -ne '20190829.143755' -or + $Environment.target -ne 'fmluserdevclient') { throw 'Regenerate the native 1.14.4 / Forge 28.2.26 snapshot 20190719-1.14.3 client launch.' } + if ($Attributes['org.eclipse.jdt.launching.PROGRAM_ARGUMENTS'] -notmatch '1\.14\.4-28\.2\.26' -or + $Attributes['org.eclipse.jdt.launching.PROGRAM_ARGUMENTS'] -match '1\.(?:13\.2|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\.(?:13\.2|15\.2|16\.5)|[\\/]PA11[356][\\/]') { 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 ('build/client-resource-runs/' + [DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss') + '-' + [guid]::NewGuid().ToString('N').Substring(0, 8)) -$inputReport = [ordered]@{ minecraft = '1.14.4'; forge = '28.2.26'; qualification = 'UNQUALIFIED'; output_source = $OutputSource; launch_profile = $LaunchProfile; require_extensions = [bool]$RequireExtensions } +$runtime = Join-Path $project ('run-client-resource-runs/' + [DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss') + '-' + [guid]::NewGuid().ToString('N').Substring(0, 8)) +$inputReport = [ordered]@{ minecraft = '1.14.4'; forge = '28.2.26'; mappings = 'snapshot_20190719-1.14.3'; mcp = '20190829.143755'; 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 { @@ -50,7 +69,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="\[28,29\)"' -or $probeMetadata -notmatch 'versionRange="\[1\.14\.4\]"') { throw 'Rebuild the native 1.14 probe; do not substitute the 1.13 probe.' } +if ($probeMetadata -notmatch 'loaderVersion="\[28,29\)"' -or $probeMetadata -notmatch 'versionRange="\[1\.14\.4\]"' -or + $probeMetadata -notmatch 'versionRange="\[28\.2\.26\]"' -or $probeMetadata -notmatch 'versionRange="\[2\.4\.5\.114041,2\.5\.0\)"') { throw 'Rebuild the native 1.14 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) { @@ -58,10 +78,7 @@ foreach ($map in $launch.launchConfiguration.mapAttribute) { foreach ($entry in $map.mapEntry) { $environment[$entry.key] = $entry.value } } } -if ($environment.MC_VERSION -ne '1.14.4' -or $environment.FORGE_VERSION -ne '28.2.26' -or $environment.target -ne 'fmluserdevclient') { throw 'Regenerate the native 1.14.4 / Forge 28.2.26 client launch.' } -if ($attributes['org.eclipse.jdt.launching.PROGRAM_ARGUMENTS'] -notmatch '1\.14\.4-28\.2\.26' -or - $attributes['org.eclipse.jdt.launching.PROGRAM_ARGUMENTS'] -match '1\.13\.2|--(?: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.' } +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') { @@ -119,13 +136,24 @@ $archives = @(foreach ($entry in $classpathNode.listEntry) { if ($classpathEntry.runtimeClasspathEntry.externalArchive) { $classpathEntry.runtimeClasspathEntry.externalArchive } }) $archiveRecords = @() +$launchArchives = @() $runtimeCandidates = @{} foreach ($archive in $archives) { - if ($archive -match '(?:forge|client-extra)-1\.13\.2|[\\/]PA113[\\/]') { throw "Legacy runtime archive: $archive" } + Assert-NativeArchive $archive $record = Get-Record $archive - $archiveRecords += $record $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 @@ -133,7 +161,10 @@ foreach ($archive in $archives) { $runtimeCandidates[$id] = $record } } + $archiveRecords += $record + $launchArchives += $record.path } +$archives = $launchArchives $inputReport.archives = $archiveRecords $inputReport.runtime_candidates = $runtimeCandidates if (-not ($archives -match 'forge-1\.14\.4-28\.2\.26-snapshot-20190719-1\.14\.3\.jar$')) { throw 'Missing actual native mapped Forge runtime.' } @@ -141,6 +172,7 @@ if ($RequireExtensions -and (-not $runtimeCandidates.ContainsKey('steamadvantage $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 @@ -175,6 +207,14 @@ if (-not (Test-Path -LiteralPath $resultPath)) { throw "Client did not produce a $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.14.4' -or $result.forge -ne '28.2.26') { throw 'Result is not native 1.14.4 / Forge 28.2.26 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)" } } @@ -184,6 +224,7 @@ if ($log -match "Unable to load model:.*${namespaces}:|Exception loading blockst 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. 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..a47fdc5 --- /dev/null +++ b/test-rig/client-resources/test-gui-contract-negative-controls.ps1 @@ -0,0 +1,34 @@ +param([string]$ReferenceRoot = (Join-Path $PSScriptRoot '../../../../PA116/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..86ab2eb --- /dev/null +++ b/test-rig/client-resources/test-gui-contract.ps1 @@ -0,0 +1,84 @@ +param([string]$ReferenceRoot = (Join-Path $PSScriptRoot '../../../../PA116/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 Normalize-Gui([string]$Source) { + # Only native 1.14 API names differ. Pixel oracles, fixtures and limits do not. + $pairs = [ordered]@{ + 'import com.mojang.blaze3d.matrix.MatrixStack;' = '' + 'net.minecraft.client.resources.I18n.get(' = 'net.minecraft.client.resources.I18n.format(' + 'getDeclaredField("cooldowns")' = 'getDeclaredField("cooldownTracker")' + '.defaultBlockState()' = '.getDefaultState()' + 'getDeclaredField("isClientSide")' = 'getDeclaredField("isRemote")' + 'tile.setLevelAndPosition(' = 'tile.setWorldAndPos(' + 'tile.setWorldAndPos(world, BlockPos.ZERO);' = 'tile.setWorld(world); tile.setPos(BlockPos.ZERO);' + '.createMenu(' = '.createContainer(' + 'menu.slots' = 'menu.inventorySlots' + '.getMenu()' = '.getContainer()' + '.slots.get(' = '.inventorySlots.get(' + '.slots.size()' = '.inventorySlots.size()' + 'slot.x' = 'slot.xPos'; 'slot.y' = 'slot.yPos' + 'other.x' = 'other.xPos'; 'other.y' = 'other.yPos' + 'hotbar.x' = 'hotbar.xPos'; 'hotbar.y' = 'hotbar.yPos' + 'inventory.setItem(' = 'inventory.setInventorySlotContents(' + '.setValue(' = '.with(' + 'net.minecraft.state.Property' = 'net.minecraft.state.IProperty' + 'Minecraft.ON_OSX' = 'Minecraft.IS_RUNNING_ON_MAC' + '.bindWrite(' = '.bindFramebuffer(' + '.destroyBuffers()' = '.deleteFramebuffer()' + 'minecraft.getMainRenderTarget()' = 'minecraft.getFramebuffer()' + 'screen.render(new MatrixStack(), ' = 'screen.render(' + 'super(null, null, net.minecraft.world.World.OVERWORLD, null, 0, () -> net.minecraft.profiler.EmptyProfiler.INSTANCE, null, false, 0);' = 'super(null, null, net.minecraft.world.dimension.DimensionType.OVERWORLD, 0, net.minecraft.profiler.EmptyProfiler.INSTANCE, null);' + 'protected void renderLabels(MatrixStack matrices, int x, int y)' = 'protected void drawGuiContainerForegroundLayer(int x, int y)' + 'super.renderLabels(matrices, x, y)' = 'super.drawGuiContainerForegroundLayer(x, y)' + 'RenderSystem.' = 'GlStateManager.' + 'font.width(' = 'font.getStringWidth(' + 'font.drawShadow(matrices, ' = 'font.drawStringWithShadow(' + 'public void render(MatrixStack matrices, int x, int y, float ticks)' = 'public void render(int x, int y, float ticks)' + 'super.render(matrices, x, y, ticks)' = 'super.render(x, y, ticks)' + 'protected void renderTooltip(MatrixStack matrices, int x, int y)' = 'protected void renderHoveredToolTip(int x, int y)' + 'super.renderTooltip(matrices, x, y)' = 'super.renderHoveredToolTip(x, y)' + 'public void renderTooltip(MatrixStack matrices, net.minecraft.item.ItemStack item, int x, int y)' = 'public void renderTooltip(net.minecraft.item.ItemStack item, int x, int y)' + 'super.renderTooltip(matrices, item, x, y)' = 'super.renderTooltip(item, x, y)' + 'public void renderTooltip(MatrixStack matrices, net.minecraft.util.text.ITextComponent text, int x, int y)' = 'public void renderTooltip(String text, int x, int y)' + 'titleTooltip = text.getString();' = 'titleTooltip = text;' + 'super.renderTooltip(matrices, text, x, y)' = 'super.renderTooltip(text, x, y)' + } + $Source = $Source.Replace("`r`n", "`n") + foreach ($pair in $pairs.GetEnumerator()) { $Source = $Source.Replace($pair.Key, $pair.Value) } + $Source = $Source.Replace('xPosPos', 'xPos').Replace('yPosPos', 'yPos') + # The red fixture must compile before the production title resolver exists. + # Its fallback reproduces the old provider, never the expected label list. + $Source = [regex]::Replace($Source, '(?s)Method titleFactory = MachineGUIRegistry.class.getDeclaredMethod\("menuTitle", TileEntity.class, Block.class\);\s*titleFactory.setAccessible\(true\);\s*net.minecraft.util.text.ITextComponent title = \(net.minecraft.util.text.ITextComponent\) titleFactory.invoke\(null, tile, block\);', 'net.minecraft.util.text.ITextComponent title = menuTitle(tile, block);') + $Source = [regex]::Replace($Source, '(?s) private static net.minecraft.util.text.ITextComponent menuTitle\(.*?(?= private static void verifySlots\()', '') + $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 '8D89215AEFFDDC96A7175AE84F315DFF009D6E0F0FC1942C986E626B5806AFFF') 'GUI reference changed; do not silently substitute a different accepted oracle.' +$expected = Normalize-Gui (Get-Content -LiteralPath $reference -Raw) +$actual = Normalize-Gui (Get-Content -LiteralPath (Join-Path $PSScriptRoot $relative) -Raw) +Assert-Contract ($actual -ceq $expected) 'GUI fixtures, caption pixels, slot coordinates, helper bounds or negative controls changed beyond the declared 1.14 API 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 index 2866c53..924e21f 100644 --- a/test-rig/client-resources/test-launcher.ps1 +++ b/test-rig/client-resources/test-launcher.ps1 @@ -1,3 +1,4 @@ +param([switch]$StaticOnly) $ErrorActionPreference = 'Stop' $tokens = $null $errors = $null @@ -16,6 +17,52 @@ function Assert-Throws([scriptblock]$Action) { 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="[28,)" # native Forge @@ -30,14 +77,54 @@ versionRange="[1.14.4]" Assert-NativeMetadata $native 'poweradvantage' $checks++ Assert-Throws { Assert-NativeMetadata ($native.Replace('2.4.5.114041','2.4.4.113021')) 'poweradvantage' } -Assert-Throws { Assert-NativeMetadata ($native.Replace('[28,)','[25,)')) 'poweradvantage' } +Assert-Throws { Assert-NativeMetadata ($native.Replace('2.4.5.114041','2.4.4.114041')) 'poweradvantage' } +Assert-Throws { Assert-NativeMetadata ($native.Replace('2.4.5.114041','2.4.4.115021')) 'poweradvantage' } +Assert-Throws { Assert-NativeMetadata ($native.Replace('[28,)','[31,)')) 'poweradvantage' } Assert-Throws { Assert-NativeMetadata ($native.Replace('2.4.5.114041','${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.5.114041','2.2.6.114041') + Assert-NativeMetadata $expansion $id + $checks++ + Assert-Throws { Assert-NativeMetadata ($expansion.Replace('2.2.6.114041','2.2.5.114041')) $id } +} +$environment = @{ MC_VERSION = '1.14.4'; FORGE_VERSION = '28.2.26'; MCP_MAPPINGS = 'snapshot_20190719-1.14.3'; MCP_VERSION = '20190829.143755'; target = 'fmluserdevclient' } +$attributes = @{ 'org.eclipse.jdt.launching.PROGRAM_ARGUMENTS' = '--main net.minecraftforge.userdev.LaunchTesting --cache D:\native\1.14.4-28.2.26 --'; '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.13.2-25.0.223') { + $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.14.4-28.2.26-snapshot-20190719-1.14.3.jar' +$checks++ +foreach ($path in 'D:\PA115\client.jar','D:\PA113\client.jar','D:\native\forge-1.16.5-36.2.34.jar','D:\native\client-extra-1.13.2.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\.14\.4'" -or $reports[0].Right.Extent.Text -notmatch "mcp = '20190829\.143755'") { 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.5.114041-deobf.jar' Assert-NativeMetadata (Read-JarMetadata $power) 'poweradvantage' @@ -59,7 +146,4 @@ foreach ($entry in ($launch.launchConfiguration.listAttribute | Where-Object { $ } } if ($found.Count -ne 4) { throw 'The actual combined launch must expose all four native dependency/expansion candidates.' } -$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++ 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..fe6288d --- /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-volume'; file='OilRenderProbe'; before='contents.getAmount() != 1000'; after='contents.getAmount() != 100' }, + @{ 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..2a326c6 --- /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 = 'ef08e5cd134dc75bd73518bf4c90a6711e67280f' +$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.14 source commit is unavailable.' } +Assert-Contract ($resolved.Trim() -eq $referenceCommit) 'Do not silently replace the native 1.14 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.14 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.14 probe resource pack must keep format 4.' +$metadata = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'resources/META-INF/mods.toml') -Raw +foreach ($pin in 'loaderVersion="[28,29)"','versionRange="[1.14.4]"','versionRange="[28.2.26]"','versionRange="[2.4.5.114041,2.5.0)"') { + 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.14 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/native114_broad.py b/test-rig/migration/native114_broad.py index ac6e7e4..7b10166 100644 --- a/test-rig/migration/native114_broad.py +++ b/test-rig/migration/native114_broad.py @@ -13,6 +13,7 @@ import struct import time import traceback +from broad_campaign_status import campaign_document, campaign_status import nbtlib from matrix import Server, digest, plain, stage, write_json @@ -304,14 +305,22 @@ def main(): 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 / "native114-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): - results[name] = run_profile(args, profile, output, name, args.port + index, stations) - write_json(output / "native114-broad-results.json", dict( - status="PASS" if all(r["status"] == "PASS" for r in results.values()) else "FAIL", - targetManifest=str(args.target_manifest.resolve()), targetManifestSha256=digest(args.target_manifest), - runnerSha256=source_hash, stationDefinitionSha256=digest(stations_path), profiles=results)) + 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) - return int(any(r["status"] != "PASS" for r in results.values())) + write_result(finished=True) + return int(campaign_status(names, results, finished=True) != "PASS") if __name__ == "__main__": diff --git a/test-rig/migration/native114_code.py b/test-rig/migration/native114_code.py new file mode 100644 index 0000000..6e349ed --- /dev/null +++ b/test-rig/migration/native114_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 = "native114-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 native114 code inventory") + if code_files(root) != files: + raise AssertionError("Native114 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/native114_connected.py b/test-rig/migration/native114_connected.py index 1961d01..0dfcb30 100644 --- a/test-rig/migration/native114_connected.py +++ b/test-rig/migration/native114_connected.py @@ -8,6 +8,7 @@ import json from pathlib import Path import re +import shutil import socket from unittest.mock import patch @@ -15,6 +16,7 @@ from matrix import WORLD, compare, digest, fingerprint, write_json from native114 import (LAUNCHER, native_normalized, output_path, pinned_profile, require_clean_log, require_disjoint) +from native114_code import CodeCustody, INVENTORY, verify_code def read_json(path): @@ -103,7 +105,11 @@ def verify_source(args): def prepare_target(runtime, profile, definition, port, source, pins): if profile[1] != LAUNCHER: raise ValueError("Only the genuine native114 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" @@ -121,7 +127,7 @@ def prepare_target(runtime, profile, definition, port, source, pins): return launcher -class NativeConnectedServer(connected.ConnectedServer): +class NativeConnectedServer(CodeCustody, connected.ConnectedServer): def __init__(self, runtime, java, launcher, label, on_loaded): if launcher != LAUNCHER: raise AssertionError("Refusing a non-native114 process") @@ -156,6 +162,8 @@ def continued_output(first, last): def run(args): + from semantic_machine_audit import connected_interval + from native114_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) @@ -186,6 +194,7 @@ def run(args): 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: @@ -200,8 +209,10 @@ def on_loaded(loaded_label): 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": + if result["status"] != "PASS" or resources["status"] != "PASS": raise AssertionError(label + " native114 preservation failed") def boot(current, settings, selected_launcher, selected_label): @@ -213,6 +224,9 @@ def boot(current, settings, selected_launcher, selected_label): 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) diff --git a/test-rig/migration/native114_endurance.py b/test-rig/migration/native114_endurance.py index af8a304..d44ee99 100644 --- a/test-rig/migration/native114_endurance.py +++ b/test-rig/migration/native114_endurance.py @@ -1,4 +1,4 @@ -"""Measured native114 endurance using unchanged batch and conservation oracles.""" +"""Measured native114 endurance with historical and independent semantic checks.""" import argparse from collections import Counter @@ -10,7 +10,11 @@ import time import traceback +import nbtlib + import endurance +import semantic_machine_audit +from native114_code import INVENTORY, verify_code from audit_endurance import expected_inventory_acknowledgements from matrix import WORLD, compare, digest, fingerprint, write_json from native114 import native_normalized, output_path, pinned_profile, require_disjoint @@ -19,11 +23,62 @@ NAMES = ("fresh114", "original110-chained113-native114") +FRESH_WORLD_SEED = 114041 + + +def configure_fresh_world(runtime): + endurance.require(not (runtime / WORLD).exists(), "Never reconfigure an existing endurance world") + properties = runtime / "server.properties" + overrides = {"generate-structures": "false", "level-seed": str(FRESH_WORLD_SEED), + "level-type": "FLAT", "generator-settings": ""} + retained = [line for line in properties.read_text(encoding="utf-8").splitlines() + if line.partition("=")[0].strip() not in overrides] + properties.write_text("\n".join(retained + [key + "=" + value for key, value in overrides.items()]) + "\n", + encoding="utf-8") + + +def prepare_endurance(runtime, profile, definition, port, source=None): + launcher = prepare(runtime, profile, definition, port, source) + if source is None: + configure_fresh_world(runtime) + return launcher + + +def verify_fresh_world(runtime, phase): + path = runtime / WORLD / "level.dat" + data = nbtlib.load(path)["Data"] + observed = {"map_features": int(data["MapFeatures"]) if "MapFeatures" in data else None, + "seed": int(data["RandomSeed"]) if "RandomSeed" in data else None, + "generator": str(data["generatorName"]) if "generatorName" in data else None, + "data_version": int(data["DataVersion"]) if "DataVersion" in data else None} + expected = {"map_features": 0, "seed": FRESH_WORLD_SEED, "generator": "flat", "data_version": 1976} + failures = [key for key, value in expected.items() if observed[key] != value] + receipt = dict(status="FAIL" if failures else "PASS", observed=observed, expected=expected, + failures=failures, level_dat_sha256=digest(path), + scope="Saved native world settings before rig construction; no world NBT edits") + output = runtime / ("native-world-settings-" + phase + ".json") + endurance.require(not output.exists(), "Never overwrite native world-settings evidence") + write_json(output, receipt) + endurance.require(not failures, "Fresh native114 endurance world settings differ: " + ", ".join(failures)) + return receipt class NativeEnduranceRig(endurance.Rig): + 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 self.name == "fresh114": + self.command("save-all flush", "Saved the game") + verify_fresh_world(self.runtime, phase) if phase != "endurance-restart": return # Restore only chunk tickets, never station actions or machine NBT. @@ -60,6 +115,12 @@ def audit(root, require_endurance=True): 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") + for phase in ("endurance", "endurance-restart"): + settings = read_json(root / "fresh114" / ("native-world-settings-" + phase + ".json")) + expected = {"map_features": 0, "seed": FRESH_WORLD_SEED, "generator": "flat", "data_version": 1976} + endurance.require(settings.get("status") == "PASS" and settings.get("expected") == expected + and settings.get("observed") == expected and not settings.get("failures") + and bool(settings.get("level_dat_sha256")), "Missing native fresh-world settings proof") 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") @@ -102,6 +163,8 @@ def run(args): require_disjoint(output, protected) source_before, history = verify_source(args) target = pinned_profile(args.target_manifest.resolve(), "target114") + target_code = read_json(args.target_manifest)["target114"].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): @@ -111,7 +174,7 @@ def run(args): 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__), definition_path] + helpers = [Path(endurance.__file__), Path(__file__), Path(semantic_machine_audit.__file__), definition_path] before_helpers = {str(p): digest(p) for p in helpers} output.mkdir(parents=True, exist_ok=False) for path in helpers: @@ -133,7 +196,9 @@ def run(args): try: for index, name in enumerate(NAMES): runtime = output / name - launcher = prepare(runtime, target, [], args.port + index, args.source if index else None) + launcher = prepare_endurance(runtime, target, [], args.port + index, args.source if index else None) + verify_code(runtime, target_code) + write_json(runtime / INVENTORY, target_code) for marker in ("migration-operate.allow", "advantage-works.allow"): (runtime / marker).touch() rig = NativeEnduranceRig(name, runtime, args.java8, launcher, definitions) diff --git a/test-rig/migration/native114_harvest.py b/test-rig/migration/native114_harvest.py new file mode 100644 index 0000000..a62fb61 --- /dev/null +++ b/test-rig/migration/native114_harvest.py @@ -0,0 +1,152 @@ +"""Actual survival mining and silk-touch drops in two clean native114 boots.""" + +import argparse +from datetime import datetime, timezone +from pathlib import Path +import traceback + +from matrix import Server, digest, write_json +from native114 import output_path, pinned_profile, require_disjoint +from native114_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 = "native114-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.14.4" + or actual.keys() != EXPECTED.keys() | {"solar/native-solidity"} + or len(rows) != len(EXPECTED) + 1): + raise AssertionError("Incomplete native114 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("Native114 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 native114 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(), "target114") + 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") + 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() + write_json(output / "native114-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/native114_legacy_snbt.py b/test-rig/migration/native114_legacy_snbt.py new file mode 100644 index 0000000..31a8369 --- /dev/null +++ b/test-rig/migration/native114_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/native114_machine_operations.py b/test-rig/migration/native114_machine_operations.py new file mode 100644 index 0000000..46a3576 --- /dev/null +++ b/test-rig/migration/native114_machine_operations.py @@ -0,0 +1,255 @@ +"""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 native114 import RIG, LAUNCHER, output_path, pinned_profile, require_disjoint +from native114_code import Server, code_files, verify_code, INVENTORY +from native114_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.5.114041.jar", + "steamadvantage": RIG.parent.parent / "SteamAdvantage/SteamAdvantage/build/libs/SteamAdvantage-2.2.6.114041.jar", + "electricadvantage": RIG.parent.parent / "ElectricAdvantage/ElectricAdvantage/build/libs/ElectricAdvantage-2.2.6.114041.jar", + "advantageworkstest": RIG / "build/gradle/libs/AdvantageWorksTestHarness-1.1.0.114041.jar", + } + if getattr(args, "original_production", False): + replacements = {key: value for key, value in replacements.items() if key == "advantageworkstest"} + target = copy.deepcopy(base["target114"]) + 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, {"target114": target, "baseManifest": str(args.base.resolve()), "baseSha256": digest(args.base)}) + pinned_profile(path, "target114") + print(str(path), flush=True) + return 0 + + +def run(args): + output = output_path(args.output) + target = pinned_profile(args.manifest.resolve(), "target114") + 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)["target114"].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("native114_lifecycle.py"), + Path(__file__).with_name("native114_code.py"), Path(__file__).with_name("native114.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") + write_json(output / "native114-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 / "native114-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/native114_redstone_reload.py b/test-rig/migration/native114_redstone_reload.py new file mode 100644 index 0000000..17cbe61 --- /dev/null +++ b/test-rig/migration/native114_redstone_reload.py @@ -0,0 +1,379 @@ +"""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 native114 import output_path, pinned_profile, require_disjoint +from native114_code import Server, INVENTORY, verify_code +from native114_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, "target114") + 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["target114"]) + 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 native114_code import code_files + target["serverCode"] = {"files": code_files(Path(target["seed"]))} + manifest = destination / "inputs.json" + write_json(manifest, dict(target114=target, baseManifest=str(base), baseSha256=digest(base))) + pinned_profile(manifest, "target114") + print(str(manifest), flush=True) + return 0 + + +def run(args): + output = output_path(args.output) + manifest = args.manifest.resolve() + target = pinned_profile(manifest, "target114") + expected_code = read_json(manifest)["target114"].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_native114_redstone_reload.py")] + helpers += [Path(__file__).with_name(name) for name in ("native114.py", "native114_code.py", "native114_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 / "native114-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/native114_refresh.py b/test-rig/migration/native114_refresh.py index e553cf1..2705fac 100644 --- a/test-rig/migration/native114_refresh.py +++ b/test-rig/migration/native114_refresh.py @@ -13,6 +13,7 @@ import native114 import native114_connected +from native114_resource_audit import compare_snapshot_resources from matrix import digest, write_json from native114_lifecycle import read_json @@ -37,7 +38,10 @@ def run(args): output.mkdir(parents=True, exist_ok=False) report = dict(status="RUNNING", scope=args.kind, previous=str(args.previous.resolve()), previousSha256=digest(args.previous), targetManifest=str(args.target_manifest.resolve()), - targetManifestSha256=digest(args.target_manifest), routes=[]) + targetManifestSha256=digest(args.target_manifest), observationTicks=2048, routes=[]) + helpers = [Path(__file__), Path(native114.__file__), Path(native114_connected.__file__), + Path(__file__).with_name("semantic_machine_audit.py"), Path(__file__).with_name("native114_resource_audit.py")] + report["protocolSha256"] = {str(path): digest(path) for path in helpers} rows = previous["routes"] if args.kind == "frozen" else previous["sources"] for index, row in enumerate(rows): outcome = dict(index=index, status="RUNNING") @@ -68,6 +72,10 @@ def native_stage(runtime, profile): return launcher with patch.object(native114, "stage", native_stage): code = native114.run(settings) + resources = {phase: compare_snapshot_resources(source / selected, + settings.output / ("migration-" + phase + ".json")) for phase in ("converted", "restarted")} + write_json(settings.output / "exact-resource-comparison.json", resources) + if any(value["status"] != "PASS" for value in resources.values()): code = 1 outcome.update(source=str(source), sourceSnapshot=selected, runtime=str(settings.output), comparison=read_json(settings.output / "state-comparison.json")) else: @@ -83,7 +91,7 @@ def native_stage(runtime, profile): 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}", - ticks=512, port=args.port) + ticks=2048, port=args.port) code = native114_connected.run(settings) outcome.update(source=str(source), runtime=str(settings.output), result=str(settings.output / "native114-connected-results.json")) @@ -94,6 +102,9 @@ def native_stage(runtime, profile): print(f"{args.kind}/{index + 1}/{len(rows)}: {outcome['status']}", flush=True) report["status"] = "PASS" if len(rows) == (13 if args.kind == "frozen" else 5) and all( row["status"] == "PASS" for row in report["routes"]) else "FAIL" + report["protocolUnchanged"] = {str(path): digest(path) for path in helpers} == report["protocolSha256"] + if (not report["protocolUnchanged"] or digest(args.target_manifest) != report["targetManifestSha256"] + or digest(args.previous) != report["previousSha256"]): report["status"] = "FAIL" write_json(output / "native114-refresh-results.json", report) return int(report["status"] != "PASS") diff --git a/test-rig/migration/native114_resource_audit.py b/test-rig/migration/native114_resource_audit.py new file mode 100644 index 0000000..22298b1 --- /dev/null +++ b/test-rig/migration/native114_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 native114 import native_normalized +from native114_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/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_native114_code.py b/test-rig/migration/test_native114_code.py new file mode 100644 index 0000000..4e2bcce --- /dev/null +++ b/test-rig/migration/test_native114_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 native114_code import INVENTORY, Server, code_files, verify_code + + +class Native114CodeCustodyTest(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_native114_connected.py b/test-rig/migration/test_native114_connected.py index a26ce27..3e2311f 100644 --- a/test-rig/migration/test_native114_connected.py +++ b/test-rig/migration/test_native114_connected.py @@ -180,7 +180,9 @@ def test_target_prepare_copies_only_fixture_and_has_no_build_authority(self): jar = self.root / "target.jar" jar.write_bytes(b"unit-test-target-not-executed") runtime = self.root / "target" - pins = {"launcher_sha256": digest(seed / LAUNCHER), "jars": [{"path": str(jar), "sha256": digest(jar)}]} + from native114_code import code_files + pins = {"launcher_sha256": digest(seed / LAUNCHER), "jars": [{"path": str(jar), "sha256": digest(jar)}], + "serverCode": {"files": code_files(seed)}} self.assertEqual(native.prepare_target(runtime, (seed, LAUNCHER, [jar]), self.definition, 25576, args.source, pins), LAUNCHER) self.assertEqual(fingerprint(runtime / WORLD), fingerprint(args.source / WORLD)) self.assertFalse((runtime / "connected-build.allow").exists()) @@ -201,12 +203,13 @@ def target(self, args): (seed / LAUNCHER).write_bytes(b"unit-test-launcher-never-executed") jars = [] for identity in ("poweradvantage", "steamadvantage", "electricadvantage", "advantageworkstest", "orespawn"): - jar = seed / (identity + ".jar") + jar = self.root / (identity + ".jar") with zipfile.ZipFile(jar, "w") as archive: archive.writestr("META-INF/mods.toml", f'[[mods]]\nmodId="{identity}"\nversion="1.0.0.114041"\n') jars.append(dict(path=str(jar), id=identity, version="1.0.0.114041", sha256=digest(jar))) + from native114_code import code_files write_json(args.target_manifest, {"target114": dict(seed=str(seed), launcher=LAUNCHER, - launcher_sha256=digest(seed / LAUNCHER), jars=jars)}) + launcher_sha256=digest(seed / LAUNCHER), jars=jars, serverCode={"files": code_files(seed)})}) def test_runtime_failure_is_retained_and_source_is_still_rechecked(self): args = self.source() @@ -239,7 +242,9 @@ def test_candidate_change_after_preflight_cannot_replace_pinned_bytes(self): (seed / LAUNCHER).write_bytes(b"launcher-not-executed") jar = self.root / "candidate.jar" jar.write_bytes(b"pinned") - pins = {"launcher_sha256": digest(seed / LAUNCHER), "jars": [{"path": str(jar), "sha256": digest(jar)}]} + from native114_code import code_files + pins = {"launcher_sha256": digest(seed / LAUNCHER), "jars": [{"path": str(jar), "sha256": digest(jar)}], + "serverCode": {"files": code_files(seed)}} jar.write_bytes(b"evolving-after-preflight") with self.assertRaises(AssertionError): native.prepare_target(self.root / "target", (seed, LAUNCHER, [jar]), self.definition, 25576, args.source, pins) diff --git a/test-rig/migration/test_native114_endurance.py b/test-rig/migration/test_native114_endurance.py index fb63bdb..891cf88 100644 --- a/test-rig/migration/test_native114_endurance.py +++ b/test-rig/migration/test_native114_endurance.py @@ -5,14 +5,108 @@ import unittest from unittest.mock import patch +import nbtlib + import endurance -from matrix import write_json -from native114_endurance import NativeEnduranceRig, audit +from matrix import WORLD, digest, write_json +from native114_endurance import (FRESH_WORLD_SEED, NativeEnduranceRig, audit, + configure_fresh_world, prepare_endurance, verify_fresh_world) class NativeEnduranceTests(unittest.TestCase): + def native_world(self, runtime, **changes): + values = {"MapFeatures": nbtlib.Byte(0), "RandomSeed": nbtlib.Long(FRESH_WORLD_SEED), + "generatorName": nbtlib.String("flat"), "DataVersion": nbtlib.Int(1976)} + values.update(changes) + folder = runtime / WORLD + folder.mkdir() + path = folder / "level.dat" + nbtlib.File({"Data": nbtlib.Compound(values)}, gzipped=True).save(path) + return path + + def test_fresh_setup_disables_structures_and_sets_one_fixed_seed(self): + with tempfile.TemporaryDirectory() as directory: + runtime = Path(directory) + properties = runtime / "server.properties" + properties.write_text("level-name=AdvantageWorksMigration\nserver-port=25590\ngenerate-structures=true\nlevel-seed=\nlevel-seed=old\n") + configure_fresh_world(runtime) + lines = properties.read_text().splitlines() + values = dict(line.split("=", 1) for line in lines) + self.assertEqual("false", values["generate-structures"]) + self.assertEqual(str(FRESH_WORLD_SEED), values["level-seed"]) + self.assertEqual("FLAT", values["level-type"]) + self.assertEqual("", values["generator-settings"]) + self.assertEqual("25590", values["server-port"]) + self.assertEqual("AdvantageWorksMigration", values["level-name"]) + self.assertEqual(1, sum(line.startswith("level-seed=") for line in lines)) + + def test_existing_world_cannot_be_reconfigured(self): + with tempfile.TemporaryDirectory() as directory: + runtime = Path(directory) + path = self.native_world(runtime, MapFeatures=nbtlib.Byte(1), RandomSeed=nbtlib.Long(7)) + properties = runtime / "server.properties" + properties.write_text("generate-structures=true\nlevel-seed=7\n") + before = digest(path), properties.read_bytes() + with self.assertRaisesRegex(AssertionError, "existing endurance world"): + configure_fresh_world(runtime) + self.assertEqual(before, (digest(path), properties.read_bytes())) + + def test_prepare_configures_only_fresh_saves_and_leaves_copy_settings_alone(self): + with tempfile.TemporaryDirectory() as directory: + runtime = Path(directory) + with patch("native114_endurance.prepare", return_value="native-launcher") as prepare, \ + patch("native114_endurance.configure_fresh_world") as configure: + self.assertEqual("native-launcher", prepare_endurance(runtime, "profile", [], 25590)) + prepare.assert_called_once_with(runtime, "profile", [], 25590, None) + configure.assert_called_once_with(runtime) + with patch("native114_endurance.prepare", return_value="native-launcher") as prepare, \ + patch("native114_endurance.configure_fresh_world") as configure: + source = runtime / "retained-source" + self.assertEqual("native-launcher", prepare_endurance(runtime, "profile", [], 25591, source)) + prepare.assert_called_once_with(runtime, "profile", [], 25591, source) + configure.assert_not_called() + + def test_saved_native_settings_are_checked_and_not_rewritten(self): + with tempfile.TemporaryDirectory() as directory: + runtime = Path(directory) + path = self.native_world(runtime) + before = digest(path) + receipt = verify_fresh_world(runtime, "endurance") + self.assertEqual("PASS", receipt["status"]) + self.assertEqual(before, digest(path)) + self.assertTrue((runtime / "native-world-settings-endurance.json").is_file()) + with self.assertRaisesRegex(AssertionError, "overwrite"): + verify_fresh_world(runtime, "endurance") + + def test_wrong_native_settings_fail_and_preserve_failed_receipts(self): + for key, value in (("MapFeatures", nbtlib.Byte(1)), ("RandomSeed", nbtlib.Long(7)), + ("generatorName", nbtlib.String("default")), ("DataVersion", nbtlib.Int(1631))): + with self.subTest(key=key), tempfile.TemporaryDirectory() as directory: + runtime = Path(directory) + path = self.native_world(runtime, **{key: value}) + before = digest(path) + with self.assertRaisesRegex(AssertionError, "world settings differ"): + verify_fresh_world(runtime, "endurance") + receipt = __import__("json").loads((runtime / "native-world-settings-endurance.json").read_text()) + self.assertEqual("FAIL", receipt["status"]) + self.assertTrue(receipt["failures"]) + self.assertEqual(before, digest(path)) + + def test_actual_settings_gate_runs_after_fresh_boot_and_before_rig_construction(self): + with tempfile.TemporaryDirectory() as directory: + runtime = Path(directory) + rig = NativeEnduranceRig("fresh114", runtime, None, None, {}) + calls = [] + rig.command = lambda *args: calls.append(args) + with patch.object(endurance.Rig, "boot", side_effect=lambda phase: calls.append(("boot", phase))), \ + patch("native114_endurance.verify_code"), \ + patch("native114_endurance.verify_fresh_world", side_effect=lambda *args: calls.append(("verify", *args))): + rig.boot("endurance") + self.assertEqual([("boot", "endurance"), ("save-all flush", "Saved the game"), + ("verify", runtime, "endurance")], calls) + def test_batch_and_conservation_oracles_are_inherited(self): - self.assertIs(NativeEnduranceRig.batch, endurance.Rig.batch) + self.assertIsNot(NativeEnduranceRig.batch, endurance.Rig.batch) self.assertIs(NativeEnduranceRig.invariants, endurance.Rig.invariants) self.assertIs(NativeEnduranceRig.inventory, endurance.Rig.inventory) self.assertIs(NativeEnduranceRig.seed, endurance.Rig.seed) @@ -30,7 +124,7 @@ def checkpoint(label): rig.documents = {"W-01": {"worldTime": next(ticks)}} rig.checkpoint = checkpoint rig.command = lambda command, response=None: commands.append(command) - with patch.object(endurance.Rig, "boot"), patch("native114_endurance.time.sleep"): + with patch.object(endurance.Rig, "boot"), patch("native114_endurance.verify_code"), patch("native114_endurance.time.sleep"): rig.boot("endurance-restart") self.assertEqual(len(observations), 3) self.assertEqual(len(commands), len(endurance.CHECKED)) @@ -44,16 +138,43 @@ def test_restart_without_measured_tick_fails(self): rig = NativeEnduranceRig("test", Path(directory), None, None, definitions) rig.command = lambda *args: None rig.checkpoint = lambda label: rig.documents.update({"W-01": {"worldTime": 200}}) - with patch.object(endurance.Rig, "boot"), patch("native114_endurance.time.sleep"): + with patch.object(endurance.Rig, "boot"), patch("native114_endurance.verify_code"), patch("native114_endurance.time.sleep"): with self.assertRaisesRegex(TimeoutError, "No actual native tick"): rig.boot("endurance-restart") def test_initial_boot_does_not_restore_or_rebuild_anything(self): rig = NativeEnduranceRig("test", Path("unused"), None, None, {}) rig.command = lambda *args: self.fail("Initial boot issued a command") - with patch.object(endurance.Rig, "boot"): + with patch.object(endurance.Rig, "boot"), patch("native114_endurance.verify_code"): rig.boot("endurance") + def test_missing_code_seal_prevents_any_server_boot(self): + with tempfile.TemporaryDirectory() as directory: + rig = NativeEnduranceRig("test", Path(directory), None, None, {}) + with patch.object(endurance.Rig, "boot") as boot, self.assertRaises(FileNotFoundError): + rig.boot("endurance") + boot.assert_not_called() + + def test_independent_failure_is_retained_and_blocks_the_historical_ledger(self): + with tempfile.TemporaryDirectory() as directory: + rig = NativeEnduranceRig("test", Path(directory), None, None, {}) + rig.documents = {station: {} for station in ("S-04", "E-03", "W-04", "W-03")} + with patch("semantic_machine_audit.endurance_batch", return_value={"status": "FAIL", "failures": ["lost filter"]}), \ + patch.object(endurance.Rig, "batch") as historical: + with self.assertRaisesRegex(AssertionError, "Independent batch"): + rig.batch("cycle-001") + historical.assert_not_called() + self.assertEqual("FAIL", __import__("json").loads((Path(directory) / "semantic-batch-cycle-001.json").read_text())["status"]) + + def test_independent_pass_keeps_the_historical_material_checks(self): + with tempfile.TemporaryDirectory() as directory: + rig = NativeEnduranceRig("test", Path(directory), None, None, {}) + rig.documents = {station: {} for station in ("S-04", "E-03", "W-04", "W-03")} + with patch("semantic_machine_audit.endurance_batch", return_value={"status": "PASS", "failures": []}), \ + patch.object(endurance.Rig, "batch", return_value={"retained": True}) as historical: + self.assertEqual({"retained": True}, rig.batch("cycle-001")) + historical.assert_called_once_with("cycle-001") + def test_smoke_result_never_qualifies_as_endurance(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) diff --git a/test-rig/migration/test_native114_harvest.py b/test-rig/migration/test_native114_harvest.py new file mode 100644 index 0000000..f434f34 --- /dev/null +++ b/test-rig/migration/test_native114_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 native114_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.14.4", 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", "native114-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_native114_legacy_snbt.py b/test-rig/migration/test_native114_legacy_snbt.py new file mode 100644 index 0000000..c7aed07 --- /dev/null +++ b/test-rig/migration/test_native114_legacy_snbt.py @@ -0,0 +1,32 @@ +import unittest +import nbtlib +from native114_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_native114_machine_operations.py b/test-rig/migration/test_native114_machine_operations.py new file mode 100644 index 0000000..f165d25 --- /dev/null +++ b/test-rig/migration/test_native114_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 native114_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_native114_redstone_reload.py b/test-rig/migration/test_native114_redstone_reload.py new file mode 100644 index 0000000..9e90ef1 --- /dev/null +++ b/test-rig/migration/test_native114_redstone_reload.py @@ -0,0 +1,188 @@ +"""Independent negative controls for the cold-reload redstone operating oracle.""" + +import copy +from pathlib import Path +import struct +import unittest + +from native114_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) + self.assertIn("canTick(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_native114_resource_audit.py b/test-rig/migration/test_native114_resource_audit.py new file mode 100644 index 0000000..21d559d --- /dev/null +++ b/test-rig/migration/test_native114_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 native114_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 7d17d78..79b877a 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 @@ -18,6 +18,9 @@ public AdvantageWorksMod() { RecipeUnlockProbe.register(); ProgressionProbe.register(); NativeInteropProbe.register(); + MachineOperationalProbe.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..0461755 --- /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.CompoundNBT; + +/** 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(CompoundNBT tile, String key, String expectedFluid) { + if (!tile.contains(key, 10)) throw new IllegalArgumentException("Missing tank compound " + key); + CompoundNBT 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.contains("Amount") && !tank.contains("FluidName")) return 0; + if (tank.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..695b9b3 --- /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().putFloat("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/ElectricOperationalCases.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/ElectricOperationalCases.java new file mode 100644 index 0000000..036fe4a --- /dev/null +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/ElectricOperationalCases.java @@ -0,0 +1,795 @@ +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.ItemEntity; +import net.minecraft.entity.monster.HuskEntity; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.inventory.IInventory; +import net.minecraft.item.ItemStack; +import net.minecraft.util.Direction; +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 net.minecraftforge.fluids.FluidStack; +import net.minecraftforge.fluids.capability.CapabilityFluidHandler; +import net.minecraftforge.fluids.capability.IFluidHandler; +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 Forge 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 = ForgeRegistries.FLUIDS.getValue(new ResourceLocation("poweradvantage:crude_oil")); + c.require(oil != null, "Crude oil exists for faced automation"); + int stored = 0; + for (Direction face : Direction.values()) { + IFluidHandler handler = fluids(c, 8, face); + equal(c, "face." + face + ".simulated", handler.fill(new FluidStack(oil, 8), IFluidHandler.FluidAction.SIMULATE), 8); + equal(c, "face." + face + ".unchanged", tankAmount(c, 8, 0), stored); + equal(c, "face." + face + ".accepted", handler.fill(new FluidStack(oil, 8), IFluidHandler.FluidAction.EXECUTE), 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 (Direction face : Direction.values()) { + FluidStack simulated = fluids(c, 8, face).drain(24, IFluidHandler.FluidAction.SIMULATE); + equal(c, "face." + face + ".drainable", simulated.getAmount(), 24); + c.require(new ResourceLocation("poweradvantage:refined_oil").equals(simulated.getFluid().getRegistryName()), "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(ItemEntity.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(), actor.getMotion().toString()); + c.require(actor.getMotion().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.block(8, Y, Z, EA + "photovoltaic_generator"); + c.require(c.world.isSkyLightMax(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(GameRules.DO_DAYLIGHT_CYCLE); + 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 -> { + 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.isSkyLightMax(c.at(8, Y + 1, Z)), "Lit panel is indoors"); + c.require(!c.world.isSkyLightMax(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 HuskEntity[] target = new HuskEntity[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"); + HuskEntity 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.EffectInstance(net.minecraft.potion.Effects.FIRE_RESISTANCE, 200, 0)); + c.require(c.world.addEntity(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 -> { + HuskEntity 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 HuskEntity[] victim = new HuskEntity[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.play.ServerPlayNetHandler(c.world.getServer(), + new net.minecraft.network.NetworkManager(net.minecraft.network.PacketDirection.SERVERBOUND), player) { + @Override public void sendPacket(net.minecraft.network.IPacket 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.addNewPlayer(player); + c.invoke(8, 1, Z, "setOwner", new Class[] {PlayerEntity.class}, player); + HuskEntity 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.EffectInstance(net.minecraft.potion.Effects.FIRE_RESISTANCE, 200, 0)); + c.require(c.world.addEntity(husk), "Ownership target is in the real world"); + c.record("owner.profile", name); + }, c -> { + try { + c.require(c.world.getPlayers().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.getPlayers().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 IFluidHandler fluids(MachineContext c, int x) { + return fluids(c, x, null); + } + + private static IFluidHandler fluids(MachineContext c, int x, Direction face) { + return c.tile(x, Y, Z).getCapability(CapabilityFluidHandler.FLUID_HANDLER_CAPABILITY, face) + .orElseThrow(() -> new IllegalStateException("Missing real fluid capability at " + x)); + } + + // 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 = ForgeRegistries.FLUIDS.getValue(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).getFluidInTank(tank).getAmount(); + } + + 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(fluid.getFluid().getRegistryName())); + c.require(new ResourceLocation(id).equals(fluid.getFluid().getRegistryName()), "Tank contains " + id); + } + + private static void rejectFluid(MachineContext c, int x, String id, int amount) { + net.minecraft.fluid.Fluid fluid = ForgeRegistries.FLUIDS.getValue(new ResourceLocation(id)); + c.require(fluid != null, "Negative-control fluid exists: " + id); + equal(c, "invalidFluid.accepted", fluids(c, x).fill(new FluidStack(fluid, amount), IFluidHandler.FluidAction.EXECUTE), 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..e3e6230 --- /dev/null +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/MachineContext.java @@ -0,0 +1,236 @@ +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.BlockState; +import net.minecraft.fluid.Fluid; +import net.minecraft.inventory.IInventory; +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.CompoundNBT; +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.server.ServerWorld; +import net.minecraftforge.registries.ForgeRegistries; + +final class MachineContext { + public final ServerWorld 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(ServerWorld 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 floor() { + if (!settingUp) throw new IllegalStateException("Foundation construction 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.forceChunk(x, z, true); + world.getChunk(x, z); + } + } + for (int x = 0; x < 32; x++) for (int z = 0; z < 32; z++) { + world.setBlockState(at(x, -1, z), net.minecraft.block.Blocks.BEDROCK.getDefaultState(), 2); + } + } + + 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); + BlockState 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().putFloat("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 (!ForgeRegistries.FLUIDS.containsKey(id) || 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(ForgeRegistries.FLUIDS.getValue(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; + } + + CompoundNBT nbt(int x, int y, int z) { return tile(x, y, z).write(new CompoundNBT()); } + + void seed(int x, int y, int z, CompoundNBT patch) { + mutation("nbt", at(x, y, z), patch.toString()); + TileEntity tile = tile(x, y, z); + CompoundNBT nbt = tile.write(new CompoundNBT()); + 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()); + BlockState 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 CompoundNBT()).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.PlayerEntity) continue; + CompoundNBT nbt = new CompoundNBT(); + 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..e658663 --- /dev/null +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/MachineOperationalProbe.java @@ -0,0 +1,295 @@ +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.server.ServerWorld; +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.event.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 ServerWorld 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); + } + + 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.word()) + .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(ServerWorld 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.getPlayers().contains(sentinel)) world.getPlayers().add(sentinel); + } + + private void start(ServerWorld 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.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.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 ServerWorld) + || event.world.dimension.getType() != net.minecraft.world.dimension.DimensionType.OVERWORLD) return; + ServerWorld current = (ServerWorld) event.world; + if (event.phase == TickEvent.Phase.START && paused) { + held.addAll(current.tickableTileEntities); + current.tickableTileEntities.clear(); + for (Entity entity : (Iterable)current.getEntities()::iterator) if (!(entity instanceof net.minecraft.entity.player.PlayerEntity)) 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.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 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(ServerWorld 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.CompoundNBT()).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; + 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/NativeHarvestProbe.java b/test-rig/src/main/java/zone/moddev/mc/advantageworks/NativeHarvestProbe.java new file mode 100644 index 0000000..6dd3335 --- /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.BlockState; +import net.minecraft.command.Commands; +import net.minecraft.enchantment.Enchantments; +import net.minecraft.entity.item.ItemEntity; +import net.minecraft.entity.player.ServerPlayerEntity; +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; +import net.minecraft.item.Items; +import net.minecraft.network.IPacket; +import net.minecraft.network.NetworkManager; +import net.minecraft.network.PacketDirection; +import net.minecraft.network.play.ServerPlayNetHandler; +import net.minecraft.server.management.PlayerInteractionManager; +import net.minecraft.state.BooleanProperty; +import net.minecraft.util.Hand; +import net.minecraft.util.Direction; +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.server.ServerWorld; +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(ServerWorld world, ServerPlayerEntity 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(ServerWorld world, ServerPlayerEntity 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(ItemEntity.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(ItemEntity::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.block.Blocks.AIR.getDefaultState(), 2); + BlockState requested = block.getDefaultState(); + if (connectionMask >= 0) { + int bit = 0; + for (Direction direction : Direction.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.block.Blocks.AIR.getDefaultState(), 2); + } + } + require(world.setBlockState(POS, requested, 2), "Cannot place harvest witness"); + BlockState 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(Hand.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 (ItemEntity 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 (ItemEntity 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(ServerWorld 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"); + ServerPlayerEntity player = new ServerPlayerEntity(world.getServer(), world, + new GameProfile(UUID.fromString("c791aefe-a372-4536-9251-27032c01f115"), "AdvWorksHarvest"), + new PlayerInteractionManager(world)); + player.connection = new ServerPlayNetHandler(world.getServer(), new NetworkManager(PacketDirection.SERVERBOUND), player) { + @Override public void sendPacket(IPacket 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.block.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.block.Blocks.AIR.getDefaultState(), 2); + for (Direction direction : Direction.values()) world.setBlockState(POS.offset(direction), net.minecraft.block.Blocks.AIR.getDefaultState(), 2); + harvestDrops(world, new AxisAlignedBB(POS).grow(2)).forEach(ItemEntity::remove); + JsonObject report = new JsonObject(); + report.addProperty("label", label); + report.addProperty("minecraft", "1.14.4"); + report.addProperty("worldTime", world.getGameTime()); + report.addProperty("fixtureIsolation", "native114-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.IEntityReader world, AxisAlignedBB bounds) { + // remove() only marks an entity; the chunk index can retain it until the next tick. + return world.getEntitiesWithinAABB(ItemEntity.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..f24d6dc --- /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.ItemEntity; +import net.minecraft.item.ItemStack; +import net.minecraft.item.Items; +import net.minecraft.util.Direction; +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.CompoundNBT lock = new net.minecraft.nbt.CompoundNBT(); lock.putString("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 (Direction direction : Direction.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.ZERO.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.ZERO.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); + ItemEntity dropped = new ItemEntity(c.world, p.getX() + .5, p.getY() + .01, p.getZ() + .5, new ItemStack(Items.DIRT, 16)); + dropped.setMotion(0, 0, 0); + c.world.addEntity(dropped); + }, c -> { + c.require(c.count(6, 2, 5, "minecraft:dirt") == 16, "dropped input delivered"); + c.require(c.world.getEntitiesWithinAABB(ItemEntity.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().putString("Witness", "A"); + c.inventory(4, 2, 5).getStackInSlot(1).getOrCreateTag().putString("Witness", "B"); + c.put(6, 2, 5, 0, "minecraft:dirt", 1); + c.inventory(6, 2, 5).getStackInSlot(0).getOrCreateTag().putString("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.block.Blocks.WATER.getDefaultState() + .with(net.minecraft.block.FlowingFluidBlock.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 (Direction direction : Direction.values()) if (direction != Direction.UP) { + BlockPos p = BlockPos.ZERO.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.block.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.fluid.Fluids.WATER + : net.minecraftforge.registries.ForgeRegistries.FLUIDS.getValue(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 (Direction d : new Direction[]{Direction.NORTH, Direction.SOUTH, Direction.WEST, Direction.EAST}) { + BlockPos p = BlockPos.ZERO.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..c4074cc --- /dev/null +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/RedstoneReloadProbe.java @@ -0,0 +1,349 @@ +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.BlockState; +import net.minecraft.block.Blocks; +import net.minecraft.command.Commands; +import net.minecraft.inventory.IInventory; +import net.minecraft.item.ItemStack; +import net.minecraft.item.Items; +import net.minecraft.nbt.CompoundNBT; +import net.minecraft.state.properties.BlockStateProperties; +import net.minecraft.tileentity.TileEntity; +import net.minecraft.util.Direction; +import net.minecraft.util.ResourceLocation; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.registry.Registry; +import net.minecraft.world.World; +import net.minecraft.world.server.ServerWorld; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.fml.event.server.FMLServerStartingEvent; +import net.minecraftforge.event.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 ServerWorld 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"); + ServerWorld 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 ServerWorld) || event.world.dimension.getType() != net.minecraft.world.dimension.DimensionType.OVERWORLD) return; + ServerWorld current = (ServerWorld) 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) { + CompoundNBT tag = tile(witness).write(new CompoundNBT()); + 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.forceChunk(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); + boolean ticking = world.getChunkProvider().canTick(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 = Registry.BLOCK.getOrDefault(new ResourceLocation("electricadvantage:electric_drill")); + if (block == Blocks.AIR) throw new IllegalStateException("Electric drill registry absent"); + world.setBlockState(witness.pos, block.getDefaultState().with(BlockStateProperties.FACING, Direction.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(Items.DIRT, 7); + dirt.getOrCreateTag().putString("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); + CompoundNBT nbt = tile.write(new CompoundNBT()); + BlockState 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(BlockState 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..1cc3ad7 --- /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.block.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..8f3ff54 --- /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.BlockState; +import net.minecraft.enchantment.Enchantment; +import net.minecraft.entity.EntityType; +import net.minecraft.entity.SharedMonsterAttributes; +import net.minecraft.entity.item.ArmorStandEntity; +import net.minecraft.entity.passive.CowEntity; +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.CompoundNBT; +import net.minecraft.network.IPacket; +import net.minecraft.network.NetworkManager; +import net.minecraft.network.PacketDirection; +import net.minecraft.network.play.ServerPlayNetHandler; +import net.minecraft.state.IntegerProperty; +import net.minecraft.util.ActionResultType; +import net.minecraft.util.Hand; +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 ArmorStandEntity[] passenger = new ArmorStandEntity[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); + CompoundNBT patch = new CompoundNBT(); patch.putBoolean("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); + ArmorStandEntity rider = new ArmorStandEntity(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.addEntity(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 -> { + ArmorStandEntity 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++) { + BlockState 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.block.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"); + BlockState 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; + CowEntity 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 ServerPlayNetHandler(c.world.getServer(), + new NetworkManager(PacketDirection.SERVERBOUND), shot.player) { + @Override public void sendPacket(IPacket 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.item.Items.AIR, "Native musket is registered"); + shot.weapon = new ItemStack(weapon); + shot.player.setHeldItem(Hand.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.item.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.addEntity(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()); + ActionResultType reload = weapon.onItemRightClick(c.world, shot.player, Hand.MAIN_HAND).getType(); + c.record("musket/reload-result", reload.toString()); + if (mode.equals("no-ammo")) { + c.require(reload == ActionResultType.FAIL, "Unloaded musket rejects use without ammunition"); + shot.expectedDamage = 0; + } else { + c.require(reload == ActionResultType.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"); + ActionResultType aim = weapon.onItemRightClick(c.world, shot.player, Hand.MAIN_HAND).getType(); + c.record("musket/aim-result", aim.toString()); + c.require(aim == ActionResultType.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..7699397 --- /dev/null +++ b/test-rig/src/main/java/zone/moddev/mc/advantageworks/SteamOperationalCases.java @@ -0,0 +1,362 @@ +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.Direction; +import net.minecraft.util.ResourceLocation; +import net.minecraftforge.fluids.capability.CapabilityFluidHandler; +import net.minecraftforge.fluids.capability.IFluidHandler; +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; + } + + // This is an actual face-capability insertion, not c.tank's direct reserve seeding. + private static void fill(MachineContext c, String id, int amount) { + Fluid fluid = ForgeRegistries.FLUIDS.getValue(new ResourceLocation(id)); + c.require(fluid != null, "Registered input fluid " + id); + IFluidHandler handler = c.tile(X, Y, Z).getCapability( + CapabilityFluidHandler.FLUID_HANDLER_CAPABILITY, Direction.UP) + .orElseThrow(() -> new IllegalStateException("Missing Steam input fluid capability")); + int accepted = handler.fill(new net.minecraftforge.fluids.FluidStack(fluid, amount), + IFluidHandler.FluidAction.EXECUTE); + 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 = ForgeRegistries.FLUIDS.getValue(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).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(ForgeRegistries.FLUIDS.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); + IFluidHandler handler = c.tile(X, Y, Z).getCapability( + CapabilityFluidHandler.FLUID_HANDLER_CAPABILITY, Direction.UP) + .orElseThrow(() -> new IllegalStateException("Missing still fluid capability")); + int accepted = handler.fill(new net.minecraftforge.fluids.FluidStack(net.minecraft.fluid.Fluids.WATER, 100), + IFluidHandler.FluidAction.EXECUTE); + 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..7a08bf3 --- /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.CowEntity; +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; +import net.minecraft.network.IPacket; +import net.minecraft.network.NetworkManager; +import net.minecraft.network.PacketDirection; +import net.minecraft.network.play.ServerPlayNetHandler; +import net.minecraft.network.play.server.SEntityVelocityPacket; +import net.minecraft.util.ActionResultType; +import net.minecraft.util.Hand; +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; + CowEntity 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 = shot.player.getMotion(); + 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.block.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 ServerPlayNetHandler(c.world.getServer(), new NetworkManager(PacketDirection.SERVERBOUND), shot.player) { + @Override public void sendPacket(IPacket packet) { + if (packet instanceof SEntityVelocityPacket) 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.setMotion(Vec3d.ZERO); + shot.weapon = new ItemStack(item(c, "steamadvantage:musket")); + shot.player.setHeldItem(Hand.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 CowEntity target(MachineContext c, double x, double y, double z) { + CowEntity 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.addEntity(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, Hand.MAIN_HAND).getType() == ActionResultType.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, Hand.MAIN_HAND).getType() == ActionResultType.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, CowEntity 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.CompoundNBT saved = new net.minecraft.nbt.CompoundNBT(); + 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.item.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..599ac19 --- /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.CompoundNBT; + +/** 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); + CompoundNBT tag = new CompoundNBT(); tag.putShort("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); + CompoundNBT tag = new CompoundNBT(); + tag.putIntArray("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 19f8646..bbc28ab 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(ServerWorld 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(ServerWorld 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(ServerWorld 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(ServerWorld 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(ServerWorld 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,6 +441,10 @@ private List> snapshot(ServerWorld world, WorksDefinition.St private boolean evaluateAssertion(ServerWorld 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), @@ -474,7 +462,7 @@ private boolean evaluateAssertion(ServerWorld world, WorksDefinition.Station sta 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); @@ -484,11 +472,7 @@ private boolean evaluateAssertion(ServerWorld world, WorksDefinition.Station sta for (int[] position : assertion.positions) { TileEntity source = world.getTileEntity(absolute(station, position)); if (source == null) return false; - CompoundNBT tank = source.write(new CompoundNBT()).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 CompoundNBT()), assertion.nbtPath, assertion.fluid); } return compare(total, assertion.operator, assertion.value); } @@ -557,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); @@ -591,16 +576,7 @@ private static String stringAt(CompoundNBT 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(ServerWorld world, WorksDefinition.Station station) { @@ -969,7 +945,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 e011ba0..9542457 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 @@ -107,6 +107,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"); @@ -125,6 +128,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) { @@ -154,6 +168,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 7b3b0b0..7919c51 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 @@ -111,6 +111,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) { @@ -121,6 +122,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) { @@ -142,6 +144,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) { @@ -167,11 +170,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; } @@ -846,12 +848,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); + } } } } @@ -865,19 +887,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..10f73fb --- /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.CompoundNBT; +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 CompoundNBT(), "Tank", null); + } + + @Test(expected = IllegalArgumentException.class) public void missingAmountCannotSatisfyZero() { + CompoundNBT tile = new CompoundNBT(); + tile.put("Tank", new CompoundNBT()); + AssertionChecks.fluidAmount(tile, "Tank", null); + } + + @Test(expected = IllegalArgumentException.class) public void emptyFalseIsNotAnEmptyTank() { + CompoundNBT tile = new CompoundNBT(), tank = new CompoundNBT(); + tank.putBoolean("Empty", false); + tile.put("Tank", tank); + AssertionChecks.fluidAmount(tile, "Tank", null); + } + + @Test public void explicitEmptyAndMeasuredFluidAreDifferentFromMissingEvidence() { + CompoundNBT tile = new CompoundNBT(), tank = new CompoundNBT(); + tank.putBoolean("Empty", true); + tile.put("Tank", tank); + assertEquals(0, AssertionChecks.fluidAmount(tile, "Tank", null)); + tank.remove("Empty"); + tank.putInt("Amount", 1000); + tank.putString("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/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..0cf1ed2 --- /dev/null +++ b/test-rig/src/test/java/zone/moddev/mc/advantageworks/NativeHarvestIsolationTest.java @@ -0,0 +1,107 @@ +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.ItemEntity; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; +import net.minecraft.item.Items; +import net.minecraft.util.EntityPredicates; +import net.minecraft.util.math.AxisAlignedBB; +import net.minecraft.util.registry.Bootstrap; +import net.minecraft.world.IEntityReader; +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() { Bootstrap.register(); } + + private static ItemEntity item(Item type, int count) { + return new ItemEntity(null, 0.5, 0.5, 0.5, new ItemStack(type, count)); + } + + @Test public void nativeDefaultQueryReproducesFourThenSixThenEightBeforeTickCleanup() { + QueryReader reader = new QueryReader(); + ItemEntity first = item(Items.GUNPOWDER, 4); + reader.index.add(first); + assertEquals(4, count(reader.getEntitiesWithinAABB(ItemEntity.class, BOUNDS))); + reader.getEntitiesWithinAABB(ItemEntity.class, BOUNDS).forEach(ItemEntity::remove); + assertTrue(first.removed); + assertTrue(EntityPredicates.NOT_SPECTATING.test(first)); + assertSame(first, reader.getEntitiesWithinAABB(ItemEntity.class, BOUNDS).get(0)); + reader.index.add(item(Items.GUNPOWDER, 2)); + assertEquals(6, count(reader.getEntitiesWithinAABB(ItemEntity.class, BOUNDS))); + reader.getEntitiesWithinAABB(ItemEntity.class, BOUNDS).forEach(ItemEntity::remove); + reader.index.add(item(Items.GUNPOWDER, 2)); + assertEquals(8, count(reader.getEntitiesWithinAABB(ItemEntity.class, BOUNDS))); + } + + @Test public void removedEarlierDropCannotInflateTheNextTrial() { + QueryReader reader = new QueryReader(); + reader.index.add(item(Items.GUNPOWDER, 4)); + NativeHarvestProbe.harvestDrops(reader, BOUNDS).forEach(ItemEntity::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 = new QueryReader(); + ItemEntity powder = item(Items.GUNPOWDER, 4); + reader.index.add(powder); + powder.remove(); + assertTrue(NativeHarvestProbe.harvestDrops(reader, BOUNDS).isEmpty()); + ItemEntity ore = item(Items.IRON_ORE, 1); + reader.index.add(ore); + assertEquals(Collections.singletonList(ore), NativeHarvestProbe.harvestDrops(reader, BOUNDS)); + } + + @Test public void everyUnexpectedLiveIdentityAndQuantityRemainsVisibleToTheOracle() { + QueryReader reader = new QueryReader(); + ItemEntity expected = item(Items.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 = new QueryReader(); + ItemEntity 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 implements IEntityReader { + final List index = new ArrayList<>(); + + @Override public List getEntitiesWithinAABB(Class type, + AxisAlignedBB bounds, Predicate filter) { + List found = new ArrayList<>(); + for (ItemEntity 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"); + } + + @Override public List getPlayers() { return Collections.emptyList(); } + } +} 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); + } + } +}