From 339c3a87cebac3dd4da47e4b5685d6f381a76d0c Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:02:23 +0000 Subject: [PATCH] Add a global hunt mode and restore the 2500-block detection range source.mode picks who hunts which source. player keeps a cache per character near them; global buries one cache anywhere in the area that every hunter reads, and the first to open it takes the loot before a new one is buried. /geiger mode [player|global] shows or switches the mode, saved to config.yml. Each mode keeps its own sources in sources.yml, so switching back resumes the old hunts. In global mode /geiger locate and /geiger move [x z] act on the shared cache. detection.max-detection-distance defaults to 2500 again. Config v8 adds source.mode and raises the range from 1000 to 2500 where it was still at that value; the v6 cap at reroll-distance is gone. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 +- .../commands/GeigerCommand.java | 168 +++++++++++++++--- .../geigercounters/config/ConfigMigrator.java | 26 +-- .../config/GeigerConfiguration.java | 40 ++++- .../handlers/SourceHandler.java | 43 ++++- .../managers/GeigerManager.java | 45 +++-- src/main/resources/config.yml | 23 ++- src/main/resources/messages.yml | 17 +- src/main/resources/plugin.yml | 2 +- .../commands/GeigerCommandTest.java | 77 +++++++- .../config/ConfigMigratorTest.java | 28 ++- .../config/GeigerConfigurationTest.java | 14 +- .../handlers/SourceHandlerTest.java | 17 ++ .../managers/GeigerManagerTest.java | 39 ++++ 14 files changed, 447 insertions(+), 95 deletions(-) diff --git a/README.md b/README.md index b413c10..671cdc9 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,12 @@ > Follow the signal, find the source, and uncover loot in TF-Minecraft. -Geiger Counters turns exploration into a treasure hunt. Each character has their own cache buried a few hundred blocks away. A signal bar, particle rings and clicking lead them to it, and a close reading shows where to dig. Opening the cache gives loot and drains the counter, and the next counter starts a new hunt near wherever the character is. +Geiger Counters turns exploration into a treasure hunt. Each character has their own cache buried a few hundred blocks away, or in global mode everyone hunts one shared cache. A signal bar, particle rings and clicking lead them to it, and a close reading shows where to dig. Opening the cache gives loot and drains the counter, and the next counter starts a new hunt near wherever the character is. ## Features - **A source per character** — each character's cache is buried near them and kept until they dig it up. +- **Global mode** — `/geiger mode global` switches to one cache anywhere in the area, shared by every hunter; the first to open it takes the loot. - **Buried caches** — the cache is a chest only its hunter sees, and only while holding a live detector. - **Signal bar** — a boss bar fills as the player closes in and peaks when they face the source; close by it shows where to dig. - **Visual tracking** — particle rings change in color and number as the player approaches the source. diff --git a/src/main/java/net/tfminecraft/geigercounters/commands/GeigerCommand.java b/src/main/java/net/tfminecraft/geigercounters/commands/GeigerCommand.java index 874147f..6a61140 100644 --- a/src/main/java/net/tfminecraft/geigercounters/commands/GeigerCommand.java +++ b/src/main/java/net/tfminecraft/geigercounters/commands/GeigerCommand.java @@ -12,7 +12,9 @@ import org.bukkit.event.Listener; import org.bukkit.event.player.PlayerJoinEvent; import net.tfminecraft.geigercounters.config.GeigerConfiguration; +import net.tfminecraft.geigercounters.config.GeigerConfiguration.HuntMode; import net.tfminecraft.geigercounters.config.Messages; +import net.tfminecraft.geigercounters.handlers.SourceHandler; import net.tfminecraft.geigercounters.managers.GeigerManager; import net.tfminecraft.geigercounters.utils.Hunters; import net.tfminecraft.geigercounters.utils.Utils; @@ -29,11 +31,12 @@ // ==================================== // Admin command handler: -// /geiger [x z]|limits |resetlimits |droplist [name]|reload> +// /geiger [x z]|mode [player|global]|limits |resetlimits |droplist [name]|reload> +// In global mode locate and move act on the shared source: /geiger locate, /geiger move [x z] // ==================================== public class GeigerCommand implements CommandExecutor, TabCompleter, Listener { - private static final List SUBCOMMANDS = Arrays.asList("locate", "move", "limits", "resetlimits", "droplist", "reload"); + private static final List SUBCOMMANDS = Arrays.asList("locate", "move", "mode", "limits", "resetlimits", "droplist", "reload"); private final GeigerManager manager; private final Map knownNames = new LinkedHashMap<>(); @@ -72,6 +75,9 @@ public boolean onCommand(CommandSender sender, Command command, String label, St case "move": handleMove(sender, args); return true; + case "mode": + handleMode(sender, args); + return true; // Singular spellings are aliases - both read naturally case "limit": case "limits": @@ -96,6 +102,10 @@ public boolean onCommand(CommandSender sender, Command command, String label, St // /geiger locate -> the sender's own source // /geiger locate -> that player's current character's source private void handleLocate(CommandSender sender, String[] args) { + if (isGlobal()) { + locateShared(sender); + return; + } if (args.length > 2 || (args.length == 1 && !(sender instanceof Player))) { sender.sendMessage(messages().get("admin.locate-usage")); return; @@ -129,24 +139,18 @@ private void handleLocate(CommandSender sender, String[] args) { // /geiger move -> clear it; a new one is placed near them on their next hold // /geiger move -> buried under these coordinates private void handleMove(CommandSender sender, String[] args) { + if (isGlobal()) { + moveShared(sender, args); + return; + } if (args.length != 2 && args.length != 4) { sender.sendMessage(messages().get("admin.move-usage")); return; } - double x = 0; - double z = 0; - if (args.length == 4) { - try { - x = Double.parseDouble(args[2]); - z = Double.parseDouble(args[3]); - 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; - } + double[] coords = args.length == 4 ? parseCoordinates(sender, args[2], args[3]) : null; + if (args.length == 4 && coords == null) { + return; } Player target = onlineTarget(sender, args[1]); @@ -162,10 +166,107 @@ private void handleMove(CommandSender sender, String[] args) { } String name = target.getName(); - manager.getSourceHandler().placeAt(hunter, x, z) + manager.getSourceHandler().placeAt(hunter, coords[0], coords[1]) .thenAccept(location -> sendNewLocation(sender, name, location)); } + // The X and Z typed, or null after telling the sender they are not numbers + private double[] parseCoordinates(CommandSender sender, String rawX, String rawZ) { + try { + double x = Double.parseDouble(rawX); + double z = Double.parseDouble(rawZ); + if (!Double.isFinite(x) || !Double.isFinite(z)) { + throw new NumberFormatException("Coordinates must be finite"); + } + return new double[] {x, z}; + } catch (NumberFormatException e) { + sender.sendMessage(messages().get("admin.move-invalid-coords")); + return null; + } + } + + private boolean isGlobal() { + return manager.getConfiguration().getHuntMode() == HuntMode.GLOBAL; + } + + // /geiger locate in global mode -> the shared source, whoever asks + private void locateShared(CommandSender sender) { + SourceHandler sources = manager.getSourceHandler(); + if (sources.isPlacing(SourceHandler.SHARED)) { + sender.sendMessage(messages().get("admin.shared-source-relocating")); + return; + } + + Location source = sources.getSource(SourceHandler.SHARED); + if (source == null) { + sender.sendMessage(messages().get("admin.shared-no-source")); + return; + } + + sender.sendMessage(messages().get("admin.shared-source-located", + "%x%", source.getBlockX(), + "%y%", source.getBlockY(), + "%z%", source.getBlockZ(), + "%world%", source.getWorld().getName())); + } + + // /geiger move -> clear the shared source; a new one is buried on the next hold + // /geiger move -> bury it under these coordinates + private void moveShared(CommandSender sender, String[] args) { + if (args.length != 1 && args.length != 3) { + sender.sendMessage(messages().get("admin.shared-move-usage")); + return; + } + + if (args.length == 1) { + manager.getSourceHandler().clear(SourceHandler.SHARED); + sender.sendMessage(messages().get("admin.shared-move-cleared")); + return; + } + + double[] coords = parseCoordinates(sender, args[1], args[2]); + if (coords == null) { + return; + } + manager.getSourceHandler().placeAt(SourceHandler.SHARED, coords[0], coords[1]) + .thenAccept(location -> sendSharedLocation(sender, location)); + } + + private void sendSharedLocation(CommandSender sender, Location source) { + if (source == null) { + sender.sendMessage(messages().get("admin.move-failed")); + return; + } + + sender.sendMessage(messages().get("admin.shared-move-success", + "%x%", source.getBlockX(), + "%y%", source.getBlockY(), + "%z%", source.getBlockZ())); + } + + // /geiger mode -> which mode is in use + // /geiger mode -> switch to it, saved to config.yml. Each mode keeps + // its own sources, so switching back resumes them. + private void handleMode(CommandSender sender, String[] args) { + if (args.length == 1) { + sender.sendMessage(messages().get("admin.mode-current", + "%mode%", manager.getConfiguration().getHuntMode().key())); + return; + } + + HuntMode mode = args.length == 2 ? HuntMode.parse(args[1]) : null; + if (mode == null) { + sender.sendMessage(messages().get("admin.mode-usage")); + return; + } + + manager.getPlugin().getConfig().set("source.mode", mode.key()); + manager.getPlugin().saveConfig(); + manager.reload(); + + sender.sendMessage(messages().get("admin.mode-changed", "%mode%", mode.key())); + } + // Sources belong to characters, so the target has to be online to know which one private Player onlineTarget(CommandSender sender, String name) { Player target = Bukkit.getPlayerExact(name); @@ -303,11 +404,9 @@ private void sendUsage(CommandSender sender) { // ==================================== // Suggest coordinates worth typing: where the sender is standing, and the - // corners of the configured search area. Axis follows the argument - - // X for the first, Z for the second. + // corners of the configured search area, on the X axis or the Z. // ==================================== - private List completeCoordinate(CommandSender sender, String[] args) { - boolean isX = args.length == 3; + private List completeCoordinate(CommandSender sender, String[] args, boolean isX) { String typed = args[args.length - 1]; List suggestions = new ArrayList<>(); @@ -354,9 +453,26 @@ public List onTabComplete(CommandSender sender, Command command, String return matches; } - // Coordinates for /geiger move - if (args[0].equalsIgnoreCase("move") && (args.length == 3 || args.length == 4)) { - return completeCoordinate(sender, args); + // Coordinates for /geiger move , or /geiger move in global mode + if (args[0].equalsIgnoreCase("move")) { + int first = isGlobal() ? 2 : 3; + if (args.length == first || args.length == first + 1) { + return completeCoordinate(sender, args, args.length == first); + } + if (isGlobal()) { + return new ArrayList<>(); + } + } + + // /geiger mode + if (args.length == 2 && args[0].equalsIgnoreCase("mode")) { + List matches = new ArrayList<>(); + for (HuntMode mode : HuntMode.values()) { + if (mode.key().startsWith(args[1].toLowerCase(Locale.ROOT))) { + matches.add(mode.key()); + } + } + return matches; } // Drop list names for /geiger droplist @@ -370,8 +486,10 @@ public List onTabComplete(CommandSender sender, Command command, String return matches; } - // locate, move and both limit subcommands (either spelling) take a player name - if (args.length == 2 && takesPlayer(args[0])) { + // locate, move and both limit subcommands (either spelling) take a player name; + // in global mode locate takes none + boolean sharedLocate = isGlobal() && args[0].equalsIgnoreCase("locate"); + if (args.length == 2 && takesPlayer(args[0]) && !sharedLocate) { Map names = new LinkedHashMap<>(); if (args[0].equalsIgnoreCase("limit") || args[0].equalsIgnoreCase("limits") || args[0].equalsIgnoreCase("resetlimit") || args[0].equalsIgnoreCase("resetlimits")) { diff --git a/src/main/java/net/tfminecraft/geigercounters/config/ConfigMigrator.java b/src/main/java/net/tfminecraft/geigercounters/config/ConfigMigrator.java index 2e6fdc4..ae43d20 100644 --- a/src/main/java/net/tfminecraft/geigercounters/config/ConfigMigrator.java +++ b/src/main/java/net/tfminecraft/geigercounters/config/ConfigMigrator.java @@ -38,7 +38,8 @@ public class ConfigMigrator { // v5: drops.tiers -> drops.lists..tiers, selected by drops.active-list // v6: a source per character - source distances, bar section, detection range capped // v7: buried caches - burial depths, pinpoint range, ring moved out, collection-distance gone - public static final int CURRENT_VERSION = 7; + // v8: source.mode (player or global), detection range back to 2500 + public static final int CURRENT_VERSION = 8; public static final String MESSAGES_FILE = "messages.yml"; @@ -85,12 +86,12 @@ public void migrate() { boolean movedDropList = migrateDropLists(live); List addedKeys = copyMissingKeys(packaged, live); - if (liveVersion < 6) { - capDetectionRange(live); - } if (liveVersion < 7) { moveRingOut(live); } + if (liveVersion < 8) { + raiseDefault(live, "detection.max-detection-distance", 1000.0, 2500.0); + } boolean versionChanged = liveVersion != CURRENT_VERSION; if (addedKeys.isEmpty() && !versionChanged && !movedDropList) { @@ -225,23 +226,6 @@ private boolean migrateDropLists(FileConfiguration live) { return true; } - // ==================================== - // A detection range sized for one source anywhere on the map makes the - // clicks and rings read "close" for most of a nearby hunt. Sources now sit - // within reroll-distance, so the range is capped at that once. - // ==================================== - private void capDetectionRange(FileConfiguration live) { - double reroll = live.getDouble("source.reroll-distance"); - double detection = live.getDouble("detection.max-detection-distance"); - if (detection <= reroll) { - return; - } - - live.set("detection.max-detection-distance", reroll); - plugin.getLogger().info("Lowered detection.max-detection-distance from " + detection + " to " + reroll - + " to match source.reroll-distance."); - } - // ==================================== // v6 shipped a 150-500 block ring, which proved too close. Only values // still at those defaults move; a ring the admin chose is kept. Caches are diff --git a/src/main/java/net/tfminecraft/geigercounters/config/GeigerConfiguration.java b/src/main/java/net/tfminecraft/geigercounters/config/GeigerConfiguration.java index d8f092d..4b4287f 100644 --- a/src/main/java/net/tfminecraft/geigercounters/config/GeigerConfiguration.java +++ b/src/main/java/net/tfminecraft/geigercounters/config/GeigerConfiguration.java @@ -16,6 +16,7 @@ import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.concurrent.TimeUnit; @@ -45,6 +46,9 @@ public class GeigerConfiguration { private World world; private double minX, maxX, minZ, maxZ; + // Whether each character hunts their own source or everyone shares one + private HuntMode huntMode; + // Where each character's source is placed, relative to the character private double minSourceDistance; private double maxSourceDistance; @@ -108,6 +112,7 @@ public GeigerConfiguration(JavaPlugin plugin) { public void load() { loadWorld(); loadSearchArea(); + loadHuntMode(); loadSourceDistances(); loadSpawnFilters(); loadDetectionSettings(); @@ -143,6 +148,15 @@ private void loadSearchArea() { } } + private void loadHuntMode() { + String raw = plugin.getConfig().getString("source.mode", HuntMode.PLAYER.key()); + huntMode = HuntMode.parse(raw); + if (huntMode == null) { + plugin.getLogger().warning("source.mode '" + raw + "' is not player or global - using player."); + huntMode = HuntMode.PLAYER; + } + } + // ==================================== // How far from the character a source is placed, and when it is placed again // ==================================== @@ -200,7 +214,7 @@ private void loadSpawnFilters() { } private void loadDetectionSettings() { - maxDetectionDistance = plugin.getConfig().getDouble("detection.max-detection-distance", 1000.0); + maxDetectionDistance = plugin.getConfig().getDouble("detection.max-detection-distance", 2500.0); pinpointRange = plugin.getConfig().getDouble("detection.pinpoint-range", 12.0); closeRangeThreshold = plugin.getConfig().getDouble("detection.close-range-threshold", 200.0); threeRingsDistance = plugin.getConfig().getDouble("detection.ring-thresholds.three-rings", 100.0); @@ -433,6 +447,7 @@ private ItemReward parseItemReward(String itemString) { public double getMaxX() { return maxX; } public double getMinZ() { return minZ; } public double getMaxZ() { return maxZ; } + public HuntMode getHuntMode() { return huntMode; } public double getMinSourceDistance() { return minSourceDistance; } public double getMaxSourceDistance() { return maxSourceDistance; } public double getRerollDistance() { return rerollDistance; } @@ -469,6 +484,29 @@ private ItemReward parseItemReward(String itemString) { // =========== Rules for valid source spawn locations ================ + // ==================================== + // player: each character hunts their own source near them. + // global: one source anywhere in the area, shared by everyone. + // ==================================== + public enum HuntMode { + PLAYER, GLOBAL; + + // As written in config.yml and typed in /geiger mode + public String key() { + return name().toLowerCase(Locale.ROOT); + } + + // The mode named by , or null if none + public static HuntMode parse(String raw) { + for (HuntMode mode : values()) { + if (mode.key().equalsIgnoreCase(raw.trim())) { + return mode; + } + } + return null; + } + } + public static class SpawnFilterConfig { private int maxAttempts = 50; private boolean rejectLiquid = true; diff --git a/src/main/java/net/tfminecraft/geigercounters/handlers/SourceHandler.java b/src/main/java/net/tfminecraft/geigercounters/handlers/SourceHandler.java index dc0a052..83fd8a1 100644 --- a/src/main/java/net/tfminecraft/geigercounters/handlers/SourceHandler.java +++ b/src/main/java/net/tfminecraft/geigercounters/handlers/SourceHandler.java @@ -35,16 +35,21 @@ import java.util.Set; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.function.Supplier; // ==================================== // Each hunter's radioactive source: placement, persistence and opening. -// A hunter is the character being played (the account without RPCharacters). +// A hunter is the character being played (the account without RPCharacters), +// or SHARED for the one source everyone hunts in global mode. // // A source is a cache buried in natural ground. Its location is the centre // of the cache block, which the hunter digs down to and opens by hand. // ==================================== public class SourceHandler { + // The hunter that owns the global source + public static final UUID SHARED = new UUID(0L, 0L); + private static final String DEAD_GEIGER_PATH = "m.TOOLS.DEAD_GEIGER_COUNTER"; // A player clicking at a cache they may not open yet would otherwise get @@ -140,6 +145,18 @@ public boolean isPlacing(UUID hunter) { // The returned future completes on the main thread with the chosen location. // ==================================== public CompletableFuture placeNear(UUID hunter, Location origin) { + return place(hunter, () -> randomSpotNear(origin.getX(), origin.getZ())); + } + + // ==================================== + // Place the hunter's source at a random spot anywhere in the area, as + // placeNear does but with no ring around anyone. Used for the shared source. + // ==================================== + public CompletableFuture placeAnywhere(UUID hunter) { + return place(hunter, this::randomSpotInArea); + } + + private CompletableFuture place(UUID hunter, Supplier spots) { // Collapse overlapping requests onto the search already running CompletableFuture running = pending.get(hunter); if (running != null) { @@ -150,7 +167,7 @@ public CompletableFuture placeNear(UUID hunter, Location origin) { return CompletableFuture.completedFuture(null); } - double[] first = randomSpotNear(origin.getX(), origin.getZ()); + double[] first = spots.get(); if (first == null) { return CompletableFuture.completedFuture(null); } @@ -161,7 +178,7 @@ public CompletableFuture placeNear(UUID hunter, Location origin) { CompletableFuture result = new CompletableFuture<>(); pending.put(hunter, result); - attemptPlacement(hunter, origin.getX(), origin.getZ(), first, 1, null, result); + attemptPlacement(hunter, spots, first, 1, null, result); return result; } @@ -185,8 +202,14 @@ private double[] randomSpotNear(double originX, double originZ) { return null; } + private double[] randomSpotInArea() { + return new double[] { + config.getMinX() + random.nextDouble() * (config.getMaxX() - config.getMinX()), + config.getMinZ() + random.nextDouble() * (config.getMaxZ() - config.getMinZ())}; + } + // is the last candidate so far that was sealed in natural ground - private void attemptPlacement(UUID hunter, double originX, double originZ, double[] spot, int attempt, + private void attemptPlacement(UUID hunter, Supplier spots, double[] spot, int attempt, Location sealed, CompletableFuture result) { double x = spot[0]; double z = spot[1]; @@ -213,16 +236,16 @@ private void attemptPlacement(UUID hunter, double originX, double originZ, doubl return; } - double[] next = attempt >= maxAttempts ? null : randomSpotNear(originX, originZ); + double[] next = attempt >= maxAttempts ? null : spots.get(); if (next != null) { - attemptPlacement(hunter, originX, originZ, next, attempt + 1, fallback, result); + attemptPlacement(hunter, spots, next, attempt + 1, fallback, result); return; } if (fallback == null) { plugin.getLogger().warning(String.format( "No sealed source location found for %s after %d attempts (last rejection: %s). " - + "Trying again in a minute.", hunter, attempt, rejection.getDescription())); + + "Trying again in a minute.", owner(hunter), attempt, rejection.getDescription())); pending.remove(hunter); retryAt.put(hunter, System.currentTimeMillis() + RETRY_MILLIS); result.complete(null); @@ -338,10 +361,14 @@ private void applySource(UUID hunter, Location location, int attempts, Completab save(); plugin.getLogger().info(String.format("Source for %s buried at X = %d Y = %d Z = %d (%d attempt%s)", - hunter, location.getBlockX(), location.getBlockY(), location.getBlockZ(), attempts, attempts == 1 ? "" : "s")); + owner(hunter), location.getBlockX(), location.getBlockY(), location.getBlockZ(), attempts, attempts == 1 ? "" : "s")); result.complete(location); } + private static String owner(UUID hunter) { + return SHARED.equals(hunter) ? "everyone" : hunter.toString(); + } + // ==================================== // The hunter opened their cache: spend the detector and show the loot // ==================================== diff --git a/src/main/java/net/tfminecraft/geigercounters/managers/GeigerManager.java b/src/main/java/net/tfminecraft/geigercounters/managers/GeigerManager.java index 4ca2fc2..a897e84 100644 --- a/src/main/java/net/tfminecraft/geigercounters/managers/GeigerManager.java +++ b/src/main/java/net/tfminecraft/geigercounters/managers/GeigerManager.java @@ -9,6 +9,7 @@ import org.bukkit.inventory.EquipmentSlot; import org.bukkit.plugin.java.JavaPlugin; import net.tfminecraft.geigercounters.config.GeigerConfiguration; +import net.tfminecraft.geigercounters.config.GeigerConfiguration.HuntMode; import net.tfminecraft.geigercounters.handlers.BuriedChest; import net.tfminecraft.geigercounters.handlers.CacheMenu; import net.tfminecraft.geigercounters.handlers.GeigerClickPlayer; @@ -184,7 +185,7 @@ private void checkAllPlayers() { if (geigerSlot == null) { continue; } - Optional hunter = Hunters.of(player); + Optional hunter = huntOf(player); if (hunter.isPresent() && handlePlayerWithGeiger(player, hunter.get(), revealed)) { hunting.add(player.getUniqueId()); } @@ -206,22 +207,44 @@ private EquipmentSlot findGeigerSlot(Player player) { } // ==================================== - // Returns whether the player has a source to read. One is placed near - // them when they have none, or have strayed beyond reroll-distance. + // Whose source the player reads: their character's, or in global mode the + // shared one. A player with no active character reads none. + // ==================================== + private Optional huntOf(Player player) { + Optional hunter = Hunters.of(player); + if (configuration.getHuntMode() == HuntMode.GLOBAL) { + return hunter.map(character -> SourceHandler.SHARED); + } + return hunter; + } + + // The shared source goes anywhere in the area, a character's near them + private void placeSource(UUID hunter, Player player) { + if (SourceHandler.SHARED.equals(hunter)) { + sourceHandler.placeAnywhere(hunter); + } else { + sourceHandler.placeNear(hunter, player.getLocation()); + } + } + + // ==================================== + // Returns whether the player has a source to read. One is placed when + // there is none, or a character's has been left beyond reroll-distance. // Players near enough to see their cache are added to . // ==================================== private boolean handlePlayerWithGeiger(Player player, UUID hunter, Set revealed) { Location source = sourceHandler.getSource(hunter); if (source == null) { if (!sourceHandler.isPlacing(hunter)) { - sourceHandler.placeNear(hunter, player.getLocation()); + placeSource(hunter, player); } return false; } double distance = calculateHorizontalDistance(player.getLocation(), source); - if (!player.getWorld().equals(source.getWorld()) || distance > configuration.getRerollDistance()) { - sourceHandler.placeNear(hunter, player.getLocation()); + boolean leftBehind = !SourceHandler.SHARED.equals(hunter) && distance > configuration.getRerollDistance(); + if (!player.getWorld().equals(source.getWorld()) || leftBehind) { + placeSource(hunter, player); return false; } @@ -246,12 +269,12 @@ private boolean handlePlayerWithGeiger(Player player, UUID hunter, Set rev // ==================================== // The hunter clicked a chest, which only shows while they hold a detector. - // It must still be their current character's cache; one shown before a - // move or a character switch opens nothing. + // It must still be the cache they hunt; one shown before a move, a + // character switch or a mode switch opens nothing. // ==================================== @Override public void open(Player player, Location block) { - Optional hunter = Hunters.of(player); + Optional hunter = huntOf(player); EquipmentSlot geigerSlot = findGeigerSlot(player); if (hunter.isEmpty() || geigerSlot == null) { return; @@ -268,13 +291,13 @@ public void open(Player player, Location block) { // ==================================== @Override public void groundProtected(Player player) { - Optional hunter = Hunters.of(player); + Optional hunter = huntOf(player); if (hunter.isEmpty()) { return; } player.sendMessage(configuration.getMessages().get("player.cache-protected")); buriedChest.hide(player); - sourceHandler.placeNear(hunter.get(), player.getLocation()); + placeSource(hunter.get(), player); } // ===== DISTANCE CALCULATION ===== diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index bb21dac..cdcb411 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -1,16 +1,21 @@ # Config version - do not edit. # The plugin uses it to add newly introduced keys on upgrade; your own values # are kept, and the previous file is backed up as config-v.yml.bak first. -config-version: 7 +config-version: 8 # ==================================== # Source location settings -# Each character hunts their own source: a cache buried in natural ground -# that only they can see, and only while holding a live detector. It is -# placed near the character when they first hold one, and stays put until -# they dig it up and open it. +# A source is a cache buried in natural ground, seen only by hunters holding +# a live detector. It stays put until someone digs it up and opens it. # ==================================== source: + # Who hunts which source. Switch in-game with /geiger mode . + # player - each character has their own cache, placed near them when they + # first hold a detector (see min-distance and max-distance) + # global - one cache anywhere in the area below, shared by everyone; the + # first to open it takes the loot and a new one is buried + mode: player + # World name where sources spawn. Detectors only read in this world. world: world @@ -25,12 +30,12 @@ source: x: 1000.0 z: 1000.0 - # How far (blocks) from the character a new source is placed + # Player mode: how far (blocks) from the character a new source is placed min-distance: 300.0 max-distance: 600.0 - # A source further than this from its character is placed again near - # them, e.g. after they travel or teleport away + # Player mode: a source further than this from its character is placed + # again near them, e.g. after they travel or teleport away reroll-distance: 1000.0 # How many blocks of ground lie over the cache. It is always sealed in @@ -76,7 +81,7 @@ source: # ==================================== detection: # Maximum distance for Geiger Counter detection (blocks) - max-detection-distance: 1000.0 + max-detection-distance: 2500.0 # Within this many blocks of the cache (measured in 3D, depth included) # the detector switches to a close reading: the bar and clicks start over diff --git a/src/main/resources/messages.yml b/src/main/resources/messages.yml index 306b74d..478f0fa 100644 --- a/src/main/resources/messages.yml +++ b/src/main/resources/messages.yml @@ -43,7 +43,7 @@ player: # ==================================== admin: # /geiger with no arguments, or an unknown subcommand - usage: "&eUsage: /geiger [x z]|limits |resetlimits |droplist [name]|reload>" + usage: "&eUsage: /geiger [x z]|mode [player|global]|limits |resetlimits |droplist [name]|reload>" # /geiger locate from the console without a player locate-usage: "&cUsage: /geiger locate " @@ -71,6 +71,21 @@ admin: # %player% = whose source, %x% %y% %z% = the cache block it was buried at move-success: "&a%player%'s cache buried at X = %x% Y = %y% Z = %z%" + # /geiger locate and move in global mode, where everyone shares one source + # %x% %y% %z% = cache block, %world% = world name + shared-source-located: "&aThe shared cache is buried at X = %x% Y = %y% Z = %z% (world: %world%)" + shared-source-relocating: "&cThe shared source is being placed right now." + shared-no-source: "&7There is no shared source yet. One is buried when someone holds a live detector." + shared-move-usage: "&cUsage: /geiger move [x z] (global mode)" + shared-move-cleared: "&aShared source cleared. A new one is buried when someone next holds a detector." + shared-move-success: "&aShared cache buried at X = %x% Y = %y% Z = %z%" + + # /geiger mode [player|global] + mode-usage: "&cUsage: /geiger mode [player|global]" + # %mode% = player or global + mode-current: "&aHunt mode: &f%mode% &7(player = a source per character, global = one shared source)" + mode-changed: "&aHunt mode switched to &f%mode%&a." + # /geiger limits limits-usage: "&cUsage: /geiger limits " limits-disabled: "&7Drop limits are disabled in the config." diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 3759912..e05da3c 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -8,7 +8,7 @@ softdepend: [WorldGuard] commands: geiger: description: Geiger Counter admin commands - usage: /geiger [x z]|limits |resetlimits |droplist [name]|reload> + usage: /geiger [x z]|mode [player|global]|limits |resetlimits |droplist [name]|reload> permission: geiger.admin permissions: geiger.admin: diff --git a/src/test/java/net/tfminecraft/geigercounters/commands/GeigerCommandTest.java b/src/test/java/net/tfminecraft/geigercounters/commands/GeigerCommandTest.java index 73c15c9..620ac4d 100644 --- a/src/test/java/net/tfminecraft/geigercounters/commands/GeigerCommandTest.java +++ b/src/test/java/net/tfminecraft/geigercounters/commands/GeigerCommandTest.java @@ -237,10 +237,85 @@ void droplistShowsStatusValidatesInputAndPersistsSelection() { verify(sender).sendMessage("admin.droplist-changed"); } + @Test + void modeShowsTheCurrentModeValidatesInputAndPersistsTheSwitch() { + when(config.getHuntMode()).thenReturn(GeigerConfiguration.HuntMode.PLAYER); + run("mode"); + verify(sender).sendMessage("admin.mode-current"); + run("mode", "everyone"); + run("mode", "global", "now"); + verify(sender, times(2)).sendMessage("admin.mode-usage"); + verify(manager, never()).reload(); + run("mode", "GLOBAL"); + assertEquals("global", plugin.getConfig().getString("source.mode")); + verify(plugin).saveConfig(); + verify(manager).reload(); + verify(sender).sendMessage("admin.mode-changed"); + } + + @Test + void globalLocateReportsTheSharedSourceToAnyone() { + when(config.getHuntMode()).thenReturn(GeigerConfiguration.HuntMode.GLOBAL); + when(source.isPlacing(SourceHandler.SHARED)).thenReturn(true, false); + run("locate"); + verify(sender).sendMessage("admin.shared-source-relocating"); + run("locate", "Hunter"); + verify(sender).sendMessage("admin.shared-no-source"); + when(source.getSource(SourceHandler.SHARED)).thenReturn(new Location(world, 5, 40, -7)); + run("locate"); + verify(sender).sendMessage("admin.shared-source-located"); + } + + @Test + void globalMoveClearsOrBuriesTheSharedSource() { + when(config.getHuntMode()).thenReturn(GeigerConfiguration.HuntMode.GLOBAL); + run("move", "Hunter"); + run("move", "Hunter", "1", "2"); + verify(sender, times(2)).sendMessage("admin.shared-move-usage"); + run("move", "bad", "2"); + verify(sender).sendMessage("admin.move-invalid-coords"); + verifyNoInteractions(source); + run("move"); + verify(source).clear(SourceHandler.SHARED); + verify(sender).sendMessage("admin.shared-move-cleared"); + CompletableFuture pending = new CompletableFuture<>(); + when(source.placeAt(SourceHandler.SHARED, 3, -4)).thenReturn(pending); + run("move", "3", "-4"); + verify(sender, never()).sendMessage("admin.shared-move-success"); + pending.complete(new Location(world, 3, 50, -4)); + verify(sender).sendMessage("admin.shared-move-success"); + when(source.placeAt(SourceHandler.SHARED, 1, 2)).thenReturn(CompletableFuture.completedFuture(null)); + run("move", "1", "2"); + verify(sender).sendMessage("admin.move-failed"); + } + + @Test + void globalCompletionSkipsPlayersForLocateAndMoveAndOffersModes() { + when(config.getHuntMode()).thenReturn(GeigerConfiguration.HuntMode.GLOBAL); + when(config.getMinX()).thenReturn(-5.0); + when(config.getMaxX()).thenReturn(5.0); + when(config.getMinZ()).thenReturn(-9.0); + when(config.getMaxZ()).thenReturn(9.0); + Player online = mock(Player.class); + when(online.getName()).thenReturn("Hunter"); + when(online.getUniqueId()).thenReturn(UUID.randomUUID()); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of(online)); + assertTrue(complete(sender, "locate", "").isEmpty()); + assertEquals(List.of("-5", "5"), complete(sender, "move", "")); + assertEquals(List.of("-9", "9"), complete(sender, "move", "1", "")); + assertTrue(complete(sender, "move", "1", "2", "").isEmpty()); + assertEquals(List.of("Hunter"), complete(sender, "limits", "h")); + } + assertEquals(List.of("player", "global"), complete(sender, "mode", "")); + assertEquals(List.of("global"), complete(sender, "mode", "G")); + assertTrue(complete(sender, "mode", "global", "").isEmpty()); + } + @Test void completionFiltersSubcommandsAndDroplistsAndIgnoresExtraArguments() { assertEquals(List.of("locate"), complete(sender, "LO")); - assertEquals(6, complete(sender, "").size()); + assertEquals(7, complete(sender, "").size()); assertEquals(List.of("Hunt"), complete(sender, "droplist", "h")); assertTrue(complete(sender, "unknown", "").isEmpty()); assertTrue(complete(sender, "move", "Hunter", "1", "2", "").isEmpty()); diff --git a/src/test/java/net/tfminecraft/geigercounters/config/ConfigMigratorTest.java b/src/test/java/net/tfminecraft/geigercounters/config/ConfigMigratorTest.java index 99bcac6..854089a 100644 --- a/src/test/java/net/tfminecraft/geigercounters/config/ConfigMigratorTest.java +++ b/src/test/java/net/tfminecraft/geigercounters/config/ConfigMigratorTest.java @@ -22,7 +22,7 @@ class ConfigMigratorTest { JavaPlugin plugin; YamlConfiguration live; ConfigMigrator migrator; - String packagedConfig = "config-version: 7\nsource:\n world: world\n"; + String packagedConfig = "config-version: 8\nsource:\n world: world\n"; String packagedMessages = "player:\n found-source: shipped\nadmin:\n help: help\n"; @BeforeEach @@ -75,7 +75,7 @@ void freshInstallCreatesMessagesWithoutAttemptingConfigMigration() { void upgradePreservesValuesUnknownKeysAndCommentsAndCreatesExactBackup() throws Exception { packagedConfig = "# Version\n" - + "config-version: 7 # version-inline\n" + + "config-version: 8 # version-inline\n" + "# World section\n" + "source:\n" + " # Server world\n" @@ -91,7 +91,7 @@ void upgradePreservesValuesUnknownKeysAndCommentsAndCreatesExactBackup() throws String previous = Files.readString(directory.resolve("config.yml")); migrator.migrate(); assertEquals(previous, Files.readString(directory.resolve("config-v1.yml.bak"))); - assertEquals(7, live.getInt("config-version")); + assertEquals(8, live.getInt("config-version")); assertEquals("custom", live.getString("source.world")); assertEquals(List.of("custom-item"), live.getStringList("list")); assertEquals(42, live.getInt("retired-setting")); @@ -117,16 +117,15 @@ void currentConfigIsIdempotentAndVersionOnlyUpgradeStillSaves() throws Exception } @Test - void upgradeToCharacterSourcesCapsDetectionRangeAtRerollDistanceOnce() throws Exception { - packagedConfig = "config-version: 7\nsource:\n reroll-distance: 1000.0\n"; - live.set("config-version", 5); - live.set("detection.max-detection-distance", 2500.0); + void upgradeToHuntModesRestoresTheDetectionRangeOnce() throws Exception { + live.set("config-version", 7); + live.set("detection.max-detection-distance", 1000.0); saveLive(); migrator.migrate(); - assertEquals(1000.0, live.getDouble("detection.max-detection-distance")); - live.set("detection.max-detection-distance", 2500.0); - migrator.migrate(); assertEquals(2500.0, live.getDouble("detection.max-detection-distance")); + live.set("detection.max-detection-distance", 1000.0); + migrator.migrate(); + assertEquals(1000.0, live.getDouble("detection.max-detection-distance")); } @Test @@ -140,7 +139,7 @@ void upgradeToBuriedCachesMovesTheDefaultRingOutAndDropsCollectionDistance() thr assertEquals(300.0, live.getDouble("source.min-distance")); assertEquals(600.0, live.getDouble("source.max-distance")); assertFalse(live.contains("detection.collection-distance")); - assertEquals(7, live.getInt("config-version")); + assertEquals(8, live.getInt("config-version")); } @Test @@ -155,8 +154,7 @@ void upgradeToBuriedCachesKeepsARingTheAdminChose() throws Exception { } @Test - void upgradeKeepsADetectionRangeAlreadyWithinRerollDistance() throws Exception { - packagedConfig = "config-version: 7\nsource:\n reroll-distance: 1000.0\n"; + void upgradeKeepsADetectionRangeTheAdminChose() throws Exception { live.set("config-version", 5); live.set("detection.max-detection-distance", 800.0); saveLive(); @@ -277,7 +275,7 @@ void backupFailureIsLoggedAndMigrationStillCompletes() throws Exception { Files.writeString( directory.resolve("config-v1.yml.bak/occupied"), "prevent replacing directory"); assertDoesNotThrow(migrator::migrate); - assertEquals(7, live.getInt("config-version")); + assertEquals(8, live.getInt("config-version")); verify(plugin).saveConfig(); } @@ -306,6 +304,6 @@ void emptyPackagedSectionsDoNotCreateUnusedLiveSections() throws Exception { saveLive(); migrator.migrate(); assertFalse(live.contains("empty")); - assertEquals(7, live.getInt("config-version")); + assertEquals(8, live.getInt("config-version")); } } diff --git a/src/test/java/net/tfminecraft/geigercounters/config/GeigerConfigurationTest.java b/src/test/java/net/tfminecraft/geigercounters/config/GeigerConfigurationTest.java index 0a4335d..a171f39 100644 --- a/src/test/java/net/tfminecraft/geigercounters/config/GeigerConfigurationTest.java +++ b/src/test/java/net/tfminecraft/geigercounters/config/GeigerConfigurationTest.java @@ -51,7 +51,8 @@ void load() { void defaultsProvideUsableDetectionSoundLimitsColorsAndSpawnFilters() { load(); assertSame(world, configuration.getWorld()); - assertEquals(1000, configuration.getMaxDetectionDistance()); + assertEquals(GeigerConfiguration.HuntMode.PLAYER, configuration.getHuntMode()); + assertEquals(2500, configuration.getMaxDetectionDistance()); assertEquals(12, configuration.getPinpointRange()); assertEquals(300, configuration.getMinSourceDistance()); assertEquals(600, configuration.getMaxSourceDistance()); @@ -122,6 +123,17 @@ void invertedBoundsAndSoundRatesAreNormalizedAndInvalidSettingsFallBack() { assertEquals(TimeUnit.HOURS.toMillis(12), configuration.getLimitWindowMillis()); } + @Test + void huntModeIsReadInAnyCaseAndFallsBackToPlayer() { + yaml.set("source.mode", " Global "); + load(); + assertEquals(GeigerConfiguration.HuntMode.GLOBAL, configuration.getHuntMode()); + assertEquals("global", configuration.getHuntMode().key()); + yaml.set("source.mode", "everyone"); + load(); + assertEquals(GeigerConfiguration.HuntMode.PLAYER, configuration.getHuntMode()); + } + @Test void sourceDistancesAndBarSettingsAreKeptConsistent() { yaml.set("source.min-distance", -5); diff --git a/src/test/java/net/tfminecraft/geigercounters/handlers/SourceHandlerTest.java b/src/test/java/net/tfminecraft/geigercounters/handlers/SourceHandlerTest.java index 2f8e603..c6e50c0 100644 --- a/src/test/java/net/tfminecraft/geigercounters/handlers/SourceHandlerTest.java +++ b/src/test/java/net/tfminecraft/geigercounters/handlers/SourceHandlerTest.java @@ -200,6 +200,23 @@ void huntersHaveSeparateSources() { assertEquals(30.5, handler.getSource(other).getX()); } + @Test + void theSharedSourceLandsAnywhereInTheAreaAndRetriesThere() { + when(config.getMinX()).thenReturn(100.0); + when(config.getMaxX()).thenReturn(300.0); + when(config.getMinZ()).thenReturn(-300.0); + when(config.getMaxZ()).thenReturn(-100.0); + when(filter.check(any())).thenReturn(SpawnLocationFilter.Rejection.LIQUID, (SpawnLocationFilter.Rejection) null); + for (int run = 0; run < 100; run++) { + Location loc = handler.placeAnywhere(SourceHandler.SHARED).join(); + assertTrue(loc.getX() >= 99 && loc.getX() <= 301, "x " + loc.getX()); + assertTrue(loc.getZ() >= -301 && loc.getZ() <= -99, "z " + loc.getZ()); + assertSame(loc, handler.getSource(SourceHandler.SHARED)); + } + verify(filter, times(101)).check(any()); + assertNull(handler.getSource(hunter)); + } + @Test void rejectedCandidatesRetryThenAccept() { when(filter.check(any())).thenReturn(SpawnLocationFilter.Rejection.LIQUID, null); diff --git a/src/test/java/net/tfminecraft/geigercounters/managers/GeigerManagerTest.java b/src/test/java/net/tfminecraft/geigercounters/managers/GeigerManagerTest.java index cf147d6..d6ecc0a 100644 --- a/src/test/java/net/tfminecraft/geigercounters/managers/GeigerManagerTest.java +++ b/src/test/java/net/tfminecraft/geigercounters/managers/GeigerManagerTest.java @@ -242,6 +242,45 @@ void protectedGroundMovesTheCacheAndSaysSo() { verify(source).placeNear(hunter, p.getLocation()); } + @Test + void globalModeReadsTheSharedSourceWithoutRerollingIt() throws Exception { + when(config.getHuntMode()).thenReturn(GeigerConfiguration.HuntMode.GLOBAL); + Player p = player(world, true, false); + check(Optional.empty(), p); + verifyNoInteractions(source); + check(p); + verify(source).placeAnywhere(SourceHandler.SHARED); + // Far beyond reroll-distance the shared source still reads + Location far = new Location(world, 2003, 64, 4); + when(source.getSource(SourceHandler.SHARED)).thenReturn(far); + check(p); + verify(clicks).updateRate(p, 2000); + verify(bar).update(p, far, 2000); + when(source.getSource(SourceHandler.SHARED)).thenReturn(new Location(mock(World.class), 3, 64, 4)); + check(p); + verify(source, times(2)).placeAnywhere(SourceHandler.SHARED); + verify(source, never()).placeNear(any(), any()); + verify(source, never()).getSource(hunter); + } + + @Test + void globalModeOpensAndMovesTheSharedCache() { + when(config.getHuntMode()).thenReturn(GeigerConfiguration.HuntMode.GLOBAL); + cache.setWorld(world); + block.setWorld(world); + when(source.getSource(SourceHandler.SHARED)).thenReturn(cache); + Player p = player(world, true, false); + open(Optional.of(hunter), p, block); + verify(source).open(p, SourceHandler.SHARED, EquipmentSlot.HAND); + when(config.getMessages()).thenReturn(mock(Messages.class)); + try (var h = mockStatic(Hunters.class)) { + h.when(() -> Hunters.of(p)).thenReturn(Optional.of(hunter)); + manager.groundProtected(p); + } + verify(source).placeAnywhere(SourceHandler.SHARED); + verify(source, never()).placeNear(any(), any()); + } + @Test void shutdownHandlesUninitializedAndInitializedComponents() throws Exception { set(manager, "clickPlayer", null);