diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 56a64fd..b34ccd9 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,20 @@ +Unreleased + +- Check generator redstone before the first power pulse so a disabled solar panel cannot produce a startup burst. +- Restore the photovoltaic panel's full-block property on Minecraft 1.13.2. +- Keep growth chamber crop stages and unfinished growth through a full restart, including saves before the chamber attaches to its world. + +- Stop the electric still consuming oil and power when its full output will not fit. +- Drain waterlogged slabs and stairs without destroying the blocks, and only charge pump power after a successful pickup. +- Keep growth controllers from requesting more electricity than they can store or use. +- Read light above the solar panel so its solid body does not block its own light reading. +- Let turret beams leave their own mounting post while still respecting immediately adjacent cover and their existing range. +- Keep hydroturbines anchored to their generators so flowing water cannot build up invalid movement across a restart. +- Sample a machine's redstone interlock on its first server tick, then retain the normal eight-tick refresh. +- Send machine sounds to nearby connected players without loading surrounding chunks. +- Use an empty or full-scale hydroelectric display without changing the existing power-output reading or electricity generation. +- Add operating and persistence regression tests, including blocked outputs, refused pickups and unchanged waterlogged host blocks. + Electric Advantage 2.2.5.113021 - Keep development archives identical in existing workspaces and fresh CI checkouts. diff --git a/README.md b/README.md index 965056c..76187dc 100644 --- a/README.md +++ b/README.md @@ -37,9 +37,40 @@ 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.electricadvantage`. Runtime identities, registries, tile/entity IDs, NBT, recipes, tags, OreSpawn provider IDs, and assets remain stable. Electric registers its legacy state, tile, and hydroturbine aliases with PowerAdvantage's shared migration hook. The RF- and Tech Reborn-labelled converters belong to Power Advantage and now use Forge Energy. Read the [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; +No release publication is included. diff --git a/src/main/java/zone/moddev/mc/electricadvantage/entities/HydroturbineEntity.java b/src/main/java/zone/moddev/mc/electricadvantage/entities/HydroturbineEntity.java index d9bad84..00c3c45 100644 --- a/src/main/java/zone/moddev/mc/electricadvantage/entities/HydroturbineEntity.java +++ b/src/main/java/zone/moddev/mc/electricadvantage/entities/HydroturbineEntity.java @@ -50,6 +50,12 @@ protected void registerData() { // do nothing } + @Override + public boolean isPushedByWater() { + // Anchored to the generator: flow drives rotation, not linear movement. + return false; + } + @Override public void tick(){ super.tick(); diff --git a/src/main/java/zone/moddev/mc/electricadvantage/gui/GUIHelper.java b/src/main/java/zone/moddev/mc/electricadvantage/gui/GUIHelper.java index d147aca..23e4341 100644 --- a/src/main/java/zone/moddev/mc/electricadvantage/gui/GUIHelper.java +++ b/src/main/java/zone/moddev/mc/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/zone/moddev/mc/electricadvantage/machines/ElectricGeneratorTileEntity.java b/src/main/java/zone/moddev/mc/electricadvantage/machines/ElectricGeneratorTileEntity.java index e928dc3..4f7c0a0 100644 --- a/src/main/java/zone/moddev/mc/electricadvantage/machines/ElectricGeneratorTileEntity.java +++ b/src/main/java/zone/moddev/mc/electricadvantage/machines/ElectricGeneratorTileEntity.java @@ -18,7 +18,6 @@ import zone.moddev.mc.electricadvantage.ElectricAdvantage; import java.util.Arrays; -import java.util.List; public abstract class ElectricGeneratorTileEntity extends cyano.poweradvantage.api.simple.TileEntitySimplePowerMachine { @@ -46,12 +45,14 @@ public ElectricGeneratorTileEntity(TileEntityType tileType, String name,int n private boolean redstone = false; + private boolean redstoneInitialized = false; private float oldEnergy = 0f; @Override public void powerUpdate(){ super.powerUpdate(); redstone = getWorld().isBlockPowered(getPos()); + redstoneInitialized = true; if(oldEnergy != this.getEnergy(Power.ELECTRIC_POWER)){ this.sync(); @@ -59,6 +60,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; } @@ -89,10 +95,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); + // The typed player query scans the existing player list, not surrounding chunks. + for(EntityPlayerMP player : getWorld().getPlayers(EntityPlayerMP.class, + candidate -> !candidate.isSpectator() && candidate.getBoundingBox().intersects(bounds))){ + if(player.connection == null) continue; player.connection.sendPacket(new SPacketCustomSound(sound.getRegistryName(), SoundCategory.BLOCKS, new net.minecraft.util.math.Vec3d(x, y, z), volume, pitch)); } diff --git a/src/main/java/zone/moddev/mc/electricadvantage/machines/ElectricMachineTileEntity.java b/src/main/java/zone/moddev/mc/electricadvantage/machines/ElectricMachineTileEntity.java index 478d5c4..e10ad06 100644 --- a/src/main/java/zone/moddev/mc/electricadvantage/machines/ElectricMachineTileEntity.java +++ b/src/main/java/zone/moddev/mc/electricadvantage/machines/ElectricMachineTileEntity.java @@ -22,7 +22,6 @@ import zone.moddev.mc.electricadvantage.ElectricAdvantage; import java.util.Arrays; -import java.util.List; public abstract class ElectricMachineTileEntity extends cyano.poweradvantage.api.simple.TileEntitySimplePowerMachine{ @@ -71,6 +70,7 @@ public ElectricMachineTileEntity(TileEntityType tileType, String name,int num } private boolean redstone = false; + private boolean redstoneInitialized = false; private float oldEnergy = 0f; private int[] syncArrayOld = null; private int[] syncArrayNew = null; @@ -79,6 +79,7 @@ public void powerUpdate(){ super.powerUpdate(); redstone = getWorld().isBlockPowered(getPos()); + redstoneInitialized = true; this.setPowerState(this.isPowered()); @@ -99,6 +100,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; } @@ -366,10 +372,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); + // The typed player query scans the existing player list, not surrounding chunks. + for(EntityPlayerMP player : getWorld().getPlayers(EntityPlayerMP.class, + candidate -> !candidate.isSpectator() && candidate.getBoundingBox().intersects(bounds))){ + if(player.connection == null) continue; player.connection.sendPacket(new SPacketCustomSound(sound.getRegistryName(), SoundCategory.BLOCKS, new net.minecraft.util.math.Vec3d(x, y, z), volume, pitch)); } diff --git a/src/main/java/zone/moddev/mc/electricadvantage/machines/ElectricPumpTileEntity.java b/src/main/java/zone/moddev/mc/electricadvantage/machines/ElectricPumpTileEntity.java index fb5f954..7db598b 100644 --- a/src/main/java/zone/moddev/mc/electricadvantage/machines/ElectricPumpTileEntity.java +++ b/src/main/java/zone/moddev/mc/electricadvantage/machines/ElectricPumpTileEntity.java @@ -9,6 +9,7 @@ import zone.moddev.mc.poweradvantage.init.Fluids; import zone.moddev.mc.poweradvantage.util.FluidIdHelper; import net.minecraft.block.state.IBlockState; +import net.minecraft.block.IBucketPickupHandler; import net.minecraft.init.SoundEvents; import net.minecraft.item.ItemStack; import net.minecraft.nbt.NBTTagCompound; @@ -83,12 +84,14 @@ 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, FluidTank.BUCKET_VOLUME), true); - this.subtractEnergy(cost, Power.ELECTRIC_POWER); - w.setBlockState(fluidSource, net.minecraft.init.Blocks.AIR.getDefaultState(), 3); - success = true; + if(blockstate.getBlock() instanceof IBucketPickupHandler && getEnergy() >= cost){ + // Native pickup drains waterlogging without deleting the containing block. + Fluid f = ((IBucketPickupHandler)blockstate.getBlock()).pickupFluid(w, fluidSource, blockstate); + if(f != null && f != net.minecraft.init.Fluids.EMPTY){ + this.getTank().fill(new FluidStack(f, FluidTank.BUCKET_VOLUME), true); + this.subtractEnergy(cost, Power.ELECTRIC_POWER); + success = true; + } } } timeUntilNextPump = PUMP_INTERVAL; @@ -130,12 +133,6 @@ protected boolean isValidInputItem(ItemStack item) { } - private Fluid getFluid(IBlockState blockstate) { - return blockstate.getFluidState().isEmpty() ? null : blockstate.getFluidState().getFluid(); - } - - - private BlockPos scan(Set searchSpace, BlockPos coord, int limit) { if(isFluidBlock(coord) == false) return null; do{ @@ -162,7 +159,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.getBlock() instanceof IBucketPickupHandler && state.getFluidState().isSource(); } diff --git a/src/main/java/zone/moddev/mc/electricadvantage/machines/ElectricStillTileEntity.java b/src/main/java/zone/moddev/mc/electricadvantage/machines/ElectricStillTileEntity.java index 19914ad..d2896f3 100644 --- a/src/main/java/zone/moddev/mc/electricadvantage/machines/ElectricStillTileEntity.java +++ b/src/main/java/zone/moddev/mc/electricadvantage/machines/ElectricStillTileEntity.java @@ -48,7 +48,8 @@ public void tickUpdate(boolean isServerWorld) { if(!this.hasRedstoneSignal()){ if(getInputTank().getFluidAmount() > 0 && getEnergy() >= electricityPerDistill - && canDistill(getInputTank().getFluid())){ + && canDistill(getInputTank().getFluid()) + && hasSpaceForDistillation()){ distill(); if(!wasActive){ this.setActiveState(true); @@ -93,6 +94,14 @@ private boolean canDistill(FluidStack fs) { } } + private boolean hasSpaceForDistillation() { + DistillationRecipe recipe = DistillationRecipeRegistry.getInstance() + .getDistillationRecipeForFluid(getInputTank().getFluid().getFluid()); + // applyRecipe consumes its input, so preview a copy before committing either tank. + FluidStack output = recipe.applyRecipe(getInputTank().getFluid().copy(), speed); + return output != null && getOutputTank().fill(output, false) == output.amount; + } + private void distill(){ DistillationRecipe recipe = DistillationRecipeRegistry.getInstance() .getDistillationRecipeForFluid(getInputTank().getFluid().getFluid()); diff --git a/src/main/java/zone/moddev/mc/electricadvantage/machines/GrowthChamberControllerTileEntity.java b/src/main/java/zone/moddev/mc/electricadvantage/machines/GrowthChamberControllerTileEntity.java index 9b46b71..a6a4a22 100644 --- a/src/main/java/zone/moddev/mc/electricadvantage/machines/GrowthChamberControllerTileEntity.java +++ b/src/main/java/zone/moddev/mc/electricadvantage/machines/GrowthChamberControllerTileEntity.java @@ -313,7 +313,10 @@ public PowerRequest getPowerRequest(ConduitType offer) { float powerWanted = (this.getEnergyCapacity(Power.GROWTHCHAMBER_POWER) - this.getEnergy(Power.GROWTHCHAMBER_POWER)); powerWanted = Math.min(powerWanted, soil / SOIL_PER_UNIT); powerWanted = Math.min(powerWanted, tank.getFluidAmount() / WATER_PER_UNIT); - return new PowerRequest(PowerRequest.MEDIUM_PRIORITY,ELECTRICITY_PER_UNIT * powerWanted,this); + float stored = getEnergy(Power.ELECTRIC_POWER); + float room = getEnergyCapacity(Power.ELECTRIC_POWER) - stored; + float electricityWanted = Math.max(0, Math.min(room, ELECTRICITY_PER_UNIT * powerWanted - stored)); + return new PowerRequest(PowerRequest.MEDIUM_PRIORITY,electricityWanted,this); } else { return PowerRequest.REQUEST_NOTHING; } diff --git a/src/main/java/zone/moddev/mc/electricadvantage/machines/GrowthChamberTileEntity.java b/src/main/java/zone/moddev/mc/electricadvantage/machines/GrowthChamberTileEntity.java index 64c30a8..8cc1636 100644 --- a/src/main/java/zone/moddev/mc/electricadvantage/machines/GrowthChamberTileEntity.java +++ b/src/main/java/zone/moddev/mc/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(zone.moddev.mc.electricadvantage.init.Entities.GROWTH_CHAMBER, "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.contains("growth", 99)){ - 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.contains("growth", 7) + ? Arrays.copyOf(tagRoot.getByteArray("growth"), GROWTH_AREA) : null; + if(tagRoot.contains("prog", 11)){ + int[] saved = tagRoot.getIntArray("prog"); + System.arraycopy(saved, 0, progression, 0, Math.min(saved.length, progression.length)); } - if(tagRoot.contains("prog", 99)){ - 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/zone/moddev/mc/electricadvantage/machines/HydroelectricGeneratorTileEntity.java b/src/main/java/zone/moddev/mc/electricadvantage/machines/HydroelectricGeneratorTileEntity.java index 143a22a..158672c 100644 --- a/src/main/java/zone/moddev/mc/electricadvantage/machines/HydroelectricGeneratorTileEntity.java +++ b/src/main/java/zone/moddev/mc/electricadvantage/machines/HydroelectricGeneratorTileEntity.java @@ -59,6 +59,13 @@ public float getPowerOutput() { } } + private final float[] progress = new float[1]; + + public float[] getProgress() { + progress[0] = isActive() ? 1F : 0F; + return progress; + } + @Override protected boolean isValidInputItem(ItemStack item) { return false; diff --git a/src/main/java/zone/moddev/mc/electricadvantage/machines/LaserTurretTileEntity.java b/src/main/java/zone/moddev/mc/electricadvantage/machines/LaserTurretTileEntity.java index bf6af06..d6dab94 100644 --- a/src/main/java/zone/moddev/mc/electricadvantage/machines/LaserTurretTileEntity.java +++ b/src/main/java/zone/moddev/mc/electricadvantage/machines/LaserTurretTileEntity.java @@ -3,6 +3,7 @@ import zone.moddev.mc.electricadvantage.ElectricAdvantage; import zone.moddev.mc.electricadvantage.init.Power; import net.minecraft.block.Block; +import net.minecraft.block.state.IBlockState; import net.minecraft.entity.Entity; import net.minecraft.entity.EntityLivingBase; import net.minecraft.entity.EnumCreatureType; @@ -291,9 +292,30 @@ 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, RayTraceFluidMode.SOURCE_ONLY, 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){ + // Check the entry face from the original optic, even for very thin adjacent shapes. + BlockPos firstPos = new BlockPos(traceOrigin); + IBlockState firstState = world.getBlockState(firstPos); + if(!firstState.getCollisionShape(world, firstPos).isEmpty() && firstState.getBlock().isCollidable(firstState)){ + impact = Block.collisionRayTrace(firstState, world, firstPos, origin, max); + } + } + if(impact == null) impact = world.rayTraceBlocks(traceOrigin, max, RayTraceFluidMode.SOURCE_ONLY, true, false); if(impact != null && impact.type == RayTraceResult.Type.BLOCK){ + // Native 1.13 rays starting inside a neighbour report a point just inside its shape. + RayTraceResult exact = Block.collisionRayTrace(world.getBlockState(impact.getBlockPos()), + 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/zone/moddev/mc/electricadvantage/machines/PhotovoltaicGeneratorBlock.java b/src/main/java/zone/moddev/mc/electricadvantage/machines/PhotovoltaicGeneratorBlock.java index 0d7bbdb..a171e0e 100644 --- a/src/main/java/zone/moddev/mc/electricadvantage/machines/PhotovoltaicGeneratorBlock.java +++ b/src/main/java/zone/moddev/mc/electricadvantage/machines/PhotovoltaicGeneratorBlock.java @@ -23,6 +23,11 @@ public boolean isFullCube(IBlockState bs) { return true; } + + @Override + public boolean isSolid(IBlockState state) { + return true; + } @Override public boolean isOpaqueCube(IBlockState bs) diff --git a/src/main/java/zone/moddev/mc/electricadvantage/machines/PhotovoltaicGeneratorTileEntity.java b/src/main/java/zone/moddev/mc/electricadvantage/machines/PhotovoltaicGeneratorTileEntity.java index 0f36592..76761c8 100644 --- a/src/main/java/zone/moddev/mc/electricadvantage/machines/PhotovoltaicGeneratorTileEntity.java +++ b/src/main/java/zone/moddev/mc/electricadvantage/machines/PhotovoltaicGeneratorTileEntity.java @@ -92,8 +92,10 @@ public void powerUpdate(){ } public static float getLightIntensityAt(BlockPos pos, World w){ + // Sample incident light above the solid panel, not inside its body. + BlockPos lightPos = pos.up(); float light; - if(w.canSeeSky(pos) && w.dimension.hasSkyLight()){ + if(w.canSeeSky(lightPos) && w.dimension.hasSkyLight()){ // outdoor light imposes no penalty light = MathHelper.sin((float)w.getDayTime() * 2.61799E-4f); // (worldTime * pi / 12000); // handle weather @@ -103,7 +105,7 @@ public static float getLightIntensityAt(BlockPos pos, World w){ light *= 0.35f; } } else { - float blockLight = w.getLight(pos) * 6.66666E-2f; // block light / 15 + float blockLight = w.getLight(lightPos) * 6.66666E-2f; // block light / 15 light = BLOCKLIGHT_FACTOR * blockLight; } return light; diff --git a/src/test/java/zone/moddev/mc/electricadvantage/entities/ElectricOperationalHydroturbineAnchorTest.java b/src/test/java/zone/moddev/mc/electricadvantage/entities/ElectricOperationalHydroturbineAnchorTest.java new file mode 100644 index 0000000..c885b23 --- /dev/null +++ b/src/test/java/zone/moddev/mc/electricadvantage/entities/ElectricOperationalHydroturbineAnchorTest.java @@ -0,0 +1,117 @@ +package zone.moddev.mc.electricadvantage.entities; + +import java.lang.reflect.Field; +import java.util.Random; +import net.minecraft.entity.Entity; +import net.minecraft.entity.EntityType; +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.nbt.NBTTagDouble; +import net.minecraft.nbt.NBTTagList; +import net.minecraft.util.math.Vec3d; +import zone.moddev.mc.electricadvantage.testsupport.Forge113TestBootstrap; +import net.minecraft.world.World; +import net.minecraft.world.dimension.DimensionType; +import net.minecraft.world.dimension.OverworldDimension; +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() { Forge113TestBootstrap.register(); } + + @Test public void nativeEntityLoadStillClampsOverspeedMotionAndPreservesItsBoundary() throws Exception { + ControlEntity control = new ControlEntity(controlType(), world()); + NBTTagCompound tag = control.writeWithoutTypeId(new NBTTagCompound()); + tag.setTag("Motion", motion(16.226, -10.001, 10)); + control.read(tag); + assertEquals(new Vec3d(0, 0, 10), motion(control)); + assertTrue(control.isPushedByWater()); + } + + @Test public void turbineRejectsLinearWaterPushingWithoutChangingRotationRate() throws Exception { + HydroturbineEntity turbine = new HydroturbineEntity(turbineType(), 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(turbineType(), world()); + NBTTagCompound saved = turbine.writeWithoutTypeId(new NBTTagCompound()); + saved.setTag("Motion", motion(5.5, -2, 0)); + turbine.read(saved); + assertEquals(new Vec3d(5.5, -2, 0), motion(turbine)); + assertFalse(turbine.isPushedByWater()); + } + + @Test public void anchoredTurbineSurvivesNativeSaveLoadWithItsParentAndIdentity() throws Exception { + EntityType type = turbineType(); + World world = world(); + HydroturbineEntity original = new HydroturbineEntity(type, world); + original.setPosition(56856.5, 81, 50008.5); + NBTTagCompound saved = original.writeWithoutTypeId(new NBTTagCompound()); + int[] parent = {56856, 82, 50008}; + saved.setIntArray("parentPos", parent); + for (int pass = 0; pass < 3; pass++) { + HydroturbineEntity loaded = new HydroturbineEntity(type, world); + loaded.read(saved); + NBTTagCompound next = loaded.writeWithoutTypeId(new NBTTagCompound()); + assertEquals(saved.getUniqueId("UUID"), next.getUniqueId("UUID")); + assertEquals(saved.getList("Pos", 6), next.getList("Pos", 6)); + assertEquals(motion(0, 0, 0), next.getList("Motion", 6)); + assertArrayEquals(parent, next.getIntArray("parentPos")); + assertFalse(loaded.isPushedByWater()); + saved = next; + } + } + + private static 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.add(new NBTTagDouble(x)); list.add(new NBTTagDouble(y)); list.add(new NBTTagDouble(z)); + return list; + } + + private static EntityType turbineType() { + return EntityType.Builder.create(HydroturbineEntity.class, world -> new HydroturbineEntity(null, world)) + .build("electricadvantage:test_anchored_turbine"); + } + + private static EntityType controlType() { + return EntityType.Builder.create(ControlEntity.class, world -> new ControlEntity(null, world)) + .build("electricadvantage:test_native_motion_control"); + } + + private static World world() throws Exception { + Class allocator = Class.forName("sun.misc.Unsafe"); + Field field = allocator.getDeclaredField("theUnsafe"); + field.setAccessible(true); + World world = (World)allocator.getMethod("allocateInstance", Class.class) + .invoke(field.get(null), NoTickWorld.class); + Field random = World.class.getField("rand"); + random.setAccessible(true); + random.set(world, new Random(0)); + Field dimension = World.class.getField("dimension"); + dimension.setAccessible(true); + dimension.set(world, new OverworldDimension(DimensionType.OVERWORLD)); + return world; + } + + private static final class NoTickWorld extends WorldServer { + private NoTickWorld() { + super(null, null, null, null, null, null); + } + } + + private static final class ControlEntity extends Entity { + ControlEntity(EntityType type, World world) { super(type, world); } + @Override protected void registerData() {} + @Override protected void readAdditional(NBTTagCompound tag) {} + @Override protected void writeAdditional(NBTTagCompound tag) {} + } +} diff --git a/src/test/java/zone/moddev/mc/electricadvantage/gui/GUIHelperTest.java b/src/test/java/zone/moddev/mc/electricadvantage/gui/GUIHelperTest.java new file mode 100644 index 0000000..ac6e55d --- /dev/null +++ b/src/test/java/zone/moddev/mc/electricadvantage/gui/GUIHelperTest.java @@ -0,0 +1,38 @@ +package zone.moddev.mc.electricadvantage.gui; + +import java.lang.reflect.Method; +import org.junit.Test; +import static org.junit.Assert.*; + +public class GUIHelperTest { + @Test public void barRetainsItsEightSegmentScale() throws Exception { + assertEquals(0, progressBarWidth(0F)); + assertEquals(17, progressBarWidth(0.5F)); + assertEquals(33, progressBarWidth(1F)); + } + + @Test public void invalidAndOversizedValuesCannotEscapeTheTexture() throws Exception { + for (float value : new float[]{-1F, Float.NEGATIVE_INFINITY, Float.POSITIVE_INFINITY, Float.NaN}) { + assertEquals(0, progressBarWidth(value)); + } + assertEquals(33, progressBarWidth(15F)); + assertEquals(33, progressBarWidth(Float.MAX_VALUE)); + } + + @Test public void finiteReadingsAreMonotonicAndBounded() throws Exception { + int previous = 0; + for (int i = -100; i <= 200; i++) { + int width = progressBarWidth(i / 100F); + assertTrue(width >= previous && width >= 0 && width <= 33); + previous = width; + } + } + + private static int progressBarWidth(float value) throws Exception { + Method method; + try { method = GUIHelper.class.getDeclaredMethod("progressBarWidth", float.class); } + catch (NoSuchMethodException missing) { throw new AssertionError("Electric progress bars need one finite, bounded eight-segment scale", missing); } + method.setAccessible(true); + return (Integer) method.invoke(null, value); + } +} diff --git a/src/test/java/zone/moddev/mc/electricadvantage/machines/ElectricOperationalNativeDefectsTest.java b/src/test/java/zone/moddev/mc/electricadvantage/machines/ElectricOperationalNativeDefectsTest.java new file mode 100644 index 0000000..66f457a --- /dev/null +++ b/src/test/java/zone/moddev/mc/electricadvantage/machines/ElectricOperationalNativeDefectsTest.java @@ -0,0 +1,234 @@ +package zone.moddev.mc.electricadvantage.machines; + +import cyano.poweradvantage.api.fluid.FluidStack; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import net.minecraft.init.Blocks; +import net.minecraft.block.state.IBlockState; +import net.minecraft.entity.Entity; +import net.minecraft.entity.item.EntityItem; +import net.minecraft.init.Fluids; +import net.minecraft.fluid.IFluidState; +import net.minecraft.util.EnumFacing; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.Vec3d; +import zone.moddev.mc.electricadvantage.testsupport.Forge113TestBootstrap; +import net.minecraft.world.World; +import net.minecraft.world.WorldServer; +import org.junit.BeforeClass; +import org.junit.Test; +import zone.moddev.mc.electricadvantage.init.Power; + +import static org.junit.Assert.*; + +/** Operating regressions; the packaged rig independently checks natural machine ticks. */ +public class ElectricOperationalNativeDefectsTest { + @BeforeClass public static void bootstrap() { Forge113TestBootstrap.register(); } + + @Test public void controllerCannotRequestMoreThanItsHundredUnitElectricalBuffer() throws Exception { + GrowthChamberControllerTileEntity controller = controller(); + assertEquals(100, controller.getPowerRequest(Power.ELECTRIC_POWER).amount, 0); + assertEquals(0, controller.getEnergy(Power.ELECTRIC_POWER), 0); + assertEquals(1000, controller.getSoil(), 0); + assertEquals(1800, controller.getTank().getFluidAmount()); + } + + @Test public void controllerSubtractsElectricityAlreadyStoredFromInputCapacity() throws Exception { + GrowthChamberControllerTileEntity controller = controller(); + controller.setEnergy(40, Power.ELECTRIC_POWER); + assertEquals(60, controller.getPowerRequest(Power.ELECTRIC_POWER).amount, 0); + controller.setEnergy(100, Power.ELECTRIC_POWER); + assertEquals(0, controller.getPowerRequest(Power.ELECTRIC_POWER).amount, 0); + } + + @Test public void scarceResourcesAndExistingElectricityBoundTheRemainingDemand() throws Exception { + GrowthChamberControllerTileEntity controller = controller(); + controller.setSoil(1); + controller.getTank().setFluid(new FluidStack(Fluids.WATER, 2)); + controller.setEnergy(4, Power.ELECTRIC_POWER); + assertEquals(4, controller.getPowerRequest(Power.ELECTRIC_POWER).amount, 0); + controller.setEnergy(999.75f, Power.GROWTHCHAMBER_POWER); + assertEquals(0, controller.getPowerRequest(Power.ELECTRIC_POWER).amount, 0); + } + + @Test public void fullGreenhouseBufferCannotRequestMoreElectricity() throws Exception { + GrowthChamberControllerTileEntity controller = controller(); + controller.setEnergy(1000, Power.GROWTHCHAMBER_POWER); + assertEquals(0, controller.getPowerRequest(Power.ELECTRIC_POWER).amount, 0); + } + + @Test public void hydroelectricDisplayIsNormalizedWithoutChangingStoredPowerOrRatedGeneration() throws Exception { + HydroelectricGeneratorTileEntity active = new HydroelectricGeneratorTileEntity() { + @Override public boolean isActive() { return true; } + }; + HydroelectricGeneratorTileEntity idle = new HydroelectricGeneratorTileEntity() { + @Override public boolean isActive() { return false; } + }; + active.setEnergy(37, Power.ELECTRIC_POWER); + idle.setEnergy(19, Power.ELECTRIC_POWER); + assertEquals(1, hydroelectricDisplay(active).length); + assertEquals(1, hydroelectricDisplay(active)[0], 0); + assertEquals(0, hydroelectricDisplay(idle)[0], 0); + assertEquals(15, active.getPowerOutput(), 0); + assertEquals(0, idle.getPowerOutput(), 0); + assertEquals(37, active.getEnergy(Power.ELECTRIC_POWER), 0); + assertEquals(19, idle.getEnergy(Power.ELECTRIC_POWER), 0); + assertEquals(4, HydroelectricGeneratorTileEntity.ENERGY_PER_TICK, 0); + } + + @Test public void panelSamplesIncidentLightAboveItsSolidBody() throws Exception { + LightWorld world = withoutConstructor(LightWorld.class); + BlockPos panel = new BlockPos(101, 82, -27); + world.surface = panel.up(); + world.brightness = 14; + assertEquals(0.1f * 14 * 0.0666666f, + PhotovoltaicGeneratorTileEntity.getLightIntensityAt(panel, world), 0.000001f); + assertEquals(1, world.skyReads); + assertEquals(1, world.lightReads); + } + + @Test public void darkPanelSurfaceProducesNoBlocklightPower() throws Exception { + LightWorld world = withoutConstructor(LightWorld.class); + BlockPos panel = new BlockPos(101, 82, -27); + world.surface = panel.up(); + assertEquals(0, PhotovoltaicGeneratorTileEntity.getLightIntensityAt(panel, world), 0); + } + + @Test public void beamIgnoresOnlyItsOwnCollisionPostAndStopsAtTheDeclaredRange() throws Exception { + BeamWorld world = withoutConstructor(BeamWorld.class); + world.turret = new BlockPos(101, 82, -27); + StubTurret turret = withoutConstructor(StubTurret.class); + turret.testWorld = world; + turret.setPos(world.turret); + 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(Fluids.WATER, 1800)); + Field redstone = GrowthChamberControllerTileEntity.class.getDeclaredField("redstone"); + redstone.setAccessible(true); + redstone.setBoolean(controller, false); + return controller; + } + + private static T withoutConstructor(Class type) throws Exception { + Class allocator = Class.forName("sun.misc.Unsafe"); + Field field = allocator.getDeclaredField("theUnsafe"); + field.setAccessible(true); + return type.cast(allocator.getMethod("allocateInstance", Class.class).invoke(field.get(null), type)); + } + + private static final class LightWorld extends WorldServer { + BlockPos surface; + int brightness, skyReads, lightReads; + private LightWorld() { + super(null, null, null, null, null, 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 getLight(BlockPos pos) { + assertEquals("Incident block light must be sampled above the solid panel", surface, pos); + lightReads++; + return brightness; + } + } + + private static final class BeamWorld extends WorldServer { + BlockPos turret, wall; + int reads; + private BeamWorld() { super(null, null, null, null, null, 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 IFluidState getFluidState(BlockPos pos) { return getBlockState(pos).getFluidState(); } + @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/zone/moddev/mc/electricadvantage/machines/ElectricOperationalPumpPickupTest.java b/src/test/java/zone/moddev/mc/electricadvantage/machines/ElectricOperationalPumpPickupTest.java new file mode 100644 index 0000000..39177ef --- /dev/null +++ b/src/test/java/zone/moddev/mc/electricadvantage/machines/ElectricOperationalPumpPickupTest.java @@ -0,0 +1,204 @@ +package zone.moddev.mc.electricadvantage.machines; + +import cyano.poweradvantage.api.fluid.FluidStack; +import java.lang.reflect.Field; +import java.util.HashMap; +import java.util.Map; +import net.minecraft.block.Block; +import net.minecraft.block.state.IBlockState; +import net.minecraft.init.Blocks; +import net.minecraft.block.BlockFlowingFluid; +import net.minecraft.block.IBucketPickupHandler; +import net.minecraft.block.BlockSlab; +import net.minecraft.block.BlockStairs; +import net.minecraft.block.material.Material; +import net.minecraft.fluid.Fluid; +import net.minecraft.fluid.IFluidState; +import net.minecraft.init.Fluids; +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.state.properties.BlockStateProperties; +import net.minecraft.state.properties.Half; +import net.minecraft.state.properties.SlabType; +import net.minecraft.state.properties.StairsShape; +import net.minecraft.util.EnumFacing; +import net.minecraft.util.SoundEvent; +import net.minecraft.util.math.BlockPos; +import zone.moddev.mc.electricadvantage.testsupport.Forge113TestBootstrap; +import net.minecraft.world.IWorld; +import net.minecraft.world.World; +import net.minecraft.world.WorldServer; +import org.junit.BeforeClass; +import org.junit.Test; +import zone.moddev.mc.electricadvantage.init.Power; + +import static org.junit.Assert.*; + +/** Focused pickup transitions; the independent rig covers natural server ticks. */ +public class ElectricOperationalPumpPickupTest { + private static final BlockPos PUMP = new BlockPos(8, 84, 8); + private static final BlockPos SOURCE = PUMP.down().north(); + private static final float RESERVE = 8000; + private static final float PICKUP_COST = 1000 + 32; + + @BeforeClass public static void bootstrap() { Forge113TestBootstrap.register(); } + + @Test public void waterloggedSlabKeepsItsBlockAndSlabType() throws Exception { + IBlockState wet = Blocks.OAK_SLAB.getDefaultState() + .with(BlockSlab.TYPE, SlabType.TOP) + .with(BlockStateProperties.WATERLOGGED, true); + StubPump pump = fixture(wet); + pump.tickUpdate(true); + assertSuccessfulPickup(pump, Fluids.WATER); + assertEquals(wet.with(BlockStateProperties.WATERLOGGED, false), pump.testWorld.getBlockState(SOURCE)); + assertEquals(1, pump.testWorld.writes); + } + + @Test public void waterloggedStairsKeepFacingHalfAndShape() throws Exception { + IBlockState wet = Blocks.OAK_STAIRS.getDefaultState() + .with(BlockStairs.FACING, EnumFacing.SOUTH) + .with(BlockStairs.HALF, Half.TOP) + .with(BlockStairs.SHAPE, StairsShape.OUTER_LEFT) + .with(BlockStateProperties.WATERLOGGED, true); + StubPump pump = fixture(wet); + pump.tickUpdate(true); + assertSuccessfulPickup(pump, Fluids.WATER); + assertEquals(wet.with(BlockStateProperties.WATERLOGGED, false), pump.testWorld.getBlockState(SOURCE)); + assertEquals(1, pump.testWorld.writes); + } + + @Test public void ordinaryWaterAndLavaSourcesStillYieldOneBucket() throws Exception { + for (Block block : new Block[]{Blocks.WATER, Blocks.LAVA}) { + StubPump pump = fixture(block.getDefaultState()); + pump.tickUpdate(true); + assertSuccessfulPickup(pump, block == Blocks.WATER ? Fluids.WATER : Fluids.LAVA); + assertEquals(Blocks.AIR.getDefaultState(), pump.testWorld.getBlockState(SOURCE)); + assertEquals(1, pump.testWorld.writes); + } + } + + @Test public void sourceWaterInsideNonPickupSeagrassIsNotDestroyed() throws Exception { + StubPump pump = fixture(Blocks.SEAGRASS.getDefaultState()); + assertTrue(pump.testWorld.getFluidState(SOURCE).isSource()); + assertFalse(pump.testWorld.getBlockState(SOURCE).getBlock() instanceof IBucketPickupHandler); + pump.tickUpdate(true); + assertUntouched(pump, Blocks.SEAGRASS.getDefaultState(), RESERVE, 0); + } + + @Test public void refusedOrNullPickupDoesNotInventFluidOrDebitElectricity() throws Exception { + for (boolean returnNull : new boolean[]{false, true}) { + RefusingSource block = new RefusingSource(returnNull); + StubPump pump = fixture(block.getDefaultState()); + pump.tickUpdate(true); + assertEquals(1, block.calls); + assertUntouched(pump, block.getDefaultState(), RESERVE, 0); + } + } + + @Test public void insufficientElectricityDoesNotCallPickup() throws Exception { + RefusingSource block = new RefusingSource(false); + StubPump pump = fixture(block.getDefaultState()); + pump.setEnergy(PICKUP_COST - 1, Power.ELECTRIC_POWER); + pump.tickUpdate(true); + assertEquals(0, block.calls); + assertUntouched(pump, block.getDefaultState(), PICKUP_COST - 1, 0); + } + + @Test public void occupiedTankDoesNotCallPickupOrOverwriteItsFluid() throws Exception { + RefusingSource block = new RefusingSource(false); + StubPump pump = fixture(block.getDefaultState()); + pump.getTank().setFluid(new FluidStack(Fluids.LAVA, 1)); + pump.tickUpdate(true); + assertEquals(0, block.calls); + assertUntouched(pump, block.getDefaultState(), RESERVE, 1); + assertSame(Fluids.LAVA, pump.getTank().getFluid().getFluid()); + } + + @Test public void flowingWaterAndDrySlabsCannotBePickedUpAsSources() throws Exception { + for (IBlockState state : new IBlockState[]{ + Blocks.WATER.getDefaultState().with(BlockFlowingFluid.LEVEL, 1), + Blocks.OAK_SLAB.getDefaultState()}) { + StubPump pump = fixture(state); + pump.tickUpdate(true); + assertUntouched(pump, state, RESERVE, 0); + } + } + + 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(2, pump.sounds); + } + + private static void assertUntouched(StubPump pump, IBlockState expected, float energy, int fluid) { + assertEquals(expected, pump.testWorld.getBlockState(SOURCE)); + assertEquals(0, pump.testWorld.writes); + assertEquals(energy, pump.getEnergy(Power.ELECTRIC_POWER), 0); + assertEquals(fluid, pump.getTank().getFluidAmount()); + assertEquals(0, pump.sounds); + } + + private static T withoutConstructor(Class type) throws Exception { + Class allocator = Class.forName("sun.misc.Unsafe"); + Field field = allocator.getDeclaredField("theUnsafe"); + field.setAccessible(true); + return type.cast(allocator.getMethod("allocateInstance", Class.class).invoke(field.get(null), type)); + } + + private static final class StubPump extends ElectricPumpTileEntity { + PumpWorld testWorld; + int sounds; + @Override public World getWorld() { return testWorld; } + @Override protected boolean hasRedstoneSignal() { return false; } + @Override protected void playSoundEffect(double x, double y, double z, SoundEvent sound, float volume, float pitch) { + sounds++; + } + } + + private static final class PumpWorld extends WorldServer { + Map blocks; + int writes; + private PumpWorld() { + super(null, null, null, null, null, null); + } + @Override public boolean isBlockLoaded(BlockPos pos) { return true; } + @Override public IBlockState getBlockState(BlockPos pos) { + return blocks.getOrDefault(pos, Blocks.STONE.getDefaultState()); + } + @Override public IFluidState getFluidState(BlockPos pos) { return getBlockState(pos).getFluidState(); } + @Override public boolean isAirBlock(BlockPos pos) { return getBlockState(pos).isAir(); } + @Override public boolean setBlockState(BlockPos pos, IBlockState state, int flags) { + writes++; + blocks.put(pos.toImmutable(), state); + return true; + } + } + + private static final class RefusingSource extends Block implements IBucketPickupHandler { + final boolean returnNull; + int calls; + RefusingSource(boolean returnNull) { + super(Block.Properties.create(Material.ROCK)); + this.returnNull = returnNull; + } + @Override public IFluidState getFluidState(IBlockState state) { return Fluids.WATER.getStillFluidState(false); } + @Override public Fluid pickupFluid(IWorld world, BlockPos pos, IBlockState state) { + calls++; + return returnNull ? null : Fluids.EMPTY; + } + } +} diff --git a/src/test/java/zone/moddev/mc/electricadvantage/machines/ElectricOperationalRedstoneCacheTest.java b/src/test/java/zone/moddev/mc/electricadvantage/machines/ElectricOperationalRedstoneCacheTest.java new file mode 100644 index 0000000..b934f71 --- /dev/null +++ b/src/test/java/zone/moddev/mc/electricadvantage/machines/ElectricOperationalRedstoneCacheTest.java @@ -0,0 +1,217 @@ +package zone.moddev.mc.electricadvantage.machines; + +import cyano.poweradvantage.api.PoweredEntity; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import net.minecraft.util.math.BlockPos; +import zone.moddev.mc.electricadvantage.testsupport.Forge113TestBootstrap; +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() { Forge113TestBootstrap.register(); } + + @Test public void signalledFirstUseDoesNotWaitForPowerUpdate() throws Exception { + ProbeMachine machine = fixture(EVEN, true); + assertTrue(machine.signal()); + assertEquals(1, machine.testWorld.queryTimes.size()); + machine.testWorld.signalled = false; + assertTrue("Repeated reads must retain the sampled interlock", machine.signal()); + assertEquals(1, machine.testWorld.queryTimes.size()); + } + + @Test public void unsignalledFirstUseIsSampledAndCachedToo() throws Exception { + ProbeMachine machine = fixture(EVEN, false); + assertFalse(machine.signal()); + assertEquals(1, machine.testWorld.queryTimes.size()); + machine.testWorld.signalled = true; + assertFalse("A cached false must not be mistaken for uninitialized", machine.signal()); + assertEquals(1, machine.testWorld.queryTimes.size()); + } + + @Test public void unattachedReadDoesNotInitializeTheWorldCache() throws Exception { + ProbeMachine machine = new ProbeMachine(); + machine.setPos(EVEN); + assertFalse(machine.signal()); + machine.testWorld = world(EVEN, true); + assertTrue(machine.signal()); + assertEquals(1, machine.testWorld.queryTimes.size()); + } + + @Test public void clientReadsNeitherQueryRedstoneNorInitializeTheCache() throws Exception { + ProbeMachine machine = fixture(EVEN, true); + SignalWorld client = machine.testWorld; + Field clientSide = World.class.getDeclaredField("isRemote"); + clientSide.setAccessible(true); + clientSide.setBoolean(client, true); + assertTrue(client.isRemote); + assertFalse(machine.signal()); + client.signalled = false; + assertFalse(machine.signal()); + assertTrue("Client reads must not query local redstone", client.queryTimes.isEmpty()); + machine.testWorld = world(EVEN, true); + assertTrue("Client reads must not initialize the server cache", machine.signal()); + assertEquals(1, machine.testWorld.queryTimes.size()); + } + + @Test public void powerUpdateBeforeFirstUseAlreadyInitializesTheCache() throws Exception { + ProbeMachine machine = fixture(EVEN, true); + machine.powerUpdate(); + assertEquals(1, machine.testWorld.queryTimes.size()); + machine.testWorld.signalled = false; + assertTrue(machine.signal()); + assertEquals("First use after powerUpdate must not resample", 1, machine.testWorld.queryTimes.size()); + } + + @Test public void realPoweredTickKeepsBothEdgesCachedUntilTheEightTickRefresh() throws Exception { + for (BlockPos position : new BlockPos[]{EVEN, ODD}) { + for (boolean initial : new boolean[]{false, true}) { + ProbeMachine machine = fixture(position, initial); + SignalWorld world = machine.testWorld; + long refresh = position.equals(EVEN) ? 8 : 9; + long first = refresh - 7; + world.time = first; + machine.tick(); + assertEquals(initial, machine.observed); + assertEquals(1, world.queryTimes.size()); + world.signalled = !initial; + for (long tick = first + 1; tick <= refresh + 8; tick++) { + world.time = tick; + machine.tick(); + assertEquals("tickUpdate must precede the scheduled refresh at " + tick, + tick <= refresh ? initial : !initial, machine.observed); + assertEquals(tick < refresh ? 1 : tick < refresh + 8 ? 2 : 3, world.queryTimes.size()); + } + assertEquals(Arrays.asList(first, refresh, refresh + 8), world.queryTimes); + assertEquals(2, machine.powerUpdates); + } + } + } + + private static ProbeMachine fixture(BlockPos position, boolean signalled) throws Exception { + ProbeMachine machine = new ProbeMachine(); + machine.setPos(position); + machine.testWorld = world(position, signalled); + // Isolate conduit discovery, not PoweredEntity's real power-update cadence. + Field delay = PoweredEntity.class.getDeclaredField("conduitRefreshDelay"); + delay.setAccessible(true); + delay.setInt(machine, -1); + return machine; + } + + private static SignalWorld world(BlockPos position, boolean signalled) throws Exception { + Class allocator = Class.forName("sun.misc.Unsafe"); + Field field = allocator.getDeclaredField("theUnsafe"); + field.setAccessible(true); + SignalWorld world = (SignalWorld)allocator.getMethod("allocateInstance", Class.class) + .invoke(field.get(null), SignalWorld.class); + world.position = position; + world.signalled = signalled; + world.queryTimes = new ArrayList<>(); + return world; + } + + private static final class ProbeMachine extends ElectricOvenTileEntity { + SignalWorld testWorld; + boolean observed; + int powerUpdates; + boolean signal() { return hasRedstoneSignal(); } + @Override public World getWorld() { return testWorld; } + @Override public void tickUpdate(boolean server) { observed = hasRedstoneSignal(); } + @Override public void powerUpdate() { + powerUpdates++; + super.powerUpdate(); + } + @Override protected void setPowerState(boolean powered) { } + } + + @Test public void generatorSamplesSignalledFirstUseBeforeItsFirstPulse() throws Exception { + ProbeGenerator generator = generatorFixture(true); + assertTrue("A newly loaded generator must observe redstone before generating", generator.signal()); + assertEquals(1, generator.testWorld.queryTimes.size()); + generator.testWorld.signalled = false; + assertTrue("First-use sampling must retain the normal cached value", generator.signal()); + assertEquals(1, generator.testWorld.queryTimes.size()); + } + + @Test public void generatorCachesAnUnsignalledFirstReadToo() throws Exception { + ProbeGenerator generator = generatorFixture(false); + assertFalse(generator.signal()); + assertEquals(1, generator.testWorld.queryTimes.size()); + generator.testWorld.signalled = true; + assertFalse("A false interlock sample is initialized, not missing", generator.signal()); + assertEquals(1, generator.testWorld.queryTimes.size()); + } + + @Test public void unattachedGeneratorReadDoesNotInitializeItsCache() throws Exception { + ProbeGenerator generator = new ProbeGenerator(); + generator.setPos(EVEN); + assertFalse(generator.signal()); + generator.testWorld = world(EVEN, true); + assertTrue(generator.signal()); + assertEquals(1, generator.testWorld.queryTimes.size()); + } + + @Test public void clientGeneratorReadCannotInitializeTheServerCache() throws Exception { + ProbeGenerator generator = generatorFixture(true); + Field clientSide = World.class.getDeclaredField("isRemote"); + clientSide.setAccessible(true); + clientSide.setBoolean(generator.testWorld, true); + assertFalse(generator.signal()); + assertTrue(generator.testWorld.queryTimes.isEmpty()); + generator.testWorld = world(EVEN, true); + assertTrue("A client-only read must leave the server interlock unsampled", generator.signal()); + assertEquals(1, generator.testWorld.queryTimes.size()); + } + + @Test public void inhibitedSolarGeneratorCannotGenerateItsFirstPulse() throws Exception { + ProbeGenerator generator = generatorFixture(true); + generator.powerUpdate(); + assertEquals("Redstone is sampled before generation, then on the ordinary refresh", 2, + generator.testWorld.queryTimes.size()); + assertEquals(0, generator.getEnergy(), 0); + assertEquals(0, generator.getPowerOutput(), 0); + } + + private static ProbeGenerator generatorFixture(boolean signalled) throws Exception { + ProbeGenerator generator = new ProbeGenerator(); + generator.setPos(EVEN); + generator.testWorld = world(EVEN, signalled); + return generator; + } + + private static final class ProbeGenerator extends PhotovoltaicGeneratorTileEntity { + SignalWorld testWorld; + boolean signal() { return hasRedstoneSignal(); } + @Override public World getWorld() { return testWorld; } + } + + private static final class SignalWorld extends 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, null, null, null); + } + @Override public boolean isBlockPowered(BlockPos position) { + assertEquals(this.position, position); + queryTimes.add(time); + return signalled; + } + @Override public long getGameTime() { return time; } + } +} diff --git a/src/test/java/zone/moddev/mc/electricadvantage/machines/ElectricOperationalSoundBroadcastTest.java b/src/test/java/zone/moddev/mc/electricadvantage/machines/ElectricOperationalSoundBroadcastTest.java new file mode 100644 index 0000000..6020f2b --- /dev/null +++ b/src/test/java/zone/moddev/mc/electricadvantage/machines/ElectricOperationalSoundBroadcastTest.java @@ -0,0 +1,134 @@ +package zone.moddev.mc.electricadvantage.machines; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.function.Predicate; +import net.minecraft.entity.Entity; +import net.minecraft.entity.player.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 zone.moddev.mc.electricadvantage.testsupport.Forge113TestBootstrap; +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() { Forge113TestBootstrap.register(); } + + @Test public void machineBroadcastUsesOnlyExistingIntersectingConnectedPlayers() throws Exception { + QueryRejectingWorld testWorld = fixture(); + ElectricOvenTileEntity machine = new ElectricOvenTileEntity() { + @Override public World getWorld() { return testWorld; } + }; + machine.playSoundEffect(100, 80, -20, SoundEvents.BLOCK_ANVIL_LAND, 0.25f, 0.75f); + assertRecipients(testWorld); + } + + @Test public void generatorBroadcastUsesOnlyExistingIntersectingConnectedPlayers() throws Exception { + QueryRejectingWorld testWorld = fixture(); + PhotovoltaicGeneratorTileEntity generator = new PhotovoltaicGeneratorTileEntity() { + @Override public World getWorld() { return testWorld; } + }; + generator.playSoundEffect(100, 80, -20, SoundEvents.BLOCK_ANVIL_LAND, 0.25f, 0.75f); + assertRecipients(testWorld); + } + + @Test public void emptyPlayerListNeverQueriesChunks() throws Exception { + QueryRejectingWorld testWorld = withoutConstructor(QueryRejectingWorld.class); + testWorld.existingPlayers = Collections.emptyList(); + ElectricOvenTileEntity machine = new ElectricOvenTileEntity() { + @Override public World getWorld() { return testWorld; } + }; + machine.playSoundEffect(50000, 82, 50000, SoundEvents.BLOCK_ANVIL_LAND, 1, 1); + assertEquals(1, testWorld.playerListReads); + } + + private static QueryRejectingWorld fixture() throws Exception { + QueryRejectingWorld world = withoutConstructor(QueryRejectingWorld.class); + world.existingPlayers = new ArrayList<>(); + world.existingPlayers.add(player(new AxisAlignedBB(99.7, 80, -20.3, 100.3, 81.8, -19.7), false, true)); + // Player center can be outside 16 blocks while its box still intersects the original cube. + world.existingPlayers.add(player(new AxisAlignedBB(115.9, 80, -20.3, 116.5, 81.8, -19.7), false, true)); + // Cube corner is outside a spherical range but was included by the original AABB query. + world.existingPlayers.add(player(new AxisAlignedBB(115, 80, -5, 115.6, 81.8, -4.4), false, true)); + world.existingPlayers.add(player(new AxisAlignedBB(116, 80, -20.3, 116.6, 81.8, -19.7), false, true)); + world.existingPlayers.add(player(new AxisAlignedBB(99.7, 96, -20.3, 100.3, 97.8, -19.7), false, true)); + world.existingPlayers.add(player(new AxisAlignedBB(99.7, 62.2, -20.3, 100.3, 64, -19.7), false, true)); + world.existingPlayers.add(player(new AxisAlignedBB(99.7, 80, -20.3, 100.3, 81.8, -19.7), true, true)); + world.existingPlayers.add(player(new AxisAlignedBB(99.7, 80, -20.3, 100.3, 81.8, -19.7), false, false)); + return world; + } + + private static StubPlayer player(AxisAlignedBB bounds, boolean spectator, boolean connected) throws Exception { + StubPlayer player = withoutConstructor(StubPlayer.class); + player.setBoundingBox(bounds); + player.spectator = spectator; + if (connected) player.connection = withoutConstructor(RecordingConnection.class); + return player; + } + + private static void assertRecipients(QueryRejectingWorld world) { + assertEquals("Broadcast reads the existing player list once", 1, world.playerListReads); + for (int i = 0; i < world.existingPlayers.size(); i++) { + 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, null, null, 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.test(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/zone/moddev/mc/electricadvantage/machines/ElectricOperationalStillCapacityTest.java b/src/test/java/zone/moddev/mc/electricadvantage/machines/ElectricOperationalStillCapacityTest.java new file mode 100644 index 0000000..6befd2c --- /dev/null +++ b/src/test/java/zone/moddev/mc/electricadvantage/machines/ElectricOperationalStillCapacityTest.java @@ -0,0 +1,97 @@ +package zone.moddev.mc.electricadvantage.machines; + +import cyano.poweradvantage.api.fluid.FluidStack; +import java.lang.reflect.Field; +import net.minecraft.init.Fluids; +import zone.moddev.mc.electricadvantage.testsupport.Forge113TestBootstrap; +import org.junit.BeforeClass; +import org.junit.Before; +import org.junit.After; +import org.junit.Test; +import zone.moddev.mc.electricadvantage.init.Power; +import zone.moddev.mc.poweradvantage.registry.still.recipe.DistillationRecipe; +import zone.moddev.mc.poweradvantage.registry.still.recipe.DistillationRecipeRegistry; +import zone.moddev.mc.poweradvantage.registry.still.recipe.ExplicitFluidReference; + +import static org.junit.Assert.*; + +/** Real tick conservation checks; the shared rig separately supplies a live server world. */ +public class ElectricOperationalStillCapacityTest { + private Field singleton; + private Object previousRegistry; + + @BeforeClass public static void bootstrap() { Forge113TestBootstrap.register(); } + + @Before public void installIsolatedRecipe() throws Exception { + singleton = DistillationRecipeRegistry.class.getDeclaredField("instance"); + singleton.setAccessible(true); + previousRegistry = singleton.get(null); + DistillationRecipeRegistry registry = new DistillationRecipeRegistry(); + registry.addRecipe(new DistillationRecipe(new ExplicitFluidReference(Fluids.WATER), 2, + new ExplicitFluidReference(Fluids.LAVA), 3)); + singleton.set(null, registry); + } + + @After public void restoreRecipeRegistry() throws Exception { + singleton.set(null, previousRegistry); + } + + @Test public void aFullTankCannotConsumeInput() throws Exception { + ElectricStillTileEntity still = fixture(1000); + for (int tick = 0; tick < 4; tick++) still.tickUpdate(true); + assertEquals(4, still.getInputTank().getFluidAmount()); + assertEquals(1000, still.getOutputTank().getFluidAmount()); + assertEquals(64, still.getEnergy(Power.ELECTRIC_POWER), 0); + } + + @Test public void aPartialOutputMustFitTheWholeRecipe() throws Exception { + ElectricStillTileEntity still = fixture(998); + still.tickUpdate(true); + assertEquals(4, still.getInputTank().getFluidAmount()); + assertEquals(998, still.getOutputTank().getFluidAmount()); + assertEquals(64, still.getEnergy(Power.ELECTRIC_POWER), 0); + } + + @Test public void exactSpaceProducesTheWholeBatchAndDebitsOnlyOneCycle() throws Exception { + ElectricStillTileEntity still = fixture(997); + still.tickUpdate(true); + assertEquals(2, still.getInputTank().getFluidAmount()); + assertEquals(1000, still.getOutputTank().getFluidAmount()); + assertSame(Fluids.LAVA, still.getOutputTank().getFluid().getFluid()); + assertEquals(48, still.getEnergy(Power.ELECTRIC_POWER), 0); + still.tickUpdate(true); + assertEquals(2, still.getInputTank().getFluidAmount()); + assertEquals(1000, still.getOutputTank().getFluidAmount()); + assertEquals(48, still.getEnergy(Power.ELECTRIC_POWER), 0); + } + + @Test public void incompatibleOutputCannotBeOverwritten() throws Exception { + ElectricStillTileEntity still = fixture(0); + still.getOutputTank().setFluid(new FluidStack(Fluids.WATER, 1)); + still.tickUpdate(true); + assertEquals(Fluids.WATER, still.getOutputTank().getFluid().getFluid()); + assertEquals(4, still.getInputTank().getFluidAmount()); + assertEquals(64, still.getEnergy(Power.ELECTRIC_POWER), 0); + } + + @Test public void anIncompleteInputDoesNotInventAnOutput() throws Exception { + ElectricStillTileEntity still = fixture(0); + still.getInputTank().setFluid(new FluidStack(Fluids.WATER, 1)); + still.tickUpdate(true); + assertEquals(1, still.getInputTank().getFluidAmount()); + assertEquals(0, still.getOutputTank().getFluidAmount()); + assertEquals(64, still.getEnergy(Power.ELECTRIC_POWER), 0); + } + + private static ElectricStillTileEntity fixture(int output) { + ElectricStillTileEntity still = new ElectricStillTileEntity() { + @Override protected boolean hasRedstoneSignal() { return false; } + @Override protected void setActiveState(boolean active) { } + }; + still.setEnergy(64, Power.ELECTRIC_POWER); + still.getInputTank().setFluid(new FluidStack(Fluids.WATER, 4)); + if (output > 0) still.getOutputTank().setFluid(new FluidStack(Fluids.LAVA, output)); + return still; + } + +} diff --git a/src/test/java/zone/moddev/mc/electricadvantage/machines/GrowthChamberPersistenceTest.java b/src/test/java/zone/moddev/mc/electricadvantage/machines/GrowthChamberPersistenceTest.java new file mode 100644 index 0000000..aeaaa42 --- /dev/null +++ b/src/test/java/zone/moddev/mc/electricadvantage/machines/GrowthChamberPersistenceTest.java @@ -0,0 +1,162 @@ +package zone.moddev.mc.electricadvantage.machines; + +import java.lang.reflect.Field; +import net.minecraft.init.Items; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.network.play.server.SPacketUpdateTileEntity; +import net.minecraft.util.SoundEvent; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.World; +import net.minecraft.world.WorldServer; +import org.junit.BeforeClass; +import org.junit.Test; +import zone.moddev.mc.electricadvantage.init.Power; +import zone.moddev.mc.electricadvantage.testsupport.Forge113TestBootstrap; + +import static org.junit.Assert.*; + +public class GrowthChamberPersistenceTest { + @BeforeClass public static void bootstrap() { Forge113TestBootstrap.register(); } + + @Test public void legacyGrowthAndProgressSurviveReadSaveBeforeWorldAttachment() { + NBTTagCompound source = work(new byte[] {7, 9, 6}, new int[] {797, 945, 627}); + for (int pass = 0; pass < 3; pass++) { + ProbeChamber chamber = new ProbeChamber(); + chamber.loadFrom(source); + assertEquals("NBT loading must not need a world or construct crops yet", 0, chamber.worldReads); + NBTTagCompound saved = saved(chamber); + assertArrayEquals(new byte[] {7, 9, 6}, saved.getByteArray("growth")); + assertArrayEquals(new int[] {797, 945, 627}, saved.getIntArray("prog")); + source = saved; + } + } + + @Test public void shortArraysArePaddedAndNewNbtClearsOldPendingWork() { + GrowthChamberTileEntity chamber = new GrowthChamberTileEntity(); + chamber.loadFrom(work(new byte[] {2}, new int[] {245})); + NBTTagCompound saved = saved(chamber); + assertArrayEquals(new byte[] {2, 0, 0}, saved.getByteArray("growth")); + assertArrayEquals(new int[] {245, 0, 0}, saved.getIntArray("prog")); + chamber.loadFrom(new NBTTagCompound()); + saved = saved(chamber); + assertArrayEquals(new byte[] {0, 0, 0}, saved.getByteArray("growth")); + assertArrayEquals(new int[] {0, 0, 0}, saved.getIntArray("prog")); + } + + @Test public void oversizedArraysAreLimitedToTheThreeCropSlots() { + GrowthChamberTileEntity chamber = new GrowthChamberTileEntity(); + chamber.loadFrom(work(new byte[] {1, 3, 7, 9}, new int[] {135, 357, 799, 945})); + NBTTagCompound saved = saved(chamber); + assertArrayEquals(new byte[] {1, 3, 7}, saved.getByteArray("growth")); + assertArrayEquals(new int[] {135, 357, 799}, saved.getIntArray("prog")); + } + + @Test public void numericTagsDoNotStandInForTheLegacyArrays() { + GrowthChamberTileEntity chamber = new GrowthChamberTileEntity(); + chamber.loadFrom(work(new byte[] {7}, new int[] {797})); + NBTTagCompound invalid = new NBTTagCompound(); + invalid.setByte("growth", (byte) 7); + invalid.setInt("prog", 797); + chamber.loadFrom(invalid); + NBTTagCompound saved = saved(chamber); + assertArrayEquals(new byte[] {0, 0, 0}, saved.getByteArray("growth")); + assertArrayEquals(new int[] {0, 0, 0}, saved.getIntArray("prog")); + } + + @Test public void attachedFirstTickResumesNearlyGrownCropWithoutResettingItsCache() throws Exception { + ProbeChamber chamber = new ProbeChamber(); + chamber.setPos(new BlockPos(8, 82, 8)); + chamber.setInputSlot(0, new ItemStack(net.minecraft.item.Item.getItemFromBlock(net.minecraft.init.Blocks.SUGAR_CANE))); + chamber.loadFrom(work(new byte[] {7}, new int[] {797})); + assertEquals(0, chamber.worldReads); + chamber.attachedWorld = emptyWorld(); + chamber.setWorld(chamber.attachedWorld); + chamber.setEnergy(3, Power.GROWTHCHAMBER_POWER); + + chamber.tickUpdate(true); + assertTrue("Virtual crops are reconstructed with the attached native world", chamber.worldReads > 0); + NBTTagCompound first = saved(chamber); + assertArrayEquals(new byte[] {7, 0, 0}, first.getByteArray("growth")); + assertArrayEquals(new int[] {798, 0, 0}, first.getIntArray("prog")); + assertEquals(2, chamber.getEnergy(Power.GROWTHCHAMBER_POWER), 0); + assertEquals(1, chamber.getInputSlot(0).getCount()); + assertEquals(0, productCount(chamber)); + + chamber.tickUpdate(true); + assertEquals(799, saved(chamber).getIntArray("prog")[0]); + assertEquals(0, productCount(chamber)); + chamber.tickUpdate(true); + assertEquals("Only the three remaining work ticks are charged", 0, + chamber.getEnergy(Power.GROWTHCHAMBER_POWER), 0); + assertTrue(chamber.getInputSlot(0) == null || chamber.getInputSlot(0).isEmpty()); + assertEquals("The actual crop harvest is produced, not only its NBT", 2, productCount(chamber)); + assertEquals(1, chamber.harvestSounds); + assertArrayEquals(new byte[] {0, 0, 0}, saved(chamber).getByteArray("growth")); + assertArrayEquals(new int[] {0, 0, 0}, saved(chamber).getIntArray("prog")); + } + + @Test public void unpoweredFirstTickRetainsCompletedCropStages() throws Exception { + ProbeChamber chamber = new ProbeChamber(); + chamber.setInputSlot(0, new ItemStack(net.minecraft.item.Item.getItemFromBlock(net.minecraft.init.Blocks.SUGAR_CANE))); + chamber.loadFrom(work(new byte[] {7}, new int[] {0})); + chamber.attachedWorld = emptyWorld(); + chamber.setWorld(chamber.attachedWorld); + chamber.tickUpdate(true); + assertArrayEquals(new byte[] {7, 0, 0}, saved(chamber).getByteArray("growth")); + assertArrayEquals(new int[] {0, 0, 0}, saved(chamber).getIntArray("prog")); + assertEquals(1, chamber.getInputSlot(0).getCount()); + assertEquals(0, productCount(chamber)); + assertEquals(0, chamber.getEnergy(Power.GROWTHCHAMBER_POWER), 0); + } + + private static NBTTagCompound work(byte[] stages, int[] progress) { + NBTTagCompound tag = new NBTTagCompound(); + tag.setByteArray("growth", stages); + tag.setIntArray("prog", progress); + return tag; + } + + private static NBTTagCompound saved(GrowthChamberTileEntity chamber) { + NBTTagCompound tag = new NBTTagCompound(); + chamber.saveTo(tag); + return tag; + } + + private static int productCount(GrowthChamberTileEntity chamber) { + int count = 0; + for (int slot = 0; slot < chamber.numberOfOutputSlots(); slot++) { + ItemStack stack = chamber.getOutputSlot(slot); + if (stack == null || stack.isEmpty()) continue; + assertSame(net.minecraft.item.Item.getItemFromBlock(net.minecraft.init.Blocks.SUGAR_CANE), stack.getItem()); + count += stack.getCount(); + } + return count; + } + + private static WorldServer emptyWorld() throws Exception { + Class allocator = Class.forName("sun.misc.Unsafe"); + Field field = allocator.getDeclaredField("theUnsafe"); + field.setAccessible(true); + return (WorldServer)allocator.getMethod("allocateInstance", Class.class) + .invoke(field.get(null), WorldServer.class); + } + + private static final class ProbeChamber extends GrowthChamberTileEntity { + World attachedWorld; + int worldReads; + int harvestSounds; + @Override public World getWorld() { + worldReads++; + assertNotNull("Crop construction must wait until the world is attached", attachedWorld); + return attachedWorld; + } + @Override protected boolean hasRedstoneSignal() { return false; } + @Override protected void setActiveState(boolean active) { } + @Override protected void playSoundEffect(double x, double y, double z, SoundEvent sound, float volume, float pitch) { + harvestSounds++; + } + @Override public void markDirty() { } + @Override public SPacketUpdateTileEntity getUpdatePacket() { return null; } + } +} diff --git a/src/test/java/zone/moddev/mc/electricadvantage/machines/PhotovoltaicSolidityTest.java b/src/test/java/zone/moddev/mc/electricadvantage/machines/PhotovoltaicSolidityTest.java new file mode 100644 index 0000000..bc62d34 --- /dev/null +++ b/src/test/java/zone/moddev/mc/electricadvantage/machines/PhotovoltaicSolidityTest.java @@ -0,0 +1,16 @@ +package zone.moddev.mc.electricadvantage.machines; + +import org.junit.BeforeClass; +import org.junit.Test; +import static org.junit.Assert.*; + +public class PhotovoltaicSolidityTest { + @BeforeClass public static void bootstrap() { + zone.moddev.mc.electricadvantage.testsupport.Forge113TestBootstrap.register(); + } + @Test public void panelKeepsItsLegacyFullBlockProperty() { + PhotovoltaicGeneratorBlock panel = new PhotovoltaicGeneratorBlock(); + assertTrue(panel.isSolid(panel.getDefaultState())); + assertTrue(panel.getDefaultState().isSolid()); + } +} diff --git a/src/test/java/zone/moddev/mc/electricadvantage/testsupport/Forge113TestBootstrap.java b/src/test/java/zone/moddev/mc/electricadvantage/testsupport/Forge113TestBootstrap.java new file mode 100644 index 0000000..d926859 --- /dev/null +++ b/src/test/java/zone/moddev/mc/electricadvantage/testsupport/Forge113TestBootstrap.java @@ -0,0 +1,34 @@ +package zone.moddev.mc.electricadvantage.testsupport; + +import java.lang.reflect.Field; +import net.minecraft.init.Bootstrap; + +/** Supplies only the launch metadata needed by Forge's vanilla registry owner in plain JUnit. */ +public final class Forge113TestBootstrap { + private Forge113TestBootstrap() {} + + public static synchronized void register() { + try { + version("mcVersion", "1.13.2"); + version("forgeVersion", "25.0.223"); + // Plain JUnit has no EventSubclassTransformer-generated EntityEvent constructor. + java.lang.reflect.Method listeners = Class.forName("net.minecraftforge.eventbus.api.EventListenerHelper") + .getDeclaredMethod("getListenerListInternal", Class.class, boolean.class); + listeners.setAccessible(true); + listeners.invoke(null, Class.forName("net.minecraftforge.event.entity.EntityEvent"), true); + } catch (ReflectiveOperationException failure) { + throw new AssertionError("Cannot initialize Forge 1.13 test launch metadata", failure); + } + Bootstrap.register(); + } + + private static void version(String name, String expected) throws ReflectiveOperationException { + Field field = Class.forName("net.minecraftforge.fml.loading.FMLLoader").getDeclaredField(name); + field.setAccessible(true); + Object current = field.get(null); + if (current == null) field.set(null, expected); + else if (!expected.equals(current)) { + throw new AssertionError("Wrong Forge test environment for " + name + ": " + current); + } + } +}