Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
67 changes: 67 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,24 @@
</properties>

<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.20.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockbukkit.mockbukkit</groupId>
<artifactId>mockbukkit-v1.21</artifactId>
<version>4.95.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.papermc.paper</groupId>
<artifactId>paper-api</artifactId>
Expand Down Expand Up @@ -89,6 +107,55 @@
</resource>
</resources>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.4</version>
</plugin>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.15</version>
<executions>
<execution>
<goals><goal>prepare-agent</goal></goals>
</execution>
<execution>
<id>coverage-report</id>
<phase>verify</phase>
<goals><goal>report</goal></goals>
</execution>
<execution>
<id>coverage-check</id>
<phase>verify</phase>
<goals><goal>check</goal></goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>INSTRUCTION</counter>
<value>COVEREDRATIO</value>
<minimum>1.0</minimum>
</limit>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>1.0</minimum>
</limit>
<limit>
<counter>BRANCH</counter>
<value>COVEREDRATIO</value>
<minimum>1.0</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
Expand Down
4 changes: 1 addition & 3 deletions src/main/java/net/tfminecraft/gathering/Gathering.java
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,7 @@ public void onEnable() {

@Override
public void onDisable() {
if (spotManager != null) {
spotManager.shutdown();
}
spotManager.shutdown();
getLogger().info("Gathering disabled.");
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public void load(File configFile) {

for (String key : config.getKeys(false)) {
List<String> drops = config.getStringList(key + ".drops");
if (drops == null || drops.isEmpty()) {
if (drops.isEmpty()) {
drops = config.getStringList(key);
}
DropCategory category = new DropCategory(key, drops);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,8 @@ private SpotTypeDefinition parse(String id, ConfigurationSection sec) {
String professionId = sec.getString("profession-id");

List<CategoryWeight> categories = new ArrayList<>();
List<?> rawCategories = sec.getMapList("categories");
for (Object raw : rawCategories) {
if (!(raw instanceof java.util.Map<?, ?> map)) continue;
List<java.util.Map<?, ?>> 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");
Expand Down Expand Up @@ -185,10 +184,10 @@ private int[] parseDropRange(String raw) {
}

private Set<Biome> parseBiomes(List<String> names) {
if (names == null || names.isEmpty()) return Collections.emptySet();
if (names.isEmpty()) return Collections.emptySet();
Set<Biome> 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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ public void onInteract(PlayerInteractEvent event) {
event.setCancelled(true);

List<ItemStack> rewards = rollRewards(spot);
if (rewards == null || rewards.isEmpty()) {
if (rewards == null) {
event.getPlayer().sendMessage("§cNothing to gather here.");
return;
}
Expand Down Expand Up @@ -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);
}
Expand Down
8 changes: 6 additions & 2 deletions src/main/java/net/tfminecraft/gathering/utils/GatherFx.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
91 changes: 91 additions & 0 deletions src/test/java/net/tfminecraft/gathering/DatabaseTest.java
Original file line number Diff line number Diff line change
@@ -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)));
}
}
Loading