diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 0bf2b88..189c1b8 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,18 @@ +Unreleased + +- Check generator redstone before the first power pulse so a disabled solar panel cannot produce a startup burst. +- Charge the moved electric drill its existing 250-unit track cost before saving its replacement, instead of charging the removed drill. +- Preserve saved growth chamber stages exactly before the first server tick, including mature crops waiting for output space. +- Stop the electric still consuming oil and power when the whole output batch will not fit. +- Respect modded fluid sources' drain rules and charge pump power only after fluid is collected. +- Keep growth controllers from requesting more electricity than they can store or use. +- Read light above solar panels without changing their generation rates. +- Keep turret beams within their existing range, clear of their own mounting post and blocked by adjacent cover. +- Keep hydroturbines anchored while flowing water turns their blades. +- Read a machine's redstone interlock on its first server tick, then keep the usual eight-tick refresh. +- Send machine sounds to nearby connected players without loading surrounding chunks. +- Use an empty or full-scale hydroelectric display without changing its existing power reading or electricity generation. + Electric Advantage 2.2.3.112021 - Retain unfinished drill progress when rebuilding the mining target after a world or chunk reload. diff --git a/src/main/java/com/mcmoddev/electricadvantage/entities/HydroturbineEntity.java b/src/main/java/com/mcmoddev/electricadvantage/entities/HydroturbineEntity.java index 8a129eb..95e5bd4 100644 --- a/src/main/java/com/mcmoddev/electricadvantage/entities/HydroturbineEntity.java +++ b/src/main/java/com/mcmoddev/electricadvantage/entities/HydroturbineEntity.java @@ -51,6 +51,12 @@ protected void entityInit() { // do nothing } + @Override + public boolean isPushedByWater() { + // Anchored to the generator: flow drives rotation, not linear movement. + return false; + } + @Override public void onUpdate(){ super.onUpdate(); diff --git a/src/main/java/com/mcmoddev/electricadvantage/gui/GUIHelper.java b/src/main/java/com/mcmoddev/electricadvantage/gui/GUIHelper.java index cfefa96..d8c9e2a 100644 --- a/src/main/java/com/mcmoddev/electricadvantage/gui/GUIHelper.java +++ b/src/main/java/com/mcmoddev/electricadvantage/gui/GUIHelper.java @@ -30,17 +30,23 @@ public static void drawIndicatorLight(boolean on,GUIContainer gc, int xOffset, i } public static void drawProgressBar(int x, int y, float progress, GUIContainer gc){ - int n = (int)(8 * (progress + 0.0625f) ); - if(n <= 0) return; - int w = 1 + (n * LED_BAR_W8); + int w = progressBarWidth(progress); + if(w == 0) return; gc.drawTexturedModalRect(x, y, LED_BAR_X, LED_BAR_Y, w, LED_BAR_H); } + + static int progressBarWidth(float progress) { + progress = Float.isFinite(progress) ? Math.max(0F, Math.min(1F, progress)) : 0F; + int n = (int)(8 * (progress + 0.0625f)); + return n <= 0 ? 0 : 1 + n * LED_BAR_W8; + } public static void drawFluidBar(FluidStack fs, float barHeight, int xPos, int yPos, GUIContainer guiContainer, int x, int y, float z, ResourceLocation displayImage){ + barHeight = Float.isFinite(barHeight) ? Math.max(0F, Math.min(1F, barHeight)) : 0F; final int w = 16; final int barSlotHeight = 60; final int h = (int)(barSlotHeight * barHeight); diff --git a/src/main/java/com/mcmoddev/electricadvantage/init/GUI.java b/src/main/java/com/mcmoddev/electricadvantage/init/GUI.java index 91b3543..0ca9c02 100644 --- a/src/main/java/com/mcmoddev/electricadvantage/init/GUI.java +++ b/src/main/java/com/mcmoddev/electricadvantage/init/GUI.java @@ -15,6 +15,7 @@ public static void init(){ Blocks.steam_powered_generator.setGuiID(MachineGUIRegistry.addGUI(new PowerGeneratorGUI()), PowerAdvantage.getInstance()); Blocks.photovoltaic_generator.setGuiID(MachineGUIRegistry.addGUI(new PowerGeneratorGUI()), PowerAdvantage.getInstance()); + Blocks.hydroelectric_generator.setGuiID(MachineGUIRegistry.addGUI(new PowerGeneratorGUI()), PowerAdvantage.getInstance()); Blocks.arc_furnace.setGuiID(MachineGUIRegistry.addGUI(new ArcFurnaceGUI()), PowerAdvantage.getInstance()); Blocks.battery_array.setGuiID(MachineGUIRegistry.addGUI(new BatteryArrayGUI()), PowerAdvantage.getInstance()); diff --git a/src/main/java/com/mcmoddev/electricadvantage/machines/ElectricDrillTileEntity.java b/src/main/java/com/mcmoddev/electricadvantage/machines/ElectricDrillTileEntity.java index 73e6582..4ae78d1 100644 --- a/src/main/java/com/mcmoddev/electricadvantage/machines/ElectricDrillTileEntity.java +++ b/src/main/java/com/mcmoddev/electricadvantage/machines/ElectricDrillTileEntity.java @@ -153,6 +153,7 @@ private boolean followTrack(){ te.readFromNBT(dataTransfer); te.setPos(nextPos); te.validate(); + te.subtractEnergy(ENERGY_COST_MOVE, te.getType()); te.markDirty(); Arrays.fill(this.getInventory(), null); @@ -161,7 +162,6 @@ private boolean followTrack(){ w.setBlockState(getPos(), Blocks.electric_conduit.getDefaultState(), 2); - this.subtractEnergy(ENERGY_COST_MOVE, getType()); return true; } diff --git a/src/main/java/com/mcmoddev/electricadvantage/machines/ElectricGeneratorTileEntity.java b/src/main/java/com/mcmoddev/electricadvantage/machines/ElectricGeneratorTileEntity.java index 2296668..b8bc129 100644 --- a/src/main/java/com/mcmoddev/electricadvantage/machines/ElectricGeneratorTileEntity.java +++ b/src/main/java/com/mcmoddev/electricadvantage/machines/ElectricGeneratorTileEntity.java @@ -17,7 +17,6 @@ import net.minecraftforge.fml.common.FMLLog; import java.util.Arrays; -import java.util.List; public abstract class ElectricGeneratorTileEntity extends cyano.poweradvantage.api.simple.TileEntitySimplePowerMachine { @@ -45,12 +44,14 @@ public ElectricGeneratorTileEntity(String name,int numInputSlots, ConduitType[] private boolean redstone = false; + private boolean redstoneInitialized = false; private float oldEnergy = 0f; @Override public void powerUpdate(){ super.powerUpdate(); redstone = getWorld().isBlockPowered(getPos()); + redstoneInitialized = true; if(oldEnergy != this.getEnergy(Power.ELECTRIC_POWER)){ this.sync(); @@ -58,6 +59,11 @@ public void powerUpdate(){ } protected boolean hasRedstoneSignal(){ + // Generators can check the interlock before their first power-update refresh. + if(!redstoneInitialized && getWorld() != null && !getWorld().isRemote){ + redstone = getWorld().isBlockPowered(getPos()); + redstoneInitialized = true; + } return redstone; } @@ -88,10 +94,13 @@ public boolean shouldRefresh(World world, BlockPos pos, IBlockState oldState, IB protected void playSoundEffect(double x, double y, double z, SoundEvent sound, float volume, float pitch){ if(getWorld().isRemote) return; final double range = 16; - List players = getWorld().getEntitiesWithinAABB(EntityPlayerMP.class, new AxisAlignedBB( + AxisAlignedBB bounds = new AxisAlignedBB( x - range, y - range, z - range, - x + range, y + range, z + range)); - for(EntityPlayerMP player : players){ + x + range, y + range, z + range); + // Query the existing player list without loading neighbouring chunks. + for(EntityPlayerMP player : getWorld().getPlayers(EntityPlayerMP.class, + candidate -> !candidate.isSpectator() && candidate.getEntityBoundingBox().intersects(bounds))){ + if(player.connection == null) continue; player.connection.sendPacket(new SPacketCustomSound(sound.getRegistryName().toString(), SoundCategory.BLOCKS, x, y, z, (float)volume, (float)pitch)); } diff --git a/src/main/java/com/mcmoddev/electricadvantage/machines/ElectricMachineTileEntity.java b/src/main/java/com/mcmoddev/electricadvantage/machines/ElectricMachineTileEntity.java index d9c0fbb..20233cd 100644 --- a/src/main/java/com/mcmoddev/electricadvantage/machines/ElectricMachineTileEntity.java +++ b/src/main/java/com/mcmoddev/electricadvantage/machines/ElectricMachineTileEntity.java @@ -17,7 +17,6 @@ import net.minecraftforge.fml.common.FMLLog; import java.util.Arrays; -import java.util.List; public abstract class ElectricMachineTileEntity extends cyano.poweradvantage.api.simple.TileEntitySimplePowerMachine{ @@ -66,6 +65,7 @@ public ElectricMachineTileEntity(String name,int numInputSlots, int numOutputSlo } private boolean redstone = false; + private boolean redstoneInitialized = false; private float oldEnergy = 0f; private int[] syncArrayOld = null; private int[] syncArrayNew = null; @@ -74,6 +74,7 @@ public void powerUpdate(){ super.powerUpdate(); redstone = getWorld().isBlockPowered(getPos()); + redstoneInitialized = true; this.setPowerState(this.isPowered()); @@ -94,6 +95,11 @@ public void powerUpdate(){ protected boolean hasRedstoneSignal(){ + // tickUpdate can run before the first scheduled powerUpdate after loading. + if(!redstoneInitialized && getWorld() != null && !getWorld().isRemote){ + redstone = getWorld().isBlockPowered(getPos()); + redstoneInitialized = true; + } return redstone; } @@ -348,10 +354,13 @@ public ItemStack insertItemToInputSlots(ItemStack itemStack){ protected void playSoundEffect(double x, double y, double z, SoundEvent sound, float volume, float pitch){ if(getWorld().isRemote) return; final double range = 16; - List players = getWorld().getEntitiesWithinAABB(EntityPlayerMP.class, new AxisAlignedBB( + AxisAlignedBB bounds = new AxisAlignedBB( x - range, y - range, z - range, - x + range, y + range, z + range)); - for(EntityPlayerMP player : players){ + x + range, y + range, z + range); + // Query the existing player list without loading neighbouring chunks. + for(EntityPlayerMP player : getWorld().getPlayers(EntityPlayerMP.class, + candidate -> !candidate.isSpectator() && candidate.getEntityBoundingBox().intersects(bounds))){ + if(player.connection == null) continue; player.connection.sendPacket(new SPacketCustomSound(sound.getRegistryName().toString(), SoundCategory.BLOCKS, x, y, z, (float)volume, (float)pitch)); } diff --git a/src/main/java/com/mcmoddev/electricadvantage/machines/ElectricPumpTileEntity.java b/src/main/java/com/mcmoddev/electricadvantage/machines/ElectricPumpTileEntity.java index 1e75475..3a72c9d 100644 --- a/src/main/java/com/mcmoddev/electricadvantage/machines/ElectricPumpTileEntity.java +++ b/src/main/java/com/mcmoddev/electricadvantage/machines/ElectricPumpTileEntity.java @@ -9,7 +9,6 @@ import com.mcmoddev.poweradvantage.init.Fluids; import com.mcmoddev.poweradvantage.util.FluidIdHelper; import net.minecraft.block.Block; -import net.minecraft.block.BlockDynamicLiquid; import net.minecraft.block.BlockLiquid; import net.minecraft.block.state.IBlockState; import net.minecraft.init.SoundEvents; @@ -83,12 +82,13 @@ public void tickUpdate(boolean isServerWorld) { float cost = ENERGY_COST_PUMP+ENERGY_COST_VERTICAL*(getPos().getY()-fluidSource.getY()); // Found a fluid to suck-up! IBlockState blockstate = w.getBlockState(fluidSource); - Fluid f = getFluid(blockstate); - if(f != null && getEnergy() >= cost){ - this.getTank().fill(new FluidStack(f, Fluid.BUCKET_VOLUME), true); - this.subtractEnergy(cost, Power.ELECTRIC_POWER); - w.setBlockToAir(fluidSource); - success = true; + if(getEnergy() >= cost){ + FluidStack drained = drainSource(w, fluidSource, blockstate); + if(drained != null && drained.amount > 0){ + this.getTank().fill(drained, true); + this.subtractEnergy(cost, Power.ELECTRIC_POWER); + success = true; + } } } timeUntilNextPump = PUMP_INTERVAL; @@ -134,6 +134,25 @@ private Fluid getFluid(IBlockState blockstate) { return FluidRegistry.lookupFluidForBlock(blockstate.getBlock()); } + private FluidStack drainSource(World world, BlockPos pos, IBlockState state) { + Block block = state.getBlock(); + if(block instanceof IFluidBlock){ + IFluidBlock source = (IFluidBlock)block; + if(!source.canDrain(world, pos)) return null; + FluidStack preview = source.drain(world, pos, false); + if(preview == null || preview.amount <= 0 || getTank().fill(preview, false) != preview.amount) return null; + // Let the source perform its own real removal, including any mod-specific state. + return source.drain(world, pos, true); + } + if(block instanceof BlockLiquid && state.getValue(BlockLiquid.LEVEL) == 0){ + Fluid fluid = getFluid(state); + if(fluid == null) return null; + FluidStack bucket = new FluidStack(fluid, Fluid.BUCKET_VOLUME); + if(getTank().fill(bucket, false) == bucket.amount && world.setBlockToAir(pos)) return bucket; + } + return null; + } + private BlockPos scan(Set searchSpace, BlockPos coord, int limit) { @@ -164,9 +183,9 @@ private BlockPos scan(Set searchSpace, BlockPos coord, int limit) { private boolean isSourceBlock( BlockPos coord){ IBlockState bs = getWorld().getBlockState(coord); if(bs.getBlock() instanceof BlockLiquid){ - return (Integer)bs.getValue(BlockDynamicLiquid.LEVEL) == 0; - } else if(bs.getBlock() instanceof BlockFluidClassic){ - return ((BlockFluidClassic)bs.getBlock()).isSourceBlock(getWorld(), coord); + return (Integer)bs.getValue(BlockLiquid.LEVEL) == 0; + } else if(bs.getBlock() instanceof IFluidBlock){ + return ((IFluidBlock)bs.getBlock()).canDrain(getWorld(), coord); }else{ return false; } diff --git a/src/main/java/com/mcmoddev/electricadvantage/machines/ElectricStillTileEntity.java b/src/main/java/com/mcmoddev/electricadvantage/machines/ElectricStillTileEntity.java index 8caf3ec..791d2e7 100644 --- a/src/main/java/com/mcmoddev/electricadvantage/machines/ElectricStillTileEntity.java +++ b/src/main/java/com/mcmoddev/electricadvantage/machines/ElectricStillTileEntity.java @@ -45,7 +45,8 @@ public void tickUpdate(boolean isServerWorld) { if(!this.hasRedstoneSignal()){ if(getInputTank().getFluidAmount() > 0 && getEnergy() >= electricityPerDistill - && canDistill(getInputTank().getFluid())){ + && canDistill(getInputTank().getFluid()) + && hasSpaceForDistillation()){ distill(); if(!wasActive){ this.setActiveState(true); @@ -90,6 +91,14 @@ private boolean canDistill(FluidStack fs) { } } + private boolean hasSpaceForDistillation() { + DistillationRecipe recipe = DistillationRecipeRegistry.getInstance() + .getDistillationRecipeForFluid(getInputTank().getFluid().getFluid()); + // applyRecipe consumes its input, so preview a copy before committing either tank. + FluidStack output = recipe.applyRecipe(getInputTank().getFluid().copy(), speed); + return output != null && getOutputTank().fill(output, false) == output.amount; + } + private void distill(){ DistillationRecipe recipe = DistillationRecipeRegistry.getInstance() .getDistillationRecipeForFluid(getInputTank().getFluid().getFluid()); diff --git a/src/main/java/com/mcmoddev/electricadvantage/machines/GrowthChamberControllerTileEntity.java b/src/main/java/com/mcmoddev/electricadvantage/machines/GrowthChamberControllerTileEntity.java index 2cfdf18..3597e23 100644 --- a/src/main/java/com/mcmoddev/electricadvantage/machines/GrowthChamberControllerTileEntity.java +++ b/src/main/java/com/mcmoddev/electricadvantage/machines/GrowthChamberControllerTileEntity.java @@ -313,7 +313,10 @@ public PowerRequest getPowerRequest(ConduitType offer) { float powerWanted = (this.getEnergyCapacity(Power.GROWTHCHAMBER_POWER) - this.getEnergy(Power.GROWTHCHAMBER_POWER)); powerWanted = Math.min(powerWanted, soil / SOIL_PER_UNIT); powerWanted = Math.min(powerWanted, tank.getFluidAmount() / WATER_PER_UNIT); - return new PowerRequest(PowerRequest.MEDIUM_PRIORITY,ELECTRICITY_PER_UNIT * powerWanted,this); + float stored = getEnergy(Power.ELECTRIC_POWER); + float room = getEnergyCapacity(Power.ELECTRIC_POWER) - stored; + float electricityWanted = Math.max(0, Math.min(room, ELECTRICITY_PER_UNIT * powerWanted - stored)); + return new PowerRequest(PowerRequest.MEDIUM_PRIORITY,electricityWanted,this); } else { return PowerRequest.REQUEST_NOTHING; } diff --git a/src/main/java/com/mcmoddev/electricadvantage/machines/GrowthChamberTileEntity.java b/src/main/java/com/mcmoddev/electricadvantage/machines/GrowthChamberTileEntity.java index 330f2b0..b870072 100644 --- a/src/main/java/com/mcmoddev/electricadvantage/machines/GrowthChamberTileEntity.java +++ b/src/main/java/com/mcmoddev/electricadvantage/machines/GrowthChamberTileEntity.java @@ -21,6 +21,7 @@ public class GrowthChamberTileEntity extends ElectricMachineTileEntity { private final int[] progression = new int[GROWTH_AREA]; private final int[] progressionMax = new int[GROWTH_AREA]; private final VirtualCrop[] crops = new VirtualCrop[GROWTH_AREA]; + private byte[] pendingGrowth; public GrowthChamberTileEntity() { super("tile.electricadvantage.growth_chamber.name",Power.GROWTHCHAMBER_POWER, ENERGY_PER_TICK*64, GROWTH_AREA, 6, 0); @@ -30,6 +31,7 @@ public GrowthChamberTileEntity() { @Override public void tickUpdate(boolean isServer) { if(isServer){ + restorePendingCrops(); boolean[] recalculate = inventoryChanged(); boolean active = false; boolean flagSync = false; @@ -157,7 +159,8 @@ public float[] getProgress() { @Override protected void saveTo(NBTTagCompound tagRoot) { - byte[] growthStages = new byte[GROWTH_AREA]; + byte[] growthStages = pendingGrowth == null ? new byte[GROWTH_AREA] + : Arrays.copyOf(pendingGrowth, GROWTH_AREA); for(int i = 0; i < growthStages.length; i++){ if(crops[i] != null) growthStages[i] = crops[i].getCurrentGrowth(); @@ -169,20 +172,34 @@ protected void saveTo(NBTTagCompound tagRoot) { @Override protected void loadFrom(NBTTagCompound tagRoot) { - if(tagRoot.hasKey("growth")){ - byte[] growthStages = tagRoot.getByteArray("growth"); - for(int i = 0; i < growthStages.length && i < GROWTH_AREA; i++){ - if(crops[i] == null){ - crops[i] = getVirtualCrop(this.getInputSlot(i)); - } - if(crops[i] != null){ - crops[i].setCurrentGrowth(growthStages[i]); - } - } + Arrays.fill(crops, null); + Arrays.fill(progression, 0); + Arrays.fill(progressionMax, 0); + oldInventory = null; + pendingGrowth = tagRoot.hasKey("growth", 7) + ? Arrays.copyOf(tagRoot.getByteArray("growth"), GROWTH_AREA) : null; + if(tagRoot.hasKey("prog", 11)){ + int[] saved = tagRoot.getIntArray("prog"); + System.arraycopy(saved, 0, progression, 0, Math.min(saved.length, progression.length)); } - if(tagRoot.hasKey("prog")){ - System.arraycopy(tagRoot.getIntArray("prog"), 0, progression, 0, progression.length); + } + + private void restorePendingCrops() { + if (pendingGrowth == null) return; + for (int i = 0; i < GROWTH_AREA; i++) { + crops[i] = getVirtualCrop(getInputSlot(i)); + if (crops[i] != null) { + crops[i].setCurrentGrowth(pendingGrowth[i]); + progressionMax[i] = crops[i].getMaxGrowth() * TICKS_PER_GROWTH; + } else { + progression[i] = 0; + } } + // Seed the comparison cache so the first tick does not discard restored work. + ItemStack[] inventory = getInventory(); + oldInventory = new ItemStack[inventory.length]; + for (int i = 0; i < inventory.length; i++) oldInventory[i] = copyStackOrNull(inventory[i]); + pendingGrowth = null; } diff --git a/src/main/java/com/mcmoddev/electricadvantage/machines/HydroelectricGeneratorTileEntity.java b/src/main/java/com/mcmoddev/electricadvantage/machines/HydroelectricGeneratorTileEntity.java index 10c680f..3255927 100644 --- a/src/main/java/com/mcmoddev/electricadvantage/machines/HydroelectricGeneratorTileEntity.java +++ b/src/main/java/com/mcmoddev/electricadvantage/machines/HydroelectricGeneratorTileEntity.java @@ -59,6 +59,13 @@ public float getPowerOutput() { } } + private final float[] progress = new float[1]; + + public float[] getProgress() { + progress[0] = isActive() ? 1F : 0F; + return progress; + } + @Override protected boolean isValidInputItem(ItemStack item) { return false; diff --git a/src/main/java/com/mcmoddev/electricadvantage/machines/LaserTurretTileEntity.java b/src/main/java/com/mcmoddev/electricadvantage/machines/LaserTurretTileEntity.java index 7f6007c..bcb1ae4 100644 --- a/src/main/java/com/mcmoddev/electricadvantage/machines/LaserTurretTileEntity.java +++ b/src/main/java/com/mcmoddev/electricadvantage/machines/LaserTurretTileEntity.java @@ -3,6 +3,7 @@ import com.mcmoddev.electricadvantage.ElectricAdvantage; import com.mcmoddev.electricadvantage.init.Power; import net.minecraft.block.Block; +import net.minecraft.block.state.IBlockState; import net.minecraft.entity.Entity; import net.minecraft.entity.EntityLivingBase; import net.minecraft.entity.EnumCreatureType; @@ -290,9 +291,29 @@ private Vec3d calculateLaserTerminus(){ return followRayToSolidBlock(getOpticPosition(),dir,ATTACK_RANGE); } private Vec3d followRayToSolidBlock(Vec3d origin, Vec3d dir, double maxRange){ - Vec3d max = origin.add(dir).add(maxRange * dir.x, maxRange * dir.y, maxRange * dir.z); - RayTraceResult impact = getWorld().rayTraceBlocks(origin, max, true, true, false); + Vec3d max = origin.add(maxRange * dir.x, maxRange * dir.y, maxRange * dir.z); + Vec3d traceOrigin = origin; + AxisAlignedBB ownBlock = new AxisAlignedBB(getPos()); + if(ownBlock.contains(origin)){ + // Trace back into our block to find its exit without skipping adjacent cover. + RayTraceResult exit = ownBlock.calculateIntercept(max, origin); + if(exit != null) traceOrigin = exit.hitVec.add(dir.scale(1.0E-7)); + } + World world = getWorld(); + RayTraceResult impact = null; + if(traceOrigin != origin){ + BlockPos firstPos = new BlockPos(traceOrigin); + IBlockState firstState = world.getBlockState(firstPos); + if(firstState.getCollisionBoundingBox(world, firstPos) != null + && firstState.getBlock().canCollideCheck(firstState, true)){ + impact = firstState.collisionRayTrace(world, firstPos, origin, max); + } + } + if(impact == null) impact = world.rayTraceBlocks(traceOrigin, max, true, true, false); if(impact != null && impact.typeOfHit == RayTraceResult.Type.BLOCK){ + RayTraceResult exact = world.getBlockState(impact.getBlockPos()) + .collisionRayTrace(world, impact.getBlockPos(), origin, max); + if(exact != null) impact = exact; final Vec3d impactSite; if(impact.hitVec != null){ impactSite = impact.hitVec; diff --git a/src/main/java/com/mcmoddev/electricadvantage/machines/PhotovoltaicGeneratorTileEntity.java b/src/main/java/com/mcmoddev/electricadvantage/machines/PhotovoltaicGeneratorTileEntity.java index 51306f9..0f88304 100644 --- a/src/main/java/com/mcmoddev/electricadvantage/machines/PhotovoltaicGeneratorTileEntity.java +++ b/src/main/java/com/mcmoddev/electricadvantage/machines/PhotovoltaicGeneratorTileEntity.java @@ -92,8 +92,10 @@ public void powerUpdate(){ } public static float getLightIntensityAt(BlockPos pos, World w){ + // Sample incident light above the solid panel, not inside its body. + BlockPos lightPos = pos.up(); float light; - if(w.canSeeSky(pos) && w.provider.hasSkyLight()){ + if(w.canSeeSky(lightPos) && w.provider.hasSkyLight()){ // outdoor light imposes no penalty light = MathHelper.sin((float)w.getWorldTime() * 2.61799E-4f); // (worldTime * pi / 12000); // handle weather @@ -103,7 +105,7 @@ public static float getLightIntensityAt(BlockPos pos, World w){ light *= 0.35f; } } else { - float blockLight = w.getLightFromNeighbors(pos) * 6.66666E-2f; // block light / 15 + float blockLight = w.getLightFromNeighbors(lightPos) * 6.66666E-2f; // block light / 15 light = BLOCKLIGHT_FACTOR * blockLight; } return light; diff --git a/src/test/java/com/mcmoddev/electricadvantage/entities/ElectricOperationalHydroturbineAnchorTest.java b/src/test/java/com/mcmoddev/electricadvantage/entities/ElectricOperationalHydroturbineAnchorTest.java new file mode 100644 index 0000000..d62bf4a --- /dev/null +++ b/src/test/java/com/mcmoddev/electricadvantage/entities/ElectricOperationalHydroturbineAnchorTest.java @@ -0,0 +1,104 @@ +package com.mcmoddev.electricadvantage.entities; + +import java.lang.reflect.Field; +import java.util.Random; +import net.minecraft.entity.Entity; +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.nbt.NBTTagDouble; +import net.minecraft.nbt.NBTTagList; +import net.minecraft.util.math.Vec3d; +import net.minecraft.init.Bootstrap; +import net.minecraft.world.World; +import net.minecraft.world.WorldProviderSurface; +import net.minecraft.world.WorldServer; +import org.junit.BeforeClass; +import org.junit.Test; + +import static org.junit.Assert.*; + +public class ElectricOperationalHydroturbineAnchorTest { + @BeforeClass public static void bootstrap() { Bootstrap.register(); } + + @Test public void nativeEntityLoadStillClampsOverspeedMotionAndPreservesItsBoundary() throws Exception { + ControlEntity control = new ControlEntity(world()); + NBTTagCompound tag = control.writeToNBT(new NBTTagCompound()); + tag.setTag("Motion", motion(16.226, -10.001, 10)); + control.readFromNBT(tag); + assertEquals(new Vec3d(0, 0, 10), motion(control)); + assertTrue(control.isPushedByWater()); + } + + @Test public void turbineRejectsLinearWaterPushingWithoutChangingRotationRate() throws Exception { + HydroturbineEntity turbine = new HydroturbineEntity(world()); + assertFalse(turbine.isPushedByWater()); + assertEquals(Vec3d.ZERO, motion(turbine)); + assertEquals(30, HydroturbineEntity.DEGREES_PER_TICK, 0); + } + + @Test public void legacyMotionInsideTheNativeLimitIsNotArbitrarilyCleared() throws Exception { + HydroturbineEntity turbine = new HydroturbineEntity(world()); + NBTTagCompound saved = turbine.writeToNBT(new NBTTagCompound()); + saved.setTag("Motion", motion(5.5, -2, 0)); + turbine.readFromNBT(saved); + assertEquals(new Vec3d(5.5, -2, 0), motion(turbine)); + assertFalse(turbine.isPushedByWater()); + } + + @Test public void anchoredTurbineSurvivesNativeSaveLoadWithItsParentAndIdentity() throws Exception { + World world = world(); + HydroturbineEntity original = new HydroturbineEntity(world); + original.setPosition(56856.5, 81, 50008.5); + NBTTagCompound saved = original.writeToNBT(new NBTTagCompound()); + int[] parent = {56856, 82, 50008}; + saved.setIntArray("parentPos", parent); + for (int pass = 0; pass < 3; pass++) { + HydroturbineEntity loaded = new HydroturbineEntity(world); + loaded.readFromNBT(saved); + NBTTagCompound next = loaded.writeToNBT(new NBTTagCompound()); + assertEquals(saved.getUniqueId("UUID"), next.getUniqueId("UUID")); + assertEquals(saved.getTagList("Pos", 6), next.getTagList("Pos", 6)); + assertEquals(motion(0, 0, 0), next.getTagList("Motion", 6)); + assertArrayEquals(parent, next.getIntArray("parentPos")); + assertFalse(loaded.isPushedByWater()); + saved = next; + } + } + + private static Vec3d motion(Entity entity) { + return new Vec3d(entity.motionX, entity.motionY, entity.motionZ); + } + + private static NBTTagList motion(double x, double y, double z) { + NBTTagList list = new NBTTagList(); + list.appendTag(new NBTTagDouble(x)); list.appendTag(new NBTTagDouble(y)); list.appendTag(new NBTTagDouble(z)); + return list; + } + + private static World world() throws Exception { + Class allocator = Class.forName("sun.misc.Unsafe"); + Field field = allocator.getDeclaredField("theUnsafe"); + field.setAccessible(true); + World world = (World)allocator.getMethod("allocateInstance", Class.class) + .invoke(field.get(null), NoTickWorld.class); + Field random = World.class.getField("rand"); + random.setAccessible(true); + random.set(world, new Random(0)); + Field dimension = World.class.getField("provider"); + dimension.setAccessible(true); + dimension.set(world, new WorldProviderSurface()); + return world; + } + + private static final class NoTickWorld extends WorldServer { + private NoTickWorld() { + super(null, null, null, 0, null); + } + } + + private static final class ControlEntity extends Entity { + ControlEntity(World world) { super(world); } + @Override protected void entityInit() {} + @Override protected void readEntityFromNBT(NBTTagCompound tag) {} + @Override protected void writeEntityToNBT(NBTTagCompound tag) {} + } +} diff --git a/src/test/java/com/mcmoddev/electricadvantage/gui/GUIHelperTest.java b/src/test/java/com/mcmoddev/electricadvantage/gui/GUIHelperTest.java new file mode 100644 index 0000000..68ff6fd --- /dev/null +++ b/src/test/java/com/mcmoddev/electricadvantage/gui/GUIHelperTest.java @@ -0,0 +1,38 @@ +package com.mcmoddev.electricadvantage.gui; + +import java.lang.reflect.Method; +import org.junit.Test; +import static org.junit.Assert.*; + +public class GUIHelperTest { + @Test public void barRetainsItsEightSegmentScale() throws Exception { + assertEquals(0, progressBarWidth(0F)); + assertEquals(17, progressBarWidth(0.5F)); + assertEquals(33, progressBarWidth(1F)); + } + + @Test public void invalidAndOversizedValuesCannotEscapeTheTexture() throws Exception { + for (float value : new float[]{-1F, Float.NEGATIVE_INFINITY, Float.POSITIVE_INFINITY, Float.NaN}) { + assertEquals(0, progressBarWidth(value)); + } + assertEquals(33, progressBarWidth(15F)); + assertEquals(33, progressBarWidth(Float.MAX_VALUE)); + } + + @Test public void finiteReadingsAreMonotonicAndBounded() throws Exception { + int previous = 0; + for (int i = -100; i <= 200; i++) { + int width = progressBarWidth(i / 100F); + assertTrue(width >= previous && width >= 0 && width <= 33); + previous = width; + } + } + + private static int progressBarWidth(float value) throws Exception { + Method method; + try { method = GUIHelper.class.getDeclaredMethod("progressBarWidth", float.class); } + catch (NoSuchMethodException missing) { throw new AssertionError("Electric progress bars need one finite, bounded eight-segment scale", missing); } + method.setAccessible(true); + return (Integer) method.invoke(null, value); + } +} diff --git a/src/test/java/com/mcmoddev/electricadvantage/machines/ElectricDrillMovementContractTest.java b/src/test/java/com/mcmoddev/electricadvantage/machines/ElectricDrillMovementContractTest.java new file mode 100644 index 0000000..53ffecc --- /dev/null +++ b/src/test/java/com/mcmoddev/electricadvantage/machines/ElectricDrillMovementContractTest.java @@ -0,0 +1,38 @@ +package com.mcmoddev.electricadvantage.machines; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import org.junit.Test; + +import static org.junit.Assert.*; + +/** Source guard only; Advantage Works supplies the native six-facing movement proof. */ +public class ElectricDrillMovementContractTest { + @Test public void replacementPaysExactlyOnceBeforeBeingMarkedDirty() throws Exception { + String source = new String(Files.readAllBytes(Paths.get("src/main/java", + ElectricDrillTileEntity.class.getName().replace('.', '/') + ".java")), StandardCharsets.UTF_8); + int start = source.indexOf("private boolean followTrack()"); + assertTrue("Locate the actual track-movement method", start >= 0); + int end = source.indexOf("@Override", start); + assertTrue("Bound the guard to the track-movement method", end > start); + String movement = source.substring(start, end).replaceAll("\\s+", ""); + String debit = "te.subtractEnergy(ENERGY_COST_MOVE,te.getType());"; + int debitIndex = movement.indexOf(debit); + int transferIndex = movement.indexOf("te.readFromNBT(dataTransfer);"); + assertTrue("The movement must copy the saved state into the replacement", transferIndex >= 0); + assertTrue("The replacement must pay after receiving the saved state", + debitIndex > transferIndex); + assertTrue("Persist the replacement only after charging its move", + debitIndex < movement.indexOf("te.markDirty();")); + assertTrue("Charge before clearing or replacing the source", + debitIndex < movement.indexOf("Arrays.fill(this.getInventory(),null);")); + assertEquals("There must be only one movement debit", debitIndex, movement.lastIndexOf(debit)); + assertEquals("Do not debit the old drill or charge twice", 1, + movement.split("subtractEnergy\\(", -1).length - 1); + } + + @Test public void trackMovementStillCostsTwoHundredAndFiftyUnits() { + assertEquals(250.0f, ElectricDrillTileEntity.ENERGY_COST_MOVE, 0.0f); + } +} diff --git a/src/test/java/com/mcmoddev/electricadvantage/machines/ElectricOperationalNativeDefectsTest.java b/src/test/java/com/mcmoddev/electricadvantage/machines/ElectricOperationalNativeDefectsTest.java new file mode 100644 index 0000000..49c4606 --- /dev/null +++ b/src/test/java/com/mcmoddev/electricadvantage/machines/ElectricOperationalNativeDefectsTest.java @@ -0,0 +1,232 @@ +package com.mcmoddev.electricadvantage.machines; + +import net.minecraftforge.fluids.FluidStack; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import net.minecraft.init.Blocks; +import net.minecraft.block.state.IBlockState; +import net.minecraft.entity.Entity; +import net.minecraft.entity.item.EntityItem; +import net.minecraftforge.fluids.FluidRegistry; +import net.minecraft.util.EnumFacing; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.Vec3d; +import net.minecraft.init.Bootstrap; +import net.minecraft.world.World; +import net.minecraft.world.WorldServer; +import org.junit.BeforeClass; +import org.junit.Test; +import com.mcmoddev.electricadvantage.init.Power; + +import static org.junit.Assert.*; + +/** Operating regressions; the packaged rig independently checks natural machine ticks. */ +public class ElectricOperationalNativeDefectsTest { + @BeforeClass public static void bootstrap() { Bootstrap.register(); } + + @Test public void controllerCannotRequestMoreThanItsHundredUnitElectricalBuffer() throws Exception { + GrowthChamberControllerTileEntity controller = controller(); + assertEquals(100, controller.getPowerRequest(Power.ELECTRIC_POWER).amount, 0); + assertEquals(0, controller.getEnergy(Power.ELECTRIC_POWER), 0); + assertEquals(1000, controller.getSoil(), 0); + assertEquals(1800, controller.getTank().getFluidAmount()); + } + + @Test public void controllerSubtractsElectricityAlreadyStoredFromInputCapacity() throws Exception { + GrowthChamberControllerTileEntity controller = controller(); + controller.setEnergy(40, Power.ELECTRIC_POWER); + assertEquals(60, controller.getPowerRequest(Power.ELECTRIC_POWER).amount, 0); + controller.setEnergy(100, Power.ELECTRIC_POWER); + assertEquals(0, controller.getPowerRequest(Power.ELECTRIC_POWER).amount, 0); + } + + @Test public void scarceResourcesAndExistingElectricityBoundTheRemainingDemand() throws Exception { + GrowthChamberControllerTileEntity controller = controller(); + controller.setSoil(1); + controller.getTank().setFluid(new FluidStack(FluidRegistry.WATER, 2)); + controller.setEnergy(4, Power.ELECTRIC_POWER); + assertEquals(4, controller.getPowerRequest(Power.ELECTRIC_POWER).amount, 0); + controller.setEnergy(999.75f, Power.GROWTHCHAMBER_POWER); + assertEquals(0, controller.getPowerRequest(Power.ELECTRIC_POWER).amount, 0); + } + + @Test public void fullGreenhouseBufferCannotRequestMoreElectricity() throws Exception { + GrowthChamberControllerTileEntity controller = controller(); + controller.setEnergy(1000, Power.GROWTHCHAMBER_POWER); + assertEquals(0, controller.getPowerRequest(Power.ELECTRIC_POWER).amount, 0); + } + + @Test public void hydroelectricDisplayIsNormalizedWithoutChangingStoredPowerOrRatedGeneration() throws Exception { + HydroelectricGeneratorTileEntity active = new HydroelectricGeneratorTileEntity() { + @Override public boolean isActive() { return true; } + }; + HydroelectricGeneratorTileEntity idle = new HydroelectricGeneratorTileEntity() { + @Override public boolean isActive() { return false; } + }; + active.setEnergy(37, Power.ELECTRIC_POWER); + idle.setEnergy(19, Power.ELECTRIC_POWER); + assertEquals(1, hydroelectricDisplay(active).length); + assertEquals(1, hydroelectricDisplay(active)[0], 0); + assertEquals(0, hydroelectricDisplay(idle)[0], 0); + assertEquals(15, active.getPowerOutput(), 0); + assertEquals(0, idle.getPowerOutput(), 0); + assertEquals(37, active.getEnergy(Power.ELECTRIC_POWER), 0); + assertEquals(19, idle.getEnergy(Power.ELECTRIC_POWER), 0); + assertEquals(4, HydroelectricGeneratorTileEntity.ENERGY_PER_TICK, 0); + } + + @Test public void panelSamplesIncidentLightAboveItsSolidBody() throws Exception { + LightWorld world = withoutConstructor(LightWorld.class); + BlockPos panel = new BlockPos(101, 82, -27); + world.surface = panel.up(); + world.brightness = 14; + assertEquals(0.1f * 14 * 0.0666666f, + PhotovoltaicGeneratorTileEntity.getLightIntensityAt(panel, world), 0.000001f); + assertEquals(1, world.skyReads); + assertEquals(1, world.lightReads); + } + + @Test public void darkPanelSurfaceProducesNoBlocklightPower() throws Exception { + LightWorld world = withoutConstructor(LightWorld.class); + BlockPos panel = new BlockPos(101, 82, -27); + world.surface = panel.up(); + assertEquals(0, PhotovoltaicGeneratorTileEntity.getLightIntensityAt(panel, world), 0); + } + + @Test public void beamIgnoresOnlyItsOwnCollisionPostAndStopsAtTheDeclaredRange() throws Exception { + BeamWorld world = withoutConstructor(BeamWorld.class); + world.turret = new BlockPos(101, 82, -27); + StubTurret turret = withoutConstructor(StubTurret.class); + turret.testWorld = world; + turret.setPos(world.turret); + Vec3d origin = new Vec3d(101.5, 82.75, -26.5); + Method trace = LaserTurretTileEntity.class.getDeclaredMethod("followRayToSolidBlock", + Vec3d.class, Vec3d.class, double.class); + trace.setAccessible(true); + Vec3d end = (Vec3d)trace.invoke(turret, origin, new Vec3d(1, 0, 0), 32.0); + assertTrue("Use native block traversal", world.reads > 0); + assertEquals(32, origin.distanceTo(end), 0.000001); + world.wall = world.turret.east(); + Vec3d covered = (Vec3d)trace.invoke(turret, origin, new Vec3d(1, 0, 0), 32.0); + assertEquals("Ignoring our post must not skip the immediately adjacent cover", + 0.5, origin.distanceTo(covered), 0.000001); + } + + @Test public void nativeSightQueryAcceptsValidTargetAndRespectsCover() throws Exception { + BeamWorld world = withoutConstructor(BeamWorld.class); + StubTurret turret = withoutConstructor(StubTurret.class); + turret.testWorld = world; + turret.setPos(new BlockPos(101, 82, -27)); + Entity observed = target(); + assertFalse(turret.canSeeEntity(null)); + assertTrue("Visible target must remain visible through the native 1.13 API", turret.canSeeEntity(observed)); + assertTrue("Use native block traversal, not a canned ray result", world.reads > 0); + world.wall = new BlockPos(101, 82, -24); + assertFalse("Actual stone must prevent sight of the same target", turret.canSeeEntity(observed)); + } + + @Test public void beamCannotSkipImmediatelyAdjacentCoverInAnyDirection() throws Exception { + BeamWorld world = withoutConstructor(BeamWorld.class); + world.turret = new BlockPos(101, 82, -27); + StubTurret turret = withoutConstructor(StubTurret.class); + turret.testWorld = world; + turret.setPos(world.turret); + Vec3d origin = new Vec3d(101.5, 82.75, -26.5); + Method trace = LaserTurretTileEntity.class.getDeclaredMethod("followRayToSolidBlock", + Vec3d.class, Vec3d.class, double.class); + trace.setAccessible(true); + for (EnumFacing facing : EnumFacing.values()) { + world.wall = world.turret.offset(facing); + Vec3d direction = new Vec3d(facing.getDirectionVec()); + Vec3d end = origin.add(direction.scale(32)); + net.minecraft.util.math.RayTraceResult expected = new net.minecraft.util.math.AxisAlignedBB(world.wall) + .calculateIntercept(origin, end); + assertNotNull(expected); + Vec3d actual = (Vec3d)trace.invoke(turret, origin, direction, 32.0); + assertEquals("Adjacent cover in direction " + facing, 0, expected.hitVec.distanceTo(actual), 0.000001); + } + } + + @Test public void beamWithoutAnEntityContextStillStopsAtAvailableCover() throws Exception { + BeamWorld world = withoutConstructor(BeamWorld.class); + StubTurret turret = withoutConstructor(StubTurret.class); + turret.testWorld = world; + turret.setPos(new BlockPos(101, 82, -27)); + world.wall = new BlockPos(106, 82, -27); + Vec3d origin = new Vec3d(101.5, 82.75, -26.5); + Method trace = LaserTurretTileEntity.class.getDeclaredMethod("followRayToSolidBlock", + Vec3d.class, Vec3d.class, double.class); + trace.setAccessible(true); + Vec3d end = (Vec3d)trace.invoke(turret, origin, new Vec3d(1, 0, 0), 32.0); + assertEquals(4.5, origin.distanceTo(end), 0.000001); + assertTrue(world.reads > 0); + } + + private static Entity target() { + EntityItem target = new EntityItem(null); + target.setPosition(101.5, 82, -20.5); + return target; + } + + private static float[] hydroelectricDisplay(HydroelectricGeneratorTileEntity generator) throws Exception { + try { + return (float[])HydroelectricGeneratorTileEntity.class.getMethod("getProgress").invoke(generator); + } catch (NoSuchMethodException legacy) { + // The legacy screen passes this reading directly to the progress bar. + return new float[]{generator.getPowerOutput()}; + } + } + + private static GrowthChamberControllerTileEntity controller() throws Exception { + GrowthChamberControllerTileEntity controller = new GrowthChamberControllerTileEntity(); + controller.setSoil(1000); + controller.getTank().setFluid(new FluidStack(FluidRegistry.WATER, 1800)); + Field redstone = GrowthChamberControllerTileEntity.class.getDeclaredField("redstone"); + redstone.setAccessible(true); + redstone.setBoolean(controller, false); + return controller; + } + + private static T withoutConstructor(Class type) throws Exception { + Class allocator = Class.forName("sun.misc.Unsafe"); + Field field = allocator.getDeclaredField("theUnsafe"); + field.setAccessible(true); + return type.cast(allocator.getMethod("allocateInstance", Class.class).invoke(field.get(null), type)); + } + + private static final class LightWorld extends WorldServer { + BlockPos surface; + int brightness, skyReads, lightReads; + private LightWorld() { + super(null, null, null, 0, null); + } + @Override public boolean canSeeSky(BlockPos pos) { + assertEquals("Sky access must be sampled above the solid panel", surface, pos); + skyReads++; + return false; + } + @Override public int getLightFromNeighbors(BlockPos pos) { + assertEquals("Incident block light must be sampled above the solid panel", surface, pos); + lightReads++; + return brightness; + } + } + + private static final class BeamWorld extends WorldServer { + BlockPos turret, wall; + int reads; + private BeamWorld() { super(null, null, null, 0, null); } + @Override public IBlockState getBlockState(BlockPos pos) { + reads++; + if (pos.equals(wall)) return Blocks.STONE.getDefaultState(); + if (pos.equals(turret)) return new LaserTurretBlock(false).getDefaultState(); + return Blocks.AIR.getDefaultState(); + } + @Override public Entity getEntityByID(int id) { return null; } + } + + private static final class StubTurret extends LaserTurretTileEntity { + World testWorld; + @Override public World getWorld() { return testWorld; } + } +} diff --git a/src/test/java/com/mcmoddev/electricadvantage/machines/ElectricOperationalPumpPickupTest.java b/src/test/java/com/mcmoddev/electricadvantage/machines/ElectricOperationalPumpPickupTest.java new file mode 100644 index 0000000..eeb21cb --- /dev/null +++ b/src/test/java/com/mcmoddev/electricadvantage/machines/ElectricOperationalPumpPickupTest.java @@ -0,0 +1,221 @@ +package com.mcmoddev.electricadvantage.machines; + +import java.lang.reflect.Field; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import net.minecraft.block.Block; +import net.minecraft.block.BlockLiquid; +import net.minecraft.block.material.Material; +import net.minecraft.block.state.IBlockState; +import net.minecraft.init.Blocks; +import net.minecraft.init.Bootstrap; +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.util.ResourceLocation; +import net.minecraft.util.SoundEvent; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.IBlockAccess; +import net.minecraft.world.World; +import net.minecraft.world.WorldServer; +import net.minecraftforge.fluids.BlockFluidClassic; +import net.minecraftforge.fluids.Fluid; +import net.minecraftforge.fluids.FluidRegistry; +import net.minecraftforge.fluids.FluidStack; +import org.junit.After; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import com.mcmoddev.electricadvantage.init.Power; + +import static org.junit.Assert.*; + +/** 1.12 has no waterlogging: native modded drain contracts replace those later-version cases. */ +public class ElectricOperationalPumpPickupTest { + private static final BlockPos PUMP = new BlockPos(8, 84, 8); + private static final BlockPos SOURCE = PUMP.down().north(); + private static final float RESERVE = 8000; + private static final float PICKUP_COST = 1000 + 32; + private static final AtomicInteger IDS = new AtomicInteger(); + private Field lookupCache; + private Object previousCache; + + @BeforeClass public static void bootstrap() { Bootstrap.register(); } + + @Before public void isolateFluidBlockLookup() throws Exception { + lookupCache = FluidRegistry.class.getDeclaredField("fluidBlocks"); + lookupCache.setAccessible(true); + previousCache = lookupCache.get(null); + // Fixture fluids are registered before the same lazy lookup used during normal startup. + lookupCache.set(null, null); + } + + @After public void restoreFluidBlockLookup() throws Exception { lookupCache.set(null, previousCache); } + + @Test public void stillAndDynamicVanillaSourcesYieldExactlyOneBucket() throws Exception { + for (Block block : new Block[]{Blocks.WATER, Blocks.FLOWING_WATER, Blocks.LAVA, Blocks.FLOWING_LAVA}) { + StubPump pump = fixture(block.getDefaultState()); + pump.tickUpdate(true); + Fluid expected = block == Blocks.WATER || block == Blocks.FLOWING_WATER ? FluidRegistry.WATER : FluidRegistry.LAVA; + assertSuccessfulPickup(pump, expected); + } + } + + @Test public void moddedSourceUsesItsOwnRealDrainExactlyOnce() throws Exception { + NativeSource source = source(true, 1000); + StubPump pump = fixture(source.getDefaultState()); + pump.tickUpdate(true); + assertTrue("Respect the modded source's permission to drain", source.permissionChecks > 0); + assertEquals(1, source.actualDrains); + assertSuccessfulPickup(pump, source.getFluid()); + } + + @Test public void nonDrainableModdedSourceCannotBeDeletedOrCreditedAsFluid() throws Exception { + NativeSource source = source(false, 1000); + StubPump pump = fixture(source.getDefaultState()); + pump.tickUpdate(true); + assertUntouched(pump, source.getDefaultState(), RESERVE, 0); + assertTrue(source.permissionChecks > 0); + assertEquals(0, source.actualDrains); + } + + @Test public void ordinarySolidsAndDrySlabsOrStairsAreNeverFluidSources() throws Exception { + for (Block block : new Block[]{Blocks.STONE, Blocks.WOODEN_SLAB, Blocks.OAK_STAIRS, Blocks.TALLGRASS}) { + StubPump pump = fixture(block.getDefaultState()); + pump.tickUpdate(true); + assertUntouched(pump, block.getDefaultState(), RESERVE, 0); + } + } + + @Test public void refusedOrEmptyRealDrainCannotInventFluidOrDebitElectricity() throws Exception { + for (int amount : new int[]{-1, 0}) { + NativeSource source = source(true, amount); + StubPump pump = fixture(source.getDefaultState()); + pump.tickUpdate(true); + assertEquals(1, source.actualDrains); + assertUntouched(pump, source.getDefaultState(), RESERVE, 0); + } + } + + @Test public void insufficientElectricityCannotDrainAModdedSource() throws Exception { + NativeSource source = source(true, 1000); + StubPump pump = fixture(source.getDefaultState()); + pump.setEnergy(PICKUP_COST - 1, Power.ELECTRIC_POWER); + pump.tickUpdate(true); + assertEquals(0, source.actualDrains); + assertUntouched(pump, source.getDefaultState(), PICKUP_COST - 1, 0); + } + + @Test public void occupiedTankCannotDrainASourceOrOverwriteItsFluid() throws Exception { + NativeSource source = source(true, 1000); + StubPump pump = fixture(source.getDefaultState()); + pump.getTank().setFluid(new FluidStack(FluidRegistry.LAVA, 1)); + pump.tickUpdate(true); + assertEquals(0, source.actualDrains); + assertUntouched(pump, source.getDefaultState(), RESERVE, 1); + assertSame(FluidRegistry.LAVA, pump.getTank().getFluid().getFluid()); + } + + @Test public void flowingVanillaLiquidsCannotBePickedUpAsFullSources() throws Exception { + for (Block block : new Block[]{Blocks.WATER, Blocks.FLOWING_WATER, Blocks.LAVA, Blocks.FLOWING_LAVA}) { + IBlockState state = block.getDefaultState().withProperty(BlockLiquid.LEVEL, 1); + StubPump pump = fixture(state); + pump.tickUpdate(true); + assertUntouched(pump, state, RESERVE, 0); + } + } + + private static NativeSource source(boolean allowed, int amount) throws Exception { + Fluid fluid = new Fluid("advworks_electric_pump_test_" + IDS.incrementAndGet(), + new ResourceLocation("minecraft", "blocks/water_still"), + new ResourceLocation("minecraft", "blocks/water_flow")); + assertTrue("Register a real, unique Forge fixture fluid", FluidRegistry.registerFluid(fluid)); + NativeSource source = new NativeSource(fluid, allowed, amount); + Field cache = FluidRegistry.class.getDeclaredField("fluidBlocks"); + cache.setAccessible(true); + cache.set(null, null); + assertSame(fluid, FluidRegistry.lookupFluidForBlock(source)); + return source; + } + + private static StubPump fixture(IBlockState source) throws Exception { + PumpWorld world = withoutConstructor(PumpWorld.class); + world.blocks = new HashMap<>(); + world.blocks.put(SOURCE, source); + StubPump pump = new StubPump(); + pump.testWorld = world; + pump.setPos(PUMP); + pump.setEnergy(RESERVE, Power.ELECTRIC_POWER); + NBTTagCompound patch = new NBTTagCompound(); + patch.setByte("NextPump", (byte)0); + pump.loadFrom(patch); + return pump; + } + + private static void assertSuccessfulPickup(StubPump pump, Fluid expected) { + assertEquals(1000, pump.getTank().getFluidAmount()); + assertSame(expected, pump.getTank().getFluid().getFluid()); + assertEquals(RESERVE - PICKUP_COST, pump.getEnergy(Power.ELECTRIC_POWER), 0); + assertEquals(Blocks.AIR.getDefaultState(), pump.testWorld.getBlockState(SOURCE)); + assertEquals(1, pump.testWorld.writes); + assertEquals(2, pump.sounds); + } + + private static void assertUntouched(StubPump pump, IBlockState state, float energy, int fluid) { + assertEquals(state, pump.testWorld.getBlockState(SOURCE)); + assertEquals(0, pump.testWorld.writes); + assertEquals(energy, pump.getEnergy(Power.ELECTRIC_POWER), 0); + assertEquals(fluid, pump.getTank().getFluidAmount()); + assertEquals(0, pump.sounds); + } + + private static T withoutConstructor(Class type) throws Exception { + Class allocator = Class.forName("sun.misc.Unsafe"); + Field field = allocator.getDeclaredField("theUnsafe"); + field.setAccessible(true); + return type.cast(allocator.getMethod("allocateInstance", Class.class).invoke(field.get(null), type)); + } + + private static final class StubPump extends ElectricPumpTileEntity { + PumpWorld testWorld; + int sounds; + @Override public World getWorld() { return testWorld; } + @Override protected boolean hasRedstoneSignal() { return false; } + @Override protected void playSoundEffect(double x, double y, double z, SoundEvent sound, float volume, float pitch) { + sounds++; + } + } + + private static final class PumpWorld extends WorldServer { + Map blocks; + int writes; + private PumpWorld() { super(null, null, null, 0, null); } + @Override public boolean isBlockLoaded(BlockPos pos) { return true; } + @Override public IBlockState getBlockState(BlockPos pos) { return blocks.getOrDefault(pos, Blocks.STONE.getDefaultState()); } + @Override public boolean isAirBlock(BlockPos pos) { return getBlockState(pos).getMaterial() == Material.AIR; } + @Override public boolean setBlockState(BlockPos pos, IBlockState state, int flags) { + writes++; + blocks.put(pos.toImmutable(), state); + return true; + } + } + + private static final class NativeSource extends BlockFluidClassic { + final boolean allowed; + final int amount; + int permissionChecks, actualDrains; + NativeSource(Fluid fluid, boolean allowed, int amount) { + super(fluid, Material.WATER); + this.allowed = allowed; + this.amount = amount; + } + @Override public boolean isSourceBlock(IBlockAccess world, BlockPos pos) { return true; } + @Override public boolean canDrain(World world, BlockPos pos) { permissionChecks++; return allowed; } + @Override public FluidStack drain(World world, BlockPos pos, boolean real) { + if (!real) return new FluidStack(getFluid(), 1000); + actualDrains++; + if (!allowed || amount < 0) return null; + if (amount > 0) world.setBlockToAir(pos); + return new FluidStack(getFluid(), amount); + } + } +} diff --git a/src/test/java/com/mcmoddev/electricadvantage/machines/ElectricOperationalRedstoneCacheTest.java b/src/test/java/com/mcmoddev/electricadvantage/machines/ElectricOperationalRedstoneCacheTest.java new file mode 100644 index 0000000..637a870 --- /dev/null +++ b/src/test/java/com/mcmoddev/electricadvantage/machines/ElectricOperationalRedstoneCacheTest.java @@ -0,0 +1,217 @@ +package com.mcmoddev.electricadvantage.machines; + +import cyano.poweradvantage.api.PoweredEntity; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import net.minecraft.util.math.BlockPos; +import net.minecraft.init.Bootstrap; +import net.minecraft.world.World; +import net.minecraft.world.WorldServer; +import org.junit.BeforeClass; +import org.junit.Test; + +import static org.junit.Assert.*; + +/** Initial interlock sampling must not turn the ordinary cache into per-tick polling. */ +public class ElectricOperationalRedstoneCacheTest { + private static final BlockPos EVEN = new BlockPos(8, 80, 8); + private static final BlockPos ODD = new BlockPos(9, 81, 9); + + @BeforeClass public static void bootstrap() { Bootstrap.register(); } + + @Test public void signalledFirstUseDoesNotWaitForPowerUpdate() throws Exception { + ProbeMachine machine = fixture(EVEN, true); + assertTrue(machine.signal()); + assertEquals(1, machine.testWorld.queryTimes.size()); + machine.testWorld.signalled = false; + assertTrue("Repeated reads must retain the sampled interlock", machine.signal()); + assertEquals(1, machine.testWorld.queryTimes.size()); + } + + @Test public void unsignalledFirstUseIsSampledAndCachedToo() throws Exception { + ProbeMachine machine = fixture(EVEN, false); + assertFalse(machine.signal()); + assertEquals(1, machine.testWorld.queryTimes.size()); + machine.testWorld.signalled = true; + assertFalse("A cached false must not be mistaken for uninitialized", machine.signal()); + assertEquals(1, machine.testWorld.queryTimes.size()); + } + + @Test public void unattachedReadDoesNotInitializeTheWorldCache() throws Exception { + ProbeMachine machine = new ProbeMachine(); + machine.setPos(EVEN); + assertFalse(machine.signal()); + machine.testWorld = world(EVEN, true); + assertTrue(machine.signal()); + assertEquals(1, machine.testWorld.queryTimes.size()); + } + + @Test public void clientReadsNeitherQueryRedstoneNorInitializeTheCache() throws Exception { + ProbeMachine machine = fixture(EVEN, true); + SignalWorld client = machine.testWorld; + Field clientSide = World.class.getDeclaredField("isRemote"); + clientSide.setAccessible(true); + clientSide.setBoolean(client, true); + assertTrue(client.isRemote); + assertFalse(machine.signal()); + client.signalled = false; + assertFalse(machine.signal()); + assertTrue("Client reads must not query local redstone", client.queryTimes.isEmpty()); + machine.testWorld = world(EVEN, true); + assertTrue("Client reads must not initialize the server cache", machine.signal()); + assertEquals(1, machine.testWorld.queryTimes.size()); + } + + @Test public void powerUpdateBeforeFirstUseAlreadyInitializesTheCache() throws Exception { + ProbeMachine machine = fixture(EVEN, true); + machine.powerUpdate(); + assertEquals(1, machine.testWorld.queryTimes.size()); + machine.testWorld.signalled = false; + assertTrue(machine.signal()); + assertEquals("First use after powerUpdate must not resample", 1, machine.testWorld.queryTimes.size()); + } + + @Test public void realPoweredTickKeepsBothEdgesCachedUntilTheEightTickRefresh() throws Exception { + for (BlockPos position : new BlockPos[]{EVEN, ODD}) { + for (boolean initial : new boolean[]{false, true}) { + ProbeMachine machine = fixture(position, initial); + SignalWorld world = machine.testWorld; + long refresh = position.equals(EVEN) ? 8 : 9; + long first = refresh - 7; + world.time = first; + machine.update(); + assertEquals(initial, machine.observed); + assertEquals(1, world.queryTimes.size()); + world.signalled = !initial; + for (long tick = first + 1; tick <= refresh + 8; tick++) { + world.time = tick; + machine.update(); + assertEquals("tickUpdate must precede the scheduled refresh at " + tick, + tick <= refresh ? initial : !initial, machine.observed); + assertEquals(tick < refresh ? 1 : tick < refresh + 8 ? 2 : 3, world.queryTimes.size()); + } + assertEquals(Arrays.asList(first, refresh, refresh + 8), world.queryTimes); + assertEquals(2, machine.powerUpdates); + } + } + } + + private static ProbeMachine fixture(BlockPos position, boolean signalled) throws Exception { + ProbeMachine machine = new ProbeMachine(); + machine.setPos(position); + machine.testWorld = world(position, signalled); + // Isolate conduit discovery, not PoweredEntity's real power-update cadence. + Field delay = PoweredEntity.class.getDeclaredField("conduitRefreshDelay"); + delay.setAccessible(true); + delay.setInt(machine, -1); + return machine; + } + + private static SignalWorld world(BlockPos position, boolean signalled) throws Exception { + Class allocator = Class.forName("sun.misc.Unsafe"); + Field field = allocator.getDeclaredField("theUnsafe"); + field.setAccessible(true); + SignalWorld world = (SignalWorld)allocator.getMethod("allocateInstance", Class.class) + .invoke(field.get(null), SignalWorld.class); + world.position = position; + world.signalled = signalled; + world.queryTimes = new ArrayList<>(); + return world; + } + + private static final class ProbeMachine extends ElectricOvenTileEntity { + SignalWorld testWorld; + boolean observed; + int powerUpdates; + boolean signal() { return hasRedstoneSignal(); } + @Override public World getWorld() { return testWorld; } + @Override public void tickUpdate(boolean server) { observed = hasRedstoneSignal(); } + @Override public void powerUpdate() { + powerUpdates++; + super.powerUpdate(); + } + @Override protected void setPowerState(boolean powered) { } + } + + @Test public void generatorSamplesSignalledFirstUseBeforeItsFirstPulse() throws Exception { + ProbeGenerator generator = generatorFixture(true); + assertTrue("A newly loaded generator must observe redstone before generating", generator.signal()); + assertEquals(1, generator.testWorld.queryTimes.size()); + generator.testWorld.signalled = false; + assertTrue("First-use sampling must retain the normal cached value", generator.signal()); + assertEquals(1, generator.testWorld.queryTimes.size()); + } + + @Test public void generatorCachesAnUnsignalledFirstReadToo() throws Exception { + ProbeGenerator generator = generatorFixture(false); + assertFalse(generator.signal()); + assertEquals(1, generator.testWorld.queryTimes.size()); + generator.testWorld.signalled = true; + assertFalse("A false interlock sample is initialized, not missing", generator.signal()); + assertEquals(1, generator.testWorld.queryTimes.size()); + } + + @Test public void unattachedGeneratorReadDoesNotInitializeItsCache() throws Exception { + ProbeGenerator generator = new ProbeGenerator(); + generator.setPos(EVEN); + assertFalse(generator.signal()); + generator.testWorld = world(EVEN, true); + assertTrue(generator.signal()); + assertEquals(1, generator.testWorld.queryTimes.size()); + } + + @Test public void clientGeneratorReadCannotInitializeTheServerCache() throws Exception { + ProbeGenerator generator = generatorFixture(true); + Field clientSide = World.class.getDeclaredField("isRemote"); + clientSide.setAccessible(true); + clientSide.setBoolean(generator.testWorld, true); + assertFalse(generator.signal()); + assertTrue(generator.testWorld.queryTimes.isEmpty()); + generator.testWorld = world(EVEN, true); + assertTrue("A client-only read must leave the server interlock unsampled", generator.signal()); + assertEquals(1, generator.testWorld.queryTimes.size()); + } + + @Test public void inhibitedSolarGeneratorCannotGenerateItsFirstPulse() throws Exception { + ProbeGenerator generator = generatorFixture(true); + generator.powerUpdate(); + assertEquals("Redstone is sampled before generation, then on the ordinary refresh", 2, + generator.testWorld.queryTimes.size()); + assertEquals(0, generator.getEnergy(), 0); + assertEquals(0, generator.getPowerOutput(), 0); + } + + private static ProbeGenerator generatorFixture(boolean signalled) throws Exception { + ProbeGenerator generator = new ProbeGenerator(); + generator.setPos(EVEN); + generator.testWorld = world(EVEN, signalled); + return generator; + } + + private static final class ProbeGenerator extends PhotovoltaicGeneratorTileEntity { + SignalWorld testWorld; + boolean signal() { return hasRedstoneSignal(); } + @Override public World getWorld() { return testWorld; } + } + + private static final class SignalWorld extends WorldServer { + @Override public boolean canSeeSky(BlockPos position) { + throw new AssertionError("An inhibited panel must not enter light sampling"); + } + BlockPos position; + boolean signalled; + long time; + List queryTimes; + private SignalWorld() { + super(null, null, null, 0, null); + } + @Override public boolean isBlockPowered(BlockPos position) { + assertEquals(this.position, position); + queryTimes.add(time); + return signalled; + } + @Override public long getTotalWorldTime() { return time; } + } +} diff --git a/src/test/java/com/mcmoddev/electricadvantage/machines/ElectricOperationalSoundBroadcastTest.java b/src/test/java/com/mcmoddev/electricadvantage/machines/ElectricOperationalSoundBroadcastTest.java new file mode 100644 index 0000000..e81a490 --- /dev/null +++ b/src/test/java/com/mcmoddev/electricadvantage/machines/ElectricOperationalSoundBroadcastTest.java @@ -0,0 +1,134 @@ +package com.mcmoddev.electricadvantage.machines; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import com.google.common.base.Predicate; +import net.minecraft.entity.Entity; +import net.minecraft.entity.player.EntityPlayerMP; +import net.minecraft.network.Packet; +import net.minecraft.network.NetHandlerPlayServer; +import net.minecraft.network.play.server.SPacketCustomSound; +import net.minecraft.init.SoundEvents; +import net.minecraft.util.math.AxisAlignedBB; +import net.minecraft.init.Bootstrap; +import net.minecraft.world.World; +import net.minecraft.world.WorldServer; +import org.junit.BeforeClass; +import org.junit.Test; + +import static org.junit.Assert.*; + +/** Real broadcast methods with an explicit player list and forbidden chunk-backed entity queries. */ +public class ElectricOperationalSoundBroadcastTest { + @BeforeClass public static void bootstrap() { Bootstrap.register(); } + + @Test public void machineBroadcastUsesOnlyExistingIntersectingConnectedPlayers() throws Exception { + QueryRejectingWorld testWorld = fixture(); + ElectricOvenTileEntity machine = new ElectricOvenTileEntity() { + @Override public World getWorld() { return testWorld; } + }; + machine.playSoundEffect(100, 80, -20, SoundEvents.BLOCK_ANVIL_LAND, 0.25f, 0.75f); + assertRecipients(testWorld); + } + + @Test public void generatorBroadcastUsesOnlyExistingIntersectingConnectedPlayers() throws Exception { + QueryRejectingWorld testWorld = fixture(); + PhotovoltaicGeneratorTileEntity generator = new PhotovoltaicGeneratorTileEntity() { + @Override public World getWorld() { return testWorld; } + }; + generator.playSoundEffect(100, 80, -20, SoundEvents.BLOCK_ANVIL_LAND, 0.25f, 0.75f); + assertRecipients(testWorld); + } + + @Test public void emptyPlayerListNeverQueriesChunks() throws Exception { + QueryRejectingWorld testWorld = withoutConstructor(QueryRejectingWorld.class); + testWorld.existingPlayers = Collections.emptyList(); + ElectricOvenTileEntity machine = new ElectricOvenTileEntity() { + @Override public World getWorld() { return testWorld; } + }; + machine.playSoundEffect(50000, 82, 50000, SoundEvents.BLOCK_ANVIL_LAND, 1, 1); + assertEquals(1, testWorld.playerListReads); + } + + private static QueryRejectingWorld fixture() throws Exception { + QueryRejectingWorld world = withoutConstructor(QueryRejectingWorld.class); + world.existingPlayers = new ArrayList<>(); + world.existingPlayers.add(player(new AxisAlignedBB(99.7, 80, -20.3, 100.3, 81.8, -19.7), false, true)); + // Player center can be outside 16 blocks while its box still intersects the original cube. + world.existingPlayers.add(player(new AxisAlignedBB(115.9, 80, -20.3, 116.5, 81.8, -19.7), false, true)); + // Cube corner is outside a spherical range but was included by the original AABB query. + world.existingPlayers.add(player(new AxisAlignedBB(115, 80, -5, 115.6, 81.8, -4.4), false, true)); + world.existingPlayers.add(player(new AxisAlignedBB(116, 80, -20.3, 116.6, 81.8, -19.7), false, true)); + world.existingPlayers.add(player(new AxisAlignedBB(99.7, 96, -20.3, 100.3, 97.8, -19.7), false, true)); + world.existingPlayers.add(player(new AxisAlignedBB(99.7, 62.2, -20.3, 100.3, 64, -19.7), false, true)); + world.existingPlayers.add(player(new AxisAlignedBB(99.7, 80, -20.3, 100.3, 81.8, -19.7), true, true)); + world.existingPlayers.add(player(new AxisAlignedBB(99.7, 80, -20.3, 100.3, 81.8, -19.7), false, false)); + return world; + } + + private static StubPlayer player(AxisAlignedBB bounds, boolean spectator, boolean connected) throws Exception { + StubPlayer player = withoutConstructor(StubPlayer.class); + player.setEntityBoundingBox(bounds); + player.spectator = spectator; + if (connected) player.connection = withoutConstructor(RecordingConnection.class); + return player; + } + + private static void assertRecipients(QueryRejectingWorld world) { + assertEquals("Broadcast reads the existing player list once", 1, world.playerListReads); + for (int i = 0; i < world.existingPlayers.size(); i++) { + EntityPlayerMP player = world.existingPlayers.get(i); + if (player.connection == null) continue; + RecordingConnection connection = (RecordingConnection)player.connection; + assertEquals("Player " + i, i < 3 ? 1 : 0, connection.sent); + if (i < 3) assertTrue(connection.last instanceof SPacketCustomSound); + } + } + + private static T withoutConstructor(Class type) throws Exception { + Class allocator = Class.forName("sun.misc.Unsafe"); + Field field = allocator.getDeclaredField("theUnsafe"); + field.setAccessible(true); + return type.cast(allocator.getMethod("allocateInstance", Class.class).invoke(field.get(null), type)); + } + + private static final class QueryRejectingWorld extends WorldServer { + List existingPlayers; + int playerListReads; + + private QueryRejectingWorld() { + super(null, null, null, 0, null); + } + + @Override public List getPlayers(Class type, Predicate filter) { + playerListReads++; + assertEquals("Sound delivery must request players, not scan all entities", EntityPlayerMP.class, type); + List selected = new ArrayList<>(); + for (EntityPlayerMP player : existingPlayers) { + T candidate = type.cast(player); + if (filter == null || filter.apply(candidate)) selected.add(candidate); + } + return selected; + } + + @Override public List getEntitiesWithinAABB(Class type, + AxisAlignedBB bounds, Predicate predicate) { + throw new AssertionError("Sound broadcast must not use chunk-backed entity queries"); + } + } + + private static final class StubPlayer extends EntityPlayerMP { + boolean spectator; + private StubPlayer() { super(null, null, null, null); } + @Override public boolean isSpectator() { return spectator; } + } + + private static final class RecordingConnection extends NetHandlerPlayServer { + int sent; + Packet last; + private RecordingConnection() { super(null, null, null); } + @Override public void sendPacket(Packet packet) { sent++; last = packet; } + } +} diff --git a/src/test/java/com/mcmoddev/electricadvantage/machines/ElectricOperationalStillCapacityTest.java b/src/test/java/com/mcmoddev/electricadvantage/machines/ElectricOperationalStillCapacityTest.java new file mode 100644 index 0000000..2d00516 --- /dev/null +++ b/src/test/java/com/mcmoddev/electricadvantage/machines/ElectricOperationalStillCapacityTest.java @@ -0,0 +1,97 @@ +package com.mcmoddev.electricadvantage.machines; + +import net.minecraftforge.fluids.FluidStack; +import java.lang.reflect.Field; +import net.minecraftforge.fluids.FluidRegistry; +import net.minecraft.init.Bootstrap; +import org.junit.BeforeClass; +import org.junit.Before; +import org.junit.After; +import org.junit.Test; +import com.mcmoddev.electricadvantage.init.Power; +import com.mcmoddev.poweradvantage.registry.still.recipe.DistillationRecipe; +import com.mcmoddev.poweradvantage.registry.still.recipe.DistillationRecipeRegistry; +import com.mcmoddev.poweradvantage.registry.still.recipe.ExplicitFluidReference; + +import static org.junit.Assert.*; + +/** Real tick conservation checks; the shared rig separately supplies a live server world. */ +public class ElectricOperationalStillCapacityTest { + private Field singleton; + private Object previousRegistry; + + @BeforeClass public static void bootstrap() { Bootstrap.register(); } + + @Before public void installIsolatedRecipe() throws Exception { + singleton = DistillationRecipeRegistry.class.getDeclaredField("instance"); + singleton.setAccessible(true); + previousRegistry = singleton.get(null); + DistillationRecipeRegistry registry = new DistillationRecipeRegistry(); + registry.addRecipe(new DistillationRecipe(new ExplicitFluidReference(FluidRegistry.WATER), 2, + new ExplicitFluidReference(FluidRegistry.LAVA), 3)); + singleton.set(null, registry); + } + + @After public void restoreRecipeRegistry() throws Exception { + singleton.set(null, previousRegistry); + } + + @Test public void aFullTankCannotConsumeInput() throws Exception { + ElectricStillTileEntity still = fixture(1000); + for (int tick = 0; tick < 4; tick++) still.tickUpdate(true); + assertEquals(4, still.getInputTank().getFluidAmount()); + assertEquals(1000, still.getOutputTank().getFluidAmount()); + assertEquals(64, still.getEnergy(Power.ELECTRIC_POWER), 0); + } + + @Test public void aPartialOutputMustFitTheWholeRecipe() throws Exception { + ElectricStillTileEntity still = fixture(998); + still.tickUpdate(true); + assertEquals(4, still.getInputTank().getFluidAmount()); + assertEquals(998, still.getOutputTank().getFluidAmount()); + assertEquals(64, still.getEnergy(Power.ELECTRIC_POWER), 0); + } + + @Test public void exactSpaceProducesTheWholeBatchAndDebitsOnlyOneCycle() throws Exception { + ElectricStillTileEntity still = fixture(997); + still.tickUpdate(true); + assertEquals(2, still.getInputTank().getFluidAmount()); + assertEquals(1000, still.getOutputTank().getFluidAmount()); + assertSame(FluidRegistry.LAVA, still.getOutputTank().getFluid().getFluid()); + assertEquals(48, still.getEnergy(Power.ELECTRIC_POWER), 0); + still.tickUpdate(true); + assertEquals(2, still.getInputTank().getFluidAmount()); + assertEquals(1000, still.getOutputTank().getFluidAmount()); + assertEquals(48, still.getEnergy(Power.ELECTRIC_POWER), 0); + } + + @Test public void incompatibleOutputCannotBeOverwritten() throws Exception { + ElectricStillTileEntity still = fixture(0); + still.getOutputTank().setFluid(new FluidStack(FluidRegistry.WATER, 1)); + still.tickUpdate(true); + assertEquals(FluidRegistry.WATER, still.getOutputTank().getFluid().getFluid()); + assertEquals(4, still.getInputTank().getFluidAmount()); + assertEquals(64, still.getEnergy(Power.ELECTRIC_POWER), 0); + } + + @Test public void anIncompleteInputDoesNotInventAnOutput() throws Exception { + ElectricStillTileEntity still = fixture(0); + still.getInputTank().setFluid(new FluidStack(FluidRegistry.WATER, 1)); + still.tickUpdate(true); + assertEquals(1, still.getInputTank().getFluidAmount()); + assertEquals(0, still.getOutputTank().getFluidAmount()); + assertEquals(64, still.getEnergy(Power.ELECTRIC_POWER), 0); + } + + private static ElectricStillTileEntity fixture(int output) { + ElectricStillTileEntity still = new ElectricStillTileEntity() { + @Override protected boolean hasRedstoneSignal() { return false; } + @Override protected void setActiveState(boolean active) { } + }; + still.setEnergy(64, Power.ELECTRIC_POWER); + still.getInputTank().setFluid(new FluidStack(FluidRegistry.WATER, 4)); + if (output > 0) still.getOutputTank().setFluid(new FluidStack(FluidRegistry.LAVA, output)); + return still; + } + +} diff --git a/src/test/java/com/mcmoddev/electricadvantage/machines/GrowthChamberPersistenceTest.java b/src/test/java/com/mcmoddev/electricadvantage/machines/GrowthChamberPersistenceTest.java new file mode 100644 index 0000000..f68de77 --- /dev/null +++ b/src/test/java/com/mcmoddev/electricadvantage/machines/GrowthChamberPersistenceTest.java @@ -0,0 +1,56 @@ +package com.mcmoddev.electricadvantage.machines; + +import net.minecraft.init.Bootstrap; +import net.minecraft.init.Items; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.NBTTagCompound; +import org.junit.BeforeClass; +import org.junit.Test; + +import static org.junit.Assert.*; + +public class GrowthChamberPersistenceTest { + @BeforeClass public static void bootstrap() { Bootstrap.register(); } + + @Test public void shortArraysArePaddedAndNewNbtClearsPendingWork() { + GrowthChamberTileEntity chamber = new GrowthChamberTileEntity(); + NBTTagCompound source = new NBTTagCompound(); + source.setByteArray("growth", new byte[] {7}); + source.setIntArray("prog", new int[] {797}); + chamber.loadFrom(source); + NBTTagCompound saved = new NBTTagCompound(); + chamber.saveTo(saved); + assertArrayEquals(new byte[] {7, 0, 0}, saved.getByteArray("growth")); + assertArrayEquals(new int[] {797, 0, 0}, saved.getIntArray("prog")); + chamber.loadFrom(new NBTTagCompound()); + chamber.saveTo(saved); + assertArrayEquals(new byte[] {0, 0, 0}, saved.getByteArray("growth")); + assertArrayEquals(new int[] {0, 0, 0}, saved.getIntArray("prog")); + } + + @Test public void fullOutputStageNineSurvivesUntickedReadSave() { + NBTTagCompound source = new NBTTagCompound(); + source.setByteArray("growth", new byte[] {9, 0, 0}); + source.setIntArray("prog", new int[] {0, 0, 0}); + for (int pass = 0; pass < 3; pass++) { + GrowthChamberTileEntity chamber = new GrowthChamberTileEntity(); + chamber.setInputSlot(0, new ItemStack(Items.REEDS)); + for (int slot = 0; slot < chamber.numberOfOutputSlots(); slot++) { + chamber.setOutputSlot(slot, new ItemStack(Items.REEDS, 64)); + } + chamber.loadFrom(source); + NBTTagCompound saved = new NBTTagCompound(); + chamber.saveTo(saved); + assertArrayEquals("Cold read/save must retain the exact mature crop stage", + new byte[] {9, 0, 0}, saved.getByteArray("growth")); + assertArrayEquals(new int[] {0, 0, 0}, saved.getIntArray("prog")); + assertSame(Items.REEDS, chamber.getInputSlot(0).getItem()); + assertEquals(1, chamber.getInputSlot(0).getCount()); + for (int slot = 0; slot < chamber.numberOfOutputSlots(); slot++) { + assertSame(Items.REEDS, chamber.getOutputSlot(slot).getItem()); + assertEquals(64, chamber.getOutputSlot(slot).getCount()); + } + source = saved; + } + } +}