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 @@ -70,3 +70,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 @@ -23,6 +23,14 @@ Originally created by [Justinas Launikonis](https://github.com/JustinasLa).

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 @@ -18,6 +18,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>
<!-- Paper API -->
<dependency>
<groupId>io.papermc.paper</groupId>
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
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ private void handleMove(CommandSender sender, String[] args) {
try {
x = Double.parseDouble(args[1]);
z = Double.parseDouble(args[2]);
if (!Double.isFinite(x) || !Double.isFinite(z)) {
throw new NumberFormatException("Coordinates must be finite");
}
} catch (NumberFormatException e) {
sender.sendMessage(messages().get("admin.move-invalid-coords"));
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,8 @@ public void migrate() {
// ====================================
private void migrateMessages() {
File messagesFile = new File(plugin.getDataFolder(), MESSAGES_FILE);
if (!messagesFile.exists()) {
boolean createdMessagesFile = !messagesFile.exists();
if (createdMessagesFile) {
plugin.saveResource(MESSAGES_FILE, false);
}

Expand All @@ -135,7 +136,11 @@ private void migrateMessages() {
if (oldSection.isConfigurationSection(key)) {
continue;
}
messages.set(key, oldSection.get(key));
// A newly installed file already contains player.* defaults.
// Legacy custom wording must replace those shipped values.
String destination = createdMessagesFile && List.of(LEGACY_ROOT_MESSAGES).contains(key)
? "player." + key : key;
messages.set(destination, oldSection.get(key));
moved.add(key);
}

Expand Down Expand Up @@ -262,12 +267,12 @@ private YamlConfiguration loadPackagedConfig() {
}

private YamlConfiguration loadPackagedYaml(String resource) {
try (InputStream stream = plugin.getResource(resource)) {
if (stream == null) {
plugin.getLogger().warning("No " + resource + " packaged in the jar - skipping its migration.");
return null;
}

InputStream stream = plugin.getResource(resource);
if (stream == null) {
plugin.getLogger().warning("No " + resource + " packaged in the jar - skipping its migration.");
return null;
}
try (stream) {
try (Reader reader = new InputStreamReader(stream, StandardCharsets.UTF_8)) {
return YamlConfiguration.loadConfiguration(reader);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ private void loadSoundSettings() {
// onto the same event once lowercased, since the enum name is the event ID
// with dots turned into underscores.
private String normalizeSoundName(String raw) {
if (raw == null || raw.trim().isEmpty()) {
if (raw.trim().isEmpty()) {
return DEFAULT_SOUND;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,11 @@ public static String coordinate(double value) {
}

private YamlConfiguration loadPackaged() {
try (InputStream stream = plugin.getResource(ConfigMigrator.MESSAGES_FILE)) {
if (stream == null) {
return null;
}
InputStream stream = plugin.getResource(ConfigMigrator.MESSAGES_FILE);
if (stream == null) {
return null;
}
try (stream) {
try (Reader reader = new InputStreamReader(stream, StandardCharsets.UTF_8)) {
return YamlConfiguration.loadConfiguration(reader);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ private void attemptRandomPlacement(int attempt, CompletableFuture<Location> res
loadChunkFor(x, z).whenComplete((chunk, error) -> {
// Paper completes chunk futures on the main thread, so everything
// below is safe to run against the world directly
if (!plugin.isEnabled()) {
if (pendingMove != result || !plugin.isEnabled()) {
result.complete(null);
return;
}
Expand Down Expand Up @@ -141,11 +141,12 @@ private void attemptRandomPlacement(int attempt, CompletableFuture<Location> res
// filter check is logged so the admin knows the spot is normally excluded
// ====================================
public CompletableFuture<Location> moveSourceToLocation(double x, double z) {
sourceLocation = null;
CompletableFuture<Location> result = new CompletableFuture<>();
pendingMove = result;

loadChunkFor(x, z).whenComplete((chunk, error) -> {
if (!plugin.isEnabled()) {
if (pendingMove != result || !plugin.isEnabled()) {
result.complete(null);
return;
}
Expand Down Expand Up @@ -207,6 +208,18 @@ public void tryCollectSource(Player player, double distance, EquipmentSlot geige
return;
}

// Callers may hold a distance from before another player's collection.
// Validate against the current source, including its world, before consuming it.
Location playerLocation = player.getLocation();
if (!sourceLocation.getWorld().equals(playerLocation.getWorld())) {
return;
}
double deltaX = playerLocation.getX() - sourceLocation.getX();
double deltaZ = playerLocation.getZ() - sourceLocation.getZ();
if (Math.hypot(deltaX, deltaZ) > config.getCollectionDistance()) {
return;
}

// Player is out of collections for this window - the source stays put
// so somebody else can still claim it
if (!dropLimits.canCollect(player)) {
Expand Down Expand Up @@ -255,17 +268,26 @@ private void notifyPlayerOfCollection(Player player) {
}

private void replaceGeigerWithDeadVersion(Player player, EquipmentSlot geigerSlot) {
// Remove active Geiger Counter from whichever hand held it
if (geigerSlot == EquipmentSlot.OFF_HAND) {
player.getInventory().setItemInOffHand(null);
// Consume one counter, preserving any others stacked in the same hand.
boolean offHand = geigerSlot == EquipmentSlot.OFF_HAND;
ItemStack active = offHand ? player.getInventory().getItemInOffHand()
: player.getInventory().getItemInMainHand();
ItemStack remaining = null;
if (active.getAmount() > 1) {
remaining = active.clone();
remaining.setAmount(active.getAmount() - 1);
}
if (offHand) {
player.getInventory().setItemInOffHand(remaining);
} else {
player.getInventory().setItemInMainHand(null);
player.getInventory().setItemInMainHand(remaining);
}

// Give dead Geiger Counter
try {
ItemStack deadGeiger = api.getCreator().getItemFromPath(DEAD_GEIGER_PATH).clone();
player.getInventory().addItem(deadGeiger);
deadGeiger.setAmount(1);
giveItemOrDropLeftovers(player, deadGeiger);
player.playSound(player.getLocation(), Sound.ENTITY_ITEM_BREAK, 1f, 1f);
player.sendMessage(config.getMessages().get("player.dead-geiger"));
} catch (Exception e) {
Expand All @@ -284,7 +306,7 @@ private void giveReward(Player player) {

// Select a tier based on weights
TierReward selectedTier = selectRandomTier(tiers);
if (selectedTier == null || selectedTier.isEmpty()) {
if (selectedTier.isEmpty()) {
return;
}

Expand Down Expand Up @@ -312,15 +334,16 @@ private TierReward selectRandomTier(List<TierReward> tiers) {

// Find which tier this value falls into
double cumulativeWeight = 0.0;
for (TierReward tier : tiers) {
for (int index = 0; index < tiers.size() - 1; index++) {
TierReward tier = tiers.get(index);
cumulativeWeight += tier.getWeight();
if (randomValue <= cumulativeWeight) {
return tier;
}
}
// Fallback to last tier (shouldnt happen but why not)
return tiers.get(tiers.size() - 1);

// Every remaining draw selects the final tier, including rounding fallback.
return tiers.getLast();
}

// ====================================
Expand All @@ -329,13 +352,19 @@ private TierReward selectRandomTier(List<TierReward> tiers) {
private void giveRewardItem(Player player, ItemReward reward, String tierName) {
try {
ItemStack rewardItem = createRewardItem(reward);
player.getInventory().addItem(rewardItem);
giveItemOrDropLeftovers(player, rewardItem);
plugin.getLogger().info(player.getName() + " received " + tierName + " reward: " + reward.getOutputItem());
} catch (Exception e) {
plugin.getLogger().warning("Failed to give reward to " + player.getName() + ": " + e.getMessage());
}
}

private void giveItemOrDropLeftovers(Player player, ItemStack item) {
for (ItemStack leftover : player.getInventory().addItem(item).values()) {
player.getWorld().dropItemNaturally(player.getLocation(), leftover);
}
}

// ====================================
// Create an ItemStack for the reward
// ====================================
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package net.tfminecraft.geigercounters.hooks;

import com.sk89q.worldedit.bukkit.BukkitAdapter;
import com.sk89q.worldedit.bukkit.BukkitWorld;
import com.sk89q.worldedit.math.BlockVector3;
import com.sk89q.worldguard.WorldGuard;
import com.sk89q.worldguard.protection.managers.RegionManager;
Expand Down Expand Up @@ -30,7 +30,7 @@ public boolean isInAnyRegion(Location location, Set<String> regionIds) {
RegionManager regions = WorldGuard.getInstance()
.getPlatform()
.getRegionContainer()
.get(BukkitAdapter.adapt(location.getWorld()));
.get(new BukkitWorld(location.getWorld()));

if (regions == null) {
return false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import java.util.Deque;
import java.util.HashMap;
import java.util.List;
import java.util.Iterator;
import java.util.Map;
import java.util.UUID;

Expand Down Expand Up @@ -91,19 +92,22 @@ public int getRemaining(UUID playerId) {

// ====================================
// Millis until the next collection slot frees up, or 0 if one is free.
// That is the oldest stamp in the window plus the window length.
// If the configured limit was lowered, enough stamps must expire to
// bring the retained count below the new limit.
// ====================================
public long getMillisUntilNextDrop(UUID playerId) {
if (getRemaining(playerId) > 0) {
if (!config.isLimitEnabled() || getRemaining(playerId) > 0) {
return 0L;
}

// Enabled limits are positive; zero remaining guarantees a nonempty history.
Deque<Long> stamps = collections.get(playerId);
if (stamps == null || stamps.isEmpty()) {
return 0L;
Iterator<Long> timestamps = stamps.iterator();
long nextSlot = timestamps.next();
for (int excess = stamps.size() - config.getLimitDrops(); excess > 0; excess--) {
nextSlot = timestamps.next();
}

long readyAt = stamps.peekFirst() + config.getLimitWindowMillis();
long readyAt = nextSlot + config.getLimitWindowMillis();
return Math.max(0L, readyAt - System.currentTimeMillis());
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,12 +141,15 @@ private void startClickTask() {
// in either hand
// ====================================
private void checkAllPlayers() {
Location source = sourceHandler.getSourceLocation();
if (source == null) {
return;
}

for (Player player : Bukkit.getOnlinePlayers()) {
// A preceding player can collect and relocate the source immediately.
Location source = sourceHandler.getSourceLocation();
if (source == null) {
return;
}
if (!player.getLocation().getWorld().equals(source.getWorld())) {
continue;
}
EquipmentSlot geigerSlot = findGeigerSlot(player);

if (geigerSlot != null) {
Expand Down
Loading