diff --git a/src/main/java/twilightforest/block/SortLogCoreBlock.java b/src/main/java/twilightforest/block/SortLogCoreBlock.java index e3f5db3f3d..d1057e0032 100644 --- a/src/main/java/twilightforest/block/SortLogCoreBlock.java +++ b/src/main/java/twilightforest/block/SortLogCoreBlock.java @@ -14,6 +14,7 @@ import net.neoforged.neoforge.transfer.ResourceHandlerUtil; import net.neoforged.neoforge.transfer.item.ItemResource; import net.neoforged.neoforge.transfer.transaction.Transaction; +import org.jspecify.annotations.Nullable; import twilightforest.config.TFConfig; import twilightforest.init.TFParticleType; import twilightforest.network.ParticlePacket; @@ -28,7 +29,7 @@ public class SortLogCoreBlock extends SpecialMagicLogBlock { - private final BlockCapabilityDirectionalCache> capabilityCache = new BlockCapabilityDirectionalCache<>(); + private final BlockCapabilityDirectionalCache> capabilityCache = new BlockCapabilityDirectionalCache<>(Capabilities.Item.BLOCK); public SortLogCoreBlock(Properties properties) { super(properties); @@ -39,31 +40,28 @@ public boolean doesCoreFunction() { return !TFConfig.disableSortingCore; } - //TODO fuckkkkkkkkk @Override void performTreeEffect(ServerLevel level, BlockPos pos, RandomSource rand) { Map>, Vec3> inputMap = new HashMap<>(); Map, Vec3> outputMap = new HashMap<>(); - for (BlockPos blockPos : WorldUtil.getAllAround(pos, TFConfig.sortingCoreRange)) { // Get every itemHandler from every block in the area + for (BlockEntity blockEntity : WorldUtil.getLoadedBlockEntitiesInRange(level, pos, TFConfig.sortingCoreRange)) { + BlockPos blockPos = blockEntity.getBlockPos(); if (!blockPos.equals(pos)) { - BlockEntity blockEntity = level.getBlockEntity(blockPos); - if (blockEntity != null) { - // Put it in the input if its within 2 blocks - if (Math.abs(blockPos.getX() - pos.getX()) <= 2 && Math.abs(blockPos.getY() - pos.getY()) <= 2 && Math.abs(blockPos.getZ() - pos.getZ()) <= 2) { - List> handlers = new ArrayList<>(); - for (Direction side : Direction.values()) { - ResourceHandler handler = this.capabilityCache.get(Capabilities.Item.BLOCK, level, blockPos, side); - if (handler != null) handlers.add(handler); - } - if (!handlers.isEmpty()) { - inputMap.put(handlers, Vec3.upFromBottomCenterOf(blockPos, 1.9D)); - } - } else { // Output if its outside that range - for (Direction side : Direction.values()) { - ResourceHandler handler = this.capabilityCache.get(Capabilities.Item.BLOCK, level, blockPos, side); - if (handler != null) outputMap.put(handler, Vec3.upFromBottomCenterOf(blockPos, 1.9D)); - } + // Put it in the input if its within 2 blocks + if (Math.abs(blockPos.getX() - pos.getX()) <= 2 && Math.abs(blockPos.getY() - pos.getY()) <= 2 && Math.abs(blockPos.getZ() - pos.getZ()) <= 2) { + List> handlers = new ArrayList<>(); + for (Direction side : Direction.values()) { + ResourceHandler handler = this.getItemHandler(level, blockEntity, side); + if (handler != null) handlers.add(handler); + } + if (!handlers.isEmpty()) { + inputMap.put(handlers, Vec3.upFromBottomCenterOf(blockPos, 1.9D)); + } + } else { // Output if its outside that range + for (Direction side : Direction.values()) { + ResourceHandler handler = this.getItemHandler(level, blockEntity, side); + if (handler != null) outputMap.put(handler, Vec3.upFromBottomCenterOf(blockPos, 1.9D)); } } } @@ -129,7 +127,7 @@ void performTreeEffect(ServerLevel level, BlockPos pos, RandomSource rand) { double x = diff.x - 0.25D + rand.nextDouble() * 0.5D; double y = diff.y - 1.75D + rand.nextDouble() * 0.5D; double z = diff.z - 0.25D + rand.nextDouble() * 0.5D; - particlePacket.queueParticle(TFParticleType.SORTING_PARTICLE.get(), false, xyz, new Vec3(x, y, z).scale(1D / diff.length())); + particlePacket.queueParticle(TFParticleType.SORTING_PARTICLE.get(), false, false, xyz, new Vec3(x, y, z).scale(1D / diff.length())); PacketDistributor.sendToPlayersNear(level, null, xyz.x(), xyz.y(), xyz.z(), 64.0D, particlePacket); break; } @@ -141,4 +139,13 @@ void performTreeEffect(ServerLevel level, BlockPos pos, RandomSource rand) { } } } + + @Nullable + ResourceHandler getItemHandler(ServerLevel level, BlockEntity blockEntity, Direction side) { + return this.capabilityCache.get(level, blockEntity, side); + } + + public void clearCapabilityCache(ServerLevel level) { + this.capabilityCache.clear(level); + } } diff --git a/src/main/java/twilightforest/events/MiscEvents.java b/src/main/java/twilightforest/events/MiscEvents.java index 224fe77dd3..86c3513b33 100644 --- a/src/main/java/twilightforest/events/MiscEvents.java +++ b/src/main/java/twilightforest/events/MiscEvents.java @@ -24,9 +24,11 @@ import net.neoforged.neoforge.event.entity.EntityJoinLevelEvent; import net.neoforged.neoforge.event.entity.living.LivingEquipmentChangeEvent; import net.neoforged.neoforge.event.entity.player.PlayerInteractEvent; +import net.neoforged.neoforge.event.level.LevelEvent; import net.neoforged.neoforge.network.PacketDistributor; import tamaized.beanification.Component; import tamaized.beanification.PostConstruct; +import twilightforest.block.SortLogCoreBlock; import twilightforest.compat.curios.CuriosCompat; import twilightforest.entity.monster.DeathTome; import twilightforest.entity.passive.Bighorn; @@ -47,6 +49,13 @@ private void setup() { NeoForge.EVENT_BUS.addListener(this::updateCicadaSoundsOnHead); NeoForge.EVENT_BUS.addListener(this::addTomesToLecterns); NeoForge.EVENT_BUS.addListener(this::washOffCloth); + NeoForge.EVENT_BUS.addListener(this::clearSortingTreeCapabilityCache); + } + + private void clearSortingTreeCapabilityCache(LevelEvent.Unload event) { + if (event.getLevel() instanceof ServerLevel level) { + ((SortLogCoreBlock) TFBlocks.SORTING_LOG_CORE.get()).clearCapabilityCache(level); + } } private void addPrey(EntityJoinLevelEvent event) { diff --git a/src/main/java/twilightforest/util/BlockCapabilityDirectionalCache.java b/src/main/java/twilightforest/util/BlockCapabilityDirectionalCache.java index 6da020b61a..a59bb31e0e 100644 --- a/src/main/java/twilightforest/util/BlockCapabilityDirectionalCache.java +++ b/src/main/java/twilightforest/util/BlockCapabilityDirectionalCache.java @@ -3,29 +3,55 @@ import net.minecraft.core.BlockPos; import net.minecraft.core.Direction; import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.block.entity.BlockEntity; import net.neoforged.neoforge.capabilities.BlockCapability; import net.neoforged.neoforge.capabilities.BlockCapabilityCache; +import org.jspecify.annotations.Nullable; -import javax.annotation.Nullable; -import java.util.HashMap; +import java.util.EnumMap; +import java.util.IdentityHashMap; import java.util.Map; +import java.util.WeakHashMap; -// Voidscape copy -public class BlockCapabilityDirectionalCache { +/** + * Caches sided block capabilities for loaded block entities, separated by level. + * + *

Entries within a level are weakly keyed by the target block entity rather than globally keyed by position. + * This keeps identical positions in different levels separate and allows the directional caches to be collected + * after NeoForge invalidates them on a normal block or chunk unload.

+ * + *

{@link BlockCapabilityCache} retains its {@link ServerLevel}, and level unload does not fire the usual chunk + * invalidations. Call {@link #clear(ServerLevel)} from {@code LevelEvent.Unload} to release the level explicitly.

+ */ +public final class BlockCapabilityDirectionalCache { - private final Map> data = new HashMap<>(); + private final BlockCapability capability; + private final Map>>> data = new IdentityHashMap<>(); + + public BlockCapabilityDirectionalCache(BlockCapability capability) { + this.capability = capability; + } @Nullable - public R get(BlockCapability capability, ServerLevel level, BlockPos pos, Direction direction) { - BlockCapabilityCache cache = this.data.get(new BlockPosAndDirection(pos, direction)); - if (cache == null) { - cache = BlockCapabilityCache.create(capability, level, pos, direction); - this.data.put(new BlockPosAndDirection(pos, direction), cache); + public R get(ServerLevel level, BlockEntity blockEntity, Direction direction) { + Map>> levelCaches = this.data.computeIfAbsent( + level, + ignored -> new WeakHashMap<>() + ); + EnumMap> directionalCaches = levelCaches.computeIfAbsent( + blockEntity, + ignored -> new EnumMap<>(Direction.class) + ); + BlockPos blockPos = blockEntity.getBlockPos(); + BlockCapabilityCache cache = directionalCaches.get(direction); + if (cache == null || cache.level() != level || !cache.pos().equals(blockPos)) { + cache = BlockCapabilityCache.create(this.capability, level, blockPos, direction); + directionalCaches.put(direction, cache); } return cache.getCapability(); } - private record BlockPosAndDirection(BlockPos pos, Direction direction) { - + public void clear(ServerLevel level) { + this.data.remove(level); } } diff --git a/src/main/java/twilightforest/util/WorldUtil.java b/src/main/java/twilightforest/util/WorldUtil.java index 555cdda088..661b678000 100644 --- a/src/main/java/twilightforest/util/WorldUtil.java +++ b/src/main/java/twilightforest/util/WorldUtil.java @@ -10,6 +10,7 @@ import net.minecraft.core.Holder; import net.minecraft.core.HolderSet; import net.minecraft.core.RegistryAccess; +import net.minecraft.core.SectionPos; import net.minecraft.server.level.ServerChunkCache; import net.minecraft.server.level.ServerLevel; import net.minecraft.util.Mth; @@ -20,6 +21,7 @@ import net.minecraft.world.level.LevelAccessor; import net.minecraft.world.level.StructureManager; import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.block.entity.BlockEntity; import net.minecraft.world.level.chunk.ChunkGeneratorStructureState; import net.minecraft.world.level.levelgen.Heightmap; import net.minecraft.world.level.levelgen.structure.Structure; @@ -61,6 +63,35 @@ public static Iterable getAllAround(BlockPos center, int range) { return BlockPos.betweenClosed(center.offset(-range, -range, -range), center.offset(range, range, range)); } + /** + * Finds block entities in loaded chunks within a cube around {@code center}, inclusive of its edges. + * This avoids loading chunks or scanning every block position in the cube. + */ + public static List getLoadedBlockEntitiesInRange(ServerLevel level, BlockPos center, int range) { + int minX = center.getX() - range; + int minY = center.getY() - range; + int minZ = center.getZ() - range; + int maxX = center.getX() + range; + int maxY = center.getY() + range; + int maxZ = center.getZ() + range; + ChunkPos minChunk = new ChunkPos(SectionPos.blockToSectionCoord(minX), SectionPos.blockToSectionCoord(minZ)); + ChunkPos maxChunk = new ChunkPos(SectionPos.blockToSectionCoord(maxX), SectionPos.blockToSectionCoord(maxZ)); + ServerChunkCache chunkSource = level.getChunkSource(); + + return ChunkPos.rangeClosed(minChunk, maxChunk) + .map(chunkPos -> chunkSource.getChunkNow(chunkPos.x(), chunkPos.z())) + .filter(Objects::nonNull) + .flatMap(chunk -> chunk.getBlockEntities().values().stream()) + .filter(blockEntity -> !blockEntity.isRemoved()) + .filter(blockEntity -> { + BlockPos blockPos = blockEntity.getBlockPos(); + return blockPos.getX() >= minX && blockPos.getX() <= maxX + && blockPos.getY() >= minY && blockPos.getY() <= maxY + && blockPos.getZ() >= minZ && blockPos.getZ() <= maxZ; + }) + .toList(); + } + /** * Floors both corners of the bounding box to integers * Inclusive of edges diff --git a/src/test/java/twilightforest/block/SortLogCoreBlockTests.java b/src/test/java/twilightforest/block/SortLogCoreBlockTests.java new file mode 100644 index 0000000000..2d5fff3d3a --- /dev/null +++ b/src/test/java/twilightforest/block/SortLogCoreBlockTests.java @@ -0,0 +1,172 @@ +package twilightforest.block; + +import com.mojang.serialization.Codec; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.server.level.ServerChunkCache; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.util.RandomSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.chunk.LevelChunk; +import net.minecraft.world.phys.AABB; +import net.neoforged.neoforge.network.PacketDistributor; +import net.neoforged.neoforge.transfer.ResourceStacksResourceHandler; +import net.neoforged.neoforge.transfer.item.ItemResource; +import net.neoforged.neoforge.transfer.resource.ResourceStack; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Answers; +import org.mockito.MockedStatic; +import tamaized.beanification.junit.MockitoFixer; +import twilightforest.config.TFConfig; +import twilightforest.network.ParticlePacket; + +import java.util.List; +import java.util.Map; +import java.util.function.Predicate; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoFixer.class) +public class SortLogCoreBlockTests { + + @Test + public void transfersItemsAcrossAChunkBoundary() { + int originalRange = TFConfig.sortingCoreRange; + TFConfig.sortingCoreRange = 4; + + try { + TreeTestFixture fixture = treeTestFixture(64); + + try (MockedStatic packets = mockStatic(PacketDistributor.class)) { + SortLogCoreBlock block = runTreeEffect(fixture); + packets.verify(() -> PacketDistributor.sendToPlayersNear( + same(fixture.level()), isNull(), anyDouble(), anyDouble(), anyDouble(), anyDouble(), any(ParticlePacket.class) + )); + verify(block, times(Direction.values().length)).getItemHandler( + same(fixture.level()), same(fixture.inputBlockEntity()), any(Direction.class) + ); + verify(block, times(Direction.values().length)).getItemHandler( + same(fixture.level()), same(fixture.outputBlockEntity()), any(Direction.class) + ); + } + + assertEquals(1, fixture.input().getAmountAsInt(0)); + assertEquals(2, fixture.output().getAmountAsInt(0)); + } finally { + TFConfig.sortingCoreRange = originalRange; + } + } + + @Test + public void rollsBackExtractionWhenTheOutputIsFull() { + int originalRange = TFConfig.sortingCoreRange; + TFConfig.sortingCoreRange = 4; + + try { + TreeTestFixture fixture = treeTestFixture(1); + + try (MockedStatic packets = mockStatic(PacketDistributor.class)) { + runTreeEffect(fixture); + packets.verifyNoInteractions(); + } + + assertEquals(2, fixture.input().getAmountAsInt(0)); + assertEquals(1, fixture.output().getAmountAsInt(0)); + } finally { + TFConfig.sortingCoreRange = originalRange; + } + } + + private static TreeTestFixture treeTestFixture(int outputCapacity) { + ServerLevel level = mock(ServerLevel.class); + ServerChunkCache chunkSource = mock(ServerChunkCache.class); + LevelChunk inputChunk = mock(LevelChunk.class); + LevelChunk outputChunk = mock(LevelChunk.class); + BlockPos corePos = new BlockPos(15, 64, 0); + BlockPos inputPos = new BlockPos(16, 64, 0); + BlockPos outputPos = new BlockPos(11, 64, 0); + BlockEntity inputBlockEntity = blockEntityAt(inputPos); + BlockEntity outputBlockEntity = blockEntityAt(outputPos); + ItemResource empty = mock(ItemResource.class); + ItemResource diamonds = mock(ItemResource.class); + when(empty.isEmpty()).thenReturn(true); + when(diamonds.isEmpty()).thenReturn(false); + TestItemHandler input = new TestItemHandler(empty, 64); + TestItemHandler output = new TestItemHandler(empty, outputCapacity); + input.set(0, diamonds, 2); + output.set(0, diamonds, 1); + + when(level.getChunkSource()).thenReturn(chunkSource); + when(chunkSource.getChunkNow(0, 0)).thenReturn(outputChunk); + when(chunkSource.getChunkNow(1, 0)).thenReturn(inputChunk); + when(inputChunk.getBlockEntities()).thenReturn(Map.of(inputPos, inputBlockEntity)); + when(outputChunk.getBlockEntities()).thenReturn(Map.of(outputPos, outputBlockEntity)); + when(level.getEntities( + isNull(Entity.class), + any(AABB.class), + org.mockito.ArgumentMatchers.>any() + )).thenReturn(List.of()); + + return new TreeTestFixture( + level, + corePos, + inputBlockEntity, + outputBlockEntity, + input, + output + ); + } + + private static SortLogCoreBlock runTreeEffect(TreeTestFixture fixture) { + // Constructing an unregistered block after Minecraft freezes its registries is not allowed in unit tests. + SortLogCoreBlock block = mock(SortLogCoreBlock.class, Answers.CALLS_REAL_METHODS); + doAnswer(invocation -> invocation.getArgument(1) == fixture.inputBlockEntity() ? fixture.input() : fixture.output()) + .when(block).getItemHandler(same(fixture.level()), any(BlockEntity.class), any(Direction.class)); + block.performTreeEffect(fixture.level(), fixture.corePos(), RandomSource.create(0L)); + return block; + } + + private static BlockEntity blockEntityAt(BlockPos pos) { + BlockEntity blockEntity = mock(BlockEntity.class); + when(blockEntity.getBlockPos()).thenReturn(pos); + when(blockEntity.isRemoved()).thenReturn(false); + return blockEntity; + } + + private static final class TestItemHandler extends ResourceStacksResourceHandler { + + private final int capacity; + + private TestItemHandler(ItemResource emptyResource, int capacity) { + super(1, emptyResource, Codec.STRING.xmap(ignored -> new ResourceStack<>(emptyResource, 0), ignored -> "")); + this.capacity = capacity; + } + + @Override + protected int getCapacity(int index, ItemResource resource) { + return this.capacity; + } + } + + private record TreeTestFixture( + ServerLevel level, + BlockPos corePos, + BlockEntity inputBlockEntity, + BlockEntity outputBlockEntity, + TestItemHandler input, + TestItemHandler output + ) { + } +} diff --git a/src/test/java/twilightforest/util/BlockCapabilityDirectionalCacheTests.java b/src/test/java/twilightforest/util/BlockCapabilityDirectionalCacheTests.java index 2e6237185a..8e53a61e73 100644 --- a/src/test/java/twilightforest/util/BlockCapabilityDirectionalCacheTests.java +++ b/src/test/java/twilightforest/util/BlockCapabilityDirectionalCacheTests.java @@ -3,48 +3,103 @@ import net.minecraft.core.BlockPos; import net.minecraft.core.Direction; import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.block.entity.BlockEntity; import net.neoforged.neoforge.capabilities.BlockCapability; import net.neoforged.neoforge.capabilities.BlockCapabilityCache; -import org.junit.jupiter.api.BeforeEach; +import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.MockedStatic; import tamaized.beanification.junit.MockitoFixer; import static org.junit.jupiter.api.Assertions.assertSame; -import static org.mockito.Mockito.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; @ExtendWith(MockitoFixer.class) public class BlockCapabilityDirectionalCacheTests { - private BlockCapabilityDirectionalCache instance; + @Test + @SuppressWarnings({"unchecked", "rawtypes"}) + public void reusesCacheForTheSameSideAndSeparatesDirections() { + BlockCapability capability = mock(BlockCapability.class); + BlockCapabilityDirectionalCache instance = new BlockCapabilityDirectionalCache<>(capability); + ServerLevel level = mock(ServerLevel.class); + BlockEntity blockEntity = blockEntityAt(new BlockPos(4, 8, 15)); + BlockCapabilityCache northCache = mock(BlockCapabilityCache.class); + BlockCapabilityCache southCache = mock(BlockCapabilityCache.class); + Object northHandler = new Object(); + Object southHandler = new Object(); + + try (MockedStatic caches = mockStatic(BlockCapabilityCache.class)) { + caches.when(() -> BlockCapabilityCache.create(capability, level, blockEntity.getBlockPos(), Direction.NORTH)).thenReturn(northCache); + caches.when(() -> BlockCapabilityCache.create(capability, level, blockEntity.getBlockPos(), Direction.SOUTH)).thenReturn(southCache); + when(northCache.level()).thenReturn(level); + when(northCache.pos()).thenReturn(blockEntity.getBlockPos()); + when(northCache.getCapability()).thenReturn(northHandler); + when(southCache.getCapability()).thenReturn(southHandler); + + assertSame(northHandler, instance.get(level, blockEntity, Direction.NORTH)); + assertSame(northHandler, instance.get(level, blockEntity, Direction.NORTH)); + assertSame(southHandler, instance.get(level, blockEntity, Direction.SOUTH)); - @BeforeEach - public void setup() { - instance = new BlockCapabilityDirectionalCache<>(); + caches.verify(() -> BlockCapabilityCache.create(capability, level, blockEntity.getBlockPos(), Direction.NORTH), times(1)); + caches.verify(() -> BlockCapabilityCache.create(capability, level, blockEntity.getBlockPos(), Direction.SOUTH), times(1)); + verify(northCache, times(2)).getCapability(); + verify(southCache).getCapability(); + } } @Test @SuppressWarnings({"unchecked", "rawtypes"}) - public void get() { - try (MockedStatic blockCapabilityCacheMockedStatic = mockStatic(BlockCapabilityCache.class)) { - BlockCapability blockCapability = mock(BlockCapability.class); - ServerLevel level = mock(ServerLevel.class); - BlockPos pos = mock(BlockPos.class); - Direction direction = mock(Direction.class); + public void keepsLevelsIndependentAndClearsOnlyTheUnloadedLevel() { + BlockCapability capability = mock(BlockCapability.class); + BlockCapabilityDirectionalCache instance = new BlockCapabilityDirectionalCache<>(capability); + ServerLevel firstLevel = mock(ServerLevel.class); + ServerLevel secondLevel = mock(ServerLevel.class); + BlockPos sharedPos = new BlockPos(4, 8, 15); + BlockEntity firstBlockEntity = blockEntityAt(sharedPos); + BlockEntity secondBlockEntity = blockEntityAt(sharedPos); + BlockCapabilityCache firstCache = mock(BlockCapabilityCache.class); + BlockCapabilityCache secondCache = mock(BlockCapabilityCache.class); + BlockCapabilityCache recreatedFirstCache = mock(BlockCapabilityCache.class); + Object firstHandler = new Object(); + Object secondHandler = new Object(); + Object recreatedFirstHandler = new Object(); + + try (MockedStatic caches = mockStatic(BlockCapabilityCache.class)) { + caches.when(() -> BlockCapabilityCache.create(capability, firstLevel, sharedPos, Direction.NORTH)).thenReturn(firstCache, recreatedFirstCache); + caches.when(() -> BlockCapabilityCache.create(capability, secondLevel, sharedPos, Direction.NORTH)).thenReturn(secondCache); + when(firstCache.level()).thenReturn(firstLevel); + when(firstCache.pos()).thenReturn(sharedPos); + when(secondCache.level()).thenReturn(secondLevel); + when(secondCache.pos()).thenReturn(sharedPos); + when(recreatedFirstCache.level()).thenReturn(firstLevel); + when(recreatedFirstCache.pos()).thenReturn(sharedPos); + when(firstCache.getCapability()).thenReturn(firstHandler); + when(secondCache.getCapability()).thenReturn(secondHandler); + when(recreatedFirstCache.getCapability()).thenReturn(recreatedFirstHandler); - BlockCapabilityCache cache = mock(BlockCapabilityCache.class); - blockCapabilityCacheMockedStatic.when(() -> BlockCapabilityCache.create(blockCapability, level, pos, direction)).thenReturn(cache); + assertSame(firstHandler, instance.get(firstLevel, firstBlockEntity, Direction.NORTH)); + assertSame(secondHandler, instance.get(secondLevel, secondBlockEntity, Direction.NORTH)); - Object check = mock(Object.class); - when(cache.getCapability()).thenReturn(check); + instance.clear(firstLevel); - assertSame(check, instance.get(blockCapability, level, pos, direction)); - // Once more to verify the caching - assertSame(check, instance.get(blockCapability, level, pos, direction)); + assertSame(recreatedFirstHandler, instance.get(firstLevel, firstBlockEntity, Direction.NORTH)); + assertSame(secondHandler, instance.get(secondLevel, secondBlockEntity, Direction.NORTH)); - blockCapabilityCacheMockedStatic.verify(() -> BlockCapabilityCache.create(blockCapability, level, pos, direction), times(1)); + caches.verify(() -> BlockCapabilityCache.create(capability, firstLevel, sharedPos, Direction.NORTH), times(2)); + caches.verify(() -> BlockCapabilityCache.create(capability, secondLevel, sharedPos, Direction.NORTH), times(1)); + verify(secondCache, times(2)).getCapability(); } } + private static BlockEntity blockEntityAt(BlockPos pos) { + BlockEntity blockEntity = mock(BlockEntity.class); + when(blockEntity.getBlockPos()).thenReturn(pos); + return blockEntity; + } } diff --git a/src/test/java/twilightforest/util/WorldUtilTests.java b/src/test/java/twilightforest/util/WorldUtilTests.java new file mode 100644 index 0000000000..c68b3f2892 --- /dev/null +++ b/src/test/java/twilightforest/util/WorldUtilTests.java @@ -0,0 +1,122 @@ +package twilightforest.util; + +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerChunkCache; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.chunk.LevelChunk; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import tamaized.beanification.junit.MockitoFixer; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoFixer.class) +public class WorldUtilTests { + + @Test + public void filtersLoadedBlockEntitiesAcrossChunkBoundaries() { + ServerLevel level = mock(ServerLevel.class); + ServerChunkCache chunkSource = mock(ServerChunkCache.class); + LevelChunk chunk00 = mock(LevelChunk.class); + LevelChunk chunk10 = mock(LevelChunk.class); + LevelChunk chunk01 = mock(LevelChunk.class); + + BlockEntity inside = blockEntityAt(14, 63, 14, false); + BlockEntity xEdge = blockEntityAt(16, 65, 14, false); + BlockEntity zEdge = blockEntityAt(14, 64, 16, false); + BlockEntity outsideHorizontal = blockEntityAt(13, 64, 15, false); + BlockEntity outsideVertical = blockEntityAt(15, 66, 15, false); + BlockEntity removed = blockEntityAt(15, 64, 15, true); + + when(level.getChunkSource()).thenReturn(chunkSource); + when(chunkSource.getChunkNow(0, 0)).thenReturn(chunk00); + when(chunkSource.getChunkNow(1, 0)).thenReturn(chunk10); + when(chunkSource.getChunkNow(0, 1)).thenReturn(chunk01); + when(chunkSource.getChunkNow(1, 1)).thenReturn(null); + when(chunk00.getBlockEntities()).thenReturn(Map.of( + new BlockPos(14, 63, 14), inside, + new BlockPos(13, 64, 15), outsideHorizontal, + new BlockPos(15, 66, 15), outsideVertical, + new BlockPos(15, 64, 15), removed + )); + when(chunk10.getBlockEntities()).thenReturn(Map.of(new BlockPos(16, 65, 14), xEdge)); + when(chunk01.getBlockEntities()).thenReturn(Map.of(new BlockPos(14, 64, 16), zEdge)); + + List result = WorldUtil.getLoadedBlockEntitiesInRange(level, new BlockPos(15, 64, 15), 1); + + assertEquals(3, result.size()); + assertEquals(Set.of(inside, xEdge, zEdge), new HashSet<>(result)); + verify(chunkSource).getChunkNow(0, 0); + verify(chunkSource).getChunkNow(1, 0); + verify(chunkSource).getChunkNow(0, 1); + verify(chunkSource).getChunkNow(1, 1); + verifyNoMoreInteractions(chunkSource); + } + + @Test + public void handlesNegativeChunkBoundaries() { + ServerLevel level = mock(ServerLevel.class); + ServerChunkCache chunkSource = mock(ServerChunkCache.class); + LevelChunk chunkNegativeTwo = mock(LevelChunk.class); + LevelChunk chunkNegativeOne = mock(LevelChunk.class); + BlockEntity negativeEdge = blockEntityAt(-17, 64, -16, false); + BlockEntity positiveEdge = blockEntityAt(-15, 64, -16, false); + + when(level.getChunkSource()).thenReturn(chunkSource); + when(chunkSource.getChunkNow(-2, -2)).thenReturn(null); + when(chunkSource.getChunkNow(-1, -2)).thenReturn(null); + when(chunkSource.getChunkNow(-2, -1)).thenReturn(chunkNegativeTwo); + when(chunkSource.getChunkNow(-1, -1)).thenReturn(chunkNegativeOne); + when(chunkNegativeTwo.getBlockEntities()).thenReturn(Map.of(new BlockPos(-17, 64, -16), negativeEdge)); + when(chunkNegativeOne.getBlockEntities()).thenReturn(Map.of(new BlockPos(-15, 64, -16), positiveEdge)); + + List result = WorldUtil.getLoadedBlockEntitiesInRange(level, new BlockPos(-16, 64, -16), 1); + + assertEquals(2, result.size()); + assertEquals(Set.of(negativeEdge, positiveEdge), new HashSet<>(result)); + verify(chunkSource).getChunkNow(-2, -2); + verify(chunkSource).getChunkNow(-1, -2); + verify(chunkSource).getChunkNow(-2, -1); + verify(chunkSource).getChunkNow(-1, -1); + verifyNoMoreInteractions(chunkSource); + } + + @Test + public void supportsZeroRange() { + ServerLevel level = mock(ServerLevel.class); + ServerChunkCache chunkSource = mock(ServerChunkCache.class); + LevelChunk chunk = mock(LevelChunk.class); + BlockEntity center = blockEntityAt(1, 64, 1, false); + BlockEntity neighbor = blockEntityAt(2, 64, 1, false); + + when(level.getChunkSource()).thenReturn(chunkSource); + when(chunkSource.getChunkNow(0, 0)).thenReturn(chunk); + when(chunk.getBlockEntities()).thenReturn(Map.of( + new BlockPos(1, 64, 1), center, + new BlockPos(2, 64, 1), neighbor + )); + + List result = WorldUtil.getLoadedBlockEntitiesInRange(level, new BlockPos(1, 64, 1), 0); + + assertEquals(List.of(center), result); + verify(chunkSource).getChunkNow(0, 0); + verifyNoMoreInteractions(chunkSource); + } + + private static BlockEntity blockEntityAt(int x, int y, int z, boolean removed) { + BlockEntity blockEntity = mock(BlockEntity.class); + when(blockEntity.getBlockPos()).thenReturn(new BlockPos(x, y, z)); + when(blockEntity.isRemoved()).thenReturn(removed); + return blockEntity; + } +}