From b45ec3b34fe561ae110842034da8abf0a60308f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 31 Mar 2026 15:47:52 +0000 Subject: [PATCH 1/8] Code review fixes, dependency updates, and new features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bugs fixed: - foundHeadsAlreadyCount always returned 0 (looked for non-existent top-level "heads" key), causing every player to see "You are the first to find this egg" — now iterates all player records correctly - Double head recording: HeadWorldController.playerCollectedHead manually wrote to the YAML list and then also called insertCollectedHead, recording each collection twice; removed the duplicate write - Integer == comparison in hasAlreadyCollectedHead and HeadWorldController used reference equality, which fails for coordinates outside JVM cache range (-128..127) — switched to Objects.equals() - debugheadhunt permission check used || instead of &&, requiring players to have BOTH the permission node and OP simultaneously; fixed to && - SKINSMAX was 9 but config contained 10 skins (indices 0-9), leaving skin 9 unreachable; corrected to 10 - Leading space in maven-shade-plugin shadedPattern removed - Version mismatch between pom.xml (1.1.1) and plugin.yml (1.2.0) — aligned pom.xml to 1.2.0 New features: - On startup, logs loaded player profile count and total heads collected - /heads [player] now accepts an optional player name so any sender (including console) can look up a specific player's egg count - /debugheadhunt clearheads [player] now accepts an optional player name, allowing console and admins to clear another player's heads; also updates their helmet and scoreboard if they are online - /leaderboard console output now shows "1. PlayerName - N heads" instead of the raw record toString() - Players who have already collected all available eggs are now blocked from continuing to collect more (LANG.HEAD.ALLHEADSCOLLECTED config key added) Dependency updates: - maven-compiler-plugin 3.8.1 → 3.13.0 - maven-shade-plugin 3.4.1 → 3.5.3 - lombok 1.18.30 → 1.18.36 - snakeyaml 2.0 → 2.3 - httpclient5 5.2 → 5.4.1 https://claude.ai/code/session_01JyfubfoVpre8dsRWa3K6Vx --- pom.xml | 14 ++-- .../PlayerHeadHunt/HeadChatController.java | 12 ++- .../modularsoft/PlayerHeadHunt/HeadQuery.java | 83 +++++++++++++++---- .../PlayerHeadHunt/HeadWorldController.java | 35 +------- .../PlayerHeadHunt/PlayerHeadHuntMain.java | 6 ++ .../PlayerHeadHunt/PluginConfig.java | 2 + .../commands/debugheadhunt.java | 23 ++++- .../PlayerHeadHunt/commands/heads.java | 12 ++- .../PlayerHeadHunt/commands/leaderboard.java | 13 ++- .../PlayerHeadHunt/events/HeadFindEvent.java | 10 ++- src/main/resources/config.yml | 3 +- src/main/resources/plugin.yml | 6 +- 12 files changed, 147 insertions(+), 72 deletions(-) diff --git a/pom.xml b/pom.xml index 79a78cc..7dd4151 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ org.modularsoft PlayerHeadHunt - 1.1.1 + 1.2.0 UTF-8 @@ -18,7 +18,7 @@ org.apache.maven.plugins maven-compiler-plugin - 3.8.1 + 3.13.0 17 @@ -26,7 +26,7 @@ org.apache.maven.plugins maven-shade-plugin - 3.4.1 + 3.5.3 package @@ -37,7 +37,7 @@ org.apache.hc - org.modularsoft.PlayerHeadHunt.shaded.org.apache.hc + org.modularsoft.PlayerHeadHunt.shaded.org.apache.hc @@ -111,20 +111,20 @@ org.projectlombok lombok - 1.18.30 + 1.18.36 provided org.yaml snakeyaml - 2.0 + 2.3 org.apache.httpcomponents.client5 httpclient5 - 5.2 + 5.4.1 diff --git a/src/main/java/org/modularsoft/PlayerHeadHunt/HeadChatController.java b/src/main/java/org/modularsoft/PlayerHeadHunt/HeadChatController.java index d1320e1..0944b7f 100644 --- a/src/main/java/org/modularsoft/PlayerHeadHunt/HeadChatController.java +++ b/src/main/java/org/modularsoft/PlayerHeadHunt/HeadChatController.java @@ -101,12 +101,22 @@ public void newPlayerJoinsTheHunt(Player player) { } public void playersOwnHeadCountResponse(Player player) { - // Use the instance of HeadQuery to call the method player.sendMessage(plugin.config().getLangHeadCount() .replace("%FOUNDHEADS%", "" + headQuery.foundHeadsCount(player)) .replace("%NUMBEROFHEADS%", "" + plugin.config().getTotalHeads())); } + public void targetPlayerHeadCountResponse(org.bukkit.command.CommandSender sender, String targetName, int count) { + if (count == -1) { + sender.sendMessage(ChatColor.RED + "No data found for player: " + targetName); + return; + } + sender.sendMessage(plugin.config().getLangHeadCount() + .replace("%FOUNDHEADS%", "" + count) + .replace("%NUMBEROFHEADS%", "" + plugin.config().getTotalHeads()) + .replace("You have", targetName + " has")); + } + public void playerClearedTheirHeadsResponse(Player player) { player.sendMessage("All heads have been cleared."); } diff --git a/src/main/java/org/modularsoft/PlayerHeadHunt/HeadQuery.java b/src/main/java/org/modularsoft/PlayerHeadHunt/HeadQuery.java index a6e4fa4..100801d 100644 --- a/src/main/java/org/modularsoft/PlayerHeadHunt/HeadQuery.java +++ b/src/main/java/org/modularsoft/PlayerHeadHunt/HeadQuery.java @@ -12,6 +12,7 @@ import java.util.*; import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; +import java.util.stream.Stream; public class HeadQuery { private final YamlFileManager yamlFileManager; @@ -50,24 +51,28 @@ public int foundHeadsCount(Player player) { public int foundHeadsAlreadyCount(int xCord, int yCord, int zCord) { Map data = yamlFileManager.getData(); - Object headsObject = data.get("heads"); + int count = 0; - // Ensure the "heads" object is a list - if (!(headsObject instanceof List)) { - return 0; // Return 0 if the data is not a list + for (Map.Entry entry : data.entrySet()) { + if (!(entry.getValue() instanceof Map playerData)) continue; + + Object headsObj = playerData.get("headsCollected"); + if (!(headsObj instanceof List heads)) continue; + + for (Object head : heads) { + if (!(head instanceof Map headMap)) continue; + Object hx = headMap.get("x"); + Object hy = headMap.get("y"); + Object hz = headMap.get("z"); + if (hx instanceof Integer && hy instanceof Integer && hz instanceof Integer + && (Integer) hx == xCord && (Integer) hy == yCord && (Integer) hz == zCord) { + count++; + break; // each player counts once per head location + } + } } - List heads = (List) headsObject; - - // Filter and count matching heads - return (int) heads.stream() - .filter(head -> head instanceof Map) - .map(head -> (Map) head) - .filter(head -> - head.get("x") instanceof Integer && head.get("y") instanceof Integer && head.get("z") instanceof Integer && - head.get("x").equals(xCord) && head.get("y").equals(yCord) && head.get("z").equals(zCord) - ) - .count(); + return count; } public boolean clearHeads(Player player) { @@ -108,7 +113,7 @@ public boolean hasAlreadyCollectedHead(Player player, int x, int y, int z) { // Check if the head coordinates already exist in the list return headsCollected.stream().anyMatch(head -> - head.get("x") == x && head.get("y") == y && head.get("z") == z); + Objects.equals(head.get("x"), x) && Objects.equals(head.get("y"), y) && Objects.equals(head.get("z"), z)); } public void insertCollectedHead(Player player, int x, int y, int z) { @@ -163,6 +168,52 @@ public boolean addNewHunter(Player player) { return true; } + public int foundHeadsCountByName(String playerName) { + Map data = yamlFileManager.getData(); + for (Map.Entry entry : data.entrySet()) { + if (!(entry.getValue() instanceof Map playerData)) continue; + String username = (String) playerData.get("username"); + if (!playerName.equalsIgnoreCase(username)) continue; + Object headsObj = playerData.get("headsCollected"); + if (headsObj instanceof List heads) { + return heads.size(); + } + return 0; + } + return -1; // -1 indicates player not found in data + } + + public boolean clearHeadsByName(String playerName) { + Map data = yamlFileManager.getData(); + for (Map.Entry entry : data.entrySet()) { + if (!(entry.getValue() instanceof Map rawPlayerData)) continue; + String username = (String) rawPlayerData.get("username"); + if (!playerName.equalsIgnoreCase(username)) continue; + + Map playerData = (Map) entry.getValue(); + List> headsCollected = (List>) playerData.get("headsCollected"); + if (headsCollected != null) headsCollected.clear(); + playerData.put("headsCollectedCount", 0); + yamlFileManager.save(); + return true; + } + return false; + } + + public int getLoadedPlayerCount() { + return yamlFileManager.getData().size(); + } + + public int getTotalHeadsCollectedAcrossAllPlayers() { + return yamlFileManager.getData().values().stream() + .filter(v -> v instanceof Map) + .mapToInt(v -> { + Object headsObj = ((Map) v).get("headsCollected"); + return (headsObj instanceof List list) ? list.size() : 0; + }) + .sum(); + } + private boolean isPlayerExcluded(UUID uuid, String username) { return luckPerms.getUserManager().loadUser(uuid) .thenApply(user -> { diff --git a/src/main/java/org/modularsoft/PlayerHeadHunt/HeadWorldController.java b/src/main/java/org/modularsoft/PlayerHeadHunt/HeadWorldController.java index 4afbb0e..ab72564 100644 --- a/src/main/java/org/modularsoft/PlayerHeadHunt/HeadWorldController.java +++ b/src/main/java/org/modularsoft/PlayerHeadHunt/HeadWorldController.java @@ -21,18 +21,13 @@ import org.bukkit.block.data.BlockData; import org.bukkit.entity.Player; import org.bukkit.scheduler.BukkitRunnable; -import org.modularsoft.PlayerHeadHunt.helpers.YamlFileManager; - -import java.io.File; import java.util.*; public class HeadWorldController { private final PlayerHeadHuntMain plugin; - private final YamlFileManager yamlFileManager; public HeadWorldController(PlayerHeadHuntMain plugin) { this.plugin = plugin; - this.yamlFileManager = new YamlFileManager(new File(plugin.getDataFolder(), "player-data.yml")); } public void countHeadsInRegion() { @@ -55,39 +50,11 @@ public void countHeadsInRegion() { } public void playerCollectedHead(Player player, Block block, int x, int y, int z) { - String playerUUID = player.getUniqueId().toString(); - Map data = yamlFileManager.getData(); - Map playerData = (Map) data.get(playerUUID); - - if (playerData == null) { - playerData = new HashMap<>(); - playerData.put("headsCollected", new ArrayList>()); - data.put(playerUUID, playerData); - } - - List> collectedHeads = (List>) playerData.get("headsCollected"); - if (collectedHeads == null) { - collectedHeads = new ArrayList<>(); - playerData.put("headsCollected", collectedHeads); - } - - boolean alreadyCollected = collectedHeads.stream().anyMatch(head -> - head.get("x") == x && head.get("y") == y && head.get("z") == z); - - if (alreadyCollected) { - player.sendMessage(plugin.config().getLangHeadAlreadyFound()); - return; - } - - collectedHeads.add(Map.of("x", x, "y", y, "z", z)); - yamlFileManager.save(); - - // Increment the player's head count + // Record the collection via HeadQuery (single source of truth for player data) plugin.getHeadQuery().insertCollectedHead(player, x, y, z); Material blockType = block.getType(); BlockData blockData = block.getBlockData(); - int headRespawnTimer = plugin.config().getHeadRespawnTimer(); breakBlock(x, y, z); diff --git a/src/main/java/org/modularsoft/PlayerHeadHunt/PlayerHeadHuntMain.java b/src/main/java/org/modularsoft/PlayerHeadHunt/PlayerHeadHuntMain.java index 1096f6e..b1c1a9b 100644 --- a/src/main/java/org/modularsoft/PlayerHeadHunt/PlayerHeadHuntMain.java +++ b/src/main/java/org/modularsoft/PlayerHeadHunt/PlayerHeadHuntMain.java @@ -40,6 +40,12 @@ public void onEnable() { HeadHatController headHatController = new HeadHatController(this); HeadScoreboardController headScoreboardController = new HeadScoreboardController(this); + // Log loaded player and head statistics + int playerCount = headQuery.getLoadedPlayerCount(); + int totalCollected = headQuery.getTotalHeadsCollectedAcrossAllPlayers(); + console.sendMessage(ChatColor.GREEN + "Loaded " + playerCount + " player profile(s) from storage."); + console.sendMessage(ChatColor.GREEN + "Total heads collected across all players: " + totalCollected); + // Do an initial calculation of the number of heads. This can be // manually recalculated with the relevant command. headWorldController.countHeadsInRegion(); diff --git a/src/main/java/org/modularsoft/PlayerHeadHunt/PluginConfig.java b/src/main/java/org/modularsoft/PlayerHeadHunt/PluginConfig.java index 2ed8059..caae20a 100644 --- a/src/main/java/org/modularsoft/PlayerHeadHunt/PluginConfig.java +++ b/src/main/java/org/modularsoft/PlayerHeadHunt/PluginConfig.java @@ -43,6 +43,7 @@ public class PluginConfig { @Getter private final String langLastHeadFound; @Getter private final String langHeadCount; @Getter private final String langHeadCollectionMilestoneReached; + @Getter private final String langAllHeadsCollected; @Getter private final String langLeaderboardNoHeads; @Getter private final String langLeaderboardHeader; @@ -98,6 +99,7 @@ public PluginConfig(PlayerHeadHuntMain plugin) { langLastHeadFound = ChatColor.translateAlternateColorCodes('&', Objects.requireNonNull(config.getString("LANG.HEAD.LASTHEADFOUND"))); langHeadCount = ChatColor.translateAlternateColorCodes('&', Objects.requireNonNull(config.getString("LANG.HEAD.HEADCOUNT"))); langHeadCollectionMilestoneReached = ChatColor.translateAlternateColorCodes('&', Objects.requireNonNull(config.getString("LANG.HEAD.HEADCOLLECTIONMILESTONEREACHED"))); + langAllHeadsCollected = ChatColor.translateAlternateColorCodes('&', Objects.requireNonNull(config.getString("LANG.HEAD.ALLHEADSCOLLECTED"))); langLeaderboardNoHeads = ChatColor.translateAlternateColorCodes('&', Objects.requireNonNull(config.getString("LANG.LEADERBOARD.NOHEADS"))); langLeaderboardHeader = ChatColor.translateAlternateColorCodes('&', Objects.requireNonNull(config.getString("LANG.LEADERBOARD.HEADER"))); diff --git a/src/main/java/org/modularsoft/PlayerHeadHunt/commands/debugheadhunt.java b/src/main/java/org/modularsoft/PlayerHeadHunt/commands/debugheadhunt.java index 0f8606b..071db89 100644 --- a/src/main/java/org/modularsoft/PlayerHeadHunt/commands/debugheadhunt.java +++ b/src/main/java/org/modularsoft/PlayerHeadHunt/commands/debugheadhunt.java @@ -40,19 +40,20 @@ public debugheadhunt(PlayerHeadHuntMain plugin, @Override public boolean onCommand(@NotNull CommandSender sender, @NotNull Command cmd, @NotNull String label, String[] args) { - if (!sender.hasPermission("playerheadhunt.debug") || !sender.isOp()) { + if (!sender.hasPermission("playerheadhunt.debug") && !sender.isOp()) { sender.sendMessage(plugin.config().getLangInsufficientPermissions()); return true; } if (args.length == 0) { - sender.sendMessage("Usage: /debugheadhunt "); + sender.sendMessage("Usage: /debugheadhunt "); return true; } switch (args[0].toLowerCase()) { case "clearheads" -> { - if (sender instanceof Player player) { + if (sender instanceof Player player && args.length == 1) { + // Player clearing their own heads if (!headQuery.clearHeads(player)) { sender.sendMessage("No heads to clear."); return true; @@ -61,8 +62,22 @@ public boolean onCommand(@NotNull CommandSender sender, @NotNull Command cmd, @N headHatController.clearHelmet(player); scoreboardController.reloadScoreboard(player, headQuery.foundHeadsCount(player)); sender.sendMessage("Heads cleared successfully."); + } else if (args.length >= 2) { + // Clear heads for a named player (works from console or player) + String targetName = args[1]; + if (!headQuery.clearHeadsByName(targetName)) { + sender.sendMessage("No data found for player: " + targetName); + return true; + } + sender.sendMessage("Heads cleared for player: " + targetName); + // If the target is online, update their scoreboard and helmet too + org.bukkit.entity.Player online = plugin.getServer().getPlayerExact(targetName); + if (online != null) { + headHatController.clearHelmet(online); + scoreboardController.reloadScoreboard(online, 0); + } } else { - sender.sendMessage("The 'clearheads' command can only be executed by a player."); + sender.sendMessage("Usage: /debugheadhunt clearheads [player]"); } } case "countheads" -> { diff --git a/src/main/java/org/modularsoft/PlayerHeadHunt/commands/heads.java b/src/main/java/org/modularsoft/PlayerHeadHunt/commands/heads.java index 0b31f53..2dd5268 100644 --- a/src/main/java/org/modularsoft/PlayerHeadHunt/commands/heads.java +++ b/src/main/java/org/modularsoft/PlayerHeadHunt/commands/heads.java @@ -1,5 +1,6 @@ package org.modularsoft.PlayerHeadHunt.commands; +import org.modularsoft.PlayerHeadHunt.HeadQuery; import org.modularsoft.PlayerHeadHunt.PlayerHeadHuntMain; import org.modularsoft.PlayerHeadHunt.HeadChatController; import org.bukkit.command.Command; @@ -11,14 +12,24 @@ public class heads implements CommandExecutor { private final PlayerHeadHuntMain plugin; private final HeadChatController headChatController; + private final HeadQuery headQuery; public heads(PlayerHeadHuntMain plugin, HeadChatController headChatController) { this.plugin = plugin; this.headChatController = headChatController; + this.headQuery = plugin.getHeadQuery(); } @Override public boolean onCommand(@NotNull CommandSender sender, @NotNull Command cmd, @NotNull String s, String[] args) { + if (args.length >= 1) { + // /heads — any sender can look up a specific player + String targetName = args[0]; + int count = headQuery.foundHeadsCountByName(targetName); + headChatController.targetPlayerHeadCountResponse(sender, targetName, count); + return true; + } + if (!(sender instanceof Player player)) { sender.sendMessage(plugin.config().getLangNotAPlayer()); return true; @@ -27,5 +38,4 @@ public boolean onCommand(@NotNull CommandSender sender, @NotNull Command cmd, @N headChatController.playersOwnHeadCountResponse(player); return true; } - } diff --git a/src/main/java/org/modularsoft/PlayerHeadHunt/commands/leaderboard.java b/src/main/java/org/modularsoft/PlayerHeadHunt/commands/leaderboard.java index 93bb5d8..8931bd1 100644 --- a/src/main/java/org/modularsoft/PlayerHeadHunt/commands/leaderboard.java +++ b/src/main/java/org/modularsoft/PlayerHeadHunt/commands/leaderboard.java @@ -36,10 +36,15 @@ public boolean onCommand(@NotNull CommandSender sender, @NotNull Command cmd, @N } else { // Handle the command for the console headQuery.getBestHunters(5).thenAccept(bestHunters -> { - // Log the leaderboard to the console - plugin.getServer().getConsoleSender().sendMessage("Top 5 Hunters:"); - for (int i = 0; i < bestHunters.size(); i++) { - plugin.getServer().getConsoleSender().sendMessage((i + 1) + ". " + bestHunters.get(i)); + plugin.getServer().getConsoleSender().sendMessage("=== Top 5 Head Hunters ==="); + if (bestHunters.isEmpty()) { + plugin.getServer().getConsoleSender().sendMessage("No hunters found."); + } else { + for (int i = 0; i < bestHunters.size(); i++) { + HeadQuery.HeadHunter hunter = bestHunters.get(i); + plugin.getServer().getConsoleSender().sendMessage( + (i + 1) + ". " + hunter.name() + " - " + hunter.headsCollected() + " heads"); + } } }); } diff --git a/src/main/java/org/modularsoft/PlayerHeadHunt/events/HeadFindEvent.java b/src/main/java/org/modularsoft/PlayerHeadHunt/events/HeadFindEvent.java index 7b8a793..1ca4943 100644 --- a/src/main/java/org/modularsoft/PlayerHeadHunt/events/HeadFindEvent.java +++ b/src/main/java/org/modularsoft/PlayerHeadHunt/events/HeadFindEvent.java @@ -47,10 +47,18 @@ public void onHeadFind(PlayerInteractEvent event) { int y = block.getY(); int z = block.getZ(); + // Check if the player has already collected all available heads + int foundHeadsBeforeCollection = headQuery.foundHeadsCount(player); + int totalHeads = plugin.config().getTotalHeads(); + if (totalHeads > 0 && foundHeadsBeforeCollection >= totalHeads) { + player.sendMessage(plugin.config().getLangAllHeadsCollected()); + return; + } + // Check if the head has already been collected if (headQuery.hasAlreadyCollectedHead(player, x, y, z)) { // Send the "head already found" message and stop further processing - headChatController.headFoundResponse(player, true, headQuery.foundHeadsCount(player), x, y, z); + headChatController.headFoundResponse(player, true, foundHeadsBeforeCollection, x, y, z); return; // Ensure no further processing occurs } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 8b039b3..488c779 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -19,7 +19,7 @@ HEAD: HEADTOTAL: HEADBLOCK: PLAYER_HEAD RESPAWNTIMER: 1200 # Default is 1200 (1 minute) - SKINSMAX: 9 + SKINSMAX: 10 SKINS: 0: "eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvOGM1MGFlZTg4MDEzZThmYWY0MjdlMTlmM2I4OTgyOGI4NmJiZjAzZGQyZjE3YzRjNzYwZDFkZGUyMmRlMyJ9fX0=" 1: "eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvYzc2NTk1ZWZmY2M1NjI3ZTg1YjE0YzljODgyNDY3MWI1ZWMyOTY1NjU5YzhjNDE3ODQ5YTY2Nzg3OGZhNDkwIn19fQ==" @@ -78,6 +78,7 @@ LANG: FIRSTHEADFOUND: "&e&l%PLAYER% &5has found their first egg!" LASTHEADFOUND: "&e&l%PLAYER% &5has found all &e&l%NUMBEROFHEADS% &r&5eggs!" HEADCOUNT: "&eYou have found &l&6%FOUNDHEADS%/%NUMBEROFHEADS% &r&eheads." + ALLHEADSCOLLECTED: "&eYou have already collected all available eggs!" # Use %PLAYER% to display players name, use %NUMBEROFHEADS% to display number of eggs. HEADCOLLECTIONMILESTONEREACHED: "&e&l%PLAYER% &5has collected &e&l%NUMBEROFHEADS% &r&5eggs!" LEADERBOARD: diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index b31a5c0..1186e55 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -8,8 +8,8 @@ depend: [WorldEdit] commands: heads: - description: Grab the amount of heads you have. - usage: /heads + description: Grab the amount of heads you have, or check another player's count. + usage: /heads [player] aliases: [eggs, presents] leaderboard: description: Show the 5 best heads hunters on the Server. @@ -17,7 +17,7 @@ commands: aliases: [lb] debugheadhunt: description: Debug command for developers. - usage: /debugheadhunt + usage: /debugheadhunt aliases: [ dhh ] permissions: From 362e8adf3b4e5e99cf20120358240f7a1381ab4f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Apr 2026 21:21:32 +0000 Subject: [PATCH 2/8] Add copper helmet milestone at 75 heads Inserts copper helmet into the progression between leather (25) and chainmail (100). Requires Minecraft 1.21.2+ (Material.COPPER_HELMET). https://claude.ai/code/session_01JyfubfoVpre8dsRWa3K6Vx --- src/main/java/org/modularsoft/PlayerHeadHunt/PluginConfig.java | 1 + src/main/resources/config.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/src/main/java/org/modularsoft/PlayerHeadHunt/PluginConfig.java b/src/main/java/org/modularsoft/PlayerHeadHunt/PluginConfig.java index caae20a..85eaf48 100644 --- a/src/main/java/org/modularsoft/PlayerHeadHunt/PluginConfig.java +++ b/src/main/java/org/modularsoft/PlayerHeadHunt/PluginConfig.java @@ -78,6 +78,7 @@ public PluginConfig(PlayerHeadHuntMain plugin) { for (Integer minor : config.getIntegerList("MILESTONES.MAJOR")) headMilestones.put(minor, new HeadMileStone(minor, true)); headMilestones.get(config.getInt("MILESTONES.LEATHERHELMET")).setHelmet(Material.LEATHER_HELMET); + headMilestones.get(config.getInt("MILESTONES.COPPERHELMET")).setHelmet(Material.COPPER_HELMET); headMilestones.get(config.getInt("MILESTONES.CHAINMAILHELMET")).setHelmet(Material.CHAINMAIL_HELMET); headMilestones.get(config.getInt("MILESTONES.IRONHELMET")).setHelmet(Material.IRON_HELMET); headMilestones.get(config.getInt("MILESTONES.GOLDENHELMET")).setHelmet(Material.GOLDEN_HELMET); diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 488c779..d1c03a8 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -55,6 +55,7 @@ MILESTONES: - 625 # These need to match up 1 to 1 with a milestone above LEATHERHELMET: 25 + COPPERHELMET: 75 CHAINMAILHELMET: 100 IRONHELMET: 156 GOLDENHELMET: 314 From 653553c48dd238495253c8d98fa0153e2391b08d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Apr 2026 21:33:23 +0000 Subject: [PATCH 3/8] Bump Paper API to 1.21.4 to support Material.COPPER_HELMET Copper armor was added in Minecraft 1.21.2; Material.COPPER_HELMET does not exist in the 1.20.4 API, causing a compilation error. Updated the Paper API dependency to 1.21.4-R0.1-SNAPSHOT and raised api-version in plugin.yml to 1.21 accordingly. https://claude.ai/code/session_01JyfubfoVpre8dsRWa3K6Vx --- pom.xml | 2 +- src/main/resources/plugin.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 7dd4151..50a876b 100644 --- a/pom.xml +++ b/pom.xml @@ -71,7 +71,7 @@ io.papermc.paper paper-api - 1.20.4-R0.1-SNAPSHOT + 1.21.4-R0.1-SNAPSHOT provided diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 1186e55..119ffed 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -3,7 +3,7 @@ name: PlayerHeadHunt description: An Player Head Hunt minigame. version: 1.2.0 author: ModularSoft -api-version: 1.19 +api-version: 1.21 depend: [WorldEdit] commands: From f2691883d1b509e3b964c6c46e3e0aea21be3c83 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 07:26:24 +0000 Subject: [PATCH 4/8] Inline helmet assignments directly into milestone config entries Previously, helmets were mapped via separate keys (LEATHERHELMET: 25, CHAINMAILHELMET: 100, etc.) which required keeping two separate lists in sync. Each milestone entry can now carry an optional 'helmet' field, so adding or moving a milestone is a single edit in one place. Config format change: MINOR / MAJOR entries are either a plain number (no helmet) or a map: - count: 50 helmet: LEATHER_HELMET The parser in PluginConfig.parseMilestoneEntry() handles both forms and logs a warning for any unrecognised Material name rather than crashing. https://claude.ai/code/session_01JyfubfoVpre8dsRWa3K6Vx --- .../PlayerHeadHunt/PluginConfig.java | 35 +++++++++++++------ src/main/resources/config.yml | 32 +++++++++-------- 2 files changed, 41 insertions(+), 26 deletions(-) diff --git a/src/main/java/org/modularsoft/PlayerHeadHunt/PluginConfig.java b/src/main/java/org/modularsoft/PlayerHeadHunt/PluginConfig.java index 85eaf48..1ee2b59 100644 --- a/src/main/java/org/modularsoft/PlayerHeadHunt/PluginConfig.java +++ b/src/main/java/org/modularsoft/PlayerHeadHunt/PluginConfig.java @@ -73,17 +73,10 @@ public PluginConfig(PlayerHeadHuntMain plugin) { majorCollectionSound = Sound.valueOf(config.getString("SOUND.MAJORCOLLECTIONMILESTONE")); headMilestones = new HashMap<>(); - for (Integer minor : config.getIntegerList("MILESTONES.MINOR")) - headMilestones.put(minor, new HeadMileStone(minor, false)); - for (Integer minor : config.getIntegerList("MILESTONES.MAJOR")) - headMilestones.put(minor, new HeadMileStone(minor, true)); - headMilestones.get(config.getInt("MILESTONES.LEATHERHELMET")).setHelmet(Material.LEATHER_HELMET); - headMilestones.get(config.getInt("MILESTONES.COPPERHELMET")).setHelmet(Material.COPPER_HELMET); - headMilestones.get(config.getInt("MILESTONES.CHAINMAILHELMET")).setHelmet(Material.CHAINMAIL_HELMET); - headMilestones.get(config.getInt("MILESTONES.IRONHELMET")).setHelmet(Material.IRON_HELMET); - headMilestones.get(config.getInt("MILESTONES.GOLDENHELMET")).setHelmet(Material.GOLDEN_HELMET); - headMilestones.get(config.getInt("MILESTONES.DIAMONDHELMET")).setHelmet(Material.DIAMOND_HELMET); - headMilestones.get(config.getInt("MILESTONES.NETHERITEHELMET")).setHelmet(Material.NETHERITE_HELMET); + for (Object entry : config.getList("MILESTONES.MINOR", Collections.emptyList())) + parseMilestoneEntry(entry, false); + for (Object entry : config.getList("MILESTONES.MAJOR", Collections.emptyList())) + parseMilestoneEntry(entry, true); langDatabaseConnectionError = ChatColor.translateAlternateColorCodes('&', Objects.requireNonNull(config.getString("LANG.DATABASE.CONNECTIONERROR"))); langDatabaseConnectionSuccess = ChatColor.translateAlternateColorCodes('&', Objects.requireNonNull(config.getString("LANG.DATABASE.CONNECTIONSUCCESS"))); @@ -110,6 +103,26 @@ public PluginConfig(PlayerHeadHuntMain plugin) { langLeaderboardFormat = ChatColor.translateAlternateColorCodes('&', Objects.requireNonNull(config.getString("LANG.LEADERBOARD.FORMAT"))); } + @SuppressWarnings("unchecked") + private void parseMilestoneEntry(Object entry, boolean isMajor) { + if (entry instanceof Integer count) { + headMilestones.put(count, new HeadMileStone(count, isMajor)); + } else if (entry instanceof Map map) { + Object countObj = map.get("count"); + if (!(countObj instanceof Integer count)) return; + HeadMileStone milestone = new HeadMileStone(count, isMajor); + Object helmetStr = map.get("helmet"); + if (helmetStr instanceof String name) { + try { + milestone.setHelmet(Material.valueOf(name.toUpperCase())); + } catch (IllegalArgumentException e) { + plugin.getLogger().warning("Unknown helmet material in milestones config: " + name); + } + } + headMilestones.put(count, milestone); + } + } + public void save() { plugin.saveConfig(); } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index d1c03a8..ba659b8 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -37,30 +37,32 @@ SOUND: MINORCOLLECTIONMILESTONE: "ENTITY_PLAYER_LEVELUP" MAJORCOLLECTIONMILESTONE: "UI_TOAST_CHALLENGE_COMPLETE" MILESTONES: + # Each entry is either a plain number (no helmet reward) or a map with + # a 'count' and optional 'helmet' (any valid Bukkit Material name). + # MINOR uses the minor sound; MAJOR uses the major sound. MINOR: - 10 - 25 - - 50 - - 75 - - 100 + - count: 50 + helmet: LEATHER_HELMET + - count: 75 + helmet: COPPER_HELMET + - count: 100 + helmet: CHAINMAIL_HELMET - 200 - 300 - 400 - 500 - 600 MAJOR: - - 156 - - 314 - - 468 - - 625 - # These need to match up 1 to 1 with a milestone above - LEATHERHELMET: 25 - COPPERHELMET: 75 - CHAINMAILHELMET: 100 - IRONHELMET: 156 - GOLDENHELMET: 314 - DIAMONDHELMET: 468 - NETHERITEHELMET: 625 # Should equal the head count for maximum results. + - count: 156 + helmet: IRON_HELMET + - count: 314 + helmet: GOLDEN_HELMET + - count: 468 + helmet: DIAMOND_HELMET + - count: 625 + helmet: NETHERITE_HELMET LANG: DATABASE: CONNECTIONERROR: "&cA database error has occurred, please contact an Administrator." From e45af53a398275768fc175232295a1994bda94be Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 07:30:11 +0000 Subject: [PATCH 5/8] Auto-derive milestone counts from total egg count via percentages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Milestone counts used to be hardcoded values that had to be updated manually whenever eggs were added or removed. They are now stored as percentages of the total egg count and recomputed automatically every time countHeadsInRegion() updates the total (i.e. on startup and via /debugheadhunt countheads). Config format change — each MILESTONES entry is now a percentage: Plain number: - 8.0 (8% of total, no helmet) With helmet: - percentage: 25.0 helmet: IRON_HELMET PluginConfig.recomputeMilestones(int) clears and repopulates the headMilestones map in-place, so HeadFindEvent's reference to the map automatically reflects the updated counts without re-registration. The computed milestone counts are logged to console after each scan. https://claude.ai/code/session_01JyfubfoVpre8dsRWa3K6Vx --- .../PlayerHeadHunt/PluginConfig.java | 49 ++++++++++++++----- src/main/resources/config.yml | 35 ++++++------- 2 files changed, 56 insertions(+), 28 deletions(-) diff --git a/src/main/java/org/modularsoft/PlayerHeadHunt/PluginConfig.java b/src/main/java/org/modularsoft/PlayerHeadHunt/PluginConfig.java index 1ee2b59..0320ce7 100644 --- a/src/main/java/org/modularsoft/PlayerHeadHunt/PluginConfig.java +++ b/src/main/java/org/modularsoft/PlayerHeadHunt/PluginConfig.java @@ -28,6 +28,10 @@ public class PluginConfig { @Getter private final Map headMilestones; + // Raw percentage templates parsed once from config; counts derived at runtime + private record MilestoneTemplate(double percentage, boolean isMajor, Material helmet) {} + private final List milestoneTemplates; + @Getter private final String langDatabaseConnectionError; @Getter private final String langDatabaseConnectionSuccess; @Getter private final String langNotAPlayer; @@ -72,11 +76,14 @@ public PluginConfig(PlayerHeadHuntMain plugin) { minorCollectionSound = Sound.valueOf(config.getString("SOUND.MINORCOLLECTIONMILESTONE")); majorCollectionSound = Sound.valueOf(config.getString("SOUND.MAJORCOLLECTIONMILESTONE")); - headMilestones = new HashMap<>(); + milestoneTemplates = new ArrayList<>(); for (Object entry : config.getList("MILESTONES.MINOR", Collections.emptyList())) - parseMilestoneEntry(entry, false); + parseMilestoneTemplate(entry, false); for (Object entry : config.getList("MILESTONES.MAJOR", Collections.emptyList())) - parseMilestoneEntry(entry, true); + parseMilestoneTemplate(entry, true); + + headMilestones = new HashMap<>(); + recomputeMilestones(getTotalHeads()); langDatabaseConnectionError = ChatColor.translateAlternateColorCodes('&', Objects.requireNonNull(config.getString("LANG.DATABASE.CONNECTIONERROR"))); langDatabaseConnectionSuccess = ChatColor.translateAlternateColorCodes('&', Objects.requireNonNull(config.getString("LANG.DATABASE.CONNECTIONSUCCESS"))); @@ -103,24 +110,43 @@ public PluginConfig(PlayerHeadHuntMain plugin) { langLeaderboardFormat = ChatColor.translateAlternateColorCodes('&', Objects.requireNonNull(config.getString("LANG.LEADERBOARD.FORMAT"))); } - @SuppressWarnings("unchecked") - private void parseMilestoneEntry(Object entry, boolean isMajor) { - if (entry instanceof Integer count) { - headMilestones.put(count, new HeadMileStone(count, isMajor)); + private void parseMilestoneTemplate(Object entry, boolean isMajor) { + double percentage; + Material helmet = null; + + if (entry instanceof Number num) { + percentage = num.doubleValue(); } else if (entry instanceof Map map) { - Object countObj = map.get("count"); - if (!(countObj instanceof Integer count)) return; - HeadMileStone milestone = new HeadMileStone(count, isMajor); + Object pctObj = map.get("percentage"); + if (!(pctObj instanceof Number pctNum)) return; + percentage = pctNum.doubleValue(); Object helmetStr = map.get("helmet"); if (helmetStr instanceof String name) { try { - milestone.setHelmet(Material.valueOf(name.toUpperCase())); + helmet = Material.valueOf(name.toUpperCase()); } catch (IllegalArgumentException e) { plugin.getLogger().warning("Unknown helmet material in milestones config: " + name); } } + } else { + return; + } + + milestoneTemplates.add(new MilestoneTemplate(percentage, isMajor, helmet)); + } + + public void recomputeMilestones(int totalHeads) { + headMilestones.clear(); + if (totalHeads <= 0) return; + + for (MilestoneTemplate template : milestoneTemplates) { + int count = Math.max(1, (int) Math.round(template.percentage() / 100.0 * totalHeads)); + HeadMileStone milestone = new HeadMileStone(count, template.isMajor()); + if (template.helmet() != null) milestone.setHelmet(template.helmet()); headMilestones.put(count, milestone); } + + plugin.getLogger().info("Milestones recalculated for " + totalHeads + " total heads: " + headMilestones.keySet().stream().sorted().toList()); } public void save() { @@ -129,6 +155,7 @@ public void save() { public void setTotalHeads(int totalHeads) { config.set("HEAD.HEADTOTAL", totalHeads); + recomputeMilestones(totalHeads); } public int getTotalHeads() { diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index ba659b8..41f29a1 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -37,31 +37,32 @@ SOUND: MINORCOLLECTIONMILESTONE: "ENTITY_PLAYER_LEVELUP" MAJORCOLLECTIONMILESTONE: "UI_TOAST_CHALLENGE_COMPLETE" MILESTONES: - # Each entry is either a plain number (no helmet reward) or a map with - # a 'count' and optional 'helmet' (any valid Bukkit Material name). - # MINOR uses the minor sound; MAJOR uses the major sound. + # Milestone counts are derived automatically from the total egg count. + # Each entry is either a plain percentage (e.g. 8.0 = 8% of total) or a + # map with a 'percentage' and optional 'helmet' (any Bukkit Material name). + # MINOR uses the minor sound; MAJOR uses the major sound and a broadcast. MINOR: - - 10 - - 25 - - count: 50 + - 1.6 + - 4.0 + - percentage: 8.0 helmet: LEATHER_HELMET - - count: 75 + - percentage: 12.0 helmet: COPPER_HELMET - - count: 100 + - percentage: 16.0 helmet: CHAINMAIL_HELMET - - 200 - - 300 - - 400 - - 500 - - 600 + - 32.0 + - 48.0 + - 64.0 + - 80.0 + - 96.0 MAJOR: - - count: 156 + - percentage: 25.0 helmet: IRON_HELMET - - count: 314 + - percentage: 50.0 helmet: GOLDEN_HELMET - - count: 468 + - percentage: 75.0 helmet: DIAMOND_HELMET - - count: 625 + - percentage: 100.0 helmet: NETHERITE_HELMET LANG: DATABASE: From d3bef2a22e0112ec70d2459fbd6c698dee7f63ad Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 07:46:38 +0000 Subject: [PATCH 6/8] Add flight disable feature with join-time override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When FEATURE.DISABLEFLIGHT is TRUE the plugin: - Strips allowFlight and isFlying from every non-exempt player on join, overriding any grant made by another plugin or the server before the join event fires (EventPriority.HIGHEST) - Cancels PlayerToggleFlightEvent mid-session for the same player set, as a secondary guard against plugins granting flight after join - Sends LANG.FLIGHT.DISABLED message when flight is blocked Exempt from restriction: - Ops - Players with playerheadhunt.flight.exempt permission - Creative and Spectator mode players New files / changes: events/HeadHunterFlightControl.java — new listener + enforceFlight() events/HeadHunterOnJoin.java — calls enforceFlight() on join PluginConfig.java — flightDisabledFeatureEnabled, langFlightDisabled fields config.yml — FEATURE.DISABLEFLIGHT, LANG.FLIGHT plugin.yml — playerheadhunt.flight.exempt perm https://claude.ai/code/session_01JyfubfoVpre8dsRWa3K6Vx --- .../PlayerHeadHunt/PlayerHeadHuntMain.java | 5 +- .../PlayerHeadHunt/PluginConfig.java | 4 ++ .../events/HeadHunterFlightControl.java | 48 +++++++++++++++++++ .../events/HeadHunterOnJoin.java | 12 +++-- src/main/resources/config.yml | 3 ++ src/main/resources/plugin.yml | 3 ++ 6 files changed, 70 insertions(+), 5 deletions(-) create mode 100644 src/main/java/org/modularsoft/PlayerHeadHunt/events/HeadHunterFlightControl.java diff --git a/src/main/java/org/modularsoft/PlayerHeadHunt/PlayerHeadHuntMain.java b/src/main/java/org/modularsoft/PlayerHeadHunt/PlayerHeadHuntMain.java index b1c1a9b..80a9ed6 100644 --- a/src/main/java/org/modularsoft/PlayerHeadHunt/PlayerHeadHuntMain.java +++ b/src/main/java/org/modularsoft/PlayerHeadHunt/PlayerHeadHuntMain.java @@ -4,6 +4,7 @@ import org.modularsoft.PlayerHeadHunt.commands.*; import org.modularsoft.PlayerHeadHunt.events.HeadFindEvent; import org.modularsoft.PlayerHeadHunt.events.HeadHatOnHead; +import org.modularsoft.PlayerHeadHunt.events.HeadHunterFlightControl; import org.modularsoft.PlayerHeadHunt.events.HeadHunterOnJoin; import org.bukkit.ChatColor; import org.bukkit.command.ConsoleCommandSender; @@ -51,10 +52,12 @@ public void onEnable() { headWorldController.countHeadsInRegion(); // Plugin Event Register + HeadHunterFlightControl flightControl = new HeadHunterFlightControl(this); PluginManager pluginmanager = getServer().getPluginManager(); pluginmanager.registerEvents(new HeadFindEvent(this, headWorldController, headChatController, headHatController, headScoreboardController, headQuery), this); - pluginmanager.registerEvents(new HeadHunterOnJoin(this, headChatController, headScoreboardController, headQuery), this); + pluginmanager.registerEvents(new HeadHunterOnJoin(this, headChatController, headScoreboardController, headQuery, flightControl), this); pluginmanager.registerEvents(new HeadHatOnHead(), this); + pluginmanager.registerEvents(flightControl, this); // Command Registry Objects.requireNonNull(getCommand("heads")).setExecutor(new heads(this, headChatController)); diff --git a/src/main/java/org/modularsoft/PlayerHeadHunt/PluginConfig.java b/src/main/java/org/modularsoft/PlayerHeadHunt/PluginConfig.java index 0320ce7..44fefe7 100644 --- a/src/main/java/org/modularsoft/PlayerHeadHunt/PluginConfig.java +++ b/src/main/java/org/modularsoft/PlayerHeadHunt/PluginConfig.java @@ -16,6 +16,7 @@ public class PluginConfig { @Getter private final boolean milestoneHatFeatureEnabled; @Getter private final boolean milestoneMessageFeatureEnabled; + @Getter private final boolean flightDisabledFeatureEnabled; @Getter private final String headBlock; @Getter private final int headRespawnTimer; @@ -48,6 +49,7 @@ private record MilestoneTemplate(double percentage, boolean isMajor, Material he @Getter private final String langHeadCount; @Getter private final String langHeadCollectionMilestoneReached; @Getter private final String langAllHeadsCollected; + @Getter private final String langFlightDisabled; @Getter private final String langLeaderboardNoHeads; @Getter private final String langLeaderboardHeader; @@ -62,6 +64,7 @@ public PluginConfig(PlayerHeadHuntMain plugin) { milestoneHatFeatureEnabled = config.getBoolean("FEATURE.MILESTONEHAT"); milestoneMessageFeatureEnabled = config.getBoolean("FEATURE.MILESTONEMESSAGE"); + flightDisabledFeatureEnabled = config.getBoolean("FEATURE.DISABLEFLIGHT"); headBlock = config.getString("HEAD.HEADBLOCK"); headRespawnTimer = config.getInt("HEAD.RESPAWNTIMER"); @@ -101,6 +104,7 @@ public PluginConfig(PlayerHeadHuntMain plugin) { langHeadCount = ChatColor.translateAlternateColorCodes('&', Objects.requireNonNull(config.getString("LANG.HEAD.HEADCOUNT"))); langHeadCollectionMilestoneReached = ChatColor.translateAlternateColorCodes('&', Objects.requireNonNull(config.getString("LANG.HEAD.HEADCOLLECTIONMILESTONEREACHED"))); langAllHeadsCollected = ChatColor.translateAlternateColorCodes('&', Objects.requireNonNull(config.getString("LANG.HEAD.ALLHEADSCOLLECTED"))); + langFlightDisabled = ChatColor.translateAlternateColorCodes('&', Objects.requireNonNull(config.getString("LANG.FLIGHT.DISABLED"))); langLeaderboardNoHeads = ChatColor.translateAlternateColorCodes('&', Objects.requireNonNull(config.getString("LANG.LEADERBOARD.NOHEADS"))); langLeaderboardHeader = ChatColor.translateAlternateColorCodes('&', Objects.requireNonNull(config.getString("LANG.LEADERBOARD.HEADER"))); diff --git a/src/main/java/org/modularsoft/PlayerHeadHunt/events/HeadHunterFlightControl.java b/src/main/java/org/modularsoft/PlayerHeadHunt/events/HeadHunterFlightControl.java new file mode 100644 index 0000000..7e5d7a1 --- /dev/null +++ b/src/main/java/org/modularsoft/PlayerHeadHunt/events/HeadHunterFlightControl.java @@ -0,0 +1,48 @@ +package org.modularsoft.PlayerHeadHunt.events; + +import org.bukkit.GameMode; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerToggleFlightEvent; +import org.modularsoft.PlayerHeadHunt.PlayerHeadHuntMain; + +public class HeadHunterFlightControl implements Listener { + private final PlayerHeadHuntMain plugin; + + public HeadHunterFlightControl(PlayerHeadHuntMain plugin) { + this.plugin = plugin; + } + + @EventHandler(priority = EventPriority.HIGHEST) + public void onFlightToggle(PlayerToggleFlightEvent event) { + if (!plugin.config().isFlightDisabledFeatureEnabled()) return; + if (!event.isFlying()) return; // allow toggling flight off + + Player player = event.getPlayer(); + if (isExempt(player)) return; + + event.setCancelled(true); + player.setAllowFlight(false); + player.sendMessage(plugin.config().getLangFlightDisabled()); + } + + /** + * Strips flight from a player, overriding any previous grant. + * Safe to call at any time (join, teleport, etc.). + */ + public void enforceFlight(Player player) { + if (!plugin.config().isFlightDisabledFeatureEnabled()) return; + if (isExempt(player)) return; + player.setFlying(false); + player.setAllowFlight(false); + } + + private boolean isExempt(Player player) { + return player.isOp() + || player.hasPermission("playerheadhunt.flight.exempt") + || player.getGameMode() == GameMode.CREATIVE + || player.getGameMode() == GameMode.SPECTATOR; + } +} diff --git a/src/main/java/org/modularsoft/PlayerHeadHunt/events/HeadHunterOnJoin.java b/src/main/java/org/modularsoft/PlayerHeadHunt/events/HeadHunterOnJoin.java index 074ecf7..b8f5898 100644 --- a/src/main/java/org/modularsoft/PlayerHeadHunt/events/HeadHunterOnJoin.java +++ b/src/main/java/org/modularsoft/PlayerHeadHunt/events/HeadHunterOnJoin.java @@ -8,21 +8,25 @@ import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; import org.bukkit.event.player.PlayerJoinEvent; +import org.modularsoft.PlayerHeadHunt.events.HeadHunterFlightControl; public class HeadHunterOnJoin implements Listener { private final PlayerHeadHuntMain plugin; private final HeadChatController headChatController; private final HeadScoreboardController headScoreboardController; - private final HeadQuery headQuery; // Add HeadQuery instance + private final HeadQuery headQuery; + private final HeadHunterFlightControl flightControl; public HeadHunterOnJoin(PlayerHeadHuntMain plugin, HeadChatController headChatController, HeadScoreboardController headScoreboardController, - HeadQuery headQuery) { + HeadQuery headQuery, + HeadHunterFlightControl flightControl) { this.plugin = plugin; this.headChatController = headChatController; this.headScoreboardController = headScoreboardController; - this.headQuery = headQuery; // Initialize HeadQuery + this.headQuery = headQuery; + this.flightControl = flightControl; } @EventHandler @@ -30,8 +34,8 @@ public void onHeadHunterJoin(PlayerJoinEvent event) { Player player = event.getPlayer(); String username = player.getName(); - // Use the instance of HeadQuery to call the method headScoreboardController.reloadScoreboard(player, headQuery.foundHeadsCount(player)); + flightControl.enforceFlight(player); if (headQuery.addNewHunter(player)) { // New player joined diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 41f29a1..41e1731 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -11,6 +11,7 @@ FEATURE: MILESTONEHAT: TRUE MILESTONEMESSAGE: TRUE LEADERBOARDDAILYWEBHOOK: TRUE + DISABLEFLIGHT: TRUE DISCORD: WEBHOOKURL: "https://discord.com/api/webhooks/000000000000000000/000000000000000000" HOUR: 7 @@ -83,6 +84,8 @@ LANG: LASTHEADFOUND: "&e&l%PLAYER% &5has found all &e&l%NUMBEROFHEADS% &r&5eggs!" HEADCOUNT: "&eYou have found &l&6%FOUNDHEADS%/%NUMBEROFHEADS% &r&eheads." ALLHEADSCOLLECTED: "&eYou have already collected all available eggs!" + FLIGHT: + DISABLED: "&cFlight is disabled during the Head Hunt." # Use %PLAYER% to display players name, use %NUMBEROFHEADS% to display number of eggs. HEADCOLLECTIONMILESTONEREACHED: "&e&l%PLAYER% &5has collected &e&l%NUMBEROFHEADS% &r&5eggs!" LEADERBOARD: diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 119ffed..0998832 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -24,3 +24,6 @@ permissions: playerheadhunt.leaderboard.exempt: description: Allows the player to be exempt from the leaderboard. default: false + playerheadhunt.flight.exempt: + description: Allows the player to fly even when FEATURE.DISABLEFLIGHT is enabled. + default: false From face66d4b689b222b162c2ede030ca5797b9550a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 07:52:09 +0000 Subject: [PATCH 7/8] Fix NPE on startup when existing config.yml is missing new keys saveDefaultConfig() only writes the file when it doesn't exist, so servers upgrading from an older version never received new config keys (e.g. LANG.FLIGHT.DISABLED). Objects.requireNonNull() then threw NPE before console was even assigned, causing a second NPE in onDisable. Fix: call getConfig().options().copyDefaults(true) + saveConfig() after saveDefaultConfig() so any missing keys are merged from the bundled default before PluginConfig reads them. Also guard onDisable against a null console so startup failures don't produce a second confusing stack trace. https://claude.ai/code/session_01JyfubfoVpre8dsRWa3K6Vx --- .../modularsoft/PlayerHeadHunt/PlayerHeadHuntMain.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/modularsoft/PlayerHeadHunt/PlayerHeadHuntMain.java b/src/main/java/org/modularsoft/PlayerHeadHunt/PlayerHeadHuntMain.java index 80a9ed6..e0be49e 100644 --- a/src/main/java/org/modularsoft/PlayerHeadHunt/PlayerHeadHuntMain.java +++ b/src/main/java/org/modularsoft/PlayerHeadHunt/PlayerHeadHuntMain.java @@ -28,8 +28,11 @@ public PluginConfig config() { @Override public void onEnable() { - // Generate configuration file + // Generate configuration file, then merge any new keys from the + // bundled default into an existing config on disk before reading. saveDefaultConfig(); + getConfig().options().copyDefaults(true); + saveConfig(); config = new PluginConfig(this); console = getServer().getConsoleSender(); @@ -78,7 +81,7 @@ public void onEnable() { @Override public void onDisable() { - // Plugin Shutdown Message - console.sendMessage(ChatColor.RED + getDescription().getName() + " is now disabled."); + if (console != null) + console.sendMessage(ChatColor.RED + getDescription().getName() + " is now disabled."); } } From e14917357ff3dc9d343def9f093a437509be17dc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 08:10:35 +0000 Subject: [PATCH 8/8] Fix startup crash caused by saveConfig() corrupting config.yml Root cause: calling saveConfig() with copyDefaults(true) on startup tried to merge new structured MILESTONES defaults (percentage maps) into the server's existing config (plain integer lists), producing invalid YAML. On the next restart Bukkit could not parse the corrupt file, causing getString() to return null for keys that had always existed (e.g. LANG.HEAD.HEADCOLLECTIONMILESTONEREACHED), which then hit requireNonNull. Fixes: - Remove saveConfig() from onEnable entirely. copyDefaults(true) alone is sufficient: Bukkit's getConfig() already sets the bundled config.yml as the defaults source, so any key absent from the server file is served from the in-memory defaults without touching disk. - Replace every Objects.requireNonNull(config.getString(...)) in PluginConfig with a lang() helper that calls config.getString() directly (which falls back to bundled defaults automatically) and logs a warning + returns "" if somehow still null, rather than crashing the server. Note: if your config.yml was already corrupted by the previous build, delete plugins/PlayerHeadHunt/config.yml and restart to regenerate it. https://claude.ai/code/session_01JyfubfoVpre8dsRWa3K6Vx --- .../PlayerHeadHunt/PlayerHeadHuntMain.java | 7 ++- .../PlayerHeadHunt/PluginConfig.java | 58 +++++++++++-------- 2 files changed, 38 insertions(+), 27 deletions(-) diff --git a/src/main/java/org/modularsoft/PlayerHeadHunt/PlayerHeadHuntMain.java b/src/main/java/org/modularsoft/PlayerHeadHunt/PlayerHeadHuntMain.java index e0be49e..4b95600 100644 --- a/src/main/java/org/modularsoft/PlayerHeadHunt/PlayerHeadHuntMain.java +++ b/src/main/java/org/modularsoft/PlayerHeadHunt/PlayerHeadHuntMain.java @@ -28,11 +28,12 @@ public PluginConfig config() { @Override public void onEnable() { - // Generate configuration file, then merge any new keys from the - // bundled default into an existing config on disk before reading. + // Write default config.yml if it doesn't exist on disk, then enable + // in-memory fallback to bundled defaults for any key not present in + // the server's config.yml. Do NOT call saveConfig() here — merging + // new structured defaults into an old config corrupts the YAML file. saveDefaultConfig(); getConfig().options().copyDefaults(true); - saveConfig(); config = new PluginConfig(this); console = getServer().getConsoleSender(); diff --git a/src/main/java/org/modularsoft/PlayerHeadHunt/PluginConfig.java b/src/main/java/org/modularsoft/PlayerHeadHunt/PluginConfig.java index 44fefe7..6509639 100644 --- a/src/main/java/org/modularsoft/PlayerHeadHunt/PluginConfig.java +++ b/src/main/java/org/modularsoft/PlayerHeadHunt/PluginConfig.java @@ -88,30 +88,40 @@ public PluginConfig(PlayerHeadHuntMain plugin) { headMilestones = new HashMap<>(); recomputeMilestones(getTotalHeads()); - langDatabaseConnectionError = ChatColor.translateAlternateColorCodes('&', Objects.requireNonNull(config.getString("LANG.DATABASE.CONNECTIONERROR"))); - langDatabaseConnectionSuccess = ChatColor.translateAlternateColorCodes('&', Objects.requireNonNull(config.getString("LANG.DATABASE.CONNECTIONSUCCESS"))); - langNotAPlayer = ChatColor.translateAlternateColorCodes('&', Objects.requireNonNull(config.getString("LANG.COMMAND.NOTAPLAYER"))); - langInsufficientPermissions = ChatColor.translateAlternateColorCodes('&', Objects.requireNonNull(config.getString("LANG.COMMAND.INSUFFICENTPERMISSIONS"))); - langCommandIncomplete = ChatColor.translateAlternateColorCodes('&', Objects.requireNonNull(config.getString("LANG.COMMAND.COMMANDINCOMPLETE"))); - langHeadFound = ChatColor.translateAlternateColorCodes('&', Objects.requireNonNull(config.getString("LANG.HEAD.HEADFOUND"))); - langHeadAlreadyFound = ChatColor.translateAlternateColorCodes('&', Objects.requireNonNull(config.getString("LANG.HEAD.HEADALREADYFOUND"))); - langHeadFirstFinder = ChatColor.translateAlternateColorCodes('&', Objects.requireNonNull(config.getString("LANG.HEAD.FIRSTFINDER"))); - langHeadFirstFinderStill = ChatColor.translateAlternateColorCodes('&', Objects.requireNonNull(config.getString("LANG.HEAD.FIRSTFINDERSTILL"))); - langHeadNotFirstFinderSingle = ChatColor.translateAlternateColorCodes('&', Objects.requireNonNull(config.getString("LANG.HEAD.NOTFIRSTFINDERSINGLE"))); - langHeadNotFirstFinderMultiple = ChatColor.translateAlternateColorCodes('&', Objects.requireNonNull(config.getString("LANG.HEAD.NOTFIRSTFINDERMULTIPLE"))); - langFirstHeadFound = ChatColor.translateAlternateColorCodes('&', Objects.requireNonNull(config.getString("LANG.HEAD.FIRSTHEADFOUND"))); - langLastHeadFound = ChatColor.translateAlternateColorCodes('&', Objects.requireNonNull(config.getString("LANG.HEAD.LASTHEADFOUND"))); - langHeadCount = ChatColor.translateAlternateColorCodes('&', Objects.requireNonNull(config.getString("LANG.HEAD.HEADCOUNT"))); - langHeadCollectionMilestoneReached = ChatColor.translateAlternateColorCodes('&', Objects.requireNonNull(config.getString("LANG.HEAD.HEADCOLLECTIONMILESTONEREACHED"))); - langAllHeadsCollected = ChatColor.translateAlternateColorCodes('&', Objects.requireNonNull(config.getString("LANG.HEAD.ALLHEADSCOLLECTED"))); - langFlightDisabled = ChatColor.translateAlternateColorCodes('&', Objects.requireNonNull(config.getString("LANG.FLIGHT.DISABLED"))); - - langLeaderboardNoHeads = ChatColor.translateAlternateColorCodes('&', Objects.requireNonNull(config.getString("LANG.LEADERBOARD.NOHEADS"))); - langLeaderboardHeader = ChatColor.translateAlternateColorCodes('&', Objects.requireNonNull(config.getString("LANG.LEADERBOARD.HEADER"))); - langLeaderboardFirstColour = ChatColor.translateAlternateColorCodes('&', Objects.requireNonNull(config.getString("LANG.LEADERBOARD.FIRSTCOLOUR"))); - langLeaderboardSecondColour = ChatColor.translateAlternateColorCodes('&', Objects.requireNonNull(config.getString("LANG.LEADERBOARD.SECONDCOLOUR"))); - langLeaderboardThirdColour = ChatColor.translateAlternateColorCodes('&', Objects.requireNonNull(config.getString("LANG.LEADERBOARD.THIRDCOLOUR"))); - langLeaderboardFormat = ChatColor.translateAlternateColorCodes('&', Objects.requireNonNull(config.getString("LANG.LEADERBOARD.FORMAT"))); + langDatabaseConnectionError = lang("LANG.DATABASE.CONNECTIONERROR"); + langDatabaseConnectionSuccess = lang("LANG.DATABASE.CONNECTIONSUCCESS"); + langNotAPlayer = lang("LANG.COMMAND.NOTAPLAYER"); + langInsufficientPermissions = lang("LANG.COMMAND.INSUFFICENTPERMISSIONS"); + langCommandIncomplete = lang("LANG.COMMAND.COMMANDINCOMPLETE"); + langHeadFound = lang("LANG.HEAD.HEADFOUND"); + langHeadAlreadyFound = lang("LANG.HEAD.HEADALREADYFOUND"); + langHeadFirstFinder = lang("LANG.HEAD.FIRSTFINDER"); + langHeadFirstFinderStill = lang("LANG.HEAD.FIRSTFINDERSTILL"); + langHeadNotFirstFinderSingle = lang("LANG.HEAD.NOTFIRSTFINDERSINGLE"); + langHeadNotFirstFinderMultiple = lang("LANG.HEAD.NOTFIRSTFINDERMULTIPLE"); + langFirstHeadFound = lang("LANG.HEAD.FIRSTHEADFOUND"); + langLastHeadFound = lang("LANG.HEAD.LASTHEADFOUND"); + langHeadCount = lang("LANG.HEAD.HEADCOUNT"); + langHeadCollectionMilestoneReached = lang("LANG.HEAD.HEADCOLLECTIONMILESTONEREACHED"); + langAllHeadsCollected = lang("LANG.HEAD.ALLHEADSCOLLECTED"); + langFlightDisabled = lang("LANG.FLIGHT.DISABLED"); + + langLeaderboardNoHeads = lang("LANG.LEADERBOARD.NOHEADS"); + langLeaderboardHeader = lang("LANG.LEADERBOARD.HEADER"); + langLeaderboardFirstColour = lang("LANG.LEADERBOARD.FIRSTCOLOUR"); + langLeaderboardSecondColour = lang("LANG.LEADERBOARD.SECONDCOLOUR"); + langLeaderboardThirdColour = lang("LANG.LEADERBOARD.THIRDCOLOUR"); + langLeaderboardFormat = lang("LANG.LEADERBOARD.FORMAT"); + } + + /** Reads a lang string, falling back to the bundled default if the key is absent or the file is corrupt. */ + private String lang(String path) { + String value = config.getString(path); // falls through to bundled defaults automatically + if (value == null) { + plugin.getLogger().warning("Missing config key: " + path + " — using empty string. Delete config.yml to regenerate."); + return ""; + } + return ChatColor.translateAlternateColorCodes('&', value); } private void parseMilestoneTemplate(Object entry, boolean isMajor) {