diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 237ce55..2a6e840 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,18 @@ +Unreleased - Minecraft 1.13.2 + +- Use a server-safe collision box when tracing musket shots on dedicated servers. + +- Keep waterlogged blocks intact when the steam pump collects their water, and only charge for successful pickup. +- Keep fluid in the steam pump when an attached tank refuses it. +- Pause the steam still when its output cannot hold a complete batch, without wasting input or processing steam. +- Let a loaded musket fire its last cartridge without needing spare ammunition in the inventory. +- Stop musket shots at terrain and select nearby targets without loading extra server chunks. +- Send machine sounds to connected nearby players without scanning unloaded chunks; keep the existing sound ranges and spectator exclusion. +- Read live redstone before the crusher and furnace resume after loading, preserving saved work and the first-tick steam heat bonus without changing the normal polling interval. +- Save real coal-boiler tank changes immediately, leave simulations unchanged, and return an empty result for null, empty or mismatched drain requests. +- Honour the water-only input of coal, electric and geothermal boilers even when empty, and safely refuse null, empty or mismatched fluid requests. +- Save accepted external tank changes in electric and geothermal boilers immediately, with the same simulation and client-side safeguards as the coal boiler. + Steam Advantage 2.2.5.113021 - Keep development archives identical in existing workspaces and fresh CI checkouts. diff --git a/README.md b/README.md index 1d6190c..a7c8b6f 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,39 @@ The Eclipse resource root is `build/resources/main`, so its builder copies expan ## Compatibility +The [stronger regression-test backport](https://github.com/MinecraftModDevelopmentMods/PowerAdvantage/blob/master-1.13.2/docs/1.13/TEST-COVERAGE-BACKPORT.md) +records measured machine work, restart checks, repaired windows and remaining limits. + For an all-Advantage play-test, run `prepareCombinedEclipseLaunch` in the sibling PowerAdvantage checkout and select its `runClientAllAdvantage.launch`. The [native resource probe](https://github.com/MinecraftModDevelopmentMods/PowerAdvantage/blob/master-1.13.2/test-rig/client-resources/README.md) checks models, textures and names across all three mods. These resource checks do not replace gameplay testing. The implementation namespace is `zone.moddev.mc.steamadvantage`. Runtime identities, registries, tile IDs, saved NBT, recipes, and assets remain stable. Steam registers its legacy state and NBT codecs with PowerAdvantage's shared migration hook so 1.10 and 1.12 worlds can be converted before Minecraft flattens old numeric block states. Read PowerAdvantage's [1.13 migration guide](https://github.com/MinecraftModDevelopmentMods/PowerAdvantage/blob/master-1.13.2/docs/1.13/MIGRATION.md) before upgrading, and always use a backup. The [qualification notes](https://github.com/MinecraftModDevelopmentMods/PowerAdvantage/blob/master-1.13.2/docs/1.13/CURRENT-QUALIFICATION.md) list the tested scenarios and remaining gaps. + +## Generator Follow-Up + +The [generator follow-up](../../PowerAdvantage/docs/1.13/TEST-COVERAGE-BACKPORT.md#generator-follow-up) records the older `pa113-candidate-g` inputs. +Its 126 operating jobs and 126 cold checks, 39 GUI windows, 351 caption cases, +355 rejected broken controls, eight frozen routes, five connected routes and +paired 30-minute runs passed. This is a pre-final Steam-control checkpoint, +not qualification of the newer SteamAdvantage jars or harness. Those still need +the final sweep. Manual play-testing and release review remain separate. + +## Final T Candidate + +The [final-t checkpoint](../../PowerAdvantage/docs/1.13/TEST-COVERAGE-BACKPORT.md#final-t-candidate) +records profile `pa113-candidate-i` and its exact jar hashes. All 132 operating +jobs and 132 cold-restart comparisons pass, including an independent audit of +six Steam controls and their nine tile-hold records. GUI checks pass for 39 +windows, 351 caption cases and 355 rejected broken controls. +Frozen migration passed on 8 routes and connected-source operation passed on +all five declared routes. +Fresh and migrated-copy 30-minute runs pass. Power and Electric retain their +exact-hash release and reproducibility proof; final-t Steam passes its full suite +and two independent export builds. All 18 mod/version release and reproducibility +gates pass across the six maintained Minecraft lines. Both requested broad +profiles and their independent audit pass; the reporter-only unit/replay +qualification preserves the original native proof. Survival harvest checks pass +before and after restart. The report's extra network/drill scopes are now +qualified; manual acceptance remains separate. Earlier checkpoints keep their original scope; +nothing has been published by this local test work. diff --git a/src/main/java/zone/moddev/mc/steamadvantage/gui/GUIHelper.java b/src/main/java/zone/moddev/mc/steamadvantage/gui/GUIHelper.java index 697f3d3..29aa8d5 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 34b2780..ffcef37 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; @@ -34,6 +35,7 @@ import net.minecraft.util.ActionResult; import net.minecraft.util.EnumActionResult; import net.minecraft.util.EnumHand; +import net.minecraft.util.EntitySelectors; import net.minecraft.particles.IParticleData; import net.minecraft.util.ResourceLocation; import net.minecraft.util.SoundCategory; @@ -110,7 +112,7 @@ public EnumAction getUseAction(ItemStack stack){ public ActionResult onItemRightClick(World world, EntityPlayer playerEntity, EnumHand hand){ ItemStack srcItemStack = playerEntity.getHeldItem(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); @@ -361,37 +363,42 @@ 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, RayTraceFluidMode.SOURCE_ONLY, false, false); + Vec3d rayEnd = terrainHit == null ? rangeEnd : terrainHit.hitVec; + AxisAlignedBB aoi = new AxisAlignedBB(rayOrigin.x, rayOrigin.y, rayOrigin.z, + rayEnd.x, rayEnd.y, rayEnd.z).grow(1.0D); + double closestDistSqr = rayOrigin.squareDistanceTo(rayEnd); Entity closestEntity = null; - for(Entity e : entities){ - double distSqr = e.getDistanceSq(source.posX, source.posY, source.posZ); - if(distSqr < rangeSqr){ - // e is within range - AxisAlignedBB box = e.getBoundingBox(); - if(rayIntersectsBoundingBox(rayOrigin,rayDirection, box)){ - // e is in cross-hairs - if(distSqr < closestDistSqr){ - // e is closest entity in line of fire - closestDistSqr = distSqr; - closestEntity = e; - } + Vec3d closestHit = null; + // A spatial entity query can load chunks; targeting only needs already loaded entities. + for(Entity e : w.loadedEntityList){ + if(e == source || e.removed || !EntitySelectors.NOT_SPECTATING.test(e) + || !aoi.intersects(e.getBoundingBox())) continue; + Optional hit = rayHitBeforeTerrain(rayOrigin, rayEnd, e.getBoundingBox()); + if(hit.isPresent()){ + double distSqr = rayOrigin.squareDistanceTo(hit.get()); + if(distSqr < closestDistSqr){ + closestDistSqr = distSqr; + closestEntity = e; + closestHit = hit.get(); } } } - if(closestEntity == null) { - return w.rayTraceBlocks(rayOrigin, rayOrigin.add(mul(rayDirection, maxRange)), RayTraceFluidMode.SOURCE_ONLY, false, false); + return closestEntity == null ? terrainHit : new RayTraceResult(closestEntity, closestHit); + } + + static Optional rayHitBeforeTerrain(Vec3d origin, Vec3d terrainEnd, AxisAlignedBB target){ + Vec3d point; + if(target.contains(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){ @@ -424,9 +431,6 @@ public static boolean rayIntersectsBoundingBox(Vec3d rayOrigin, Vec3d rayDirecti } - private static Vec3d mul(Vec3d a, double b){ - return new Vec3d(a.x * b, a.y * b, a.z * b); - } private static double max(double a, double b){ return Math.max(a, b); } diff --git a/src/main/java/zone/moddev/mc/steamadvantage/machines/BlastFurnaceTileEntity.java b/src/main/java/zone/moddev/mc/steamadvantage/machines/BlastFurnaceTileEntity.java index df77562..bde70cc 100644 --- a/src/main/java/zone/moddev/mc/steamadvantage/machines/BlastFurnaceTileEntity.java +++ b/src/main/java/zone/moddev/mc/steamadvantage/machines/BlastFurnaceTileEntity.java @@ -38,6 +38,13 @@ public class BlastFurnaceTileEntity extends cyano.poweradvantage.api.simple.Tile private static final float minSmeltingTemperature = 100f; private boolean redstone = true; + private boolean redstoneInitialized = false; + + @Override + public void onLoad() { + super.onLoad(); + redstoneInitialized = false; + } public BlastFurnaceTileEntity() { super(zone.moddev.mc.steamadvantage.init.Entities.STEAM_FURNACE, Power.steam_power, 200, "tile.steamadvantage.steam_furnace.name"); @@ -66,6 +73,10 @@ public float getTemperatureLevel(){ @Override public void tickUpdate(boolean isServerWorld) { if(isServerWorld){ + if (!redstoneInitialized && getWorld() != null) { + redstone = hasRedstoneSignal(); + redstoneInitialized = true; + } // fual input heat float e = 0; if(burnTime > 0){ @@ -172,6 +183,7 @@ public void powerUpdate(){ this.sync(); } redstone = hasRedstoneSignal(); + redstoneInitialized = true; } private boolean hasRedstoneSignal() { diff --git a/src/main/java/zone/moddev/mc/steamadvantage/machines/CoalBoilerTileEntity.java b/src/main/java/zone/moddev/mc/steamadvantage/machines/CoalBoilerTileEntity.java index f94468a..7a0465b 100644 --- a/src/main/java/zone/moddev/mc/steamadvantage/machines/CoalBoilerTileEntity.java +++ b/src/main/java/zone/moddev/mc/steamadvantage/machines/CoalBoilerTileEntity.java @@ -39,7 +39,15 @@ public class CoalBoilerTileEntity extends cyano.poweradvantage.api.simple.TileEn public CoalBoilerTileEntity() { super(zone.moddev.mc.steamadvantage.init.Entities.STEAM_BOILER_COAL, new ConduitType[]{Power.steam_power,Fluids.fluidConduit_general}, new float[]{1000,1000}, "tile.steamadvantage.steam_boiler_coal.name"); - tank = new FluidTank(FluidTank.BUCKET_VOLUME * 4); + tank = new FluidTank(FluidTank.BUCKET_VOLUME * 4) { + @Override + protected void onContentsChanged() { + if (CoalBoilerTileEntity.this.getWorld() != null + && !CoalBoilerTileEntity.this.getWorld().isRemote) { + CoalBoilerTileEntity.this.markDirty(); + } + } + }; inventory = new ItemStack[1]; } @@ -349,6 +357,8 @@ && getTank().getFluidAmount() > 0 */ @Override public int fill(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 { @@ -363,10 +373,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; } } /** @@ -377,6 +388,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 cfbfb33..3a1c28e 100644 --- a/src/main/java/zone/moddev/mc/steamadvantage/machines/ElectricBoilerTileEntity.java +++ b/src/main/java/zone/moddev/mc/steamadvantage/machines/ElectricBoilerTileEntity.java @@ -40,7 +40,15 @@ public ElectricBoilerTileEntity() { super(zone.moddev.mc.steamadvantage.init.Entities.STEAM_BOILER_ELECTRIC, new ConduitType[]{Power.steam_power, ELECTRIC_POWER, Fluids.fluidConduit_general}, new float[]{100, MAX_ELECTRICITY, 1000}, "tile.steamadvantage.steam_boiler_electric.name"); - tank = new FluidTank(FluidTank.BUCKET_VOLUME * 2); + tank = new FluidTank(FluidTank.BUCKET_VOLUME * 2) { + @Override + protected void onContentsChanged() { + if (ElectricBoilerTileEntity.this.getWorld() != null + && !ElectricBoilerTileEntity.this.getWorld().isRemote) { + ElectricBoilerTileEntity.this.markDirty(); + } + } + }; inventory = new ItemStack[0]; } @@ -301,6 +309,8 @@ public PowerRequest getPowerRequest(ConduitType offer) { */ @Override public int fill(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 { @@ -315,10 +325,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; } } /** @@ -329,6 +340,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 399b7f7..9d16ba8 100644 --- a/src/main/java/zone/moddev/mc/steamadvantage/machines/GeothermalBoilerTileEntity.java +++ b/src/main/java/zone/moddev/mc/steamadvantage/machines/GeothermalBoilerTileEntity.java @@ -43,7 +43,15 @@ public class GeothermalBoilerTileEntity extends cyano.poweradvantage.api.simple. public GeothermalBoilerTileEntity() { super(zone.moddev.mc.steamadvantage.init.Entities.STEAM_BOILER_GEOTHERMAL, new ConduitType[]{Power.steam_power,Fluids.fluidConduit_general}, new float[]{100,1000}, "tile.steamadvantage.steam_boiler_geothermal.name"); - tank = new FluidTank(FluidTank.BUCKET_VOLUME * 2); + tank = new FluidTank(FluidTank.BUCKET_VOLUME * 2) { + @Override + protected void onContentsChanged() { + if (GeothermalBoilerTileEntity.this.getWorld() != null + && !GeothermalBoilerTileEntity.this.getWorld().isRemote) { + GeothermalBoilerTileEntity.this.markDirty(); + } + } + }; inventory = new ItemStack[0]; } @@ -337,6 +345,8 @@ public PowerRequest getPowerRequest(ConduitType offer) { */ @Override public int fill(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 { @@ -351,10 +361,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; } } /** @@ -365,6 +376,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 13f4598..9950c86 100644 --- a/src/main/java/zone/moddev/mc/steamadvantage/machines/RockCrusherTileEntity.java +++ b/src/main/java/zone/moddev/mc/steamadvantage/machines/RockCrusherTileEntity.java @@ -33,6 +33,13 @@ public RockCrusherTileEntity() { } private boolean redstone = true; + private boolean redstoneInitialized = false; + + @Override + public void onLoad() { + super.onLoad(); + redstoneInitialized = false; + } private int timeSinceLastSteamBurst = 0; @@ -41,6 +48,10 @@ public RockCrusherTileEntity() { @Override public void tickUpdate(boolean isServerWorld) { if(isServerWorld){ + if (!redstoneInitialized && getWorld() != null) { + redstone = hasRedstoneSignal(); + redstoneInitialized = true; + } // reset progress when item in slot changes ItemStack input = inventory[0]; if(itemCheck != null && (input == null || input.isEmpty() || ItemStack.areItemsEqual(itemCheck, input) == false)){ @@ -113,6 +124,7 @@ public void powerUpdate(){ super.powerUpdate(); redstone = hasRedstoneSignal(); + redstoneInitialized = true; if(oldSteam != this.getEnergy(Power.steam_power) || progress != oldProgress){ this.sync(); diff --git a/src/main/java/zone/moddev/mc/steamadvantage/machines/SteamPumpTileEntity.java b/src/main/java/zone/moddev/mc/steamadvantage/machines/SteamPumpTileEntity.java index 448f3f1..c311bfb 100644 --- a/src/main/java/zone/moddev/mc/steamadvantage/machines/SteamPumpTileEntity.java +++ b/src/main/java/zone/moddev/mc/steamadvantage/machines/SteamPumpTileEntity.java @@ -11,8 +11,8 @@ import zone.moddev.mc.poweradvantage.util.FluidHandlerHelper; import zone.moddev.mc.steamadvantage.init.Power; import net.minecraft.block.state.IBlockState; +import net.minecraft.block.IBucketPickupHandler; import net.minecraft.entity.player.EntityPlayer; -import net.minecraft.init.Blocks; import net.minecraft.init.SoundEvents; import net.minecraft.item.ItemStack; import net.minecraft.nbt.NBTTagCompound; @@ -21,6 +21,7 @@ import net.minecraft.util.SoundCategory; import net.minecraft.util.math.BlockPos; import net.minecraft.world.World; +import net.minecraft.world.IWorld; import net.minecraft.fluid.Fluid; import cyano.poweradvantage.api.fluid.FluidStack; import cyano.poweradvantage.api.fluid.FluidTank; @@ -89,11 +90,14 @@ public void tickUpdate(boolean isServerWorld) { // Found a fluid to suck-up! IBlockState blockstate = w.getBlockState(fluidSource); Fluid f = getFluid(blockstate); - if(f != null && getEnergy(Power.steam_power) >= cost){ - this.getTank().fill(new FluidStack(f,FluidTank.BUCKET_VOLUME), true); - this.subtractEnergy(cost, Power.steam_power); - w.removeBlock(fluidSource); - success = true; + if(f != null && getEnergy(Power.steam_power) >= cost + && getTank().fill(new FluidStack(f,FluidTank.BUCKET_VOLUME), false) == FluidTank.BUCKET_VOLUME){ + Fluid collected = pickupSource(w, fluidSource, blockstate); + if(collected != net.minecraft.init.Fluids.EMPTY){ + this.getTank().fill(new FluidStack(collected,FluidTank.BUCKET_VOLUME), true); + this.subtractEnergy(cost, Power.steam_power); + success = true; + } } } timeUntilNextPump = PUMP_INTERVAL; @@ -120,6 +124,15 @@ private Fluid getFluid(IBlockState blockstate) { return blockstate.getFluidState().isEmpty() ? null : blockstate.getFluidState().getFluid(); } + static Fluid pickupSource(IWorld world, BlockPos pos, IBlockState state) { + if(!state.getFluidState().isSource() || !(state.getBlock() instanceof IBucketPickupHandler)){ + return net.minecraft.init.Fluids.EMPTY; + } + // Native pickup removes the fluid without destroying a waterlogged host block. + Fluid collected = ((IBucketPickupHandler)state.getBlock()).pickupFluid(world, pos, state); + return collected == null ? net.minecraft.init.Fluids.EMPTY : collected; + } + private BlockPos scan(Set searchSpace, BlockPos coord, int limit) { @@ -148,7 +161,8 @@ private BlockPos scan(Set searchSpace, BlockPos coord, int limit) { } private boolean isSourceBlock( BlockPos coord){ - return getWorld().getFluidState(coord).isSource(); + IBlockState state = getWorld().getBlockState(coord); + return state.getFluidState().isSource() && state.getBlock() instanceof IBucketPickupHandler; } @@ -197,7 +211,7 @@ public void powerUpdate(){ IAdvantageFluidHandler handler = FluidHandlerHelper.getHandler(neighbor, dir.getOpposite()); if(handler != null){ int d = handler.fill(dir.getOpposite(), fluid, true); - getTank().drain(Math.max(d,1),true); // no free energy! + if(d > 0) getTank().drain(d,true); fluid = getTank().getFluid(); } } diff --git a/src/main/java/zone/moddev/mc/steamadvantage/machines/SteamStillTileEntity.java b/src/main/java/zone/moddev/mc/steamadvantage/machines/SteamStillTileEntity.java index cbb4702..54f8a54 100644 --- a/src/main/java/zone/moddev/mc/steamadvantage/machines/SteamStillTileEntity.java +++ b/src/main/java/zone/moddev/mc/steamadvantage/machines/SteamStillTileEntity.java @@ -53,8 +53,7 @@ public void tickUpdate(boolean isServerWorld) { if(!redstone){ if(getInputTank().getFluidAmount() > 0 && getEnergy(Power.steam_power) > steamPerDistill - && canDistill(getInputTank().getFluid())){ - distill(); + && canDistill(getInputTank().getFluid()) && distill()){ this.subtractEnergy(steamPerDistill, Power.steam_power); if(timeSinceSound > 200){ playSoundAtTileEntity(SoundEvents.BLOCK_LAVA_AMBIENT, SoundCategory.AMBIENT, 0.3f, 1.5f, this); @@ -294,20 +293,34 @@ private boolean canDistill(Fluid f) { } private boolean canDistill(FluidStack fs) { + if(fs == null) return false; DistillationRecipe recipe = DistillationRecipeRegistry.getInstance().getDistillationRecipeForFluid(fs.getFluid()); if(recipe == null) return false; - if(this.getOutputTank().getFluidAmount() > 0){ - return recipe.isValidInput(fs) && recipe.isValidOutput(getOutputTank().getFluid()); - } else { - return recipe.isValidInput(fs); + return recipe.isValidInput(fs) && (outputTank.getFluidAmount() == 0 + || recipe.isValidOutput(outputTank.getFluid())); + } + + private int distillationApplications(DistillationRecipe recipe, FluidStack input) { + if(!recipe.isValidInput(input)) return 0; + if(outputTank.getFluidAmount() > 0 && !recipe.isValidOutput(outputTank.getFluid())) return 0; + // Preview on a copy so blocked output cannot consume input or lose part of a batch. + for(int applications = speed; applications > 0; applications--){ + FluidStack output = recipe.applyRecipe(input.copy(), applications); + if(output != null && outputTank.fill(output, false) == output.amount) return applications; } + return 0; } - private void distill(){ + private boolean distill(){ DistillationRecipe recipe = DistillationRecipeRegistry.getInstance() .getDistillationRecipeForFluid(getInputTank().getFluid().getFluid()); - FluidStack output = recipe.applyRecipe(inputTank.getFluid(), speed); - getOutputTank().fill(output, true); + int applications = distillationApplications(recipe, inputTank.getFluid()); + if(applications > 0){ + FluidStack output = recipe.applyRecipe(inputTank.getFluid(), applications); + getOutputTank().fill(output, true); + return true; + } + return false; } diff --git a/src/main/java/zone/moddev/mc/steamadvantage/util/SoundHelper.java b/src/main/java/zone/moddev/mc/steamadvantage/util/SoundHelper.java index 4a1c4d1..1601717 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(), soundType, + x + range, y + range, z + range); + for(EntityPlayer candidate : serverWorld.playerEntities){ + if(!(candidate instanceof EntityPlayerMP) || !EntitySelectors.NOT_SPECTATING.test(candidate) + || !area.intersects(candidate.getBoundingBox())) continue; + EntityPlayerMP player = (EntityPlayerMP) candidate; + if(player.connection != null) player.connection.sendPacket(new SPacketCustomSound(sound.getRegistryName(), soundType, new Vec3d(x, y, z), volume, pitch)); } } @@ -35,11 +38,14 @@ public static void playSoundAtPosition(double x, double y, double z, SoundEvent public static void playBigSoundAtPosition(double x, double y, double z, SoundEvent sound, SoundCategory soundType, float volume, float pitch, World serverWorld){ if(serverWorld.isRemote) return; final double range = 50; - List players = serverWorld.getEntitiesWithinAABB(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(), soundType, + x + range, y + range, z + range); + for(EntityPlayer candidate : serverWorld.playerEntities){ + if(!(candidate instanceof EntityPlayerMP) || !EntitySelectors.NOT_SPECTATING.test(candidate) + || !area.intersects(candidate.getBoundingBox())) continue; + EntityPlayerMP player = (EntityPlayerMP) candidate; + if(player.connection != null) player.connection.sendPacket(new SPacketCustomSound(sound.getRegistryName(), soundType, new Vec3d(x, y, z), volume, pitch)); } } diff --git a/src/test/java/zone/moddev/mc/steamadvantage/gui/GUIHelperTest.java b/src/test/java/zone/moddev/mc/steamadvantage/gui/GUIHelperTest.java new file mode 100644 index 0000000..6e4be43 --- /dev/null +++ b/src/test/java/zone/moddev/mc/steamadvantage/gui/GUIHelperTest.java @@ -0,0 +1,29 @@ +package zone.moddev.mc.steamadvantage.gui; + +import java.lang.reflect.Method; +import org.junit.Test; +import static org.junit.Assert.*; + +public class GUIHelperTest { + @Test public void readingsKeepTheirScaleButStayInsideTheGauge() throws Exception { + assertEquals(0F, displayLevel(-1F), 0F); + assertEquals(0F, displayLevel(0F), 0F); + assertEquals(0.5F, displayLevel(0.5F), 0F); + assertEquals(1F, displayLevel(1F), 0F); + assertEquals(1F, displayLevel(15F), 0F); + } + + @Test public void nonfiniteReadingsAreSafeToDraw() throws Exception { + for (float value : new float[]{Float.NaN, Float.POSITIVE_INFINITY, Float.NEGATIVE_INFINITY}) { + assertEquals(0F, displayLevel(value), 0F); + } + } + + private static float displayLevel(float value) throws Exception { + Method method; + try { method = GUIHelper.class.getDeclaredMethod("displayLevel", float.class); } + catch (NoSuchMethodException missing) { throw new AssertionError("Steam gauge readings need one finite, bounded display scale", missing); } + method.setAccessible(true); + return (Float) method.invoke(null, value); + } +} diff --git a/src/test/java/zone/moddev/mc/steamadvantage/items/MusketLoadedCartridgeTest.java b/src/test/java/zone/moddev/mc/steamadvantage/items/MusketLoadedCartridgeTest.java new file mode 100644 index 0000000..18fd668 --- /dev/null +++ b/src/test/java/zone/moddev/mc/steamadvantage/items/MusketLoadedCartridgeTest.java @@ -0,0 +1,130 @@ +package zone.moddev.mc.steamadvantage.items; + +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import net.minecraft.item.ItemStack; +import net.minecraft.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 compiled use-method guard, without inventing a world or a player. */ +public class MusketLoadedCartridgeTest { + @BeforeClass public static void bootstrap() { + zone.moddev.mc.steamadvantage.support.SteamNativeTestBootstrap.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(), new Object(), new Object(), hand, null}; + List operands = new ArrayList<>(); + int steps = 0; + for (AbstractInsnNode instruction = use.instructions.getFirst(); instruction != null;) { + assertTrue("The finite use guard must terminate", steps++ < 150); + AbstractInsnNode next = instruction.getNext(); + int opcode = instruction.getOpcode(); + if (opcode < 0) { + instruction = next; + continue; + } + if (instruction instanceof VarInsnNode) { + int variable = ((VarInsnNode) instruction).var; + if (opcode == Opcodes.ALOAD) operands.add(locals[variable]); + else if (opcode == Opcodes.ASTORE) locals[variable] = pop(operands); + else fail("Unsupported guard variable opcode " + opcode); + } else if (instruction instanceof FieldInsnNode) { + FieldInsnNode field = (FieldInsnNode) instruction; + assertEquals(Opcodes.GETSTATIC, opcode); + if (field.owner.equals("net/minecraft/util/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("getHeldItem")) { + assertSame(hand, pop(operands)); + assertSame(locals[2], pop(operands)); + operands.add(stack); + } else if (call.name.equals("isNotLoaded")) { + assertEquals("zone/moddev/mc/steamadvantage/items/MusketItem", call.owner); + operands.add(MusketItem.isNotLoaded((ItemStack) pop(operands))); + } else if (call.name.equals("hasAmmo")) { + assertSame(stack, pop(operands)); + assertSame(locals[2], pop(operands)); + operands.add(hasAmmo); + } else if (call.name.equals("newResult")) { + assertEquals("net/minecraft/util/ActionResult", call.owner); + assertSame(stack, pop(operands)); + return ((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..42c3971 --- /dev/null +++ b/src/test/java/zone/moddev/mc/steamadvantage/items/MusketTargetingContractTest.java @@ -0,0 +1,110 @@ +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() { + zone.moddev.mc.steamadvantage.support.SteamNativeTestBootstrap.register(); + } + + @Test public void nearerTerrainRejectsTargetsBehindItOnEveryAxis() { + Vec3d origin = Vec3d.ZERO; + for (EnumFacing direction : EnumFacing.values()) { + Vec3d ray = new Vec3d(direction.getXOffset(), direction.getYOffset(), direction.getZOffset()); + assertFalse(direction.toString(), hit(origin, ray.scale(3), around(ray.scale(5))).isPresent()); + Optional hit = hit(origin, ray.scale(8), around(ray.scale(5))); + assertTrue(direction.toString(), hit.isPresent()); + assertEquals(4.5 * 4.5, origin.squareDistanceTo(hit.get()), 0.000001); + } + } + + @Test public void terrainWinsAnExactTieWithTheEntitySurface() { + assertFalse(hit(Vec3d.ZERO, new Vec3d(0, 0, 4.5), + around(new Vec3d(0, 0, 5))).isPresent()); + } + + @Test public void finiteSegmentRejectsOffRayBehindAndBeyondRangeTargets() { + Vec3d end = new Vec3d(0, 0, 64); + assertFalse(hit(Vec3d.ZERO, end, around(new Vec3d(2, 0, 5))).isPresent()); + assertFalse(hit(Vec3d.ZERO, end, around(new Vec3d(0, 0, -5))).isPresent()); + assertFalse(hit(Vec3d.ZERO, end, around(new Vec3d(0, 0, 65))).isPresent()); + } + + @Test public void overlappingEntityStartsAtZeroDistance() { + Optional hit = hit(Vec3d.ZERO, new Vec3d(0, 0, 8), around(Vec3d.ZERO)); + assertTrue(hit.isPresent()); + assertEquals(Vec3d.ZERO, hit.get()); + } + + @Test public void targetingUsesLoadedServerEntitiesAndTerrainBeforeEntitySelection() throws Exception { + ClassNode node = new ClassNode(); + try (InputStream input = getClass().getResourceAsStream("/zone/moddev/mc/steamadvantage/items/MusketItem.class")) { + assertNotNull(input); + new ClassReader(input).accept(node, 0); + } + MethodNode trace = node.methods.stream().filter(method -> method.name.equals("rayTraceBlocksAndEntities")) + .findFirst().orElseThrow(() -> new AssertionError("Missing targeting method")); + boolean terrain = false, loadedEntities = false, boundedSelection = false; + 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("java/util/function/Predicate") && call.name.equals("test")) { + 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 AxisAlignedBB around(Vec3d center) { + return new AxisAlignedBB(center.x - 0.5, center.y - 0.5, center.z - 0.5, + center.x + 0.5, center.y + 0.5, center.z + 0.5); + } +} diff --git a/src/test/java/zone/moddev/mc/steamadvantage/machines/CoalBoilerFillPersistenceTest.java b/src/test/java/zone/moddev/mc/steamadvantage/machines/CoalBoilerFillPersistenceTest.java new file mode 100644 index 0000000..e4bfc12 --- /dev/null +++ b/src/test/java/zone/moddev/mc/steamadvantage/machines/CoalBoilerFillPersistenceTest.java @@ -0,0 +1,219 @@ +package zone.moddev.mc.steamadvantage.machines; + +import static org.junit.Assert.*; + +import cyano.poweradvantage.api.ConduitType; +import zone.moddev.mc.poweradvantage.init.Fluids; +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.world.World; +import cyano.poweradvantage.api.fluid.FluidStack; +import org.junit.BeforeClass; +import org.junit.Test; + +/** Save-notification unit witness; actual disk persistence is checked by the native restart rig. */ +public class CoalBoilerFillPersistenceTest { + @BeforeClass public static void bootstrap() { + zone.moddev.mc.steamadvantage.support.SteamNativeTestBootstrap.register(); + if (zone.moddev.mc.steamadvantage.init.Entities.STEAM_BOILER_COAL == null) + zone.moddev.mc.steamadvantage.init.Entities.STEAM_BOILER_COAL = net.minecraft.tileentity.TileEntityType.Builder.create(CoalBoilerTileEntity::new).build(null); + } + + @Test public void acceptingTheLastFiveMillibucketsRequestsSavingBeforeAnyTick() { + SaveWitnessBoiler boiler = seeded(3995); + FluidStack offered = new FluidStack(net.minecraft.init.Fluids.WATER, 5); + + assertEquals(5, boiler.fill(null, offered, true)); + assertEquals(4000, boiler.getTank().getFluidAmount()); + assertEquals(5, offered.amount); + assertSaved(boiler, 4000); + } + + @Test public void partialAcceptanceSavesOnlyTheQuantityActuallyAccepted() { + SaveWitnessBoiler boiler = seeded(3995); + FluidStack offered = new FluidStack(net.minecraft.init.Fluids.WATER, 100); + + assertEquals(5, boiler.fill(null, offered, true)); + assertEquals(4000, boiler.getTank().getFluidAmount()); + assertEquals(100, offered.amount); + assertSaved(boiler, 4000); + } + + @Test public void waterAcceptedThroughTheNetworkAlsoRequestsSavingImmediately() { + SaveWitnessBoiler boiler = seeded(3995); + ConduitType water = Fluids.fluidToConduitType(net.minecraft.init.Fluids.WATER); + + assertEquals(5, boiler.addEnergy(5, water), 0); + assertEquals(4000, boiler.getTank().getFluidAmount()); + assertSaved(boiler, 4000); + } + + @Test public void simulationNeitherChangesTheTankNorRequestsSaving() { + SaveWitnessBoiler boiler = seeded(3995); + FluidStack offered = new FluidStack(net.minecraft.init.Fluids.WATER, 100); + + assertEquals(5, boiler.fill(null, offered, false)); + assertEquals(3995, boiler.getTank().getFluidAmount()); + assertEquals(100, offered.amount); + assertUnchangedSave(boiler, 3995); + } + + @Test public void aFullTankRejectsInputWithoutRequestingSaving() { + SaveWitnessBoiler boiler = seeded(4000); + + assertEquals(0, boiler.fill(null, new FluidStack(net.minecraft.init.Fluids.WATER, 5), true)); + assertEquals(4000, boiler.getTank().getFluidAmount()); + assertUnchangedSave(boiler, 4000); + } + + @Test public void mismatchedFluidIsNotCreditedOrSaved() { + SaveWitnessBoiler boiler = seeded(3995); + FluidStack offered = new FluidStack(net.minecraft.init.Fluids.LAVA, 5); + + assertEquals(0, boiler.fill(null, offered, true)); + assertSame(net.minecraft.init.Fluids.WATER, boiler.getTank().getFluid().getFluid()); + assertEquals(3995, boiler.getTank().getFluidAmount()); + assertEquals(5, offered.amount); + assertUnchangedSave(boiler, 3995); + } + + @Test public void aZeroQuantityDoesNotRequestSaving() { + SaveWitnessBoiler boiler = seeded(3995); + + assertEquals(0, boiler.fill(null, new FluidStack(net.minecraft.init.Fluids.WATER, 0), true)); + assertEquals(3995, boiler.getTank().getFluidAmount()); + assertUnchangedSave(boiler, 3995); + } + + @Test public void theImmediateSaveWitnessRetainsFluidIdentityAndTypedNbt() { + SaveWitnessBoiler boiler = new SaveWitnessBoiler(); + FluidStack offered = new FluidStack(net.minecraft.init.Fluids.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(net.minecraft.init.Fluids.WATER, saved.getFluid()); + assertEquals(original, saved.tag); + assertEquals(original, offered.tag); + assertEquals(5, offered.amount); + } + + @Test public void emptyRequestedDrainReturnsNullWithoutDereferencingAMissingFluid() { + SaveWitnessBoiler boiler = new SaveWitnessBoiler(); + boiler.rememberSavedTank(); + assertNull(boiler.drain(null, new FluidStack(net.minecraft.init.Fluids.WATER, 5), true)); + assertNull(boiler.drain(null, new FluidStack(net.minecraft.init.Fluids.WATER, 5), false)); + assertEquals(0, boiler.dirtyCalls); + } + + @Test public void nullAndZeroRequestsNeitherMutateNorRequestSaving() { + SaveWitnessBoiler boiler = seeded(10); + assertEquals(0, boiler.fill(null, null, true)); + assertNull(boiler.drain(null, (FluidStack)null, true)); + assertNull(boiler.drain(null, new FluidStack(net.minecraft.init.Fluids.WATER, 0), true)); + assertNull(boiler.drain(null, 0, true)); + assertNull(boiler.drain(null, -1, true)); + assertEquals(10, boiler.getTank().getFluidAmount()); + assertUnchangedSave(boiler, 10); + } + + @Test public void mismatchedRequestedDrainReturnsNullRatherThanAZeroStack() { + SaveWitnessBoiler boiler = seeded(10); + assertNull(boiler.drain(null, new FluidStack(net.minecraft.init.Fluids.LAVA, 5), true)); + assertNull(boiler.drain(null, new FluidStack(net.minecraft.init.Fluids.LAVA, 5), false)); + assertEquals(10, boiler.getTank().getFluidAmount()); + assertUnchangedSave(boiler, 10); + } + + @Test public void nativeTankSimulationDoesNotRequestSaving() { + SaveWitnessBoiler boiler = seeded(10); + FluidStack preview = boiler.getTank().drain(5, false); + assertNotNull(preview); + assertEquals(5, preview.amount); + assertEquals(10, boiler.getTank().getFluidAmount()); + assertUnchangedSave(boiler, 10); + } + + @Test public void nativeRealTankDrainRequestsSavingEvenOutsideTheBoilerOverloads() { + SaveWitnessBoiler boiler = seeded(10); + FluidStack drained = boiler.getTank().drain(5, true); + assertNotNull(drained); + assertEquals(5, drained.amount); + assertEquals(5, boiler.getTank().getFluidAmount()); + assertSaved(boiler, 5); + } + + @Test public void aRealDrainToEmptySavesTheNativeEmptyTankRepresentation() { + SaveWitnessBoiler boiler = seeded(5); + assertEquals(5, boiler.drain(null, 5, true).amount); + assertEquals(0, boiler.getTank().getFluidAmount()); + assertSaved(boiler, 0); + assertNull(FluidStack.loadFluidStackFromNBT(boiler.savedTank)); + assertNull(boiler.drain(null, new FluidStack(net.minecraft.init.Fluids.WATER, 5), true)); + } + + @Test public void clientSideTankChangesDoNotRequestServerSaving() { + SaveWitnessBoiler boiler = seeded(3995); + boiler.saveWorld = world(true); + assertEquals(5, boiler.fill(null, new FluidStack(net.minecraft.init.Fluids.WATER, 5), true)); + assertEquals(4000, boiler.getTank().getFluidAmount()); + assertUnchangedSave(boiler, 3995); + } + + private static SaveWitnessBoiler seeded(int amount) { + SaveWitnessBoiler boiler = new SaveWitnessBoiler(); + boiler.getTank().setFluid(new FluidStack(net.minecraft.init.Fluids.WATER, amount)); + boiler.rememberSavedTank(); + return boiler; + } + + private static void assertSaved(SaveWitnessBoiler boiler, int amount) { + assertTrue("An accepted external fill must request saving without relying on a later tile tick", + boiler.dirtyCalls > 0); + assertEquals(amount, boiler.savedTank.getInt("Amount")); + } + + private static void assertUnchangedSave(SaveWitnessBoiler boiler, int amount) { + assertEquals(0, boiler.dirtyCalls); + assertEquals(amount, boiler.savedTank.getInt("Amount")); + } + + private static final class SaveWitnessBoiler extends CoalBoilerTileEntity { + int dirtyCalls; + NBTTagCompound savedTank; + SaveWorld saveWorld = world(false); + @Override public World getWorld() { return saveWorld; } + 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 SaveWorld world(boolean remote) { + try { + Class allocator = Class.forName("sun.misc.Unsafe"); + java.lang.reflect.Field field = allocator.getDeclaredField("theUnsafe"); + field.setAccessible(true); + SaveWorld world = (SaveWorld) allocator.getMethod("allocateInstance", Class.class).invoke(field.get(null), SaveWorld.class); + java.lang.reflect.Field side = World.class.getDeclaredField("isRemote"); + side.setAccessible(true); + side.setBoolean(world, remote); + return world; + } catch (ReflectiveOperationException failure) { + throw new AssertionError("Cannot prepare the save-notification world", failure); + } + } + private static final class SaveWorld extends net.minecraft.world.WorldServer { + private SaveWorld() { super(null, null, null, null, null, null); } + } +} diff --git a/src/test/java/zone/moddev/mc/steamadvantage/machines/RockCrusherComparatorTest.java b/src/test/java/zone/moddev/mc/steamadvantage/machines/RockCrusherComparatorTest.java new file mode 100644 index 0000000..e1a8428 --- /dev/null +++ b/src/test/java/zone/moddev/mc/steamadvantage/machines/RockCrusherComparatorTest.java @@ -0,0 +1,25 @@ +package zone.moddev.mc.steamadvantage.machines; + +import static org.junit.Assert.assertEquals; +import net.minecraft.item.ItemStack; +import net.minecraft.init.Items; +import org.junit.BeforeClass; +import org.junit.Test; + +public class RockCrusherComparatorTest { + @BeforeClass public static void bootstrap() { + zone.moddev.mc.steamadvantage.support.SteamNativeTestBootstrap.register(); + if (zone.moddev.mc.steamadvantage.init.Entities.STEAM_CRUSHER == null) + zone.moddev.mc.steamadvantage.init.Entities.STEAM_CRUSHER = net.minecraft.tileentity.TileEntityType.Builder.create(RockCrusherTileEntity::new).build(null); + } + @Test public void emptyInputHasNoComparatorSignal() { assertSignal(0, 0); } + @Test public void oneItemGivesSignalOne() { assertSignal(1, 1); } + @Test public void halfAStackGivesSignalEight() { assertSignal(32, 8); } + @Test public void aFullStackGivesSignalFifteen() { assertSignal(64, 15); } + + private static void assertSignal(int count, int expected) { + RockCrusherTileEntity crusher = new RockCrusherTileEntity(); + crusher.setInventorySlotContents(0, count == 0 ? ItemStack.EMPTY : new ItemStack(Items.COAL, count)); + assertEquals("Actual crusher comparator at " + count + " input items", expected, crusher.getComparatorOutput()); + } +} diff --git a/src/test/java/zone/moddev/mc/steamadvantage/machines/RockCrusherReloadRedstoneTest.java b/src/test/java/zone/moddev/mc/steamadvantage/machines/RockCrusherReloadRedstoneTest.java new file mode 100644 index 0000000..59a0712 --- /dev/null +++ b/src/test/java/zone/moddev/mc/steamadvantage/machines/RockCrusherReloadRedstoneTest.java @@ -0,0 +1,92 @@ +package zone.moddev.mc.steamadvantage.machines; + +import static org.junit.Assert.*; +import java.lang.reflect.Field; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.World; +import net.minecraft.world.WorldServer; +import org.junit.BeforeClass; +import org.junit.Test; + +public class RockCrusherReloadRedstoneTest { + @BeforeClass public static void bootstrap() { + zone.moddev.mc.steamadvantage.support.SteamNativeTestBootstrap.register(); + if (zone.moddev.mc.steamadvantage.init.Entities.STEAM_CRUSHER == null) + zone.moddev.mc.steamadvantage.init.Entities.STEAM_CRUSHER = net.minecraft.tileentity.TileEntityType.Builder.create(RockCrusherTileEntity::new).build(null); + if (zone.moddev.mc.steamadvantage.init.Entities.STEAM_FURNACE == null) + zone.moddev.mc.steamadvantage.init.Entities.STEAM_FURNACE = net.minecraft.tileentity.TileEntityType.Builder.create(BlastFurnaceTileEntity::new).build(null); + } + + @Test public void resumedWorkCannotBeDecrementedBeforeTheFirstLiveControlSample() throws Exception { + ProbeCrusher crusher = new ProbeCrusher(); + Field progress = RockCrusherTileEntity.class.getDeclaredField("progress"); + progress.setAccessible(true); + progress.setInt(crusher, 106); + crusher.testWorld = world(); + crusher.testWorld.stopAtFirstSample = true; + try { + crusher.tickUpdate(true); + fail("Live control must be sampled before touching loaded progress"); + } catch (ControlSample expected) { + assertEquals(106, progress.getInt(crusher)); + assertEquals(1, crusher.testWorld.samples); + } + } + + @Test public void scheduledPowerUpdateStillRefreshesTheInitializedCache() throws Exception { + ProbeCrusher crusher = new ProbeCrusher(); + crusher.testWorld = world(); + crusher.tickUpdate(true); + Field redstone = RockCrusherTileEntity.class.getDeclaredField("redstone"); + redstone.setAccessible(true); + assertFalse(redstone.getBoolean(crusher)); + crusher.testWorld.powered = true; + crusher.tickUpdate(true); + assertFalse(redstone.getBoolean(crusher)); + crusher.powerUpdate(); + assertTrue(redstone.getBoolean(crusher)); + assertEquals(2, crusher.testWorld.samples); + } + + @Test public void furnaceSamplesBeforeChangingItsRestoredHeatOrWork() throws Exception { + ProbeFurnace furnace = new ProbeFurnace(); + Field temperature = BlastFurnaceTileEntity.class.getDeclaredField("temperature"); + temperature.setAccessible(true); + temperature.setFloat(furnace, 1000); + furnace.testWorld = world(); + furnace.testWorld.stopAtFirstSample = true; + try { + furnace.tickUpdate(true); + fail("Live control must precede changes to loaded furnace heat or work"); + } catch (ControlSample expected) { + assertEquals(1000, temperature.getFloat(furnace), 0); + assertEquals(1, furnace.testWorld.samples); + } + } + + private static ControlWorld world() throws Exception { + Class allocator = Class.forName("sun.misc.Unsafe"); + Field field = allocator.getDeclaredField("theUnsafe"); + field.setAccessible(true); + return (ControlWorld) allocator.getMethod("allocateInstance", Class.class).invoke(field.get(null), ControlWorld.class); + } + private static final class ProbeCrusher extends RockCrusherTileEntity { + ControlWorld testWorld; + @Override public World getWorld() { return testWorld; } + } + private static final class ProbeFurnace extends BlastFurnaceTileEntity { + ControlWorld testWorld; + @Override public World getWorld() { return testWorld; } + } + private static final class ControlSample extends RuntimeException {} + private static final class ControlWorld extends WorldServer { + boolean powered, stopAtFirstSample; + int samples; + private ControlWorld() { super(null, null, null, null, null, null); } + @Override public boolean isBlockPowered(BlockPos pos) { + samples++; + if (stopAtFirstSample) throw new ControlSample(); + return powered; + } + } +} diff --git a/src/test/java/zone/moddev/mc/steamadvantage/machines/SteamOperationalPumpSourceTest.java b/src/test/java/zone/moddev/mc/steamadvantage/machines/SteamOperationalPumpSourceTest.java new file mode 100644 index 0000000..fa6c788 --- /dev/null +++ b/src/test/java/zone/moddev/mc/steamadvantage/machines/SteamOperationalPumpSourceTest.java @@ -0,0 +1,190 @@ +package zone.moddev.mc.steamadvantage.machines; + +import java.io.InputStream; +import java.lang.reflect.Proxy; +import net.minecraft.block.Block; +import net.minecraft.block.state.IBlockState; +import net.minecraft.init.Blocks; +import net.minecraft.block.BlockFlowingFluid; +import net.minecraft.block.IBucketPickupHandler; +import net.minecraft.block.material.Material; +import net.minecraft.fluid.Fluid; +import net.minecraft.fluid.IFluidState; +import net.minecraft.init.Fluids; +import net.minecraft.state.properties.BlockStateProperties; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.IWorld; +import org.junit.BeforeClass; +import org.junit.Test; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.tree.AbstractInsnNode; +import org.objectweb.asm.tree.ClassNode; +import org.objectweb.asm.tree.FieldInsnNode; +import org.objectweb.asm.tree.MethodInsnNode; +import org.objectweb.asm.tree.MethodNode; +import org.objectweb.asm.tree.TypeInsnNode; +import static org.junit.Assert.*; + +public class SteamOperationalPumpSourceTest { + private static final BlockPos SOURCE = new BlockPos(8, 80, 8); + + @BeforeClass public static void bootstrap() { + zone.moddev.mc.steamadvantage.support.SteamNativeTestBootstrap.register(); + } + + @Test public void nativePickupRetainsTheSlabAndOnlyClearsItsWater() { + IBlockState wet = Blocks.OAK_SLAB.getDefaultState().with(BlockStateProperties.WATERLOGGED, true); + PickupWorld world = new PickupWorld(wet); + assertSame(Fluids.WATER, pickup(world.world, SOURCE, wet)); + assertEquals(1, world.writes); + assertEquals(wet.with(BlockStateProperties.WATERLOGGED, false), world.state); + assertSame(Fluids.EMPTY, pickup(world.world, SOURCE, world.state)); + assertEquals("A dry host cannot produce another bucket", 1, world.writes); + } + + @Test public void nativePickupConsumesOrdinaryWaterAndLavaSources() { + for (Block block : new Block[]{Blocks.WATER, Blocks.LAVA}) { + IBlockState source = block.getDefaultState(); + PickupWorld world = new PickupWorld(source); + assertSame(source.getFluidState().getFluid(), pickup(world.world, SOURCE, source)); + assertEquals(1, world.writes); + assertEquals(Blocks.AIR.getDefaultState(), world.state); + } + } + + @Test public void unsupportedModSourceIsNeverReplacedOrCredited() { + Block host = new Block(Block.Properties.create(Material.ROCK)) { + @Override public IFluidState getFluidState(IBlockState state) { return Fluids.WATER.getDefaultState(); } + }; + IBlockState source = host.getDefaultState(); + assertTrue(source.getFluidState().isSource()); + PickupWorld world = new PickupWorld(source); + assertSame(Fluids.EMPTY, pickup(world.world, SOURCE, source)); + assertSame(source, world.state); + assertEquals(0, world.writes); + } + + @Test public void flowingAndRefusedSourcesCannotProduceABucket() { + IBlockState flowing = Blocks.WATER.getDefaultState().with(BlockFlowingFluid.LEVEL, 1); + PickupWorld world = new PickupWorld(flowing); + assertSame(Fluids.EMPTY, pickup(world.world, SOURCE, flowing)); + assertEquals(0, world.writes); + RefusingSource host = new RefusingSource(); + IBlockState refused = host.getDefaultState(); + PickupWorld refusedWorld = new PickupWorld(refused); + assertSame(Fluids.EMPTY, pickup(refusedWorld.world, SOURCE, refused)); + assertSame(refused, refusedWorld.state); + assertEquals(0, refusedWorld.writes); + } + + @Test public void pumpUsesNativePickupBeforeCreditWithoutDestructiveFallback() throws Exception { + ClassNode node = new ClassNode(); + try (InputStream input = getClass().getResourceAsStream( + "/zone/moddev/mc/steamadvantage/machines/SteamPumpTileEntity.class")) { + assertNotNull(input); + new ClassReader(input).accept(node, 0); + } + MethodNode tick = method(node, "tickUpdate"), pickup = method(node, "pickupSource"); + int extraction = -1, fill = -1, index = 0; + boolean nativePickup = false, nativeGuard = false; + for (MethodNode candidate : new MethodNode[]{tick, pickup}) { + for (AbstractInsnNode instruction : candidate.instructions.toArray()) { + if (instruction instanceof FieldInsnNode) { + FieldInsnNode field = (FieldInsnNode) instruction; + assertFalse("Caller must not erase waterlogged hosts with AIR", + field.getOpcode() == Opcodes.GETSTATIC && field.owner.equals("net/minecraft/init/Blocks") + && field.name.equals("AIR")); + } else if (instruction instanceof TypeInsnNode) { + TypeInsnNode type = (TypeInsnNode) instruction; + if (type.getOpcode() == Opcodes.INSTANCEOF && type.desc.equals("net/minecraft/block/IBucketPickupHandler")) nativeGuard = true; + } else if (instruction instanceof MethodInsnNode) { + MethodInsnNode call = (MethodInsnNode) instruction; + assertFalse("No destructive fallback when native pickup is unavailable", + call.name.equals("removeBlock") || call.name.equals("destroyBlock")); + if (candidate == pickup) { + assertFalse("Pickup policy delegates block mutation to the native handler", + call.name.equals("setBlockState")); + if (call.owner.equals("net/minecraft/block/IBucketPickupHandler") && call.name.equals("pickupFluid")) nativePickup = true; + } else { + if (call.name.equals("pickupSource")) extraction = index; + if (call.owner.equals("cyano/poweradvantage/api/fluid/FluidTank") && call.name.equals("fill")) fill = index; + } + } + index++; + } + } + assertTrue("Unsupported blocks are rejected before native pickup", nativeGuard && nativePickup); + assertTrue("Successful native pickup precedes the final bucket credit", extraction >= 0 && fill > extraction); + } + + @Test public void rejectedNeighborFillDoesNotDrainOneMillibucket() throws Exception { + ClassNode node = new ClassNode(); + try (InputStream input = getClass().getResourceAsStream( + "/zone/moddev/mc/steamadvantage/machines/SteamPumpTileEntity.class")) { + assertNotNull(input); + new ClassReader(input).accept(node, 0); + } + boolean afterFill = false, guarded = false, drained = false; + for (AbstractInsnNode instruction : method(node, "powerUpdate").instructions.toArray()) { + if (instruction instanceof MethodInsnNode) { + MethodInsnNode call = (MethodInsnNode) instruction; + if (call.owner.equals("cyano/poweradvantage/api/fluid/IAdvantageFluidHandler") + && call.name.equals("fill")) afterFill = true; + if (!afterFill) continue; + assertFalse("A zero acceptance must not be rounded up into a one-millibucket loss", + call.owner.equals("java/lang/Math") && call.name.equals("max") + && call.desc.equals("(II)I")); + if (call.owner.equals("cyano/poweradvantage/api/fluid/FluidTank") && call.name.equals("drain")) { + assertTrue("Reject nonpositive acceptance before debiting the tank", guarded); + drained = true; + break; + } + } else if (afterFill && instruction instanceof org.objectweb.asm.tree.JumpInsnNode + && instruction.getOpcode() == Opcodes.IFLE) guarded = true; + } + assertTrue("The direct-neighbor transfer path is covered", afterFill && drained); + } + + private static Fluid pickup(IWorld world, BlockPos pos, IBlockState state) { + try { + java.lang.reflect.Method method = SteamPumpTileEntity.class.getDeclaredMethod( + "pickupSource", IWorld.class, BlockPos.class, IBlockState.class); + method.setAccessible(true); + return (Fluid) method.invoke(null, world, pos, state); + } catch (ReflectiveOperationException failure) { + throw new AssertionError("Pump must delegate source pickup without destroying the host", failure); + } + } + + private static MethodNode method(ClassNode node, String name) { + return node.methods.stream().filter(method -> method.name.equals(name)).findFirst() + .orElseThrow(() -> new AssertionError("Missing pump method " + name)); + } + + private static final class RefusingSource extends Block implements IBucketPickupHandler { + private RefusingSource() { super(Block.Properties.create(Material.ROCK)); } + @Override public IFluidState getFluidState(IBlockState state) { return Fluids.WATER.getDefaultState(); } + @Override public Fluid pickupFluid(IWorld world, BlockPos pos, IBlockState state) { return Fluids.EMPTY; } + } + + private static final class PickupWorld { + private IBlockState state; + private int writes; + private final IWorld world; + + private PickupWorld(IBlockState initial) { + state = initial; + world = (IWorld) Proxy.newProxyInstance(IWorld.class.getClassLoader(), new Class[]{IWorld.class}, + (proxy, method, arguments) -> { + if (method.getName().equals("setBlockState")) { + assertEquals(SOURCE, arguments[0]); + state = (IBlockState) arguments[1]; + writes++; + return true; + } + throw new AssertionError("Unexpected native pickup world call: " + method.getName()); + }); + } + } +} diff --git a/src/test/java/zone/moddev/mc/steamadvantage/machines/SteamOperationalStillCapacityTest.java b/src/test/java/zone/moddev/mc/steamadvantage/machines/SteamOperationalStillCapacityTest.java new file mode 100644 index 0000000..74eb748 --- /dev/null +++ b/src/test/java/zone/moddev/mc/steamadvantage/machines/SteamOperationalStillCapacityTest.java @@ -0,0 +1,90 @@ +package zone.moddev.mc.steamadvantage.machines; + +import cyano.poweradvantage.api.fluid.FluidStack; +import java.lang.reflect.Method; +import net.minecraft.init.Fluids; +import net.minecraft.tileentity.TileEntityType; +import org.junit.BeforeClass; +import org.junit.Test; +import zone.moddev.mc.poweradvantage.registry.still.recipe.DistillationRecipe; +import zone.moddev.mc.poweradvantage.registry.still.recipe.ExplicitFluidReference; +import zone.moddev.mc.steamadvantage.init.Entities; +import static org.junit.Assert.*; + +/** Pure capacity planning; native operating regressions live in SteamOperationalCases. */ +public class SteamOperationalStillCapacityTest { + @BeforeClass public static void bootstrap() { + zone.moddev.mc.steamadvantage.support.SteamNativeTestBootstrap.register(); + if (Entities.STEAM_STILL == null) { + Entities.STEAM_STILL = TileEntityType.Builder.create(SteamStillTileEntity::new).build(null); + } + } + + @Test public void fullOutputCannotPlanOrDebitAnyInput() throws Exception { + SteamStillTileEntity still = still(1000); + FluidStack input = new FluidStack(Fluids.WATER, 200); + assertEquals(0, plan(still, recipe(2, 1), input)); + assertEquals(200, input.amount); + assertEquals(1000, still.getOutputTank().getFluidAmount()); + } + + @Test public void oneOutputSpacePlansOnlyOneApplicationWithoutMutatingEitherTank() throws Exception { + SteamStillTileEntity still = still(999); + FluidStack input = new FluidStack(Fluids.WATER, 200); + assertEquals(1, plan(still, recipe(2, 1), input)); + assertEquals(200, input.amount); + assertEquals(999, still.getOutputTank().getFluidAmount()); + } + + @Test public void normalSpeedPlansTwoApplicationsWhenBothProductsFit() throws Exception { + SteamStillTileEntity still = still(998); + FluidStack input = new FluidStack(Fluids.WATER, 200); + assertEquals(2, plan(still, recipe(2, 1), input)); + assertEquals(200, input.amount); + assertEquals(998, still.getOutputTank().getFluidAmount()); + } + + @Test public void incompleteInputCannotPlanProduct() throws Exception { + SteamStillTileEntity still = still(0); + FluidStack input = new FluidStack(Fluids.WATER, 1); + assertEquals(0, plan(still, recipe(2, 1), input)); + assertEquals(1, input.amount); + assertEquals(0, still.getOutputTank().getFluidAmount()); + } + + @Test public void mismatchedProductIsNotOverwritten() throws Exception { + SteamStillTileEntity still = new SteamStillTileEntity(); + still.getOutputTank().setFluid(new FluidStack(Fluids.WATER, 10)); + FluidStack input = new FluidStack(Fluids.WATER, 200); + assertEquals(0, plan(still, recipe(2, 1), input)); + assertEquals(200, input.amount); + assertSame(Fluids.WATER, still.getOutputTank().getFluid().getFluid()); + assertEquals(10, still.getOutputTank().getFluidAmount()); + } + + @Test public void multiMillibucketProductMustFitAsAWhole() throws Exception { + FluidStack input = new FluidStack(Fluids.WATER, 200); + assertEquals(0, plan(still(997), recipe(3, 4), input)); + assertEquals(1, plan(still(996), recipe(3, 4), input)); + assertEquals(2, plan(still(992), recipe(3, 4), input)); + assertEquals(200, input.amount); + } + + private static SteamStillTileEntity still(int output) { + SteamStillTileEntity still = new SteamStillTileEntity(); + if (output > 0) still.getOutputTank().setFluid(new FluidStack(Fluids.LAVA, output)); + return still; + } + + private static DistillationRecipe recipe(int in, int out) { + return new DistillationRecipe(new ExplicitFluidReference(Fluids.WATER), in, + new ExplicitFluidReference(Fluids.LAVA), out); + } + + private static int plan(SteamStillTileEntity still, DistillationRecipe recipe, FluidStack input) throws Exception { + Method method = SteamStillTileEntity.class.getDeclaredMethod("distillationApplications", + DistillationRecipe.class, FluidStack.class); + method.setAccessible(true); + return (Integer) method.invoke(still, recipe, input); + } +} diff --git a/src/test/java/zone/moddev/mc/steamadvantage/machines/WaterBoilerFluidBoundaryTest.java b/src/test/java/zone/moddev/mc/steamadvantage/machines/WaterBoilerFluidBoundaryTest.java new file mode 100644 index 0000000..c0df35c --- /dev/null +++ b/src/test/java/zone/moddev/mc/steamadvantage/machines/WaterBoilerFluidBoundaryTest.java @@ -0,0 +1,166 @@ +package zone.moddev.mc.steamadvantage.machines; + +import static org.junit.Assert.*; +import java.lang.reflect.Method; +import java.util.*; +import net.minecraft.nbt.NBTTagCompound; +import cyano.poweradvantage.api.fluid.FluidStack; +import cyano.poweradvantage.api.fluid.FluidTank; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +/** Calls each actual boiler method; no stand-in fill/drain implementation or world ticks. */ +@RunWith(Parameterized.class) +public class WaterBoilerFluidBoundaryTest { + @Parameterized.Parameters(name = "{0}") + public static Collection boilers() { + return Arrays.asList(new Object[][]{{CoalBoilerTileEntity.class}, + {ElectricBoilerTileEntity.class}, {GeothermalBoilerTileEntity.class}}); + } + private final Class type; + private Object boiler; + private FluidTank tank; + public WaterBoilerFluidBoundaryTest(Class type) { this.type = type; } + @BeforeClass public static void bootstrap() { + zone.moddev.mc.steamadvantage.support.SteamNativeTestBootstrap.register(); + if (zone.moddev.mc.steamadvantage.init.Entities.STEAM_BOILER_COAL == null) + zone.moddev.mc.steamadvantage.init.Entities.STEAM_BOILER_COAL = net.minecraft.tileentity.TileEntityType.Builder.create(CoalBoilerTileEntity::new).build(null); + if (zone.moddev.mc.steamadvantage.init.Entities.STEAM_BOILER_ELECTRIC == null) + zone.moddev.mc.steamadvantage.init.Entities.STEAM_BOILER_ELECTRIC = net.minecraft.tileentity.TileEntityType.Builder.create(ElectricBoilerTileEntity::new).build(null); + if (zone.moddev.mc.steamadvantage.init.Entities.STEAM_BOILER_GEOTHERMAL == null) + zone.moddev.mc.steamadvantage.init.Entities.STEAM_BOILER_GEOTHERMAL = net.minecraft.tileentity.TileEntityType.Builder.create(GeothermalBoilerTileEntity::new).build(null); + } + @Before public void createActualBoiler() throws Exception { + boiler = type.getConstructor().newInstance(); + tank = (FluidTank) type.getMethod("getTank").invoke(boiler); + } + + @Test public void emptyTankRejectsLavaInSimulationAndExecutionWithoutChangingTheOffer() throws Exception { + for (boolean execute : new boolean[]{false, true}) { + stock(0); + FluidStack lava = new FluidStack(net.minecraft.init.Fluids.LAVA, 5); + NBTTagCompound original = lava.writeToNBT(new NBTTagCompound()).copy(); + assertEquals(Boolean.FALSE, type.getMethod("canFill", net.minecraft.util.EnumFacing.class, net.minecraft.fluid.Fluid.class).invoke(boiler, null, net.minecraft.init.Fluids.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.minecraft.init.Fluids.WATER, 5); + NBTTagCompound original = offered.writeToNBT(new NBTTagCompound()).copy(); + assertEquals(Boolean.TRUE, type.getMethod("canFill", net.minecraft.util.EnumFacing.class, net.minecraft.fluid.Fluid.class).invoke(boiler, null, net.minecraft.init.Fluids.WATER)); + assertEquals(5, fill(offered, true)); + assertEquals(5, tank.getFluidAmount()); + assertSame(net.minecraft.init.Fluids.WATER, tank.getFluid().getFluid()); + assertEquals(original, offered.writeToNBT(new NBTTagCompound())); + } + + @Test public void seededTankRejectsMismatchedFillAndDrain() throws Exception { + stock(100); + FluidStack lava = new FluidStack(net.minecraft.init.Fluids.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.minecraft.init.Fluids.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.minecraft.init.Fluids.WATER, amount); + request.amount = amount; + assertEquals(0, fill(request, false)); + assertEquals(0, fill(request, true)); + } + assertEquals(100, tank.getFluidAmount()); + } + + @Test public void typedDrainOnAnEmptyTankReturnsNull() throws Exception { + assertNull(drain(new FluidStack(net.minecraft.init.Fluids.WATER, 5), false)); + assertNull(drain(new FluidStack(net.minecraft.init.Fluids.WATER, 5), true)); + assertEquals(0, tank.getFluidAmount()); + } + + @Test public void nullTypedDrainIsSafeOnEmptyAndSeededTanks() throws Exception { + for (int amount : new int[]{0, 100}) { + stock(amount); + assertNull(drain((FluidStack)null, false)); + assertNull(drain((FluidStack)null, true)); + assertEquals(amount, tank.getFluidAmount()); + } + } + + @Test public void zeroAndNegativeTypedDrainsReturnNull() throws Exception { + stock(100); + for (int amount : new int[]{0, -5}) { + FluidStack request = new FluidStack(net.minecraft.init.Fluids.WATER, amount); + request.amount = amount; + assertNull(drain(request, false)); + assertNull(drain(request, true)); + } + assertEquals(100, tank.getFluidAmount()); + } + + @Test public void zeroAndNegativeAmountDrainsReturnNull() throws Exception { + stock(100); + for (int amount : new int[]{0, -5}) { + assertNull(drain(amount, false)); + assertNull(drain(amount, true)); + } + assertEquals(100, tank.getFluidAmount()); + } + + @Test public void matchingSimulationKeepsAllStockAndTheFiniteRequestUnchanged() throws Exception { + stock(100); + FluidStack request = new FluidStack(net.minecraft.init.Fluids.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.minecraft.init.Fluids.WATER, 5); + FluidStack result = drain(request, true); + assertNotNull(result); + assertSame(net.minecraft.init.Fluids.WATER, result.getFluid()); + assertEquals(5, result.amount); + assertEquals(95, tank.getFluidAmount()); + assertEquals(5, request.amount); + } + + private void stock(int amount) { tank.setFluid(amount == 0 ? null : new FluidStack(net.minecraft.init.Fluids.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..5e0ce23 --- /dev/null +++ b/src/test/java/zone/moddev/mc/steamadvantage/machines/WaterBoilerTankNotificationTest.java @@ -0,0 +1,165 @@ +package zone.moddev.mc.steamadvantage.machines; + +import static org.junit.Assert.*; +import cyano.poweradvantage.api.IPowerMachine; +import zone.moddev.mc.poweradvantage.init.Fluids; +import java.util.*; +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.world.World; +import cyano.poweradvantage.api.fluid.FluidStack; +import cyano.poweradvantage.api.fluid.FluidTank; +import org.junit.BeforeClass; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +/** Save notifications only; native restart reports remain the disk-persistence evidence. */ +@RunWith(Parameterized.class) +public class WaterBoilerTankNotificationTest { + @Parameterized.Parameters(name = "{0}") + public static Collection boilers() { return Arrays.asList(new Object[][]{{"electric"}, {"geothermal"}}); } + private final String type; + private Probe probe; + public WaterBoilerTankNotificationTest(String type) { this.type = type; } + @BeforeClass public static void bootstrap() { + zone.moddev.mc.steamadvantage.support.SteamNativeTestBootstrap.register(); + if (zone.moddev.mc.steamadvantage.init.Entities.STEAM_BOILER_ELECTRIC == null) + zone.moddev.mc.steamadvantage.init.Entities.STEAM_BOILER_ELECTRIC = net.minecraft.tileentity.TileEntityType.Builder.create(ElectricBoilerTileEntity::new).build(null); + if (zone.moddev.mc.steamadvantage.init.Entities.STEAM_BOILER_GEOTHERMAL == null) + zone.moddev.mc.steamadvantage.init.Entities.STEAM_BOILER_GEOTHERMAL = net.minecraft.tileentity.TileEntityType.Builder.create(GeothermalBoilerTileEntity::new).build(null); + } + @Before public void createRealBoiler() { + probe = type.equals("electric") ? new ElectricProbe() : new GeothermalProbe(); + probe.save().remember(probe.tank()); + } + + @Test public void publicAcceptedFillRequestsSavingBeforeAnyTick() throws Exception { + FluidStack offer = water(5); + assertEquals(5, fill(offer, true)); + assertEquals(5, offer.amount); + saved(5); + } + + @Test public void networkAcceptedWaterAlsoRequestsSavingBeforeAnyTick() { + assertEquals(5, probe.machine().addEnergy(5, Fluids.fluidToConduitType(net.minecraft.init.Fluids.WATER)), 0); + saved(5); + } + + @Test public void directTankFillUsesTheSameOwnerNotification() { + assertEquals(5, probe.tank().fill(water(5), true)); + saved(5); + } + + @Test public void publicRealDrainRequestsSaving() throws Exception { + stock(10); + assertEquals(5, drain(5, true).amount); + saved(5); + } + + @Test public void directRealDrainUsesTheSameOwnerNotification() { + stock(10); + assertEquals(5, probe.tank().drain(5, true).amount); + saved(5); + } + + @Test public void drainingToEmptyRequestsSavingOfTheNativeEmptyRepresentation() { + stock(5); + assertEquals(5, probe.tank().drain(5, true).amount); + saved(0); + assertNull(FluidStack.loadFluidStackFromNBT(probe.save().savedTank)); + } + + @Test public void directAndPublicSimulationsNeverRequestSaving() throws Exception { + stock(10); + assertEquals(5, probe.tank().fill(water(5), false)); + assertEquals(5, probe.tank().drain(5, false).amount); + assertEquals(5, fill(water(5), false)); + assertEquals(5, drain(5, false).amount); + unchanged(10); + } + + @Test public void aClientFillChangesOnlyItsLocalTankWithoutRequestingServerSaving() throws Exception { + stock(10); probe.side(true); + assertEquals(5, fill(water(5), true)); + assertEquals(15, probe.tank().getFluidAmount()); + assertEquals(0, probe.save().dirtyCalls); + assertEquals(10, probe.save().savedTank.getInt("Amount")); + } + + @Test public void aClientDirectDrainDoesNotRequestServerSaving() { + stock(10); probe.side(true); + assertEquals(5, probe.tank().drain(5, true).amount); + assertEquals(5, probe.tank().getFluidAmount()); + assertEquals(0, probe.save().dirtyCalls); + assertEquals(10, probe.save().savedTank.getInt("Amount")); + } + + private static FluidStack water(int amount) { return new FluidStack(net.minecraft.init.Fluids.WATER, amount); } + private void stock(int amount) { probe.tank().setFluid(water(amount)); probe.save().remember(probe.tank()); } + private void saved(int amount) { + assertTrue("Real server-side tank mutation must notify its owner immediately", probe.save().dirtyCalls > 0); + assertEquals(amount, probe.tank().getFluidAmount()); + assertEquals(amount, probe.save().savedTank.getInt("Amount")); + } + private void unchanged(int amount) { + assertEquals(0, probe.save().dirtyCalls); + assertEquals(amount, probe.tank().getFluidAmount()); + assertEquals(amount, probe.save().savedTank.getInt("Amount")); + } + private int fill(FluidStack offer, boolean execute) throws Exception { + return ((Number) probe.machine().getClass().getMethod("fill", net.minecraft.util.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) { + try { + Class allocator = Class.forName("sun.misc.Unsafe"); + java.lang.reflect.Field field = allocator.getDeclaredField("theUnsafe"); + field.setAccessible(true); + SaveWorld world = (SaveWorld) allocator.getMethod("allocateInstance", Class.class).invoke(field.get(null), SaveWorld.class); + java.lang.reflect.Field clientField = World.class.getDeclaredField("isRemote"); + clientField.setAccessible(true); + clientField.setBoolean(world, client); + return world; + } catch (ReflectiveOperationException failure) { throw new AssertionError(failure); } + } + private static final class SaveWorld extends net.minecraft.world.WorldServer { + private SaveWorld() { super(null, null, null, null, null, null); } + } +} diff --git a/src/test/java/zone/moddev/mc/steamadvantage/support/SteamNativeTestBootstrap.java b/src/test/java/zone/moddev/mc/steamadvantage/support/SteamNativeTestBootstrap.java new file mode 100644 index 0000000..6760cd8 --- /dev/null +++ b/src/test/java/zone/moddev/mc/steamadvantage/support/SteamNativeTestBootstrap.java @@ -0,0 +1,38 @@ +package zone.moddev.mc.steamadvantage.support; + +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.Properties; +import net.minecraft.init.Bootstrap; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +/** Supplies Forge 25 launcher metadata before unit tests register native objects. */ +public final class SteamNativeTestBootstrap { + private SteamNativeTestBootstrap() {} + + public static synchronized void register() { + Properties platform = new Properties(); + try (InputStream input = Files.newInputStream(Paths.get("gradle.properties"))) { + platform.load(input); + Class loader = Class.forName("net.minecraftforge.fml.loading.FMLLoader"); + supplyVersion(loader, "mcVersion", platform.getProperty("minecraft_version")); + supplyVersion(loader, "forgeVersion", platform.getProperty("forge_version")); + } catch (IOException | ReflectiveOperationException failure) { + throw new AssertionError("Native test bootstrap could not establish Forge launcher metadata", failure); + } + Bootstrap.register(); + } + + private static void supplyVersion(Class loader, String name, String expected) throws ReflectiveOperationException { + assertNotNull("Configured platform version exists for " + name, expected); + Field field = loader.getDeclaredField(name); + field.setAccessible(true); + Object actual = field.get(null); + if (actual == null) field.set(null, expected); + else assertEquals("Existing launcher metadata matches this test platform: " + name, expected, actual); + } +} 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..63d1efd --- /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("java/util/function/Predicate") && call.name.equals("test")) { + tested = method.instructions.indexOf(instruction); + AbstractInsnNode guard = instruction.getNext(); + while (guard != null && guard.getOpcode() < 0) guard = guard.getNext(); + assertTrue(method.name + " rejects a false native spectator predicate", + guard instanceof JumpInsnNode && guard.getOpcode() == Opcodes.IFEQ); + } + if (call.owner.equals("net/minecraft/network/play/server/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.intersects(new AxisAlignedBB(radius - 0.1, -0.5, -0.3, radius + 0.5, 1.3, 0.3))); + assertTrue("Corner overlap remains included, not replaced with a spherical radius", + area.intersects(new AxisAlignedBB(radius - 0.1, radius - 0.1, radius - 0.1, + radius + 0.5, radius + 1.7, radius + 0.5))); + assertFalse("Merely touching the positive boundary is not an intersection", + area.intersects(new AxisAlignedBB(radius, -0.5, -0.3, radius + 0.6, 1.3, 0.3))); + assertFalse("Merely touching the negative boundary is not an intersection", + area.intersects(new AxisAlignedBB(-radius - 0.6, -0.5, -0.3, -radius, 1.3, 0.3))); + } + } + + private static void verifyDispatch(String name, double radius) throws Exception { + MethodNode method = soundHelper().methods.stream().filter(candidate -> candidate.name.equals(name)) + .findFirst().orElseThrow(() -> new AssertionError("Missing sound dispatch " + name)); + int playerLists = 0, bounds = 0, intersections = 0, soundPackets = 0; + boolean correctRadius = false, serverPlayer = false, connection = false, nullGuard = false; + for (AbstractInsnNode instruction : method.instructions.toArray()) { + if (instruction instanceof MethodInsnNode) { + MethodInsnNode call = (MethodInsnNode) instruction; + if (call.name.equals("getBoundingBox")) bounds++; + if (call.owner.equals("net/minecraft/util/math/AxisAlignedBB") && call.name.equals("intersects") + && call.desc.equals("(Lnet/minecraft/util/math/AxisAlignedBB;)Z")) intersections++; + if (call.owner.equals("net/minecraft/network/play/server/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; + } +}