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..4f05b83 --- /dev/null +++ b/src/main/java/net/tfminecraft/geigercounters/handlers/BuriedChest.java @@ -0,0 +1,192 @@ +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's column, from just below the + // cache up to the surface over it, means the ground is protected + private static final double PROTECTED_DIG_DISTANCE = 3.0; + private static final int PROTECTED_DIG_BELOW = 3; + private static final int PROTECTED_DIG_ABOVE = 16; + + 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 the chest at + void open(Player player, Location block); + + // 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()); + } + + // ==================================== + // Put the real block back for this player now, when their cache moves + // ==================================== + public void hide(Player player) { + Shown current = shown.remove(player.getUniqueId()); + if (current != null) { + revert(player, current.block); + } + } + + // 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(), clicked.getLocation()); + } + + // 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())) { + return; + } + int height = dug.getBlockY() - current.block.getBlockY(); + double across = Math.hypot(dug.getX() - current.block.getX(), dug.getZ() - current.block.getZ()); + if (across > PROTECTED_DIG_DISTANCE || height < -PROTECTED_DIG_BELOW || height > PROTECTED_DIG_ABOVE) { + 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..cd44d46 --- /dev/null +++ b/src/main/java/net/tfminecraft/geigercounters/handlers/CacheMenu.java @@ -0,0 +1,149 @@ +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.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.PlayerDeathEvent; +import org.bukkit.event.inventory.InventoryCloseEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +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. A player who +// dies with it open loses it with the rest of their things, grave included. +// ==================================== +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); + } + // Another plugin may refuse the window; the reward is theirs regardless + if (player.openInventory(inventory) == null) { + empty(player, inventory); + } + } + + @EventHandler + public void onClose(InventoryCloseEvent event) { + if (event.getInventory().getHolder() instanceof Holder && event.getPlayer() instanceof Player player) { + empty(player, event.getInventory()); + } + } + + // The window is still open when the death drops are gathered, and closing + // it afterwards would return its contents to an inventory about to be + // cleared. Lowest priority, so they are in place before anything else acts: + // put in the inventory for graves (RPCharacters snapshots it, then clears + // the drops) and kept inventories, and in the drops for a plain death. + // + // Only empty slots are used. Paper's drops are the inventory's own stacks, + // so merging into one would grow its drop too, and the drop entries + // themselves are left alone so Paper spawns them as usual. Whatever has no + // free slot falls where the player died. Known limit: with no grave, an + // added drop that another plugin refuses to spawn (WorldGuard item-drop + // deny) is lost; no TFMC region sets that flag. + @EventHandler(priority = EventPriority.LOWEST) + public void onDeath(PlayerDeathEvent event) { + Player player = event.getEntity(); + Inventory top = player.getOpenInventory().getTopInventory(); + if (!(top.getHolder() instanceof Holder)) { + return; + } + PlayerInventory inventory = player.getInventory(); + ItemStack[] storage = inventory.getStorageContents(); + int slot = 0; + for (ItemStack item : take(top)) { + while (slot < storage.length && storage[slot] != null && !storage[slot].isEmpty()) { + slot++; + } + if (slot == storage.length) { + player.getWorld().dropItemNaturally(player.getLocation(), item); + continue; + } + // Storage contents are slots 0-35, in order + inventory.setItem(slot, item); + storage[slot] = item; + if (!event.getKeepInventory()) { + event.getDrops().add(item.clone()); + } + } + } + + // ==================================== + // 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) { + for (ItemStack item : take(inventory)) { + SourceHandler.giveItemOrDropLeftovers(player, item); + } + } + + // Everything in the window, which is left empty + private static List take(Inventory inventory) { + List left = new ArrayList<>(); + for (ItemStack item : inventory.getContents()) { + if (item != null && !item.isEmpty()) { + left.add(item); + } + } + inventory.clear(); + return left; + } + + // 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..dc0a052 100644 --- a/src/main/java/net/tfminecraft/geigercounters/handlers/SourceHandler.java +++ b/src/main/java/net/tfminecraft/geigercounters/handlers/SourceHandler.java @@ -1,516 +1,620 @@ -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; + + // Trunks and natural cover standing on the ground a cache is buried in + private static final Set OVERGROWTH = 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, Material.SNOW, Material.MOSS_CARPET, + Material.PALE_MOSS_CARPET, Material.CACTUS, Material.BAMBOO, Material.PUMPKIN, Material.MELON); + + // When no spot near a hunter holds a sealed cache, how long before trying again + private static final long RETRY_MILLIS = 60_000L; + + // 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<>(); + + // Hunter -> when a search that found no sealed spot may run again + private final Map retryAt = 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 that was + // sealed in natural ground is used anyway. A cache is never placed where it + // would show, so with no sealed candidate at all the hunter gets nothing + // and the search waits a minute before running again. 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; + } + Long retry = retryAt.get(hunter); + if (retry != null && System.currentTimeMillis() < retry) { + return CompletableFuture.completedFuture(null); + } + + 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, null, 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; + } + + // is the last candidate so far that was sealed in natural ground + private void attemptPlacement(UUID hunter, double originX, double originZ, double[] spot, int attempt, + Location sealed, 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 burial = candidate.checkBuried(spawnFilter); + SpawnLocationFilter.Rejection rejection = burial != null ? burial : spawnFilter.check(candidate.surface()); + Location fallback = burial == null ? candidate.cache() : sealed; + + if (rejection == null) { + applySource(hunter, candidate.cache(), attempt, result); + return; + } + + double[] next = attempt >= maxAttempts ? null : randomSpotNear(originX, originZ); + if (next != null) { + attemptPlacement(hunter, originX, originZ, next, attempt + 1, fallback, result); + return; + } + + if (fallback == null) { + plugin.getLogger().warning(String.format( + "No sealed source location found for %s after %d attempts (last rejection: %s). " + + "Trying again in a minute.", hunter, attempt, rejection.getDescription())); + pending.remove(hunter); + retryAt.put(hunter, System.currentTimeMillis() + RETRY_MILLIS); + result.complete(null); + return; + } + plugin.getLogger().warning(String.format( + "No valid source location found after %d attempts (last rejection: %s). " + + "Using the last sealed candidate - check your source.spawn-filters settings.", + attempt, rejection.getDescription())); + applySource(hunter, fallback, attempt, 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.checkBuried(spawnFilter); + if (rejection == null) { + rejection = spawnFilter.check(candidate.surface()); + } + 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); + retryAt.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 + // trunks and snow or moss cover are followed down, so the ground is the soil + // beneath them. + // ==================================== + 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 && OVERGROWTH.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); + retryAt.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. The + // surface rules look at the ground; the burial rule at the cache and the + // cover over it, so a cache never sits under a floor or a path. + private record Candidate(Location surface, Location cache) { + + SpawnLocationFilter.Rejection checkBuried(SpawnLocationFilter filter) { + return filter.checkBuried(cache.getBlock(), surface.getBlockY() - 1 - cache.getBlockY()); + } + } +} diff --git a/src/main/java/net/tfminecraft/geigercounters/managers/GeigerManager.java b/src/main/java/net/tfminecraft/geigercounters/managers/GeigerManager.java index cb908a5..4ca2fc2 100644 --- a/src/main/java/net/tfminecraft/geigercounters/managers/GeigerManager.java +++ b/src/main/java/net/tfminecraft/geigercounters/managers/GeigerManager.java @@ -1,224 +1,288 @@ -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 a chest, which only shows while they hold a detector. + // It must still be their current character's cache; one shown before a + // move or a character switch opens nothing. + // ==================================== + @Override + public void open(Player player, Location block) { + Optional hunter = Hunters.of(player); + EquipmentSlot geigerSlot = findGeigerSlot(player); + if (hunter.isEmpty() || geigerSlot == null) { + return; + } + Location source = sourceHandler.getSource(hunter.get()); + if (source == null || !source.toBlockLocation().equals(block)) { + 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")); + buriedChest.hide(player); + 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..23823cc 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,32 @@ 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 and in all blocks above it, up + // to the surface, so nobody sees it without digging and no build is over it + // ==================================== + public Rejection checkBuried(Block cache, int cover) { + if (!isNaturalGround(cache)) { + return Rejection.NOT_BURIED; + } + for (BlockFace side : SIDES) { + if (!isNaturalGround(cache.getRelative(side))) { + return Rejection.NOT_BURIED; + } + } + for (int above = 2; above <= cover; above++) { + if (!isNaturalGround(cache.getRelative(BlockFace.UP, above))) { + 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