From cb9687f0157cbf4ca56def49b0187de7b93275f9 Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Fri, 30 Oct 2026 20:50:25 +0000 Subject: [PATCH 1/2] Fix Electric Advantage machine state and strengthen Minecraft 1.14.4 tests --- CHANGELOG.txt | 14 ++ README.md | 31 +++ .../entities/HydroturbineEntity.java | 6 + .../mc/electricadvantage/gui/GUIHelper.java | 12 +- .../machines/ElectricGeneratorTileEntity.java | 19 +- .../machines/ElectricMachineTileEntity.java | 19 +- .../machines/ElectricPumpTileEntity.java | 24 +- .../machines/ElectricStillTileEntity.java | 11 +- .../GrowthChamberControllerTileEntity.java | 5 +- .../HydroelectricGeneratorTileEntity.java | 7 + .../machines/LaserTurretTileEntity.java | 19 +- .../PhotovoltaicGeneratorTileEntity.java | 6 +- ...tricOperationalHydroturbineAnchorTest.java | 116 +++++++++ .../electricadvantage/gui/GUIHelperTest.java | 38 +++ .../ElectricOperationalNativeDefectsTest.java | 228 ++++++++++++++++++ .../ElectricOperationalPumpPickupTest.java | 204 ++++++++++++++++ .../ElectricOperationalRedstoneCacheTest.java | 217 +++++++++++++++++ ...ElectricOperationalSoundBroadcastTest.java | 128 ++++++++++ .../ElectricOperationalStillCapacityTest.java | 97 ++++++++ 19 files changed, 1170 insertions(+), 31 deletions(-) create mode 100644 src/test/java/zone/moddev/mc/electricadvantage/entities/ElectricOperationalHydroturbineAnchorTest.java create mode 100644 src/test/java/zone/moddev/mc/electricadvantage/gui/GUIHelperTest.java create mode 100644 src/test/java/zone/moddev/mc/electricadvantage/machines/ElectricOperationalNativeDefectsTest.java create mode 100644 src/test/java/zone/moddev/mc/electricadvantage/machines/ElectricOperationalPumpPickupTest.java create mode 100644 src/test/java/zone/moddev/mc/electricadvantage/machines/ElectricOperationalRedstoneCacheTest.java create mode 100644 src/test/java/zone/moddev/mc/electricadvantage/machines/ElectricOperationalSoundBroadcastTest.java create mode 100644 src/test/java/zone/moddev/mc/electricadvantage/machines/ElectricOperationalStillCapacityTest.java diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 36af649..91c77dd 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,17 @@ +Unreleased + +- Check generator redstone before the first power pulse so a disabled solar panel cannot produce a startup burst. +- Stop the electric still consuming oil and power when its full output will not fit. +- Drain waterlogged slabs and stairs without destroying the blocks, and only charge pump power after a successful pickup. +- Keep growth controllers from requesting more electricity than they can store or use. +- Read light above the solar panel so its solid body does not block its own light reading. +- Fix a 1.14 turret crash when checking a target's line of sight. Keep nearby cover and the existing beam range effective without hitting the turret's own mounting post. +- Keep hydroturbines anchored to their generators so flowing water cannot build up invalid movement across a restart. +- Sample a machine's redstone interlock on its first server tick, then retain the normal eight-tick refresh. +- Send machine sounds to nearby connected players without loading surrounding chunks. +- Use an empty or full-scale hydroelectric display without changing the existing power-output reading or electricity generation. +- Add operating and persistence regression tests, including blocked outputs, refused pickups and unchanged waterlogged host blocks. + Electric Advantage 2.2.6.114041 - Target Minecraft 1.14.4 and Forge 28.2.26 using native platform classes and Java 8 production bytecode. diff --git a/README.md b/README.md index 15ea4e3..939560b 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,38 @@ in-world GUI, rendering, sound, control or weapon interaction, multiplayer behav or compatibility with arbitrary third-party mods. Unit/resource tests alone are not runtime or world-upgrade evidence. +The local [test-coverage backport](../../PowerAdvantage/docs/1.14/TEST-COVERAGE-BACKPORT.md) records the stronger machine, restart and GUI checks for the repaired candidate. + The implementation namespace is `zone.moddev.mc.electricadvantage`. Runtime identities, registries, tile/entity IDs, NBT, recipes, tags, OreSpawn provider IDs, and assets remain stable. Electric registers its legacy state, tile, and hydroturbine aliases with PowerAdvantage's shared migration hook. The RF- and Tech Reborn-labelled converters belong to Power Advantage and now use Forge Energy. The historical [1.13 migration guide](https://github.com/MinecraftModDevelopmentMods/PowerAdvantage/blob/master-1.13.2/docs/1.13/MIGRATION.md) explains the legacy conversion and backup process; the 1.14 test results above describe this port's coverage. Always make a whole-world backup before upgrading, keep the complete set of migration backups, and test only disposable copies. Check that every other mod supports the target version; these results do not qualify unrelated mods or an entire existing modpack. + +## Generator Follow-Up + +The [generator follow-up](../../PowerAdvantage/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](../../PowerAdvantage/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/src/main/java/zone/moddev/mc/electricadvantage/entities/HydroturbineEntity.java b/src/main/java/zone/moddev/mc/electricadvantage/entities/HydroturbineEntity.java index a8af37b..61308dd 100644 --- a/src/main/java/zone/moddev/mc/electricadvantage/entities/HydroturbineEntity.java +++ b/src/main/java/zone/moddev/mc/electricadvantage/entities/HydroturbineEntity.java @@ -54,6 +54,12 @@ public IPacket createSpawnPacket() { return NetworkHooks.getEntitySpawningPacket(this); } + @Override + public boolean isPushedByWater() { + // Anchored to the generator: flow drives rotation, not linear movement. + return false; + } + @Override public void tick(){ super.tick(); diff --git a/src/main/java/zone/moddev/mc/electricadvantage/gui/GUIHelper.java b/src/main/java/zone/moddev/mc/electricadvantage/gui/GUIHelper.java index 81a9761..41a7ab0 100644 --- a/src/main/java/zone/moddev/mc/electricadvantage/gui/GUIHelper.java +++ b/src/main/java/zone/moddev/mc/electricadvantage/gui/GUIHelper.java @@ -31,17 +31,23 @@ public static void drawIndicatorLight(boolean on,GUIContainer gc, int xOffset, i } public static void drawProgressBar(int x, int y, float progress, GUIContainer gc){ - int n = (int)(8 * (progress + 0.0625f) ); - if(n <= 0) return; - int w = 1 + (n * LED_BAR_W8); + int w = progressBarWidth(progress); + if(w == 0) return; gc.blit(x, y, LED_BAR_X, LED_BAR_Y, w, LED_BAR_H); } + + static int progressBarWidth(float progress) { + progress = Float.isFinite(progress) ? Math.max(0F, Math.min(1F, progress)) : 0F; + int n = (int)(8 * (progress + 0.0625f)); + return n <= 0 ? 0 : 1 + n * LED_BAR_W8; + } public static void drawFluidBar(FluidStack fs, float barHeight, int xPos, int yPos, GUIContainer guiContainer, int x, int y, float z, ResourceLocation displayImage){ + 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/electricadvantage/machines/ElectricGeneratorTileEntity.java b/src/main/java/zone/moddev/mc/electricadvantage/machines/ElectricGeneratorTileEntity.java index 90dcedc..dedd215 100644 --- a/src/main/java/zone/moddev/mc/electricadvantage/machines/ElectricGeneratorTileEntity.java +++ b/src/main/java/zone/moddev/mc/electricadvantage/machines/ElectricGeneratorTileEntity.java @@ -3,6 +3,7 @@ import zone.moddev.mc.electricadvantage.init.Power; import cyano.poweradvantage.api.ConduitType; import net.minecraft.block.BlockState; +import net.minecraft.entity.player.PlayerEntity; import net.minecraft.entity.player.ServerPlayerEntity; import net.minecraft.item.ItemStack; import net.minecraft.nbt.CompoundNBT; @@ -18,7 +19,6 @@ import zone.moddev.mc.electricadvantage.ElectricAdvantage; import java.util.Arrays; -import java.util.List; public abstract class ElectricGeneratorTileEntity extends cyano.poweradvantage.api.simple.TileEntitySimplePowerMachine { @@ -46,12 +46,14 @@ public ElectricGeneratorTileEntity(TileEntityType tileType, String name,int n private boolean redstone = false; + private boolean redstoneInitialized = false; private float oldEnergy = 0f; @Override public void powerUpdate(){ super.powerUpdate(); redstone = getWorld().isBlockPowered(getPos()); + redstoneInitialized = true; if(oldEnergy != this.getEnergy(Power.ELECTRIC_POWER)){ this.sync(); @@ -59,6 +61,11 @@ public void powerUpdate(){ } protected boolean hasRedstoneSignal(){ + // Generators can check the interlock before their first power-update refresh. + if(!redstoneInitialized && getWorld() != null && !getWorld().isRemote){ + redstone = getWorld().isBlockPowered(getPos()); + redstoneInitialized = true; + } return redstone; } @@ -74,10 +81,14 @@ protected void setActive(boolean active){ protected void playSoundEffect(double x, double y, double z, SoundEvent sound, float volume, float pitch){ if(getWorld().isRemote) return; final double range = 16; - List players = getWorld().getEntitiesWithinAABB(ServerPlayerEntity.class, new AxisAlignedBB( + AxisAlignedBB bounds = new AxisAlignedBB( x - range, y - range, z - range, - x + range, y + range, z + range)); - for(ServerPlayerEntity player : players){ + x + range, y + range, z + range); + // Broadcast to existing players without loading chunks for a spatial entity query. + for(PlayerEntity candidate : getWorld().getPlayers()){ + if(!(candidate instanceof ServerPlayerEntity)) continue; + ServerPlayerEntity player = (ServerPlayerEntity)candidate; + if(player.connection == null || player.isSpectator() || !player.getBoundingBox().intersects(bounds)) continue; player.connection.sendPacket(new SPlaySoundPacket(sound.getRegistryName(), SoundCategory.BLOCKS, new net.minecraft.util.math.Vec3d(x, y, z), volume, pitch)); } diff --git a/src/main/java/zone/moddev/mc/electricadvantage/machines/ElectricMachineTileEntity.java b/src/main/java/zone/moddev/mc/electricadvantage/machines/ElectricMachineTileEntity.java index 9677d1a..ecaaa9b 100644 --- a/src/main/java/zone/moddev/mc/electricadvantage/machines/ElectricMachineTileEntity.java +++ b/src/main/java/zone/moddev/mc/electricadvantage/machines/ElectricMachineTileEntity.java @@ -3,6 +3,7 @@ import zone.moddev.mc.electricadvantage.init.Power; import cyano.poweradvantage.api.ConduitType; import net.minecraft.block.BlockState; +import net.minecraft.entity.player.PlayerEntity; import net.minecraft.entity.player.ServerPlayerEntity; import net.minecraft.inventory.Inventory; import net.minecraft.item.ItemStack; @@ -22,7 +23,6 @@ import zone.moddev.mc.electricadvantage.ElectricAdvantage; import java.util.Arrays; -import java.util.List; public abstract class ElectricMachineTileEntity extends cyano.poweradvantage.api.simple.TileEntitySimplePowerMachine{ @@ -71,6 +71,7 @@ public ElectricMachineTileEntity(TileEntityType tileType, String name,int num } private boolean redstone = false; + private boolean redstoneInitialized = false; private float oldEnergy = 0f; private int[] syncArrayOld = null; private int[] syncArrayNew = null; @@ -79,6 +80,7 @@ public void powerUpdate(){ super.powerUpdate(); redstone = getWorld().isBlockPowered(getPos()); + redstoneInitialized = true; this.setPowerState(this.isPowered()); @@ -99,6 +101,11 @@ public void powerUpdate(){ protected boolean hasRedstoneSignal(){ + // tickUpdate can run before the first scheduled powerUpdate after loading. + if(!redstoneInitialized && getWorld() != null && !getWorld().isRemote){ + redstone = getWorld().isBlockPowered(getPos()); + redstoneInitialized = true; + } return redstone; } @@ -343,10 +350,14 @@ public ItemStack insertItemToInputSlots(ItemStack itemStack){ protected void playSoundEffect(double x, double y, double z, SoundEvent sound, float volume, float pitch){ if(getWorld().isRemote) return; final double range = 16; - List players = getWorld().getEntitiesWithinAABB(ServerPlayerEntity.class, new AxisAlignedBB( + AxisAlignedBB bounds = new AxisAlignedBB( x - range, y - range, z - range, - x + range, y + range, z + range)); - for(ServerPlayerEntity player : players){ + x + range, y + range, z + range); + // Broadcast to existing players without loading chunks for a spatial entity query. + for(PlayerEntity candidate : getWorld().getPlayers()){ + if(!(candidate instanceof ServerPlayerEntity)) continue; + ServerPlayerEntity player = (ServerPlayerEntity)candidate; + if(player.connection == null || player.isSpectator() || !player.getBoundingBox().intersects(bounds)) continue; player.connection.sendPacket(new SPlaySoundPacket(sound.getRegistryName(), SoundCategory.BLOCKS, new net.minecraft.util.math.Vec3d(x, y, z), volume, pitch)); } diff --git a/src/main/java/zone/moddev/mc/electricadvantage/machines/ElectricPumpTileEntity.java b/src/main/java/zone/moddev/mc/electricadvantage/machines/ElectricPumpTileEntity.java index 710c41d..b2a8047 100644 --- a/src/main/java/zone/moddev/mc/electricadvantage/machines/ElectricPumpTileEntity.java +++ b/src/main/java/zone/moddev/mc/electricadvantage/machines/ElectricPumpTileEntity.java @@ -9,6 +9,7 @@ import zone.moddev.mc.poweradvantage.init.Fluids; import zone.moddev.mc.poweradvantage.util.FluidIdHelper; import net.minecraft.block.BlockState; +import net.minecraft.block.IBucketPickupHandler; import net.minecraft.util.SoundEvents; import net.minecraft.item.ItemStack; import net.minecraft.nbt.CompoundNBT; @@ -82,12 +83,14 @@ public void tickUpdate(boolean isServerWorld) { float cost = ENERGY_COST_PUMP+ENERGY_COST_VERTICAL*(getPos().getY()-fluidSource.getY()); // Found a fluid to suck-up! BlockState blockstate = w.getBlockState(fluidSource); - Fluid f = getFluid(blockstate); - if(f != null && getEnergy() >= cost){ - this.getTank().fill(new FluidStack(f, FluidTank.BUCKET_VOLUME), true); - this.subtractEnergy(cost, Power.ELECTRIC_POWER); - w.setBlockState(fluidSource, net.minecraft.block.Blocks.AIR.getDefaultState(), 3); - success = true; + if(blockstate.getBlock() instanceof IBucketPickupHandler && getEnergy() >= cost){ + // Native pickup drains waterlogging without deleting the containing block. + Fluid f = ((IBucketPickupHandler)blockstate.getBlock()).pickupFluid(w, fluidSource, blockstate); + if(f != null && f != net.minecraft.fluid.Fluids.EMPTY){ + this.getTank().fill(new FluidStack(f, FluidTank.BUCKET_VOLUME), true); + this.subtractEnergy(cost, Power.ELECTRIC_POWER); + success = true; + } } } timeUntilNextPump = PUMP_INTERVAL; @@ -129,12 +132,6 @@ protected boolean isValidInputItem(ItemStack item) { } - private Fluid getFluid(BlockState blockstate) { - return blockstate.getFluidState().isEmpty() ? null : blockstate.getFluidState().getFluid(); - } - - - private BlockPos scan(Set searchSpace, BlockPos coord, int limit) { if(isFluidBlock(coord) == false) return null; do{ @@ -161,7 +158,8 @@ private BlockPos scan(Set searchSpace, BlockPos coord, int limit) { } private boolean isSourceBlock( BlockPos coord){ - return getWorld().getFluidState(coord).isSource(); + BlockState state = getWorld().getBlockState(coord); + return state.getBlock() instanceof IBucketPickupHandler && state.getFluidState().isSource(); } diff --git a/src/main/java/zone/moddev/mc/electricadvantage/machines/ElectricStillTileEntity.java b/src/main/java/zone/moddev/mc/electricadvantage/machines/ElectricStillTileEntity.java index b983f43..f23b39d 100644 --- a/src/main/java/zone/moddev/mc/electricadvantage/machines/ElectricStillTileEntity.java +++ b/src/main/java/zone/moddev/mc/electricadvantage/machines/ElectricStillTileEntity.java @@ -48,7 +48,8 @@ public void tickUpdate(boolean isServerWorld) { if(!this.hasRedstoneSignal()){ if(getInputTank().getFluidAmount() > 0 && getEnergy() >= electricityPerDistill - && canDistill(getInputTank().getFluid())){ + && canDistill(getInputTank().getFluid()) + && hasSpaceForDistillation()){ distill(); if(!wasActive){ this.setActiveState(true); @@ -93,6 +94,14 @@ private boolean canDistill(FluidStack fs) { } } + private boolean hasSpaceForDistillation() { + DistillationRecipe recipe = DistillationRecipeRegistry.getInstance() + .getDistillationRecipeForFluid(getInputTank().getFluid().getFluid()); + // applyRecipe consumes its input, so preview a copy before committing either tank. + FluidStack output = recipe.applyRecipe(getInputTank().getFluid().copy(), speed); + return output != null && getOutputTank().fill(output, false) == output.amount; + } + private void distill(){ DistillationRecipe recipe = DistillationRecipeRegistry.getInstance() .getDistillationRecipeForFluid(getInputTank().getFluid().getFluid()); diff --git a/src/main/java/zone/moddev/mc/electricadvantage/machines/GrowthChamberControllerTileEntity.java b/src/main/java/zone/moddev/mc/electricadvantage/machines/GrowthChamberControllerTileEntity.java index 006b994..b7cbb5e 100644 --- a/src/main/java/zone/moddev/mc/electricadvantage/machines/GrowthChamberControllerTileEntity.java +++ b/src/main/java/zone/moddev/mc/electricadvantage/machines/GrowthChamberControllerTileEntity.java @@ -313,7 +313,10 @@ public PowerRequest getPowerRequest(ConduitType offer) { float powerWanted = (this.getEnergyCapacity(Power.GROWTHCHAMBER_POWER) - this.getEnergy(Power.GROWTHCHAMBER_POWER)); powerWanted = Math.min(powerWanted, soil / SOIL_PER_UNIT); powerWanted = Math.min(powerWanted, tank.getFluidAmount() / WATER_PER_UNIT); - return new PowerRequest(PowerRequest.MEDIUM_PRIORITY,ELECTRICITY_PER_UNIT * powerWanted,this); + float stored = getEnergy(Power.ELECTRIC_POWER); + float room = getEnergyCapacity(Power.ELECTRIC_POWER) - stored; + float electricityWanted = Math.max(0, Math.min(room, ELECTRICITY_PER_UNIT * powerWanted - stored)); + return new PowerRequest(PowerRequest.MEDIUM_PRIORITY,electricityWanted,this); } else { return PowerRequest.REQUEST_NOTHING; } diff --git a/src/main/java/zone/moddev/mc/electricadvantage/machines/HydroelectricGeneratorTileEntity.java b/src/main/java/zone/moddev/mc/electricadvantage/machines/HydroelectricGeneratorTileEntity.java index d8a8d91..fe8aa9b 100644 --- a/src/main/java/zone/moddev/mc/electricadvantage/machines/HydroelectricGeneratorTileEntity.java +++ b/src/main/java/zone/moddev/mc/electricadvantage/machines/HydroelectricGeneratorTileEntity.java @@ -59,6 +59,13 @@ public float getPowerOutput() { } } + private final float[] progress = new float[1]; + + public float[] getProgress() { + progress[0] = isActive() ? 1F : 0F; + return progress; + } + @Override protected boolean isValidInputItem(ItemStack item) { return false; diff --git a/src/main/java/zone/moddev/mc/electricadvantage/machines/LaserTurretTileEntity.java b/src/main/java/zone/moddev/mc/electricadvantage/machines/LaserTurretTileEntity.java index 1fd55d8..41a5dbb 100644 --- a/src/main/java/zone/moddev/mc/electricadvantage/machines/LaserTurretTileEntity.java +++ b/src/main/java/zone/moddev/mc/electricadvantage/machines/LaserTurretTileEntity.java @@ -3,6 +3,7 @@ import zone.moddev.mc.electricadvantage.ElectricAdvantage; import zone.moddev.mc.electricadvantage.init.Power; import net.minecraft.block.Block; +import net.minecraft.block.BlockState; import net.minecraft.entity.Entity; import net.minecraft.entity.LivingEntity; import net.minecraft.entity.EntityClassification; @@ -18,6 +19,9 @@ import net.minecraft.util.ResourceLocation; import net.minecraft.util.SoundEvent; import net.minecraft.util.math.*; +import net.minecraft.util.math.shapes.VoxelShape; +import net.minecraft.util.math.shapes.VoxelShapes; +import net.minecraft.world.IBlockReader; import net.minecraft.world.World; import net.minecraftforge.api.distmarker.Dist; import net.minecraftforge.api.distmarker.OnlyIn; @@ -293,9 +297,18 @@ private Vec3d calculateLaserTerminus(){ return followRayToSolidBlock(getOpticPosition(),dir,ATTACK_RANGE); } private Vec3d followRayToSolidBlock(Vec3d origin, Vec3d dir, double maxRange){ - Vec3d max = origin.add(dir).add(maxRange * dir.x, maxRange * dir.y, maxRange * dir.z); + Entity target = getWorld().getEntityByID(targetID); + // A client may receive the lock before its target entity; do not trace a missing beam. + if(target == null) return origin; + Vec3d max = origin.add(maxRange * dir.x, maxRange * dir.y, maxRange * dir.z); BlockRayTraceResult impact = getWorld().rayTraceBlocks(new RayTraceContext(origin, max, - RayTraceContext.BlockMode.COLLIDER, RayTraceContext.FluidMode.SOURCE_ONLY, null)); + RayTraceContext.BlockMode.COLLIDER, RayTraceContext.FluidMode.SOURCE_ONLY, target) { + @Override public VoxelShape getBlockShape(BlockState state, IBlockReader world, BlockPos pos) { + // The optic is inside our post; nearby cover must still block the beam. + return pos.equals(LaserTurretTileEntity.this.getPos()) ? VoxelShapes.empty() + : super.getBlockShape(state, world, pos); + } + }); if(impact.getType() == RayTraceResult.Type.BLOCK){ return impact.getHitVec(); } else { @@ -379,7 +392,7 @@ public boolean canSeeEntity(Entity e){ Vec3d offsetOrigin = getOpticPosition().add(e.getPositionVector().subtract(getOpticPosition()).normalize()); BlockRayTraceResult collision = getWorld().rayTraceBlocks(new RayTraceContext(offsetOrigin, e.getPositionVector().add(0, e.getHeight()*0.5, 0), RayTraceContext.BlockMode.COLLIDER, - RayTraceContext.FluidMode.SOURCE_ONLY, null)); + RayTraceContext.FluidMode.SOURCE_ONLY, e)); if(collision.getType() == RayTraceResult.Type.BLOCK){ return false; } diff --git a/src/main/java/zone/moddev/mc/electricadvantage/machines/PhotovoltaicGeneratorTileEntity.java b/src/main/java/zone/moddev/mc/electricadvantage/machines/PhotovoltaicGeneratorTileEntity.java index 660b8bb..0c583ff 100644 --- a/src/main/java/zone/moddev/mc/electricadvantage/machines/PhotovoltaicGeneratorTileEntity.java +++ b/src/main/java/zone/moddev/mc/electricadvantage/machines/PhotovoltaicGeneratorTileEntity.java @@ -92,8 +92,10 @@ public void powerUpdate(){ } public static float getLightIntensityAt(BlockPos pos, World w){ + // Sample incident light above the solid panel, not inside its body. + BlockPos lightPos = pos.up(); float light; - if(w.isSkyLightMax(pos) && w.dimension.hasSkyLight()){ + if(w.isSkyLightMax(lightPos) && w.dimension.hasSkyLight()){ // outdoor light imposes no penalty light = MathHelper.sin((float)w.getDayTime() * 2.61799E-4f); // (worldTime * pi / 12000); // handle weather @@ -103,7 +105,7 @@ public static float getLightIntensityAt(BlockPos pos, World w){ light *= 0.35f; } } else { - float blockLight = w.getLight(pos) * 6.66666E-2f; // block light / 15 + float blockLight = w.getLight(lightPos) * 6.66666E-2f; // block light / 15 light = BLOCKLIGHT_FACTOR * blockLight; } return light; diff --git a/src/test/java/zone/moddev/mc/electricadvantage/entities/ElectricOperationalHydroturbineAnchorTest.java b/src/test/java/zone/moddev/mc/electricadvantage/entities/ElectricOperationalHydroturbineAnchorTest.java new file mode 100644 index 0000000..c131238 --- /dev/null +++ b/src/test/java/zone/moddev/mc/electricadvantage/entities/ElectricOperationalHydroturbineAnchorTest.java @@ -0,0 +1,116 @@ +package zone.moddev.mc.electricadvantage.entities; + +import java.lang.reflect.Field; +import java.util.Random; +import net.minecraft.entity.Entity; +import net.minecraft.entity.EntityClassification; +import net.minecraft.entity.EntityType; +import net.minecraft.nbt.CompoundNBT; +import net.minecraft.nbt.DoubleNBT; +import net.minecraft.nbt.ListNBT; +import net.minecraft.network.IPacket; +import net.minecraft.util.math.Vec3d; +import net.minecraft.util.registry.Bootstrap; +import net.minecraft.world.World; +import net.minecraft.world.dimension.DimensionType; +import net.minecraft.world.dimension.OverworldDimension; +import net.minecraft.world.server.ServerWorld; +import org.junit.BeforeClass; +import org.junit.Test; + +import static org.junit.Assert.*; + +public class ElectricOperationalHydroturbineAnchorTest { + @BeforeClass public static void bootstrap() { Bootstrap.register(); } + + @Test public void nativeEntityLoadStillClampsOverspeedMotionAndPreservesItsBoundary() throws Exception { + ControlEntity control = new ControlEntity(controlType(), world()); + CompoundNBT tag = control.writeWithoutTypeId(new CompoundNBT()); + tag.put("Motion", motion(16.226, -10.001, 10)); + control.read(tag); + assertEquals(new Vec3d(0, 0, 10), control.getMotion()); + assertTrue(control.isPushedByWater()); + } + + @Test public void turbineRejectsLinearWaterPushingWithoutChangingRotationRate() throws Exception { + HydroturbineEntity turbine = new HydroturbineEntity(turbineType(), world()); + assertFalse(turbine.isPushedByWater()); + assertEquals(Vec3d.ZERO, turbine.getMotion()); + assertEquals(30, HydroturbineEntity.DEGREES_PER_TICK, 0); + } + + @Test public void legacyMotionInsideTheNativeLimitIsNotArbitrarilyCleared() throws Exception { + HydroturbineEntity turbine = new HydroturbineEntity(turbineType(), world()); + CompoundNBT saved = turbine.writeWithoutTypeId(new CompoundNBT()); + saved.put("Motion", motion(5.5, -2, 0)); + turbine.read(saved); + assertEquals(new Vec3d(5.5, -2, 0), turbine.getMotion()); + assertFalse(turbine.isPushedByWater()); + } + + @Test public void anchoredTurbineSurvivesNativeSaveLoadWithItsParentAndIdentity() throws Exception { + EntityType type = turbineType(); + World world = world(); + HydroturbineEntity original = new HydroturbineEntity(type, world); + original.setPosition(56856.5, 81, 50008.5); + CompoundNBT saved = original.writeWithoutTypeId(new CompoundNBT()); + int[] parent = {56856, 82, 50008}; + saved.putIntArray("parentPos", parent); + for (int pass = 0; pass < 3; pass++) { + HydroturbineEntity loaded = new HydroturbineEntity(type, world); + loaded.read(saved); + CompoundNBT next = loaded.writeWithoutTypeId(new CompoundNBT()); + assertEquals(saved.getUniqueId("UUID"), next.getUniqueId("UUID")); + assertEquals(saved.getList("Pos", 6), next.getList("Pos", 6)); + assertEquals(motion(0, 0, 0), next.getList("Motion", 6)); + assertArrayEquals(parent, next.getIntArray("parentPos")); + assertFalse(loaded.isPushedByWater()); + saved = next; + } + } + + private static ListNBT motion(double x, double y, double z) { + ListNBT list = new ListNBT(); + list.add(new DoubleNBT(x)); list.add(new DoubleNBT(y)); list.add(new DoubleNBT(z)); + return list; + } + + private static EntityType turbineType() { + return EntityType.Builder.create(HydroturbineEntity::new, EntityClassification.MISC) + .size(1, 1).build("electricadvantage:test_anchored_turbine"); + } + + private static EntityType controlType() { + return EntityType.Builder.create(ControlEntity::new, EntityClassification.MISC) + .size(1, 1).build("electricadvantage:test_native_motion_control"); + } + + private static World world() throws Exception { + Class allocator = Class.forName("sun.misc.Unsafe"); + Field field = allocator.getDeclaredField("theUnsafe"); + field.setAccessible(true); + World world = (World)allocator.getMethod("allocateInstance", Class.class) + .invoke(field.get(null), NoTickWorld.class); + Field random = World.class.getField("rand"); + random.setAccessible(true); + random.set(world, new Random(0)); + Field dimension = World.class.getField("dimension"); + dimension.setAccessible(true); + dimension.set(world, new OverworldDimension(world, DimensionType.OVERWORLD)); + return world; + } + + private static final class NoTickWorld extends ServerWorld { + private NoTickWorld() { + super(null, null, null, null, null, null, null); + } + } + + private static final class ControlEntity extends Entity { + ControlEntity(EntityType type, World world) { super(type, world); } + @Override protected void registerData() {} + @Override protected void readAdditional(CompoundNBT tag) {} + @Override protected void writeAdditional(CompoundNBT tag) {} + @Override public IPacket createSpawnPacket() { return null; } + } +} diff --git a/src/test/java/zone/moddev/mc/electricadvantage/gui/GUIHelperTest.java b/src/test/java/zone/moddev/mc/electricadvantage/gui/GUIHelperTest.java new file mode 100644 index 0000000..ac6e55d --- /dev/null +++ b/src/test/java/zone/moddev/mc/electricadvantage/gui/GUIHelperTest.java @@ -0,0 +1,38 @@ +package zone.moddev.mc.electricadvantage.gui; + +import java.lang.reflect.Method; +import org.junit.Test; +import static org.junit.Assert.*; + +public class GUIHelperTest { + @Test public void barRetainsItsEightSegmentScale() throws Exception { + assertEquals(0, progressBarWidth(0F)); + assertEquals(17, progressBarWidth(0.5F)); + assertEquals(33, progressBarWidth(1F)); + } + + @Test public void invalidAndOversizedValuesCannotEscapeTheTexture() throws Exception { + for (float value : new float[]{-1F, Float.NEGATIVE_INFINITY, Float.POSITIVE_INFINITY, Float.NaN}) { + assertEquals(0, progressBarWidth(value)); + } + assertEquals(33, progressBarWidth(15F)); + assertEquals(33, progressBarWidth(Float.MAX_VALUE)); + } + + @Test public void finiteReadingsAreMonotonicAndBounded() throws Exception { + int previous = 0; + for (int i = -100; i <= 200; i++) { + int width = progressBarWidth(i / 100F); + assertTrue(width >= previous && width >= 0 && width <= 33); + previous = width; + } + } + + private static int progressBarWidth(float value) throws Exception { + Method method; + try { method = GUIHelper.class.getDeclaredMethod("progressBarWidth", float.class); } + catch (NoSuchMethodException missing) { throw new AssertionError("Electric progress bars need one finite, bounded eight-segment scale", missing); } + method.setAccessible(true); + return (Integer) method.invoke(null, value); + } +} diff --git a/src/test/java/zone/moddev/mc/electricadvantage/machines/ElectricOperationalNativeDefectsTest.java b/src/test/java/zone/moddev/mc/electricadvantage/machines/ElectricOperationalNativeDefectsTest.java new file mode 100644 index 0000000..11451e0 --- /dev/null +++ b/src/test/java/zone/moddev/mc/electricadvantage/machines/ElectricOperationalNativeDefectsTest.java @@ -0,0 +1,228 @@ +package zone.moddev.mc.electricadvantage.machines; + +import cyano.poweradvantage.api.fluid.FluidStack; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import net.minecraft.block.Blocks; +import net.minecraft.block.BlockState; +import net.minecraft.entity.Entity; +import net.minecraft.entity.EntityType; +import net.minecraft.entity.item.ItemEntity; +import net.minecraft.fluid.Fluids; +import net.minecraft.fluid.IFluidState; +import net.minecraft.util.Direction; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.BlockRayTraceResult; +import net.minecraft.util.math.RayTraceContext; +import net.minecraft.util.math.Vec3d; +import net.minecraft.util.registry.Bootstrap; +import net.minecraft.world.World; +import net.minecraft.world.server.ServerWorld; +import org.junit.BeforeClass; +import org.junit.Test; +import zone.moddev.mc.electricadvantage.init.Power; + +import static org.junit.Assert.*; + +/** Operating regressions; the packaged rig independently checks natural machine ticks. */ +public class ElectricOperationalNativeDefectsTest { + @BeforeClass public static void bootstrap() { Bootstrap.register(); } + + @Test public void controllerCannotRequestMoreThanItsHundredUnitElectricalBuffer() throws Exception { + GrowthChamberControllerTileEntity controller = controller(); + assertEquals(100, controller.getPowerRequest(Power.ELECTRIC_POWER).amount, 0); + assertEquals(0, controller.getEnergy(Power.ELECTRIC_POWER), 0); + assertEquals(1000, controller.getSoil(), 0); + assertEquals(1800, controller.getTank().getFluidAmount()); + } + + @Test public void controllerSubtractsElectricityAlreadyStoredFromInputCapacity() throws Exception { + GrowthChamberControllerTileEntity controller = controller(); + controller.setEnergy(40, Power.ELECTRIC_POWER); + assertEquals(60, controller.getPowerRequest(Power.ELECTRIC_POWER).amount, 0); + controller.setEnergy(100, Power.ELECTRIC_POWER); + assertEquals(0, controller.getPowerRequest(Power.ELECTRIC_POWER).amount, 0); + } + + @Test public void scarceResourcesAndExistingElectricityBoundTheRemainingDemand() throws Exception { + GrowthChamberControllerTileEntity controller = controller(); + controller.setSoil(1); + controller.getTank().setFluid(new FluidStack(Fluids.WATER, 2)); + controller.setEnergy(4, Power.ELECTRIC_POWER); + assertEquals(4, controller.getPowerRequest(Power.ELECTRIC_POWER).amount, 0); + controller.setEnergy(999.75f, Power.GROWTHCHAMBER_POWER); + assertEquals(0, controller.getPowerRequest(Power.ELECTRIC_POWER).amount, 0); + } + + @Test public void fullGreenhouseBufferCannotRequestMoreElectricity() throws Exception { + GrowthChamberControllerTileEntity controller = controller(); + controller.setEnergy(1000, Power.GROWTHCHAMBER_POWER); + assertEquals(0, controller.getPowerRequest(Power.ELECTRIC_POWER).amount, 0); + } + + @Test public void hydroelectricDisplayIsNormalizedWithoutChangingStoredPowerOrRatedGeneration() throws Exception { + HydroelectricGeneratorTileEntity active = new HydroelectricGeneratorTileEntity() { + @Override public boolean isActive() { return true; } + }; + HydroelectricGeneratorTileEntity idle = new HydroelectricGeneratorTileEntity() { + @Override public boolean isActive() { return false; } + }; + active.setEnergy(37, Power.ELECTRIC_POWER); + idle.setEnergy(19, Power.ELECTRIC_POWER); + assertEquals(1, hydroelectricDisplay(active).length); + assertEquals(1, hydroelectricDisplay(active)[0], 0); + assertEquals(0, hydroelectricDisplay(idle)[0], 0); + assertEquals(15, active.getPowerOutput(), 0); + assertEquals(0, idle.getPowerOutput(), 0); + assertEquals(37, active.getEnergy(Power.ELECTRIC_POWER), 0); + assertEquals(19, idle.getEnergy(Power.ELECTRIC_POWER), 0); + assertEquals(4, HydroelectricGeneratorTileEntity.ENERGY_PER_TICK, 0); + } + + @Test public void panelSamplesIncidentLightAboveItsSolidBody() throws Exception { + LightWorld world = withoutConstructor(LightWorld.class); + BlockPos panel = new BlockPos(101, 82, -27); + world.surface = panel.up(); + world.brightness = 14; + assertEquals(0.1f * 14 * 0.0666666f, + PhotovoltaicGeneratorTileEntity.getLightIntensityAt(panel, world), 0.000001f); + assertEquals(1, world.skyReads); + assertEquals(1, world.lightReads); + } + + @Test public void darkPanelSurfaceProducesNoBlocklightPower() throws Exception { + LightWorld world = withoutConstructor(LightWorld.class); + BlockPos panel = new BlockPos(101, 82, -27); + world.surface = panel.up(); + assertEquals(0, PhotovoltaicGeneratorTileEntity.getLightIntensityAt(panel, world), 0); + } + + @Test public void beamIgnoresOnlyItsOwnCollisionPostAndStopsAtTheDeclaredRange() throws Exception { + BeamWorld world = withoutConstructor(BeamWorld.class); + world.turret = new BlockPos(101, 82, -27); + StubTurret turret = withoutConstructor(StubTurret.class); + turret.testWorld = world; + turret.setPos(world.turret); + world.target = target(); + turret.setTarget(world.target); + Vec3d origin = new Vec3d(101.5, 82.75, -26.5); + Method trace = LaserTurretTileEntity.class.getDeclaredMethod("followRayToSolidBlock", + Vec3d.class, Vec3d.class, double.class); + trace.setAccessible(true); + Vec3d end = (Vec3d)trace.invoke(turret, origin, new Vec3d(1, 0, 0), 32.0); + assertEquals(1, world.traces); + assertEquals(32, origin.distanceTo(end), 0.000001); + } + + @Test public void sightQueryUsesTheObservedEntityAndNativeTraversalStillRespectsCover() throws Exception { + SightWorld world = withoutConstructor(SightWorld.class); + StubTurret turret = withoutConstructor(StubTurret.class); + turret.testWorld = world; + turret.setPos(new BlockPos(101, 82, -27)); + Entity observed = target(); + assertFalse(turret.canSeeEntity(null)); + assertTrue("A visible target must be checked without a null selection-context entity", turret.canSeeEntity(observed)); + assertTrue("Use the native block traversal, not a canned ray result", world.reads > 0); + world.wall = new BlockPos(101, 82, -24); + assertFalse("An actual stone block must prevent sight of the same target", turret.canSeeEntity(observed)); + } + + @Test public void missingClientTargetCannotCreateANullEntityBeamTrace() throws Exception { + BeamWorld world = withoutConstructor(BeamWorld.class); + StubTurret turret = withoutConstructor(StubTurret.class); + turret.testWorld = world; + turret.setPos(new BlockPos(101, 82, -27)); + Vec3d origin = new Vec3d(101.5, 82.75, -26.5); + Method trace = LaserTurretTileEntity.class.getDeclaredMethod("followRayToSolidBlock", + Vec3d.class, Vec3d.class, double.class); + trace.setAccessible(true); + assertEquals(origin, trace.invoke(turret, origin, new Vec3d(1, 0, 0), 32.0)); + assertEquals(0, world.traces); + } + + private static Entity target() { + ItemEntity target = new ItemEntity(EntityType.ITEM, null); + target.setPosition(101.5, 82, -20.5); + return target; + } + + private static float[] hydroelectricDisplay(HydroelectricGeneratorTileEntity generator) throws Exception { + try { + return (float[])HydroelectricGeneratorTileEntity.class.getMethod("getProgress").invoke(generator); + } catch (NoSuchMethodException legacy) { + // The legacy screen passes this reading directly to the progress bar. + return new float[]{generator.getPowerOutput()}; + } + } + + private static GrowthChamberControllerTileEntity controller() throws Exception { + GrowthChamberControllerTileEntity controller = new GrowthChamberControllerTileEntity(); + controller.setSoil(1000); + controller.getTank().setFluid(new FluidStack(Fluids.WATER, 1800)); + Field redstone = GrowthChamberControllerTileEntity.class.getDeclaredField("redstone"); + redstone.setAccessible(true); + redstone.setBoolean(controller, false); + return controller; + } + + private static T withoutConstructor(Class type) throws Exception { + Class allocator = Class.forName("sun.misc.Unsafe"); + Field field = allocator.getDeclaredField("theUnsafe"); + field.setAccessible(true); + return type.cast(allocator.getMethod("allocateInstance", Class.class).invoke(field.get(null), type)); + } + + private static final class LightWorld extends ServerWorld { + BlockPos surface; + int brightness, skyReads, lightReads; + private LightWorld() { + super(null, null, null, null, null, null, null); + } + @Override public boolean isSkyLightMax(BlockPos pos) { + assertEquals("Sky access must be sampled above the solid panel", surface, pos); + skyReads++; + return false; + } + @Override public int getLight(BlockPos pos) { + assertEquals("Incident block light must be sampled above the solid panel", surface, pos); + lightReads++; + return brightness; + } + } + + private static final class BeamWorld extends ServerWorld { + BlockPos turret; + Entity target; + int traces; + private BeamWorld() { + super(null, null, null, null, null, null, null); + } + @Override public Entity getEntityByID(int id) { + return target != null && target.getEntityId() == id ? target : null; + } + @Override public BlockRayTraceResult rayTraceBlocks(RayTraceContext context) { + traces++; + assertTrue("Owning turret post must not stop its own beam", + context.getBlockShape(new LaserTurretBlock(false).getDefaultState(), this, turret).isEmpty()); + assertFalse("An immediately adjacent solid block must still stop the beam", + context.getBlockShape(Blocks.STONE.getDefaultState(), this, turret.east()).isEmpty()); + return BlockRayTraceResult.createMiss(context.func_222250_a(), Direction.EAST, new BlockPos(context.func_222250_a())); + } + } + + private static final class SightWorld extends ServerWorld { + BlockPos wall; + int reads; + private SightWorld() { super(null, null, null, null, null, null, null); } + @Override public BlockState getBlockState(BlockPos pos) { + reads++; + return (pos.equals(wall) ? Blocks.STONE : Blocks.AIR).getDefaultState(); + } + @Override public IFluidState getFluidState(BlockPos pos) { return getBlockState(pos).getFluidState(); } + } + + private static final class StubTurret extends LaserTurretTileEntity { + World testWorld; + @Override public World getWorld() { return testWorld; } + } +} diff --git a/src/test/java/zone/moddev/mc/electricadvantage/machines/ElectricOperationalPumpPickupTest.java b/src/test/java/zone/moddev/mc/electricadvantage/machines/ElectricOperationalPumpPickupTest.java new file mode 100644 index 0000000..60cd28d --- /dev/null +++ b/src/test/java/zone/moddev/mc/electricadvantage/machines/ElectricOperationalPumpPickupTest.java @@ -0,0 +1,204 @@ +package zone.moddev.mc.electricadvantage.machines; + +import cyano.poweradvantage.api.fluid.FluidStack; +import java.lang.reflect.Field; +import java.util.HashMap; +import java.util.Map; +import net.minecraft.block.Block; +import net.minecraft.block.BlockState; +import net.minecraft.block.Blocks; +import net.minecraft.block.FlowingFluidBlock; +import net.minecraft.block.IBucketPickupHandler; +import net.minecraft.block.SlabBlock; +import net.minecraft.block.StairsBlock; +import net.minecraft.block.material.Material; +import net.minecraft.fluid.Fluid; +import net.minecraft.fluid.IFluidState; +import net.minecraft.fluid.Fluids; +import net.minecraft.nbt.CompoundNBT; +import net.minecraft.state.properties.BlockStateProperties; +import net.minecraft.state.properties.Half; +import net.minecraft.state.properties.SlabType; +import net.minecraft.state.properties.StairsShape; +import net.minecraft.util.Direction; +import net.minecraft.util.SoundEvent; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.registry.Bootstrap; +import net.minecraft.world.IWorld; +import net.minecraft.world.World; +import net.minecraft.world.server.ServerWorld; +import org.junit.BeforeClass; +import org.junit.Test; +import zone.moddev.mc.electricadvantage.init.Power; + +import static org.junit.Assert.*; + +/** Focused pickup transitions; the independent rig covers natural server ticks. */ +public class ElectricOperationalPumpPickupTest { + private static final BlockPos PUMP = new BlockPos(8, 84, 8); + private static final BlockPos SOURCE = PUMP.down().north(); + private static final float RESERVE = 8000; + private static final float PICKUP_COST = 1000 + 32; + + @BeforeClass public static void bootstrap() { Bootstrap.register(); } + + @Test public void waterloggedSlabKeepsItsBlockAndSlabType() throws Exception { + BlockState wet = Blocks.OAK_SLAB.getDefaultState() + .with(SlabBlock.TYPE, SlabType.TOP) + .with(BlockStateProperties.WATERLOGGED, true); + StubPump pump = fixture(wet); + pump.tickUpdate(true); + assertSuccessfulPickup(pump, Fluids.WATER); + assertEquals(wet.with(BlockStateProperties.WATERLOGGED, false), pump.testWorld.getBlockState(SOURCE)); + assertEquals(1, pump.testWorld.writes); + } + + @Test public void waterloggedStairsKeepFacingHalfAndShape() throws Exception { + BlockState wet = Blocks.OAK_STAIRS.getDefaultState() + .with(StairsBlock.FACING, Direction.SOUTH) + .with(StairsBlock.HALF, Half.TOP) + .with(StairsBlock.SHAPE, StairsShape.OUTER_LEFT) + .with(BlockStateProperties.WATERLOGGED, true); + StubPump pump = fixture(wet); + pump.tickUpdate(true); + assertSuccessfulPickup(pump, Fluids.WATER); + assertEquals(wet.with(BlockStateProperties.WATERLOGGED, false), pump.testWorld.getBlockState(SOURCE)); + assertEquals(1, pump.testWorld.writes); + } + + @Test public void ordinaryWaterAndLavaSourcesStillYieldOneBucket() throws Exception { + for (Block block : new Block[]{Blocks.WATER, Blocks.LAVA}) { + StubPump pump = fixture(block.getDefaultState()); + pump.tickUpdate(true); + assertSuccessfulPickup(pump, block == Blocks.WATER ? Fluids.WATER : Fluids.LAVA); + assertEquals(Blocks.AIR.getDefaultState(), pump.testWorld.getBlockState(SOURCE)); + assertEquals(1, pump.testWorld.writes); + } + } + + @Test public void sourceWaterInsideNonPickupSeagrassIsNotDestroyed() throws Exception { + StubPump pump = fixture(Blocks.SEAGRASS.getDefaultState()); + assertTrue(pump.testWorld.getFluidState(SOURCE).isSource()); + assertFalse(pump.testWorld.getBlockState(SOURCE).getBlock() instanceof IBucketPickupHandler); + pump.tickUpdate(true); + assertUntouched(pump, Blocks.SEAGRASS.getDefaultState(), RESERVE, 0); + } + + @Test public void refusedOrNullPickupDoesNotInventFluidOrDebitElectricity() throws Exception { + for (boolean returnNull : new boolean[]{false, true}) { + RefusingSource block = new RefusingSource(returnNull); + StubPump pump = fixture(block.getDefaultState()); + pump.tickUpdate(true); + assertEquals(1, block.calls); + assertUntouched(pump, block.getDefaultState(), RESERVE, 0); + } + } + + @Test public void insufficientElectricityDoesNotCallPickup() throws Exception { + RefusingSource block = new RefusingSource(false); + StubPump pump = fixture(block.getDefaultState()); + pump.setEnergy(PICKUP_COST - 1, Power.ELECTRIC_POWER); + pump.tickUpdate(true); + assertEquals(0, block.calls); + assertUntouched(pump, block.getDefaultState(), PICKUP_COST - 1, 0); + } + + @Test public void occupiedTankDoesNotCallPickupOrOverwriteItsFluid() throws Exception { + RefusingSource block = new RefusingSource(false); + StubPump pump = fixture(block.getDefaultState()); + pump.getTank().setFluid(new FluidStack(Fluids.LAVA, 1)); + pump.tickUpdate(true); + assertEquals(0, block.calls); + assertUntouched(pump, block.getDefaultState(), RESERVE, 1); + assertSame(Fluids.LAVA, pump.getTank().getFluid().getFluid()); + } + + @Test public void flowingWaterAndDrySlabsCannotBePickedUpAsSources() throws Exception { + for (BlockState state : new BlockState[]{ + Blocks.WATER.getDefaultState().with(FlowingFluidBlock.LEVEL, 1), + Blocks.OAK_SLAB.getDefaultState()}) { + StubPump pump = fixture(state); + pump.tickUpdate(true); + assertUntouched(pump, state, RESERVE, 0); + } + } + + private static StubPump fixture(BlockState source) throws Exception { + PumpWorld world = withoutConstructor(PumpWorld.class); + world.blocks = new HashMap<>(); + world.blocks.put(SOURCE, source); + StubPump pump = new StubPump(); + pump.testWorld = world; + pump.setPos(PUMP); + pump.setEnergy(RESERVE, Power.ELECTRIC_POWER); + CompoundNBT patch = new CompoundNBT(); + patch.putByte("NextPump", (byte)0); + pump.loadFrom(patch); + return pump; + } + + private static void assertSuccessfulPickup(StubPump pump, Fluid expected) { + assertEquals(1000, pump.getTank().getFluidAmount()); + assertSame(expected, pump.getTank().getFluid().getFluid()); + assertEquals(RESERVE - PICKUP_COST, pump.getEnergy(Power.ELECTRIC_POWER), 0); + assertEquals(2, pump.sounds); + } + + private static void assertUntouched(StubPump pump, BlockState expected, float energy, int fluid) { + assertEquals(expected, pump.testWorld.getBlockState(SOURCE)); + assertEquals(0, pump.testWorld.writes); + assertEquals(energy, pump.getEnergy(Power.ELECTRIC_POWER), 0); + assertEquals(fluid, pump.getTank().getFluidAmount()); + assertEquals(0, pump.sounds); + } + + private static T withoutConstructor(Class type) throws Exception { + Class allocator = Class.forName("sun.misc.Unsafe"); + Field field = allocator.getDeclaredField("theUnsafe"); + field.setAccessible(true); + return type.cast(allocator.getMethod("allocateInstance", Class.class).invoke(field.get(null), type)); + } + + private static final class StubPump extends ElectricPumpTileEntity { + PumpWorld testWorld; + int sounds; + @Override public World getWorld() { return testWorld; } + @Override protected boolean hasRedstoneSignal() { return false; } + @Override protected void playSoundEffect(double x, double y, double z, SoundEvent sound, float volume, float pitch) { + sounds++; + } + } + + private static final class PumpWorld extends ServerWorld { + Map blocks; + int writes; + private PumpWorld() { + super(null, null, null, null, null, null, null); + } + @Override public boolean isBlockLoaded(BlockPos pos) { return true; } + @Override public BlockState getBlockState(BlockPos pos) { + return blocks.getOrDefault(pos, Blocks.STONE.getDefaultState()); + } + @Override public IFluidState getFluidState(BlockPos pos) { return getBlockState(pos).getFluidState(); } + @Override public boolean isAirBlock(BlockPos pos) { return getBlockState(pos).isAir(); } + @Override public boolean setBlockState(BlockPos pos, BlockState state, int flags) { + writes++; + blocks.put(pos.toImmutable(), state); + return true; + } + } + + private static final class RefusingSource extends Block implements IBucketPickupHandler { + final boolean returnNull; + int calls; + RefusingSource(boolean returnNull) { + super(Block.Properties.create(Material.ROCK)); + this.returnNull = returnNull; + } + @Override public IFluidState getFluidState(BlockState state) { return Fluids.WATER.getStillFluidState(false); } + @Override public Fluid pickupFluid(IWorld world, BlockPos pos, BlockState state) { + calls++; + return returnNull ? null : Fluids.EMPTY; + } + } +} diff --git a/src/test/java/zone/moddev/mc/electricadvantage/machines/ElectricOperationalRedstoneCacheTest.java b/src/test/java/zone/moddev/mc/electricadvantage/machines/ElectricOperationalRedstoneCacheTest.java new file mode 100644 index 0000000..d1568dc --- /dev/null +++ b/src/test/java/zone/moddev/mc/electricadvantage/machines/ElectricOperationalRedstoneCacheTest.java @@ -0,0 +1,217 @@ +package zone.moddev.mc.electricadvantage.machines; + +import cyano.poweradvantage.api.PoweredEntity; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.registry.Bootstrap; +import net.minecraft.world.World; +import net.minecraft.world.server.ServerWorld; +import org.junit.BeforeClass; +import org.junit.Test; + +import static org.junit.Assert.*; + +/** Initial interlock sampling must not turn the ordinary cache into per-tick polling. */ +public class ElectricOperationalRedstoneCacheTest { + private static final BlockPos EVEN = new BlockPos(8, 80, 8); + private static final BlockPos ODD = new BlockPos(9, 81, 9); + + @BeforeClass public static void bootstrap() { Bootstrap.register(); } + + @Test public void signalledFirstUseDoesNotWaitForPowerUpdate() throws Exception { + ProbeMachine machine = fixture(EVEN, true); + assertTrue(machine.signal()); + assertEquals(1, machine.testWorld.queryTimes.size()); + machine.testWorld.signalled = false; + assertTrue("Repeated reads must retain the sampled interlock", machine.signal()); + assertEquals(1, machine.testWorld.queryTimes.size()); + } + + @Test public void unsignalledFirstUseIsSampledAndCachedToo() throws Exception { + ProbeMachine machine = fixture(EVEN, false); + assertFalse(machine.signal()); + assertEquals(1, machine.testWorld.queryTimes.size()); + machine.testWorld.signalled = true; + assertFalse("A cached false must not be mistaken for uninitialized", machine.signal()); + assertEquals(1, machine.testWorld.queryTimes.size()); + } + + @Test public void unattachedReadDoesNotInitializeTheWorldCache() throws Exception { + ProbeMachine machine = new ProbeMachine(); + machine.setPos(EVEN); + assertFalse(machine.signal()); + machine.testWorld = world(EVEN, true); + assertTrue(machine.signal()); + assertEquals(1, machine.testWorld.queryTimes.size()); + } + + @Test public void clientReadsNeitherQueryRedstoneNorInitializeTheCache() throws Exception { + ProbeMachine machine = fixture(EVEN, true); + SignalWorld client = machine.testWorld; + Field clientSide = World.class.getDeclaredField("isRemote"); + clientSide.setAccessible(true); + clientSide.setBoolean(client, true); + assertTrue(client.isRemote); + assertFalse(machine.signal()); + client.signalled = false; + assertFalse(machine.signal()); + assertTrue("Client reads must not query local redstone", client.queryTimes.isEmpty()); + machine.testWorld = world(EVEN, true); + assertTrue("Client reads must not initialize the server cache", machine.signal()); + assertEquals(1, machine.testWorld.queryTimes.size()); + } + + @Test public void powerUpdateBeforeFirstUseAlreadyInitializesTheCache() throws Exception { + ProbeMachine machine = fixture(EVEN, true); + machine.powerUpdate(); + assertEquals(1, machine.testWorld.queryTimes.size()); + machine.testWorld.signalled = false; + assertTrue(machine.signal()); + assertEquals("First use after powerUpdate must not resample", 1, machine.testWorld.queryTimes.size()); + } + + @Test public void realPoweredTickKeepsBothEdgesCachedUntilTheEightTickRefresh() throws Exception { + for (BlockPos position : new BlockPos[]{EVEN, ODD}) { + for (boolean initial : new boolean[]{false, true}) { + ProbeMachine machine = fixture(position, initial); + SignalWorld world = machine.testWorld; + long refresh = position.equals(EVEN) ? 8 : 9; + long first = refresh - 7; + world.time = first; + machine.tick(); + assertEquals(initial, machine.observed); + assertEquals(1, world.queryTimes.size()); + world.signalled = !initial; + for (long tick = first + 1; tick <= refresh + 8; tick++) { + world.time = tick; + machine.tick(); + assertEquals("tickUpdate must precede the scheduled refresh at " + tick, + tick <= refresh ? initial : !initial, machine.observed); + assertEquals(tick < refresh ? 1 : tick < refresh + 8 ? 2 : 3, world.queryTimes.size()); + } + assertEquals(Arrays.asList(first, refresh, refresh + 8), world.queryTimes); + assertEquals(2, machine.powerUpdates); + } + } + } + + private static ProbeMachine fixture(BlockPos position, boolean signalled) throws Exception { + ProbeMachine machine = new ProbeMachine(); + machine.setPos(position); + machine.testWorld = world(position, signalled); + // Isolate conduit discovery, not PoweredEntity's real power-update cadence. + Field delay = PoweredEntity.class.getDeclaredField("conduitRefreshDelay"); + delay.setAccessible(true); + delay.setInt(machine, -1); + return machine; + } + + private static SignalWorld world(BlockPos position, boolean signalled) throws Exception { + Class allocator = Class.forName("sun.misc.Unsafe"); + Field field = allocator.getDeclaredField("theUnsafe"); + field.setAccessible(true); + SignalWorld world = (SignalWorld)allocator.getMethod("allocateInstance", Class.class) + .invoke(field.get(null), SignalWorld.class); + world.position = position; + world.signalled = signalled; + world.queryTimes = new ArrayList<>(); + return world; + } + + private static final class ProbeMachine extends ElectricOvenTileEntity { + SignalWorld testWorld; + boolean observed; + int powerUpdates; + boolean signal() { return hasRedstoneSignal(); } + @Override public World getWorld() { return testWorld; } + @Override public void tickUpdate(boolean server) { observed = hasRedstoneSignal(); } + @Override public void powerUpdate() { + powerUpdates++; + super.powerUpdate(); + } + @Override protected void setPowerState(boolean powered) { } + } + + @Test public void generatorSamplesSignalledFirstUseBeforeItsFirstPulse() throws Exception { + ProbeGenerator generator = generatorFixture(true); + assertTrue("A newly loaded generator must observe redstone before generating", generator.signal()); + assertEquals(1, generator.testWorld.queryTimes.size()); + generator.testWorld.signalled = false; + assertTrue("First-use sampling must retain the normal cached value", generator.signal()); + assertEquals(1, generator.testWorld.queryTimes.size()); + } + + @Test public void generatorCachesAnUnsignalledFirstReadToo() throws Exception { + ProbeGenerator generator = generatorFixture(false); + assertFalse(generator.signal()); + assertEquals(1, generator.testWorld.queryTimes.size()); + generator.testWorld.signalled = true; + assertFalse("A false interlock sample is initialized, not missing", generator.signal()); + assertEquals(1, generator.testWorld.queryTimes.size()); + } + + @Test public void unattachedGeneratorReadDoesNotInitializeItsCache() throws Exception { + ProbeGenerator generator = new ProbeGenerator(); + generator.setPos(EVEN); + assertFalse(generator.signal()); + generator.testWorld = world(EVEN, true); + assertTrue(generator.signal()); + assertEquals(1, generator.testWorld.queryTimes.size()); + } + + @Test public void clientGeneratorReadCannotInitializeTheServerCache() throws Exception { + ProbeGenerator generator = generatorFixture(true); + Field clientSide = World.class.getDeclaredField("isRemote"); + clientSide.setAccessible(true); + clientSide.setBoolean(generator.testWorld, true); + assertFalse(generator.signal()); + assertTrue(generator.testWorld.queryTimes.isEmpty()); + generator.testWorld = world(EVEN, true); + assertTrue("A client-only read must leave the server interlock unsampled", generator.signal()); + assertEquals(1, generator.testWorld.queryTimes.size()); + } + + @Test public void inhibitedSolarGeneratorCannotGenerateItsFirstPulse() throws Exception { + ProbeGenerator generator = generatorFixture(true); + generator.powerUpdate(); + assertEquals("Redstone is sampled before generation, then on the ordinary refresh", 2, + generator.testWorld.queryTimes.size()); + assertEquals(0, generator.getEnergy(), 0); + assertEquals(0, generator.getPowerOutput(), 0); + } + + private static ProbeGenerator generatorFixture(boolean signalled) throws Exception { + ProbeGenerator generator = new ProbeGenerator(); + generator.setPos(EVEN); + generator.testWorld = world(EVEN, signalled); + return generator; + } + + private static final class ProbeGenerator extends PhotovoltaicGeneratorTileEntity { + SignalWorld testWorld; + boolean signal() { return hasRedstoneSignal(); } + @Override public World getWorld() { return testWorld; } + } + + private static final class SignalWorld extends ServerWorld { + @Override public boolean isSkyLightMax(BlockPos position) { + throw new AssertionError("An inhibited panel must not enter light sampling"); + } + BlockPos position; + boolean signalled; + long time; + List queryTimes; + private SignalWorld() { + super(null, null, null, null, null, null, null); + } + @Override public boolean isBlockPowered(BlockPos position) { + assertEquals(this.position, position); + queryTimes.add(time); + return signalled; + } + @Override public long getGameTime() { return time; } + } +} diff --git a/src/test/java/zone/moddev/mc/electricadvantage/machines/ElectricOperationalSoundBroadcastTest.java b/src/test/java/zone/moddev/mc/electricadvantage/machines/ElectricOperationalSoundBroadcastTest.java new file mode 100644 index 0000000..a5ca5de --- /dev/null +++ b/src/test/java/zone/moddev/mc/electricadvantage/machines/ElectricOperationalSoundBroadcastTest.java @@ -0,0 +1,128 @@ +package zone.moddev.mc.electricadvantage.machines; + +import java.lang.reflect.Field; +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.player.ServerPlayerEntity; +import net.minecraft.network.IPacket; +import net.minecraft.network.play.ServerPlayNetHandler; +import net.minecraft.network.play.server.SPlaySoundPacket; +import net.minecraft.util.SoundEvents; +import net.minecraft.util.math.AxisAlignedBB; +import net.minecraft.util.registry.Bootstrap; +import net.minecraft.world.World; +import net.minecraft.world.server.ServerWorld; +import org.junit.BeforeClass; +import org.junit.Test; + +import static org.junit.Assert.*; + +/** Real broadcast methods with an explicit player list and forbidden chunk-backed entity queries. */ +public class ElectricOperationalSoundBroadcastTest { + @BeforeClass public static void bootstrap() { Bootstrap.register(); } + + @Test public void machineBroadcastUsesOnlyExistingIntersectingConnectedPlayers() throws Exception { + QueryRejectingWorld testWorld = fixture(); + ElectricOvenTileEntity machine = new ElectricOvenTileEntity() { + @Override public World getWorld() { return testWorld; } + }; + machine.playSoundEffect(100, 80, -20, SoundEvents.BLOCK_ANVIL_LAND, 0.25f, 0.75f); + assertRecipients(testWorld); + } + + @Test public void generatorBroadcastUsesOnlyExistingIntersectingConnectedPlayers() throws Exception { + QueryRejectingWorld testWorld = fixture(); + PhotovoltaicGeneratorTileEntity generator = new PhotovoltaicGeneratorTileEntity() { + @Override public World getWorld() { return testWorld; } + }; + generator.playSoundEffect(100, 80, -20, SoundEvents.BLOCK_ANVIL_LAND, 0.25f, 0.75f); + assertRecipients(testWorld); + } + + @Test public void emptyPlayerListNeverQueriesChunks() throws Exception { + QueryRejectingWorld testWorld = withoutConstructor(QueryRejectingWorld.class); + testWorld.existingPlayers = Collections.emptyList(); + ElectricOvenTileEntity machine = new ElectricOvenTileEntity() { + @Override public World getWorld() { return testWorld; } + }; + machine.playSoundEffect(50000, 82, 50000, SoundEvents.BLOCK_ANVIL_LAND, 1, 1); + assertEquals(1, testWorld.playerListReads); + } + + private static QueryRejectingWorld fixture() throws Exception { + QueryRejectingWorld world = withoutConstructor(QueryRejectingWorld.class); + world.existingPlayers = new ArrayList<>(); + world.existingPlayers.add(player(new AxisAlignedBB(99.7, 80, -20.3, 100.3, 81.8, -19.7), false, true)); + // Player center can be outside 16 blocks while its box still intersects the original cube. + world.existingPlayers.add(player(new AxisAlignedBB(115.9, 80, -20.3, 116.5, 81.8, -19.7), false, true)); + // Cube corner is outside a spherical range but was included by the original AABB query. + world.existingPlayers.add(player(new AxisAlignedBB(115, 80, -5, 115.6, 81.8, -4.4), false, true)); + world.existingPlayers.add(player(new AxisAlignedBB(116, 80, -20.3, 116.6, 81.8, -19.7), false, true)); + world.existingPlayers.add(player(new AxisAlignedBB(99.7, 96, -20.3, 100.3, 97.8, -19.7), false, true)); + world.existingPlayers.add(player(new AxisAlignedBB(99.7, 62.2, -20.3, 100.3, 64, -19.7), false, true)); + world.existingPlayers.add(player(new AxisAlignedBB(99.7, 80, -20.3, 100.3, 81.8, -19.7), true, true)); + world.existingPlayers.add(player(new AxisAlignedBB(99.7, 80, -20.3, 100.3, 81.8, -19.7), false, false)); + return world; + } + + private static StubPlayer player(AxisAlignedBB bounds, boolean spectator, boolean connected) throws Exception { + StubPlayer player = withoutConstructor(StubPlayer.class); + player.setBoundingBox(bounds); + player.spectator = spectator; + if (connected) player.connection = withoutConstructor(RecordingConnection.class); + return player; + } + + private static void assertRecipients(QueryRejectingWorld world) { + assertEquals("Broadcast reads the existing player list once", 1, world.playerListReads); + for (int i = 0; i < world.existingPlayers.size(); i++) { + ServerPlayerEntity player = world.existingPlayers.get(i); + if (player.connection == null) continue; + RecordingConnection connection = (RecordingConnection)player.connection; + assertEquals("Player " + i, i < 3 ? 1 : 0, connection.sent); + if (i < 3) assertTrue(connection.last instanceof SPlaySoundPacket); + } + } + + private static T withoutConstructor(Class type) throws Exception { + Class allocator = Class.forName("sun.misc.Unsafe"); + Field field = allocator.getDeclaredField("theUnsafe"); + field.setAccessible(true); + return type.cast(allocator.getMethod("allocateInstance", Class.class).invoke(field.get(null), type)); + } + + private static final class QueryRejectingWorld extends ServerWorld { + List existingPlayers; + int playerListReads; + + private QueryRejectingWorld() { + super(null, null, null, null, null, null, null); + } + + @Override public List getPlayers() { + playerListReads++; + return existingPlayers; + } + + @Override public List getEntitiesWithinAABB(Class type, + AxisAlignedBB bounds, Predicate predicate) { + throw new AssertionError("Sound broadcast must not use chunk-backed entity queries"); + } + } + + private static final class StubPlayer extends ServerPlayerEntity { + boolean spectator; + private StubPlayer() { super(null, null, null, null); } + @Override public boolean isSpectator() { return spectator; } + } + + private static final class RecordingConnection extends ServerPlayNetHandler { + int sent; + IPacket last; + private RecordingConnection() { super(null, null, null); } + @Override public void sendPacket(IPacket packet) { sent++; last = packet; } + } +} diff --git a/src/test/java/zone/moddev/mc/electricadvantage/machines/ElectricOperationalStillCapacityTest.java b/src/test/java/zone/moddev/mc/electricadvantage/machines/ElectricOperationalStillCapacityTest.java new file mode 100644 index 0000000..e5bcbf7 --- /dev/null +++ b/src/test/java/zone/moddev/mc/electricadvantage/machines/ElectricOperationalStillCapacityTest.java @@ -0,0 +1,97 @@ +package zone.moddev.mc.electricadvantage.machines; + +import cyano.poweradvantage.api.fluid.FluidStack; +import java.lang.reflect.Field; +import net.minecraft.fluid.Fluids; +import net.minecraft.util.registry.Bootstrap; +import org.junit.BeforeClass; +import org.junit.Before; +import org.junit.After; +import org.junit.Test; +import zone.moddev.mc.electricadvantage.init.Power; +import zone.moddev.mc.poweradvantage.registry.still.recipe.DistillationRecipe; +import zone.moddev.mc.poweradvantage.registry.still.recipe.DistillationRecipeRegistry; +import zone.moddev.mc.poweradvantage.registry.still.recipe.ExplicitFluidReference; + +import static org.junit.Assert.*; + +/** Real tick conservation checks; the shared rig separately supplies a live server world. */ +public class ElectricOperationalStillCapacityTest { + private Field singleton; + private Object previousRegistry; + + @BeforeClass public static void bootstrap() { Bootstrap.register(); } + + @Before public void installIsolatedRecipe() throws Exception { + singleton = DistillationRecipeRegistry.class.getDeclaredField("instance"); + singleton.setAccessible(true); + previousRegistry = singleton.get(null); + DistillationRecipeRegistry registry = new DistillationRecipeRegistry(); + registry.addRecipe(new DistillationRecipe(new ExplicitFluidReference(Fluids.WATER), 2, + new ExplicitFluidReference(Fluids.LAVA), 3)); + singleton.set(null, registry); + } + + @After public void restoreRecipeRegistry() throws Exception { + singleton.set(null, previousRegistry); + } + + @Test public void aFullTankCannotConsumeInput() throws Exception { + ElectricStillTileEntity still = fixture(1000); + for (int tick = 0; tick < 4; tick++) still.tickUpdate(true); + assertEquals(4, still.getInputTank().getFluidAmount()); + assertEquals(1000, still.getOutputTank().getFluidAmount()); + assertEquals(64, still.getEnergy(Power.ELECTRIC_POWER), 0); + } + + @Test public void aPartialOutputMustFitTheWholeRecipe() throws Exception { + ElectricStillTileEntity still = fixture(998); + still.tickUpdate(true); + assertEquals(4, still.getInputTank().getFluidAmount()); + assertEquals(998, still.getOutputTank().getFluidAmount()); + assertEquals(64, still.getEnergy(Power.ELECTRIC_POWER), 0); + } + + @Test public void exactSpaceProducesTheWholeBatchAndDebitsOnlyOneCycle() throws Exception { + ElectricStillTileEntity still = fixture(997); + still.tickUpdate(true); + assertEquals(2, still.getInputTank().getFluidAmount()); + assertEquals(1000, still.getOutputTank().getFluidAmount()); + assertSame(Fluids.LAVA, still.getOutputTank().getFluid().getFluid()); + assertEquals(48, still.getEnergy(Power.ELECTRIC_POWER), 0); + still.tickUpdate(true); + assertEquals(2, still.getInputTank().getFluidAmount()); + assertEquals(1000, still.getOutputTank().getFluidAmount()); + assertEquals(48, still.getEnergy(Power.ELECTRIC_POWER), 0); + } + + @Test public void incompatibleOutputCannotBeOverwritten() throws Exception { + ElectricStillTileEntity still = fixture(0); + still.getOutputTank().setFluid(new FluidStack(Fluids.WATER, 1)); + still.tickUpdate(true); + assertEquals(Fluids.WATER, still.getOutputTank().getFluid().getFluid()); + assertEquals(4, still.getInputTank().getFluidAmount()); + assertEquals(64, still.getEnergy(Power.ELECTRIC_POWER), 0); + } + + @Test public void anIncompleteInputDoesNotInventAnOutput() throws Exception { + ElectricStillTileEntity still = fixture(0); + still.getInputTank().setFluid(new FluidStack(Fluids.WATER, 1)); + still.tickUpdate(true); + assertEquals(1, still.getInputTank().getFluidAmount()); + assertEquals(0, still.getOutputTank().getFluidAmount()); + assertEquals(64, still.getEnergy(Power.ELECTRIC_POWER), 0); + } + + private static ElectricStillTileEntity fixture(int output) { + ElectricStillTileEntity still = new ElectricStillTileEntity() { + @Override protected boolean hasRedstoneSignal() { return false; } + @Override protected void setActiveState(boolean active) { } + }; + still.setEnergy(64, Power.ELECTRIC_POWER); + still.getInputTank().setFluid(new FluidStack(Fluids.WATER, 4)); + if (output > 0) still.getOutputTank().setFluid(new FluidStack(Fluids.LAVA, output)); + return still; + } + +} From da6bd188d86cf96ee2690ff1fa77dbe6a1aa06cb Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Fri, 30 Oct 2026 20:57:39 +0000 Subject: [PATCH 2/2] Clarify public source status without changing qualification records --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 939560b..1a0757e 100644 --- a/README.md +++ b/README.md @@ -95,4 +95,4 @@ 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. +No release publication is included.