diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e839925..319e4b3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -75,3 +75,11 @@ jobs: name: unit-test-reports-${{ github.run_id }}-${{ github.run_attempt }} path: target/surefire-reports/ if-no-files-found: error + + - name: Upload coverage reports + if: ${{ !cancelled() && hashFiles('target/site/jacoco/jacoco.xml') != '' }} + uses: actions/upload-artifact@v7 + with: + name: coverage-reports-${{ github.run_id }}-${{ github.run_attempt }} + path: target/site/jacoco/ + if-no-files-found: error diff --git a/README.md b/README.md index 5f5ae91..bb08b6a 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,14 @@ Originally created by [Drefvelin](https://github.com/Drefvelin). Technical documentation is maintained in [TF-Minecraft/Docs](https://github.com/TF-Minecraft/Docs). +## Tests + +With Java 21 and the pinned plugin dependencies installed, run `mvn clean verify`. +Tests use JUnit, Mockito, and MockBukkit; JaCoCo reports are written to +`target/site/jacoco/index.html` and uploaded by CI. Tests run locally without a live +Minecraft server. Verification requires 100% line, branch, and instruction +coverage of production code, with no coverage exclusions. + ## License Copyright (c) 2026 TF-Minecraft contributors. diff --git a/pom.xml b/pom.xml index 29aadaa..61ab76e 100644 --- a/pom.xml +++ b/pom.xml @@ -20,6 +20,24 @@ + + org.junit.jupiter + junit-jupiter + 5.10.2 + test + + + org.mockito + mockito-core + 5.20.0 + test + + + org.mockbukkit.mockbukkit + mockbukkit-v1.21 + 4.95.0 + test + io.papermc.paper paper-api @@ -89,6 +107,55 @@ + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.4 + + + org.jacoco + jacoco-maven-plugin + 0.8.15 + + + prepare-agent + + + coverage-report + verify + report + + + coverage-check + verify + check + + + + BUNDLE + + + INSTRUCTION + COVEREDRATIO + 1.0 + + + LINE + COVEREDRATIO + 1.0 + + + BRANCH + COVEREDRATIO + 1.0 + + + + + + + + org.apache.maven.plugins maven-compiler-plugin diff --git a/src/main/java/net/tfminecraft/gathering/Gathering.java b/src/main/java/net/tfminecraft/gathering/Gathering.java index 8e673b9..50353aa 100644 --- a/src/main/java/net/tfminecraft/gathering/Gathering.java +++ b/src/main/java/net/tfminecraft/gathering/Gathering.java @@ -43,9 +43,7 @@ public void onEnable() { @Override public void onDisable() { - if (spotManager != null) { - spotManager.shutdown(); - } + spotManager.shutdown(); getLogger().info("Gathering disabled."); } diff --git a/src/main/java/net/tfminecraft/gathering/loader/CategoryLoader.java b/src/main/java/net/tfminecraft/gathering/loader/CategoryLoader.java index 343e4a6..f81f5fd 100644 --- a/src/main/java/net/tfminecraft/gathering/loader/CategoryLoader.java +++ b/src/main/java/net/tfminecraft/gathering/loader/CategoryLoader.java @@ -38,7 +38,7 @@ public void load(File configFile) { for (String key : config.getKeys(false)) { List drops = config.getStringList(key + ".drops"); - if (drops == null || drops.isEmpty()) { + if (drops.isEmpty()) { drops = config.getStringList(key); } DropCategory category = new DropCategory(key, drops); diff --git a/src/main/java/net/tfminecraft/gathering/loader/SpotTypeLoader.java b/src/main/java/net/tfminecraft/gathering/loader/SpotTypeLoader.java index 4095f30..f7ce05e 100644 --- a/src/main/java/net/tfminecraft/gathering/loader/SpotTypeLoader.java +++ b/src/main/java/net/tfminecraft/gathering/loader/SpotTypeLoader.java @@ -136,9 +136,8 @@ private SpotTypeDefinition parse(String id, ConfigurationSection sec) { String professionId = sec.getString("profession-id"); List categories = new ArrayList<>(); - List rawCategories = sec.getMapList("categories"); - for (Object raw : rawCategories) { - if (!(raw instanceof java.util.Map map)) continue; + List> rawCategories = sec.getMapList("categories"); + for (java.util.Map map : rawCategories) { Object catId = map.get("id"); Object weight = map.get("weight"); Object drops = map.get("drops"); @@ -185,10 +184,10 @@ private int[] parseDropRange(String raw) { } private Set parseBiomes(List names) { - if (names == null || names.isEmpty()) return Collections.emptySet(); + if (names.isEmpty()) return Collections.emptySet(); Set out = new HashSet<>(); for (String name : names) { - if (name == null || name.isBlank()) continue; + if (name.isBlank()) continue; NamespacedKey key = NamespacedKey.fromString(name.trim().toLowerCase(Locale.ROOT)); Biome biome = key == null ? null : RegistryAccess.registryAccess().getRegistry(RegistryKey.BIOME).get(key); if (biome == null) { diff --git a/src/main/java/net/tfminecraft/gathering/loot/DropCategory.java b/src/main/java/net/tfminecraft/gathering/loot/DropCategory.java index 6393107..c083032 100644 --- a/src/main/java/net/tfminecraft/gathering/loot/DropCategory.java +++ b/src/main/java/net/tfminecraft/gathering/loot/DropCategory.java @@ -86,9 +86,10 @@ public Entry pickOne(ThreadLocalRandom rng) { if (total <= 0.0) return null; double r = rng.nextDouble() * total; double acc = 0.0; - for (Entry e : entries) { + for (int i = 0; i < entries.size() - 1; i++) { + Entry e = entries.get(i); acc += Math.max(0.0, e.weight); - if (r <= acc) return e; + if (r < acc) return e; } return entries.get(entries.size() - 1); } diff --git a/src/main/java/net/tfminecraft/gathering/manager/SpotManager.java b/src/main/java/net/tfminecraft/gathering/manager/SpotManager.java index 4be3b4f..515fcf7 100644 --- a/src/main/java/net/tfminecraft/gathering/manager/SpotManager.java +++ b/src/main/java/net/tfminecraft/gathering/manager/SpotManager.java @@ -241,7 +241,7 @@ public int getCooldownChunkCount() { long now = System.currentTimeMillis(); int count = 0; for (Long until : chunkCooldownUntil.values()) { - if (until != null && until > now) count++; + if (until > now) count++; } return count; } diff --git a/src/main/java/net/tfminecraft/gathering/spawn/SpawnPlanner.java b/src/main/java/net/tfminecraft/gathering/spawn/SpawnPlanner.java index 9927107..5c9a796 100644 --- a/src/main/java/net/tfminecraft/gathering/spawn/SpawnPlanner.java +++ b/src/main/java/net/tfminecraft/gathering/spawn/SpawnPlanner.java @@ -64,7 +64,6 @@ private static ProbeResult probeColumn(World world, int x, int z, SpotTypeLoader if (!above.getType().isAir() || !above2.getType().isAir()) continue; Material surfaceMat = surface.getType(); - if (surfaceMat == Material.WATER || surfaceMat == Material.LAVA) continue; if (!type.acceptsSpawnBlock(surfaceMat)) continue; if (!type.acceptsAltitude(y)) continue; diff --git a/src/main/java/net/tfminecraft/gathering/spot/SpotGatherHandler.java b/src/main/java/net/tfminecraft/gathering/spot/SpotGatherHandler.java index 0408c45..891b5cf 100644 --- a/src/main/java/net/tfminecraft/gathering/spot/SpotGatherHandler.java +++ b/src/main/java/net/tfminecraft/gathering/spot/SpotGatherHandler.java @@ -49,7 +49,7 @@ public void onInteract(PlayerInteractEvent event) { event.setCancelled(true); List rewards = rollRewards(spot); - if (rewards == null || rewards.isEmpty()) { + if (rewards == null) { event.getPlayer().sendMessage("§cNothing to gather here."); return; } @@ -99,9 +99,10 @@ private SpotTypeLoader.CategoryWeight pickCategory(SpotTypeLoader.SpotTypeDefini double r = ThreadLocalRandom.current().nextDouble() * total; double acc = 0.0; - for (SpotTypeLoader.CategoryWeight cw : type.categories) { + for (int i = 0; i < type.categories.size() - 1; i++) { + SpotTypeLoader.CategoryWeight cw = type.categories.get(i); acc += cw.weight; - if (r <= acc) return cw; + if (r < acc) return cw; } return type.categories.get(type.categories.size() - 1); } diff --git a/src/main/java/net/tfminecraft/gathering/utils/GatherFx.java b/src/main/java/net/tfminecraft/gathering/utils/GatherFx.java index b3f82d9..e365a19 100644 --- a/src/main/java/net/tfminecraft/gathering/utils/GatherFx.java +++ b/src/main/java/net/tfminecraft/gathering/utils/GatherFx.java @@ -154,15 +154,19 @@ private static Sound fallbackBreakSound(Material mat) { private static void kickUp(Item ent, ThreadLocalRandom rng) { double vx = randomSigned(rng, Cache.gatherKickHorizontalMin, Cache.gatherKickHorizontalMax); double vz = randomSigned(rng, Cache.gatherKickHorizontalMin, Cache.gatherKickHorizontalMax); - double vy = rng.nextDouble(Cache.gatherKickVelocityMin, Cache.gatherKickVelocityMax); + double vy = randomBetween(rng, Cache.gatherKickVelocityMin, Cache.gatherKickVelocityMax); ent.setVelocity(new Vector(vx, vy, vz)); } private static double randomSigned(ThreadLocalRandom rng, double min, double max) { - double v = rng.nextDouble(min, max); + double v = randomBetween(rng, min, max); return rng.nextBoolean() ? v : -v; } + private static double randomBetween(ThreadLocalRandom rng, double min, double max) { + return min == max ? min : rng.nextDouble(min, max); + } + private static void startCritTrail(Entity entity, int maxTicks) { new BukkitRunnable() { int t = 0; diff --git a/src/test/java/net/tfminecraft/gathering/DatabaseTest.java b/src/test/java/net/tfminecraft/gathering/DatabaseTest.java new file mode 100644 index 0000000..4369198 --- /dev/null +++ b/src/test/java/net/tfminecraft/gathering/DatabaseTest.java @@ -0,0 +1,91 @@ +package net.tfminecraft.gathering; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.file.*; +import java.util.*; +import net.tfminecraft.gathering.database.*; +import net.tfminecraft.gathering.manager.ChunkKey; +import net.tfminecraft.gathering.spot.GatheringSpot; +import org.bukkit.Material; +import org.junit.jupiter.api.Test; + +class DatabaseTest extends GatheringTestSupport { + Path data(String name, String content) throws Exception { + Files.createDirectories(temp.resolve("Data")); + return Files.writeString(temp.resolve("Data/" + name), content); + } + + @Test + void chunkCacheRoundTripsAndFiltersInvalidExpiredEntries() throws Exception { + assertTrue(ChunkCacheDatabase.load().cooldowns.isEmpty()); + ChunkKey first = new ChunkKey("world", 1, -2), second = new ChunkKey("other", 3, 4); + long future = System.currentTimeMillis() + 600000; + ChunkCacheDatabase.save(Map.of(first, future), Set.of(first, second), Map.of(first, "blocked")); + var loaded = ChunkCacheDatabase.load(); + assertEquals(Map.of(first, future), ChunkCacheDatabase.parseCooldowns(loaded)); + assertEquals(Set.of(first, second), ChunkCacheDatabase.parseExcluded(loaded)); + assertEquals("blocked", ChunkCacheDatabase.parseExclusionReasons(loaded).get(first)); + assertEquals("no_valid_surface", ChunkCacheDatabase.parseExclusionReasons(loaded).get(second)); + loaded.cooldowns.put("invalid", future); + loaded.cooldowns.put("world:2:2", null); + loaded.cooldowns.put("world:3:3", 1L); + assertEquals(Map.of(first, future), ChunkCacheDatabase.parseCooldowns(loaded)); + loaded.excluded.add(null); + loaded.excluded.add(new ChunkCacheDatabase.ExcludedChunk(null, 0, 0, null)); + loaded.excluded.add(new ChunkCacheDatabase.ExcludedChunk("new", 0, 0, null)); + assertEquals(3, ChunkCacheDatabase.parseExcluded(loaded).size()); + assertEquals( + "no_valid_surface", + ChunkCacheDatabase.parseExclusionReasons(loaded).get(new ChunkKey("new", 0, 0))); + assertEquals(first, new ChunkCacheDatabase.ExcludedChunk("world", 1, -2, "x").toKey()); + loaded.cooldowns = null; + loaded.excluded = null; + assertTrue(ChunkCacheDatabase.parseCooldowns(loaded).isEmpty()); + assertTrue(ChunkCacheDatabase.parseExcluded(loaded).isEmpty()); + assertTrue(ChunkCacheDatabase.parseExclusionReasons(loaded).isEmpty()); + data("chunk-cache.json", "null"); + assertTrue(ChunkCacheDatabase.load().cooldowns.isEmpty()); + Files.delete(temp.resolve("Data/chunk-cache.json")); + Files.createDirectory(temp.resolve("Data/chunk-cache.json")); + assertTrue(ChunkCacheDatabase.load().cooldowns.isEmpty()); + assertDoesNotThrow(() -> ChunkCacheDatabase.save(Map.of(), Set.of(), Map.of())); + } + + @Test + void spotsRoundTripMetadataAndSkipInvalidRecords() throws Exception { + assertTrue(SpotDatabase.loadAll().isEmpty()); + UUID charId = UUID.randomUUID(), id = UUID.randomUUID(); + GatheringSpot spot = + new GatheringSpot( + id, "world", 1, 64, 3, "herb", Material.GRASS_BLOCK, 42, Map.of(charId, 123L)); + GatheringSpot fallback = + new GatheringSpot(UUID.randomUUID(), "world", 4, 5, 6, "herb", null, 43, null); + SpotDatabase.saveAll(List.of(spot, fallback)); + var loaded = SpotDatabase.loadAll(); + assertEquals(2, loaded.size()); + assertEquals(spot, loaded.get(0)); + assertEquals(Map.of(charId, 123L), loaded.get(0).getDiscoveredByCharacter()); + assertEquals(42, loaded.get(0).getSpawnedAtMs()); + assertEquals(Material.STONE, loaded.get(1).getSpawnBlockMaterial()); + data( + "spots.json", + """ +[{"id":"%s","world":"world","spawnBlockMaterial":"STONE","discoveredByCharacter":{"bad":4,"%s":5}}, +{"id":"%s","world":"world","spawnBlockMaterial":"STONE","discoveredByCharacter":null}, +{"id":"bad","spawnBlockMaterial":"STONE"}, +{"id":"%s","spawnBlockMaterial":"INVALID"}] +""" + .formatted(id, charId, UUID.randomUUID(), id)); + loaded = SpotDatabase.loadAll(); + assertEquals(2, loaded.size()); + assertEquals(Map.of(charId, 5L), loaded.get(0).getDiscoveredByCharacter()); + assertTrue(loaded.get(1).getDiscoveredByCharacter().isEmpty()); + data("spots.json", "null"); + assertTrue(SpotDatabase.loadAll().isEmpty()); + Files.delete(temp.resolve("Data/spots.json")); + Files.createDirectory(temp.resolve("Data/spots.json")); + assertTrue(SpotDatabase.loadAll().isEmpty()); + assertDoesNotThrow(() -> SpotDatabase.saveAll(List.of(spot))); + } +} diff --git a/src/test/java/net/tfminecraft/gathering/DomainTest.java b/src/test/java/net/tfminecraft/gathering/DomainTest.java new file mode 100644 index 0000000..bb7d3f3 --- /dev/null +++ b/src/test/java/net/tfminecraft/gathering/DomainTest.java @@ -0,0 +1,210 @@ +package net.tfminecraft.gathering; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import java.util.concurrent.ThreadLocalRandom; +import net.tfminecraft.gathering.loader.SpotTypeLoader.*; +import net.tfminecraft.gathering.loot.DropCategory; +import net.tfminecraft.gathering.manager.ChunkKey; +import net.tfminecraft.gathering.spot.GatheringSpot; +import org.bukkit.*; +import org.bukkit.block.Biome; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; + +class DomainTest { + ServerMock server; + + @BeforeEach + void setup() { + server = MockBukkit.mock(); + } + + @AfterEach + void teardown() { + MockBukkit.unmock(); + } + + @Test + void chunkKeysRoundTripAndUseFloorCoordinates() { + ChunkKey key = ChunkKey.of("world", -1, -17); + assertEquals(-1, key.chunkX); + assertEquals(-2, key.chunkZ); + assertEquals("world:-1:-2", key.serialize()); + assertEquals(key.serialize(), key.toString()); + assertEquals(key, ChunkKey.deserialize(key.serialize())); + assertEquals(key.hashCode(), ChunkKey.deserialize(key.serialize()).hashCode()); + assertEquals(key, key); + assertNotEquals(key, null); + assertNotEquals(key, "key"); + assertNotEquals(key, new ChunkKey("world", 1, -2)); + assertNotEquals(key, new ChunkKey("world", -1, 2)); + assertNotEquals(key, new ChunkKey("other", -1, -2)); + assertNull(ChunkKey.deserialize(null)); + assertNull(ChunkKey.deserialize("world")); + assertNull(ChunkKey.deserialize("world:bad:2")); + } + + @Test + void spotsPreserveIdentityDiscoveriesAndBlockGeometry() { + World world = server.addSimpleWorld("world"); + World other = server.addSimpleWorld("other"); + UUID id = UUID.randomUUID(), character = UUID.randomUUID(); + Map discoveries = new HashMap<>(Map.of(character, 123L)); + GatheringSpot spot = + new GatheringSpot( + id, "world", -1, 64, -17, "herb", Material.GRASS_BLOCK, 100L, discoveries); + discoveries.clear(); + assertEquals(123L, spot.getDiscoveredByCharacter().get(character)); + assertEquals(id, spot.getId()); + assertEquals("world", spot.getWorldName()); + assertEquals(-1, spot.getBlockX()); + assertEquals(64, spot.getBlockY()); + assertEquals(-17, spot.getBlockZ()); + assertEquals("herb", spot.getSpotTypeId()); + assertEquals(Material.GRASS_BLOCK, spot.getSpawnBlockMaterial()); + assertEquals(100L, spot.getSpawnedAtMs()); + assertEquals(-1, spot.getChunkX()); + assertEquals(-2, spot.getChunkZ()); + spot.markDiscovered(character); + assertEquals(123L, spot.getDiscoveredByCharacter().get(character)); + spot.markDiscovered(null); + assertFalse(spot.isDiscoveredBy(null)); + UUID unseen = UUID.randomUUID(); + assertFalse(spot.isDiscoveredBy(unseen)); + spot.markDiscovered(unseen); + assertTrue(spot.isDiscoveredBy(unseen)); + assertEquals(world, spot.resolveWorld()); + assertEquals(new Location(world, -.5, 64.5, -16.5), spot.getAnchor()); + assertEquals(64.85, spot.getParticleLocation().getY(), 1e-9); + assertEquals(64.75, spot.getGatherEffectLocation().getY(), 1e-9); + assertEquals(new Location(world, -1, 64, -17), spot.getBlockCorner()); + assertEquals(0, spot.distanceSquaredTo(spot.getAnchor())); + assertEquals(14, spot.distanceSquaredTo(spot.getAnchor().add(1, 2, 3))); + assertEquals(Double.MAX_VALUE, spot.distanceSquaredTo(null)); + assertEquals(Double.MAX_VALUE, spot.distanceSquaredTo(new Location(null, 0, 0, 0))); + assertEquals(Double.MAX_VALUE, spot.distanceSquaredTo(new Location(other, 0, 0, 0))); + assertTrue(spot.matchesBlock(new Location(world, -.1, 64.9, -16.1))); + assertFalse(spot.matchesBlock(null)); + assertFalse(spot.matchesBlock(new Location(null, 0, 0, 0))); + assertFalse(spot.matchesBlock(new Location(other, -1, 64, -17))); + assertFalse(spot.matchesBlock(new Location(world, 0, 64, -17))); + assertFalse(spot.matchesBlock(new Location(world, -1, 65, -17))); + assertFalse(spot.matchesBlock(new Location(world, -1, 64, -16))); + assertEquals(spot, spot); + assertNotEquals(spot, null); + assertNotEquals(spot, "spot"); + GatheringSpot same = new GatheringSpot(id, "other", 0, 0, 0, null, null, 0, null); + assertEquals(spot, same); + assertEquals(spot.hashCode(), same.hashCode()); + GatheringSpot made = GatheringSpot.create("world", 0, 0, 0, "herb", Material.STONE); + assertNotEquals(spot, made); + assertTrue(made.getSpawnedAtMs() > 0); + World mocked = mock(World.class); + when(mocked.getName()).thenReturn("unloaded"); + GatheringSpot missing = GatheringSpot.create("missing", 0, 0, 0, "herb", Material.STONE); + assertNull(missing.getAnchor()); + assertNull(missing.getParticleLocation()); + assertNull(missing.getGatherEffectLocation()); + assertNull(missing.getBlockCorner()); + assertFalse(missing.isChunkLoaded()); + world.loadChunk(-1, -2); + assertTrue(spot.isChunkLoaded()); + } + + @Test + void dropLinesValidateRangesCommentsAndWeights() { + assertTrue(new DropCategory("empty", null).isEmpty()); + DropCategory category = + new DropCategory( + "herbs", + Arrays.asList( + null, + " ", + "# comment", + "bad", + "STONE 2 1", + "STONE x-2 1", + "STONE 1-2 nope", + "STONE 3-1 2 # note", + "DIRT 0-0 -5")); + assertEquals("herbs", category.getName()); + assertFalse(category.isEmpty()); + assertEquals(2, category.getEntries().size()); + DropCategory.Entry first = category.getEntries().getFirst(); + assertEquals("STONE", first.type); + assertEquals(1, first.minAmount); + assertEquals(3, first.maxAmount); + assertEquals(2, first.weight); + assertThrows(UnsupportedOperationException.class, () -> category.getEntries().clear()); + ThreadLocalRandom rng = mock(ThreadLocalRandom.class); + when(rng.nextInt(1, 4)).thenReturn(2); + assertEquals(2, first.rollAmount(rng)); + assertEquals(0, new DropCategory.Entry("X", -1, -2, 1).rollAmount(rng)); + assertNull(new DropCategory("empty", List.of()).pickOne(rng)); + assertNull(new DropCategory("zero", List.of("DIRT 1-1 0")).pickOne(rng)); + assertNull(new DropCategory("zeroamount", List.of("DIRT 0-0 1")).rollOne(rng)); + assertNull(new DropCategory("empty", List.of()).rollOne(rng)); + DropCategory.Drop drop = category.rollOne(rng); + assertEquals("STONE", drop.type); + assertEquals(2, drop.amount); + assertEquals(3, category.rollMany(3, rng).size()); + assertTrue(category.rollMany(-1, rng).isEmpty()); + assertTrue(new DropCategory("zero", List.of("DIRT 0-0 1")).rollMany(2, rng).isEmpty()); + when(rng.nextDouble()).thenReturn(.75); + assertEquals( + "DIRT", new DropCategory("two", List.of("STONE 1-1 1", "DIRT 1-1 1")).rollOne(rng).type); + } + + @Test + void zeroWeightEntryMustNeverBeSelectedAtRandomLowerBound() { + ThreadLocalRandom rng = mock(ThreadLocalRandom.class); + when(rng.nextDouble()).thenReturn(0.0); + DropCategory category = new DropCategory("weighted", List.of("DIRT 1-1 0", "STONE 1-1 1")); + assertEquals("STONE", category.rollOne(rng).type); + } + + @Test + void spotDefinitionsRespectInclusiveLimitsAndDefaults() { + SpotTypeDefinition open = + new SpotTypeDefinition("a", null, null, null, null, null, 2, 5, null, List.of()); + assertTrue(open.acceptsBiome(Biome.PLAINS)); + assertTrue(open.acceptsAltitude(-100)); + assertTrue(open.acceptsSpawnBlock(Material.STONE)); + assertEquals(Material.DIRT, open.resolveParticleMaterial(Material.DIRT)); + SpotTypeDefinition bounded = + new SpotTypeDefinition( + "b", + Set.of(Biome.PLAINS), + 10, + 20, + Material.STONE, + Material.DIRT, + 2, + 5, + "herbalism", + List.of()); + assertTrue(bounded.acceptsBiome(Biome.PLAINS)); + assertFalse(bounded.acceptsBiome(Biome.DESERT)); + assertFalse(bounded.acceptsAltitude(9)); + assertTrue(bounded.acceptsAltitude(10)); + assertTrue(bounded.acceptsAltitude(20)); + assertFalse(bounded.acceptsAltitude(21)); + assertTrue(bounded.acceptsSpawnBlock(Material.STONE)); + assertFalse(bounded.acceptsSpawnBlock(Material.DIRT)); + assertEquals(Material.DIRT, bounded.resolveParticleMaterial(Material.STONE)); + assertTrue( + new SpotTypeDefinition("c", Set.of(), null, null, null, null, 1, 0, null, List.of()) + .acceptsBiome(Biome.DESERT)); + ThreadLocalRandom rng = mock(ThreadLocalRandom.class); + CategoryWeight single = new CategoryWeight("a", -1, -1, 0); + assertEquals(0, single.weight); + assertEquals(1, single.rollDropCount(rng)); + CategoryWeight range = new CategoryWeight("b", 2, 2, 4); + when(rng.nextInt(2, 5)).thenReturn(3); + assertEquals(3, range.rollDropCount(rng)); + } +} diff --git a/src/test/java/net/tfminecraft/gathering/EffectsTest.java b/src/test/java/net/tfminecraft/gathering/EffectsTest.java new file mode 100644 index 0000000..b081ab3 --- /dev/null +++ b/src/test/java/net/tfminecraft/gathering/EffectsTest.java @@ -0,0 +1,239 @@ +package net.tfminecraft.gathering; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Method; +import java.util.*; +import net.tfminecraft.gathering.cache.Cache; +import net.tfminecraft.gathering.loader.SpotTypeLoader; +import net.tfminecraft.gathering.spot.*; +import net.tfminecraft.gathering.utils.GatherFx; +import org.bukkit.*; +import org.bukkit.block.Block; +import org.bukkit.block.data.BlockData; +import org.bukkit.entity.*; +import org.bukkit.inventory.ItemStack; +import org.bukkit.util.Vector; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class EffectsTest extends GatheringTestSupport { + Object helper(String name, Class[] types, Object... args) throws Exception { + Method m = GatherFx.class.getDeclaredMethod(name, types); + m.setAccessible(true); + return m.invoke(null, args); + } + + @Test + void particlesRequireLoadedSpotsAndUseConfiguredDust() { + Player player = mock(Player.class); + World world = mock(World.class); + GatheringSpot spot = mock(GatheringSpot.class); + SpotParticles.tickRing(null, spot, null, false); + SpotParticles.tickRing(player, null, null, false); + SpotParticles.tickRing(player, spot, null, false); + when(spot.getParticleLocation()).thenReturn(new Location(null, 0, 0, 0)); + SpotParticles.tickRing(player, spot, null, false); + when(spot.getParticleLocation()).thenReturn(new Location(world, 1, 2, 3)); + SpotParticles.tickRing(player, spot, null, false); + verifyNoInteractions(player); + when(spot.isChunkLoaded()).thenReturn(true); + Cache.particleRingRadius = .5; + SpotParticles.tickRing(player, spot, null, false); + var type = + new SpotTypeLoader.SpotTypeDefinition( + "x", Set.of(), null, null, null, Material.DIRT, 1, 0, null, List.of()); + for (int i = 0; i < 33; i++) SpotParticles.tickRing(player, spot, type, true); + verify(player, times(68)) + .spawnParticle( + eq(Particle.BLOCK), + anyDouble(), + anyDouble(), + anyDouble(), + eq(1), + eq(0d), + eq(0d), + eq(0d), + eq(0d), + any(BlockData.class)); + verify(player, times(33)) + .spawnParticle( + eq(Particle.END_ROD), eq(1d), eq(2.5), eq(3d), eq(2), eq(.05), eq(.1), eq(.05), eq(0d)); + when(spot.getSpawnBlockMaterial()).thenReturn(Material.STONE); + SpotParticles.tickRing(player, spot, null, false); + } + + @Test + void gatherSchedulesBurstsRewardEntitiesAndFiniteTrails() { + World world = mock(World.class); + Player player = mock(Player.class); + Location corner = new Location(world, 1, 64, 2); + Location effect = corner.clone().add(.5, .75, .5); + when(player.getLocation()).thenAnswer(a -> corner.clone()); + Block block = mock(Block.class); + when(block.getType()).thenReturn(Material.STONE); + when(block.getBlockData()).thenReturn(Material.STONE.createBlockData()); + when(world.getBlockAt(any(Location.class))).thenReturn(block); + List spawned = new ArrayList<>(); + when(world.dropItem(any(Location.class), any(ItemStack.class))) + .thenAnswer( + a -> { + Item item = mock(Item.class); + when(item.isValid()).thenReturn(true); + Location loc = a.getArgument(0); + when(item.getLocation()).thenAnswer(b -> loc.clone()); + spawned.add(item); + return item; + }); + Cache.gatherBurstParticles = true; + Cache.gatherKickVelocityMin = .4; + Cache.gatherKickVelocityMax = .8; + Cache.gatherKickHorizontalMin = .01; + Cache.gatherKickHorizontalMax = .05; + ItemStack plain = new ItemStack(Material.STONE, 2), named = new ItemStack(Material.DIAMOND); + var meta = named.getItemMeta(); + meta.setDisplayName("Gem"); + named.setItemMeta(meta); + GatherFx.playGather(null, effect, corner, Material.STONE, List.of(plain)); + GatherFx.playGather(player, null, corner, Material.STONE, List.of(plain)); + GatherFx.playGather( + player, new Location(null, 0, 0, 0), corner, Material.STONE, List.of(plain)); + GatherFx.playGather(player, effect, corner, Material.STONE, null); + GatherFx.playGather(player, effect, corner, Material.STONE, List.of()); + verifyNoInteractions(world); + GatherFx.playGather(player, effect, corner, Material.STONE, Arrays.asList(plain, null, named)); + assertTrue(spawned.isEmpty()); + server.getScheduler().performTicks(15); + assertEquals(2, spawned.size()); + verify(spawned.get(0)).setCustomName("§f2x Stone"); + verify(spawned.get(1)).setCustomName("§f1x Gem"); + verify(spawned.get(0)).setPickupDelay(0); + verify(spawned.get(0)).setCustomNameVisible(true); + ArgumentCaptor velocity = ArgumentCaptor.forClass(Vector.class); + verify(spawned.get(0)).setVelocity(velocity.capture()); + assertTrue(velocity.getValue().getY() >= .4 && velocity.getValue().getY() < .8); + assertTrue( + Math.abs(velocity.getValue().getX()) >= .01 && Math.abs(velocity.getValue().getX()) < .05); + server.getScheduler().performTicks(300); + verify(world, times(280)) + .spawnParticle( + eq(Particle.CRIT), any(Location.class), eq(4), eq(.05), eq(.05), eq(.05), eq(0d)); + GatherFx.dropAtPlayer(null, List.of(plain)); + GatherFx.dropAtPlayer(player, null); + GatherFx.dropAtPlayer(player, List.of()); + Cache.gatherBurstParticles = false; + GatherFx.dropAtPlayer(player, List.of(plain)); + server.getScheduler().performTicks(15); + assertEquals(3, spawned.size()); + when(spawned.get(2).isDead()).thenReturn(true); + server.getScheduler().performTicks(2); + } + + @Test + void materialSoundAndLocationFallbacksAreConsistent() throws Exception { + Class[] resolve = {World.class, Location.class, Material.class}; + assertEquals( + Material.STONE, + ((BlockData) helper("resolveBlockData", resolve, null, null, null)).getMaterial()); + assertEquals( + Material.DIRT, + ((BlockData) helper("resolveBlockData", resolve, null, null, Material.DIRT)).getMaterial()); + World world = mock(World.class); + Block block = mock(Block.class); + when(block.getType()).thenReturn(Material.AIR); + when(world.getBlockAt(any(Location.class))).thenReturn(block); + assertEquals( + Material.STONE, + ((BlockData) helper("resolveBlockData", resolve, world, null, null)).getMaterial()); + assertEquals( + Material.DIRT, + ((BlockData) + helper( + "resolveBlockData", resolve, world, new Location(null, 1, 2, 3), Material.DIRT)) + .getMaterial()); + Location corner = new Location(world, 1.7, 2.8, -.1); + assertEquals( + new Location(world, 1, 2, -1), + helper("normalizeCorner", new Class[] {Location.class}, corner)); + assertEquals( + Material.STONE, + ((BlockData) helper("resolveBlockData", resolve, world, corner, null)).getMaterial()); + assertEquals( + Material.DIRT, + ((BlockData) helper("resolveBlockData", resolve, world, corner, Material.DIRT)) + .getMaterial()); + Class[] sound = {World.class, Location.class, BlockData.class}; + BlockData dirt = Material.DIRT.createBlockData(); + assertEquals(Sound.BLOCK_GRASS_BREAK, helper("resolveBreakSound", sound, null, null, dirt)); + assertEquals(Sound.BLOCK_GRASS_BREAK, helper("resolveBreakSound", sound, world, null, dirt)); + assertEquals(Sound.BLOCK_GRASS_BREAK, helper("resolveBreakSound", sound, world, corner, dirt)); + Map cases = + Map.of( + Material.STONE, + Sound.BLOCK_STONE_BREAK, + Material.DIRT, + Sound.BLOCK_GRASS_BREAK, + Material.SAND, + Sound.BLOCK_SAND_BREAK, + Material.GRAVEL, + Sound.BLOCK_GRAVEL_BREAK, + Material.SNOW, + Sound.BLOCK_SNOW_BREAK, + Material.NETHERRACK, + Sound.BLOCK_NETHERRACK_BREAK, + Material.OAK_LOG, + Sound.BLOCK_WOOD_BREAK); + for (var e : cases.entrySet()) + assertEquals( + e.getValue(), helper("fallbackBreakSound", new Class[] {Material.class}, e.getKey())); + assertEquals( + Sound.BLOCK_STONE_BREAK, + helper("fallbackBreakSound", new Class[] {Material.class}, (Object) null)); + assertEquals("Item", helper("displayNameOf", new Class[] {ItemStack.class}, (Object) null)); + ItemStack noMeta = mock(ItemStack.class); + when(noMeta.getType()).thenReturn(Material.IRON_INGOT); + assertEquals("Iron ingot", helper("displayNameOf", new Class[] {ItemStack.class}, noMeta)); + Class[] burst = {World.class, Location.class, Location.class, BlockData.class}; + helper("playDigBurst", burst, null, corner, corner, dirt); + helper("playDigBurst", burst, world, null, corner, dirt); + helper("playDigBurst", burst, world, corner, corner, null); + Class[] rewards = {World.class, Location.class, List.class}; + helper("spawnRewards", rewards, null, corner, List.of()); + helper("spawnRewards", rewards, world, null, List.of()); + helper("spawnRewards", rewards, world, corner, null); + helper("spawnRewards", rewards, world, corner, List.of()); + helper("startCritTrail", new Class[] {Entity.class, int.class}, null, 1); + Entity invalid = mock(Entity.class); + helper("startCritTrail", new Class[] {Entity.class, int.class}, invalid, 1); + server.getScheduler().performTicks(1); + } + + @Test + void horizontalKicksUseBothSignsWithoutChangingMagnitude() throws Exception { + var rng = mock(java.util.concurrent.ThreadLocalRandom.class); + when(rng.nextDouble(.01, .05)).thenReturn(.03); + when(rng.nextBoolean()).thenReturn(true, false); + Class[] types = {java.util.concurrent.ThreadLocalRandom.class, double.class, double.class}; + assertEquals(.03, (double) helper("randomSigned", types, rng, .01, .05)); + assertEquals(-.03, (double) helper("randomSigned", types, rng, .01, .05)); + } + + @Test + void equalConfiguredKickBoundsProduceFixedVelocity() throws Exception { + Cache.gatherKickHorizontalMin = .03; + Cache.gatherKickHorizontalMax = .03; + Cache.gatherKickVelocityMin = .6; + Cache.gatherKickVelocityMax = .6; + Item item = mock(Item.class); + helper( + "kickUp", + new Class[] {Item.class, java.util.concurrent.ThreadLocalRandom.class}, + item, + java.util.concurrent.ThreadLocalRandom.current()); + ArgumentCaptor velocity = ArgumentCaptor.forClass(Vector.class); + verify(item).setVelocity(velocity.capture()); + assertEquals(.6, velocity.getValue().getY()); + assertEquals(.03, Math.abs(velocity.getValue().getX())); + } +} diff --git a/src/test/java/net/tfminecraft/gathering/GatherInteractionTest.java b/src/test/java/net/tfminecraft/gathering/GatherInteractionTest.java new file mode 100644 index 0000000..5f0d6c9 --- /dev/null +++ b/src/test/java/net/tfminecraft/gathering/GatherInteractionTest.java @@ -0,0 +1,168 @@ +package net.tfminecraft.gathering; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Method; +import java.util.*; +import java.util.concurrent.ThreadLocalRandom; +import net.tfminecraft.gathering.loader.*; +import net.tfminecraft.gathering.loot.DropCategory; +import net.tfminecraft.gathering.manager.*; +import net.tfminecraft.gathering.spot.*; +import net.tfminecraft.gathering.utils.*; +import org.bukkit.*; +import org.bukkit.block.*; +import org.bukkit.entity.Player; +import org.bukkit.event.block.Action; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.inventory.*; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +class GatherInteractionTest extends GatheringTestSupport { + PlayerInteractEvent event(Player player, Block block, Action action, EquipmentSlot hand) { + return new PlayerInteractEvent(player, action, null, block, BlockFace.UP, hand); + } + + void categories(List categories) { + SpotTypeLoader.get() + .put( + "herb", + new SpotTypeLoader.SpotTypeDefinition( + "herb", Set.of(), null, null, null, null, 1, 0, null, categories)); + } + + @Test + void interactionGuardsCharacterDiscoveryAndUnavailableRewards() { + Player player = server.addPlayer(); + Block block = player.getWorld().getBlockAt(1, 64, 1); + block.setType(Material.STONE); + SpotManager manager = new SpotManager(); + when(plugin.getSpotManager()).thenReturn(manager); + SpotGatherHandler listener = new SpotGatherHandler(); + UUID character = UUID.randomUUID(); + try (MockedStatic bridge = mockStatic(CharacterBridge.class); + MockedStatic items = mockStatic(ItemResolver.class)) { + listener.onInteract(event(player, block, Action.LEFT_CLICK_BLOCK, EquipmentSlot.HAND)); + listener.onInteract(event(player, block, Action.RIGHT_CLICK_BLOCK, EquipmentSlot.OFF_HAND)); + listener.onInteract(event(player, null, Action.RIGHT_CLICK_BLOCK, EquipmentSlot.HAND)); + listener.onInteract(event(player, block, Action.RIGHT_CLICK_BLOCK, EquipmentSlot.HAND)); + GatheringSpot spot = + GatheringSpot.create(player.getWorld().getName(), 1, 64, 1, "herb", Material.STONE); + manager.register(spot); + var absent = event(player, block, Action.RIGHT_CLICK_BLOCK, EquipmentSlot.HAND); + listener.onInteract(absent); + assertFalse(absent.isCancelled()); + bridge.when(() -> CharacterBridge.getActiveCharacterUuid(player)).thenReturn(character); + var hidden = event(player, block, Action.RIGHT_CLICK_BLOCK, EquipmentSlot.HAND); + listener.onInteract(hidden); + assertFalse(hidden.isCancelled()); + spot.markDiscovered(character); + var missing = event(player, block, Action.RIGHT_CLICK_BLOCK, EquipmentSlot.HAND); + listener.onInteract(missing); + assertTrue(missing.isCancelled()); + assertEquals(1, manager.getActiveSpotCount()); + categories(List.of()); + listener.onInteract(event(player, block, Action.RIGHT_CLICK_BLOCK, EquipmentSlot.HAND)); + categories(List.of(new SpotTypeLoader.CategoryWeight("missing", 0, 1, 1))); + listener.onInteract(event(player, block, Action.RIGHT_CLICK_BLOCK, EquipmentSlot.HAND)); + categories(List.of(new SpotTypeLoader.CategoryWeight("drops", 1, 1, 1))); + listener.onInteract(event(player, block, Action.RIGHT_CLICK_BLOCK, EquipmentSlot.HAND)); + CategoryLoader.get().put("drops", new DropCategory("drops", List.of())); + listener.onInteract(event(player, block, Action.RIGHT_CLICK_BLOCK, EquipmentSlot.HAND)); + CategoryLoader.get().put("drops", new DropCategory("drops", List.of("STONE 1-1 1"))); + listener.onInteract(event(player, block, Action.RIGHT_CLICK_BLOCK, EquipmentSlot.HAND)); + assertEquals(1, manager.getActiveSpotCount()); + } + } + + @Test + void successfulGatherAwardsEffectsRemovesSpotAndStartsCooldown() { + Player player = server.addPlayer(); + Block block = player.getWorld().getBlockAt(1, 64, 1); + SpotManager manager = new SpotManager(); + when(plugin.getSpotManager()).thenReturn(manager); + GatheringSpot spot = + GatheringSpot.create(player.getWorld().getName(), 1, 64, 1, "herb", Material.STONE); + manager.register(spot); + AdminModeService.get().enable(player); + categories(List.of(new SpotTypeLoader.CategoryWeight("drops", 1, 1, 1))); + CategoryLoader.get().put("drops", new DropCategory("drops", List.of("STONE 2-2 1"))); + net.tfminecraft.gathering.cache.Cache.chunkCooldownMinutes = 10; + try (MockedStatic resolver = mockStatic(ItemResolver.class); + MockedStatic effects = mockStatic(GatherFx.class)) { + ItemStack reward = new ItemStack(Material.STONE, 2); + resolver.when(() -> ItemResolver.fromPath("STONE", 2)).thenReturn(reward); + var event = event(player, block, Action.RIGHT_CLICK_BLOCK, EquipmentSlot.HAND); + new SpotGatherHandler().onInteract(event); + assertTrue(event.isCancelled()); + assertEquals(0, manager.getActiveSpotCount()); + assertTrue(manager.isChunkOnCooldown(new ChunkKey(player.getWorld().getName(), 0, 0))); + effects.verify( + () -> + GatherFx.playGather( + eq(player), + eq(spot.getGatherEffectLocation()), + eq(spot.getBlockCorner()), + eq(Material.STONE), + eq(List.of(reward)))); + // Stored worlds can disappear during plugin/world reload; rewards still go to the player. + SpotManager mocked = mock(SpotManager.class); + when(plugin.getSpotManager()).thenReturn(mocked); + GatheringSpot missing = GatheringSpot.create("missing", 1, 64, 1, "herb", Material.STONE); + when(mocked.getAtBlock(any())).thenReturn(missing); + new SpotGatherHandler() + .onInteract(event(player, block, Action.RIGHT_CLICK_BLOCK, EquipmentSlot.HAND)); + effects.verify(() -> GatherFx.dropAtPlayer(player, List.of(reward))); + verify(mocked).remove(missing); + GatheringSpot unloading = mock(GatheringSpot.class); + when(unloading.getSpotTypeId()).thenReturn("herb"); + when(unloading.getGatherEffectLocation()).thenReturn(player.getLocation()); + when(unloading.getWorldName()).thenReturn("world"); + when(mocked.getAtBlock(any())).thenReturn(unloading); + new SpotGatherHandler() + .onInteract(event(player, block, Action.RIGHT_CLICK_BLOCK, EquipmentSlot.HAND)); + effects.verify(() -> GatherFx.dropAtPlayer(player, List.of(reward)), times(2)); + } finally { + AdminModeService.get().disable(player); + } + } + + @Test + void weightedCategoriesRespectZeroWeightsAndSelectionBoundaries() throws Exception { + categories( + List.of( + new SpotTypeLoader.CategoryWeight("disabled", 0, 1, 1), + new SpotTypeLoader.CategoryWeight("enabled", 1, 1, 1))); + ThreadLocalRandom rng = mock(ThreadLocalRandom.class); + when(rng.nextDouble()).thenReturn(0.0); + Method pick = + SpotGatherHandler.class.getDeclaredMethod( + "pickCategory", SpotTypeLoader.SpotTypeDefinition.class); + pick.setAccessible(true); + try (MockedStatic random = mockStatic(ThreadLocalRandom.class)) { + random.when(ThreadLocalRandom::current).thenReturn(rng); + assertEquals( + "enabled", + ((SpotTypeLoader.CategoryWeight) + pick.invoke(new SpotGatherHandler(), SpotTypeLoader.getByString("herb"))) + .id); + categories( + List.of( + new SpotTypeLoader.CategoryWeight("first", 1, 1, 1), + new SpotTypeLoader.CategoryWeight("second", 1, 1, 1))); + assertEquals( + "first", + ((SpotTypeLoader.CategoryWeight) + pick.invoke(new SpotGatherHandler(), SpotTypeLoader.getByString("herb"))) + .id); + when(rng.nextDouble()).thenReturn(.75); + assertEquals( + "second", + ((SpotTypeLoader.CategoryWeight) + pick.invoke(new SpotGatherHandler(), SpotTypeLoader.getByString("herb"))) + .id); + } + } +} diff --git a/src/test/java/net/tfminecraft/gathering/GatheringTestSupport.java b/src/test/java/net/tfminecraft/gathering/GatheringTestSupport.java new file mode 100644 index 0000000..ac4f362 --- /dev/null +++ b/src/test/java/net/tfminecraft/gathering/GatheringTestSupport.java @@ -0,0 +1,37 @@ +package net.tfminecraft.gathering; + +import static org.mockito.Mockito.*; + +import java.nio.file.Path; +import java.util.logging.Logger; +import net.tfminecraft.gathering.loader.*; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.mockbukkit.mockbukkit.*; + +abstract class GatheringTestSupport { + @TempDir Path temp; + ServerMock server; + Gathering plugin; + + @BeforeEach + void setupServer() { + server = MockBukkit.mock(); + plugin = mock(Gathering.class); + when(plugin.getDataFolder()).thenReturn(temp.toFile()); + when(plugin.getLogger()).thenReturn(Logger.getLogger("GatheringTest")); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getName()).thenReturn("Gathering"); + Gathering.plugin = plugin; + CategoryLoader.clear(); + SpotTypeLoader.clear(); + } + + @AfterEach + void closeServer() { + CategoryLoader.clear(); + SpotTypeLoader.clear(); + MockBukkit.unmock(); + Gathering.plugin = null; + } +} diff --git a/src/test/java/net/tfminecraft/gathering/IntegrationTest.java b/src/test/java/net/tfminecraft/gathering/IntegrationTest.java new file mode 100644 index 0000000..ba7410a --- /dev/null +++ b/src/test/java/net/tfminecraft/gathering/IntegrationTest.java @@ -0,0 +1,207 @@ +package net.tfminecraft.gathering; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.Indyuce.mmocore.MMOCore; +import net.Indyuce.mmocore.experience.Profession; +import net.Indyuce.mmocore.manager.profession.ProfessionManager; +import net.tfminecraft.gathering.cache.Cache; +import net.tfminecraft.gathering.discovery.SpotDiscoveryService; +import net.tfminecraft.gathering.loader.SpotTypeLoader; +import net.tfminecraft.gathering.manager.*; +import net.tfminecraft.gathering.spot.*; +import net.tfminecraft.gathering.utils.*; +import net.tfminecraft.rpcharacters.managers.PlayerManager; +import net.tfminecraft.rpcharacters.objects.*; +import net.tfminecraft.rpcharacters.objects.attributes.AttributeModifier; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import org.bukkit.*; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockito.MockedStatic; + +class IntegrationTest extends GatheringTestSupport { + @Test + void characterBridgeHandlesAbsentInactiveAndMalformedIdentities() { + Player player = server.addPlayer(); + try (MockedStatic players = mockStatic(PlayerManager.class)) { + assertNull(CharacterBridge.getActiveCharacter(null)); + assertNull(CharacterBridge.getActiveCharacter(player)); + assertNull(CharacterBridge.getActiveCharacterUuid(player)); + PlayerData data = mock(PlayerData.class); + players.when(() -> PlayerManager.get(player)).thenReturn(data); + assertNull(CharacterBridge.getActiveCharacter(player)); + when(data.hasActiveCharacter()).thenReturn(true); + RPCharacter character = mock(RPCharacter.class); + when(data.getActiveCharacter()).thenReturn(character); + assertSame(character, CharacterBridge.getActiveCharacter(player)); + assertNull(CharacterBridge.getActiveCharacterUuid(player)); + when(character.getId()).thenReturn("invalid"); + assertNull(CharacterBridge.getActiveCharacterUuid(player)); + UUID id = UUID.randomUUID(); + when(character.getId()).thenReturn(id.toString()); + assertEquals(id, CharacterBridge.getActiveCharacterUuid(player)); + } + } + + @Test + void itemResolutionRejectsMissingItemsAndClampsAmounts() { + assertNull(ItemResolver.fromPath(null, 1)); + assertNull(ItemResolver.fromPath(" ", 1)); + try (MockedStatic tlibs = mockStatic(TLibs.class)) { + ItemAPI api = mock(ItemAPI.class, RETURNS_DEEP_STUBS); + tlibs.when(TLibs::getItemAPI).thenReturn(api); + when(api.getCreator().getItemFromPath("missing")).thenReturn(null); + assertNull(ItemResolver.fromPath("missing", 1)); + when(api.getCreator().getItemFromPath("air")).thenReturn(new ItemStack(Material.AIR)); + assertNull(ItemResolver.fromPath("air", 1)); + when(api.getCreator().getItemFromPath("stone")) + .thenAnswer(a -> new ItemStack(Material.STONE)); + assertEquals(1, ItemResolver.fromPath("stone", -4).getAmount()); + assertEquals(64, ItemResolver.fromPath("stone", 100).getAmount()); + assertEquals(3, ItemResolver.fromPath("stone", 3).getAmount()); + when(api.getCreator().getItemFromPath("broken")) + .thenThrow(new IllegalArgumentException("bad")); + assertNull(ItemResolver.fromPath("broken", 1)); + } + } + + @Test + void professionIntegrationIsOptionalAndUsesConfiguredOverride() throws Exception { + Player player = server.addPlayer(); + Cache.professionEnabled = false; + assertEquals(0, ProfessionBridge.professionBonus(player, null)); + Cache.professionEnabled = true; + assertEquals(0, ProfessionBridge.professionBonus(player, null)); + MockBukkit.createMockPlugin("MMOCore"); + assertEquals(0, ProfessionBridge.professionBonus(null, null)); + Cache.professionId = null; + assertEquals(0, ProfessionBridge.professionBonus(player, null)); + Cache.professionId = " "; + assertEquals(0, ProfessionBridge.professionBonus(player, null)); + Cache.professionId = "herbalism"; + Cache.professionLevelWeight = .1; + MMOCore original = MMOCore.plugin; + try (MockedStatic players = + mockStatic(net.Indyuce.mmocore.api.player.PlayerData.class)) { + MMOCore core = mock(MMOCore.class); + MMOCore.plugin = core; + ProfessionManager professions = mock(ProfessionManager.class); + var field = MMOCore.class.getField("professionManager"); + field.setAccessible(true); + field.set(core, professions); + assertEquals(0, ProfessionBridge.professionBonus(player, null)); + Profession profession = mock(Profession.class); + when(professions.get("herbalism")).thenReturn(profession); + when(professions.get("mining")).thenReturn(profession); + var data = mock(net.Indyuce.mmocore.api.player.PlayerData.class, RETURNS_DEEP_STUBS); + players.when(() -> net.Indyuce.mmocore.api.player.PlayerData.get(player)).thenReturn(data); + when(data.getCollectionSkills().getLevel(profession)).thenReturn(3); + assertEquals(.3, ProfessionBridge.professionBonus(player, ManagerTest.type("x", 1, 0)), 1e-9); + var override = + new SpotTypeLoader.SpotTypeDefinition( + "ore", Set.of(), null, null, null, null, 1, 0, "mining", List.of()); + assertEquals(.3, ProfessionBridge.professionBonus(player, override), 1e-9); + verify(professions).get("mining"); + when(data.getCollectionSkills().getLevel(profession)).thenReturn(-1); + assertEquals(0, ProfessionBridge.professionBonus(player, null)); + when(professions.get("herbalism")).thenThrow(new IllegalStateException("unavailable")); + assertEquals(0, ProfessionBridge.professionBonus(player, null)); + } finally { + MMOCore.plugin = original; + } + } + + @Test + void discoveryUsesAttributesClampsProbabilityAndNotifiesOnlyOnce() { + Player player = server.addPlayer(); + SpotManager manager = mock(SpotManager.class); + RPCharacter character = mock(RPCharacter.class, RETURNS_DEEP_STUBS); + UUID id = UUID.randomUUID(); + GatheringSpot spot = + GatheringSpot.create(player.getWorld().getName(), 0, 64, 0, "herb", Material.STONE); + Cache.passiveBaseChance = .1; + Cache.wisdomWeight = .02; + Cache.intelligenceWeight = .015; + when(character.getAttributeData().getAmount(any(AttributeModifier.class))) + .thenAnswer(a -> ((AttributeModifier) a.getArgument(0)).getType().equals("wisdom") ? 3 : 4); + try (MockedStatic characters = mockStatic(CharacterBridge.class); + MockedStatic professions = mockStatic(ProfessionBridge.class); + MockedStatic visuals = mockStatic(SpotVisualManager.class)) { + SpotVisualManager visual = mock(SpotVisualManager.class); + visuals.when(SpotVisualManager::get).thenReturn(visual); + assertEquals(.22, SpotDiscoveryService.computeChance(character, player, null), 1e-9); + Cache.passiveBaseChance = -10; + assertEquals(0, SpotDiscoveryService.computeChance(character, player, null)); + Cache.passiveBaseChance = 10; + assertEquals(1, SpotDiscoveryService.computeChance(character, player, null)); + assertFalse(SpotDiscoveryService.tryPassiveDiscovery(null, character, spot, manager)); + assertFalse(SpotDiscoveryService.tryPassiveDiscovery(player, null, spot, manager)); + assertFalse(SpotDiscoveryService.tryPassiveDiscovery(player, character, null, manager)); + assertFalse(SpotDiscoveryService.tryPassiveDiscovery(player, character, spot, manager)); + characters.when(() -> CharacterBridge.getActiveCharacterUuid(player)).thenReturn(id); + Cache.passiveBaseChance = -10; + assertFalse(SpotDiscoveryService.tryPassiveDiscovery(player, character, spot, manager)); + Cache.passiveBaseChance = 10; + Cache.passiveMessage = "&aFound"; + assertTrue(SpotDiscoveryService.tryPassiveDiscovery(player, character, spot, manager)); + assertTrue(spot.isDiscoveredBy(id)); + verify(manager).markDirty(); + verify(visual).refreshPlayer(player, manager); + assertFalse(SpotDiscoveryService.tryPassiveDiscovery(player, character, spot, manager)); + for (String message : Arrays.asList(null, " ")) { + Cache.passiveMessage = message; + assertTrue( + SpotDiscoveryService.tryPassiveDiscovery( + player, + character, + GatheringSpot.create("world", 0, 0, 0, "herb", Material.STONE), + manager)); + } + Cache.passiveDiscoveryEnabled = false; + SpotDiscoveryService.tickPassive(manager); + characters.verify(() -> CharacterBridge.getActiveCharacter(player), never()); + Cache.passiveDiscoveryEnabled = true; + SpotDiscoveryService.tickPassive(manager); + characters.when(() -> CharacterBridge.getActiveCharacter(player)).thenReturn(character); + when(manager.getSpotsNear(any(), anyDouble())).thenReturn(List.of(spot)); + SpotDiscoveryService.tickPassive(manager); + } + } + + @Test + void visualsRespectOnlineCharacterDiscoveryAndAdminBypass() { + Player player = server.addPlayer(); + SpotManager manager = mock(SpotManager.class); + UUID id = UUID.randomUUID(); + GatheringSpot + visible = + GatheringSpot.create(player.getWorld().getName(), 0, 64, 0, "herb", Material.STONE), + hidden = + GatheringSpot.create(player.getWorld().getName(), 16, 64, 0, "herb", Material.STONE); + visible.markDiscovered(id); + when(manager.getSpotsNear(any(), anyDouble())).thenReturn(List.of(visible, hidden)); + try (MockedStatic characters = mockStatic(CharacterBridge.class); + MockedStatic particles = mockStatic(SpotParticles.class)) { + SpotVisualManager visual = SpotVisualManager.get(); + visual.refreshPlayer(null, manager); + visual.refreshPlayer(mock(Player.class), manager); + visual.refreshPlayer(player, manager); + verify(manager, never()).getSpotsNear(any(), anyDouble()); + characters.when(() -> CharacterBridge.getActiveCharacterUuid(player)).thenReturn(id); + visual.tickAllPlayers(manager); + particles.verify(() -> SpotParticles.tickRing(player, visible, null, false)); + particles.verify(() -> SpotParticles.tickRing(player, hidden, null, false), never()); + AdminModeService.get().enable(player); + visual.refreshPlayer(player, manager); + particles.verify(() -> SpotParticles.tickRing(player, visible, null, true)); + particles.verify(() -> SpotParticles.tickRing(player, hidden, null, true)); + AdminModeService.get().disable(player); + } + } +} diff --git a/src/test/java/net/tfminecraft/gathering/LifecycleTest.java b/src/test/java/net/tfminecraft/gathering/LifecycleTest.java new file mode 100644 index 0000000..2ef16fd --- /dev/null +++ b/src/test/java/net/tfminecraft/gathering/LifecycleTest.java @@ -0,0 +1,48 @@ +package net.tfminecraft.gathering; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.file.Files; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.*; + +class LifecycleTest { + ServerMock server; + + @BeforeEach + void setup() { + server = MockBukkit.mock(); + MockBukkit.createMockPlugin("TLibs"); + MockBukkit.createMockPlugin("RPCharacters"); + } + + @AfterEach + void teardown() { + MockBukkit.unmock(); + Gathering.plugin = null; + } + + @Test + void pluginCreatesLoadsReloadsAndPreservesExistingConfiguration() throws Exception { + Gathering plugin = MockBukkit.load(Gathering.class); + assertSame(plugin, Gathering.plugin); + assertNotNull(plugin.getSpotManager()); + assertNotNull(plugin.getCommand("gathering").getExecutor()); + assertNotNull(plugin.getCommand("gathering").getTabCompleter()); + assertTrue(plugin.getDataFolder().toPath().resolve("Data").toFile().isDirectory()); + var file = plugin.getDataFolder().toPath().resolve("config.yml"); + Files.writeString(file, "spawn-interval-minutes: 17\n"); + plugin.createFolders(); + plugin.createConfigs(); + plugin.reloadAll(); + assertEquals(17, net.tfminecraft.gathering.cache.Cache.spawnIntervalMinutes); + assertEquals("spawn-interval-minutes: 17\n", Files.readString(file)); + plugin.onDisable(); + try (var paths = Files.walk(plugin.getDataFolder().toPath())) { + for (var path : paths.sorted(java.util.Comparator.reverseOrder()).toList()) + Files.delete(path); + } + plugin.createFolders(); + assertTrue(Files.isDirectory(plugin.getDataFolder().toPath().resolve("Data"))); + } +} diff --git a/src/test/java/net/tfminecraft/gathering/LoadersTest.java b/src/test/java/net/tfminecraft/gathering/LoadersTest.java new file mode 100644 index 0000000..4d4915c --- /dev/null +++ b/src/test/java/net/tfminecraft/gathering/LoadersTest.java @@ -0,0 +1,194 @@ +package net.tfminecraft.gathering; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.file.*; +import java.util.*; +import net.tfminecraft.gathering.cache.Cache; +import net.tfminecraft.gathering.loader.*; +import org.bukkit.Material; +import org.bukkit.block.Biome; +import org.junit.jupiter.api.Test; + +class LoadersTest extends GatheringTestSupport { + Path yaml(String name, String text) throws Exception { + return Files.writeString(temp.resolve(name), text); + } + + @Test + void configDefaultsClampsBoundsAndReversedEffects() throws Exception { + ConfigLoader loader = new ConfigLoader(); + loader.load(yaml("empty.yml", "{}").toFile()); + assertEquals(10, Cache.spawnIntervalMinutes); + assertEquals(4320, Cache.chunkCooldownMinutes); + assertTrue(Cache.worldBounds.isEmpty()); + assertTrue(Cache.passiveDiscoveryEnabled); + assertEquals("herbalism", Cache.professionId); + loader.load( + yaml( + "config.yml", + """ + spawn-interval-minutes: 0 + chunk-cooldown-minutes: -1 + spawn-attempts-per-tick: 0 + probe-column-attempts: 0 + worlds: + world: + chunk-x-min: -5 + chunk-x-max: 8 + chunk-z-min: -7 + chunk-z-max: 9 + passive-discovery: + enabled: false + interval-seconds: 0 + radius: 12 + base-chance: 0.4 + message: hello + attributes: + wisdom-weight: 0.3 + intelligence-weight: 0.2 + profession: + enabled: false + id: mining + level-weight: 0.5 + particles: + interval-ticks: 0 + ring-radius: 0.7 + gather-fx: + burst-particles: true + kick-velocity-min: 2 + kick-velocity-max: 1 + kick-horizontal-min: 0.4 + kick-horizontal-max: 0.2 + """) + .toFile()); + assertEquals(1, Cache.spawnIntervalMinutes); + assertEquals(0, Cache.chunkCooldownMinutes); + assertEquals(1, Cache.spawnAttemptsPerTick); + assertEquals(1, Cache.probeColumnAttempts); + var bounds = Cache.worldBounds.get("world"); + assertEquals("world", bounds.world); + assertEquals(-5, bounds.chunkXMin); + assertEquals(8, bounds.chunkXMax); + assertEquals(-7, bounds.chunkZMin); + assertEquals(9, bounds.chunkZMax); + assertFalse(Cache.passiveDiscoveryEnabled); + assertEquals(1, Cache.passiveIntervalSeconds); + assertEquals(12, Cache.passiveRadius); + assertEquals(.4, Cache.passiveBaseChance); + assertEquals("hello", Cache.passiveMessage); + assertEquals(.3, Cache.wisdomWeight); + assertEquals(.2, Cache.intelligenceWeight); + assertFalse(Cache.professionEnabled); + assertEquals("mining", Cache.professionId); + assertEquals(.5, Cache.professionLevelWeight); + assertEquals(1, Cache.particleIntervalTicks); + assertEquals(.7, Cache.particleRingRadius); + assertTrue(Cache.gatherBurstParticles); + assertEquals(1, Cache.gatherKickVelocityMin); + assertEquals(2, Cache.gatherKickVelocityMax); + assertEquals(.2, Cache.gatherKickHorizontalMin); + assertEquals(.4, Cache.gatherKickHorizontalMax); + loader.load(temp.resolve("missing.yml").toFile()); + assertEquals(10, Cache.spawnIntervalMinutes); + loader.load(yaml("invalid.yml", "key: [unterminated").toFile()); + assertTrue(Cache.worldBounds.isEmpty()); + } + + @Test + void categoriesSupportNestedAndLegacyListsAndReplaceOnReload() throws Exception { + CategoryLoader loader = new CategoryLoader(); + loader.load( + yaml( + "categories.yml", + """ + herbs: + drops: ["STONE 1-2 1"] + legacy: ["DIRT 1-1 2"] + empty: [] + """) + .toFile()); + assertEquals(3, CategoryLoader.get().size()); + assertEquals("STONE", CategoryLoader.getByString("herbs").getEntries().getFirst().type); + assertEquals("DIRT", CategoryLoader.getByString("legacy").getEntries().getFirst().type); + assertTrue(CategoryLoader.getByString("empty").isEmpty()); + loader.load(temp.resolve("missing.yml").toFile()); + assertTrue(CategoryLoader.get().isEmpty()); + loader.load(yaml("badcategories.yml", "key: [unterminated").toFile()); + assertTrue(CategoryLoader.get().isEmpty()); + CategoryLoader.clear(); + assertNull(CategoryLoader.getByString("herbs")); + } + + @Test + void spotTypesParseFiltersAndFlexibleCategoryRanges() throws Exception { + SpotTypeLoader loader = new SpotTypeLoader(); + loader.load( + yaml( + "types.yml", + """ + ignored: value + defaults: {} + herb: + biome: [PLAINS, minecraft:desert, ' ', 'bad:unknown', 'bad name'] + altitude-min: 10 + altitude-max: 90 + spawn-block: stone + particle-dust: dirt + max-active: 0 + cooldown-minutes: -3 + profession-id: herbalism + categories: + - {id: first, weight: 2, drops: '2-4'} + - {id: second, weight: '0.4', drops: '3'} + - {id: invalid, weight: 'bad', drops: 'bad'} + - {id: default} + - {weight: 2} + bad: + spawn-block: unknown + particle-dust: ' ' + categories: + - {id: reversed, drops: '5-2'} + - {id: negative, drops: '-1'} + - {id: blank, drops: ' '} + """) + .toFile()); + assertEquals(3, SpotTypeLoader.all().size()); + assertEquals(3, SpotTypeLoader.get().size()); + assertNull(SpotTypeLoader.getByString("ignored")); + var herb = SpotTypeLoader.getByString("herb"); + assertEquals(Set.of(Biome.PLAINS, Biome.DESERT), herb.biomes); + assertEquals(10, herb.altitudeMin); + assertEquals(90, herb.altitudeMax); + assertEquals(Material.STONE, herb.spawnBlock); + assertEquals(Material.DIRT, herb.particleDust); + assertEquals(1, herb.maxActive); + assertEquals(0, herb.cooldownMinutes); + assertEquals("herbalism", herb.professionId); + assertEquals(4, herb.categories.size()); + assertEquals(2, herb.categories.get(0).dropsMin); + assertEquals(4, herb.categories.get(0).dropsMax); + assertEquals(.4, herb.categories.get(1).weight); + assertEquals(3, herb.categories.get(1).dropsMax); + assertEquals(1, herb.categories.get(2).weight); + assertEquals(1, herb.categories.get(2).dropsMax); + assertThrows(UnsupportedOperationException.class, () -> herb.categories.clear()); + var bad = SpotTypeLoader.getByString("bad"); + assertNull(bad.spawnBlock); + assertNull(bad.particleDust); + assertEquals(5, bad.categories.get(0).dropsMax); + assertEquals(1, bad.categories.get(1).dropsMin); + loader.load( + yaml( + "biome-inputs.yml", + "missing: {}\nempty:\n biome: []\nnulls:\n biome: [null, plains]\n") + .toFile()); + assertEquals(Set.of(), SpotTypeLoader.getByString("missing").biomes); + assertEquals(Set.of(), SpotTypeLoader.getByString("empty").biomes); + assertEquals(Set.of(Biome.PLAINS), SpotTypeLoader.getByString("nulls").biomes); + loader.load(temp.resolve("missing.yml").toFile()); + assertTrue(SpotTypeLoader.all().isEmpty()); + loader.load(yaml("badtypes.yml", "key: [unterminated").toFile()); + assertTrue(SpotTypeLoader.all().isEmpty()); + } +} diff --git a/src/test/java/net/tfminecraft/gathering/ManagerTest.java b/src/test/java/net/tfminecraft/gathering/ManagerTest.java new file mode 100644 index 0000000..f7a02fe --- /dev/null +++ b/src/test/java/net/tfminecraft/gathering/ManagerTest.java @@ -0,0 +1,259 @@ +package net.tfminecraft.gathering; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.file.Files; +import java.util.*; +import net.tfminecraft.gathering.cache.Cache; +import net.tfminecraft.gathering.loader.SpotTypeLoader; +import net.tfminecraft.gathering.manager.*; +import net.tfminecraft.gathering.spawn.*; +import net.tfminecraft.gathering.spot.GatheringSpot; +import org.bukkit.*; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.bukkit.event.player.PlayerQuitEvent; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +class ManagerTest extends GatheringTestSupport { + static SpotTypeLoader.SpotTypeDefinition type(String id, int max, int cooldown) { + return new SpotTypeLoader.SpotTypeDefinition( + id, Set.of(), null, null, null, null, max, cooldown, null, List.of()); + } + + @Test + void indexLookupRemovalPersistenceAndCooldowns() throws Exception { + World world = server.addSimpleWorld("world"); + World other = server.addSimpleWorld("other"); + SpotManager manager = new SpotManager(); + manager.shutdown(); + manager.loadAllFromDisk(); + assertEquals(0, manager.getActiveSpotCount()); + GatheringSpot spot = GatheringSpot.create("world", 1, 64, 1, "herb", Material.STONE); + GatheringSpot second = GatheringSpot.create("world", 18, 64, 1, "ore", Material.STONE); + manager.register(spot); + manager.register(second); + assertEquals(2, manager.getAllSpots().size()); + assertEquals(spot, manager.get(spot.getId())); + assertEquals(1, manager.countActiveByType("herb")); + assertEquals(0, manager.countActiveByType("absent")); + ChunkKey key = manager.chunkKey("world", 0, 0); + assertTrue(manager.hasActiveSpotInChunk(key)); + assertFalse(manager.hasActiveSpotInChunk(null)); + assertEquals(spot, manager.getAtBlock(new Location(world, 1, 64, 1))); + assertNull(manager.getAtBlock(null)); + assertNull(manager.getAtBlock(new Location(null, 0, 0, 0))); + assertNull(manager.getAtBlock(new Location(world, 1, 65, 1))); + assertNull(manager.getAtBlock(new Location(other, 1, 64, 1))); + assertEquals(List.of(spot), manager.getSpotsNear(spot.getAnchor(), 5)); + assertEquals(2, manager.getSpotsNear(spot.getAnchor(), 20).size()); + assertTrue(manager.getSpotsNear(null, 5).isEmpty()); + assertTrue(manager.getSpotsNear(new Location(null, 0, 0, 0), 5).isEmpty()); + assertTrue(manager.getSpotsNear(spot.getAnchor(), 0).isEmpty()); + manager.setChunkCooldown(null); + assertFalse(manager.isChunkOnCooldown(key)); + Cache.chunkCooldownMinutes = 10; + manager.setChunkCooldown(key); + assertTrue(manager.isChunkOnCooldown(key)); + assertEquals(1, manager.getCooldownChunkCount()); + Cache.chunkCooldownMinutes = 0; + manager.setChunkCooldown(key); + assertEquals(0, manager.getCooldownChunkCount()); + assertFalse(manager.isChunkOnCooldown(key)); + manager.markChunkExcluded(null, "bad"); + assertFalse(manager.isChunkExcluded(null)); + manager.markChunkExcluded(key, null); + manager.markChunkExcluded(new ChunkKey("other", 0, 0), "stone"); + assertEquals(2, manager.getExcludedChunkCount()); + assertTrue(manager.isChunkExcluded(key)); + manager.clearExcludedChunks("world"); + assertFalse(manager.isChunkExcluded(key)); + assertEquals(1, manager.getExcludedChunkCount()); + manager.clearExcludedChunks(" "); + assertEquals(0, manager.getExcludedChunkCount()); + manager.clearExcludedChunks(null); + manager.shutdown(); + assertTrue(Files.exists(temp.resolve("Data/spots.json"))); + assertTrue(Files.exists(temp.resolve("Data/chunk-cache.json"))); + SpotManager restored = new SpotManager(); + restored.loadAllFromDisk(); + assertEquals(2, restored.getActiveSpotCount()); + assertEquals(spot, restored.getAtBlock(spot.getBlockCorner())); + assertEquals(world, restored.getConfiguredWorld("world")); + assertNull(restored.getConfiguredWorld("missing")); + GatheringSpot replacement = GatheringSpot.create("world", 2, 64, 2, "herb", Material.STONE); + manager.register(replacement); + manager.remove(spot); + assertTrue(manager.hasActiveSpotInChunk(key)); + manager.remove(replacement); + assertFalse(manager.hasActiveSpotInChunk(key)); + manager.remove(null); + assertNull(manager.get(replacement.getId())); + } + + @Test + void stalePublicIndexEntriesAreIgnoredAndMovedIdentitiesAppearOnce() { + World world = server.addSimpleWorld("world"); + SpotManager manager = new SpotManager(); + UUID id = UUID.randomUUID(); + GatheringSpot first = new GatheringSpot(id, "world", 1, 64, 1, "herb", Material.STONE, 1, null); + manager.register(first); + GatheringSpot moved = + new GatheringSpot(id, "world", 17, 64, 1, "herb", Material.STONE, 1, null); + manager.register(moved); + assertEquals(List.of(moved), manager.getSpotsNear(new Location(world, 8, 64, 1), 32)); + manager.getAllSpots().clear(); + assertNull(manager.getAtBlock(moved.getBlockCorner())); + assertTrue(manager.getSpotsNear(new Location(world, 8, 64, 1), 32).isEmpty()); + } + + @Test + void spawnSelectionHonorsCountsAndTypeCooldowns() { + SpotManager manager = new SpotManager(); + assertNull(manager.pickSpawnableType()); + assertFalse(manager.isTypeOnCooldown(null)); + var herb = type("herb", 1, 5); + SpotTypeLoader.get().put("herb", herb); + assertFalse(manager.isTypeOnCooldown(herb)); + assertSame(herb, manager.pickSpawnableType()); + manager.markTypeSpawned("herb"); + assertTrue(manager.isTypeOnCooldown(herb)); + assertNull(manager.pickSpawnableType()); + var immediate = type("herb", 1, 0); + SpotTypeLoader.get().put("herb", immediate); + assertFalse(manager.isTypeOnCooldown(immediate)); + assertSame(immediate, manager.pickSpawnableType()); + manager.register(GatheringSpot.create("world", 1, 2, 3, "herb", Material.STONE)); + assertNull(manager.pickSpawnableType()); + } + + @Test + void scheduledServicesFlushDirtyStateAndCanTickCleanly() { + Cache.passiveDiscoveryEnabled = false; + Cache.passiveIntervalSeconds = 1; + Cache.particleIntervalTicks = 10; + Cache.spawnIntervalMinutes = 1; + Cache.worldBounds = Map.of(); + SpotManager manager = new SpotManager(); + manager.start(); + server.getScheduler().performTicks(1200); + manager.register(GatheringSpot.create("world", 1, 64, 1, "herb", Material.STONE)); + manager.markChunkExcluded(new ChunkKey("world", 1, 1), "blocked"); + server.getScheduler().performTicks(1200); + assertTrue(Files.exists(temp.resolve("Data/spots.json"))); + assertTrue(Files.exists(temp.resolve("Data/chunk-cache.json"))); + manager.shutdown(); + } + + @Test + void adminStateTogglesAndCleansUpOnQuit() { + Player player = server.addPlayer(); + AdminModeService admin = AdminModeService.get(); + assertFalse(admin.isEnabled(null)); + assertFalse(admin.enable(null)); + assertFalse(admin.disable(null)); + assertFalse(admin.toggle(null)); + assertFalse(admin.isEnabled(player)); + assertTrue(admin.enable(player)); + assertFalse(admin.enable(player)); + assertTrue(admin.isEnabled(player)); + assertTrue(admin.disable(player)); + assertFalse(admin.disable(player)); + assertTrue(admin.toggle(player)); + assertFalse(admin.toggle(player)); + admin.enable(player); + admin.onQuit(new PlayerQuitEvent(player, "bye")); + assertFalse(admin.isEnabled(player)); + } + + @Test + void commandsValidatePermissionsAndDispatchAllSubcommands() { + CommandManager commands = new CommandManager(); + CommandSender sender = mock(CommandSender.class); + SpotManager manager = mock(SpotManager.class); + when(plugin.getSpotManager()).thenReturn(manager); + commands.onCommand(sender, null, "gathering", new String[] {"reload"}); + verify(sender).sendMessage("§cNo permission."); + verify(plugin, never()).reloadAll(); + assertTrue(commands.onTabComplete(sender, null, "gathering", new String[] {""}).isEmpty()); + when(sender.hasPermission("gathering.admin")).thenReturn(true); + for (String[] args : + new String[][] { + {}, + {"unknown"}, + {"reload"}, + {"status"}, + {"clearcache"}, + {"clearcache", "world"}, + {"forcespawn", "herb"}, + {"adminmode"} + }) assertTrue(commands.onCommand(sender, null, "gathering", args)); + verify(plugin).reloadAll(); + verify(manager).clearExcludedChunks(null); + verify(manager).clearExcludedChunks("world"); + verify(sender, times(2)).sendMessage("§cPlayers only."); + Player player = server.addPlayer(); + player.setOp(true); + commands.onCommand(player, null, "gathering", new String[] {"forcespawn"}); + commands.onCommand(player, null, "gathering", new String[] {"forcespawn", "bad"}); + var type = type("herb", 2, 0); + SpotTypeLoader.get().put("herb", type); + ChunkKey key = new ChunkKey(player.getWorld().getName(), 0, 0); + when(manager.chunkKey(anyString(), anyInt(), anyInt())).thenReturn(key); + when(manager.hasActiveSpotInChunk(key)).thenReturn(true); + commands.onCommand(player, null, "gathering", new String[] {"forcespawn", "herb"}); + when(manager.hasActiveSpotInChunk(key)).thenReturn(false); + try (MockedStatic helper = mockStatic(ChunkLoadHelper.class); + MockedStatic probe = mockStatic(ChunkProbe.class)) { + probe + .when(() -> ChunkProbe.probe(eq(manager), any(), eq(type))) + .thenReturn(ChunkProbe.ProbeOutcome.SPAWNED); + helper + .when(() -> ChunkLoadHelper.withLoadedChunk(any(), anyInt(), anyInt(), any())) + .thenAnswer( + a -> + ((java.util.function.Function) a.getArgument(3)) + .apply(player.getWorld().getChunkAt(0, 0))); + commands.onCommand( + player, null, "gathering", new String[] {"forcespawn", "herb", "1", "bad"}); + helper + .when(() -> ChunkLoadHelper.withLoadedChunk(any(), anyInt(), anyInt(), any())) + .thenReturn(ChunkProbe.ProbeOutcome.EXCLUDED); + commands.onCommand(player, null, "gathering", new String[] {"forcespawn", "herb"}); + } + commands.onCommand(player, null, "gathering", new String[] {"adminmode"}); + assertTrue(AdminModeService.get().isEnabled(player)); + commands.onCommand(player, null, "gathering", new String[] {"adminmode"}); + assertFalse(AdminModeService.get().isEnabled(player)); + for (String mode : List.of("on", "enable", "true")) { + commands.onCommand(player, null, "gathering", new String[] {"adminmode", mode}); + assertTrue(AdminModeService.get().isEnabled(player)); + } + for (String mode : List.of("off", "disable", "false")) { + commands.onCommand(player, null, "gathering", new String[] {"adminmode", mode}); + assertFalse(AdminModeService.get().isEnabled(player)); + } + commands.onCommand(player, null, "gathering", new String[] {"adminmode", "bad"}); + assertEquals( + List.of("reload"), commands.onTabComplete(sender, null, "gathering", new String[] {"RE"})); + assertEquals( + List.of("on", "off"), + commands.onTabComplete(sender, null, "gathering", new String[] {"adminmode", "o"})); + assertEquals( + List.of("herb"), + commands.onTabComplete(sender, null, "gathering", new String[] {"forcespawn", "h"})); + assertFalse( + commands + .onTabComplete(sender, null, "gathering", new String[] {"clearcache", ""}) + .isEmpty()); + assertTrue( + commands.onTabComplete(sender, null, "gathering", new String[] {"other", ""}).isEmpty()); + assertTrue(commands.onTabComplete(sender, null, "gathering", new String[] {}).isEmpty()); + assertTrue( + commands + .onTabComplete(sender, null, "gathering", new String[] {"forcespawn", "herb", ""}) + .isEmpty()); + } +} diff --git a/src/test/java/net/tfminecraft/gathering/SpawnTest.java b/src/test/java/net/tfminecraft/gathering/SpawnTest.java new file mode 100644 index 0000000..60f0380 --- /dev/null +++ b/src/test/java/net/tfminecraft/gathering/SpawnTest.java @@ -0,0 +1,178 @@ +package net.tfminecraft.gathering; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import java.util.function.Function; +import net.tfminecraft.gathering.cache.Cache; +import net.tfminecraft.gathering.loader.*; +import net.tfminecraft.gathering.manager.*; +import net.tfminecraft.gathering.spawn.*; +import org.bukkit.*; +import org.bukkit.block.*; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +class SpawnTest extends GatheringTestSupport { + @Test + void chunkLoadingRestoresForceStateEvenWhenCallbackFails() { + World world = mock(World.class); + Chunk chunk = mock(Chunk.class); + when(world.getChunkAt(2, 3)).thenReturn(chunk); + when(chunk.getX()).thenReturn(2); + when(chunk.getZ()).thenReturn(3); + assertNull(ChunkLoadHelper.withLoadedChunk(null, 2, 3, c -> "x")); + assertNull(ChunkLoadHelper.withLoadedChunk(world, 2, 3, null)); + assertEquals( + "ok", + ChunkLoadHelper.withLoadedChunk( + world, + 2, + 3, + c -> { + assertSame(chunk, c); + return "ok"; + })); + verify(chunk).setForceLoaded(true); + verify(chunk).setForceLoaded(false); + verify(world).unloadChunk(2, 3, false); + when(world.isChunkLoaded(2, 3)).thenReturn(true); + when(chunk.isForceLoaded()).thenReturn(true); + assertThrows( + IllegalStateException.class, + () -> + ChunkLoadHelper.withLoadedChunk( + world, + 2, + 3, + c -> { + throw new IllegalStateException("test"); + })); + verify(world, times(1)).unloadChunk(2, 3, false); + verify(chunk, times(3)).setForceLoaded(true); + when(world.isChunkLoaded(2, 3)).thenReturn(false); + when(world.unloadChunk(2, 3, false)).thenThrow(new IllegalStateException("busy")); + assertEquals("ok", ChunkLoadHelper.withLoadedChunk(world, 2, 3, c -> "ok")); + } + + @Test + void plannerRequiresSolidSurfaceHeadroomBiomeAndAltitude() { + World world = mock(World.class); + Chunk chunk = mock(Chunk.class); + when(chunk.getWorld()).thenReturn(world); + when(chunk.getX()).thenReturn(-1); + when(chunk.getZ()).thenReturn(2); + when(world.getMaxHeight()).thenReturn(5); + when(world.getMinHeight()).thenReturn(0); + Map materials = new HashMap<>(); + materials.put(1, Material.STONE); + when(world.getBlockAt(anyInt(), anyInt(), anyInt())) + .thenAnswer( + a -> { + Block b = mock(Block.class); + when(b.getType()).thenReturn(materials.getOrDefault(a.getArgument(1), Material.AIR)); + when(b.getBiome()).thenReturn(Biome.PLAINS); + return b; + }); + var type = ManagerTest.type("herb", 1, 0); + Cache.probeColumnAttempts = 2; + assertNull(SpawnPlanner.tryFindLocation(null, type)); + assertNull(SpawnPlanner.tryFindLocation(chunk, null)); + var result = SpawnPlanner.tryFindLocation(chunk, type); + assertNotNull(result); + assertEquals(1, result.location.getBlockY()); + assertEquals(Material.STONE, result.surfaceMaterial); + assertTrue(result.location.getBlockX() >= -16 && result.location.getBlockX() < 0); + assertTrue(result.location.getBlockZ() >= 32 && result.location.getBlockZ() < 48); + materials.put(2, Material.WATER); + assertNull(SpawnPlanner.tryFindLocation(chunk, type)); + materials.remove(2); + materials.put(3, Material.WATER); + assertNull(SpawnPlanner.tryFindLocation(chunk, type)); + materials.remove(3); + assertNull( + SpawnPlanner.tryFindLocation( + chunk, + new SpotTypeLoader.SpotTypeDefinition( + "x", Set.of(), null, null, Material.DIRT, null, 1, 0, null, List.of()))); + assertNull( + SpawnPlanner.tryFindLocation( + chunk, + new SpotTypeLoader.SpotTypeDefinition( + "x", Set.of(), 2, null, null, null, 1, 0, null, List.of()))); + assertNull( + SpawnPlanner.tryFindLocation( + chunk, + new SpotTypeLoader.SpotTypeDefinition( + "x", Set.of(Biome.DESERT), null, null, null, null, 1, 0, null, List.of()))); + materials.clear(); + assertNull(SpawnPlanner.tryFindLocation(chunk, type)); + Cache.probeColumnAttempts = 0; + assertNull(SpawnPlanner.tryFindLocation(chunk, type)); + } + + @Test + void probesRegisterValidLocationsOrRememberExcludedChunks() { + World world = server.addSimpleWorld("world"); + Chunk chunk = world.getChunkAt(1, 2); + SpotManager manager = new SpotManager(); + var type = ManagerTest.type("herb", 1, 2); + ChunkKey key = new ChunkKey("world", 1, 2); + try (MockedStatic planner = mockStatic(SpawnPlanner.class)) { + assertEquals(ChunkProbe.ProbeOutcome.EXCLUDED, ChunkProbe.probe(manager, chunk, type)); + assertTrue(manager.isChunkExcluded(key)); + planner + .when(() -> SpawnPlanner.tryFindLocation(chunk, type)) + .thenReturn(new SpawnPlanner.ProbeResult(null, Material.STONE)); + assertEquals(ChunkProbe.ProbeOutcome.EXCLUDED, ChunkProbe.probe(manager, chunk, type)); + Location location = new Location(world, 17, 64, 33); + planner + .when(() -> SpawnPlanner.tryFindLocation(chunk, type)) + .thenReturn(new SpawnPlanner.ProbeResult(location, Material.STONE)); + assertEquals(ChunkProbe.ProbeOutcome.SPAWNED, ChunkProbe.probe(manager, chunk, type)); + assertEquals(location, manager.getAtBlock(location).getBlockCorner()); + assertTrue(manager.isTypeOnCooldown(type)); + } + } + + @Test + void schedulerSkipsUnavailableTypesWorldsAndOccupiedChunks() { + SpotManager manager = mock(SpotManager.class); + World world = server.addSimpleWorld("world"); + when(manager.getConfiguredWorld("world")).thenReturn(world); + var type = ManagerTest.type("herb", 1, 0); + when(manager.pickSpawnableType()).thenReturn(type); + ChunkKey key = new ChunkKey("world", 2, 3); + when(manager.chunkKey(eq("world"), anyInt(), anyInt())).thenReturn(key); + Cache.spawnIntervalMinutes = 1; + Cache.spawnAttemptsPerTick = 1; + Cache.worldBounds = new LinkedHashMap<>(); + Cache.worldBounds.put("missing", new ConfigLoader.WorldBounds("missing", 0, 0, 0, 0)); + Cache.worldBounds.put("world", new ConfigLoader.WorldBounds("world", 2, 2, 4, 3)); + try (MockedStatic helper = mockStatic(ChunkLoadHelper.class); + MockedStatic probe = mockStatic(ChunkProbe.class)) { + helper + .when(() -> ChunkLoadHelper.withLoadedChunk(eq(world), anyInt(), anyInt(), any())) + .thenAnswer( + a -> + ((Function) a.getArgument(3)) + .apply(world.getChunkAt((int) a.getArgument(1), (int) a.getArgument(2)))); + new SpawnScheduler(manager).start(); + server.getScheduler().performTicks(1200); + probe.verify(() -> ChunkProbe.probe(eq(manager), any(), eq(type))); + when(manager.hasActiveSpotInChunk(key)).thenReturn(true); + server.getScheduler().performTicks(1200); + when(manager.hasActiveSpotInChunk(key)).thenReturn(false); + when(manager.isChunkOnCooldown(key)).thenReturn(true); + server.getScheduler().performTicks(1200); + when(manager.isChunkOnCooldown(key)).thenReturn(false); + when(manager.isChunkExcluded(key)).thenReturn(true); + server.getScheduler().performTicks(1200); + when(manager.isChunkExcluded(key)).thenReturn(false); + when(manager.pickSpawnableType()).thenReturn(null); + server.getScheduler().performTicks(1200); + probe.verifyNoMoreInteractions(); + } + } +}