From 32654aa561791bb19703835dd8aa6981f16ae5ba Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:16:02 +0000 Subject: [PATCH 1/6] Bury each character's source as a chest only they can see The source is now a cache buried 3-6 blocks deep in natural ground, 300-600 blocks from the character. While holding a live detector within 32 blocks, the hunter's client is sent a chest at the cache block; the server keeps the real ground, so nobody else sees or opens it. Within detection.pinpoint-range (12 blocks, in 3D) the bar retitles and the bar and clicks show closeness to the cache alone, to find where to dig. Clicking the chest spends the detector and opens a chest window with the reward; anything left in it goes to the player on close. Caches are only placed where the block and all six neighbours are natural ground, so they never show in caves or sit under builds. A dig next to the cache that another plugin refuses moves the cache elsewhere. Config v7 raises the old 150/500 default ring to 300/600 and drops detection.collection-distance. Surface sources saved before this are dropped on load, so their hunters get a buried cache on the next hold. Co-Authored-By: Claude Opus 5.5 --- README.md | 13 +- .../commands/GeigerCommand.java | 12 +- .../geigercounters/config/ConfigMigrator.java | 28 +- .../config/GeigerConfiguration.java | 996 +++++++-------- .../geigercounters/config/Messages.java | 4 - .../geigercounters/handlers/BuriedChest.java | 175 +++ .../geigercounters/handlers/CacheMenu.java | 98 ++ .../handlers/GeigerClickPlayer.java | 18 +- .../geigercounters/handlers/SignalBar.java | 68 +- .../handlers/SourceHandler.java | 1105 +++++++++-------- .../managers/GeigerManager.java | 505 ++++---- .../validators/SpawnLocationFilter.java | 49 +- src/main/resources/config.yml | 772 ++++++------ src/main/resources/messages.yml | 19 +- .../LifecycleUtilitiesTest.java | 1 - .../config/ConfigMigratorTest.java | 39 +- .../config/GeigerConfigurationTest.java | 20 +- .../handlers/BuriedChestTest.java | 198 +++ .../handlers/CacheMenuTest.java | 131 ++ .../handlers/GeigerEffectsTest.java | 29 + .../handlers/SignalBarTest.java | 25 + .../handlers/SourceHandlerTest.java | 176 ++- .../managers/GeigerManagerTest.java | 86 +- .../validators/SpawnLocationFilterTest.java | 31 + 24 files changed, 2881 insertions(+), 1717 deletions(-) create mode 100644 src/main/java/net/tfminecraft/geigercounters/handlers/BuriedChest.java create mode 100644 src/main/java/net/tfminecraft/geigercounters/handlers/CacheMenu.java create mode 100644 src/test/java/net/tfminecraft/geigercounters/handlers/BuriedChestTest.java create mode 100644 src/test/java/net/tfminecraft/geigercounters/handlers/CacheMenuTest.java diff --git a/README.md b/README.md index c69790a..b413c10 100644 --- a/README.md +++ b/README.md @@ -2,16 +2,17 @@ > 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 hidden radioactive source a few hundred blocks away. A signal bar, particle rings and clicking lead them to it. Reaching the source awards 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. 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 source is placed near them and kept until they find it. -- **Signal bar** — a boss bar fills as the player closes in and peaks when they face the source. +- **A source per character** — each character's cache is buried near them and kept until they dig it up. +- **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. - **Audible feedback** — irregular counter clicks become more frequent at closer distances. - **Tiered discoveries** — weighted reward tiers give each successful hunt a chance of different loot. -- **Consumable counters** — a successful collection replaces the active counter with a dead one. +- **Consumable counters** — opening the cache replaces the active counter with a dead one. - **Collection limits** — each account may collect a set number of sources per time window. Originally created by [Justinas Launikonis](https://github.com/JustinasLa). @@ -29,8 +30,8 @@ Tests use JUnit 5, Mockito, and MockBukkit. Surefire test results are in `target/surefire-reports/`; JaCoCo HTML and XML reports are in `target/site/jacoco/`. The Build workflow uploads both. Verification requires 100% line, branch, and instruction coverage of production code, with no coverage exclusions. -Client sounds and particles, live world generation, and installed item and region -plugins require separate in-game checks. +Client sounds and particles, the client-side chest, live world generation, and +installed item and region plugins require separate in-game checks. ## License diff --git a/src/main/java/net/tfminecraft/geigercounters/commands/GeigerCommand.java b/src/main/java/net/tfminecraft/geigercounters/commands/GeigerCommand.java index fad7d9b..a859463 100644 --- a/src/main/java/net/tfminecraft/geigercounters/commands/GeigerCommand.java +++ b/src/main/java/net/tfminecraft/geigercounters/commands/GeigerCommand.java @@ -101,13 +101,14 @@ private void handleLocate(CommandSender sender, String[] args) { sender.sendMessage(messages().get("admin.source-located", "%player%", target.getName(), - "%x%", Messages.coordinate(source.getX()), - "%z%", Messages.coordinate(source.getZ()), + "%x%", source.getBlockX(), + "%y%", source.getBlockY(), + "%z%", source.getBlockZ(), "%world%", source.getWorld().getName())); } // /geiger move -> clear it; a new one is placed near them on their next hold - // /geiger move -> specific coordinates + // /geiger move -> buried under these coordinates private void handleMove(CommandSender sender, String[] args) { if (args.length != 2 && args.length != 4) { sender.sendMessage(messages().get("admin.move-usage")); @@ -173,8 +174,9 @@ private void sendNewLocation(CommandSender sender, String name, Location source) sender.sendMessage(messages().get("admin.move-success", "%player%", name, - "%x%", Messages.coordinate(source.getX()), - "%z%", Messages.coordinate(source.getZ()))); + "%x%", source.getBlockX(), + "%y%", source.getBlockY(), + "%z%", source.getBlockZ())); } // /geiger limits -> how many collections that player has left diff --git a/src/main/java/net/tfminecraft/geigercounters/config/ConfigMigrator.java b/src/main/java/net/tfminecraft/geigercounters/config/ConfigMigrator.java index 3864164..2e6fdc4 100644 --- a/src/main/java/net/tfminecraft/geigercounters/config/ConfigMigrator.java +++ b/src/main/java/net/tfminecraft/geigercounters/config/ConfigMigrator.java @@ -37,7 +37,8 @@ public class ConfigMigrator { // v4: messages.yml split into player/admin sections // v5: drops.tiers -> drops.lists..tiers, selected by drops.active-list // v6: a source per character - source distances, bar section, detection range capped - public static final int CURRENT_VERSION = 6; + // v7: buried caches - burial depths, pinpoint range, ring moved out, collection-distance gone + public static final int CURRENT_VERSION = 7; public static final String MESSAGES_FILE = "messages.yml"; @@ -87,6 +88,9 @@ public void migrate() { if (liveVersion < 6) { capDetectionRange(live); } + if (liveVersion < 7) { + moveRingOut(live); + } boolean versionChanged = liveVersion != CURRENT_VERSION; if (addedKeys.isEmpty() && !versionChanged && !movedDropList) { @@ -238,6 +242,28 @@ private void capDetectionRange(FileConfiguration live) { + " 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 + // opened by hand now, so collection-distance no longer means anything. + // ==================================== + private void moveRingOut(FileConfiguration live) { + raiseDefault(live, "source.min-distance", 150.0, 300.0); + raiseDefault(live, "source.max-distance", 500.0, 600.0); + if (live.contains("detection.collection-distance", true)) { + live.set("detection.collection-distance", null); + plugin.getLogger().info("Removed detection.collection-distance - caches are dug up and opened now."); + } + } + + private void raiseDefault(FileConfiguration live, String path, double oldDefault, double newDefault) { + if (live.getDouble(path) != oldDefault) { + return; + } + live.set(path, newDefault); + plugin.getLogger().info("Raised " + path + " from " + oldDefault + " to " + newDefault + "."); + } + // ==================================== // Copy every leaf key the packaged config has and the live one lacks. // diff --git a/src/main/java/net/tfminecraft/geigercounters/config/GeigerConfiguration.java b/src/main/java/net/tfminecraft/geigercounters/config/GeigerConfiguration.java index c43458c..d8f092d 100644 --- a/src/main/java/net/tfminecraft/geigercounters/config/GeigerConfiguration.java +++ b/src/main/java/net/tfminecraft/geigercounters/config/GeigerConfiguration.java @@ -1,489 +1,507 @@ -package net.tfminecraft.geigercounters.config; - -import org.bukkit.Bukkit; -import org.bukkit.Material; -import org.bukkit.World; -import org.bukkit.configuration.ConfigurationSection; -import org.bukkit.configuration.file.YamlConfiguration; -import org.bukkit.plugin.java.JavaPlugin; -import net.tfminecraft.geigercounters.models.ItemReward; -import net.tfminecraft.geigercounters.models.TierReward; -import net.tfminecraft.geigercounters.utils.Utils; - -import java.io.File; -import java.util.ArrayList; -import java.util.EnumSet; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.TimeUnit; - -// ==================================== -// Handles loading and storing all Geiger Counter configuration -// ==================================== -public class GeigerConfiguration { - - private static final String DEFAULT_SOUND = "block.note_block.hat"; - - // Bukkit enum spellings admins are likely to paste in, mapped to the event - // ID. Only the click-like sounds - anything else has to use the event ID. - private static final Map ENUM_TO_EVENT = new HashMap<>(); - static { - ENUM_TO_EVENT.put("block_note_block_hat", "block.note_block.hat"); - ENUM_TO_EVENT.put("block_dispenser_fail", "block.dispenser.fail"); - ENUM_TO_EVENT.put("block_comparator_click", "block.comparator.click"); - ENUM_TO_EVENT.put("block_lever_click", "block.lever.click"); - ENUM_TO_EVENT.put("ui_button_click", "ui.button.click"); - ENUM_TO_EVENT.put("entity_item_pickup", "entity.item.pickup"); - } - - private final JavaPlugin plugin; - - // World settings - private World world; - private double minX, maxX, minZ, maxZ; - - // Where each character's source is placed, relative to the character - private double minSourceDistance; - private double maxSourceDistance; - private double rerollDistance; - - // Spawn location filters - private SpawnFilterConfig spawnFilters = new SpawnFilterConfig(); - - // Detection settings - private double collectionDistance; - private double maxDetectionDistance; - private double closeRangeThreshold; - private double threeRingsDistance; - private double twoRingsDistance; - - // Signal bar - private boolean barEnabled; - private double barRange; - private double barFacingWeight; - - // Click sound - private boolean soundEnabled; - private String soundName; - private double soundVolume; - private double soundPitch; - private double soundPitchVariance; - private double soundMinRate; - private double soundMaxRate; - private double soundCurve; - - // Drop limits - private boolean limitEnabled; - private int limitDrops; - private long limitWindowMillis; - - // Messages (messages.yml, not config.yml) - private Messages messages; - - // Colors - private ColorConfig closeRangeStartColor; - private ColorConfig closeRangeEndColor; - private ColorConfig farRangeStartColor; - private ColorConfig farRangeEndColor; - - // Rewards - private String activeDropList; - private final List dropListNames = new ArrayList<>(); - private final List tierRewards = new ArrayList<>(); - - public GeigerConfiguration(JavaPlugin plugin) { - this.plugin = plugin; - } - - // ==================================== - // Load all configuration from config.yml - // ==================================== - public void load() { - loadWorld(); - loadSearchArea(); - loadSourceDistances(); - loadSpawnFilters(); - loadDetectionSettings(); - loadBarSettings(); - loadDropLimits(); - loadSoundSettings(); - loadColorSettings(); - loadMessages(); - loadRewards(); - } - - private void loadWorld() { - String worldName = plugin.getConfig().getString("source.world"); - world = Bukkit.getWorld(worldName); - } - - private void loadSearchArea() { - minX = plugin.getConfig().getDouble("source.top-left.x"); - maxX = plugin.getConfig().getDouble("source.bottom-right.x"); - minZ = plugin.getConfig().getDouble("source.top-left.z"); - maxZ = plugin.getConfig().getDouble("source.bottom-right.z"); - - // Make sure that is always less than - if (minX > maxX) { - double temp = minX; - minX = maxX; - maxX = temp; - } - if (minZ > maxZ) { - double temp = minZ; - minZ = maxZ; - maxZ = temp; - } - } - - // ==================================== - // How far from the character a source is placed, and when it is placed again - // ==================================== - private void loadSourceDistances() { - minSourceDistance = Math.max(0.0, plugin.getConfig().getDouble("source.min-distance", 150.0)); - maxSourceDistance = plugin.getConfig().getDouble("source.max-distance", 500.0); - rerollDistance = plugin.getConfig().getDouble("source.reroll-distance", 1000.0); - - if (maxSourceDistance < minSourceDistance) { - plugin.getLogger().warning("source.max-distance is below source.min-distance - using min-distance for both."); - maxSourceDistance = minSourceDistance; - } - - // Below max-distance a fresh source could be placed out of range and re-rolled forever - if (rerollDistance < maxSourceDistance) { - plugin.getLogger().warning("source.reroll-distance is below source.max-distance - using max-distance."); - rerollDistance = maxSourceDistance; - } - } - - // ==================================== - // Load the rules that decide where the source is allowed to spawn - // ==================================== - private void loadSpawnFilters() { - String base = "source.spawn-filters."; - SpawnFilterConfig filters = new SpawnFilterConfig(); - - filters.maxAttempts = Math.max(1, plugin.getConfig().getInt(base + "max-attempts", 50)); - filters.rejectLiquid = plugin.getConfig().getBoolean(base + "reject-liquid", true); - filters.rejectVoid = plugin.getConfig().getBoolean(base + "reject-void", true); - filters.minDistanceFromSpawn = plugin.getConfig().getDouble(base + "min-distance-from-spawn", 0.0); - - for (String name : plugin.getConfig().getStringList(base + "blocked-blocks")) { - Material material = Material.matchMaterial(name); - if (material == null) { - plugin.getLogger().warning("Unknown material in spawn-filters.blocked-blocks: " + name); - continue; - } - filters.blockedBlocks.add(material); - } - - filters.worldGuardEnabled = plugin.getConfig().getBoolean(base + "worldguard.enabled", true); - for (String region : plugin.getConfig().getStringList(base + "worldguard.blacklisted-regions")) { - filters.blacklistedRegions.add(region.toLowerCase()); - } - - spawnFilters = filters; - } - - private void loadDetectionSettings() { - collectionDistance = plugin.getConfig().getDouble("detection.collection-distance", 20.0); - maxDetectionDistance = plugin.getConfig().getDouble("detection.max-detection-distance", 1000.0); - closeRangeThreshold = plugin.getConfig().getDouble("detection.close-range-threshold", 200.0); - threeRingsDistance = plugin.getConfig().getDouble("detection.ring-thresholds.three-rings", 100.0); - twoRingsDistance = plugin.getConfig().getDouble("detection.ring-thresholds.two-rings", 300.0); - } - - // ==================================== - // Boss bar that fills with closeness and facing - // ==================================== - private void loadBarSettings() { - barEnabled = plugin.getConfig().getBoolean("bar.enabled", true); - barRange = plugin.getConfig().getDouble("bar.range", 750.0); - barFacingWeight = Math.min(1.0, Math.max(0.0, plugin.getConfig().getDouble("bar.facing-weight", 0.6))); - - if (barRange <= 0.0) { - plugin.getLogger().warning("bar.range must be greater than 0 - falling back to 750."); - barRange = 750.0; - } - } - - // ==================================== - // Geiger clicking sound - // ==================================== - private void loadSoundSettings() { - soundEnabled = plugin.getConfig().getBoolean("sound.enabled", true); - soundName = normalizeSoundName(plugin.getConfig().getString("sound.sound", DEFAULT_SOUND)); - soundVolume = plugin.getConfig().getDouble("sound.volume", 0.35); - soundPitch = plugin.getConfig().getDouble("sound.pitch", 1.7); - soundPitchVariance = Math.max(0.0, plugin.getConfig().getDouble("sound.pitch-variance", 0.15)); - soundMinRate = Math.max(0.0, plugin.getConfig().getDouble("sound.min-rate", 0.5)); - soundMaxRate = Math.max(0.0, plugin.getConfig().getDouble("sound.max-rate", 18.0)); - soundCurve = plugin.getConfig().getDouble("sound.curve", 2.0); - - // An inverted range would make the sound quieten as the player closes in - if (soundMaxRate < soundMinRate) { - plugin.getLogger().warning("sound.max-rate is below sound.min-rate - swapping them so clicks speed up near the source."); - double swap = soundMinRate; - soundMinRate = soundMaxRate; - soundMaxRate = swap; - } - - // A non-positive curve would make every distance click at full rate - if (soundCurve <= 0.0) { - plugin.getLogger().warning("sound.curve must be greater than 0 - falling back to 2.0."); - soundCurve = 2.0; - } - } - - // Accepts the sound event ID, with or without the "minecraft:" namespace. - // Bukkit's enum spelling (BLOCK_NOTE_BLOCK_HAT) is accepted too - it maps - // 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.trim().isEmpty()) { - return DEFAULT_SOUND; - } - - String name = raw.trim().toLowerCase(); - if (name.startsWith("minecraft:")) { - name = name.substring("minecraft:".length()); - } - - // Enum spelling has no dots at all - rebuild the event ID from it - if (!name.contains(".")) { - String rebuilt = ENUM_TO_EVENT.get(name); - if (rebuilt == null) { - plugin.getLogger().warning("sound.sound '" + raw + "' is not a sound event ID (expected something like " - + DEFAULT_SOUND + ") - falling back to " + DEFAULT_SOUND + "."); - return DEFAULT_SOUND; - } - return rebuilt; - } - - return name; - } - - // ==================================== - // How many sources a single player may collect per time window - // ==================================== - private void loadDropLimits() { - limitEnabled = plugin.getConfig().getBoolean("limits.enabled", true); - limitDrops = plugin.getConfig().getInt("limits.drops", 3); - - String rawTime = plugin.getConfig().getString("limits.time", "12h"); - limitWindowMillis = Utils.parseDurationMillis(rawTime); - if (limitWindowMillis <= 0) { - plugin.getLogger().warning("Invalid limits.time value '" + rawTime + "' - expected something like 30s, 45m, 12h or 1d. Falling back to 12h."); - limitWindowMillis = TimeUnit.HOURS.toMillis(12); - } - - // A non-positive drop count would lock everyone out permanently, which - // is never what an admin means - they mean "turn the limit off" - if (limitEnabled && limitDrops <= 0) { - plugin.getLogger().warning("limits.drops must be at least 1 - disabling the drop limit. Set limits.enabled to false to silence this."); - limitEnabled = false; - } - } - - // ==================================== - // Text lives in messages.yml - re-read from disk on every load so - // /geiger reload picks up edits without a restart - // ==================================== - private void loadMessages() { - if (messages == null) { - messages = new Messages(plugin); - } else { - messages.reload(); - } - } - - // =========== Color Settings ====================== - private void loadColorSettings() { - closeRangeStartColor = new ColorConfig( - plugin.getConfig().getInt("colors.close-range.start.red", 255), - plugin.getConfig().getInt("colors.close-range.start.green", 255), - plugin.getConfig().getInt("colors.close-range.start.blue", 255) - ); - closeRangeEndColor = new ColorConfig( - plugin.getConfig().getInt("colors.close-range.end.red", 255), - plugin.getConfig().getInt("colors.close-range.end.green", 0), - plugin.getConfig().getInt("colors.close-range.end.blue", 255) - ); - - farRangeStartColor = new ColorConfig( - plugin.getConfig().getInt("colors.far-range.start.red", 255), - plugin.getConfig().getInt("colors.far-range.start.green", 0), - plugin.getConfig().getInt("colors.far-range.start.blue", 255) - ); - farRangeEndColor = new ColorConfig( - plugin.getConfig().getInt("colors.far-range.end.red", 17), - plugin.getConfig().getInt("colors.far-range.end.green", 0), - plugin.getConfig().getInt("colors.far-range.end.blue", 17) - ); - } - - // ==================================== - // Load tiered reward system from config - // ==================================== - private void loadRewards() { - tierRewards.clear(); - dropListNames.clear(); - - // Load tier weights - ConfigurationSection weightsSection = plugin.getConfig().getConfigurationSection("drops.tier-weights"); - if (weightsSection == null) { - plugin.getLogger().warning("No tier-weights section found in config!"); - return; - } - - // Several named lists live under drops.lists; drops.active-list picks one - ConfigurationSection listsSection = plugin.getConfig().getConfigurationSection("drops.lists"); - if (listsSection == null) { - plugin.getLogger().warning("No drops.lists section found in config!"); - return; - } - dropListNames.addAll(listsSection.getKeys(false)); - - activeDropList = plugin.getConfig().getString("drops.active-list", "default"); - if (!listsSection.isConfigurationSection(activeDropList)) { - if (dropListNames.isEmpty()) { - plugin.getLogger().warning("drops.lists has no drop lists in it!"); - return; - } - String fallback = dropListNames.get(0); - plugin.getLogger().warning("Drop list '" + activeDropList + "' not found in drops.lists - using '" + fallback + "' instead."); - activeDropList = fallback; - } - - ConfigurationSection tiersSection = listsSection.getConfigurationSection(activeDropList + ".tiers"); - if (tiersSection == null) { - plugin.getLogger().warning("Drop list '" + activeDropList + "' has no tiers section!"); - return; - } - - // Process each tier - String[] tierNames = {"common", "uncommon", "rare", "epic", "legendary", "mythical"}; - for (String tierName : tierNames) { - double weight = weightsSection.getDouble(tierName, 0.0); - - if (weight > 0) { - TierReward tier = new TierReward(tierName, weight); - - // Load items for this tier - List tierItems = tiersSection.getStringList(tierName); - for (String itemString : tierItems) { - ItemReward item = parseItemReward(itemString); - if (item != null) { - tier.addItem(item); - } - } - - // Only add tier if it has items - if (!tier.isEmpty()) { - tierRewards.add(tier); - } - } - } - - plugin.getLogger().info("Loaded " + tierRewards.size() + " reward tiers from drop list '" + activeDropList + "'"); - } - - private ItemReward parseItemReward(String itemString) { - // Split on the last colon to separate item path from amount - int lastColonIndex = itemString.lastIndexOf(':'); - if (lastColonIndex == -1) { - return null; - } - - String itemPath = itemString.substring(0, lastColonIndex); - String amountString = itemString.substring(lastColonIndex + 1); - - try { - int amount = Integer.parseInt(amountString); - return new ItemReward(itemPath, amount); - } catch (NumberFormatException e) { - plugin.getLogger().warning("Invalid amount in item reward: " + itemString); - return null; - } - } - - // ===== GETTERS ===== - - public World getWorld() { return world; } - public double getMinX() { return minX; } - public double getMaxX() { return maxX; } - public double getMinZ() { return minZ; } - public double getMaxZ() { return maxZ; } - public double getMinSourceDistance() { return minSourceDistance; } - public double getMaxSourceDistance() { return maxSourceDistance; } - public double getRerollDistance() { return rerollDistance; } - public SpawnFilterConfig getSpawnFilters() { return spawnFilters; } - public double getCollectionDistance() { return collectionDistance; } - public double getMaxDetectionDistance() { return maxDetectionDistance; } - public double getCloseRangeThreshold() { return closeRangeThreshold; } - public double getThreeRingsDistance() { return threeRingsDistance; } - public double getTwoRingsDistance() { return twoRingsDistance; } - public Messages getMessages() { return messages; } - public boolean isBarEnabled() { return barEnabled; } - public double getBarRange() { return barRange; } - public double getBarFacingWeight() { return barFacingWeight; } - public boolean isSoundEnabled() { return soundEnabled; } - public String getSoundName() { return soundName; } - public double getSoundVolume() { return soundVolume; } - public double getSoundPitch() { return soundPitch; } - public double getSoundPitchVariance() { return soundPitchVariance; } - public double getSoundMinRate() { return soundMinRate; } - public double getSoundMaxRate() { return soundMaxRate; } - public double getSoundCurve() { return soundCurve; } - public boolean isLimitEnabled() { return limitEnabled; } - public int getLimitDrops() { return limitDrops; } - public long getLimitWindowMillis() { return limitWindowMillis; } - public ColorConfig getCloseRangeStartColor() { return closeRangeStartColor; } - public ColorConfig getCloseRangeEndColor() { return closeRangeEndColor; } - public ColorConfig getFarRangeStartColor() { return farRangeStartColor; } - public ColorConfig getFarRangeEndColor() { return farRangeEndColor; } - public List getTierRewards() { return tierRewards; } - public String getActiveDropList() { return activeDropList; } - public List getDropListNames() { return dropListNames; } - - - // =========== Rules for valid source spawn locations ================ - public static class SpawnFilterConfig { - private int maxAttempts = 50; - private boolean rejectLiquid = true; - private boolean rejectVoid = true; - private double minDistanceFromSpawn = 0.0; - private final Set blockedBlocks = EnumSet.noneOf(Material.class); - private boolean worldGuardEnabled = true; - private final Set blacklistedRegions = new HashSet<>(); - - public int getMaxAttempts() { return maxAttempts; } - public boolean isRejectLiquid() { return rejectLiquid; } - public boolean isRejectVoid() { return rejectVoid; } - public double getMinDistanceFromSpawn() { return minDistanceFromSpawn; } - public Set getBlockedBlocks() { return blockedBlocks; } - public boolean isWorldGuardEnabled() { return worldGuardEnabled; } - public Set getBlacklistedRegions() { return blacklistedRegions; } - } - - // =========== Store RGB color values ================ - public static class ColorConfig { - private final int red; - private final int green; - private final int blue; - - public ColorConfig(int red, int green, int blue) { - this.red = red; - this.green = green; - this.blue = blue; - } - - public int getRed() { return red; } - public int getGreen() { return green; } - public int getBlue() { return blue; } - } -} - +package net.tfminecraft.geigercounters.config; + +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.plugin.java.JavaPlugin; +import net.tfminecraft.geigercounters.models.ItemReward; +import net.tfminecraft.geigercounters.models.TierReward; +import net.tfminecraft.geigercounters.utils.Utils; + +import java.io.File; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +// ==================================== +// Handles loading and storing all Geiger Counter configuration +// ==================================== +public class GeigerConfiguration { + + private static final String DEFAULT_SOUND = "block.note_block.hat"; + + // Bukkit enum spellings admins are likely to paste in, mapped to the event + // ID. Only the click-like sounds - anything else has to use the event ID. + private static final Map ENUM_TO_EVENT = new HashMap<>(); + static { + ENUM_TO_EVENT.put("block_note_block_hat", "block.note_block.hat"); + ENUM_TO_EVENT.put("block_dispenser_fail", "block.dispenser.fail"); + ENUM_TO_EVENT.put("block_comparator_click", "block.comparator.click"); + ENUM_TO_EVENT.put("block_lever_click", "block.lever.click"); + ENUM_TO_EVENT.put("ui_button_click", "ui.button.click"); + ENUM_TO_EVENT.put("entity_item_pickup", "entity.item.pickup"); + } + + private final JavaPlugin plugin; + + // World settings + private World world; + private double minX, maxX, minZ, maxZ; + + // Where each character's source is placed, relative to the character + private double minSourceDistance; + private double maxSourceDistance; + private double rerollDistance; + + // How many blocks of ground cover a cache + private int minBurialDepth; + private int maxBurialDepth; + + // Spawn location filters + private SpawnFilterConfig spawnFilters = new SpawnFilterConfig(); + + // Detection settings + private double maxDetectionDistance; + private double pinpointRange; + private double closeRangeThreshold; + private double threeRingsDistance; + private double twoRingsDistance; + + // Signal bar + private boolean barEnabled; + private double barRange; + private double barFacingWeight; + + // Click sound + private boolean soundEnabled; + private String soundName; + private double soundVolume; + private double soundPitch; + private double soundPitchVariance; + private double soundMinRate; + private double soundMaxRate; + private double soundCurve; + + // Drop limits + private boolean limitEnabled; + private int limitDrops; + private long limitWindowMillis; + + // Messages (messages.yml, not config.yml) + private Messages messages; + + // Colors + private ColorConfig closeRangeStartColor; + private ColorConfig closeRangeEndColor; + private ColorConfig farRangeStartColor; + private ColorConfig farRangeEndColor; + + // Rewards + private String activeDropList; + private final List dropListNames = new ArrayList<>(); + private final List tierRewards = new ArrayList<>(); + + public GeigerConfiguration(JavaPlugin plugin) { + this.plugin = plugin; + } + + // ==================================== + // Load all configuration from config.yml + // ==================================== + public void load() { + loadWorld(); + loadSearchArea(); + loadSourceDistances(); + loadSpawnFilters(); + loadDetectionSettings(); + loadBarSettings(); + loadDropLimits(); + loadSoundSettings(); + loadColorSettings(); + loadMessages(); + loadRewards(); + } + + private void loadWorld() { + String worldName = plugin.getConfig().getString("source.world"); + world = Bukkit.getWorld(worldName); + } + + private void loadSearchArea() { + minX = plugin.getConfig().getDouble("source.top-left.x"); + maxX = plugin.getConfig().getDouble("source.bottom-right.x"); + minZ = plugin.getConfig().getDouble("source.top-left.z"); + maxZ = plugin.getConfig().getDouble("source.bottom-right.z"); + + // Make sure that is always less than + if (minX > maxX) { + double temp = minX; + minX = maxX; + maxX = temp; + } + if (minZ > maxZ) { + double temp = minZ; + minZ = maxZ; + maxZ = temp; + } + } + + // ==================================== + // How far from the character a source is placed, and when it is placed again + // ==================================== + private void loadSourceDistances() { + minSourceDistance = Math.max(0.0, plugin.getConfig().getDouble("source.min-distance", 300.0)); + maxSourceDistance = plugin.getConfig().getDouble("source.max-distance", 600.0); + rerollDistance = plugin.getConfig().getDouble("source.reroll-distance", 1000.0); + + if (maxSourceDistance < minSourceDistance) { + plugin.getLogger().warning("source.max-distance is below source.min-distance - using min-distance for both."); + maxSourceDistance = minSourceDistance; + } + + // Below max-distance a fresh source could be placed out of range and re-rolled forever + if (rerollDistance < maxSourceDistance) { + plugin.getLogger().warning("source.reroll-distance is below source.max-distance - using max-distance."); + rerollDistance = maxSourceDistance; + } + + minBurialDepth = Math.max(1, plugin.getConfig().getInt("source.burial.min-depth", 3)); + maxBurialDepth = plugin.getConfig().getInt("source.burial.max-depth", 6); + if (maxBurialDepth < minBurialDepth) { + plugin.getLogger().warning("source.burial.max-depth is below min-depth - using min-depth for both."); + maxBurialDepth = minBurialDepth; + } + } + + // ==================================== + // Load the rules that decide where the source is allowed to spawn + // ==================================== + private void loadSpawnFilters() { + String base = "source.spawn-filters."; + SpawnFilterConfig filters = new SpawnFilterConfig(); + + filters.maxAttempts = Math.max(1, plugin.getConfig().getInt(base + "max-attempts", 50)); + filters.rejectLiquid = plugin.getConfig().getBoolean(base + "reject-liquid", true); + filters.rejectVoid = plugin.getConfig().getBoolean(base + "reject-void", true); + filters.minDistanceFromSpawn = plugin.getConfig().getDouble(base + "min-distance-from-spawn", 0.0); + + for (String name : plugin.getConfig().getStringList(base + "blocked-blocks")) { + Material material = Material.matchMaterial(name); + if (material == null) { + plugin.getLogger().warning("Unknown material in spawn-filters.blocked-blocks: " + name); + continue; + } + filters.blockedBlocks.add(material); + } + + filters.worldGuardEnabled = plugin.getConfig().getBoolean(base + "worldguard.enabled", true); + for (String region : plugin.getConfig().getStringList(base + "worldguard.blacklisted-regions")) { + filters.blacklistedRegions.add(region.toLowerCase()); + } + + spawnFilters = filters; + } + + private void loadDetectionSettings() { + maxDetectionDistance = plugin.getConfig().getDouble("detection.max-detection-distance", 1000.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); + twoRingsDistance = plugin.getConfig().getDouble("detection.ring-thresholds.two-rings", 300.0); + + if (pinpointRange <= 0.0) { + plugin.getLogger().warning("detection.pinpoint-range must be greater than 0 - falling back to 12."); + pinpointRange = 12.0; + } + } + + // ==================================== + // Boss bar that fills with closeness and facing + // ==================================== + private void loadBarSettings() { + barEnabled = plugin.getConfig().getBoolean("bar.enabled", true); + barRange = plugin.getConfig().getDouble("bar.range", 750.0); + barFacingWeight = Math.min(1.0, Math.max(0.0, plugin.getConfig().getDouble("bar.facing-weight", 0.6))); + + if (barRange <= 0.0) { + plugin.getLogger().warning("bar.range must be greater than 0 - falling back to 750."); + barRange = 750.0; + } + } + + // ==================================== + // Geiger clicking sound + // ==================================== + private void loadSoundSettings() { + soundEnabled = plugin.getConfig().getBoolean("sound.enabled", true); + soundName = normalizeSoundName(plugin.getConfig().getString("sound.sound", DEFAULT_SOUND)); + soundVolume = plugin.getConfig().getDouble("sound.volume", 0.35); + soundPitch = plugin.getConfig().getDouble("sound.pitch", 1.7); + soundPitchVariance = Math.max(0.0, plugin.getConfig().getDouble("sound.pitch-variance", 0.15)); + soundMinRate = Math.max(0.0, plugin.getConfig().getDouble("sound.min-rate", 0.5)); + soundMaxRate = Math.max(0.0, plugin.getConfig().getDouble("sound.max-rate", 18.0)); + soundCurve = plugin.getConfig().getDouble("sound.curve", 2.0); + + // An inverted range would make the sound quieten as the player closes in + if (soundMaxRate < soundMinRate) { + plugin.getLogger().warning("sound.max-rate is below sound.min-rate - swapping them so clicks speed up near the source."); + double swap = soundMinRate; + soundMinRate = soundMaxRate; + soundMaxRate = swap; + } + + // A non-positive curve would make every distance click at full rate + if (soundCurve <= 0.0) { + plugin.getLogger().warning("sound.curve must be greater than 0 - falling back to 2.0."); + soundCurve = 2.0; + } + } + + // Accepts the sound event ID, with or without the "minecraft:" namespace. + // Bukkit's enum spelling (BLOCK_NOTE_BLOCK_HAT) is accepted too - it maps + // 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.trim().isEmpty()) { + return DEFAULT_SOUND; + } + + String name = raw.trim().toLowerCase(); + if (name.startsWith("minecraft:")) { + name = name.substring("minecraft:".length()); + } + + // Enum spelling has no dots at all - rebuild the event ID from it + if (!name.contains(".")) { + String rebuilt = ENUM_TO_EVENT.get(name); + if (rebuilt == null) { + plugin.getLogger().warning("sound.sound '" + raw + "' is not a sound event ID (expected something like " + + DEFAULT_SOUND + ") - falling back to " + DEFAULT_SOUND + "."); + return DEFAULT_SOUND; + } + return rebuilt; + } + + return name; + } + + // ==================================== + // How many sources a single player may collect per time window + // ==================================== + private void loadDropLimits() { + limitEnabled = plugin.getConfig().getBoolean("limits.enabled", true); + limitDrops = plugin.getConfig().getInt("limits.drops", 3); + + String rawTime = plugin.getConfig().getString("limits.time", "12h"); + limitWindowMillis = Utils.parseDurationMillis(rawTime); + if (limitWindowMillis <= 0) { + plugin.getLogger().warning("Invalid limits.time value '" + rawTime + "' - expected something like 30s, 45m, 12h or 1d. Falling back to 12h."); + limitWindowMillis = TimeUnit.HOURS.toMillis(12); + } + + // A non-positive drop count would lock everyone out permanently, which + // is never what an admin means - they mean "turn the limit off" + if (limitEnabled && limitDrops <= 0) { + plugin.getLogger().warning("limits.drops must be at least 1 - disabling the drop limit. Set limits.enabled to false to silence this."); + limitEnabled = false; + } + } + + // ==================================== + // Text lives in messages.yml - re-read from disk on every load so + // /geiger reload picks up edits without a restart + // ==================================== + private void loadMessages() { + if (messages == null) { + messages = new Messages(plugin); + } else { + messages.reload(); + } + } + + // =========== Color Settings ====================== + private void loadColorSettings() { + closeRangeStartColor = new ColorConfig( + plugin.getConfig().getInt("colors.close-range.start.red", 255), + plugin.getConfig().getInt("colors.close-range.start.green", 255), + plugin.getConfig().getInt("colors.close-range.start.blue", 255) + ); + closeRangeEndColor = new ColorConfig( + plugin.getConfig().getInt("colors.close-range.end.red", 255), + plugin.getConfig().getInt("colors.close-range.end.green", 0), + plugin.getConfig().getInt("colors.close-range.end.blue", 255) + ); + + farRangeStartColor = new ColorConfig( + plugin.getConfig().getInt("colors.far-range.start.red", 255), + plugin.getConfig().getInt("colors.far-range.start.green", 0), + plugin.getConfig().getInt("colors.far-range.start.blue", 255) + ); + farRangeEndColor = new ColorConfig( + plugin.getConfig().getInt("colors.far-range.end.red", 17), + plugin.getConfig().getInt("colors.far-range.end.green", 0), + plugin.getConfig().getInt("colors.far-range.end.blue", 17) + ); + } + + // ==================================== + // Load tiered reward system from config + // ==================================== + private void loadRewards() { + tierRewards.clear(); + dropListNames.clear(); + + // Load tier weights + ConfigurationSection weightsSection = plugin.getConfig().getConfigurationSection("drops.tier-weights"); + if (weightsSection == null) { + plugin.getLogger().warning("No tier-weights section found in config!"); + return; + } + + // Several named lists live under drops.lists; drops.active-list picks one + ConfigurationSection listsSection = plugin.getConfig().getConfigurationSection("drops.lists"); + if (listsSection == null) { + plugin.getLogger().warning("No drops.lists section found in config!"); + return; + } + dropListNames.addAll(listsSection.getKeys(false)); + + activeDropList = plugin.getConfig().getString("drops.active-list", "default"); + if (!listsSection.isConfigurationSection(activeDropList)) { + if (dropListNames.isEmpty()) { + plugin.getLogger().warning("drops.lists has no drop lists in it!"); + return; + } + String fallback = dropListNames.get(0); + plugin.getLogger().warning("Drop list '" + activeDropList + "' not found in drops.lists - using '" + fallback + "' instead."); + activeDropList = fallback; + } + + ConfigurationSection tiersSection = listsSection.getConfigurationSection(activeDropList + ".tiers"); + if (tiersSection == null) { + plugin.getLogger().warning("Drop list '" + activeDropList + "' has no tiers section!"); + return; + } + + // Process each tier + String[] tierNames = {"common", "uncommon", "rare", "epic", "legendary", "mythical"}; + for (String tierName : tierNames) { + double weight = weightsSection.getDouble(tierName, 0.0); + + if (weight > 0) { + TierReward tier = new TierReward(tierName, weight); + + // Load items for this tier + List tierItems = tiersSection.getStringList(tierName); + for (String itemString : tierItems) { + ItemReward item = parseItemReward(itemString); + if (item != null) { + tier.addItem(item); + } + } + + // Only add tier if it has items + if (!tier.isEmpty()) { + tierRewards.add(tier); + } + } + } + + plugin.getLogger().info("Loaded " + tierRewards.size() + " reward tiers from drop list '" + activeDropList + "'"); + } + + private ItemReward parseItemReward(String itemString) { + // Split on the last colon to separate item path from amount + int lastColonIndex = itemString.lastIndexOf(':'); + if (lastColonIndex == -1) { + return null; + } + + String itemPath = itemString.substring(0, lastColonIndex); + String amountString = itemString.substring(lastColonIndex + 1); + + try { + int amount = Integer.parseInt(amountString); + return new ItemReward(itemPath, amount); + } catch (NumberFormatException e) { + plugin.getLogger().warning("Invalid amount in item reward: " + itemString); + return null; + } + } + + // ===== GETTERS ===== + + public World getWorld() { return world; } + public double getMinX() { return minX; } + public double getMaxX() { return maxX; } + public double getMinZ() { return minZ; } + public double getMaxZ() { return maxZ; } + public double getMinSourceDistance() { return minSourceDistance; } + public double getMaxSourceDistance() { return maxSourceDistance; } + public double getRerollDistance() { return rerollDistance; } + public int getMinBurialDepth() { return minBurialDepth; } + public int getMaxBurialDepth() { return maxBurialDepth; } + public SpawnFilterConfig getSpawnFilters() { return spawnFilters; } + public double getPinpointRange() { return pinpointRange; } + public double getMaxDetectionDistance() { return maxDetectionDistance; } + public double getCloseRangeThreshold() { return closeRangeThreshold; } + public double getThreeRingsDistance() { return threeRingsDistance; } + public double getTwoRingsDistance() { return twoRingsDistance; } + public Messages getMessages() { return messages; } + public boolean isBarEnabled() { return barEnabled; } + public double getBarRange() { return barRange; } + public double getBarFacingWeight() { return barFacingWeight; } + public boolean isSoundEnabled() { return soundEnabled; } + public String getSoundName() { return soundName; } + public double getSoundVolume() { return soundVolume; } + public double getSoundPitch() { return soundPitch; } + public double getSoundPitchVariance() { return soundPitchVariance; } + public double getSoundMinRate() { return soundMinRate; } + public double getSoundMaxRate() { return soundMaxRate; } + public double getSoundCurve() { return soundCurve; } + public boolean isLimitEnabled() { return limitEnabled; } + public int getLimitDrops() { return limitDrops; } + public long getLimitWindowMillis() { return limitWindowMillis; } + public ColorConfig getCloseRangeStartColor() { return closeRangeStartColor; } + public ColorConfig getCloseRangeEndColor() { return closeRangeEndColor; } + public ColorConfig getFarRangeStartColor() { return farRangeStartColor; } + public ColorConfig getFarRangeEndColor() { return farRangeEndColor; } + public List getTierRewards() { return tierRewards; } + public String getActiveDropList() { return activeDropList; } + public List getDropListNames() { return dropListNames; } + + + // =========== Rules for valid source spawn locations ================ + public static class SpawnFilterConfig { + private int maxAttempts = 50; + private boolean rejectLiquid = true; + private boolean rejectVoid = true; + private double minDistanceFromSpawn = 0.0; + private final Set blockedBlocks = EnumSet.noneOf(Material.class); + private boolean worldGuardEnabled = true; + private final Set blacklistedRegions = new HashSet<>(); + + public int getMaxAttempts() { return maxAttempts; } + public boolean isRejectLiquid() { return rejectLiquid; } + public boolean isRejectVoid() { return rejectVoid; } + public double getMinDistanceFromSpawn() { return minDistanceFromSpawn; } + public Set getBlockedBlocks() { return blockedBlocks; } + public boolean isWorldGuardEnabled() { return worldGuardEnabled; } + public Set getBlacklistedRegions() { return blacklistedRegions; } + } + + // =========== Store RGB color values ================ + public static class ColorConfig { + private final int red; + private final int green; + private final int blue; + + public ColorConfig(int red, int green, int blue) { + this.red = red; + this.green = green; + this.blue = blue; + } + + public int getRed() { return red; } + public int getGreen() { return green; } + public int getBlue() { return blue; } + } +} + diff --git a/src/main/java/net/tfminecraft/geigercounters/config/Messages.java b/src/main/java/net/tfminecraft/geigercounters/config/Messages.java index 27fdccf..85787f0 100644 --- a/src/main/java/net/tfminecraft/geigercounters/config/Messages.java +++ b/src/main/java/net/tfminecraft/geigercounters/config/Messages.java @@ -69,10 +69,6 @@ public String get(String path, Object... placeholderPairs) { return Utils.colorize(raw); } - // Coordinates read better as whole-ish numbers than raw doubles - public static String coordinate(double value) { - return String.format("%.1f", value); - } private YamlConfiguration loadPackaged() { InputStream stream = plugin.getResource(ConfigMigrator.MESSAGES_FILE); diff --git a/src/main/java/net/tfminecraft/geigercounters/handlers/BuriedChest.java b/src/main/java/net/tfminecraft/geigercounters/handlers/BuriedChest.java new file mode 100644 index 0000000..4dbe190 --- /dev/null +++ b/src/main/java/net/tfminecraft/geigercounters/handlers/BuriedChest.java @@ -0,0 +1,175 @@ +package net.tfminecraft.geigercounters.handlers; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.block.data.BlockData; +import org.bukkit.block.data.Directional; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.block.BlockBreakEvent; +import org.bukkit.event.player.PlayerInteractEvent; + +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +// ==================================== +// The cache as its hunter sees it: a chest sent to their client alone. +// +// The server still holds the real ground block, so nobody else sees or can +// open it. The chest is sent while the hunter holds a live detector within +// REVEAL_DISTANCE, and resent every few checks, since a chunk reload or +// someone else digging there replaces it on their client. +// ==================================== +public class BuriedChest implements Listener { + + // Within this many blocks the chest is sent to the hunter's client + public static final double REVEAL_DISTANCE = 32.0; + + // Player checks between resends + static final int RESEND_CHECKS = 4; + + // A cancelled dig this close to the cache means the ground is protected + private static final double PROTECTED_DIG_DISTANCE = 3.0; + + private static final BlockFace[] FACINGS = {BlockFace.NORTH, BlockFace.EAST, BlockFace.SOUTH, BlockFace.WEST}; + + // ==================================== + // What the hunt does when the hunter reaches their cache + // ==================================== + public interface Actions { + // The hunter clicked their cache + void open(Player player); + + // Something stopped the hunter digging next to their cache + void groundProtected(Player player); + } + + private final Actions actions; + + // Player -> the cache block their client is showing as a chest + private final Map shown = new HashMap<>(); + + private int check; + + public BuriedChest(Actions actions) { + this.actions = actions; + } + + // ==================================== + // Show the player their cache at , resending when it is due + // ==================================== + public void show(Player player, Location cache) { + Location block = cache.toBlockLocation(); + Shown current = shown.get(player.getUniqueId()); + if (current != null && current.block.equals(block) && check - current.sentAt < RESEND_CHECKS) { + return; + } + if (current != null && !current.block.equals(block)) { + revert(player, current.block); + } + + player.sendBlockChange(block, chestAt(block)); + shown.put(player.getUniqueId(), new Shown(block, check)); + } + + // ==================================== + // Put the real block back for every player not in . Called once + // per player check, after every player has been shown their cache. + // ==================================== + public void retain(Set showing) { + check++; + Iterator> iterator = shown.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + if (showing.contains(entry.getKey())) { + continue; + } + iterator.remove(); + revert(Bukkit.getPlayer(entry.getKey()), entry.getValue().block); + } + } + + public void clear() { + retain(Set.of()); + } + + // Clicking the chest opens it. Cancelled first so nothing else acts on the + // real block underneath, and a left click never starts breaking it. + @EventHandler(priority = EventPriority.LOWEST) + public void onInteract(PlayerInteractEvent event) { + Block clicked = event.getClickedBlock(); + if (clicked == null || !isShownAt(event.getPlayer(), clicked)) { + return; + } + event.setCancelled(true); + actions.open(event.getPlayer()); + } + + // Breaking the cache block outright (creative mode) would dig up the real + // block; the chest comes back on the next check + @EventHandler(priority = EventPriority.LOWEST) + public void onBreakCache(BlockBreakEvent event) { + if (!isShownAt(event.getPlayer(), event.getBlock())) { + return; + } + event.setCancelled(true); + shown.get(event.getPlayer().getUniqueId()).sentAt = check - RESEND_CHECKS; + } + + // Another plugin refusing the hunter's dig beside their cache (a protected + // region or claim) means they can never reach it, so it moves elsewhere + @EventHandler(priority = EventPriority.MONITOR) + public void onProtectedDig(BlockBreakEvent event) { + Shown current = shown.get(event.getPlayer().getUniqueId()); + if (!event.isCancelled() || current == null) { + return; + } + Location dug = event.getBlock().getLocation(); + if (dug.equals(current.block) || !dug.getWorld().equals(current.block.getWorld()) + || dug.distance(current.block) > PROTECTED_DIG_DISTANCE) { + return; + } + actions.groundProtected(event.getPlayer()); + } + + private boolean isShownAt(Player player, Block block) { + Shown current = shown.get(player.getUniqueId()); + return current != null && current.block.equals(block.getLocation()); + } + + // Facing fixed by position, so resends never turn the chest + private static BlockData chestAt(Location block) { + BlockFace facing = FACINGS[Math.floorMod(block.getBlockX() * 31 + block.getBlockZ(), FACINGS.length)]; + return Bukkit.createBlockData(Material.CHEST, data -> ((Directional) data).setFacing(facing)); + } + + // Only when the player can still see that block; a chunk sent later + // carries the real block anyway, and reading an unloaded one would load it + private static void revert(Player player, Location block) { + World world = block.getWorld(); + if (player == null || !world.equals(player.getWorld()) + || !world.isChunkLoaded(block.getBlockX() >> 4, block.getBlockZ() >> 4)) { + return; + } + player.sendBlockChange(block, block.getBlock().getBlockData()); + } + + private static final class Shown { + final Location block; + int sentAt; + + Shown(Location block, int sentAt) { + this.block = block; + this.sentAt = sentAt; + } + } +} diff --git a/src/main/java/net/tfminecraft/geigercounters/handlers/CacheMenu.java b/src/main/java/net/tfminecraft/geigercounters/handlers/CacheMenu.java new file mode 100644 index 0000000..63942a5 --- /dev/null +++ b/src/main/java/net/tfminecraft/geigercounters/handlers/CacheMenu.java @@ -0,0 +1,98 @@ +package net.tfminecraft.geigercounters.handlers; + +import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.inventory.InventoryCloseEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.ItemStack; +import net.tfminecraft.geigercounters.config.GeigerConfiguration; + +import java.util.ArrayList; +import java.util.List; + +// ==================================== +// The chest window shown when a hunter opens their cache. +// +// The window is not backed by any block, so whatever is still in it when it +// closes (or the server stops) goes to the player, never lost. +// ==================================== +public class CacheMenu implements Listener { + + private static final int SIZE = 27; + + // Middle slot of the middle row + private static final int REWARD_SLOT = 13; + + private static final LegacyComponentSerializer LEGACY = LegacyComponentSerializer.builder() + .character(LegacyComponentSerializer.SECTION_CHAR) + .hexColors() + .useUnusualXRepeatedCharacterHexFormat() + .build(); + + private final GeigerConfiguration config; + + public CacheMenu(GeigerConfiguration config) { + this.config = config; + } + + // ==================================== + // Show the cache holding , which may be null for an empty cache + // ==================================== + public void open(Player player, ItemStack reward) { + Holder holder = new Holder(); + Inventory inventory = Bukkit.createInventory(holder, SIZE, + LEGACY.deserialize(config.getMessages().get("player.cache-title"))); + holder.inventory = inventory; + if (reward != null) { + inventory.setItem(REWARD_SLOT, reward); + } + player.openInventory(inventory); + } + + @EventHandler + public void onClose(InventoryCloseEvent event) { + if (event.getInventory().getHolder() instanceof Holder && event.getPlayer() instanceof Player player) { + empty(player, event.getInventory()); + } + } + + // ==================================== + // Hand back every open cache before the plugin goes away + // ==================================== + public void closeAll() { + for (Player player : Bukkit.getOnlinePlayers()) { + Inventory top = player.getOpenInventory().getTopInventory(); + if (top.getHolder() instanceof Holder) { + empty(player, top); + player.closeInventory(); + } + } + } + + private static void empty(Player player, Inventory inventory) { + List left = new ArrayList<>(); + for (ItemStack item : inventory.getContents()) { + if (item != null && !item.isEmpty()) { + left.add(item); + } + } + inventory.clear(); + for (ItemStack item : left) { + SourceHandler.giveItemOrDropLeftovers(player, item); + } + } + + // Marks a cache window; the inventory is set once Bukkit has made it + static final class Holder implements InventoryHolder { + private Inventory inventory; + + @Override + public Inventory getInventory() { + return inventory; + } + } +} diff --git a/src/main/java/net/tfminecraft/geigercounters/handlers/GeigerClickPlayer.java b/src/main/java/net/tfminecraft/geigercounters/handlers/GeigerClickPlayer.java index 611f7ba..9c1955a 100644 --- a/src/main/java/net/tfminecraft/geigercounters/handlers/GeigerClickPlayer.java +++ b/src/main/java/net/tfminecraft/geigercounters/handlers/GeigerClickPlayer.java @@ -49,11 +49,27 @@ public GeigerClickPlayer(GeigerConfiguration config) { // Called by the periodic player check, not every tick // ==================================== public void updateRate(Player player, double distance) { + setRate(player, calculateClicksPerTick(distance)); + } + + // ==================================== + // Close reading for a cache blocks away in 3D: the clicks drop + // back to min-rate at pinpoint-range and climb straight to max-rate on + // the cache, so the slowdown on arrival tells the player they are close + // ==================================== + public void updatePinpoint(Player player, double distance) { + double range = config.getPinpointRange(); + double closeness = 1.0 - Math.min(distance, range) / range; + double minRate = config.getSoundMinRate(); + double clicksPerSecond = minRate + (config.getSoundMaxRate() - minRate) * closeness; + setRate(player, Math.min(1.0, clicksPerSecond / TICKS_PER_SECOND)); + } + + private void setRate(Player player, double clicksPerTick) { if (!config.isSoundEnabled()) { return; } - double clicksPerTick = calculateClicksPerTick(distance); if (clicksPerTick <= 0.0) { states.remove(player.getUniqueId()); return; diff --git a/src/main/java/net/tfminecraft/geigercounters/handlers/SignalBar.java b/src/main/java/net/tfminecraft/geigercounters/handlers/SignalBar.java index 580a2be..0356b69 100644 --- a/src/main/java/net/tfminecraft/geigercounters/handlers/SignalBar.java +++ b/src/main/java/net/tfminecraft/geigercounters/handlers/SignalBar.java @@ -1,6 +1,7 @@ package net.tfminecraft.geigercounters.handlers; import net.kyori.adventure.bossbar.BossBar; +import net.kyori.adventure.text.Component; import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; import org.bukkit.Bukkit; import org.bukkit.Location; @@ -17,11 +18,15 @@ // Boss bar shown while a player holds a detector. // // It fills as the player closes in and peaks when they face their source, so -// sweeping round on the spot shows which way to walk. MMOCore owns the action -// bar, which is why this sits at the top of the screen. +// sweeping round on the spot shows which way to walk. Within pinpoint range +// it retitles and shows closeness to the cache alone, to find where to dig. +// MMOCore owns the action bar, which is why this sits at the top of the screen. // ==================================== public class SignalBar { + static final String TITLE = "player.signal-bar"; + static final String PINPOINT_TITLE = "player.signal-bar-close"; + private static final LegacyComponentSerializer LEGACY = LegacyComponentSerializer.builder() .character(LegacyComponentSerializer.SECTION_CHAR) .hexColors() @@ -30,8 +35,8 @@ public class SignalBar { private final GeigerConfiguration config; - // Players currently shown a bar - private final Map bars = new HashMap<>(); + // Players currently shown a bar, and which title it carries + private final Map bars = new HashMap<>(); public SignalBar(GeigerConfiguration config) { this.config = config; @@ -41,36 +46,55 @@ public SignalBar(GeigerConfiguration config) { // Show or refresh the player's bar for a source blocks away // ==================================== public void update(Player player, Location source, double distance) { + show(player, signal(player.getLocation(), source, distance), TITLE); + } + + // ==================================== + // Show or refresh the close reading for a cache blocks away in 3D + // ==================================== + public void pinpoint(Player player, double distance) { + double range = config.getPinpointRange(); + show(player, 1.0 - Math.min(distance, range) / range, PINPOINT_TITLE); + } + + private void show(Player player, double signal, String title) { if (!config.isBarEnabled()) { hide(player.getUniqueId()); return; } - float progress = (float) signal(player.getLocation(), source, distance); - BossBar bar = bars.get(player.getUniqueId()); - if (bar != null) { - bar.progress(progress); + float progress = (float) signal; + Shown shown = bars.get(player.getUniqueId()); + if (shown != null) { + shown.bar.progress(progress); + if (!shown.title.equals(title)) { + shown.bar.name(title(title)); + shown.title = title; + } return; } - bar = BossBar.bossBar(LEGACY.deserialize(config.getMessages().get("player.signal-bar")), progress, - BossBar.Color.PURPLE, BossBar.Overlay.NOTCHED_10); - bars.put(player.getUniqueId(), bar); + BossBar bar = BossBar.bossBar(title(title), progress, BossBar.Color.PURPLE, BossBar.Overlay.NOTCHED_10); + bars.put(player.getUniqueId(), new Shown(bar, title)); player.showBossBar(bar); } + private Component title(String key) { + return LEGACY.deserialize(config.getMessages().get(key)); + } + // ==================================== // Hide every bar except those of the given players, who are still hunting // ==================================== public void retain(Set hunting) { - Iterator> iterator = bars.entrySet().iterator(); + Iterator> iterator = bars.entrySet().iterator(); while (iterator.hasNext()) { - Map.Entry entry = iterator.next(); + Map.Entry entry = iterator.next(); if (hunting.contains(entry.getKey())) { continue; } iterator.remove(); - hideFrom(entry.getKey(), entry.getValue()); + hideFrom(entry.getKey(), entry.getValue().bar); } } @@ -79,9 +103,9 @@ public void clear() { } private void hide(UUID playerId) { - BossBar bar = bars.remove(playerId); - if (bar != null) { - hideFrom(playerId, bar); + Shown shown = bars.remove(playerId); + if (shown != null) { + hideFrom(playerId, shown.bar); } } @@ -121,4 +145,14 @@ static double facing(Location from, Location source) { double half = (cosine + 1.0) / 2.0; return half * half; } + + private static final class Shown { + final BossBar bar; + String title; + + Shown(BossBar bar, String title) { + this.bar = bar; + this.title = title; + } + } } diff --git a/src/main/java/net/tfminecraft/geigercounters/handlers/SourceHandler.java b/src/main/java/net/tfminecraft/geigercounters/handlers/SourceHandler.java index cdc99e1..920c28b 100644 --- a/src/main/java/net/tfminecraft/geigercounters/handlers/SourceHandler.java +++ b/src/main/java/net/tfminecraft/geigercounters/handlers/SourceHandler.java @@ -1,516 +1,589 @@ -package net.tfminecraft.geigercounters.handlers; - -import net.tfminecraft.tlibs.objects.api.ItemAPI; -import org.bukkit.Bukkit; -import org.bukkit.ChatColor; -import org.bukkit.Chunk; -import org.bukkit.Location; -import org.bukkit.Sound; -import org.bukkit.World; -import org.bukkit.configuration.ConfigurationSection; -import org.bukkit.configuration.file.YamlConfiguration; -import org.bukkit.entity.Player; -import org.bukkit.inventory.EquipmentSlot; -import org.bukkit.inventory.ItemStack; -import org.bukkit.plugin.java.JavaPlugin; -import net.tfminecraft.geigercounters.config.GeigerConfiguration; -import net.tfminecraft.geigercounters.events.GeigerSourceCollectEvent; -import net.tfminecraft.geigercounters.managers.DropLimitManager; -import net.tfminecraft.geigercounters.metrics.UsageStats; -import net.tfminecraft.geigercounters.models.ItemReward; -import net.tfminecraft.geigercounters.models.TierReward; -import net.tfminecraft.geigercounters.utils.Utils; -import net.tfminecraft.geigercounters.validators.SpawnLocationFilter; - -import java.io.File; -import java.io.IOException; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Random; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; - -// ==================================== -// Each hunter's radioactive source: placement, persistence and collection. -// A hunter is the character being played (the account without RPCharacters). -// ==================================== -public class SourceHandler { - - private static final String DEAD_GEIGER_PATH = "m.TOOLS.DEAD_GEIGER_COUNTER"; - - // Collection is checked every few ticks, so the limit message needs its own - // cooldown or a player standing on the source would be spammed - private static final long LIMIT_MESSAGE_COOLDOWN_MILLIS = 15_000L; - - private static final String DATA_FILE_NAME = "sources.yml"; - private static final String SOURCES_PATH = "sources"; - - // Random spots tried for one that is both in the ring and inside the area - private static final int SPOT_TRIES = 100; - - private final JavaPlugin plugin; - private final GeigerConfiguration config; - private final ItemAPI api; - private final SpawnLocationFilter spawnFilter; - private final DropLimitManager dropLimits; - private final File dataFile; - private final Random random = new Random(); - - // Last time each player was told they are rate limited - private final Map lastLimitMessage = new HashMap<>(); - - // Hunter -> their source - private final Map sources = new HashMap<>(); - - // Hunter -> placement still resolving chunk loads - private final Map> pending = new HashMap<>(); - - public SourceHandler(JavaPlugin plugin, GeigerConfiguration config, ItemAPI api, - SpawnLocationFilter spawnFilter, DropLimitManager dropLimits) { - this.plugin = plugin; - this.config = config; - this.api = api; - this.spawnFilter = spawnFilter; - this.dropLimits = dropLimits; - this.dataFile = new File(plugin.getDataFolder(), DATA_FILE_NAME); - } - - // ==================================== - // The hunter's source, or null if they have none - // ==================================== - public Location getSource(UUID hunter) { - Source source = sources.get(hunter); - return source == null ? null : source.location(); - } - - // Whether a placement for this hunter is still resolving - public boolean isPlacing(UUID hunter) { - return pending.containsKey(hunter); - } - - // ==================================== - // Place the hunter's source at a random spot near - // - // Candidates sit between source.min-distance and source.max-distance from - // the origin, inside the configured area, and are rolled until one passes - // the spawn filters (liquid, void, blocked blocks, distance from spawn, - // WorldGuard regions). Every candidate needs its chunk loaded to read the - // terrain, so attempts run one at a time through getChunkAtAsync instead - // of stalling the main thread. - // - // If no candidate passes within the attempt budget the last one is used - // anyway, so the hunter always gets a source. When no part of the ring lies - // inside the area (the hunter is outside it) nothing is placed, any old - // source is kept, and the future completes with null. - // - // The returned future completes on the main thread with the chosen location. - // ==================================== - public CompletableFuture placeNear(UUID hunter, Location origin) { - // Collapse overlapping requests onto the search already running - CompletableFuture running = pending.get(hunter); - if (running != null) { - return running; - } - - double[] first = randomSpotNear(origin.getX(), origin.getZ()); - if (first == null) { - return CompletableFuture.completedFuture(null); - } - - // Clear the old source for the duration of the search so it cannot be - // collected while attempts are still resolving - sources.remove(hunter); - - CompletableFuture result = new CompletableFuture<>(); - pending.put(hunter, result); - attemptPlacement(hunter, origin.getX(), origin.getZ(), first, 1, result); - return result; - } - - // ==================================== - // A random spot between min-distance and max-distance from the origin that - // is also inside the area, or null if none turns up. Uniform over the - // ring's area, so sources do not bunch at the inner edge. - // ==================================== - private double[] randomSpotNear(double originX, double originZ) { - double min = config.getMinSourceDistance(); - double max = config.getMaxSourceDistance(); - for (int attempt = 0; attempt < SPOT_TRIES; attempt++) { - double angle = random.nextDouble() * 2.0 * Math.PI; - double radius = Math.sqrt(min * min + random.nextDouble() * (max * max - min * min)); - double x = originX + radius * Math.cos(angle); - double z = originZ + radius * Math.sin(angle); - if (x >= config.getMinX() && x <= config.getMaxX() && z >= config.getMinZ() && z <= config.getMaxZ()) { - return new double[] {x, z}; - } - } - return null; - } - - private void attemptPlacement(UUID hunter, double originX, double originZ, double[] spot, int attempt, - CompletableFuture result) { - double x = spot[0]; - double z = spot[1]; - int maxAttempts = config.getSpawnFilters().getMaxAttempts(); - - 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 (pending.get(hunter) != result || !plugin.isEnabled()) { - result.complete(null); - return; - } - if (error != null) { - plugin.getLogger().warning("Failed to load chunk for candidate source location: " + error.getMessage()); - } - - Location candidate = toSurfaceLocation(x, z); - SpawnLocationFilter.Rejection rejection = spawnFilter.check(candidate); - - if (rejection == null) { - applySource(hunter, candidate, attempt, result); - return; - } - - double[] next = attempt >= maxAttempts ? null : randomSpotNear(originX, originZ); - if (next == null) { - plugin.getLogger().warning(String.format( - "No valid source location found after %d attempts (last rejection: %s). " - + "Using the last candidate - check your source.spawn-filters settings.", - attempt, rejection.getDescription())); - applySource(hunter, candidate, attempt, result); - return; - } - - attemptPlacement(hunter, originX, originZ, next, attempt + 1, result); - }); - } - - // ==================================== - // Move the hunter's source to specific X/Z coordinates (Y snaps to surface) - // Admin-driven moves bypass the spawn filters on purpose, but a failed - // filter check is logged so the admin knows the spot is normally excluded - // ==================================== - public CompletableFuture placeAt(UUID hunter, double x, double z) { - sources.remove(hunter); - CompletableFuture result = new CompletableFuture<>(); - pending.put(hunter, result); - - loadChunkFor(x, z).whenComplete((chunk, error) -> { - if (pending.get(hunter) != result || !plugin.isEnabled()) { - result.complete(null); - return; - } - if (error != null) { - plugin.getLogger().warning("Failed to load chunk for the requested source location: " + error.getMessage()); - } - - Location location = toSurfaceLocation(x, z); - - SpawnLocationFilter.Rejection rejection = spawnFilter.check(location); - if (rejection != null) { - plugin.getLogger().warning(String.format( - "Source manually moved to a location that fails the spawn filters (%s).", - rejection.getDescription())); - } - - applySource(hunter, location, 1, result); - }); - - return result; - } - - // ==================================== - // Forget the hunter's source; a new one is placed when they next hunt - // ==================================== - public void clear(UUID hunter) { - pending.remove(hunter); - if (sources.remove(hunter) != null) { - save(); - } - } - - // Loads (generating if needed) the chunk containing the given block coords - private CompletableFuture loadChunkFor(double x, double z) { - return config.getWorld().getChunkAtAsync(blockCoord(x) >> 4, blockCoord(z) >> 4, true); - } - - // Y snaps to one block above the highest block in the column - private Location toSurfaceLocation(double x, double z) { - double surfaceY = config.getWorld().getHighestBlockYAt(blockCoord(x), blockCoord(z)) + 1.0; - return new Location(config.getWorld(), x, surfaceY, z); - } - - // floor, not truncation - casting rounds toward zero and lands one block - // off for negative coordinates - private static int blockCoord(double value) { - return (int) Math.floor(value); - } - - private void applySource(UUID hunter, Location location, int attempts, CompletableFuture result) { - pending.remove(hunter); - sources.put(hunter, new Source(location, System.currentTimeMillis())); - save(); - - plugin.getLogger().info(String.format("Source for %s placed at X = %.1f Z = %.1f (%d attempt%s)", - hunter, location.getX(), location.getZ(), attempts, attempts == 1 ? "" : "s")); - result.complete(location); - } - - // ==================================== - // Check if the player is close enough to collect their source - // ==================================== - public void tryCollectSource(Player player, UUID hunter, double distance, EquipmentSlot geigerSlot) { - if (distance > config.getCollectionDistance()) { - return; - } - - Source source = sources.get(hunter); - if (source == null) { - return; - } - - // Out of collections for this window - the source stays put for later - if (!dropLimits.canCollect(player)) { - notifyLimitReached(player); - return; - } - - collectSource(player, hunter, source.location(), geigerSlot); - } - - // ==================================== - // Tell the player when their next collection slot opens up - // ==================================== - private void notifyLimitReached(Player player) { - long now = System.currentTimeMillis(); - Long lastSent = lastLimitMessage.get(player.getUniqueId()); - if (lastSent != null && now - lastSent < LIMIT_MESSAGE_COOLDOWN_MILLIS) { - return; - } - lastLimitMessage.put(player.getUniqueId(), now); - - long waitMillis = dropLimits.getMillisUntilNextDrop(player.getUniqueId()); - - player.sendMessage(config.getMessages().get("player.limit-reached", - "%max%", config.getLimitDrops(), - "%window%", Utils.formatDuration(config.getLimitWindowMillis()), - "%time%", Utils.formatDuration(waitMillis))); - } - - private void collectSource(Player player, UUID hunter, Location collected, EquipmentSlot geigerSlot) { - // No new source yet - the next hold of a live detector places one near the hunter - clear(hunter); - - dropLimits.recordCollection(player); - UsageStats.getInstance().recordSourceCollected(); - Bukkit.getPluginManager().callEvent(new GeigerSourceCollectEvent(player, collected)); - replaceGeigerWithDeadVersion(player, geigerSlot); - notifyPlayerOfCollection(player); - giveReward(player); - } - - // One line: the find, then the spent counter. A blank half is left out. - // A reset between them stops the first half's colour and format carrying over. - private void notifyPlayerOfCollection(Player player) { - String found = config.getMessages().get("player.found-source"); - String spent = config.getMessages().get("player.dead-geiger"); - if (found.isBlank() || spent.isBlank()) { - String only = found.isBlank() ? spent : found; - if (!only.isBlank()) { - player.sendMessage(only); - } - return; - } - player.sendMessage(found + " " + ChatColor.RESET + spent); - } - - private void replaceGeigerWithDeadVersion(Player player, EquipmentSlot geigerSlot) { - // 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(remaining); - } - - // Give dead Geiger Counter - try { - ItemStack deadGeiger = api.getCreator().getItemFromPath(DEAD_GEIGER_PATH).clone(); - deadGeiger.setAmount(1); - giveItemOrDropLeftovers(player, deadGeiger); - player.playSound(player.getLocation(), Sound.ENTITY_ITEM_BREAK, 1f, 1f); - } catch (Exception e) { - plugin.getLogger().warning("Failed to give dead Geiger Counter to " + player.getName() + ": " + e.getMessage()); - } - } - - // ==================================== - // Give a random reward to the player based on tier weights - // ==================================== - private void giveReward(Player player) { - List tiers = config.getTierRewards(); - if (tiers.isEmpty()) { - return; - } - - // Select a tier based on weights - TierReward selectedTier = selectRandomTier(tiers); - if (selectedTier.isEmpty()) { - return; - } - - // Select a random item from the tier - List tierItems = selectedTier.getItems(); - ItemReward randomItem = tierItems.get(random.nextInt(tierItems.size())); - - // Give the item to the player - giveRewardItem(player, randomItem, selectedTier.getTierName()); - } - - // ==================================== - // Select a random tier based on weights - // Uses cumulative weight distribution - // ==================================== - private TierReward selectRandomTier(List tiers) { - // Calculate total weight - double totalWeight = 0.0; - for (TierReward tier : tiers) { - totalWeight += tier.getWeight(); - } - - // Generate random value between 0 and total weight - double randomValue = random.nextDouble() * totalWeight; - - // Find which tier this value falls into - double cumulativeWeight = 0.0; - for (int index = 0; index < tiers.size() - 1; index++) { - TierReward tier = tiers.get(index); - cumulativeWeight += tier.getWeight(); - if (randomValue <= cumulativeWeight) { - return tier; - } - } - - // Every remaining draw selects the final tier, including rounding fallback. - return tiers.getLast(); - } - - // ==================================== - // Give a specific reward item to the player - // ==================================== - private void giveRewardItem(Player player, ItemReward reward, String tierName) { - try { - ItemStack rewardItem = createRewardItem(reward); - 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 - // ==================================== - private ItemStack createRewardItem(ItemReward reward) { - String itemPath = parseItemPath(reward.getOutputItem()); - int amount = reward.getOutputAmount(); - - ItemStack item = api.getCreator().getItemFromPath(itemPath.toLowerCase()); - if (item == null) { - throw new IllegalArgumentException("Invalid item path: " + itemPath); - } - - item = item.clone(); - item.setAmount(amount); - return item; - } - - private String parseItemPath(String rawPath) { - if (rawPath.contains(":")) { - return rawPath.split(":")[0]; - } - return rawPath; - } - - // ===== PERSISTENCE ===== - - // ==================================== - // Read sources.yml. Sources in worlds that are not loaded are dropped. - // ==================================== - public void load() { - sources.clear(); - if (!dataFile.exists()) { - return; - } - - YamlConfiguration data = YamlConfiguration.loadConfiguration(dataFile); - ConfigurationSection section = data.getConfigurationSection(SOURCES_PATH); - if (section == null) { - return; - } - - for (String key : section.getKeys(false)) { - UUID hunter; - try { - hunter = UUID.fromString(key); - } catch (IllegalArgumentException e) { - plugin.getLogger().warning("Skipping invalid UUID in " + DATA_FILE_NAME + ": " + key); - continue; - } - - ConfigurationSection entry = section.getConfigurationSection(key); - if (entry == null) { - continue; - } - long placedAt = entry.getLong("placed"); - World world = Bukkit.getWorld(entry.getString("world", "")); - if (world == null) { - continue; - } - - Location location = new Location(world, entry.getDouble("x"), entry.getDouble("y"), entry.getDouble("z")); - sources.put(hunter, new Source(location, placedAt)); - } - } - - // ==================================== - // Write every source to disk. Called whenever one is placed or cleared. - // ==================================== - public void save() { - YamlConfiguration data = new YamlConfiguration(); - - for (Map.Entry entry : sources.entrySet()) { - String base = SOURCES_PATH + "." + entry.getKey() + "."; - Location location = entry.getValue().location(); - data.set(base + "world", location.getWorld().getName()); - data.set(base + "x", location.getX()); - data.set(base + "y", location.getY()); - data.set(base + "z", location.getZ()); - data.set(base + "placed", entry.getValue().placedAt()); - } - - try { - data.save(dataFile); - } catch (IOException e) { - plugin.getLogger().warning("Failed to save " + DATA_FILE_NAME + ": " + e.getMessage()); - } - } - - private record Source(Location location, long placedAt) { - } -} +package net.tfminecraft.geigercounters.handlers; + +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import org.bukkit.Bukkit; +import org.bukkit.ChatColor; +import org.bukkit.Chunk; +import org.bukkit.HeightMap; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.Particle; +import org.bukkit.Sound; +import org.bukkit.World; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.inventory.EquipmentSlot; +import org.bukkit.inventory.ItemStack; +import org.bukkit.plugin.java.JavaPlugin; +import net.tfminecraft.geigercounters.config.GeigerConfiguration; +import net.tfminecraft.geigercounters.events.GeigerSourceCollectEvent; +import net.tfminecraft.geigercounters.managers.DropLimitManager; +import net.tfminecraft.geigercounters.metrics.UsageStats; +import net.tfminecraft.geigercounters.models.ItemReward; +import net.tfminecraft.geigercounters.models.TierReward; +import net.tfminecraft.geigercounters.utils.Utils; +import net.tfminecraft.geigercounters.validators.SpawnLocationFilter; + +import java.io.File; +import java.io.IOException; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +// ==================================== +// Each hunter's radioactive source: placement, persistence and opening. +// A hunter is the character being played (the account without RPCharacters). +// +// 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 { + + 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 + // the limit message on every click + private static final long LIMIT_MESSAGE_COOLDOWN_MILLIS = 15_000L; + + private static final String DATA_FILE_NAME = "sources.yml"; + private static final String SOURCES_PATH = "sources"; + + // Sources saved before caches were buried sit on the surface; they lack + // this flag and are dropped on load so a buried one replaces them + private static final String BURIED_KEY = "buried"; + + // Highest a tree trunk is followed down to the ground beneath it + private static final int MAX_TRUNK_HEIGHT = 32; + + // Overworld trunks stand on the ground a cache is buried in + private static final Set TRUNKS = EnumSet.of( + Material.OAK_LOG, Material.SPRUCE_LOG, Material.BIRCH_LOG, Material.JUNGLE_LOG, Material.ACACIA_LOG, + Material.DARK_OAK_LOG, Material.MANGROVE_LOG, Material.MANGROVE_ROOTS, Material.CHERRY_LOG, + Material.PALE_OAK_LOG, Material.MUSHROOM_STEM); + + // Random spots tried for one that is both in the ring and inside the area + private static final int SPOT_TRIES = 100; + + private final JavaPlugin plugin; + private final GeigerConfiguration config; + private final ItemAPI api; + private final SpawnLocationFilter spawnFilter; + private final DropLimitManager dropLimits; + private final CacheMenu cacheMenu; + private final File dataFile; + private final Random random = new Random(); + + // Last time each player was told they are rate limited + private final Map lastLimitMessage = new HashMap<>(); + + // Hunter -> their source + private final Map sources = new HashMap<>(); + + // Hunter -> placement still resolving chunk loads + private final Map> pending = new HashMap<>(); + + public SourceHandler(JavaPlugin plugin, GeigerConfiguration config, ItemAPI api, + SpawnLocationFilter spawnFilter, DropLimitManager dropLimits, CacheMenu cacheMenu) { + this.plugin = plugin; + this.config = config; + this.api = api; + this.spawnFilter = spawnFilter; + this.dropLimits = dropLimits; + this.cacheMenu = cacheMenu; + this.dataFile = new File(plugin.getDataFolder(), DATA_FILE_NAME); + } + + // ==================================== + // The hunter's source, or null if they have none + // ==================================== + public Location getSource(UUID hunter) { + Source source = sources.get(hunter); + return source == null ? null : source.location(); + } + + // Whether a placement for this hunter is still resolving + public boolean isPlacing(UUID hunter) { + return pending.containsKey(hunter); + } + + // ==================================== + // Place the hunter's source at a random spot near + // + // Candidates sit between source.min-distance and source.max-distance from + // the origin, inside the configured area, and are rolled until one passes + // the spawn filters (liquid, void, blocked blocks, distance from spawn, + // WorldGuard regions) and can be buried in natural ground. Every + // candidate needs its chunk loaded to read the + // terrain, so attempts run one at a time through getChunkAtAsync instead + // of stalling the main thread. + // + // If no candidate passes within the attempt budget the last one is used + // anyway, so the hunter always gets a source. When no part of the ring lies + // inside the area (the hunter is outside it) nothing is placed, any old + // source is kept, and the future completes with null. + // + // The returned future completes on the main thread with the chosen location. + // ==================================== + public CompletableFuture placeNear(UUID hunter, Location origin) { + // Collapse overlapping requests onto the search already running + CompletableFuture running = pending.get(hunter); + if (running != null) { + return running; + } + + double[] first = randomSpotNear(origin.getX(), origin.getZ()); + if (first == null) { + return CompletableFuture.completedFuture(null); + } + + // Clear the old source for the duration of the search so it cannot be + // collected while attempts are still resolving + sources.remove(hunter); + + CompletableFuture result = new CompletableFuture<>(); + pending.put(hunter, result); + attemptPlacement(hunter, origin.getX(), origin.getZ(), first, 1, result); + return result; + } + + // ==================================== + // A random spot between min-distance and max-distance from the origin that + // is also inside the area, or null if none turns up. Uniform over the + // ring's area, so sources do not bunch at the inner edge. + // ==================================== + private double[] randomSpotNear(double originX, double originZ) { + double min = config.getMinSourceDistance(); + double max = config.getMaxSourceDistance(); + for (int attempt = 0; attempt < SPOT_TRIES; attempt++) { + double angle = random.nextDouble() * 2.0 * Math.PI; + double radius = Math.sqrt(min * min + random.nextDouble() * (max * max - min * min)); + double x = originX + radius * Math.cos(angle); + double z = originZ + radius * Math.sin(angle); + if (x >= config.getMinX() && x <= config.getMaxX() && z >= config.getMinZ() && z <= config.getMaxZ()) { + return new double[] {x, z}; + } + } + return null; + } + + private void attemptPlacement(UUID hunter, double originX, double originZ, double[] spot, int attempt, + CompletableFuture result) { + double x = spot[0]; + double z = spot[1]; + int maxAttempts = config.getSpawnFilters().getMaxAttempts(); + + 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 (pending.get(hunter) != result || !plugin.isEnabled()) { + result.complete(null); + return; + } + if (error != null) { + plugin.getLogger().warning("Failed to load chunk for candidate source location: " + error.getMessage()); + } + + Candidate candidate = candidateAt(x, z); + SpawnLocationFilter.Rejection rejection = candidate.check(spawnFilter); + + if (rejection == null) { + applySource(hunter, candidate.cache(), attempt, result); + return; + } + + double[] next = attempt >= maxAttempts ? null : randomSpotNear(originX, originZ); + if (next == null) { + plugin.getLogger().warning(String.format( + "No valid source location found after %d attempts (last rejection: %s). " + + "Using the last candidate - check your source.spawn-filters settings.", + attempt, rejection.getDescription())); + applySource(hunter, candidate.cache(), attempt, result); + return; + } + + attemptPlacement(hunter, originX, originZ, next, attempt + 1, result); + }); + } + + // ==================================== + // Move the hunter's source to specific X/Z coordinates, buried beneath the ground there + // Admin-driven moves bypass the spawn filters on purpose, but a failed + // filter check is logged so the admin knows the spot is normally excluded + // ==================================== + public CompletableFuture placeAt(UUID hunter, double x, double z) { + sources.remove(hunter); + CompletableFuture result = new CompletableFuture<>(); + pending.put(hunter, result); + + loadChunkFor(x, z).whenComplete((chunk, error) -> { + if (pending.get(hunter) != result || !plugin.isEnabled()) { + result.complete(null); + return; + } + if (error != null) { + plugin.getLogger().warning("Failed to load chunk for the requested source location: " + error.getMessage()); + } + + Candidate candidate = candidateAt(x, z); + + SpawnLocationFilter.Rejection rejection = candidate.check(spawnFilter); + if (rejection != null) { + plugin.getLogger().warning(String.format( + "Source manually moved to a location that fails the spawn filters (%s).", + rejection.getDescription())); + } + + applySource(hunter, candidate.cache(), 1, result); + }); + + return result; + } + + // ==================================== + // Forget the hunter's source; a new one is placed when they next hunt + // ==================================== + public void clear(UUID hunter) { + pending.remove(hunter); + if (sources.remove(hunter) != null) { + save(); + } + } + + // Loads (generating if needed) the chunk containing the given block coords + private CompletableFuture loadChunkFor(double x, double z) { + return config.getWorld().getChunkAtAsync(blockCoord(x) >> 4, blockCoord(z) >> 4, true); + } + + // ==================================== + // A cache buried burial-depth blocks under the ground at x/z. + // + // The column is kept one block inside its chunk, so the cache's neighbours + // are in the chunk already loaded. Leaves are skipped by the height map and + // tree trunks are followed down, so the ground is the soil the tree grows in. + // ==================================== + private Candidate candidateAt(double x, double z) { + World world = config.getWorld(); + int blockX = insideChunk(blockCoord(x)); + int blockZ = insideChunk(blockCoord(z)); + + int groundY = world.getHighestBlockYAt(blockX, blockZ, HeightMap.MOTION_BLOCKING_NO_LEAVES); + int trunkBottom = groundY - MAX_TRUNK_HEIGHT; + while (groundY > trunkBottom && TRUNKS.contains(world.getBlockAt(blockX, groundY, blockZ).getType())) { + groundY--; + } + + int min = config.getMinBurialDepth(); + int depth = min + random.nextInt(config.getMaxBurialDepth() - min + 1); + int cacheY = Math.max(world.getMinHeight(), groundY - depth); + return new Candidate( + new Location(world, blockX + 0.5, groundY + 1.0, blockZ + 0.5), + new Location(world, blockX + 0.5, cacheY + 0.5, blockZ + 0.5)); + } + + // Local block coordinates 0 and 15 are moved one block inwards + static int insideChunk(int blockCoord) { + int local = blockCoord & 15; + if (local == 0) { + return blockCoord + 1; + } + return local == 15 ? blockCoord - 1 : blockCoord; + } + + // floor, not truncation - casting rounds toward zero and lands one block + // off for negative coordinates + private static int blockCoord(double value) { + return (int) Math.floor(value); + } + + private void applySource(UUID hunter, Location location, int attempts, CompletableFuture result) { + pending.remove(hunter); + sources.put(hunter, new Source(location, System.currentTimeMillis())); + 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")); + result.complete(location); + } + + // ==================================== + // The hunter opened their cache: spend the detector and show the loot + // ==================================== + public void open(Player player, UUID hunter, EquipmentSlot geigerSlot) { + Source source = sources.get(hunter); + if (source == null) { + return; + } + + // Out of finds for this window - the cache stays put for later + if (!dropLimits.canCollect(player)) { + notifyLimitReached(player); + return; + } + + collectSource(player, hunter, source.location(), geigerSlot); + } + + // ==================================== + // Tell the player when their next collection slot opens up + // ==================================== + private void notifyLimitReached(Player player) { + long now = System.currentTimeMillis(); + Long lastSent = lastLimitMessage.get(player.getUniqueId()); + if (lastSent != null && now - lastSent < LIMIT_MESSAGE_COOLDOWN_MILLIS) { + return; + } + lastLimitMessage.put(player.getUniqueId(), now); + + long waitMillis = dropLimits.getMillisUntilNextDrop(player.getUniqueId()); + + player.sendMessage(config.getMessages().get("player.limit-reached", + "%max%", config.getLimitDrops(), + "%window%", Utils.formatDuration(config.getLimitWindowMillis()), + "%time%", Utils.formatDuration(waitMillis))); + } + + private void collectSource(Player player, UUID hunter, Location collected, EquipmentSlot geigerSlot) { + // No new source yet - the next hold of a live detector places one near the hunter + clear(hunter); + + dropLimits.recordCollection(player); + UsageStats.getInstance().recordSourceCollected(); + Bukkit.getPluginManager().callEvent(new GeigerSourceCollectEvent(player, collected)); + replaceGeigerWithDeadVersion(player, geigerSlot); + notifyPlayerOfCollection(player); + + player.playSound(collected, Sound.BLOCK_CHEST_OPEN, 1f, 1f); + player.spawnParticle(Particle.WITCH, collected, 30, 0.4, 0.4, 0.4, 0.0); + cacheMenu.open(player, rollReward(player)); + } + + // One line: the find, then the spent counter. A blank half is left out. + // A reset between them stops the first half's colour and format carrying over. + private void notifyPlayerOfCollection(Player player) { + String found = config.getMessages().get("player.found-source"); + String spent = config.getMessages().get("player.dead-geiger"); + if (found.isBlank() || spent.isBlank()) { + String only = found.isBlank() ? spent : found; + if (!only.isBlank()) { + player.sendMessage(only); + } + return; + } + player.sendMessage(found + " " + ChatColor.RESET + spent); + } + + private void replaceGeigerWithDeadVersion(Player player, EquipmentSlot geigerSlot) { + // 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(remaining); + } + + // Give dead Geiger Counter + try { + ItemStack deadGeiger = api.getCreator().getItemFromPath(DEAD_GEIGER_PATH).clone(); + deadGeiger.setAmount(1); + giveItemOrDropLeftovers(player, deadGeiger); + player.playSound(player.getLocation(), Sound.ENTITY_ITEM_BREAK, 1f, 1f); + } catch (Exception e) { + plugin.getLogger().warning("Failed to give dead Geiger Counter to " + player.getName() + ": " + e.getMessage()); + } + } + + // ==================================== + // A random reward based on tier weights, or null when there is none + // ==================================== + private ItemStack rollReward(Player player) { + List tiers = config.getTierRewards(); + if (tiers.isEmpty()) { + return null; + } + + // Select a tier based on weights + TierReward selectedTier = selectRandomTier(tiers); + if (selectedTier.isEmpty()) { + return null; + } + + // Select a random item from the tier + List tierItems = selectedTier.getItems(); + ItemReward randomItem = tierItems.get(random.nextInt(tierItems.size())); + return rewardItem(player, randomItem, selectedTier.getTierName()); + } + + // ==================================== + // Select a random tier based on weights + // Uses cumulative weight distribution + // ==================================== + private TierReward selectRandomTier(List tiers) { + // Calculate total weight + double totalWeight = 0.0; + for (TierReward tier : tiers) { + totalWeight += tier.getWeight(); + } + + // Generate random value between 0 and total weight + double randomValue = random.nextDouble() * totalWeight; + + // Find which tier this value falls into + double cumulativeWeight = 0.0; + for (int index = 0; index < tiers.size() - 1; index++) { + TierReward tier = tiers.get(index); + cumulativeWeight += tier.getWeight(); + if (randomValue <= cumulativeWeight) { + return tier; + } + } + + // Every remaining draw selects the final tier, including rounding fallback. + return tiers.getLast(); + } + + // ==================================== + // Build a specific reward item, or null if its path does not resolve + // ==================================== + private ItemStack rewardItem(Player player, ItemReward reward, String tierName) { + try { + ItemStack rewardItem = createRewardItem(reward); + plugin.getLogger().info(player.getName() + " received " + tierName + " reward: " + reward.getOutputItem()); + return rewardItem; + } catch (Exception e) { + plugin.getLogger().warning("Failed to give reward to " + player.getName() + ": " + e.getMessage()); + return null; + } + } + + static 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 + // ==================================== + private ItemStack createRewardItem(ItemReward reward) { + String itemPath = parseItemPath(reward.getOutputItem()); + int amount = reward.getOutputAmount(); + + ItemStack item = api.getCreator().getItemFromPath(itemPath.toLowerCase()); + if (item == null) { + throw new IllegalArgumentException("Invalid item path: " + itemPath); + } + + item = item.clone(); + item.setAmount(amount); + return item; + } + + private String parseItemPath(String rawPath) { + if (rawPath.contains(":")) { + return rawPath.split(":")[0]; + } + return rawPath; + } + + // ===== PERSISTENCE ===== + + // ==================================== + // Read sources.yml. Sources in worlds that are not loaded, and surface + // sources from before caches were buried, are dropped. + // ==================================== + public void load() { + sources.clear(); + int surfaceSources = 0; + if (!dataFile.exists()) { + return; + } + + YamlConfiguration data = YamlConfiguration.loadConfiguration(dataFile); + ConfigurationSection section = data.getConfigurationSection(SOURCES_PATH); + if (section == null) { + return; + } + + for (String key : section.getKeys(false)) { + UUID hunter; + try { + hunter = UUID.fromString(key); + } catch (IllegalArgumentException e) { + plugin.getLogger().warning("Skipping invalid UUID in " + DATA_FILE_NAME + ": " + key); + continue; + } + + ConfigurationSection entry = section.getConfigurationSection(key); + if (entry == null) { + continue; + } + if (!entry.getBoolean(BURIED_KEY)) { + surfaceSources++; + continue; + } + long placedAt = entry.getLong("placed"); + World world = Bukkit.getWorld(entry.getString("world", "")); + if (world == null) { + continue; + } + + Location location = new Location(world, entry.getDouble("x"), entry.getDouble("y"), entry.getDouble("z")); + sources.put(hunter, new Source(location, placedAt)); + } + + if (surfaceSources > 0) { + plugin.getLogger().info("Dropped " + surfaceSources + " surface source(s) from before caches were buried;" + + " their hunters get a buried cache on their next hold."); + } + } + + // ==================================== + // Write every source to disk. Called whenever one is placed or cleared. + // ==================================== + public void save() { + YamlConfiguration data = new YamlConfiguration(); + + for (Map.Entry entry : sources.entrySet()) { + String base = SOURCES_PATH + "." + entry.getKey() + "."; + Location location = entry.getValue().location(); + data.set(base + "world", location.getWorld().getName()); + data.set(base + "x", location.getX()); + data.set(base + "y", location.getY()); + data.set(base + "z", location.getZ()); + data.set(base + "placed", entry.getValue().placedAt()); + data.set(base + BURIED_KEY, true); + } + + try { + data.save(dataFile); + } catch (IOException e) { + plugin.getLogger().warning("Failed to save " + DATA_FILE_NAME + ": " + e.getMessage()); + } + } + + private record Source(Location location, long placedAt) { + } + + // Where the hunter stands over the cache, and the cache itself + private record Candidate(Location surface, Location cache) { + + // The surface rules look at the ground; the burial rule at the cache + SpawnLocationFilter.Rejection check(SpawnLocationFilter filter) { + SpawnLocationFilter.Rejection rejection = filter.check(surface); + return rejection != null ? rejection : filter.checkBuried(cache.getBlock()); + } + } +} diff --git a/src/main/java/net/tfminecraft/geigercounters/managers/GeigerManager.java b/src/main/java/net/tfminecraft/geigercounters/managers/GeigerManager.java index cb908a5..d78048b 100644 --- a/src/main/java/net/tfminecraft/geigercounters/managers/GeigerManager.java +++ b/src/main/java/net/tfminecraft/geigercounters/managers/GeigerManager.java @@ -1,224 +1,281 @@ -package net.tfminecraft.geigercounters.managers; - -import net.tfminecraft.tlibs.objects.api.ItemAPI; -import net.tfminecraft.tlibs.TLibs; -import org.bukkit.Bukkit; -import org.bukkit.Location; -import org.bukkit.World; -import org.bukkit.entity.Player; -import org.bukkit.inventory.EquipmentSlot; -import org.bukkit.plugin.java.JavaPlugin; -import net.tfminecraft.geigercounters.config.GeigerConfiguration; -import net.tfminecraft.geigercounters.handlers.GeigerClickPlayer; -import net.tfminecraft.geigercounters.handlers.ParticleRenderer; -import net.tfminecraft.geigercounters.handlers.SignalBar; -import net.tfminecraft.geigercounters.handlers.SourceHandler; -import net.tfminecraft.geigercounters.validators.GeigerValidator; -import net.tfminecraft.geigercounters.utils.Hunters; -import net.tfminecraft.geigercounters.validators.SpawnLocationFilter; - -import java.util.HashSet; -import java.util.Optional; -import java.util.Set; -import java.util.UUID; - -// ==================================== -// Main manager for the Geiger Counter system -// Coordinates between configuration, validation, particles, and source handling -// ==================================== -public class GeigerManager { - - private static final long CHECK_INTERVAL_TICKS = 5L; - - private static GeigerManager instance; - private final JavaPlugin plugin; - - // ===== COMPONENTS ===== - private GeigerConfiguration configuration; - private GeigerValidator validator; - private SpawnLocationFilter spawnFilter; - private ParticleRenderer particleRenderer; - private GeigerClickPlayer clickPlayer; - private SignalBar signalBar; - private SourceHandler sourceHandler; - private DropLimitManager dropLimitManager; - private ItemAPI api; - - // ===== INITIALIZATION ===== - - private GeigerManager(JavaPlugin plugin) { - this.plugin = plugin; - } - - public static GeigerManager getInstance(JavaPlugin plugin) { - if (instance == null) { - instance = new GeigerManager(plugin); - } - return instance; - } - - public static GeigerManager getInstance() { - return instance; - } - - // ==================================== - // Initialize the Geiger Counter system - // ==================================== - public void initialize() { - api = TLibs.getItemAPI(); - - configuration = new GeigerConfiguration(plugin); - configuration.load(); - - validator = new GeigerValidator(plugin, api); - - particleRenderer = new ParticleRenderer(configuration); - - clickPlayer = new GeigerClickPlayer(configuration); - - signalBar = new SignalBar(configuration); - - spawnFilter = new SpawnLocationFilter(plugin, configuration); - - dropLimitManager = new DropLimitManager(plugin, configuration); - dropLimitManager.load(); - - sourceHandler = new SourceHandler(plugin, configuration, api, spawnFilter, dropLimitManager); - sourceHandler.load(); - - // Start player checking task - startPlayerCheckTask(); - - // Clicks roll every tick so close range can crackle instead of tick - startClickTask(); - - plugin.getLogger().info("Geiger Counter plugin has been enabled."); - } - - // ==================================== - // Reload configuration from disk - // ==================================== - public void reload() { - plugin.reloadConfig(); - configuration.load(); - } - - // ==================================== - // Flush drop limits and sources to disk on shutdown - // ==================================== - public void shutdown() { - if (clickPlayer != null) { - clickPlayer.clear(); - } - if (signalBar != null) { - signalBar.clear(); - } - if (dropLimitManager != null) { - dropLimitManager.save(); - } - if (sourceHandler != null) { - sourceHandler.save(); - } - } - - public JavaPlugin getPlugin() { - return plugin; - } - - public GeigerClickPlayer getClickPlayer() { - return clickPlayer; - } - - public DropLimitManager getDropLimitManager() { - return dropLimitManager; - } - - public SourceHandler getSourceHandler() { - return sourceHandler; - } - - public GeigerConfiguration getConfiguration() { - return configuration; - } - - private void startPlayerCheckTask() { - Bukkit.getScheduler().runTaskTimer(plugin, this::checkAllPlayers, 0L, CHECK_INTERVAL_TICKS); - } - - private void startClickTask() { - Bukkit.getScheduler().runTaskTimer(plugin, clickPlayer::tick, 0L, 1L); - } - - // ===== PLAYER CHECKING ===== - - // ==================================== - // Check every online player in the source world holding a Geiger Counter - // in either hand - // ==================================== - private void checkAllPlayers() { - World world = configuration.getWorld(); - Set hunting = new HashSet<>(); - - for (Player player : Bukkit.getOnlinePlayers()) { - if (world == null || !world.equals(player.getWorld())) { - continue; - } - EquipmentSlot geigerSlot = findGeigerSlot(player); - if (geigerSlot == null) { - continue; - } - Optional hunter = Hunters.of(player); - if (hunter.isPresent() && handlePlayerWithGeiger(player, hunter.get(), geigerSlot)) { - hunting.add(player.getUniqueId()); - } - } - - signalBar.retain(hunting); - } - - // Returns the hand holding a Geiger Counter, or null if neither - private EquipmentSlot findGeigerSlot(Player player) { - if (validator.isGeigerCounter(player.getInventory().getItemInMainHand())) { - return EquipmentSlot.HAND; - } - if (validator.isGeigerCounter(player.getInventory().getItemInOffHand())) { - return EquipmentSlot.OFF_HAND; - } - return null; - } - - // ==================================== - // Returns whether the player has a source to read. One is placed near - // them when they have none, or have strayed beyond reroll-distance. - // ==================================== - private boolean handlePlayerWithGeiger(Player player, UUID hunter, EquipmentSlot geigerSlot) { - Location source = sourceHandler.getSource(hunter); - if (source == null) { - if (!sourceHandler.isPlacing(hunter)) { - sourceHandler.placeNear(hunter, player.getLocation()); - } - return false; - } - - double distance = calculateHorizontalDistance(player.getLocation(), source); - if (!player.getWorld().equals(source.getWorld()) || distance > configuration.getRerollDistance()) { - sourceHandler.placeNear(hunter, player.getLocation()); - return false; - } - - particleRenderer.showParticleEffect(player, distance); - clickPlayer.updateRate(player, distance); - signalBar.update(player, source, distance); - sourceHandler.tryCollectSource(player, hunter, distance, geigerSlot); - return true; - } - - // ===== DISTANCE CALCULATION ===== - - // Calculate horizontal distance between two locations (ignores Y axis) - private double calculateHorizontalDistance(Location from, Location to) { - double deltaX = from.getX() - to.getX(); - double deltaZ = from.getZ() - to.getZ(); - return Math.sqrt(deltaX * deltaX + deltaZ * deltaZ); - } -} +package net.tfminecraft.geigercounters.managers; + +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import net.tfminecraft.tlibs.TLibs; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.bukkit.inventory.EquipmentSlot; +import org.bukkit.plugin.java.JavaPlugin; +import net.tfminecraft.geigercounters.config.GeigerConfiguration; +import net.tfminecraft.geigercounters.handlers.BuriedChest; +import net.tfminecraft.geigercounters.handlers.CacheMenu; +import net.tfminecraft.geigercounters.handlers.GeigerClickPlayer; +import net.tfminecraft.geigercounters.handlers.ParticleRenderer; +import net.tfminecraft.geigercounters.handlers.SignalBar; +import net.tfminecraft.geigercounters.handlers.SourceHandler; +import net.tfminecraft.geigercounters.validators.GeigerValidator; +import net.tfminecraft.geigercounters.utils.Hunters; +import net.tfminecraft.geigercounters.validators.SpawnLocationFilter; + +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +// ==================================== +// Main manager for the Geiger Counter system +// Coordinates between configuration, validation, particles, and source handling +// ==================================== +public class GeigerManager implements BuriedChest.Actions { + + private static final long CHECK_INTERVAL_TICKS = 5L; + + private static GeigerManager instance; + private final JavaPlugin plugin; + + // ===== COMPONENTS ===== + private GeigerConfiguration configuration; + private GeigerValidator validator; + private SpawnLocationFilter spawnFilter; + private ParticleRenderer particleRenderer; + private GeigerClickPlayer clickPlayer; + private SignalBar signalBar; + private SourceHandler sourceHandler; + private BuriedChest buriedChest; + private CacheMenu cacheMenu; + private DropLimitManager dropLimitManager; + private ItemAPI api; + + // ===== INITIALIZATION ===== + + private GeigerManager(JavaPlugin plugin) { + this.plugin = plugin; + } + + public static GeigerManager getInstance(JavaPlugin plugin) { + if (instance == null) { + instance = new GeigerManager(plugin); + } + return instance; + } + + public static GeigerManager getInstance() { + return instance; + } + + // ==================================== + // Initialize the Geiger Counter system + // ==================================== + public void initialize() { + api = TLibs.getItemAPI(); + + configuration = new GeigerConfiguration(plugin); + configuration.load(); + + validator = new GeigerValidator(plugin, api); + + particleRenderer = new ParticleRenderer(configuration); + + clickPlayer = new GeigerClickPlayer(configuration); + + signalBar = new SignalBar(configuration); + + spawnFilter = new SpawnLocationFilter(plugin, configuration); + + dropLimitManager = new DropLimitManager(plugin, configuration); + dropLimitManager.load(); + + cacheMenu = new CacheMenu(configuration); + + sourceHandler = new SourceHandler(plugin, configuration, api, spawnFilter, dropLimitManager, cacheMenu); + sourceHandler.load(); + + buriedChest = new BuriedChest(this); + Bukkit.getPluginManager().registerEvents(buriedChest, plugin); + Bukkit.getPluginManager().registerEvents(cacheMenu, plugin); + + // Start player checking task + startPlayerCheckTask(); + + // Clicks roll every tick so close range can crackle instead of tick + startClickTask(); + + plugin.getLogger().info("Geiger Counter plugin has been enabled."); + } + + // ==================================== + // Reload configuration from disk + // ==================================== + public void reload() { + plugin.reloadConfig(); + configuration.load(); + } + + // ==================================== + // Flush drop limits and sources to disk on shutdown + // ==================================== + public void shutdown() { + if (clickPlayer != null) { + clickPlayer.clear(); + } + if (signalBar != null) { + signalBar.clear(); + } + if (buriedChest != null) { + buriedChest.clear(); + } + if (cacheMenu != null) { + cacheMenu.closeAll(); + } + if (dropLimitManager != null) { + dropLimitManager.save(); + } + if (sourceHandler != null) { + sourceHandler.save(); + } + } + + public JavaPlugin getPlugin() { + return plugin; + } + + public GeigerClickPlayer getClickPlayer() { + return clickPlayer; + } + + public DropLimitManager getDropLimitManager() { + return dropLimitManager; + } + + public SourceHandler getSourceHandler() { + return sourceHandler; + } + + public GeigerConfiguration getConfiguration() { + return configuration; + } + + private void startPlayerCheckTask() { + Bukkit.getScheduler().runTaskTimer(plugin, this::checkAllPlayers, 0L, CHECK_INTERVAL_TICKS); + } + + private void startClickTask() { + Bukkit.getScheduler().runTaskTimer(plugin, clickPlayer::tick, 0L, 1L); + } + + // ===== PLAYER CHECKING ===== + + // ==================================== + // Check every online player in the source world holding a Geiger Counter + // in either hand + // ==================================== + private void checkAllPlayers() { + World world = configuration.getWorld(); + Set hunting = new HashSet<>(); + Set revealed = new HashSet<>(); + + for (Player player : Bukkit.getOnlinePlayers()) { + if (world == null || !world.equals(player.getWorld())) { + continue; + } + EquipmentSlot geigerSlot = findGeigerSlot(player); + if (geigerSlot == null) { + continue; + } + Optional hunter = Hunters.of(player); + if (hunter.isPresent() && handlePlayerWithGeiger(player, hunter.get(), revealed)) { + hunting.add(player.getUniqueId()); + } + } + + signalBar.retain(hunting); + buriedChest.retain(revealed); + } + + // Returns the hand holding a Geiger Counter, or null if neither + private EquipmentSlot findGeigerSlot(Player player) { + if (validator.isGeigerCounter(player.getInventory().getItemInMainHand())) { + return EquipmentSlot.HAND; + } + if (validator.isGeigerCounter(player.getInventory().getItemInOffHand())) { + return EquipmentSlot.OFF_HAND; + } + return null; + } + + // ==================================== + // Returns whether the player has a source to read. One is placed near + // them when they have none, or have strayed 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()); + } + return false; + } + + double distance = calculateHorizontalDistance(player.getLocation(), source); + if (!player.getWorld().equals(source.getWorld()) || distance > configuration.getRerollDistance()) { + sourceHandler.placeNear(hunter, player.getLocation()); + return false; + } + + particleRenderer.showParticleEffect(player, distance); + + // Depth counts once the hunter is close enough to start digging + double reach = player.getLocation().distance(source); + if (reach <= configuration.getPinpointRange()) { + clickPlayer.updatePinpoint(player, reach); + signalBar.pinpoint(player, reach); + } else { + clickPlayer.updateRate(player, distance); + signalBar.update(player, source, distance); + } + + if (reach <= BuriedChest.REVEAL_DISTANCE) { + buriedChest.show(player, source); + revealed.add(player.getUniqueId()); + } + return true; + } + + // ==================================== + // The hunter clicked their cache, which only shows while they hold a detector + // ==================================== + @Override + public void open(Player player) { + Optional hunter = Hunters.of(player); + EquipmentSlot geigerSlot = findGeigerSlot(player); + if (hunter.isEmpty() || geigerSlot == null) { + return; + } + sourceHandler.open(player, hunter.get(), geigerSlot); + } + + // ==================================== + // The ground over the cache cannot be dug, so bury it somewhere else + // ==================================== + @Override + public void groundProtected(Player player) { + Optional hunter = Hunters.of(player); + if (hunter.isEmpty()) { + return; + } + player.sendMessage(configuration.getMessages().get("player.cache-protected")); + sourceHandler.placeNear(hunter.get(), player.getLocation()); + } + + // ===== DISTANCE CALCULATION ===== + + // Calculate horizontal distance between two locations (ignores Y axis) + private double calculateHorizontalDistance(Location from, Location to) { + double deltaX = from.getX() - to.getX(); + double deltaZ = from.getZ() - to.getZ(); + return Math.sqrt(deltaX * deltaX + deltaZ * deltaZ); + } +} diff --git a/src/main/java/net/tfminecraft/geigercounters/validators/SpawnLocationFilter.java b/src/main/java/net/tfminecraft/geigercounters/validators/SpawnLocationFilter.java index 9559f5c..d0569dc 100644 --- a/src/main/java/net/tfminecraft/geigercounters/validators/SpawnLocationFilter.java +++ b/src/main/java/net/tfminecraft/geigercounters/validators/SpawnLocationFilter.java @@ -5,14 +5,19 @@ import org.bukkit.Material; import org.bukkit.World; import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; import org.bukkit.plugin.java.JavaPlugin; import net.tfminecraft.geigercounters.config.GeigerConfiguration; import net.tfminecraft.geigercounters.hooks.WorldGuardHook; +import java.util.EnumSet; +import java.util.Set; + // ==================================== // Decides whether a candidate source location is allowed to be used // Rejects water/lava, void (no ground), blacklisted blocks, locations too -// close to world spawn, and locations inside blacklisted WorldGuard regions +// close to world spawn, locations inside blacklisted WorldGuard regions, and +// caches that would not sit sealed inside natural ground // ==================================== public class SpawnLocationFilter { @@ -24,7 +29,8 @@ public enum Rejection { LIQUID("ground is water or lava"), BLOCKED_BLOCK("ground block is blacklisted"), TOO_CLOSE_TO_SPAWN("too close to world spawn"), - WORLDGUARD_REGION("inside a blacklisted WorldGuard region"); + WORLDGUARD_REGION("inside a blacklisted WorldGuard region"), + NOT_BURIED("cache not sealed in natural ground"); private final String description; @@ -37,6 +43,25 @@ public String getDescription() { } } + // Untouched terrain a cache may be buried in. Player builds are left out, + // so a cache never lands under, or inside the walls of, someone's base. + private static final Set NATURAL_GROUND = EnumSet.of( + Material.STONE, Material.GRANITE, Material.DIORITE, Material.ANDESITE, Material.DEEPSLATE, + Material.TUFF, Material.CALCITE, Material.DRIPSTONE_BLOCK, + Material.DIRT, Material.COARSE_DIRT, Material.ROOTED_DIRT, Material.GRASS_BLOCK, Material.PODZOL, + Material.MYCELIUM, Material.MUD, Material.MOSS_BLOCK, Material.CLAY, Material.GRAVEL, + Material.SAND, Material.RED_SAND, Material.SANDSTONE, Material.RED_SANDSTONE, Material.SNOW_BLOCK, + Material.TERRACOTTA, Material.WHITE_TERRACOTTA, Material.ORANGE_TERRACOTTA, Material.YELLOW_TERRACOTTA, + Material.LIGHT_GRAY_TERRACOTTA, Material.BROWN_TERRACOTTA, Material.RED_TERRACOTTA, + Material.COAL_ORE, Material.IRON_ORE, Material.COPPER_ORE, Material.GOLD_ORE, Material.REDSTONE_ORE, + Material.LAPIS_ORE, Material.DIAMOND_ORE, Material.EMERALD_ORE, + Material.DEEPSLATE_COAL_ORE, Material.DEEPSLATE_IRON_ORE, Material.DEEPSLATE_COPPER_ORE, + Material.DEEPSLATE_GOLD_ORE, Material.DEEPSLATE_REDSTONE_ORE, Material.DEEPSLATE_LAPIS_ORE, + Material.DEEPSLATE_DIAMOND_ORE, Material.DEEPSLATE_EMERALD_ORE); + + private static final BlockFace[] SIDES = { + BlockFace.UP, BlockFace.DOWN, BlockFace.NORTH, BlockFace.SOUTH, BlockFace.EAST, BlockFace.WEST}; + private final JavaPlugin plugin; private final GeigerConfiguration config; @@ -76,6 +101,26 @@ public Rejection check(Location location) { return null; } + // ==================================== + // Returns null if a cache at would sit in natural ground with + // natural ground on all six sides, so nobody sees it without digging + // ==================================== + public Rejection checkBuried(Block cache) { + if (!isNaturalGround(cache)) { + return Rejection.NOT_BURIED; + } + for (BlockFace side : SIDES) { + if (!isNaturalGround(cache.getRelative(side))) { + return Rejection.NOT_BURIED; + } + } + return null; + } + + public static boolean isNaturalGround(Block block) { + return NATURAL_GROUND.contains(block.getType()); + } + // ==================================== // Ground checks: void, liquid, and blacklisted materials // The source stands at , so the ground is the block below it diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index ac2704c..bb21dac 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -1,380 +1,392 @@ -# 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: 6 - -# ==================================== -# Source location settings -# Each character hunts their own source. It is placed near the character -# when they first hold a live detector, and stays put until they find it. -# ==================================== -source: - # World name where sources spawn. Detectors only read in this world. - world: world - - # Sources always stay inside this area, given by two corners - # Top-left corner (smaller X, smaller Z) - top-left: - x: -1000.0 - z: -1000.0 - - # Bottom-right corner (larger X, larger Z) - bottom-right: - x: 1000.0 - z: 1000.0 - - # How far (blocks) from the character a new source is placed - min-distance: 150.0 - max-distance: 500.0 - - # 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 - - # ==================================== - # Rules deciding where the source is allowed to spawn - # A random spot is re-rolled until it passes every enabled rule - # ==================================== - spawn-filters: - # How many spots to try before giving up and using the last one anyway. - # Chunks load asynchronously, so a high value costs search time (the source - # is briefly inactive) rather than main-thread lag. - max-attempts: 50 - - # Reject spots where the source would land on water or lava - # (also catches waterlogged blocks, kelp, seagrass, etc.) - reject-liquid: true - - # Reject spots with no ground in the column (void / empty chunks) - reject-void: true - - # Extra ground blocks the source may never spawn on - # Uses vanilla material names, e.g. MAGMA_BLOCK, POWDER_SNOW - blocked-blocks: [] - - # Minimum horizontal distance (blocks) from the world spawn point - # Set to 0 to disable - min-distance-from-spawn: 250.0 - - # WorldGuard region blacklist (ignored when WorldGuard is not installed) - worldguard: - enabled: true - # Region IDs the source may not spawn inside - blacklisted-regions: [] - -# ==================================== -# Detection settings -# ==================================== -detection: - # Maximum distance to collect the radioactive source (blocks) - collection-distance: 20.0 - - # Maximum distance for Geiger Counter detection (blocks) - max-detection-distance: 1000.0 - - # Distance threshold for close/far range colors (blocks) - close-range-threshold: 200.0 - - # Ring count thresholds - number of particle rings based on distance - ring-thresholds: - three-rings: 100.0 # Show 3 rings when closer than this distance - two-rings: 300.0 # Show 2 rings when closer than this distance (otherwise 1 ring) - - -# ==================================== -# Signal bar -# A boss bar shown while holding a detector. It fills as the player gets -# closer and peaks when they face their source. -# ==================================== -bar: - enabled: true - - # Distance (blocks) at which the bar reads empty - range: 750.0 - - # How much facing the source matters: 0 = distance only, 1 = facing only - facing-weight: 0.6 - - -# ==================================== -# Geiger clicking sound -# Distance controls how FAST the counter clicks, not the pitch - that is what -# a real Geiger-Muller tube does. Clicks are rolled per tick, so their spacing -# is irregular instead of metronome-even. -# Only the player holding the counter hears it. -# ==================================== -sound: - enabled: true - - # Sound event ID (see https://minecraftsounds.com to preview) - # Other good clicks: block.dispenser.fail, block.comparator.click - sound: block.note_block.hat - - # 0.0 - 1.0 for a personal readout; higher carries further - volume: 0.35 - - # Base pitch (0.5 - 2.0) and how far each click may wander from it, - # so repeats do not sound like a loop - pitch: 1.7 - pitch-variance: 0.15 - - # Clicks per second at max-detection-distance and at the source itself. - # 20 is the ceiling - one click per tick - min-rate: 0.5 - max-rate: 18.0 - - # Higher values keep the clicking slow until the player is close, - # then ramp hard. 1.0 is a straight line - curve: 2.0 - - -# ==================================== -# Per-player drop limits -# A player may collect at most sources within any