From fbd0e2d6a762a856f154185650f8323b5c24f0ce Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Fri, 30 Oct 2026 20:54:57 +0000 Subject: [PATCH] Fix Steam Advantage machine state and strengthen Minecraft 1.10.2 tests --- CHANGELOG.txt | 13 ++ .../mc/steamadvantage/gui/GUIHelper.java | 8 + .../mc/steamadvantage/items/MusketItem.java | 59 ++--- .../machines/BlastFurnaceTileEntity.java | 12 + .../machines/CoalBoilerTileEntity.java | 16 +- .../machines/ElectricBoilerTileEntity.java | 16 +- .../machines/GeothermalBoilerTileEntity.java | 16 +- .../machines/RockCrusherTileEntity.java | 12 + .../machines/SteamPumpTileEntity.java | 36 ++- .../machines/SteamStillTileEntity.java | 31 ++- .../mc/steamadvantage/util/SoundHelper.java | 26 ++- .../mc/steamadvantage/gui/GUIHelperTest.java | 29 +++ .../items/MusketLoadedCartridgeTest.java | 126 +++++++++++ .../items/MusketTargetingContractTest.java | 148 ++++++++++++ .../CoalBoilerFillPersistenceTest.java | 210 ++++++++++++++++++ .../machines/RockCrusherComparatorTest.java | 24 ++ .../RockCrusherReloadRedstoneTest.java | 86 +++++++ .../SteamOperationalPumpSourceTest.java | 182 +++++++++++++++ .../SteamOperationalStillCapacityTest.java | 85 +++++++ .../SteamRedstoneInitializationTest.java | 105 +++++++++ .../WaterBoilerFluidBoundaryTest.java | 160 +++++++++++++ .../WaterBoilerTankNotificationTest.java | 152 +++++++++++++ .../util/SoundHelperLoadedPlayersTest.java | 134 +++++++++++ 23 files changed, 1627 insertions(+), 59 deletions(-) create mode 100644 src/test/java/zone/moddev/mc/steamadvantage/gui/GUIHelperTest.java create mode 100644 src/test/java/zone/moddev/mc/steamadvantage/items/MusketLoadedCartridgeTest.java create mode 100644 src/test/java/zone/moddev/mc/steamadvantage/items/MusketTargetingContractTest.java create mode 100644 src/test/java/zone/moddev/mc/steamadvantage/machines/CoalBoilerFillPersistenceTest.java create mode 100644 src/test/java/zone/moddev/mc/steamadvantage/machines/RockCrusherComparatorTest.java create mode 100644 src/test/java/zone/moddev/mc/steamadvantage/machines/RockCrusherReloadRedstoneTest.java create mode 100644 src/test/java/zone/moddev/mc/steamadvantage/machines/SteamOperationalPumpSourceTest.java create mode 100644 src/test/java/zone/moddev/mc/steamadvantage/machines/SteamOperationalStillCapacityTest.java create mode 100644 src/test/java/zone/moddev/mc/steamadvantage/machines/SteamRedstoneInitializationTest.java create mode 100644 src/test/java/zone/moddev/mc/steamadvantage/machines/WaterBoilerFluidBoundaryTest.java create mode 100644 src/test/java/zone/moddev/mc/steamadvantage/machines/WaterBoilerTankNotificationTest.java create mode 100644 src/test/java/zone/moddev/mc/steamadvantage/util/SoundHelperLoadedPlayersTest.java diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 28f0640..d0f86ff 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,16 @@ +Unreleased - Minecraft 1.10.2 + +- Let Forge fluid sources control their own drainage, preserving the amount and fluid data they return. +- Keep fluid in the steam pump when an attached tank refuses it, and only charge for successful pickup. +- 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.5.110021 - Retain unfinished drill progress when rebuilding the mining target after a world or chunk reload. 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 7a7a008..e897bdd 100644 --- a/src/main/java/zone/moddev/mc/steamadvantage/gui/GUIHelper.java +++ b/src/main/java/zone/moddev/mc/steamadvantage/gui/GUIHelper.java @@ -24,6 +24,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 @@ -84,6 +85,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; @@ -99,6 +101,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; @@ -116,6 +119,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; @@ -127,4 +131,8 @@ public static void drawDownArrowProgress(int x, int y, float progress, gc.drawTexturedModalRect(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 46e00ea..33ce39c 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,7 @@ import static zone.moddev.mc.steamadvantage.util.SoundHelper.playSoundAtPosition; import java.util.List; +import java.util.Optional; import com.google.common.collect.HashMultimap; import com.google.common.collect.Multimap; @@ -32,6 +33,7 @@ import net.minecraft.util.ActionResult; import net.minecraft.util.EnumActionResult; import net.minecraft.util.EnumHand; +import net.minecraft.util.EntitySelectors; import net.minecraft.util.EnumParticleTypes; import net.minecraft.util.ResourceLocation; import net.minecraft.util.SoundCategory; @@ -104,7 +106,7 @@ public EnumAction getItemUseAction(ItemStack stack){ @Override public ActionResult onItemRightClick(ItemStack srcItemStack, World world, EntityPlayer playerEntity, EnumHand hand){ if(hand != EnumHand.MAIN_HAND) return ActionResult.newResult(EnumActionResult.PASS,srcItemStack); - if(!hasAmmo(playerEntity, srcItemStack)) return ActionResult.newResult(EnumActionResult.FAIL,srcItemStack); + if(isNotLoaded(srcItemStack) && !hasAmmo(playerEntity, srcItemStack)) return ActionResult.newResult(EnumActionResult.FAIL,srcItemStack); if(isNotLoaded(srcItemStack) && EnchantmentHelper.getEnchantmentLevel(Enchantments.rapid_reload, srcItemStack) > 0){ // instant reload decrementAmmo(playerEntity,srcItemStack); @@ -364,37 +366,43 @@ public static void decrementAmmo(EntityLivingBase e, ItemStack musket) { public static RayTraceResult rayTraceBlocksAndEntities(World w, double maxRange, EntityLivingBase 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; + Vec3d rangeEnd = rayOrigin.add(rayDirection.scale(maxRange)); + RayTraceResult terrainHit = w.rayTraceBlocks(rayOrigin, rangeEnd, true, false, false); + Vec3d rayEnd = terrainHit == null ? rangeEnd : terrainHit.hitVec; + AxisAlignedBB aoi = new AxisAlignedBB( + Math.min(rayOrigin.xCoord, rayEnd.xCoord) - 1, Math.min(rayOrigin.yCoord, rayEnd.yCoord) - 1, Math.min(rayOrigin.zCoord, rayEnd.zCoord) - 1, + Math.max(rayOrigin.xCoord, rayEnd.xCoord) + 1, Math.max(rayOrigin.yCoord, rayEnd.yCoord) + 1, Math.max(rayOrigin.zCoord, rayEnd.zCoord) + 1); + 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.getEntityBoundingBox(); - 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; + // Target only loaded entities; a spatial query can otherwise load distant chunks. + for(Entity e : w.loadedEntityList){ + if(e == source || e.isDead || !EntitySelectors.NOT_SPECTATING.apply(e) + || !aoi.intersectsWith(e.getEntityBoundingBox())) continue; + Optional hit = rayHitBeforeTerrain(rayOrigin, rayEnd, e.getEntityBoundingBox()); + 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(rayOrigin, rayOrigin.add(mul(rayDirection, maxRange)), true, false, false); + return closestEntity == null ? terrainHit : new RayTraceResult(closestEntity, closestHit); + } + + static Optional rayHitBeforeTerrain(Vec3d origin, Vec3d terrainEnd, AxisAlignedBB target){ + Vec3d point; + if(target.isVecInside(origin)){ + point = origin; } else { - Vec3d pos = new Vec3d(closestEntity.posX, closestEntity.posY+closestEntity.getEyeHeight(), closestEntity.posZ); - RayTraceResult entityCollision = new RayTraceResult(closestEntity, pos); - return entityCollision; + RayTraceResult hit = target.calculateIntercept(origin, terrainEnd); + point = hit == null ? null : hit.hitVec; } + return Optional.ofNullable(point).filter(hit -> origin.squareDistanceTo(hit) < origin.squareDistanceTo(terrainEnd)); } public static boolean rayIntersectsBoundingBox(Vec3d rayOrigin, Vec3d rayDirection, AxisAlignedBB box){ @@ -427,9 +435,6 @@ public static boolean rayIntersectsBoundingBox(Vec3d rayOrigin, Vec3d rayDirecti } - private static Vec3d mul(Vec3d a, double b){ - return new Vec3d(a.xCoord * b, a.yCoord * b, a.zCoord * 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 737ffbb..505b929 100644 --- a/src/main/java/zone/moddev/mc/steamadvantage/machines/BlastFurnaceTileEntity.java +++ b/src/main/java/zone/moddev/mc/steamadvantage/machines/BlastFurnaceTileEntity.java @@ -35,6 +35,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(Power.steam_power, 200, "tile.steamadvantage.steam_furnace.name"); @@ -63,6 +70,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){ @@ -168,6 +179,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 2a3c658..7bf9e48 100644 --- a/src/main/java/zone/moddev/mc/steamadvantage/machines/CoalBoilerTileEntity.java +++ b/src/main/java/zone/moddev/mc/steamadvantage/machines/CoalBoilerTileEntity.java @@ -40,7 +40,15 @@ public class CoalBoilerTileEntity extends cyano.poweradvantage.api.simple.TileEn public CoalBoilerTileEntity() { super(new ConduitType[]{Power.steam_power,Fluids.fluidConduit_general}, new float[]{1000,1000}, "tile.steamadvantage.steam_boiler_coal.name"); - tank = new FluidTank(FluidContainerRegistry.BUCKET_VOLUME * 4); + tank = new FluidTank(FluidContainerRegistry.BUCKET_VOLUME * 4) { + @Override + protected void onContentsChanged() { + if (CoalBoilerTileEntity.this.getWorld() != null + && !CoalBoilerTileEntity.this.getWorld().isRemote) { + CoalBoilerTileEntity.this.markDirty(); + } + } + }; inventory = new ItemStack[1]; } @@ -350,6 +358,8 @@ && getTank().getFluidAmount() > 0 */ @Override public int fill(EnumFacing 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 { @@ -364,10 +374,11 @@ public int fill(EnumFacing face, FluidStack fluid, boolean forReal) { */ @Override public FluidStack drain(EnumFacing 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; } } /** @@ -378,6 +389,7 @@ public FluidStack drain(EnumFacing face, FluidStack fluid, boolean forReal) { */ @Override public FluidStack drain(EnumFacing 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 4f717c6..2a9250e 100644 --- a/src/main/java/zone/moddev/mc/steamadvantage/machines/ElectricBoilerTileEntity.java +++ b/src/main/java/zone/moddev/mc/steamadvantage/machines/ElectricBoilerTileEntity.java @@ -41,7 +41,15 @@ public ElectricBoilerTileEntity() { super(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(FluidContainerRegistry.BUCKET_VOLUME * 2); + tank = new FluidTank(FluidContainerRegistry.BUCKET_VOLUME * 2) { + @Override + protected void onContentsChanged() { + if (ElectricBoilerTileEntity.this.getWorld() != null + && !ElectricBoilerTileEntity.this.getWorld().isRemote) { + ElectricBoilerTileEntity.this.markDirty(); + } + } + }; inventory = new ItemStack[0]; } @@ -302,6 +310,8 @@ public PowerRequest getPowerRequest(ConduitType offer) { */ @Override public int fill(EnumFacing 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 { @@ -316,10 +326,11 @@ public int fill(EnumFacing face, FluidStack fluid, boolean forReal) { */ @Override public FluidStack drain(EnumFacing 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; } } /** @@ -330,6 +341,7 @@ public FluidStack drain(EnumFacing face, FluidStack fluid, boolean forReal) { */ @Override public FluidStack drain(EnumFacing 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 7f6052f..e9ec689 100644 --- a/src/main/java/zone/moddev/mc/steamadvantage/machines/GeothermalBoilerTileEntity.java +++ b/src/main/java/zone/moddev/mc/steamadvantage/machines/GeothermalBoilerTileEntity.java @@ -38,7 +38,15 @@ public class GeothermalBoilerTileEntity extends cyano.poweradvantage.api.simple. public GeothermalBoilerTileEntity() { super(new ConduitType[]{Power.steam_power,Fluids.fluidConduit_general}, new float[]{100,1000}, "tile.steamadvantage.steam_boiler_geothermal.name"); - tank = new FluidTank(FluidContainerRegistry.BUCKET_VOLUME * 2); + tank = new FluidTank(FluidContainerRegistry.BUCKET_VOLUME * 2) { + @Override + protected void onContentsChanged() { + if (GeothermalBoilerTileEntity.this.getWorld() != null + && !GeothermalBoilerTileEntity.this.getWorld().isRemote) { + GeothermalBoilerTileEntity.this.markDirty(); + } + } + }; inventory = new ItemStack[0]; } @@ -331,6 +339,8 @@ public PowerRequest getPowerRequest(ConduitType offer) { */ @Override public int fill(EnumFacing 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 { @@ -345,10 +355,11 @@ public int fill(EnumFacing face, FluidStack fluid, boolean forReal) { */ @Override public FluidStack drain(EnumFacing 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; } } /** @@ -359,6 +370,7 @@ public FluidStack drain(EnumFacing face, FluidStack fluid, boolean forReal) { */ @Override public FluidStack drain(EnumFacing 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 7e6d5a9..5a4acd1 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 && ItemStack.areItemsEqual(itemCheck, input) == false){ @@ -112,6 +123,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 99d3d48..e075c16 100644 --- a/src/main/java/zone/moddev/mc/steamadvantage/machines/SteamPumpTileEntity.java +++ b/src/main/java/zone/moddev/mc/steamadvantage/machines/SteamPumpTileEntity.java @@ -82,11 +82,14 @@ public void tickUpdate(boolean isServerWorld) { // Found a fluid to suck-up! IBlockState blockstate = w.getBlockState(fluidSource); Fluid f = getFluid(blockstate); - if(f != null && getEnergy(Power.steam_power) >= cost){ - this.getTank().fill(new FluidStack(f,FluidContainerRegistry.BUCKET_VOLUME), true); - this.subtractEnergy(cost, Power.steam_power); - w.setBlockToAir(fluidSource); - success = true; + if(f != null && getEnergy(Power.steam_power) >= cost + && getTank().fill(new FluidStack(f,FluidContainerRegistry.BUCKET_VOLUME), false) == FluidContainerRegistry.BUCKET_VOLUME){ + FluidStack collected = pickupSource(w, fluidSource, blockstate); + if(collected != null && collected.amount > 0){ + this.getTank().fill(collected, true); + this.subtractEnergy(cost, Power.steam_power); + success = true; + } } } timeUntilNextPump = PUMP_INTERVAL; @@ -110,7 +113,26 @@ public void tickUpdate(boolean isServerWorld) { private Fluid getFluid(IBlockState blockstate) { - return FluidRegistry.lookupFluidForBlock(blockstate.getBlock()); + Block block = blockstate.getBlock(); + return block instanceof IFluidBlock ? ((IFluidBlock)block).getFluid() : FluidRegistry.lookupFluidForBlock(block); + } + + static FluidStack pickupSource(World world, BlockPos pos, IBlockState state) { + Block block = state.getBlock(); + if(block instanceof IFluidBlock){ + IFluidBlock source = (IFluidBlock)block; + if(!source.canDrain(world, pos)) return null; + FluidStack preview = source.drain(world, pos, false); + if(preview == null || preview.amount <= 0 || preview.amount > FluidContainerRegistry.BUCKET_VOLUME) return null; + // Forge owns source mutation and the actual quantity/NBT returned by the drain. + FluidStack collected = source.drain(world, pos, true); + return collected != null && collected.amount > 0 ? collected : null; + } + if((block == Blocks.WATER || block == Blocks.LAVA) && state.getValue(BlockLiquid.LEVEL) == 0){ + Fluid fluid = FluidRegistry.lookupFluidForBlock(block); + if(fluid != null && world.setBlockToAir(pos)) return new FluidStack(fluid, FluidContainerRegistry.BUCKET_VOLUME); + } + return null; } @@ -201,7 +223,7 @@ public void powerUpdate(){ if(neighbor instanceof IFluidHandler && ((IFluidHandler)neighbor).canFill(dir.getOpposite(),fluid.getFluid())){ int d = ((IFluidHandler)neighbor).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 911c4b1..ad8fea4 100644 --- a/src/main/java/zone/moddev/mc/steamadvantage/machines/SteamStillTileEntity.java +++ b/src/main/java/zone/moddev/mc/steamadvantage/machines/SteamStillTileEntity.java @@ -46,8 +46,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); @@ -287,20 +286,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 6130ad9..54a6f95 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.EntityPlayer; import net.minecraft.entity.player.EntityPlayerMP; import net.minecraft.network.play.server.SPacketCustomSound; import net.minecraft.tileentity.TileEntity; +import net.minecraft.util.EntitySelectors; 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(EntityPlayerMP.class, new AxisAlignedBB( + final AxisAlignedBB area = new AxisAlignedBB( x - range, y - range, z - range, - x + range, y + range, z + range)); - for(EntityPlayerMP player : players){ - player.connection.sendPacket(new SPacketCustomSound(sound.getRegistryName().toString(), soundType, + x + range, y + range, z + range); + for(EntityPlayer candidate : serverWorld.playerEntities){ + if(!(candidate instanceof EntityPlayerMP) || !EntitySelectors.NOT_SPECTATING.apply(candidate) + || !area.intersectsWith(candidate.getEntityBoundingBox())) continue; + EntityPlayerMP player = (EntityPlayerMP) candidate; + if(player.connection != null) player.connection.sendPacket(new SPacketCustomSound(sound.getRegistryName().toString(), soundType, x, y, z, (float)volume, (float)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(EntityPlayerMP.class, new AxisAlignedBB( + final AxisAlignedBB area = new AxisAlignedBB( x - range, y - range, z - range, - x + range, y + range, z + range)); - for(EntityPlayerMP player : players){ - player.connection.sendPacket(new SPacketCustomSound(sound.getRegistryName().toString(), soundType, + x + range, y + range, z + range); + for(EntityPlayer candidate : serverWorld.playerEntities){ + if(!(candidate instanceof EntityPlayerMP) || !EntitySelectors.NOT_SPECTATING.apply(candidate) + || !area.intersectsWith(candidate.getEntityBoundingBox())) continue; + EntityPlayerMP player = (EntityPlayerMP) candidate; + if(player.connection != null) player.connection.sendPacket(new SPacketCustomSound(sound.getRegistryName().toString(), soundType, x, y, z, (float)volume, (float)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..64107b9 --- /dev/null +++ b/src/test/java/zone/moddev/mc/steamadvantage/items/MusketLoadedCartridgeTest.java @@ -0,0 +1,126 @@ +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.init.Items; +import net.minecraft.util.EnumHand; +import net.minecraft.util.EnumActionResult; +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 native 1.10 stack-first use guard without inventing a world or a player. */ +public class MusketLoadedCartridgeTest { + @BeforeClass public static void bootstrap() { + net.minecraft.init.Bootstrap.register(); + } + + @Test public void lastLoadedCartridgeCanBeAimedWithoutSpareAmmunition() throws Exception { + ItemStack stack = new ItemStack(Items.STICK); + MusketItem.finishLoad(stack); + assertEquals("USE", guardResult(stack, false, EnumHand.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, EnumHand.MAIN_HAND)); + assertTrue(MusketItem.isAlmostLoaded(stack)); + } + + @Test public void unloadedMusketStillRejectsMissingAmmunition() throws Exception { + ItemStack stack = new ItemStack(Items.STICK); + assertEquals("FAIL", guardResult(stack, false, EnumHand.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, EnumHand.OFF_HAND)); + assertTrue(MusketItem.isLoaded(stack)); + } + + private static String guardResult(ItemStack stack, boolean hasAmmo, EnumHand 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(), stack, new Object(), new Object(), hand}; + 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/EnumHand")) { + assertEquals("MAIN_HAND", field.name); + operands.add(EnumHand.MAIN_HAND); + } else if (field.owner.equals("net/minecraft/util/EnumActionResult")) { + operands.add(EnumActionResult.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("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[3], pop(operands)); + operands.add(hasAmmo); + } else if (call.name.equals("newResult")) { + assertEquals("net/minecraft/util/ActionResult", call.owner); + assertSame(stack, pop(operands)); + return ((EnumActionResult) 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..5a12921 --- /dev/null +++ b/src/test/java/zone/moddev/mc/steamadvantage/items/MusketTargetingContractTest.java @@ -0,0 +1,148 @@ +package zone.moddev.mc.steamadvantage.items; + +import java.io.InputStream; +import java.util.Optional; +import net.minecraft.util.EnumFacing; +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.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 static org.junit.Assert.*; + +public class MusketTargetingContractTest { + @BeforeClass public static void bootstrap() { + net.minecraft.init.Bootstrap.register(); + } + + @Test public void nearerTerrainRejectsTargetsBehindItOnEveryAxis() { + Vec3d origin = Vec3d.ZERO; + for (EnumFacing direction : EnumFacing.values()) { + Vec3d ray = new Vec3d(direction.getFrontOffsetX(), direction.getFrontOffsetY(), direction.getFrontOffsetZ()); + 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 targetingUsesThePortableSixCoordinateAabbConstructor() 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")); + requirePortableBounds(trace); + } + + @Test public void constructorAuditRejectsTheClientOverloadAndMissingBounds() { + MethodNode synthetic = new MethodNode(); + synthetic.instructions.add(new MethodInsnNode(Opcodes.INVOKESPECIAL, + "net/minecraft/util/math/AxisAlignedBB", "", + "(Lnet/minecraft/util/math/Vec3d;Lnet/minecraft/util/math/Vec3d;)V", false)); + assertThrows("Reject the constructor that broke packaged server targeting", AssertionError.class, + () -> requirePortableBounds(synthetic)); + synthetic.instructions.clear(); + assertThrows("An absent bounds check cannot pass vacuously", AssertionError.class, + () -> requirePortableBounds(synthetic)); + synthetic.instructions.add(new MethodInsnNode(Opcodes.INVOKESPECIAL, + "net/minecraft/util/math/AxisAlignedBB", "", "(DDDDDD)V", false)); + requirePortableBounds(synthetic); + } + + @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; + boolean spectatorPredicate = false, spectatorFilter = false; + for (AbstractInsnNode instruction : trace.instructions.toArray()) { + if (instruction instanceof FieldInsnNode) { + FieldInsnNode field = (FieldInsnNode) instruction; + if (field.getOpcode() == Opcodes.GETFIELD + && field.owner.equals("net/minecraft/world/World") && field.name.equals("loadedEntityList")) { + assertTrue("Terrain must be traced before selecting entities", terrain); + loadedEntities = true; + } + if (field.getOpcode() == Opcodes.GETSTATIC + && field.owner.equals("net/minecraft/util/EntitySelectors") && field.name.equals("NOT_SPECTATING")) { + spectatorPredicate = true; + } + } + if (!(instruction instanceof MethodInsnNode)) continue; + MethodInsnNode call = (MethodInsnNode) instruction; + if (call.name.equals("rayTraceBlocks")) terrain = true; + if (call.name.equals("rayHitBeforeTerrain")) boundedSelection = true; + if (spectatorPredicate && call.owner.equals("com/google/common/base/Predicate") && call.name.equals("apply")) { + AbstractInsnNode guard = instruction.getNext(); + while (guard != null && guard.getOpcode() < 0) guard = guard.getNext(); + assertTrue("The native spectator predicate must reject a false result", + guard instanceof JumpInsnNode && guard.getOpcode() == Opcodes.IFEQ); + 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 void requirePortableBounds(MethodNode method) { + int boxes = 0; + for (AbstractInsnNode instruction : method.instructions.toArray()) { + if (!(instruction instanceof MethodInsnNode)) continue; + MethodInsnNode call = (MethodInsnNode) instruction; + if (!call.owner.equals("net/minecraft/util/math/AxisAlignedBB") || !call.name.equals("")) continue; + assertEquals("Server targeting must use the six-coordinate constructor", "(DDDDDD)V", call.desc); + boxes++; + } + assertTrue("Targeting retains real finite query bounds", boxes > 0); + } + + private static AxisAlignedBB around(Vec3d center) { + return new AxisAlignedBB(center.xCoord - 0.5, center.yCoord - 0.5, center.zCoord - 0.5, + center.xCoord + 0.5, center.yCoord + 0.5, center.zCoord + 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..47a0696 --- /dev/null +++ b/src/test/java/zone/moddev/mc/steamadvantage/machines/CoalBoilerFillPersistenceTest.java @@ -0,0 +1,210 @@ +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.init.Bootstrap; +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.block.state.IBlockState; +import net.minecraft.profiler.Profiler; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.World; +import net.minecraft.world.WorldProviderSurface; +import net.minecraft.world.chunk.IChunkProvider; +import net.minecraftforge.fluids.FluidRegistry; +import net.minecraftforge.fluids.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() { Bootstrap.register(); } + + @Test public void acceptingTheLastFiveMillibucketsRequestsSavingBeforeAnyTick() { + SaveWitnessBoiler boiler = seeded(3995); + FluidStack offered = new FluidStack(FluidRegistry.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(FluidRegistry.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(FluidRegistry.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(FluidRegistry.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(FluidRegistry.WATER, 5), true)); + assertEquals(4000, boiler.getTank().getFluidAmount()); + assertUnchangedSave(boiler, 4000); + } + + @Test public void mismatchedFluidIsNotCreditedOrSaved() { + SaveWitnessBoiler boiler = seeded(3995); + FluidStack offered = new FluidStack(FluidRegistry.LAVA, 5); + + assertEquals(0, boiler.fill(null, offered, true)); + assertSame(FluidRegistry.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(FluidRegistry.WATER, 0), true)); + assertEquals(3995, boiler.getTank().getFluidAmount()); + assertUnchangedSave(boiler, 3995); + } + + @Test public void theImmediateSaveWitnessRetainsFluidIdentityAndTypedNbt() { + SaveWitnessBoiler boiler = new SaveWitnessBoiler(); + FluidStack offered = new FluidStack(FluidRegistry.WATER, 5); + offered.tag = new NBTTagCompound(); + offered.tag.setString("AdvWorksWitness", "external-fill-before-next-tick"); + offered.tag.setShort("TypedWitness", (short) 7); + NBTTagCompound 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(FluidRegistry.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(FluidRegistry.WATER, 5), true)); + assertNull(boiler.drain(null, new FluidStack(FluidRegistry.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(FluidRegistry.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(FluidRegistry.LAVA, 5), true)); + assertNull(boiler.drain(null, new FluidStack(FluidRegistry.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(FluidRegistry.WATER, 5), true)); + } + + @Test public void clientSideTankChangesDoNotRequestServerSaving() { + SaveWitnessBoiler boiler = seeded(3995); + boiler.setWorld(new SaveWorld(true)); + assertEquals(5, boiler.fill(null, new FluidStack(FluidRegistry.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(FluidRegistry.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.getInteger("Amount")); + } + + private static void assertUnchangedSave(SaveWitnessBoiler boiler, int amount) { + assertEquals(0, boiler.dirtyCalls); + assertEquals(amount, boiler.savedTank.getInteger("Amount")); + } + + private static final class SaveWitnessBoiler extends CoalBoilerTileEntity { + int dirtyCalls; + NBTTagCompound savedTank; + SaveWitnessBoiler() { setWorld(new SaveWorld(false)); } + void rememberSavedTank() { + dirtyCalls = 0; + savedTank = getTank().writeToNBT(new NBTTagCompound()).copy(); + } + @Override public void markDirty() { + dirtyCalls++; + // Record only the notification payload; do not impersonate a native server save. + savedTank = getTank().writeToNBT(new NBTTagCompound()).copy(); + } + } + + private static final class SaveWorld extends World { + SaveWorld(boolean remote) { super(null, null, new WorldProviderSurface(), new Profiler(), remote); } + @Override public IBlockState getBlockState(BlockPos pos) { return net.minecraft.init.Blocks.AIR.getDefaultState(); } + @Override protected boolean isChunkLoaded(int x, int z, boolean allowEmpty) { return true; } + @Override protected IChunkProvider createChunkProvider() { throw new AssertionError("No native world ticking in a save-notification test"); } + } +} 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..543ea9f --- /dev/null +++ b/src/test/java/zone/moddev/mc/steamadvantage/machines/RockCrusherComparatorTest.java @@ -0,0 +1,24 @@ +package zone.moddev.mc.steamadvantage.machines; + +import static org.junit.Assert.assertEquals; +import net.minecraft.item.ItemStack; +import net.minecraft.init.Items; +import org.junit.BeforeClass; +import org.junit.Test; + +public class RockCrusherComparatorTest { + @BeforeClass public static void bootstrap() { + net.minecraft.init.Bootstrap.register(); + + } + @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 ? null : 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..f81d2e9 --- /dev/null +++ b/src/test/java/zone/moddev/mc/steamadvantage/machines/RockCrusherReloadRedstoneTest.java @@ -0,0 +1,86 @@ +package zone.moddev.mc.steamadvantage.machines; + +import static org.junit.Assert.*; + +import java.lang.reflect.Field; +import net.minecraft.block.state.IBlockState; +import net.minecraft.init.Bootstrap; +import net.minecraft.profiler.Profiler; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.World; +import net.minecraft.world.WorldProviderSurface; +import net.minecraft.world.chunk.IChunkProvider; +import org.junit.BeforeClass; +import org.junit.Test; + +public class RockCrusherReloadRedstoneTest { + @BeforeClass public static void bootstrap() { Bootstrap.register(); } + + @Test public void resumedWorkCannotBeDecrementedBeforeTheFirstLiveControlSample() throws Exception { + RockCrusherTileEntity crusher = new RockCrusherTileEntity(); + Field progress = RockCrusherTileEntity.class.getDeclaredField("progress"); + progress.setAccessible(true); + progress.setInt(crusher, 106); + ControlWorld world = new ControlWorld(); + world.stopAtFirstSample = true; + crusher.setWorld(world); + crusher.setPos(new BlockPos(8, 80, 8)); + try { + crusher.tickUpdate(true); + fail("The first live control sample must precede all resumed work"); + } catch (ControlSample expected) { + assertEquals("Sample before touching the saved progress", 106, progress.getInt(crusher)); + assertEquals(1, world.samples); + } + } + + @Test public void scheduledPowerUpdateStillRefreshesTheInitializedCache() throws Exception { + RockCrusherTileEntity crusher = new RockCrusherTileEntity(); + ControlWorld world = new ControlWorld(); + crusher.setWorld(world); + crusher.setPos(new BlockPos(8, 80, 8)); + crusher.tickUpdate(true); + Field redstone = RockCrusherTileEntity.class.getDeclaredField("redstone"); + redstone.setAccessible(true); + assertFalse(redstone.getBoolean(crusher)); + world.powered = true; + crusher.tickUpdate(true); + assertFalse(redstone.getBoolean(crusher)); + crusher.powerUpdate(); + assertTrue(redstone.getBoolean(crusher)); + assertEquals(2, world.samples); + } + + @Test public void furnaceSamplesBeforeChangingItsRestoredHeatOrWork() throws Exception { + BlastFurnaceTileEntity furnace = new BlastFurnaceTileEntity(); + Field temperature = BlastFurnaceTileEntity.class.getDeclaredField("temperature"); + temperature.setAccessible(true); + temperature.setFloat(furnace, 1000); + ControlWorld world = new ControlWorld(); + world.stopAtFirstSample = true; + furnace.setWorld(world); + furnace.setPos(new BlockPos(8, 80, 8)); + try { + furnace.tickUpdate(true); + fail("The furnace must sample live control before changing loaded heat or work"); + } catch (ControlSample expected) { + assertEquals(1000, temperature.getFloat(furnace), 0); + assertEquals(1, world.samples); + } + } + + private static final class ControlSample extends RuntimeException {} + private static final class ControlWorld extends World { + boolean powered, stopAtFirstSample; + int samples; + ControlWorld() { super(null, null, new WorldProviderSurface(), new Profiler(), false); } + @Override public boolean isBlockPowered(BlockPos pos) { + samples++; + if (stopAtFirstSample) throw new ControlSample(); + return powered; + } + @Override public IBlockState getBlockState(BlockPos pos) { return net.minecraft.init.Blocks.AIR.getDefaultState(); } + @Override protected boolean isChunkLoaded(int x, int z, boolean allowEmpty) { return true; } + @Override protected IChunkProvider createChunkProvider() { throw new AssertionError("No chunks may be generated by this control test"); } + } +} 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..d4fdd99 --- /dev/null +++ b/src/test/java/zone/moddev/mc/steamadvantage/machines/SteamOperationalPumpSourceTest.java @@ -0,0 +1,182 @@ +package zone.moddev.mc.steamadvantage.machines; + +import java.io.InputStream; +import java.lang.reflect.Method; +import net.minecraft.block.Block; +import net.minecraft.block.BlockLiquid; +import net.minecraft.block.material.Material; +import net.minecraft.block.state.IBlockState; +import net.minecraft.init.Blocks; +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.World; +import net.minecraftforge.fluids.Fluid; +import net.minecraftforge.fluids.FluidRegistry; +import net.minecraftforge.fluids.FluidStack; +import net.minecraftforge.fluids.IFluidBlock; +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.JumpInsnNode; +import org.objectweb.asm.tree.MethodInsnNode; +import org.objectweb.asm.tree.MethodNode; +import static org.junit.Assert.*; + +/** Forge drain-contract checks; real World pickup and transfer run in the packaged rig. */ +public class SteamOperationalPumpSourceTest { + private static final BlockPos SOURCE = new BlockPos(8, 80, 8); + + @BeforeClass public static void bootstrap() { + net.minecraft.init.Bootstrap.register(); + } + + @Test public void forgeSourceOwnsItsDrainAndProvidesOneExactBucket() { + DrainSource source = new DrainSource(1000, true); + FluidStack collected = pickup(source.getDefaultState()); + assertNotNull(collected); + assertSame(FluidRegistry.WATER, collected.getFluid()); + assertEquals(1000, collected.amount); + assertTrue("Check the source's own drain permission", source.permissionChecks > 0); + assertEquals("The real drain happens exactly once", 1, source.realDrains); + assertNull("The source, not a generic block deletion, consumes its fluid", source.stored); + } + + @Test public void refusedForgeSourceIsNotDrainedOrCredited() { + DrainSource source = new DrainSource(1000, false); + assertNull(pickup(source.getDefaultState())); + assertEquals(0, source.realDrains); + assertEquals(0, source.previews); + assertEquals(1000, source.stored.amount); + } + + @Test public void flowingVanillaAndUnsupportedSolidCannotSupplyABucket() { + assertNull(pickup(Blocks.WATER.getDefaultState().withProperty(BlockLiquid.LEVEL, 1))); + assertNull(pickup(Blocks.FLOWING_LAVA.getDefaultState().withProperty(BlockLiquid.LEVEL, 1))); + assertNull(pickup(Blocks.STONE.getDefaultState())); + } + + @Test public void partialForgeDrainKeepsActualQuantityAndFluidNbt() { + DrainSource source = new DrainSource(375, true); + NBTTagCompound tag = new NBTTagCompound(); + tag.setString("AdvWorksWitness", "partial-forge-source"); + source.stored.tag = tag; + FluidStack collected = pickup(source.getDefaultState()); + assertNotNull(collected); + assertEquals("Never substitute an assumed 1000 mB for the native drain result", 375, collected.amount); + assertEquals(tag, collected.tag); + assertEquals(1, source.realDrains); + assertNull(source.stored); + } + + @Test public void successfulExtractionPrecedesCreditAndSteamCharge() throws Exception { + ClassNode node = pump(); + MethodNode tick = method(node, "tickUpdate"); + MethodNode pickup = method(node, "pickupSource"); + int extraction = -1, credit = -1, charge = -1; + boolean nativeDrain = false, permission = false; + for (AbstractInsnNode instruction : tick.instructions.toArray()) { + if (!(instruction instanceof MethodInsnNode)) continue; + MethodInsnNode call = (MethodInsnNode) instruction; + assertFalse("The tick caller must not erase a Forge source instead of draining it", + call.name.equals("setBlockToAir") || call.name.equals("destroyBlock")); + int index = tick.instructions.indexOf(instruction); + if (call.name.equals("pickupSource")) extraction = index; + if (call.owner.equals("net/minecraftforge/fluids/FluidTank") && call.name.equals("fill")) credit = index; + if (call.name.equals("subtractEnergy")) charge = index; + } + for (AbstractInsnNode instruction : pickup.instructions.toArray()) { + if (!(instruction instanceof MethodInsnNode)) continue; + MethodInsnNode call = (MethodInsnNode) instruction; + if (call.owner.equals("net/minecraftforge/fluids/IFluidBlock")) { + if (call.name.equals("canDrain")) permission = true; + if (call.name.equals("drain")) nativeDrain = true; + } + } + assertTrue("Use the Forge source's native permission and drain contract", permission && nativeDrain); + assertTrue("A successful pickup precedes the final tank credit", extraction >= 0 && credit > extraction); + assertTrue("A successful pickup precedes its steam charge", charge > extraction); + } + + @Test public void rejectedNeighborFillDoesNotDrainOneMillibucket() throws Exception { + boolean afterFill = false, guarded = false, drained = false; + for (AbstractInsnNode instruction : method(pump(), "powerUpdate").instructions.toArray()) { + if (instruction instanceof MethodInsnNode) { + MethodInsnNode call = (MethodInsnNode) instruction; + if (call.owner.equals("net/minecraftforge/fluids/IFluidHandler") + && call.name.equals("fill")) afterFill = true; + if (!afterFill) continue; + assertFalse("A refused fill must not become a one-millibucket loss", + call.owner.equals("java/lang/Math") && call.name.equals("max") && call.desc.equals("(II)I")); + if (call.owner.equals("net/minecraftforge/fluids/FluidTank") && call.name.equals("drain")) { + assertTrue("Reject nonpositive acceptance before draining", guarded); + drained = true; + break; + } + } else if (afterFill && instruction instanceof JumpInsnNode && instruction.getOpcode() == Opcodes.IFLE) guarded = true; + } + assertTrue("The direct-neighbor transfer path is covered", afterFill && drained); + } + + private static FluidStack pickup(IBlockState state) { + try { + Method method = SteamPumpTileEntity.class.getDeclaredMethod("pickupSource", World.class, BlockPos.class, IBlockState.class); + method.setAccessible(true); + // The fake Forge block owns all state and rejects any replacement through World. + return (FluidStack) method.invoke(null, null, SOURCE, state); + } catch (ReflectiveOperationException failure) { + throw new AssertionError("Pump pickup must honor native drain results", failure); + } + } + + private static ClassNode pump() throws Exception { + ClassNode node = new ClassNode(); + try (InputStream input = SteamOperationalPumpSourceTest.class.getResourceAsStream( + "/zone/moddev/mc/steamadvantage/machines/SteamPumpTileEntity.class")) { + assertNotNull(input); + new ClassReader(input).accept(node, 0); + } + return node; + } + + 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 DrainSource extends Block implements IFluidBlock { + private FluidStack stored; + private final boolean allowed; + private int permissionChecks, previews, realDrains; + + private DrainSource(int amount, boolean allowed) { + super(Material.WATER); + stored = new FluidStack(FluidRegistry.WATER, amount); + this.allowed = allowed; + } + + @Override public Fluid getFluid() { return FluidRegistry.WATER; } + @Override public FluidStack drain(World world, BlockPos pos, boolean real) { + assertNull("This unit source owns its state; no World is needed", world); + assertEquals(SOURCE, pos); + assertTrue("Drain permission must be checked", allowed); + assertNotNull("The source cannot be drained twice", stored); + FluidStack result = stored.copy(); + if (real) { + realDrains++; + stored = null; + } else previews++; + return result; + } + @Override public boolean canDrain(World world, BlockPos pos) { + permissionChecks++; + assertEquals(SOURCE, pos); + return allowed && stored != null; + } + @Override public float getFilledPercentage(World world, BlockPos pos) { + return stored == null ? 0 : stored.amount / 1000.0F; + } + } +} 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..920d363 --- /dev/null +++ b/src/test/java/zone/moddev/mc/steamadvantage/machines/SteamOperationalStillCapacityTest.java @@ -0,0 +1,85 @@ +package zone.moddev.mc.steamadvantage.machines; + +import net.minecraftforge.fluids.FluidStack; +import java.lang.reflect.Method; +import net.minecraftforge.fluids.FluidRegistry; +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 static org.junit.Assert.*; + +/** Pure capacity planning; native operating regressions live in SteamOperationalCases. */ +public class SteamOperationalStillCapacityTest { + @BeforeClass public static void bootstrap() { + net.minecraft.init.Bootstrap.register(); + } + + @Test public void fullOutputCannotPlanOrDebitAnyInput() throws Exception { + SteamStillTileEntity still = still(1000); + FluidStack input = new FluidStack(FluidRegistry.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(FluidRegistry.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(FluidRegistry.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(FluidRegistry.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(FluidRegistry.WATER, 10)); + FluidStack input = new FluidStack(FluidRegistry.WATER, 200); + assertEquals(0, plan(still, recipe(2, 1), input)); + assertEquals(200, input.amount); + assertSame(FluidRegistry.WATER, still.getOutputTank().getFluid().getFluid()); + assertEquals(10, still.getOutputTank().getFluidAmount()); + } + + @Test public void multiMillibucketProductMustFitAsAWhole() throws Exception { + FluidStack input = new FluidStack(FluidRegistry.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(FluidRegistry.LAVA, output)); + return still; + } + + private static DistillationRecipe recipe(int in, int out) { + return new DistillationRecipe(new ExplicitFluidReference(FluidRegistry.WATER), in, + new ExplicitFluidReference(FluidRegistry.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/SteamRedstoneInitializationTest.java b/src/test/java/zone/moddev/mc/steamadvantage/machines/SteamRedstoneInitializationTest.java new file mode 100644 index 0000000..15d7955 --- /dev/null +++ b/src/test/java/zone/moddev/mc/steamadvantage/machines/SteamRedstoneInitializationTest.java @@ -0,0 +1,105 @@ +package zone.moddev.mc.steamadvantage.machines; + +import static org.junit.Assert.*; + +import cyano.poweradvantage.api.simple.TileEntitySimplePowerMachine; +import java.lang.reflect.Field; +import java.util.*; +import net.minecraft.block.state.IBlockState; +import net.minecraft.init.Bootstrap; +import net.minecraft.profiler.Profiler; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.World; +import net.minecraft.world.WorldProviderSurface; +import net.minecraft.world.chunk.IChunkProvider; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +@RunWith(Parameterized.class) +public class SteamRedstoneInitializationTest { + @Parameterized.Parameters(name = "{0}") + public static Collection machines() { + return Arrays.asList(new Object[][]{ + {RockCrusherTileEntity.class}, {BlastFurnaceTileEntity.class} + }); + } + + private final Class type; + public SteamRedstoneInitializationTest(Class type) { this.type = type; } + @BeforeClass public static void bootstrap() { Bootstrap.register(); } + + @Test public void firstServerTickSamplesTheUnpoweredWorldBeforeUsingTheCache() throws Exception { + ControlWorld world = new ControlWorld(); + TileEntitySimplePowerMachine machine = machine(world); + machine.tickUpdate(true); + assertTrue("The first server tick must read the real control signal", world.samples > 0); + assertFalse("A fresh cache must not invent a redstone signal", redstone(machine)); + } + + @Test public void firstServerTickHonoursARealPoweredControl() throws Exception { + ControlWorld world = new ControlWorld(); + world.powered = true; + TileEntitySimplePowerMachine machine = machine(world); + machine.tickUpdate(true); + assertTrue("A default true value alone does not prove a world sample", world.samples > 0); + assertTrue(redstone(machine)); + } + + @Test public void ordinaryTicksDoNotReplaceTheScheduledControlPolling() throws Exception { + ControlWorld world = new ControlWorld(); + TileEntitySimplePowerMachine machine = machine(world); + machine.tickUpdate(true); + assertFalse(redstone(machine)); + world.powered = true; + machine.tickUpdate(true); + assertFalse("After initialization, the cache must wait for its scheduled power update", redstone(machine)); + } + + @Test public void theClientTickDoesNotInitializeAServerControlCache() throws Exception { + ControlWorld world = new ControlWorld(); + TileEntitySimplePowerMachine machine = machine(world); + machine.tickUpdate(false); + assertEquals(0, world.samples); + assertTrue(redstone(machine)); + } + + @Test public void loadingAnExistingInstanceMakesTheNextServerTickResample() throws Exception { + ControlWorld world = new ControlWorld(); + TileEntitySimplePowerMachine machine = machine(world); + machine.tickUpdate(true); + assertFalse(redstone(machine)); + machine.setWorld(null); + machine.onLoad(); + machine.setWorld(world); + world.powered = true; + world.samples = 0; + machine.tickUpdate(true); + assertTrue("A reused tile instance must not retain its old control decision", world.samples > 0); + assertTrue(redstone(machine)); + } + + private TileEntitySimplePowerMachine machine(ControlWorld world) throws Exception { + TileEntitySimplePowerMachine result = type.getConstructor().newInstance(); + result.setWorld(world); + result.setPos(new BlockPos(8, 80, 8)); + return result; + } + + private boolean redstone(TileEntitySimplePowerMachine machine) throws Exception { + Field field = type.getDeclaredField("redstone"); + field.setAccessible(true); + return field.getBoolean(machine); + } + + private static final class ControlWorld extends World { + boolean powered; + int samples; + ControlWorld() { super(null, null, new WorldProviderSurface(), new Profiler(), false); } + @Override public boolean isBlockPowered(BlockPos pos) { samples++; return powered; } + @Override public IBlockState getBlockState(BlockPos pos) { return net.minecraft.init.Blocks.AIR.getDefaultState(); } + @Override protected boolean isChunkLoaded(int x, int z, boolean allowEmpty) { return true; } + @Override protected IChunkProvider createChunkProvider() { throw new AssertionError("No chunks may be generated by this control test"); } + } +} 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..3c9ef7d --- /dev/null +++ b/src/test/java/zone/moddev/mc/steamadvantage/machines/WaterBoilerFluidBoundaryTest.java @@ -0,0 +1,160 @@ +package zone.moddev.mc.steamadvantage.machines; + +import static org.junit.Assert.*; +import java.lang.reflect.Method; +import java.util.*; +import net.minecraft.nbt.NBTTagCompound; +import net.minecraftforge.fluids.FluidStack; +import net.minecraftforge.fluids.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.init.Bootstrap.register(); + } + @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.minecraftforge.fluids.FluidRegistry.LAVA, 5); + NBTTagCompound original = lava.writeToNBT(new NBTTagCompound()).copy(); + assertEquals(Boolean.FALSE, type.getMethod("canFill", net.minecraft.util.EnumFacing.class, net.minecraftforge.fluids.Fluid.class).invoke(boiler, null, net.minecraftforge.fluids.FluidRegistry.LAVA)); + assertEquals(0, fill(lava, execute)); + assertEquals(0, tank.getFluidAmount()); + assertEquals(original, lava.writeToNBT(new NBTTagCompound())); + } + } + + @Test public void emptyTankStillAcceptsWaterWithoutChangingTheOffer() throws Exception { + FluidStack offered = new FluidStack(net.minecraftforge.fluids.FluidRegistry.WATER, 5); + NBTTagCompound original = offered.writeToNBT(new NBTTagCompound()).copy(); + assertEquals(Boolean.TRUE, type.getMethod("canFill", net.minecraft.util.EnumFacing.class, net.minecraftforge.fluids.Fluid.class).invoke(boiler, null, net.minecraftforge.fluids.FluidRegistry.WATER)); + assertEquals(5, fill(offered, true)); + assertEquals(5, tank.getFluidAmount()); + assertSame(net.minecraftforge.fluids.FluidRegistry.WATER, tank.getFluid().getFluid()); + assertEquals(original, offered.writeToNBT(new NBTTagCompound())); + } + + @Test public void seededTankRejectsMismatchedFillAndDrain() throws Exception { + stock(100); + FluidStack lava = new FluidStack(net.minecraftforge.fluids.FluidRegistry.LAVA, 5); + NBTTagCompound original = lava.writeToNBT(new NBTTagCompound()).copy(); + for (boolean execute : new boolean[]{false, true}) { + assertEquals(0, fill(lava, execute)); + assertNull(drain(lava, execute)); + } + assertEquals(100, tank.getFluidAmount()); + assertSame(net.minecraftforge.fluids.FluidRegistry.WATER, tank.getFluid().getFluid()); + assertEquals(original, lava.writeToNBT(new NBTTagCompound())); + } + + @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.minecraftforge.fluids.FluidRegistry.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.minecraftforge.fluids.FluidRegistry.WATER, 5), false)); + assertNull(drain(new FluidStack(net.minecraftforge.fluids.FluidRegistry.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.minecraftforge.fluids.FluidRegistry.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.minecraftforge.fluids.FluidRegistry.WATER, 5); + NBTTagCompound original = request.writeToNBT(new NBTTagCompound()).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 NBTTagCompound())); + } + + @Test public void aRealMatchingDrainReturnsOnlyTheRequestedWater() throws Exception { + stock(100); + FluidStack request = new FluidStack(net.minecraftforge.fluids.FluidRegistry.WATER, 5); + FluidStack result = drain(request, true); + assertNotNull(result); + assertSame(net.minecraftforge.fluids.FluidRegistry.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.minecraftforge.fluids.FluidRegistry.WATER, amount)); } + private int fill(FluidStack offered, boolean execute) throws Exception { + Method method = type.getMethod("fill", net.minecraft.util.EnumFacing.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.EnumFacing.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.EnumFacing.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..0a24173 --- /dev/null +++ b/src/test/java/zone/moddev/mc/steamadvantage/machines/WaterBoilerTankNotificationTest.java @@ -0,0 +1,152 @@ +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.NBTTagCompound; +import net.minecraft.world.World; +import net.minecraftforge.fluids.FluidStack; +import net.minecraftforge.fluids.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.init.Bootstrap.register(); + } + @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.minecraftforge.fluids.FluidRegistry.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.getInteger("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.getInteger("Amount")); + } + + private static FluidStack water(int amount) { return new FluidStack(net.minecraftforge.fluids.FluidRegistry.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.getInteger("Amount")); + } + private void unchanged(int amount) { + assertEquals(0, probe.save().dirtyCalls); + assertEquals(amount, probe.tank().getFluidAmount()); + assertEquals(amount, probe.save().savedTank.getInteger("Amount")); + } + private int fill(FluidStack offer, boolean execute) throws Exception { + return ((Number) probe.machine().getClass().getMethod("fill", net.minecraft.util.EnumFacing.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.EnumFacing.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; + NBTTagCompound savedTank; + void remember(FluidTank tank) { dirtyCalls = 0; savedTank = tank.writeToNBT(new NBTTagCompound()).copy(); } + void changed(FluidTank tank) { dirtyCalls++; savedTank = tank.writeToNBT(new NBTTagCompound()).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) { return new SaveWorld(client); } + private static final class SaveWorld extends World { + SaveWorld(boolean client) { super(null, null, new net.minecraft.world.WorldProviderSurface(), new net.minecraft.profiler.Profiler(), client); } + @Override protected net.minecraft.world.chunk.IChunkProvider createChunkProvider() { throw new AssertionError("No world ticks in notification tests"); } + @Override protected boolean isChunkLoaded(int x, int z, boolean empty) { return true; } + } +} 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..dafac78 --- /dev/null +++ b/src/test/java/zone/moddev/mc/steamadvantage/util/SoundHelperLoadedPlayersTest.java @@ -0,0 +1,134 @@ +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 FieldInsnNode) { + FieldInsnNode field = (FieldInsnNode) instruction; + if (field.getOpcode() == Opcodes.GETFIELD && field.owner.equals("net/minecraft/world/World") + && field.name.equals("playerEntities")) loadedPlayerIterations++; + } + 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")); + } + } + 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/EntitySelectors") + && field.name.equals("NOT_SPECTATING")) predicate = method.instructions.indexOf(instruction); + } else if (instruction instanceof MethodInsnNode) { + MethodInsnNode call = (MethodInsnNode) instruction; + if (call.owner.equals("com/google/common/base/Predicate") && call.name.equals("apply")) { + 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/SPacketCustomSound") && 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.intersectsWith(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.intersectsWith(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.intersectsWith(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.intersectsWith(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("getEntityBoundingBox")) bounds++; + if (call.owner.equals("net/minecraft/util/math/AxisAlignedBB") && call.name.equals("intersectsWith") + && call.desc.equals("(Lnet/minecraft/util/math/AxisAlignedBB;)Z")) intersections++; + if (call.owner.equals("net/minecraft/network/play/server/SPacketCustomSound") && 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/EntityPlayerMP")) serverPlayer = true; + } else if (instruction instanceof FieldInsnNode) { + FieldInsnNode field = (FieldInsnNode) instruction; + if (field.getOpcode() == Opcodes.GETFIELD && field.owner.equals("net/minecraft/world/World") + && field.name.equals("playerEntities")) playerLists++; + 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; + } +}