diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 20b90ac..cab0e7d 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,16 @@ +Unreleased - Minecraft 1.14.4 + +- Keep waterlogged blocks intact when the steam pump collects their water, and only charge for successful pickup. +- Keep fluid in the steam pump when an attached tank refuses it. +- Pause the steam still when its output cannot hold a complete batch, without wasting input or processing steam. +- Let a loaded musket fire its last cartridge without needing spare ammunition in the inventory. +- Stop musket shots at terrain and select nearby targets without loading extra server chunks. +- Send machine sounds to connected nearby players without scanning unloaded chunks; keep the existing sound ranges and spectator exclusion. +- Read live redstone before the crusher and furnace resume after loading, preserving saved work and the first-tick steam heat bonus without changing the normal polling interval. +- Save real coal-boiler tank changes immediately, leave simulations unchanged, and return an empty result for null, empty or mismatched drain requests. +- Honour the water-only input of coal, electric and geothermal boilers even when empty, and safely refuse null, empty or mismatched fluid requests. +- Save accepted external tank changes in electric and geothermal boilers immediately, with the same simulation and client-side safeguards as the coal boiler. + Steam Advantage 2.2.6.114041 - Save the drill's cached facing without reloading a chunk during its unload-save. diff --git a/README.md b/README.md index 095f45d..eeb9f08 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,37 @@ runtime or world-upgrade evidence. For an all-Advantage play-test, run `prepareCombinedEclipseLaunch` in the sibling PowerAdvantage checkout and select its `runClientAllAdvantage.launch`. The [native resource probe](https://github.com/MinecraftModDevelopmentMods/PowerAdvantage/blob/master-1.14.4/test-rig/client-resources/README.md) checks models, textures and names across all three mods. These resource checks do not replace gameplay testing. +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.steamadvantage`. Runtime identities, registries, tile IDs, saved NBT, recipes, and assets remain stable. Steam registers its legacy state and NBT codecs with PowerAdvantage's shared migration hook so 1.10 and 1.12 worlds can be converted before Minecraft flattens old numeric block states. 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/steamadvantage/gui/GUIHelper.java b/src/main/java/zone/moddev/mc/steamadvantage/gui/GUIHelper.java index 81c9109..c5dffaa 100644 --- a/src/main/java/zone/moddev/mc/steamadvantage/gui/GUIHelper.java +++ b/src/main/java/zone/moddev/mc/steamadvantage/gui/GUIHelper.java @@ -25,6 +25,7 @@ public static float maxDelta(float newValue,float oldValue,float maxChange){ public static void drawNeedle(float pivotX, float pivotY, float zLevel, float value) { + value = displayLevel(value); final float pixelSize = 1.0f / 256; // conversion factor @@ -85,6 +86,7 @@ public static void drawNeedle(float pivotX, float pivotY, float zLevel, } public static void drawFlameProgress(int x, int y, float flameHeight, GUIContainer gc){ + flameHeight = displayLevel(flameHeight); if(flameHeight <= 0) return; final int flameTexX = 200; final int flameTexY = 0; @@ -100,6 +102,7 @@ public static void drawFlameProgress(int x, int y, float flameHeight, GUIContain public static void drawFluidBar(FluidStack fs, float barHeight, int xPos, int yPos, GUIContainer guiContainer, int x, int y, float z, ResourceLocation displayImage, int texOverlayX, int texOverlayY, int texOverlayW, int texOverlayH){ + barHeight = displayLevel(barHeight); final int texMarginW = (texOverlayW - 16) / 2; final int texMarginH = (texOverlayH - 60) / 2; final int w = 16; @@ -117,6 +120,7 @@ public static void drawFluidBar(FluidStack fs, float barHeight, int xPos, int yP public static void drawDownArrowProgress(int x, int y, float progress, GUIContainer gc) { + progress = displayLevel(progress); if(progress <= 0) return; final int arrowTexX = 184; final int arrowTexY = 0; @@ -128,4 +132,8 @@ public static void drawDownArrowProgress(int x, int y, float progress, gc.blit(x, y, arrowTexX, arrowTexY, width, h); } } + + static float displayLevel(float value) { + return Float.isFinite(value) ? Math.max(0F, Math.min(1F, value)) : 0F; + } } diff --git a/src/main/java/zone/moddev/mc/steamadvantage/items/MusketItem.java b/src/main/java/zone/moddev/mc/steamadvantage/items/MusketItem.java index 7d437c8..e56d053 100644 --- a/src/main/java/zone/moddev/mc/steamadvantage/items/MusketItem.java +++ b/src/main/java/zone/moddev/mc/steamadvantage/items/MusketItem.java @@ -4,6 +4,8 @@ import static zone.moddev.mc.steamadvantage.util.SoundHelper.playSoundAtPosition; import java.util.List; +import java.util.Iterator; +import java.util.Optional; import com.google.common.collect.HashMultimap; import com.google.common.collect.Multimap; @@ -114,7 +116,7 @@ public UseAction getUseAction(ItemStack stack){ public ActionResult onItemRightClick(World world, PlayerEntity playerEntity, Hand hand){ ItemStack srcItemStack = playerEntity.getHeldItem(hand); if(hand != Hand.MAIN_HAND) return ActionResult.newResult(ActionResultType.PASS,srcItemStack); - if(!hasAmmo(playerEntity, srcItemStack)) return ActionResult.newResult(ActionResultType.FAIL,srcItemStack); + if(isNotLoaded(srcItemStack) && !hasAmmo(playerEntity, srcItemStack)) return ActionResult.newResult(ActionResultType.FAIL,srcItemStack); if(isNotLoaded(srcItemStack) && EnchantmentHelper.getEnchantmentLevel(Enchantments.rapid_reload, srcItemStack) > 0){ // instant reload decrementAmmo(playerEntity,srcItemStack); @@ -367,38 +369,37 @@ public static void decrementAmmo(LivingEntity e, ItemStack musket) { public static RayTraceResult rayTraceBlocksAndEntities(World w, double maxRange, LivingEntity source){ - double rangeSqr = maxRange * maxRange; Vec3d rayOrigin = new Vec3d(source.posX, source.posY + source.getEyeHeight(), source.posZ); Vec3d rayDirection = source.getLookVec(); - BlockPos srcPos = source.getPosition(); - AxisAlignedBB aoi = new AxisAlignedBB(srcPos.getX() - maxRange, srcPos.getY() - maxRange, srcPos.getZ() - maxRange, - srcPos.getX() + maxRange, srcPos.getY() + maxRange, srcPos.getZ() + maxRange); - List entities = w.getEntitiesWithinAABBExcludingEntity(source, aoi); - double closestDistSqr = Double.MAX_VALUE; + BlockRayTraceResult terrainHit = w.rayTraceBlocks(new RayTraceContext(rayOrigin, rayOrigin.add(rayDirection.scale(maxRange)), + RayTraceContext.BlockMode.COLLIDER, RayTraceContext.FluidMode.SOURCE_ONLY, source)); + Vec3d rayEnd = terrainHit.getHitVec(); + AxisAlignedBB aoi = new AxisAlignedBB(rayOrigin, rayEnd).grow(1.0D); + // Select existing server entities without a spatial query that can load distant chunks. + Iterator entities = w instanceof ServerWorld ? ((ServerWorld)w).getEntities().iterator() + : w.getEntitiesWithinAABBExcludingEntity(source, aoi).iterator(); + double closestDistSqr = rayOrigin.squareDistanceTo(rayEnd); Entity closestEntity = null; - for(Entity e : entities){ - double distSqr = e.getDistanceSq(source.posX, source.posY, source.posZ); - if(distSqr < rangeSqr){ - // e is within range - AxisAlignedBB box = e.getBoundingBox(); - if(rayIntersectsBoundingBox(rayOrigin,rayDirection, box)){ - // e is in cross-hairs - if(distSqr < closestDistSqr){ - // e is closest entity in line of fire - closestDistSqr = distSqr; - closestEntity = e; - } + Vec3d closestHit = null; + while(entities.hasNext()){ + Entity e = entities.next(); + if(e == source || e.removed || e.isSpectator() || !aoi.intersects(e.getBoundingBox())) continue; + Optional hit = rayHitBeforeTerrain(rayOrigin, rayEnd, e.getBoundingBox()); + if(hit.isPresent()){ + double distSqr = rayOrigin.squareDistanceTo(hit.get()); + if(distSqr < closestDistSqr){ + closestDistSqr = distSqr; + closestEntity = e; + closestHit = hit.get(); } } } - if(closestEntity == null) { - return w.rayTraceBlocks(new RayTraceContext(rayOrigin, rayOrigin.add(mul(rayDirection, maxRange)), - RayTraceContext.BlockMode.COLLIDER, RayTraceContext.FluidMode.SOURCE_ONLY, source)); - } else { - Vec3d pos = new Vec3d(closestEntity.posX, closestEntity.posY+closestEntity.getEyeHeight(), closestEntity.posZ); - RayTraceResult entityCollision = new EntityRayTraceResult(closestEntity, pos); - return entityCollision; - } + return closestEntity == null ? terrainHit : new EntityRayTraceResult(closestEntity, closestHit); + } + + static Optional rayHitBeforeTerrain(Vec3d origin, Vec3d terrainEnd, AxisAlignedBB target){ + Optional hit = target.contains(origin) ? Optional.of(origin) : target.rayTrace(origin, terrainEnd); + return hit.filter(point -> origin.squareDistanceTo(point) < origin.squareDistanceTo(terrainEnd)); } public static boolean rayIntersectsBoundingBox(Vec3d rayOrigin, Vec3d rayDirection, AxisAlignedBB box){ @@ -431,9 +432,6 @@ public static boolean rayIntersectsBoundingBox(Vec3d rayOrigin, Vec3d rayDirecti } - private static Vec3d mul(Vec3d a, double b){ - return new Vec3d(a.x * b, a.y * b, a.z * b); - } private static double max(double a, double b){ return Math.max(a, b); } diff --git a/src/main/java/zone/moddev/mc/steamadvantage/machines/BlastFurnaceTileEntity.java b/src/main/java/zone/moddev/mc/steamadvantage/machines/BlastFurnaceTileEntity.java index 2ab9d80..e41a90c 100644 --- a/src/main/java/zone/moddev/mc/steamadvantage/machines/BlastFurnaceTileEntity.java +++ b/src/main/java/zone/moddev/mc/steamadvantage/machines/BlastFurnaceTileEntity.java @@ -38,6 +38,13 @@ public class BlastFurnaceTileEntity extends cyano.poweradvantage.api.simple.Tile private static final float minSmeltingTemperature = 100f; private boolean redstone = true; + private boolean redstoneInitialized = false; + + @Override + public void onLoad() { + super.onLoad(); + redstoneInitialized = false; + } public BlastFurnaceTileEntity() { super(zone.moddev.mc.steamadvantage.init.Entities.STEAM_FURNACE, Power.steam_power, 200, "tile.steamadvantage.steam_furnace.name"); @@ -66,6 +73,10 @@ public float getTemperatureLevel(){ @Override public void tickUpdate(boolean isServerWorld) { if(isServerWorld){ + if (!redstoneInitialized && getWorld() != null) { + redstone = hasRedstoneSignal(); + redstoneInitialized = true; + } // fual input heat float e = 0; if(burnTime > 0){ @@ -172,6 +183,7 @@ public void powerUpdate(){ this.sync(); } redstone = hasRedstoneSignal(); + redstoneInitialized = true; } private boolean hasRedstoneSignal() { diff --git a/src/main/java/zone/moddev/mc/steamadvantage/machines/CoalBoilerTileEntity.java b/src/main/java/zone/moddev/mc/steamadvantage/machines/CoalBoilerTileEntity.java index 04d835a..c3215f7 100644 --- a/src/main/java/zone/moddev/mc/steamadvantage/machines/CoalBoilerTileEntity.java +++ b/src/main/java/zone/moddev/mc/steamadvantage/machines/CoalBoilerTileEntity.java @@ -39,7 +39,15 @@ public class CoalBoilerTileEntity extends cyano.poweradvantage.api.simple.TileEn public CoalBoilerTileEntity() { super(zone.moddev.mc.steamadvantage.init.Entities.STEAM_BOILER_COAL, new ConduitType[]{Power.steam_power,Fluids.fluidConduit_general}, new float[]{1000,1000}, "tile.steamadvantage.steam_boiler_coal.name"); - tank = new FluidTank(FluidTank.BUCKET_VOLUME * 4); + tank = new FluidTank(FluidTank.BUCKET_VOLUME * 4) { + @Override + protected void onContentsChanged() { + if (CoalBoilerTileEntity.this.getWorld() != null + && !CoalBoilerTileEntity.this.getWorld().isRemote) { + CoalBoilerTileEntity.this.markDirty(); + } + } + }; inventory = new ItemStack[1]; } @@ -349,6 +357,8 @@ && getTank().getFluidAmount() > 0 */ @Override public int fill(Direction face, FluidStack fluid, boolean forReal) { + if (fluid == null || fluid.amount <= 0 || !canFill(face, fluid.getFluid()) + || getTank().getFluidAmount() >= getTank().getCapacity()) return 0; if(getTank().getFluidAmount() <= 0 || getTank().getFluid().getFluid().equals(fluid.getFluid())){ return getTank().fill(fluid, forReal); } else { @@ -363,10 +373,11 @@ public int fill(Direction face, FluidStack fluid, boolean forReal) { */ @Override public FluidStack drain(Direction face, FluidStack fluid, boolean forReal) { + if (fluid == null || fluid.amount <= 0) return null; if(getTank().getFluidAmount() > 0 && getTank().getFluid().getFluid().equals(fluid.getFluid())){ return getTank().drain(fluid.amount,forReal); } else { - return new FluidStack(getTank().getFluid().getFluid(),0); + return null; } } /** @@ -377,6 +388,7 @@ public FluidStack drain(Direction face, FluidStack fluid, boolean forReal) { */ @Override public FluidStack drain(Direction face, int amount, boolean forReal) { + if (amount <= 0) return null; if(getTank().getFluidAmount() > 0 ){ return getTank().drain(amount,forReal); } else { diff --git a/src/main/java/zone/moddev/mc/steamadvantage/machines/ElectricBoilerTileEntity.java b/src/main/java/zone/moddev/mc/steamadvantage/machines/ElectricBoilerTileEntity.java index a40d441..3195b8e 100644 --- a/src/main/java/zone/moddev/mc/steamadvantage/machines/ElectricBoilerTileEntity.java +++ b/src/main/java/zone/moddev/mc/steamadvantage/machines/ElectricBoilerTileEntity.java @@ -40,7 +40,15 @@ public ElectricBoilerTileEntity() { super(zone.moddev.mc.steamadvantage.init.Entities.STEAM_BOILER_ELECTRIC, new ConduitType[]{Power.steam_power, ELECTRIC_POWER, Fluids.fluidConduit_general}, new float[]{100, MAX_ELECTRICITY, 1000}, "tile.steamadvantage.steam_boiler_electric.name"); - tank = new FluidTank(FluidTank.BUCKET_VOLUME * 2); + tank = new FluidTank(FluidTank.BUCKET_VOLUME * 2) { + @Override + protected void onContentsChanged() { + if (ElectricBoilerTileEntity.this.getWorld() != null + && !ElectricBoilerTileEntity.this.getWorld().isRemote) { + ElectricBoilerTileEntity.this.markDirty(); + } + } + }; inventory = new ItemStack[0]; } @@ -301,6 +309,8 @@ public PowerRequest getPowerRequest(ConduitType offer) { */ @Override public int fill(Direction face, FluidStack fluid, boolean forReal) { + if (fluid == null || fluid.amount <= 0 || !canFill(face, fluid.getFluid()) + || getTank().getFluidAmount() >= getTank().getCapacity()) return 0; if(getTank().getFluidAmount() <= 0 || getTank().getFluid().getFluid().equals(fluid.getFluid())){ return getTank().fill(fluid, forReal); } else { @@ -315,10 +325,11 @@ public int fill(Direction face, FluidStack fluid, boolean forReal) { */ @Override public FluidStack drain(Direction face, FluidStack fluid, boolean forReal) { + if (fluid == null || fluid.amount <= 0) return null; if(getTank().getFluidAmount() > 0 && getTank().getFluid().getFluid().equals(fluid.getFluid())){ return getTank().drain(fluid.amount,forReal); } else { - return new FluidStack(getTank().getFluid().getFluid(),0); + return null; } } /** @@ -329,6 +340,7 @@ public FluidStack drain(Direction face, FluidStack fluid, boolean forReal) { */ @Override public FluidStack drain(Direction face, int amount, boolean forReal) { + if (amount <= 0) return null; if(getTank().getFluidAmount() > 0 ){ return getTank().drain(amount,forReal); } else { diff --git a/src/main/java/zone/moddev/mc/steamadvantage/machines/GeothermalBoilerTileEntity.java b/src/main/java/zone/moddev/mc/steamadvantage/machines/GeothermalBoilerTileEntity.java index 0f921f6..2ea16e8 100644 --- a/src/main/java/zone/moddev/mc/steamadvantage/machines/GeothermalBoilerTileEntity.java +++ b/src/main/java/zone/moddev/mc/steamadvantage/machines/GeothermalBoilerTileEntity.java @@ -43,7 +43,15 @@ public class GeothermalBoilerTileEntity extends cyano.poweradvantage.api.simple. public GeothermalBoilerTileEntity() { super(zone.moddev.mc.steamadvantage.init.Entities.STEAM_BOILER_GEOTHERMAL, new ConduitType[]{Power.steam_power,Fluids.fluidConduit_general}, new float[]{100,1000}, "tile.steamadvantage.steam_boiler_geothermal.name"); - tank = new FluidTank(FluidTank.BUCKET_VOLUME * 2); + tank = new FluidTank(FluidTank.BUCKET_VOLUME * 2) { + @Override + protected void onContentsChanged() { + if (GeothermalBoilerTileEntity.this.getWorld() != null + && !GeothermalBoilerTileEntity.this.getWorld().isRemote) { + GeothermalBoilerTileEntity.this.markDirty(); + } + } + }; inventory = new ItemStack[0]; } @@ -337,6 +345,8 @@ public PowerRequest getPowerRequest(ConduitType offer) { */ @Override public int fill(Direction face, FluidStack fluid, boolean forReal) { + if (fluid == null || fluid.amount <= 0 || !canFill(face, fluid.getFluid()) + || getTank().getFluidAmount() >= getTank().getCapacity()) return 0; if(getTank().getFluidAmount() <= 0 || getTank().getFluid().getFluid().equals(fluid.getFluid())){ return getTank().fill(fluid, forReal); } else { @@ -351,10 +361,11 @@ public int fill(Direction face, FluidStack fluid, boolean forReal) { */ @Override public FluidStack drain(Direction face, FluidStack fluid, boolean forReal) { + if (fluid == null || fluid.amount <= 0) return null; if(getTank().getFluidAmount() > 0 && getTank().getFluid().getFluid().equals(fluid.getFluid())){ return getTank().drain(fluid.amount,forReal); } else { - return new FluidStack(getTank().getFluid().getFluid(),0); + return null; } } /** @@ -365,6 +376,7 @@ public FluidStack drain(Direction face, FluidStack fluid, boolean forReal) { */ @Override public FluidStack drain(Direction face, int amount, boolean forReal) { + if (amount <= 0) return null; if(getTank().getFluidAmount() > 0 ){ return getTank().drain(amount,forReal); } else { diff --git a/src/main/java/zone/moddev/mc/steamadvantage/machines/RockCrusherTileEntity.java b/src/main/java/zone/moddev/mc/steamadvantage/machines/RockCrusherTileEntity.java index daa0356..60e492b 100644 --- a/src/main/java/zone/moddev/mc/steamadvantage/machines/RockCrusherTileEntity.java +++ b/src/main/java/zone/moddev/mc/steamadvantage/machines/RockCrusherTileEntity.java @@ -33,6 +33,13 @@ public RockCrusherTileEntity() { } private boolean redstone = true; + private boolean redstoneInitialized = false; + + @Override + public void onLoad() { + super.onLoad(); + redstoneInitialized = false; + } private int timeSinceLastSteamBurst = 0; @@ -41,6 +48,10 @@ public RockCrusherTileEntity() { @Override public void tickUpdate(boolean isServerWorld) { if(isServerWorld){ + if (!redstoneInitialized && getWorld() != null) { + redstone = hasRedstoneSignal(); + redstoneInitialized = true; + } // reset progress when item in slot changes ItemStack input = inventory[0]; if(itemCheck != null && (input == null || input.isEmpty() || ItemStack.areItemsEqual(itemCheck, input) == false)){ @@ -113,6 +124,7 @@ public void powerUpdate(){ super.powerUpdate(); redstone = hasRedstoneSignal(); + redstoneInitialized = true; if(oldSteam != this.getEnergy(Power.steam_power) || progress != oldProgress){ this.sync(); diff --git a/src/main/java/zone/moddev/mc/steamadvantage/machines/SteamPumpTileEntity.java b/src/main/java/zone/moddev/mc/steamadvantage/machines/SteamPumpTileEntity.java index 850a189..a397adf 100644 --- a/src/main/java/zone/moddev/mc/steamadvantage/machines/SteamPumpTileEntity.java +++ b/src/main/java/zone/moddev/mc/steamadvantage/machines/SteamPumpTileEntity.java @@ -11,8 +11,8 @@ import zone.moddev.mc.poweradvantage.util.FluidHandlerHelper; import zone.moddev.mc.steamadvantage.init.Power; import net.minecraft.block.BlockState; +import net.minecraft.block.IBucketPickupHandler; import net.minecraft.entity.player.PlayerEntity; -import net.minecraft.block.Blocks; import net.minecraft.util.SoundEvents; import net.minecraft.item.ItemStack; import net.minecraft.nbt.CompoundNBT; @@ -21,6 +21,7 @@ import net.minecraft.util.SoundCategory; import net.minecraft.util.math.BlockPos; import net.minecraft.world.World; +import net.minecraft.world.IWorld; import net.minecraft.fluid.Fluid; import cyano.poweradvantage.api.fluid.FluidStack; import cyano.poweradvantage.api.fluid.FluidTank; @@ -89,11 +90,14 @@ public void tickUpdate(boolean isServerWorld) { // Found a fluid to suck-up! BlockState blockstate = w.getBlockState(fluidSource); Fluid f = getFluid(blockstate); - if(f != null && getEnergy(Power.steam_power) >= cost){ - this.getTank().fill(new FluidStack(f,FluidTank.BUCKET_VOLUME), true); - this.subtractEnergy(cost, Power.steam_power); - w.removeBlock(fluidSource, false); - success = true; + if(f != null && getEnergy(Power.steam_power) >= cost + && getTank().fill(new FluidStack(f,FluidTank.BUCKET_VOLUME), false) == FluidTank.BUCKET_VOLUME){ + Fluid collected = pickupSource(w, fluidSource, blockstate); + if(collected != net.minecraft.fluid.Fluids.EMPTY){ + this.getTank().fill(new FluidStack(collected,FluidTank.BUCKET_VOLUME), true); + this.subtractEnergy(cost, Power.steam_power); + success = true; + } } } timeUntilNextPump = PUMP_INTERVAL; @@ -120,6 +124,15 @@ private Fluid getFluid(BlockState blockstate) { return blockstate.getFluidState().isEmpty() ? null : blockstate.getFluidState().getFluid(); } + static Fluid pickupSource(IWorld world, BlockPos pos, BlockState state) { + if(!state.getFluidState().isSource() || !(state.getBlock() instanceof IBucketPickupHandler)){ + return net.minecraft.fluid.Fluids.EMPTY; + } + // Native pickup removes the fluid without destroying a waterlogged host block. + Fluid collected = ((IBucketPickupHandler)state.getBlock()).pickupFluid(world, pos, state); + return collected == null ? net.minecraft.fluid.Fluids.EMPTY : collected; + } + private BlockPos scan(Set searchSpace, BlockPos coord, int limit) { @@ -148,7 +161,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.getFluidState().isSource() && state.getBlock() instanceof IBucketPickupHandler; } @@ -197,7 +211,7 @@ public void powerUpdate(){ IAdvantageFluidHandler handler = FluidHandlerHelper.getHandler(neighbor, dir.getOpposite()); if(handler != null){ int d = handler.fill(dir.getOpposite(), fluid, true); - getTank().drain(Math.max(d,1),true); // no free energy! + if(d > 0) getTank().drain(d,true); fluid = getTank().getFluid(); } } diff --git a/src/main/java/zone/moddev/mc/steamadvantage/machines/SteamStillTileEntity.java b/src/main/java/zone/moddev/mc/steamadvantage/machines/SteamStillTileEntity.java index 28ff12b..6569ad3 100644 --- a/src/main/java/zone/moddev/mc/steamadvantage/machines/SteamStillTileEntity.java +++ b/src/main/java/zone/moddev/mc/steamadvantage/machines/SteamStillTileEntity.java @@ -53,8 +53,7 @@ public void tickUpdate(boolean isServerWorld) { if(!redstone){ if(getInputTank().getFluidAmount() > 0 && getEnergy(Power.steam_power) > steamPerDistill - && canDistill(getInputTank().getFluid())){ - distill(); + && canDistill(getInputTank().getFluid()) && distill()){ this.subtractEnergy(steamPerDistill, Power.steam_power); if(timeSinceSound > 200){ playSoundAtTileEntity(SoundEvents.BLOCK_LAVA_AMBIENT, SoundCategory.AMBIENT, 0.3f, 1.5f, this); @@ -294,20 +293,34 @@ private boolean canDistill(Fluid f) { } private boolean canDistill(FluidStack fs) { + if(fs == null) return false; DistillationRecipe recipe = DistillationRecipeRegistry.getInstance().getDistillationRecipeForFluid(fs.getFluid()); if(recipe == null) return false; - if(this.getOutputTank().getFluidAmount() > 0){ - return recipe.isValidInput(fs) && recipe.isValidOutput(getOutputTank().getFluid()); - } else { - return recipe.isValidInput(fs); + return recipe.isValidInput(fs) && (outputTank.getFluidAmount() == 0 + || recipe.isValidOutput(outputTank.getFluid())); + } + + private int distillationApplications(DistillationRecipe recipe, FluidStack input) { + if(!recipe.isValidInput(input)) return 0; + if(outputTank.getFluidAmount() > 0 && !recipe.isValidOutput(outputTank.getFluid())) return 0; + // Preview on a copy so blocked output cannot consume input or lose part of a batch. + for(int applications = speed; applications > 0; applications--){ + FluidStack output = recipe.applyRecipe(input.copy(), applications); + if(output != null && outputTank.fill(output, false) == output.amount) return applications; } + return 0; } - private void distill(){ + private boolean distill(){ DistillationRecipe recipe = DistillationRecipeRegistry.getInstance() .getDistillationRecipeForFluid(getInputTank().getFluid().getFluid()); - FluidStack output = recipe.applyRecipe(inputTank.getFluid(), speed); - getOutputTank().fill(output, true); + int applications = distillationApplications(recipe, inputTank.getFluid()); + if(applications > 0){ + FluidStack output = recipe.applyRecipe(inputTank.getFluid(), applications); + getOutputTank().fill(output, true); + return true; + } + return false; } diff --git a/src/main/java/zone/moddev/mc/steamadvantage/util/SoundHelper.java b/src/main/java/zone/moddev/mc/steamadvantage/util/SoundHelper.java index 59c8580..d82e35f 100644 --- a/src/main/java/zone/moddev/mc/steamadvantage/util/SoundHelper.java +++ b/src/main/java/zone/moddev/mc/steamadvantage/util/SoundHelper.java @@ -1,8 +1,10 @@ package zone.moddev.mc.steamadvantage.util; +import net.minecraft.entity.player.PlayerEntity; import net.minecraft.entity.player.ServerPlayerEntity; import net.minecraft.network.play.server.SPlaySoundPacket; import net.minecraft.tileentity.TileEntity; +import net.minecraft.util.EntityPredicates; import net.minecraft.util.SoundCategory; import net.minecraft.util.SoundEvent; import net.minecraft.util.math.AxisAlignedBB; @@ -10,8 +12,6 @@ import net.minecraft.util.math.Vec3d; import net.minecraft.world.World; -import java.util.List; - public class SoundHelper { public static void playSoundAtTileEntity(SoundEvent sound, SoundCategory soundType, float volume, float pitch, TileEntity te){ @@ -23,11 +23,14 @@ public static void playSoundAtTileEntity(SoundEvent sound, SoundCategory soundTy public static void playSoundAtPosition(double x, double y, double z, SoundEvent sound, SoundCategory soundType, float volume, float pitch, World serverWorld){ if(serverWorld.isRemote) return; final double range = 16; - List players = serverWorld.getEntitiesWithinAABB(ServerPlayerEntity.class, new AxisAlignedBB( + final AxisAlignedBB area = new AxisAlignedBB( x - range, y - range, z - range, - x + range, y + range, z + range)); - for(ServerPlayerEntity player : players){ - player.connection.sendPacket(new SPlaySoundPacket(sound.getRegistryName(), soundType, + x + range, y + range, z + range); + for(PlayerEntity candidate : serverWorld.getPlayers()){ + if(!(candidate instanceof ServerPlayerEntity) || !EntityPredicates.NOT_SPECTATING.test(candidate) + || !area.intersects(candidate.getBoundingBox())) continue; + ServerPlayerEntity player = (ServerPlayerEntity) candidate; + if(player.connection != null) player.connection.sendPacket(new SPlaySoundPacket(sound.getRegistryName(), soundType, new Vec3d(x, y, z), volume, pitch)); } } @@ -35,11 +38,14 @@ public static void playSoundAtPosition(double x, double y, double z, SoundEvent public static void playBigSoundAtPosition(double x, double y, double z, SoundEvent sound, SoundCategory soundType, float volume, float pitch, World serverWorld){ if(serverWorld.isRemote) return; final double range = 50; - List players = serverWorld.getEntitiesWithinAABB(ServerPlayerEntity.class, new AxisAlignedBB( + final AxisAlignedBB area = new AxisAlignedBB( x - range, y - range, z - range, - x + range, y + range, z + range)); - for(ServerPlayerEntity player : players){ - player.connection.sendPacket(new SPlaySoundPacket(sound.getRegistryName(), soundType, + x + range, y + range, z + range); + for(PlayerEntity candidate : serverWorld.getPlayers()){ + if(!(candidate instanceof ServerPlayerEntity) || !EntityPredicates.NOT_SPECTATING.test(candidate) + || !area.intersects(candidate.getBoundingBox())) continue; + ServerPlayerEntity player = (ServerPlayerEntity) candidate; + if(player.connection != null) player.connection.sendPacket(new SPlaySoundPacket(sound.getRegistryName(), soundType, new Vec3d(x, y, z), volume, pitch)); } } diff --git a/src/test/java/zone/moddev/mc/steamadvantage/gui/GUIHelperTest.java b/src/test/java/zone/moddev/mc/steamadvantage/gui/GUIHelperTest.java new file mode 100644 index 0000000..6e4be43 --- /dev/null +++ b/src/test/java/zone/moddev/mc/steamadvantage/gui/GUIHelperTest.java @@ -0,0 +1,29 @@ +package zone.moddev.mc.steamadvantage.gui; + +import java.lang.reflect.Method; +import org.junit.Test; +import static org.junit.Assert.*; + +public class GUIHelperTest { + @Test public void readingsKeepTheirScaleButStayInsideTheGauge() throws Exception { + assertEquals(0F, displayLevel(-1F), 0F); + assertEquals(0F, displayLevel(0F), 0F); + assertEquals(0.5F, displayLevel(0.5F), 0F); + assertEquals(1F, displayLevel(1F), 0F); + assertEquals(1F, displayLevel(15F), 0F); + } + + @Test public void nonfiniteReadingsAreSafeToDraw() throws Exception { + for (float value : new float[]{Float.NaN, Float.POSITIVE_INFINITY, Float.NEGATIVE_INFINITY}) { + assertEquals(0F, displayLevel(value), 0F); + } + } + + private static float displayLevel(float value) throws Exception { + Method method; + try { method = GUIHelper.class.getDeclaredMethod("displayLevel", float.class); } + catch (NoSuchMethodException missing) { throw new AssertionError("Steam gauge readings need one finite, bounded display scale", missing); } + method.setAccessible(true); + return (Float) method.invoke(null, value); + } +} diff --git a/src/test/java/zone/moddev/mc/steamadvantage/items/MusketLoadedCartridgeTest.java b/src/test/java/zone/moddev/mc/steamadvantage/items/MusketLoadedCartridgeTest.java new file mode 100644 index 0000000..c2e1547 --- /dev/null +++ b/src/test/java/zone/moddev/mc/steamadvantage/items/MusketLoadedCartridgeTest.java @@ -0,0 +1,130 @@ +package zone.moddev.mc.steamadvantage.items; + +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import net.minecraft.item.ItemStack; +import net.minecraft.item.Items; +import net.minecraft.util.Hand; +import net.minecraft.util.ActionResultType; +import org.junit.BeforeClass; +import org.junit.Test; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.tree.AbstractInsnNode; +import org.objectweb.asm.tree.ClassNode; +import org.objectweb.asm.tree.FieldInsnNode; +import org.objectweb.asm.tree.JumpInsnNode; +import org.objectweb.asm.tree.MethodInsnNode; +import org.objectweb.asm.tree.MethodNode; +import org.objectweb.asm.tree.VarInsnNode; +import static org.junit.Assert.*; + +/** Executes the compiled use-method guard, without inventing a world or a player. */ +public class MusketLoadedCartridgeTest { + @BeforeClass public static void bootstrap() { + net.minecraft.util.registry.Bootstrap.register(); + } + + @Test public void lastLoadedCartridgeCanBeAimedWithoutSpareAmmunition() throws Exception { + ItemStack stack = new ItemStack(Items.STICK); + MusketItem.finishLoad(stack); + assertEquals("USE", guardResult(stack, false, Hand.MAIN_HAND)); + assertTrue("Aiming must not unload the cartridge", MusketItem.isLoaded(stack)); + } + + @Test public void unfinishedReloadCanBeCompletedWithoutAnotherCartridge() throws Exception { + ItemStack stack = new ItemStack(Items.STICK); + MusketItem.startLoad(stack); + assertEquals("USE", guardResult(stack, false, Hand.MAIN_HAND)); + assertTrue(MusketItem.isAlmostLoaded(stack)); + } + + @Test public void unloadedMusketStillRejectsMissingAmmunition() throws Exception { + ItemStack stack = new ItemStack(Items.STICK); + assertEquals("FAIL", guardResult(stack, false, Hand.MAIN_HAND)); + assertTrue(MusketItem.isNotLoaded(stack)); + } + + @Test public void offHandDoesNotStartAimingOrConsumeTheLoadedCartridge() throws Exception { + ItemStack stack = new ItemStack(Items.STICK); + MusketItem.finishLoad(stack); + assertEquals("PASS", guardResult(stack, false, Hand.OFF_HAND)); + assertTrue(MusketItem.isLoaded(stack)); + } + + private static String guardResult(ItemStack stack, boolean hasAmmo, Hand hand) throws Exception { + ClassNode node = new ClassNode(); + try (InputStream input = MusketLoadedCartridgeTest.class.getResourceAsStream( + "/zone/moddev/mc/steamadvantage/items/MusketItem.class")) { + assertNotNull(input); + new ClassReader(input).accept(node, 0); + } + MethodNode use = node.methods.stream().filter(method -> method.name.equals("onItemRightClick")) + .findFirst().orElseThrow(() -> new AssertionError("Missing native use method")); + Object[] locals = {new Object(), new Object(), new Object(), hand, null}; + List operands = new ArrayList<>(); + int steps = 0; + for (AbstractInsnNode instruction = use.instructions.getFirst(); instruction != null;) { + assertTrue("The finite use guard must terminate", steps++ < 150); + AbstractInsnNode next = instruction.getNext(); + int opcode = instruction.getOpcode(); + if (opcode < 0) { + instruction = next; + continue; + } + if (instruction instanceof VarInsnNode) { + int variable = ((VarInsnNode) instruction).var; + if (opcode == Opcodes.ALOAD) operands.add(locals[variable]); + else if (opcode == Opcodes.ASTORE) locals[variable] = pop(operands); + else fail("Unsupported guard variable opcode " + opcode); + } else if (instruction instanceof FieldInsnNode) { + FieldInsnNode field = (FieldInsnNode) instruction; + assertEquals(Opcodes.GETSTATIC, opcode); + if (field.owner.equals("net/minecraft/util/Hand")) { + assertEquals("MAIN_HAND", field.name); + operands.add(Hand.MAIN_HAND); + } else if (field.owner.equals("net/minecraft/util/ActionResultType")) { + operands.add(ActionResultType.valueOf(field.name)); + } else throw new AssertionError("Unexpected constant before the aiming decision: " + field.owner + "." + field.name); + } else if (instruction instanceof JumpInsnNode) { + boolean jump; + switch (opcode) { + case Opcodes.GOTO: jump = true; break; + case Opcodes.IFEQ: jump = !((Boolean) pop(operands)); break; + case Opcodes.IFNE: jump = (Boolean) pop(operands); break; + case Opcodes.IF_ACMPEQ: jump = pop(operands) == pop(operands); break; + case Opcodes.IF_ACMPNE: jump = pop(operands) != pop(operands); break; + default: throw new AssertionError("Unsupported guard jump " + opcode); + } + if (jump) next = ((JumpInsnNode) instruction).label; + } else if (instruction instanceof MethodInsnNode) { + MethodInsnNode call = (MethodInsnNode) instruction; + if (call.name.equals("getHeldItem")) { + assertSame(hand, pop(operands)); + assertSame(locals[2], pop(operands)); + operands.add(stack); + } else if (call.name.equals("isNotLoaded")) { + assertEquals("zone/moddev/mc/steamadvantage/items/MusketItem", call.owner); + operands.add(MusketItem.isNotLoaded((ItemStack) pop(operands))); + } else if (call.name.equals("hasAmmo")) { + assertSame(stack, pop(operands)); + assertSame(locals[2], pop(operands)); + operands.add(hasAmmo); + } else if (call.name.equals("newResult")) { + assertEquals("net/minecraft/util/ActionResult", call.owner); + assertSame(stack, pop(operands)); + return ((ActionResultType) pop(operands)).name(); + } else if (call.name.equals("setActiveHand")) return "USE"; + else throw new AssertionError("Unexpected call before the aiming decision: " + call.owner + "." + call.name); + } else throw new AssertionError("Unsupported guard instruction " + opcode); + instruction = next; + } + throw new AssertionError("Use method never reached an observable aiming decision"); + } + + private static Object pop(List stack) { + assertFalse("Compiled guard operand stack is not empty", stack.isEmpty()); + return stack.remove(stack.size() - 1); + } +} diff --git a/src/test/java/zone/moddev/mc/steamadvantage/items/MusketTargetingContractTest.java b/src/test/java/zone/moddev/mc/steamadvantage/items/MusketTargetingContractTest.java new file mode 100644 index 0000000..82e7eae --- /dev/null +++ b/src/test/java/zone/moddev/mc/steamadvantage/items/MusketTargetingContractTest.java @@ -0,0 +1,92 @@ +package zone.moddev.mc.steamadvantage.items; + +import java.io.InputStream; +import java.util.Optional; +import net.minecraft.util.Direction; +import net.minecraft.util.math.AxisAlignedBB; +import net.minecraft.util.math.Vec3d; +import org.junit.BeforeClass; +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.MethodInsnNode; +import org.objectweb.asm.tree.MethodNode; +import static org.junit.Assert.*; + +public class MusketTargetingContractTest { + @BeforeClass public static void bootstrap() { + net.minecraft.util.registry.Bootstrap.register(); + } + + @Test public void nearerTerrainRejectsTargetsBehindItOnEveryAxis() { + Vec3d origin = Vec3d.ZERO; + for (Direction direction : Direction.values()) { + Vec3d ray = new Vec3d(direction.getXOffset(), direction.getYOffset(), direction.getZOffset()); + assertFalse(direction.toString(), hit(origin, ray.scale(3), around(ray.scale(5))).isPresent()); + Optional hit = hit(origin, ray.scale(8), around(ray.scale(5))); + assertTrue(direction.toString(), hit.isPresent()); + assertEquals(4.5 * 4.5, origin.squareDistanceTo(hit.get()), 0.000001); + } + } + + @Test public void terrainWinsAnExactTieWithTheEntitySurface() { + assertFalse(hit(Vec3d.ZERO, new Vec3d(0, 0, 4.5), + around(new Vec3d(0, 0, 5))).isPresent()); + } + + @Test public void finiteSegmentRejectsOffRayBehindAndBeyondRangeTargets() { + Vec3d end = new Vec3d(0, 0, 64); + assertFalse(hit(Vec3d.ZERO, end, around(new Vec3d(2, 0, 5))).isPresent()); + assertFalse(hit(Vec3d.ZERO, end, around(new Vec3d(0, 0, -5))).isPresent()); + assertFalse(hit(Vec3d.ZERO, end, around(new Vec3d(0, 0, 65))).isPresent()); + } + + @Test public void overlappingEntityStartsAtZeroDistance() { + Optional hit = hit(Vec3d.ZERO, new Vec3d(0, 0, 8), around(Vec3d.ZERO)); + assertTrue(hit.isPresent()); + assertEquals(Vec3d.ZERO, hit.get()); + } + + @Test public void targetingUsesLoadedServerEntitiesAndTerrainBeforeEntitySelection() throws Exception { + ClassNode node = new ClassNode(); + try (InputStream input = getClass().getResourceAsStream("/zone/moddev/mc/steamadvantage/items/MusketItem.class")) { + assertNotNull(input); + new ClassReader(input).accept(node, 0); + } + MethodNode trace = node.methods.stream().filter(method -> method.name.equals("rayTraceBlocksAndEntities")) + .findFirst().orElseThrow(() -> new AssertionError("Missing targeting method")); + boolean terrain = false, loadedEntities = false, boundedSelection = false, spectatorFilter = false; + for (AbstractInsnNode instruction : trace.instructions.toArray()) { + if (!(instruction instanceof MethodInsnNode)) continue; + MethodInsnNode call = (MethodInsnNode) instruction; + if (call.name.equals("rayTraceBlocks")) terrain = true; + if (call.owner.equals("net/minecraft/world/server/ServerWorld") && call.name.equals("getEntities")) { + assertTrue("Terrain must be traced before selecting entities", terrain); + loadedEntities = true; + } + if (call.name.equals("rayHitBeforeTerrain")) boundedSelection = true; + if (call.name.equals("isSpectator")) spectatorFilter = true; + } + assertTrue("Server targeting must use the loaded-entity collection", loadedEntities); + assertTrue("Entity selection must be bounded by the actual terrain hit", boundedSelection); + assertTrue("Loaded-entity iteration must preserve the previous spectator exclusion", spectatorFilter); + } + + @SuppressWarnings("unchecked") + private static Optional hit(Vec3d origin, Vec3d end, AxisAlignedBB target) { + try { + java.lang.reflect.Method method = MusketItem.class.getDeclaredMethod( + "rayHitBeforeTerrain", Vec3d.class, Vec3d.class, AxisAlignedBB.class); + method.setAccessible(true); + return (Optional) method.invoke(null, origin, end, target); + } catch (ReflectiveOperationException failure) { + throw new AssertionError("Musket targeting must be bounded by terrain and range", failure); + } + } + + private static AxisAlignedBB around(Vec3d center) { + return new AxisAlignedBB(center.x - 0.5, center.y - 0.5, center.z - 0.5, + center.x + 0.5, center.y + 0.5, center.z + 0.5); + } +} diff --git a/src/test/java/zone/moddev/mc/steamadvantage/machines/CoalBoilerFillPersistenceTest.java b/src/test/java/zone/moddev/mc/steamadvantage/machines/CoalBoilerFillPersistenceTest.java new file mode 100644 index 0000000..2c62fed --- /dev/null +++ b/src/test/java/zone/moddev/mc/steamadvantage/machines/CoalBoilerFillPersistenceTest.java @@ -0,0 +1,219 @@ +package zone.moddev.mc.steamadvantage.machines; + +import static org.junit.Assert.*; + +import cyano.poweradvantage.api.ConduitType; +import zone.moddev.mc.poweradvantage.init.Fluids; +import net.minecraft.nbt.CompoundNBT; +import net.minecraft.world.World; +import cyano.poweradvantage.api.fluid.FluidStack; +import org.junit.BeforeClass; +import org.junit.Test; + +/** Save-notification unit witness; actual disk persistence is checked by the native restart rig. */ +public class CoalBoilerFillPersistenceTest { + @BeforeClass public static void bootstrap() { + net.minecraft.util.registry.Bootstrap.register(); + if (zone.moddev.mc.steamadvantage.init.Entities.STEAM_BOILER_COAL == null) + zone.moddev.mc.steamadvantage.init.Entities.STEAM_BOILER_COAL = net.minecraft.tileentity.TileEntityType.Builder.create(CoalBoilerTileEntity::new, net.minecraft.block.Blocks.STONE).build(null); + } + + @Test public void acceptingTheLastFiveMillibucketsRequestsSavingBeforeAnyTick() { + SaveWitnessBoiler boiler = seeded(3995); + FluidStack offered = new FluidStack(net.minecraft.fluid.Fluids.WATER, 5); + + assertEquals(5, boiler.fill(null, offered, true)); + assertEquals(4000, boiler.getTank().getFluidAmount()); + assertEquals(5, offered.amount); + assertSaved(boiler, 4000); + } + + @Test public void partialAcceptanceSavesOnlyTheQuantityActuallyAccepted() { + SaveWitnessBoiler boiler = seeded(3995); + FluidStack offered = new FluidStack(net.minecraft.fluid.Fluids.WATER, 100); + + assertEquals(5, boiler.fill(null, offered, true)); + assertEquals(4000, boiler.getTank().getFluidAmount()); + assertEquals(100, offered.amount); + assertSaved(boiler, 4000); + } + + @Test public void waterAcceptedThroughTheNetworkAlsoRequestsSavingImmediately() { + SaveWitnessBoiler boiler = seeded(3995); + ConduitType water = Fluids.fluidToConduitType(net.minecraft.fluid.Fluids.WATER); + + assertEquals(5, boiler.addEnergy(5, water), 0); + assertEquals(4000, boiler.getTank().getFluidAmount()); + assertSaved(boiler, 4000); + } + + @Test public void simulationNeitherChangesTheTankNorRequestsSaving() { + SaveWitnessBoiler boiler = seeded(3995); + FluidStack offered = new FluidStack(net.minecraft.fluid.Fluids.WATER, 100); + + assertEquals(5, boiler.fill(null, offered, false)); + assertEquals(3995, boiler.getTank().getFluidAmount()); + assertEquals(100, offered.amount); + assertUnchangedSave(boiler, 3995); + } + + @Test public void aFullTankRejectsInputWithoutRequestingSaving() { + SaveWitnessBoiler boiler = seeded(4000); + + assertEquals(0, boiler.fill(null, new FluidStack(net.minecraft.fluid.Fluids.WATER, 5), true)); + assertEquals(4000, boiler.getTank().getFluidAmount()); + assertUnchangedSave(boiler, 4000); + } + + @Test public void mismatchedFluidIsNotCreditedOrSaved() { + SaveWitnessBoiler boiler = seeded(3995); + FluidStack offered = new FluidStack(net.minecraft.fluid.Fluids.LAVA, 5); + + assertEquals(0, boiler.fill(null, offered, true)); + assertSame(net.minecraft.fluid.Fluids.WATER, boiler.getTank().getFluid().getFluid()); + assertEquals(3995, boiler.getTank().getFluidAmount()); + assertEquals(5, offered.amount); + assertUnchangedSave(boiler, 3995); + } + + @Test public void aZeroQuantityDoesNotRequestSaving() { + SaveWitnessBoiler boiler = seeded(3995); + + assertEquals(0, boiler.fill(null, new FluidStack(net.minecraft.fluid.Fluids.WATER, 0), true)); + assertEquals(3995, boiler.getTank().getFluidAmount()); + assertUnchangedSave(boiler, 3995); + } + + @Test public void theImmediateSaveWitnessRetainsFluidIdentityAndTypedNbt() { + SaveWitnessBoiler boiler = new SaveWitnessBoiler(); + FluidStack offered = new FluidStack(net.minecraft.fluid.Fluids.WATER, 5); + offered.tag = new CompoundNBT(); + offered.tag.putString("AdvWorksWitness", "external-fill-before-next-tick"); + offered.tag.putShort("TypedWitness", (short) 7); + CompoundNBT original = offered.tag.copy(); + boiler.rememberSavedTank(); + + assertEquals(5, boiler.fill(null, offered, true)); + assertSaved(boiler, 5); + FluidStack saved = FluidStack.loadFluidStackFromNBT(boiler.savedTank); + assertNotNull(saved); + assertSame(net.minecraft.fluid.Fluids.WATER, saved.getFluid()); + assertEquals(original, saved.tag); + assertEquals(original, offered.tag); + assertEquals(5, offered.amount); + } + + @Test public void emptyRequestedDrainReturnsNullWithoutDereferencingAMissingFluid() { + SaveWitnessBoiler boiler = new SaveWitnessBoiler(); + boiler.rememberSavedTank(); + assertNull(boiler.drain(null, new FluidStack(net.minecraft.fluid.Fluids.WATER, 5), true)); + assertNull(boiler.drain(null, new FluidStack(net.minecraft.fluid.Fluids.WATER, 5), false)); + assertEquals(0, boiler.dirtyCalls); + } + + @Test public void nullAndZeroRequestsNeitherMutateNorRequestSaving() { + SaveWitnessBoiler boiler = seeded(10); + assertEquals(0, boiler.fill(null, null, true)); + assertNull(boiler.drain(null, (FluidStack)null, true)); + assertNull(boiler.drain(null, new FluidStack(net.minecraft.fluid.Fluids.WATER, 0), true)); + assertNull(boiler.drain(null, 0, true)); + assertNull(boiler.drain(null, -1, true)); + assertEquals(10, boiler.getTank().getFluidAmount()); + assertUnchangedSave(boiler, 10); + } + + @Test public void mismatchedRequestedDrainReturnsNullRatherThanAZeroStack() { + SaveWitnessBoiler boiler = seeded(10); + assertNull(boiler.drain(null, new FluidStack(net.minecraft.fluid.Fluids.LAVA, 5), true)); + assertNull(boiler.drain(null, new FluidStack(net.minecraft.fluid.Fluids.LAVA, 5), false)); + assertEquals(10, boiler.getTank().getFluidAmount()); + assertUnchangedSave(boiler, 10); + } + + @Test public void nativeTankSimulationDoesNotRequestSaving() { + SaveWitnessBoiler boiler = seeded(10); + FluidStack preview = boiler.getTank().drain(5, false); + assertNotNull(preview); + assertEquals(5, preview.amount); + assertEquals(10, boiler.getTank().getFluidAmount()); + assertUnchangedSave(boiler, 10); + } + + @Test public void nativeRealTankDrainRequestsSavingEvenOutsideTheBoilerOverloads() { + SaveWitnessBoiler boiler = seeded(10); + FluidStack drained = boiler.getTank().drain(5, true); + assertNotNull(drained); + assertEquals(5, drained.amount); + assertEquals(5, boiler.getTank().getFluidAmount()); + assertSaved(boiler, 5); + } + + @Test public void aRealDrainToEmptySavesTheNativeEmptyTankRepresentation() { + SaveWitnessBoiler boiler = seeded(5); + assertEquals(5, boiler.drain(null, 5, true).amount); + assertEquals(0, boiler.getTank().getFluidAmount()); + assertSaved(boiler, 0); + assertNull(FluidStack.loadFluidStackFromNBT(boiler.savedTank)); + assertNull(boiler.drain(null, new FluidStack(net.minecraft.fluid.Fluids.WATER, 5), true)); + } + + @Test public void clientSideTankChangesDoNotRequestServerSaving() { + SaveWitnessBoiler boiler = seeded(3995); + boiler.saveWorld = world(true); + assertEquals(5, boiler.fill(null, new FluidStack(net.minecraft.fluid.Fluids.WATER, 5), true)); + assertEquals(4000, boiler.getTank().getFluidAmount()); + assertUnchangedSave(boiler, 3995); + } + + private static SaveWitnessBoiler seeded(int amount) { + SaveWitnessBoiler boiler = new SaveWitnessBoiler(); + boiler.getTank().setFluid(new FluidStack(net.minecraft.fluid.Fluids.WATER, amount)); + boiler.rememberSavedTank(); + return boiler; + } + + private static void assertSaved(SaveWitnessBoiler boiler, int amount) { + assertTrue("An accepted external fill must request saving without relying on a later tile tick", + boiler.dirtyCalls > 0); + assertEquals(amount, boiler.savedTank.getInt("Amount")); + } + + private static void assertUnchangedSave(SaveWitnessBoiler boiler, int amount) { + assertEquals(0, boiler.dirtyCalls); + assertEquals(amount, boiler.savedTank.getInt("Amount")); + } + + private static final class SaveWitnessBoiler extends CoalBoilerTileEntity { + int dirtyCalls; + CompoundNBT savedTank; + SaveWorld saveWorld = world(false); + @Override public World getWorld() { return saveWorld; } + void rememberSavedTank() { + dirtyCalls = 0; + savedTank = getTank().writeToNBT(new CompoundNBT()).copy(); + } + @Override public void markDirty() { + dirtyCalls++; + // Record only the notification payload; do not impersonate a native server save. + savedTank = getTank().writeToNBT(new CompoundNBT()).copy(); + } + } + + private static SaveWorld world(boolean remote) { + try { + Class allocator = Class.forName("sun.misc.Unsafe"); + java.lang.reflect.Field field = allocator.getDeclaredField("theUnsafe"); + field.setAccessible(true); + SaveWorld world = (SaveWorld) allocator.getMethod("allocateInstance", Class.class).invoke(field.get(null), SaveWorld.class); + java.lang.reflect.Field side = World.class.getDeclaredField("isRemote"); + side.setAccessible(true); + side.setBoolean(world, remote); + return world; + } catch (ReflectiveOperationException failure) { + throw new AssertionError("Cannot prepare the save-notification world", failure); + } + } + private static final class SaveWorld extends net.minecraft.world.server.ServerWorld { + private SaveWorld() { super(null, null, null, null, null, null, null); } + } +} diff --git a/src/test/java/zone/moddev/mc/steamadvantage/machines/RockCrusherComparatorTest.java b/src/test/java/zone/moddev/mc/steamadvantage/machines/RockCrusherComparatorTest.java new file mode 100644 index 0000000..ae42364 --- /dev/null +++ b/src/test/java/zone/moddev/mc/steamadvantage/machines/RockCrusherComparatorTest.java @@ -0,0 +1,25 @@ +package zone.moddev.mc.steamadvantage.machines; + +import static org.junit.Assert.assertEquals; +import net.minecraft.item.ItemStack; +import net.minecraft.item.Items; +import org.junit.BeforeClass; +import org.junit.Test; + +public class RockCrusherComparatorTest { + @BeforeClass public static void bootstrap() { + net.minecraft.util.registry.Bootstrap.register(); + if (zone.moddev.mc.steamadvantage.init.Entities.STEAM_CRUSHER == null) + zone.moddev.mc.steamadvantage.init.Entities.STEAM_CRUSHER = net.minecraft.tileentity.TileEntityType.Builder.create(RockCrusherTileEntity::new, net.minecraft.block.Blocks.STONE).build(null); + } + @Test public void emptyInputHasNoComparatorSignal() { assertSignal(0, 0); } + @Test public void oneItemGivesSignalOne() { assertSignal(1, 1); } + @Test public void halfAStackGivesSignalEight() { assertSignal(32, 8); } + @Test public void aFullStackGivesSignalFifteen() { assertSignal(64, 15); } + + private static void assertSignal(int count, int expected) { + RockCrusherTileEntity crusher = new RockCrusherTileEntity(); + crusher.setInventorySlotContents(0, count == 0 ? ItemStack.EMPTY : new ItemStack(Items.COAL, count)); + assertEquals("Actual crusher comparator at " + count + " input items", expected, crusher.getComparatorOutput()); + } +} diff --git a/src/test/java/zone/moddev/mc/steamadvantage/machines/RockCrusherReloadRedstoneTest.java b/src/test/java/zone/moddev/mc/steamadvantage/machines/RockCrusherReloadRedstoneTest.java new file mode 100644 index 0000000..462a87a --- /dev/null +++ b/src/test/java/zone/moddev/mc/steamadvantage/machines/RockCrusherReloadRedstoneTest.java @@ -0,0 +1,92 @@ +package zone.moddev.mc.steamadvantage.machines; + +import static org.junit.Assert.*; +import java.lang.reflect.Field; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.World; +import net.minecraft.world.server.ServerWorld; +import org.junit.BeforeClass; +import org.junit.Test; + +public class RockCrusherReloadRedstoneTest { + @BeforeClass public static void bootstrap() { + net.minecraft.util.registry.Bootstrap.register(); + if (zone.moddev.mc.steamadvantage.init.Entities.STEAM_CRUSHER == null) + zone.moddev.mc.steamadvantage.init.Entities.STEAM_CRUSHER = net.minecraft.tileentity.TileEntityType.Builder.create(RockCrusherTileEntity::new, net.minecraft.block.Blocks.STONE).build(null); + if (zone.moddev.mc.steamadvantage.init.Entities.STEAM_FURNACE == null) + zone.moddev.mc.steamadvantage.init.Entities.STEAM_FURNACE = net.minecraft.tileentity.TileEntityType.Builder.create(BlastFurnaceTileEntity::new, net.minecraft.block.Blocks.STONE).build(null); + } + + @Test public void resumedWorkCannotBeDecrementedBeforeTheFirstLiveControlSample() throws Exception { + ProbeCrusher crusher = new ProbeCrusher(); + Field progress = RockCrusherTileEntity.class.getDeclaredField("progress"); + progress.setAccessible(true); + progress.setInt(crusher, 106); + crusher.testWorld = world(); + crusher.testWorld.stopAtFirstSample = true; + try { + crusher.tickUpdate(true); + fail("Live control must be sampled before touching loaded progress"); + } catch (ControlSample expected) { + assertEquals(106, progress.getInt(crusher)); + assertEquals(1, crusher.testWorld.samples); + } + } + + @Test public void scheduledPowerUpdateStillRefreshesTheInitializedCache() throws Exception { + ProbeCrusher crusher = new ProbeCrusher(); + crusher.testWorld = world(); + crusher.tickUpdate(true); + Field redstone = RockCrusherTileEntity.class.getDeclaredField("redstone"); + redstone.setAccessible(true); + assertFalse(redstone.getBoolean(crusher)); + crusher.testWorld.powered = true; + crusher.tickUpdate(true); + assertFalse(redstone.getBoolean(crusher)); + crusher.powerUpdate(); + assertTrue(redstone.getBoolean(crusher)); + assertEquals(2, crusher.testWorld.samples); + } + + @Test public void furnaceSamplesBeforeChangingItsRestoredHeatOrWork() throws Exception { + ProbeFurnace furnace = new ProbeFurnace(); + Field temperature = BlastFurnaceTileEntity.class.getDeclaredField("temperature"); + temperature.setAccessible(true); + temperature.setFloat(furnace, 1000); + furnace.testWorld = world(); + furnace.testWorld.stopAtFirstSample = true; + try { + furnace.tickUpdate(true); + fail("Live control must precede changes to loaded furnace heat or work"); + } catch (ControlSample expected) { + assertEquals(1000, temperature.getFloat(furnace), 0); + assertEquals(1, furnace.testWorld.samples); + } + } + + private static ControlWorld world() throws Exception { + Class allocator = Class.forName("sun.misc.Unsafe"); + Field field = allocator.getDeclaredField("theUnsafe"); + field.setAccessible(true); + return (ControlWorld) allocator.getMethod("allocateInstance", Class.class).invoke(field.get(null), ControlWorld.class); + } + private static final class ProbeCrusher extends RockCrusherTileEntity { + ControlWorld testWorld; + @Override public World getWorld() { return testWorld; } + } + private static final class ProbeFurnace extends BlastFurnaceTileEntity { + ControlWorld testWorld; + @Override public World getWorld() { return testWorld; } + } + private static final class ControlSample extends RuntimeException {} + private static final class ControlWorld extends ServerWorld { + boolean powered, stopAtFirstSample; + int samples; + private ControlWorld() { super(null, null, null, null, null, null, null); } + @Override public boolean isBlockPowered(BlockPos pos) { + samples++; + if (stopAtFirstSample) throw new ControlSample(); + return powered; + } + } +} diff --git a/src/test/java/zone/moddev/mc/steamadvantage/machines/SteamOperationalPumpSourceTest.java b/src/test/java/zone/moddev/mc/steamadvantage/machines/SteamOperationalPumpSourceTest.java new file mode 100644 index 0000000..d6db645 --- /dev/null +++ b/src/test/java/zone/moddev/mc/steamadvantage/machines/SteamOperationalPumpSourceTest.java @@ -0,0 +1,190 @@ +package zone.moddev.mc.steamadvantage.machines; + +import java.io.InputStream; +import java.lang.reflect.Proxy; +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.material.Material; +import net.minecraft.fluid.Fluid; +import net.minecraft.fluid.IFluidState; +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 org.objectweb.asm.ClassReader; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.tree.AbstractInsnNode; +import org.objectweb.asm.tree.ClassNode; +import org.objectweb.asm.tree.FieldInsnNode; +import org.objectweb.asm.tree.MethodInsnNode; +import org.objectweb.asm.tree.MethodNode; +import org.objectweb.asm.tree.TypeInsnNode; +import static org.junit.Assert.*; + +public class SteamOperationalPumpSourceTest { + private static final BlockPos SOURCE = new BlockPos(8, 80, 8); + + @BeforeClass public static void bootstrap() { + net.minecraft.util.registry.Bootstrap.register(); + } + + @Test public void nativePickupRetainsTheSlabAndOnlyClearsItsWater() { + BlockState wet = Blocks.OAK_SLAB.getDefaultState().with(BlockStateProperties.WATERLOGGED, true); + PickupWorld world = new PickupWorld(wet); + assertSame(Fluids.WATER, pickup(world.world, SOURCE, wet)); + assertEquals(1, world.writes); + assertEquals(wet.with(BlockStateProperties.WATERLOGGED, false), world.state); + assertSame(Fluids.EMPTY, pickup(world.world, SOURCE, world.state)); + assertEquals("A dry host cannot produce another bucket", 1, world.writes); + } + + @Test public void nativePickupConsumesOrdinaryWaterAndLavaSources() { + for (Block block : new Block[]{Blocks.WATER, Blocks.LAVA}) { + BlockState source = block.getDefaultState(); + PickupWorld world = new PickupWorld(source); + assertSame(source.getFluidState().getFluid(), pickup(world.world, SOURCE, source)); + assertEquals(1, world.writes); + assertEquals(Blocks.AIR.getDefaultState(), world.state); + } + } + + @Test public void unsupportedModSourceIsNeverReplacedOrCredited() { + Block host = new Block(Block.Properties.create(Material.ROCK)) { + @Override public IFluidState getFluidState(BlockState state) { return Fluids.WATER.getDefaultState(); } + }; + BlockState source = host.getDefaultState(); + assertTrue(source.getFluidState().isSource()); + PickupWorld world = new PickupWorld(source); + assertSame(Fluids.EMPTY, pickup(world.world, SOURCE, source)); + assertSame(source, world.state); + assertEquals(0, world.writes); + } + + @Test public void flowingAndRefusedSourcesCannotProduceABucket() { + BlockState flowing = Blocks.WATER.getDefaultState().with(FlowingFluidBlock.LEVEL, 1); + PickupWorld world = new PickupWorld(flowing); + assertSame(Fluids.EMPTY, pickup(world.world, SOURCE, flowing)); + assertEquals(0, world.writes); + RefusingSource host = new RefusingSource(); + BlockState refused = host.getDefaultState(); + PickupWorld refusedWorld = new PickupWorld(refused); + assertSame(Fluids.EMPTY, pickup(refusedWorld.world, SOURCE, refused)); + assertSame(refused, refusedWorld.state); + assertEquals(0, refusedWorld.writes); + } + + @Test public void pumpUsesNativePickupBeforeCreditWithoutDestructiveFallback() throws Exception { + ClassNode node = new ClassNode(); + try (InputStream input = getClass().getResourceAsStream( + "/zone/moddev/mc/steamadvantage/machines/SteamPumpTileEntity.class")) { + assertNotNull(input); + new ClassReader(input).accept(node, 0); + } + MethodNode tick = method(node, "tickUpdate"), pickup = method(node, "pickupSource"); + int extraction = -1, fill = -1, index = 0; + boolean nativePickup = false, nativeGuard = false; + for (MethodNode candidate : new MethodNode[]{tick, pickup}) { + for (AbstractInsnNode instruction : candidate.instructions.toArray()) { + if (instruction instanceof FieldInsnNode) { + FieldInsnNode field = (FieldInsnNode) instruction; + assertFalse("Caller must not erase waterlogged hosts with AIR", + field.getOpcode() == Opcodes.GETSTATIC && field.owner.equals("net/minecraft/block/Blocks") + && field.name.equals("AIR")); + } else if (instruction instanceof TypeInsnNode) { + TypeInsnNode type = (TypeInsnNode) instruction; + if (type.getOpcode() == Opcodes.INSTANCEOF && type.desc.equals("net/minecraft/block/IBucketPickupHandler")) nativeGuard = true; + } else if (instruction instanceof MethodInsnNode) { + MethodInsnNode call = (MethodInsnNode) instruction; + assertFalse("No destructive fallback when native pickup is unavailable", + call.name.equals("removeBlock") || call.name.equals("destroyBlock")); + if (candidate == pickup) { + assertFalse("Pickup policy delegates block mutation to the native handler", + call.name.equals("setBlockState") || call.name.equals("setBlockState")); + if (call.owner.equals("net/minecraft/block/IBucketPickupHandler") && call.name.equals("pickupFluid")) nativePickup = true; + } else { + if (call.name.equals("pickupSource")) extraction = index; + if (call.owner.equals("cyano/poweradvantage/api/fluid/FluidTank") && call.name.equals("fill")) fill = index; + } + } + index++; + } + } + assertTrue("Unsupported blocks are rejected before native pickup", nativeGuard && nativePickup); + assertTrue("Successful native pickup precedes the final bucket credit", extraction >= 0 && fill > extraction); + } + + @Test public void rejectedNeighborFillDoesNotDrainOneMillibucket() throws Exception { + ClassNode node = new ClassNode(); + try (InputStream input = getClass().getResourceAsStream( + "/zone/moddev/mc/steamadvantage/machines/SteamPumpTileEntity.class")) { + assertNotNull(input); + new ClassReader(input).accept(node, 0); + } + boolean afterFill = false, guarded = false, drained = false; + for (AbstractInsnNode instruction : method(node, "powerUpdate").instructions.toArray()) { + if (instruction instanceof MethodInsnNode) { + MethodInsnNode call = (MethodInsnNode) instruction; + if (call.owner.equals("cyano/poweradvantage/api/fluid/IAdvantageFluidHandler") + && call.name.equals("fill")) afterFill = true; + if (!afterFill) continue; + assertFalse("A zero acceptance must not be rounded up into a one-millibucket loss", + call.owner.equals("java/lang/Math") && call.name.equals("max") + && call.desc.equals("(II)I")); + if (call.owner.equals("cyano/poweradvantage/api/fluid/FluidTank") && call.name.equals("drain")) { + assertTrue("Reject nonpositive acceptance before debiting the tank", guarded); + drained = true; + break; + } + } else if (afterFill && instruction instanceof org.objectweb.asm.tree.JumpInsnNode + && instruction.getOpcode() == Opcodes.IFLE) guarded = true; + } + assertTrue("The direct-neighbor transfer path is covered", afterFill && drained); + } + + private static Fluid pickup(IWorld world, BlockPos pos, BlockState state) { + try { + java.lang.reflect.Method method = SteamPumpTileEntity.class.getDeclaredMethod( + "pickupSource", IWorld.class, BlockPos.class, BlockState.class); + method.setAccessible(true); + return (Fluid) method.invoke(null, world, pos, state); + } catch (ReflectiveOperationException failure) { + throw new AssertionError("Pump must delegate source pickup without destroying the host", failure); + } + } + + private static MethodNode method(ClassNode node, String name) { + return node.methods.stream().filter(method -> method.name.equals(name)).findFirst() + .orElseThrow(() -> new AssertionError("Missing pump method " + name)); + } + + private static final class RefusingSource extends Block implements IBucketPickupHandler { + private RefusingSource() { super(Block.Properties.create(Material.ROCK)); } + @Override public IFluidState getFluidState(BlockState state) { return Fluids.WATER.getDefaultState(); } + @Override public Fluid pickupFluid(IWorld world, BlockPos pos, BlockState state) { return Fluids.EMPTY; } + } + + private static final class PickupWorld { + private BlockState state; + private int writes; + private final IWorld world; + + private PickupWorld(BlockState initial) { + state = initial; + world = (IWorld) Proxy.newProxyInstance(IWorld.class.getClassLoader(), new Class[]{IWorld.class}, + (proxy, method, arguments) -> { + if (method.getName().equals("setBlockState")) { + assertEquals(SOURCE, arguments[0]); + state = (BlockState) arguments[1]; + writes++; + return true; + } + throw new AssertionError("Unexpected native pickup world call: " + method.getName()); + }); + } + } +} diff --git a/src/test/java/zone/moddev/mc/steamadvantage/machines/SteamOperationalStillCapacityTest.java b/src/test/java/zone/moddev/mc/steamadvantage/machines/SteamOperationalStillCapacityTest.java new file mode 100644 index 0000000..9fb503d --- /dev/null +++ b/src/test/java/zone/moddev/mc/steamadvantage/machines/SteamOperationalStillCapacityTest.java @@ -0,0 +1,91 @@ +package zone.moddev.mc.steamadvantage.machines; + +import cyano.poweradvantage.api.fluid.FluidStack; +import java.lang.reflect.Method; +import net.minecraft.block.Blocks; +import net.minecraft.fluid.Fluids; +import net.minecraft.tileentity.TileEntityType; +import org.junit.BeforeClass; +import org.junit.Test; +import zone.moddev.mc.poweradvantage.registry.still.recipe.DistillationRecipe; +import zone.moddev.mc.poweradvantage.registry.still.recipe.ExplicitFluidReference; +import zone.moddev.mc.steamadvantage.init.Entities; +import static org.junit.Assert.*; + +/** Pure capacity planning; native operating regressions live in SteamOperationalCases. */ +public class SteamOperationalStillCapacityTest { + @BeforeClass public static void bootstrap() { + net.minecraft.util.registry.Bootstrap.register(); + if (Entities.STEAM_STILL == null) { + Entities.STEAM_STILL = TileEntityType.Builder.create(SteamStillTileEntity::new, Blocks.STONE).build(null); + } + } + + @Test public void fullOutputCannotPlanOrDebitAnyInput() throws Exception { + SteamStillTileEntity still = still(1000); + FluidStack input = new FluidStack(Fluids.WATER, 200); + assertEquals(0, plan(still, recipe(2, 1), input)); + assertEquals(200, input.amount); + assertEquals(1000, still.getOutputTank().getFluidAmount()); + } + + @Test public void oneOutputSpacePlansOnlyOneApplicationWithoutMutatingEitherTank() throws Exception { + SteamStillTileEntity still = still(999); + FluidStack input = new FluidStack(Fluids.WATER, 200); + assertEquals(1, plan(still, recipe(2, 1), input)); + assertEquals(200, input.amount); + assertEquals(999, still.getOutputTank().getFluidAmount()); + } + + @Test public void normalSpeedPlansTwoApplicationsWhenBothProductsFit() throws Exception { + SteamStillTileEntity still = still(998); + FluidStack input = new FluidStack(Fluids.WATER, 200); + assertEquals(2, plan(still, recipe(2, 1), input)); + assertEquals(200, input.amount); + assertEquals(998, still.getOutputTank().getFluidAmount()); + } + + @Test public void incompleteInputCannotPlanProduct() throws Exception { + SteamStillTileEntity still = still(0); + FluidStack input = new FluidStack(Fluids.WATER, 1); + assertEquals(0, plan(still, recipe(2, 1), input)); + assertEquals(1, input.amount); + assertEquals(0, still.getOutputTank().getFluidAmount()); + } + + @Test public void mismatchedProductIsNotOverwritten() throws Exception { + SteamStillTileEntity still = new SteamStillTileEntity(); + still.getOutputTank().setFluid(new FluidStack(Fluids.WATER, 10)); + FluidStack input = new FluidStack(Fluids.WATER, 200); + assertEquals(0, plan(still, recipe(2, 1), input)); + assertEquals(200, input.amount); + assertSame(Fluids.WATER, still.getOutputTank().getFluid().getFluid()); + assertEquals(10, still.getOutputTank().getFluidAmount()); + } + + @Test public void multiMillibucketProductMustFitAsAWhole() throws Exception { + FluidStack input = new FluidStack(Fluids.WATER, 200); + assertEquals(0, plan(still(997), recipe(3, 4), input)); + assertEquals(1, plan(still(996), recipe(3, 4), input)); + assertEquals(2, plan(still(992), recipe(3, 4), input)); + assertEquals(200, input.amount); + } + + private static SteamStillTileEntity still(int output) { + SteamStillTileEntity still = new SteamStillTileEntity(); + if (output > 0) still.getOutputTank().setFluid(new FluidStack(Fluids.LAVA, output)); + return still; + } + + private static DistillationRecipe recipe(int in, int out) { + return new DistillationRecipe(new ExplicitFluidReference(Fluids.WATER), in, + new ExplicitFluidReference(Fluids.LAVA), out); + } + + private static int plan(SteamStillTileEntity still, DistillationRecipe recipe, FluidStack input) throws Exception { + Method method = SteamStillTileEntity.class.getDeclaredMethod("distillationApplications", + DistillationRecipe.class, FluidStack.class); + method.setAccessible(true); + return (Integer) method.invoke(still, recipe, input); + } +} diff --git a/src/test/java/zone/moddev/mc/steamadvantage/machines/WaterBoilerFluidBoundaryTest.java b/src/test/java/zone/moddev/mc/steamadvantage/machines/WaterBoilerFluidBoundaryTest.java new file mode 100644 index 0000000..1085312 --- /dev/null +++ b/src/test/java/zone/moddev/mc/steamadvantage/machines/WaterBoilerFluidBoundaryTest.java @@ -0,0 +1,166 @@ +package zone.moddev.mc.steamadvantage.machines; + +import static org.junit.Assert.*; +import java.lang.reflect.Method; +import java.util.*; +import net.minecraft.nbt.CompoundNBT; +import cyano.poweradvantage.api.fluid.FluidStack; +import cyano.poweradvantage.api.fluid.FluidTank; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +/** Calls each actual boiler method; no stand-in fill/drain implementation or world ticks. */ +@RunWith(Parameterized.class) +public class WaterBoilerFluidBoundaryTest { + @Parameterized.Parameters(name = "{0}") + public static Collection boilers() { + return Arrays.asList(new Object[][]{{CoalBoilerTileEntity.class}, + {ElectricBoilerTileEntity.class}, {GeothermalBoilerTileEntity.class}}); + } + private final Class type; + private Object boiler; + private FluidTank tank; + public WaterBoilerFluidBoundaryTest(Class type) { this.type = type; } + @BeforeClass public static void bootstrap() { + net.minecraft.util.registry.Bootstrap.register(); + if (zone.moddev.mc.steamadvantage.init.Entities.STEAM_BOILER_COAL == null) + zone.moddev.mc.steamadvantage.init.Entities.STEAM_BOILER_COAL = net.minecraft.tileentity.TileEntityType.Builder.create(CoalBoilerTileEntity::new, net.minecraft.block.Blocks.STONE).build(null); + if (zone.moddev.mc.steamadvantage.init.Entities.STEAM_BOILER_ELECTRIC == null) + zone.moddev.mc.steamadvantage.init.Entities.STEAM_BOILER_ELECTRIC = net.minecraft.tileentity.TileEntityType.Builder.create(ElectricBoilerTileEntity::new, net.minecraft.block.Blocks.STONE).build(null); + if (zone.moddev.mc.steamadvantage.init.Entities.STEAM_BOILER_GEOTHERMAL == null) + zone.moddev.mc.steamadvantage.init.Entities.STEAM_BOILER_GEOTHERMAL = net.minecraft.tileentity.TileEntityType.Builder.create(GeothermalBoilerTileEntity::new, net.minecraft.block.Blocks.STONE).build(null); + } + @Before public void createActualBoiler() throws Exception { + boiler = type.getConstructor().newInstance(); + tank = (FluidTank) type.getMethod("getTank").invoke(boiler); + } + + @Test public void emptyTankRejectsLavaInSimulationAndExecutionWithoutChangingTheOffer() throws Exception { + for (boolean execute : new boolean[]{false, true}) { + stock(0); + FluidStack lava = new FluidStack(net.minecraft.fluid.Fluids.LAVA, 5); + CompoundNBT original = lava.writeToNBT(new CompoundNBT()).copy(); + assertEquals(Boolean.FALSE, type.getMethod("canFill", net.minecraft.util.Direction.class, net.minecraft.fluid.Fluid.class).invoke(boiler, null, net.minecraft.fluid.Fluids.LAVA)); + assertEquals(0, fill(lava, execute)); + assertEquals(0, tank.getFluidAmount()); + assertEquals(original, lava.writeToNBT(new CompoundNBT())); + } + } + + @Test public void emptyTankStillAcceptsWaterWithoutChangingTheOffer() throws Exception { + FluidStack offered = new FluidStack(net.minecraft.fluid.Fluids.WATER, 5); + CompoundNBT original = offered.writeToNBT(new CompoundNBT()).copy(); + assertEquals(Boolean.TRUE, type.getMethod("canFill", net.minecraft.util.Direction.class, net.minecraft.fluid.Fluid.class).invoke(boiler, null, net.minecraft.fluid.Fluids.WATER)); + assertEquals(5, fill(offered, true)); + assertEquals(5, tank.getFluidAmount()); + assertSame(net.minecraft.fluid.Fluids.WATER, tank.getFluid().getFluid()); + assertEquals(original, offered.writeToNBT(new CompoundNBT())); + } + + @Test public void seededTankRejectsMismatchedFillAndDrain() throws Exception { + stock(100); + FluidStack lava = new FluidStack(net.minecraft.fluid.Fluids.LAVA, 5); + CompoundNBT original = lava.writeToNBT(new CompoundNBT()).copy(); + for (boolean execute : new boolean[]{false, true}) { + assertEquals(0, fill(lava, execute)); + assertNull(drain(lava, execute)); + } + assertEquals(100, tank.getFluidAmount()); + assertSame(net.minecraft.fluid.Fluids.WATER, tank.getFluid().getFluid()); + assertEquals(original, lava.writeToNBT(new CompoundNBT())); + } + + @Test public void nullFillIsSafeOnEmptyAndSeededTanks() throws Exception { + for (int amount : new int[]{0, 100}) { + stock(amount); + assertEquals(0, fill(null, false)); + assertEquals(0, fill(null, true)); + assertEquals(amount, tank.getFluidAmount()); + } + } + + @Test public void zeroAndNegativeFillsDoNotChangeTheTank() throws Exception { + stock(100); + for (int amount : new int[]{0, -5}) { + FluidStack request = new FluidStack(net.minecraft.fluid.Fluids.WATER, amount); + request.amount = amount; + assertEquals(0, fill(request, false)); + assertEquals(0, fill(request, true)); + } + assertEquals(100, tank.getFluidAmount()); + } + + @Test public void typedDrainOnAnEmptyTankReturnsNull() throws Exception { + assertNull(drain(new FluidStack(net.minecraft.fluid.Fluids.WATER, 5), false)); + assertNull(drain(new FluidStack(net.minecraft.fluid.Fluids.WATER, 5), true)); + assertEquals(0, tank.getFluidAmount()); + } + + @Test public void nullTypedDrainIsSafeOnEmptyAndSeededTanks() throws Exception { + for (int amount : new int[]{0, 100}) { + stock(amount); + assertNull(drain((FluidStack)null, false)); + assertNull(drain((FluidStack)null, true)); + assertEquals(amount, tank.getFluidAmount()); + } + } + + @Test public void zeroAndNegativeTypedDrainsReturnNull() throws Exception { + stock(100); + for (int amount : new int[]{0, -5}) { + FluidStack request = new FluidStack(net.minecraft.fluid.Fluids.WATER, amount); + request.amount = amount; + assertNull(drain(request, false)); + assertNull(drain(request, true)); + } + assertEquals(100, tank.getFluidAmount()); + } + + @Test public void zeroAndNegativeAmountDrainsReturnNull() throws Exception { + stock(100); + for (int amount : new int[]{0, -5}) { + assertNull(drain(amount, false)); + assertNull(drain(amount, true)); + } + assertEquals(100, tank.getFluidAmount()); + } + + @Test public void matchingSimulationKeepsAllStockAndTheFiniteRequestUnchanged() throws Exception { + stock(100); + FluidStack request = new FluidStack(net.minecraft.fluid.Fluids.WATER, 5); + CompoundNBT original = request.writeToNBT(new CompoundNBT()).copy(); + assertEquals(5, fill(request, false)); + assertEquals(5, drain(request, false).amount); + assertEquals(5, drain(5, false).amount); + assertEquals(100, tank.getFluidAmount()); + assertEquals(original, request.writeToNBT(new CompoundNBT())); + } + + @Test public void aRealMatchingDrainReturnsOnlyTheRequestedWater() throws Exception { + stock(100); + FluidStack request = new FluidStack(net.minecraft.fluid.Fluids.WATER, 5); + FluidStack result = drain(request, true); + assertNotNull(result); + assertSame(net.minecraft.fluid.Fluids.WATER, result.getFluid()); + assertEquals(5, result.amount); + assertEquals(95, tank.getFluidAmount()); + assertEquals(5, request.amount); + } + + private void stock(int amount) { tank.setFluid(amount == 0 ? null : new FluidStack(net.minecraft.fluid.Fluids.WATER, amount)); } + private int fill(FluidStack offered, boolean execute) throws Exception { + Method method = type.getMethod("fill", net.minecraft.util.Direction.class, FluidStack.class, boolean.class); + return ((Number) method.invoke(boiler, null, offered, execute)).intValue(); + } + private FluidStack drain(FluidStack request, boolean execute) throws Exception { + return (FluidStack) type.getMethod("drain", net.minecraft.util.Direction.class, FluidStack.class, boolean.class) + .invoke(boiler, null, request, execute); + } + private FluidStack drain(int amount, boolean execute) throws Exception { + return (FluidStack) type.getMethod("drain", net.minecraft.util.Direction.class, int.class, boolean.class) + .invoke(boiler, null, amount, execute); + } +} diff --git a/src/test/java/zone/moddev/mc/steamadvantage/machines/WaterBoilerTankNotificationTest.java b/src/test/java/zone/moddev/mc/steamadvantage/machines/WaterBoilerTankNotificationTest.java new file mode 100644 index 0000000..890ebc7 --- /dev/null +++ b/src/test/java/zone/moddev/mc/steamadvantage/machines/WaterBoilerTankNotificationTest.java @@ -0,0 +1,165 @@ +package zone.moddev.mc.steamadvantage.machines; + +import static org.junit.Assert.*; +import cyano.poweradvantage.api.IPowerMachine; +import zone.moddev.mc.poweradvantage.init.Fluids; +import java.util.*; +import net.minecraft.nbt.CompoundNBT; +import net.minecraft.world.World; +import cyano.poweradvantage.api.fluid.FluidStack; +import cyano.poweradvantage.api.fluid.FluidTank; +import org.junit.BeforeClass; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +/** Save notifications only; native restart reports remain the disk-persistence evidence. */ +@RunWith(Parameterized.class) +public class WaterBoilerTankNotificationTest { + @Parameterized.Parameters(name = "{0}") + public static Collection boilers() { return Arrays.asList(new Object[][]{{"electric"}, {"geothermal"}}); } + private final String type; + private Probe probe; + public WaterBoilerTankNotificationTest(String type) { this.type = type; } + @BeforeClass public static void bootstrap() { + net.minecraft.util.registry.Bootstrap.register(); + if (zone.moddev.mc.steamadvantage.init.Entities.STEAM_BOILER_ELECTRIC == null) + zone.moddev.mc.steamadvantage.init.Entities.STEAM_BOILER_ELECTRIC = net.minecraft.tileentity.TileEntityType.Builder.create(ElectricBoilerTileEntity::new, net.minecraft.block.Blocks.STONE).build(null); + if (zone.moddev.mc.steamadvantage.init.Entities.STEAM_BOILER_GEOTHERMAL == null) + zone.moddev.mc.steamadvantage.init.Entities.STEAM_BOILER_GEOTHERMAL = net.minecraft.tileentity.TileEntityType.Builder.create(GeothermalBoilerTileEntity::new, net.minecraft.block.Blocks.STONE).build(null); + } + @Before public void createRealBoiler() { + probe = type.equals("electric") ? new ElectricProbe() : new GeothermalProbe(); + probe.save().remember(probe.tank()); + } + + @Test public void publicAcceptedFillRequestsSavingBeforeAnyTick() throws Exception { + FluidStack offer = water(5); + assertEquals(5, fill(offer, true)); + assertEquals(5, offer.amount); + saved(5); + } + + @Test public void networkAcceptedWaterAlsoRequestsSavingBeforeAnyTick() { + assertEquals(5, probe.machine().addEnergy(5, Fluids.fluidToConduitType(net.minecraft.fluid.Fluids.WATER)), 0); + saved(5); + } + + @Test public void directTankFillUsesTheSameOwnerNotification() { + assertEquals(5, probe.tank().fill(water(5), true)); + saved(5); + } + + @Test public void publicRealDrainRequestsSaving() throws Exception { + stock(10); + assertEquals(5, drain(5, true).amount); + saved(5); + } + + @Test public void directRealDrainUsesTheSameOwnerNotification() { + stock(10); + assertEquals(5, probe.tank().drain(5, true).amount); + saved(5); + } + + @Test public void drainingToEmptyRequestsSavingOfTheNativeEmptyRepresentation() { + stock(5); + assertEquals(5, probe.tank().drain(5, true).amount); + saved(0); + assertNull(FluidStack.loadFluidStackFromNBT(probe.save().savedTank)); + } + + @Test public void directAndPublicSimulationsNeverRequestSaving() throws Exception { + stock(10); + assertEquals(5, probe.tank().fill(water(5), false)); + assertEquals(5, probe.tank().drain(5, false).amount); + assertEquals(5, fill(water(5), false)); + assertEquals(5, drain(5, false).amount); + unchanged(10); + } + + @Test public void aClientFillChangesOnlyItsLocalTankWithoutRequestingServerSaving() throws Exception { + stock(10); probe.side(true); + assertEquals(5, fill(water(5), true)); + assertEquals(15, probe.tank().getFluidAmount()); + assertEquals(0, probe.save().dirtyCalls); + assertEquals(10, probe.save().savedTank.getInt("Amount")); + } + + @Test public void aClientDirectDrainDoesNotRequestServerSaving() { + stock(10); probe.side(true); + assertEquals(5, probe.tank().drain(5, true).amount); + assertEquals(5, probe.tank().getFluidAmount()); + assertEquals(0, probe.save().dirtyCalls); + assertEquals(10, probe.save().savedTank.getInt("Amount")); + } + + private static FluidStack water(int amount) { return new FluidStack(net.minecraft.fluid.Fluids.WATER, amount); } + private void stock(int amount) { probe.tank().setFluid(water(amount)); probe.save().remember(probe.tank()); } + private void saved(int amount) { + assertTrue("Real server-side tank mutation must notify its owner immediately", probe.save().dirtyCalls > 0); + assertEquals(amount, probe.tank().getFluidAmount()); + assertEquals(amount, probe.save().savedTank.getInt("Amount")); + } + private void unchanged(int amount) { + assertEquals(0, probe.save().dirtyCalls); + assertEquals(amount, probe.tank().getFluidAmount()); + assertEquals(amount, probe.save().savedTank.getInt("Amount")); + } + private int fill(FluidStack offer, boolean execute) throws Exception { + return ((Number) probe.machine().getClass().getMethod("fill", net.minecraft.util.Direction.class, FluidStack.class, boolean.class) + .invoke(probe.machine(), null, offer, execute)).intValue(); + } + private FluidStack drain(int amount, boolean execute) throws Exception { + return (FluidStack) probe.machine().getClass().getMethod("drain", net.minecraft.util.Direction.class, int.class, boolean.class) + .invoke(probe.machine(), null, amount, execute); + } + private interface Probe { + FluidTank tank(); + SaveWitness save(); + IPowerMachine machine(); + void side(boolean client); + } + private static final class SaveWitness { + int dirtyCalls; + CompoundNBT savedTank; + void remember(FluidTank tank) { dirtyCalls = 0; savedTank = tank.writeToNBT(new CompoundNBT()).copy(); } + void changed(FluidTank tank) { dirtyCalls++; savedTank = tank.writeToNBT(new CompoundNBT()).copy(); } + } + private static final class ElectricProbe extends ElectricBoilerTileEntity implements Probe { + private final SaveWitness witness = new SaveWitness(); + private World testWorld = world(false); + @Override public World getWorld() { return testWorld; } + @Override public void markDirty() { witness.changed(getTank()); } + @Override public FluidTank tank() { return getTank(); } + @Override public SaveWitness save() { return witness; } + @Override public IPowerMachine machine() { return this; } + @Override public void side(boolean client) { testWorld = world(client); } + } + private static final class GeothermalProbe extends GeothermalBoilerTileEntity implements Probe { + private final SaveWitness witness = new SaveWitness(); + private World testWorld = world(false); + @Override public World getWorld() { return testWorld; } + @Override public void markDirty() { witness.changed(getTank()); } + @Override public FluidTank tank() { return getTank(); } + @Override public SaveWitness save() { return witness; } + @Override public IPowerMachine machine() { return this; } + @Override public void side(boolean client) { testWorld = world(client); } + } + private static World world(boolean client) { + try { + Class allocator = Class.forName("sun.misc.Unsafe"); + java.lang.reflect.Field field = allocator.getDeclaredField("theUnsafe"); + field.setAccessible(true); + SaveWorld world = (SaveWorld) allocator.getMethod("allocateInstance", Class.class).invoke(field.get(null), SaveWorld.class); + java.lang.reflect.Field clientField = World.class.getDeclaredField("isRemote"); + clientField.setAccessible(true); + clientField.setBoolean(world, client); + return world; + } catch (ReflectiveOperationException failure) { throw new AssertionError(failure); } + } + private static final class SaveWorld extends net.minecraft.world.server.ServerWorld { + private SaveWorld() { super(null, null, null, null, null, null, null); } + } +} diff --git a/src/test/java/zone/moddev/mc/steamadvantage/util/SoundHelperLoadedPlayersTest.java b/src/test/java/zone/moddev/mc/steamadvantage/util/SoundHelperLoadedPlayersTest.java new file mode 100644 index 0000000..8c7a44c --- /dev/null +++ b/src/test/java/zone/moddev/mc/steamadvantage/util/SoundHelperLoadedPlayersTest.java @@ -0,0 +1,129 @@ +package zone.moddev.mc.steamadvantage.util; + +import java.io.InputStream; +import net.minecraft.util.math.AxisAlignedBB; +import org.junit.Test; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.tree.AbstractInsnNode; +import org.objectweb.asm.tree.ClassNode; +import org.objectweb.asm.tree.FieldInsnNode; +import org.objectweb.asm.tree.JumpInsnNode; +import org.objectweb.asm.tree.LdcInsnNode; +import org.objectweb.asm.tree.MethodInsnNode; +import org.objectweb.asm.tree.MethodNode; +import org.objectweb.asm.tree.TypeInsnNode; +import static org.junit.Assert.*; + +public class SoundHelperLoadedPlayersTest { + @Test public void soundDispatchNeverScansEntitiesOrLoadsChunks() throws Exception { + int loadedPlayerIterations = 0; + for (MethodNode method : soundHelper().methods) { + for (AbstractInsnNode instruction : method.instructions.toArray()) { + if (!(instruction instanceof MethodInsnNode)) continue; + MethodInsnNode call = (MethodInsnNode) instruction; + assertFalse("Entity query reintroduced in " + method.name, + call.name.equals("getEntitiesWithinAABB") || call.name.equals("getEntitiesWithinAABBExcludingEntity") + || call.name.equals("getEntities")); + assertFalse("Chunk query reintroduced in " + method.name, + call.name.startsWith("getChunk") || call.name.equals("getLoadedChunk")); + if (call.name.equals("getPlayers")) loadedPlayerIterations++; + } + } + assertEquals("Both sound ranges must iterate the existing loaded-player list", 2, loadedPlayerIterations); + } + + @Test public void bothRangesKeepBoundsServerPlayerAndConnectionGuards() throws Exception { + verifyDispatch("playSoundAtPosition", 16); + verifyDispatch("playBigSoundAtPosition", 50); + } + + @Test public void bothSoundRangesExplicitlyExcludeSpectatorsBeforeSending() throws Exception { + int checked = 0; + for (MethodNode method : soundHelper().methods) { + if (!method.name.equals("playSoundAtPosition") && !method.name.equals("playBigSoundAtPosition")) continue; + int predicate = -1, tested = -1, packet = -1; + for (AbstractInsnNode instruction : method.instructions.toArray()) { + if (instruction instanceof FieldInsnNode) { + FieldInsnNode field = (FieldInsnNode) instruction; + if (field.getOpcode() == Opcodes.GETSTATIC && field.owner.equals("net/minecraft/util/EntityPredicates") + && field.name.equals("NOT_SPECTATING")) predicate = method.instructions.indexOf(instruction); + } else if (instruction instanceof MethodInsnNode) { + MethodInsnNode call = (MethodInsnNode) instruction; + if (call.owner.equals("java/util/function/Predicate") && call.name.equals("test")) { + tested = method.instructions.indexOf(instruction); + AbstractInsnNode guard = instruction.getNext(); + while (guard != null && guard.getOpcode() < 0) guard = guard.getNext(); + assertTrue(method.name + " rejects a false native spectator predicate", + guard instanceof JumpInsnNode && guard.getOpcode() == Opcodes.IFEQ); + } + if (call.owner.equals("net/minecraft/network/play/server/SPlaySoundPacket") && call.name.equals("")) { + packet = method.instructions.indexOf(instruction); + } + } + } + assertTrue(method.name + " applies native NOT_SPECTATING before creating the sound packet", + predicate >= 0 && tested > predicate && packet > tested); + checked++; + } + assertEquals("Both sound ranges preserve spectator exclusion", 2, checked); + } + + @Test public void rangeSelectionKeepsStrictAabbOverlapNotDistanceOrPlayerCenter() { + for (double radius : new double[]{16, 50}) { + AxisAlignedBB area = new AxisAlignedBB(-radius, -radius, -radius, radius, radius, radius); + assertTrue("An overlapping player body is included even with its center outside the cube", + area.intersects(new AxisAlignedBB(radius - 0.1, -0.5, -0.3, radius + 0.5, 1.3, 0.3))); + assertTrue("Corner overlap remains included, not replaced with a spherical radius", + area.intersects(new AxisAlignedBB(radius - 0.1, radius - 0.1, radius - 0.1, + radius + 0.5, radius + 1.7, radius + 0.5))); + assertFalse("Merely touching the positive boundary is not an intersection", + area.intersects(new AxisAlignedBB(radius, -0.5, -0.3, radius + 0.6, 1.3, 0.3))); + assertFalse("Merely touching the negative boundary is not an intersection", + area.intersects(new AxisAlignedBB(-radius - 0.6, -0.5, -0.3, -radius, 1.3, 0.3))); + } + } + + private static void verifyDispatch(String name, double radius) throws Exception { + MethodNode method = soundHelper().methods.stream().filter(candidate -> candidate.name.equals(name)) + .findFirst().orElseThrow(() -> new AssertionError("Missing sound dispatch " + name)); + int playerLists = 0, bounds = 0, intersections = 0, soundPackets = 0; + boolean correctRadius = false, serverPlayer = false, connection = false, nullGuard = false; + for (AbstractInsnNode instruction : method.instructions.toArray()) { + if (instruction instanceof MethodInsnNode) { + MethodInsnNode call = (MethodInsnNode) instruction; + if (call.name.equals("getPlayers")) playerLists++; + if (call.name.equals("getBoundingBox")) bounds++; + if (call.owner.equals("net/minecraft/util/math/AxisAlignedBB") && call.name.equals("intersects") + && call.desc.equals("(Lnet/minecraft/util/math/AxisAlignedBB;)Z")) intersections++; + if (call.owner.equals("net/minecraft/network/play/server/SPlaySoundPacket") && call.name.equals("")) soundPackets++; + } else if (instruction instanceof LdcInsnNode) { + Object value = ((LdcInsnNode) instruction).cst; + if (value instanceof Double && ((Double) value).doubleValue() == radius) correctRadius = true; + } else if (instruction instanceof TypeInsnNode) { + TypeInsnNode type = (TypeInsnNode) instruction; + if (type.getOpcode() == Opcodes.INSTANCEOF && type.desc.equals("net/minecraft/entity/player/ServerPlayerEntity")) serverPlayer = true; + } else if (instruction instanceof FieldInsnNode) { + FieldInsnNode field = (FieldInsnNode) instruction; + if (field.getOpcode() == Opcodes.GETFIELD && field.name.equals("connection")) connection = true; + } else if (instruction instanceof JumpInsnNode && instruction.getOpcode() == Opcodes.IFNULL) nullGuard = true; + } + assertEquals(name, 1, playerLists); + assertEquals(name, 1, bounds); + assertEquals(name, 1, intersections); + assertEquals(name, 1, soundPackets); + assertTrue(name + " keeps its exact range", correctRadius); + assertTrue(name + " requires a server player", serverPlayer); + assertTrue(name + " guards the connection before sending", connection && nullGuard); + } + + private static ClassNode soundHelper() throws Exception { + ClassNode node = new ClassNode(); + try (InputStream input = SoundHelperLoadedPlayersTest.class.getResourceAsStream( + "/zone/moddev/mc/steamadvantage/util/SoundHelper.class")) { + assertNotNull("Compiled SoundHelper is available", input); + new ClassReader(input).accept(node, 0); + } + return node; + } +}